mirror of
https://github.com/home-assistant/core.git
synced 2026-06-18 09:52:57 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4a7f723c8 |
@@ -496,6 +496,7 @@ homeassistant.components.rss_feed_template.*
|
||||
homeassistant.components.russound_rio.*
|
||||
homeassistant.components.ruuvi_gateway.*
|
||||
homeassistant.components.ruuvitag_ble.*
|
||||
homeassistant.components.samsung_exlink.*
|
||||
homeassistant.components.samsung_infrared.*
|
||||
homeassistant.components.samsungtv.*
|
||||
homeassistant.components.saunum.*
|
||||
|
||||
Generated
+2
@@ -1555,6 +1555,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/sabnzbd/ @shaiu @jpbede
|
||||
/tests/components/sabnzbd/ @shaiu @jpbede
|
||||
/homeassistant/components/saj/ @fredericvl
|
||||
/homeassistant/components/samsung_exlink/ @balloob
|
||||
/tests/components/samsung_exlink/ @balloob
|
||||
/homeassistant/components/samsung_infrared/ @lmaertin
|
||||
/tests/components/samsung_infrared/ @lmaertin
|
||||
/homeassistant/components/samsungtv/ @chemelli74
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"domain": "samsung",
|
||||
"name": "Samsung",
|
||||
"integrations": ["familyhub", "samsung_infrared", "samsungtv", "syncthru"]
|
||||
"integrations": [
|
||||
"familyhub",
|
||||
"samsung_exlink",
|
||||
"samsung_infrared",
|
||||
"samsungtv",
|
||||
"syncthru"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,13 +34,11 @@ def generate_site_selector_name(site: Site) -> str:
|
||||
|
||||
|
||||
def filter_sites(sites: list[Site]) -> list[Site]:
|
||||
"""Filter out closed sites and deduplicate the list of sites."""
|
||||
"""Deduplicates the list of sites."""
|
||||
filtered: list[Site] = []
|
||||
filtered_nmi: set[str] = set()
|
||||
|
||||
for site in sorted(sites, key=lambda site: site.status):
|
||||
if site.status == SiteStatus.CLOSED:
|
||||
continue
|
||||
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
|
||||
filtered.append(site)
|
||||
filtered_nmi.add(site.nmi)
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyaqvify"],
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyaqvify==0.0.11"]
|
||||
}
|
||||
|
||||
@@ -53,42 +53,28 @@ rules:
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
Discovery not possible, as device is connected via 4G only. No LAN connection.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
Discovery not possible, as device is connected via 4G only. No LAN connection.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations:
|
||||
status: done
|
||||
comment: |
|
||||
No known limitations
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category:
|
||||
status: done
|
||||
comment: |
|
||||
None of current sensors should be set as diagnostic
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
No repair issues are created.
|
||||
stale-devices: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
|
||||
@@ -976,51 +976,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
return None
|
||||
return await self.async_trigger(run_variables, context, skip_condition)
|
||||
|
||||
@callback
|
||||
def _handle_not_triggered(
|
||||
self,
|
||||
run_variables: dict[str, Any],
|
||||
info: trigger_helper.NotTriggeredInfo,
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Record a trace for a trigger that evaluated a change but did not fire.
|
||||
|
||||
This is the diagnostic sibling of async_trigger: a trigger calls it - in
|
||||
certain interesting cases - when it does not run the action, so the user
|
||||
can see in the trace why the automation was not triggered.
|
||||
"""
|
||||
if not self._is_enabled:
|
||||
return
|
||||
|
||||
# Create a new context referring to the old context.
|
||||
parent_id = None if context is None else context.id
|
||||
trigger_context = Context(parent_id=parent_id)
|
||||
|
||||
with trace_automation(
|
||||
self.hass,
|
||||
self.unique_id,
|
||||
self.raw_config,
|
||||
self._blueprint_inputs,
|
||||
trigger_context,
|
||||
self._trace_config,
|
||||
not_triggered=True,
|
||||
) as automation_trace:
|
||||
automation_trace.set_trace(trace_get())
|
||||
|
||||
trigger_description = run_variables.get("trigger", {}).get("description")
|
||||
automation_trace.set_trigger_description(trigger_description)
|
||||
|
||||
# Record the trigger and its diagnostics as the trigger step.
|
||||
if "idx" in run_variables.get("trigger", {}):
|
||||
trigger_path = f"trigger/{run_variables['trigger']['idx']}"
|
||||
else:
|
||||
trigger_path = "trigger"
|
||||
trace_element = TraceElement(run_variables, trigger_path)
|
||||
trace_element.set_result(**info.as_dict())
|
||||
trace_append_element(trace_element)
|
||||
|
||||
script_execution_set("not_triggered")
|
||||
|
||||
async def _async_attach_triggers(
|
||||
self, home_assistant_start: bool
|
||||
) -> Callable[[], None] | None:
|
||||
@@ -1049,7 +1004,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
self._log_callback,
|
||||
home_assistant_start,
|
||||
variables,
|
||||
did_not_trigger=self._handle_not_triggered,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -26,13 +26,10 @@ class AutomationTrace(ActionTrace):
|
||||
config: ConfigType | None,
|
||||
blueprint_inputs: ConfigType | None,
|
||||
context: Context,
|
||||
*,
|
||||
not_triggered: bool = False,
|
||||
) -> None:
|
||||
"""Container for automation trace."""
|
||||
super().__init__(item_id, config, blueprint_inputs, context)
|
||||
self._trigger_description: str | None = None
|
||||
self.not_triggered = not_triggered
|
||||
|
||||
def set_trigger_description(self, trigger: str) -> None:
|
||||
"""Set trigger description."""
|
||||
@@ -56,13 +53,9 @@ def trace_automation(
|
||||
blueprint_inputs: ConfigType | None,
|
||||
context: Context,
|
||||
trace_config: ConfigType,
|
||||
*,
|
||||
not_triggered: bool = False,
|
||||
) -> Generator[AutomationTrace]:
|
||||
"""Trace action execution of automation with automation_id."""
|
||||
trace = AutomationTrace(
|
||||
automation_id, config, blueprint_inputs, context, not_triggered=not_triggered
|
||||
)
|
||||
trace = AutomationTrace(automation_id, config, blueprint_inputs, context)
|
||||
async_store_trace(hass, trace, trace_config[CONF_STORED_TRACES])
|
||||
|
||||
try:
|
||||
|
||||
@@ -27,12 +27,7 @@ from homeassistant.helpers.event import (
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerConfig,
|
||||
TriggerNotTriggeredReporter,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -398,9 +393,7 @@ class SingleEntityEventTrigger(Trigger):
|
||||
self._options = config.options
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
|
||||
@@ -451,9 +444,7 @@ class EventTrigger(Trigger):
|
||||
self._options = config.options
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
|
||||
|
||||
@@ -434,56 +434,49 @@ async def google_generative_ai_config_option_schema(
|
||||
description={"suggested_value": options.get(CONF_TEMPERATURE)},
|
||||
default=RECOMMENDED_TEMPERATURE,
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_TOP_P,
|
||||
description={"suggested_value": options.get(CONF_TOP_P)},
|
||||
default=RECOMMENDED_TOP_P,
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_TOP_K,
|
||||
description={"suggested_value": options.get(CONF_TOP_K)},
|
||||
default=RECOMMENDED_TOP_K,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_MAX_TOKENS,
|
||||
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
|
||||
default=RECOMMENDED_MAX_TOKENS,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_HATE_BLOCK_THRESHOLD,
|
||||
description={"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
}
|
||||
)
|
||||
if subentry_type != "tts":
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_TOP_P,
|
||||
description={"suggested_value": options.get(CONF_TOP_P)},
|
||||
default=RECOMMENDED_TOP_P,
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_TOP_K,
|
||||
description={"suggested_value": options.get(CONF_TOP_K)},
|
||||
default=RECOMMENDED_TOP_K,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_MAX_TOKENS,
|
||||
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
|
||||
default=RECOMMENDED_MAX_TOKENS,
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_HATE_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
}
|
||||
)
|
||||
if subentry_type == "conversation":
|
||||
schema.update(
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@ CONF_RECOMMENDED = "recommended"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
|
||||
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
|
||||
RECOMMENDED_TTS_MODEL = "models/gemini-3.1-flash-tts-preview"
|
||||
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
|
||||
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
|
||||
@@ -18,13 +18,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_TEMPERATURE,
|
||||
LOGGER,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TTS_MODEL,
|
||||
)
|
||||
from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL
|
||||
from .entity import GoogleGenerativeAILLMBaseEntity
|
||||
from .helpers import convert_to_wav
|
||||
|
||||
@@ -197,10 +191,7 @@ class GoogleGenerativeAITextToSpeechEntity(
|
||||
self, message: str, language: str, options: dict[str, Any]
|
||||
) -> TtsAudioType:
|
||||
"""Load tts audio file from the engine."""
|
||||
config = types.GenerateContentConfig()
|
||||
config.temperature = self.subentry.data.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
config = self.create_generate_content_config()
|
||||
config.response_modalities = ["AUDIO"]
|
||||
config.speech_config = types.SpeechConfig(
|
||||
voice_config=types.VoiceConfig(
|
||||
|
||||
@@ -1124,7 +1124,7 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
|
||||
if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get(
|
||||
CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN
|
||||
):
|
||||
errors["other_settings"] = "max_below_min_kelvin"
|
||||
errors["advanced_settings"] = "max_below_min_kelvin"
|
||||
return errors
|
||||
|
||||
|
||||
@@ -1217,7 +1217,7 @@ def validate_text_platform_config(
|
||||
and CONF_MAX in config
|
||||
and config[CONF_MIN] > config[CONF_MAX]
|
||||
):
|
||||
errors["text_other_settings"] = "max_below_min"
|
||||
errors["text_advanced_settings"] = "max_below_min"
|
||||
|
||||
return errors
|
||||
|
||||
@@ -1506,7 +1506,7 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR,
|
||||
required=False,
|
||||
validator=cv.positive_int,
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
CONF_OPTIONS: PlatformField(
|
||||
selector=OPTIONS_SELECTOR,
|
||||
@@ -1678,13 +1678,13 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
selector=TIMEOUT_SELECTOR,
|
||||
required=False,
|
||||
validator=cv.positive_int,
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
CONF_OFF_DELAY: PlatformField(
|
||||
selector=TIMEOUT_SELECTOR,
|
||||
required=False,
|
||||
validator=cv.positive_int,
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
},
|
||||
Platform.BUTTON: {
|
||||
@@ -3125,7 +3125,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
default=False,
|
||||
validator=cv.boolean,
|
||||
conditions=({CONF_SCHEMA: "json"},),
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
CONF_FLASH_TIME_SHORT: PlatformField(
|
||||
selector=FLASH_TIME_SELECTOR,
|
||||
@@ -3133,7 +3133,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
validator=cv.positive_int,
|
||||
default=2,
|
||||
conditions=({CONF_SCHEMA: "json"},),
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
CONF_FLASH_TIME_LONG: PlatformField(
|
||||
selector=FLASH_TIME_SELECTOR,
|
||||
@@ -3141,7 +3141,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
validator=cv.positive_int,
|
||||
default=10,
|
||||
conditions=({CONF_SCHEMA: "json"},),
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
CONF_TRANSITION: PlatformField(
|
||||
selector=BOOLEAN_SELECTOR,
|
||||
@@ -3149,21 +3149,21 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
default=False,
|
||||
validator=cv.boolean,
|
||||
conditions=({CONF_SCHEMA: "json"},),
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
CONF_MAX_KELVIN: PlatformField(
|
||||
selector=KELVIN_SELECTOR,
|
||||
required=False,
|
||||
validator=cv.positive_int,
|
||||
default=DEFAULT_MAX_KELVIN,
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
CONF_MIN_KELVIN: PlatformField(
|
||||
selector=KELVIN_SELECTOR,
|
||||
required=False,
|
||||
validator=cv.positive_int,
|
||||
default=DEFAULT_MIN_KELVIN,
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
},
|
||||
Platform.LOCK: {
|
||||
@@ -3372,7 +3372,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
selector=TIMEOUT_SELECTOR,
|
||||
required=False,
|
||||
validator=cv.positive_int,
|
||||
section="other_settings",
|
||||
section="advanced_settings",
|
||||
),
|
||||
},
|
||||
Platform.SIREN: {
|
||||
@@ -3437,7 +3437,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
section="siren_other_settings",
|
||||
section="siren_advanced_settings",
|
||||
),
|
||||
},
|
||||
Platform.SWITCH: {
|
||||
@@ -3516,26 +3516,26 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
selector=TEXT_SIZE_SELECTOR,
|
||||
required=True,
|
||||
default=0,
|
||||
section="text_other_settings",
|
||||
section="text_advanced_settings",
|
||||
),
|
||||
CONF_MAX: PlatformField(
|
||||
selector=TEXT_SIZE_SELECTOR,
|
||||
required=True,
|
||||
default=255,
|
||||
section="text_other_settings",
|
||||
section="text_advanced_settings",
|
||||
),
|
||||
CONF_MODE: PlatformField(
|
||||
selector=TEXT_MODE_SELECTOR,
|
||||
required=True,
|
||||
default=TextSelectorType.TEXT.value,
|
||||
section="text_other_settings",
|
||||
section="text_advanced_settings",
|
||||
),
|
||||
CONF_PATTERN: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.is_regex),
|
||||
error="invalid_regular_expression",
|
||||
section="text_other_settings",
|
||||
section="text_advanced_settings",
|
||||
),
|
||||
},
|
||||
Platform.TIME: {
|
||||
@@ -3798,10 +3798,10 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
MQTT_DEVICE_PLATFORM_FIELDS = {
|
||||
CONF_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
|
||||
CONF_SW_VERSION: PlatformField(
|
||||
selector=TEXT_SELECTOR, required=False, section="other_settings"
|
||||
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
|
||||
),
|
||||
CONF_HW_VERSION: PlatformField(
|
||||
selector=TEXT_SELECTOR, required=False, section="other_settings"
|
||||
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
|
||||
),
|
||||
CONF_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
CONF_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
@@ -4686,8 +4686,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
if user_input is not None:
|
||||
new_device_data: dict[str, Any] = user_input.copy()
|
||||
_, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS)
|
||||
if "other_settings" in new_device_data:
|
||||
new_device_data |= new_device_data.pop("other_settings")
|
||||
if "advanced_settings" in new_device_data:
|
||||
new_device_data |= new_device_data.pop("advanced_settings")
|
||||
if not errors:
|
||||
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
|
||||
@@ -184,6 +184,17 @@
|
||||
},
|
||||
"description": "Enter the MQTT device details:",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"data": {
|
||||
"hw_version": "Hardware version",
|
||||
"sw_version": "Software version"
|
||||
},
|
||||
"data_description": {
|
||||
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
|
||||
"sw_version": "The software version of the device. E.g. '2025.1.0'."
|
||||
},
|
||||
"name": "Advanced device settings"
|
||||
},
|
||||
"mqtt_settings": {
|
||||
"data": {
|
||||
"message_expiry_interval": "Message Expiry Interval",
|
||||
@@ -194,17 +205,6 @@
|
||||
"qos": "The Quality of Service value the device's entities should use."
|
||||
},
|
||||
"name": "MQTT settings"
|
||||
},
|
||||
"other_settings": {
|
||||
"data": {
|
||||
"hw_version": "Hardware version",
|
||||
"sw_version": "Software version"
|
||||
},
|
||||
"data_description": {
|
||||
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
|
||||
"sw_version": "The software version of the device. E.g. '2025.1.0'."
|
||||
},
|
||||
"name": "Other device settings"
|
||||
}
|
||||
},
|
||||
"title": "Configure MQTT device details"
|
||||
@@ -286,14 +286,14 @@
|
||||
},
|
||||
"description": "Please configure specific details for {platform} entity \"{entity}\":",
|
||||
"sections": {
|
||||
"other_settings": {
|
||||
"advanced_settings": {
|
||||
"data": {
|
||||
"suggested_display_precision": "Suggested display precision"
|
||||
},
|
||||
"data_description": {
|
||||
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)"
|
||||
},
|
||||
"name": "Other settings"
|
||||
"name": "Advanced options"
|
||||
}
|
||||
},
|
||||
"title": "Configure MQTT device \"{mqtt_device}\""
|
||||
@@ -438,6 +438,29 @@
|
||||
},
|
||||
"description": "Please configure MQTT specific details for {platform} entity \"{entity}\":",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"data": {
|
||||
"expire_after": "Expire after",
|
||||
"flash": "Flash support",
|
||||
"flash_time_long": "Flash time long",
|
||||
"flash_time_short": "Flash time short",
|
||||
"max_kelvin": "Max Kelvin",
|
||||
"min_kelvin": "Min Kelvin",
|
||||
"off_delay": "OFF delay",
|
||||
"transition": "Transition support"
|
||||
},
|
||||
"data_description": {
|
||||
"expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)",
|
||||
"flash": "Enable the flash feature for this light",
|
||||
"flash_time_long": "The duration, in seconds, of a \"long\" flash.",
|
||||
"flash_time_short": "The duration, in seconds, of a \"short\" flash.",
|
||||
"max_kelvin": "The maximum color temperature in Kelvin.",
|
||||
"min_kelvin": "The minimum color temperature in Kelvin.",
|
||||
"off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensor’s state will be updated back to \"off\".",
|
||||
"transition": "Enable the transition feature for this light"
|
||||
},
|
||||
"name": "Advanced settings"
|
||||
},
|
||||
"alarm_control_panel_payload_settings": {
|
||||
"data": {
|
||||
"payload_arm_away": "Payload \"arm away\"",
|
||||
@@ -893,37 +916,14 @@
|
||||
},
|
||||
"name": "Lock payload settings"
|
||||
},
|
||||
"other_settings": {
|
||||
"data": {
|
||||
"expire_after": "Expire after",
|
||||
"flash": "Flash support",
|
||||
"flash_time_long": "Flash time long",
|
||||
"flash_time_short": "Flash time short",
|
||||
"max_kelvin": "Max Kelvin",
|
||||
"min_kelvin": "Min Kelvin",
|
||||
"off_delay": "OFF delay",
|
||||
"transition": "Transition support"
|
||||
},
|
||||
"data_description": {
|
||||
"expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)",
|
||||
"flash": "Enable the flash feature for this light",
|
||||
"flash_time_long": "The duration, in seconds, of a \"long\" flash.",
|
||||
"flash_time_short": "The duration, in seconds, of a \"short\" flash.",
|
||||
"max_kelvin": "The maximum color temperature in Kelvin.",
|
||||
"min_kelvin": "The minimum color temperature in Kelvin.",
|
||||
"off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensor’s state will be updated back to \"off\".",
|
||||
"transition": "Enable the transition feature for this light"
|
||||
},
|
||||
"name": "Other settings"
|
||||
},
|
||||
"siren_other_settings": {
|
||||
"siren_advanced_settings": {
|
||||
"data": {
|
||||
"command_off_template": "Command \"off\" template"
|
||||
},
|
||||
"data_description": {
|
||||
"command_off_template": "The [template]({command_templating_url}) for \"off\" state changes. By default the \"[Command template]({url}#command_template)\" will be used. [Learn more.]({url}#command_off_template)"
|
||||
},
|
||||
"name": "Other siren settings"
|
||||
"name": "Advanced siren settings"
|
||||
},
|
||||
"target_humidity_settings": {
|
||||
"data": {
|
||||
@@ -985,7 +985,7 @@
|
||||
},
|
||||
"name": "Target temperature settings"
|
||||
},
|
||||
"text_other_settings": {
|
||||
"text_advanced_settings": {
|
||||
"data": {
|
||||
"max": "Maximum length",
|
||||
"min": "Minimum length",
|
||||
@@ -998,7 +998,7 @@
|
||||
"mode": "Mode of the text input",
|
||||
"pattern": "A valid regex pattern"
|
||||
},
|
||||
"name": "Other text entity settings"
|
||||
"name": "Advanced text entity settings"
|
||||
},
|
||||
"valve_payload_settings": {
|
||||
"data": {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""The Samsung ExLink integration."""
|
||||
|
||||
from samsung_exlink import MODELS, SamsungTV, TVState
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MODEL, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN, LOGGER, SamsungExLinkConfigEntry
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: SamsungExLinkConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Samsung ExLink from a config entry."""
|
||||
port = entry.data[CONF_DEVICE]
|
||||
tv = SamsungTV(port, model=MODELS.get(entry.data.get(CONF_MODEL, "")))
|
||||
|
||||
try:
|
||||
await tv.connect()
|
||||
# refresh() tolerates a powered-off TV; it only raises on a broken link.
|
||||
await tv.refresh()
|
||||
except (ConnectionError, OSError, TimeoutError) as err:
|
||||
if tv.connected:
|
||||
await tv.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = tv
|
||||
|
||||
@callback
|
||||
def _on_disconnect(state: TVState | None) -> None:
|
||||
# Only reload if the entry is still loaded. During entry removal,
|
||||
# disconnect() fires this callback but the entry is already gone.
|
||||
if state is None and entry.state is ConfigEntryState.LOADED:
|
||||
LOGGER.warning("Samsung TV disconnected, reloading config entry")
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
entry.async_on_unload(tv.subscribe(_on_disconnect))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: SamsungExLinkConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
await entry.runtime_data.disconnect()
|
||||
|
||||
return unload_ok
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Config flow for the Samsung ExLink integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from samsung_exlink import MODELS, SamsungTV, SamsungTVError, TVModel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MODEL
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
SerialPortSelector,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE): SerialPortSelector(),
|
||||
vol.Optional(CONF_MODEL): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(value=key, label=model.name)
|
||||
for key, model in MODELS.items()
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Outcome of _async_attempt_connect that means the serial port works but no
|
||||
# Samsung TV answered it; this routes the user to the troubleshooting step.
|
||||
RESULT_NO_TV = "no_tv"
|
||||
|
||||
|
||||
async def _async_attempt_connect(port: str, model: TVModel | None) -> str | None:
|
||||
"""Attempt to connect to the TV at the given port.
|
||||
|
||||
Returns None on success, otherwise an outcome key: "cannot_connect" when
|
||||
the serial port could not be opened, RESULT_NO_TV when the port works but
|
||||
no Samsung TV responded to it, or "unknown" for an unexpected error.
|
||||
"""
|
||||
tv = SamsungTV(port, model=model)
|
||||
|
||||
try:
|
||||
await tv.connect()
|
||||
except ValueError, ConnectionError, OSError, TimeoutError:
|
||||
# The serial port itself could not be opened.
|
||||
return "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
await tv.query_power()
|
||||
except TimeoutError, SamsungTVError:
|
||||
# The port was opened but no Samsung TV responded to the power query.
|
||||
return RESULT_NO_TV
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
return "unknown"
|
||||
finally:
|
||||
await tv.disconnect()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class SamsungExLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Samsung ExLink."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_user_input: dict[str, Any] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
port = user_input[CONF_DEVICE]
|
||||
model_key = user_input.get(CONF_MODEL)
|
||||
|
||||
self._async_abort_entries_match({CONF_DEVICE: port})
|
||||
error = await _async_attempt_connect(port, MODELS.get(model_key or ""))
|
||||
if error is None:
|
||||
return self.async_create_entry(title="Samsung TV", data=user_input)
|
||||
if error == RESULT_NO_TV:
|
||||
self._user_input = user_input
|
||||
return await self.async_step_troubleshoot()
|
||||
errors["base"] = error
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
DATA_SCHEMA, user_input or self._user_input or {}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_troubleshoot(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Guide the user to enable ExLink control after a failed connection."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_user()
|
||||
|
||||
return self.async_show_form(step_id="troubleshoot")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Constants for the Samsung ExLink integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from samsung_exlink import SamsungTV
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
DOMAIN = "samsung_exlink"
|
||||
|
||||
type SamsungExLinkConfigEntry = ConfigEntry[SamsungTV]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "samsung_exlink",
|
||||
"name": "Samsung TV via ExLink",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/samsung_exlink",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["samsung_exlink"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["samsung-exlink==1.1.0"]
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Media player platform for the Samsung ExLink integration."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from samsung_exlink import MAX_VOLUME, CommandRejected, InputSource, TVState
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, SamsungExLinkConfigEntry
|
||||
|
||||
# Samsung TVs do not push state over RS-232, so the entity is polled.
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
INPUT_SOURCE_SAMSUNG_TO_HA: dict[InputSource, str] = {
|
||||
InputSource.TV: "tv",
|
||||
InputSource.AV1: "av1",
|
||||
InputSource.AV2: "av2",
|
||||
InputSource.AV3: "av3",
|
||||
InputSource.S_VIDEO1: "s_video1",
|
||||
InputSource.S_VIDEO2: "s_video2",
|
||||
InputSource.S_VIDEO3: "s_video3",
|
||||
InputSource.COMPONENT1: "component1",
|
||||
InputSource.COMPONENT2: "component2",
|
||||
InputSource.COMPONENT3: "component3",
|
||||
InputSource.PC1: "pc1",
|
||||
InputSource.PC2: "pc2",
|
||||
InputSource.PC3: "pc3",
|
||||
InputSource.HDMI1: "hdmi1",
|
||||
InputSource.HDMI2: "hdmi2",
|
||||
InputSource.HDMI3: "hdmi3",
|
||||
InputSource.HDMI4: "hdmi4",
|
||||
InputSource.DVI1: "dvi1",
|
||||
InputSource.DVI2: "dvi2",
|
||||
InputSource.DVI3: "dvi3",
|
||||
InputSource.RVU: "rvu",
|
||||
}
|
||||
INPUT_SOURCE_HA_TO_SAMSUNG: dict[str, InputSource] = {
|
||||
value: key for key, value in INPUT_SOURCE_SAMSUNG_TO_HA.items()
|
||||
}
|
||||
|
||||
_BASE_SUPPORTED_FEATURES = (
|
||||
MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
|
||||
def catch_command_errors[**_P](
|
||||
func: Callable[_P, Coroutine[Any, Any, None]],
|
||||
) -> Callable[_P, Coroutine[Any, Any, None]]:
|
||||
"""Translate Samsung library errors raised by an action into HomeAssistantError."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(*args, **kwargs)
|
||||
except CommandRejected as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_rejected",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
except (ConnectionError, OSError, TimeoutError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: SamsungExLinkConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Samsung ExLink media player."""
|
||||
async_add_entities([SamsungExLinkMediaPlayer(config_entry)])
|
||||
|
||||
|
||||
class SamsungExLinkMediaPlayer(MediaPlayerEntity):
|
||||
"""Representation of a Samsung TV controlled over ExLink (RS-232)."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_translation_key = "tv"
|
||||
_attr_source_list = sorted(INPUT_SOURCE_SAMSUNG_TO_HA.values())
|
||||
|
||||
def __init__(self, config_entry: SamsungExLinkConfigEntry) -> None:
|
||||
"""Initialize the media player."""
|
||||
self._tv = config_entry.runtime_data
|
||||
self._attr_unique_id = config_entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
manufacturer="Samsung",
|
||||
)
|
||||
self._async_update_from_state(self._tv.state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to TV state updates."""
|
||||
self.async_on_remove(self._tv.subscribe(self._async_on_state_update))
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Poll the TV for its current state."""
|
||||
await self._tv.refresh()
|
||||
|
||||
@callback
|
||||
def _async_on_state_update(self, state: TVState | None) -> None:
|
||||
"""Handle a state update from the TV."""
|
||||
if state is None:
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._async_update_from_state(state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_update_from_state(self, state: TVState) -> None:
|
||||
"""Update entity attributes from a TV state snapshot."""
|
||||
if state.power is None:
|
||||
self._attr_state = None
|
||||
else:
|
||||
self._attr_state = (
|
||||
MediaPlayerState.ON if state.power else MediaPlayerState.OFF
|
||||
)
|
||||
|
||||
# A standby TV only accepts power-on over RS-232; source, volume, and
|
||||
# mute commands time out. Those controls (and their attributes) are
|
||||
# therefore only exposed while the TV is on, and volume/mute also
|
||||
# require the value to be known.
|
||||
features = _BASE_SUPPORTED_FEATURES
|
||||
if state.power:
|
||||
features |= MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
source = state.input_source
|
||||
self._attr_source = (
|
||||
INPUT_SOURCE_SAMSUNG_TO_HA.get(source) if source else None
|
||||
)
|
||||
|
||||
if state.volume is None:
|
||||
self._attr_volume_level = None
|
||||
else:
|
||||
features |= (
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
)
|
||||
self._attr_volume_level = state.volume / MAX_VOLUME
|
||||
|
||||
if state.mute is None:
|
||||
self._attr_is_volume_muted = None
|
||||
else:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
self._attr_is_volume_muted = state.mute
|
||||
else:
|
||||
self._attr_source = None
|
||||
self._attr_volume_level = None
|
||||
self._attr_is_volume_muted = None
|
||||
|
||||
self._attr_supported_features = features
|
||||
|
||||
@catch_command_errors
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the TV on."""
|
||||
await self._tv.power_on()
|
||||
|
||||
@catch_command_errors
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the TV off."""
|
||||
await self._tv.power_off()
|
||||
|
||||
@catch_command_errors
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
await self._tv.set_volume(round(volume * MAX_VOLUME))
|
||||
|
||||
@catch_command_errors
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or unmute the TV."""
|
||||
await self._tv.set_mute(mute)
|
||||
|
||||
@catch_command_errors
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select an input source."""
|
||||
await self._tv.select_input_source(INPUT_SOURCE_HA_TO_SAMSUNG[source])
|
||||
@@ -0,0 +1,84 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: The integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: The integration does not register custom actions.
|
||||
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: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: The integration has no options to configure.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: The integration does not require authentication.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Serial devices are configured manually; there is no discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: RS-232 serial connections cannot be discovered.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: The integration does not create dynamic devices.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: The integration only provides a single primary entity.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: The media player entity uses its device class for its icon.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: The integration has no user-actionable issues to repair.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: The integration does not create devices that can become stale.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: The integration does not make HTTP requests.
|
||||
strict-typing: done
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"troubleshoot": {
|
||||
"description": "Home Assistant could not communicate with the Samsung TV over the serial port.\n\nThe most common cause is that ExLink control is not enabled. On TVs that use the USB-to-ExLink dongle, **EXT Link Support** and **USB Serial** must be enabled in the hidden service menu (on the remote, press **Mute → 1 → 8 → 2 → Power**); the exact path varies by model, so check your TV's documentation.\n\nAlso make sure that:\n- The TV is powered on. A TV in standby does not answer status queries.\n- The cable is connected to the TV's 3.5 mm ExLink jack (or the Samsung USB-to-ExLink dongle) and is fully seated.\n- The correct serial port was selected.\n\nSelect **Submit** to try again.",
|
||||
"title": "Connection failed"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::port%]",
|
||||
"model": "TV generation"
|
||||
},
|
||||
"data_description": {
|
||||
"device": "Serial port path to connect to.",
|
||||
"model": "Select your TV's generation to translate the active input back into a named source. Leave empty if your model is not listed; you can still switch sources."
|
||||
},
|
||||
"description": "Make sure the TV is powered on before continuing. Home Assistant queries the TV during setup to confirm the connection, and a TV in standby will not respond."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"media_player": {
|
||||
"tv": {
|
||||
"state_attributes": {
|
||||
"source": {
|
||||
"state": {
|
||||
"av1": "AV 1",
|
||||
"av2": "AV 2",
|
||||
"av3": "AV 3",
|
||||
"component1": "Component 1",
|
||||
"component2": "Component 2",
|
||||
"component3": "Component 3",
|
||||
"dvi1": "DVI 1",
|
||||
"dvi2": "DVI 2",
|
||||
"dvi3": "DVI 3",
|
||||
"hdmi1": "HDMI 1",
|
||||
"hdmi2": "HDMI 2",
|
||||
"hdmi3": "HDMI 3",
|
||||
"hdmi4": "HDMI 4",
|
||||
"pc1": "PC 1",
|
||||
"pc2": "PC 2",
|
||||
"pc3": "PC 3",
|
||||
"rvu": "RVU",
|
||||
"s_video1": "S-Video 1",
|
||||
"s_video2": "S-Video 2",
|
||||
"s_video3": "S-Video 3",
|
||||
"tv": "TV"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Error connecting to the Samsung TV: {error}"
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Failed to send the command to the TV: {error}"
|
||||
},
|
||||
"command_rejected": {
|
||||
"message": "The TV rejected the command: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -50,13 +50,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
# Remove deprecated solar_rising sensor entity (removed in 2026.1)
|
||||
ent_reg = er.async_get(hass)
|
||||
if entity_id := ent_reg.async_get_entity_id(
|
||||
Platform.SENSOR, DOMAIN, f"{entry.entry_id}-solar_rising"
|
||||
):
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
sun = Sun(hass)
|
||||
component = EntityComponent[Sun](_LOGGER, DOMAIN, hass)
|
||||
await component.async_add_entities([sun])
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Support for Template fans."""
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_DIRECTION,
|
||||
ATTR_OSCILLATING,
|
||||
ATTR_PERCENTAGE,
|
||||
ATTR_PRESET_MODE,
|
||||
DIRECTION_FORWARD,
|
||||
DIRECTION_REVERSE,
|
||||
DOMAIN as FAN_DOMAIN,
|
||||
@@ -97,15 +100,6 @@ FAN_CONFIG_ENTRY_SCHEMA = FAN_COMMON_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
class FanScriptVariable(StrEnum):
|
||||
"""Variables for scripts."""
|
||||
|
||||
DIRECTION = "direction"
|
||||
OSCILLATING = "oscillating"
|
||||
PERCENTAGE = "percentage"
|
||||
PRESET_MODE = "preset_mode"
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -241,8 +235,8 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
await self.async_run_script(
|
||||
self._action_scripts[CONF_ON_ACTION],
|
||||
run_variables={
|
||||
FanScriptVariable.PERCENTAGE: percentage,
|
||||
FanScriptVariable.PRESET_MODE: preset_mode,
|
||||
ATTR_PERCENTAGE: percentage,
|
||||
ATTR_PRESET_MODE: preset_mode,
|
||||
},
|
||||
context=self._context,
|
||||
)
|
||||
@@ -273,7 +267,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
if script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION):
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={FanScriptVariable.PERCENTAGE: self._attr_percentage},
|
||||
run_variables={ATTR_PERCENTAGE: self._attr_percentage},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
@@ -290,7 +284,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
if script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION):
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={FanScriptVariable.PRESET_MODE: self._attr_preset_mode},
|
||||
run_variables={ATTR_PRESET_MODE: self._attr_preset_mode},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
@@ -308,7 +302,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
) is not None:
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={FanScriptVariable.OSCILLATING: self.oscillating},
|
||||
run_variables={ATTR_OSCILLATING: self.oscillating},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
@@ -324,7 +318,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
) is not None:
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={FanScriptVariable.DIRECTION: direction},
|
||||
run_variables={ATTR_DIRECTION: direction},
|
||||
context=self._context,
|
||||
)
|
||||
if CONF_DIRECTION not in self._templates:
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.number import (
|
||||
ATTR_VALUE,
|
||||
DEFAULT_MAX_VALUE,
|
||||
DEFAULT_MIN_VALUE,
|
||||
DEFAULT_STEP,
|
||||
@@ -160,7 +161,7 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity):
|
||||
if set_value := self._action_scripts.get(CONF_SET_VALUE):
|
||||
await self.async_run_script(
|
||||
set_value,
|
||||
run_variables={"value": value},
|
||||
run_variables={ATTR_VALUE: value},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.select import (
|
||||
ATTR_OPTION,
|
||||
ATTR_OPTIONS,
|
||||
DOMAIN as SELECT_DOMAIN,
|
||||
ENTITY_ID_FORMAT,
|
||||
SelectEntity,
|
||||
@@ -46,7 +48,7 @@ SCRIPT_FIELDS = (CONF_SELECT_OPTION,)
|
||||
|
||||
SELECT_COMMON_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): cv.template,
|
||||
vol.Required(ATTR_OPTIONS): cv.template,
|
||||
vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
|
||||
vol.Optional(CONF_STATE): cv.template,
|
||||
}
|
||||
@@ -145,7 +147,7 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
|
||||
if select_option := self._action_scripts.get(CONF_SELECT_OPTION):
|
||||
await self.async_run_script(
|
||||
select_option,
|
||||
run_variables={"option": option},
|
||||
run_variables={ATTR_OPTION: option},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
@@ -173,7 +175,7 @@ class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect):
|
||||
"""Select entity based on trigger data."""
|
||||
|
||||
domain = SELECT_DOMAIN
|
||||
extra_template_keys_complex = (CONF_OPTIONS,)
|
||||
extra_template_keys_complex = (ATTR_OPTIONS,)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
ATTR_LAST_RESET,
|
||||
CONF_STATE_CLASS,
|
||||
DEVICE_CLASSES_SCHEMA,
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
@@ -49,14 +50,13 @@ from .schemas import (
|
||||
from .template_entity import TemplateEntity
|
||||
from .trigger_entity import TriggerEntity
|
||||
|
||||
CONF_LAST_RESET = "last_reset"
|
||||
DEFAULT_NAME = "Template Sensor"
|
||||
|
||||
|
||||
def validate_last_reset(val):
|
||||
"""Run extra validation checks."""
|
||||
if (
|
||||
val.get(CONF_LAST_RESET) is not None
|
||||
val.get(ATTR_LAST_RESET) is not None
|
||||
and val.get(CONF_STATE_CLASS) != SensorStateClass.TOTAL
|
||||
):
|
||||
raise vol.Invalid(
|
||||
@@ -78,7 +78,7 @@ SENSOR_COMMON_SCHEMA = vol.Schema(
|
||||
SENSOR_YAML_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_LAST_RESET): cv.template,
|
||||
vol.Optional(ATTR_LAST_RESET): cv.template,
|
||||
}
|
||||
)
|
||||
.extend(SENSOR_COMMON_SCHEMA.schema)
|
||||
@@ -204,10 +204,10 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor):
|
||||
self._validate_state,
|
||||
)
|
||||
self.setup_template(
|
||||
CONF_LAST_RESET,
|
||||
ATTR_LAST_RESET,
|
||||
"_attr_last_reset",
|
||||
validate_datetime(
|
||||
self, CONF_LAST_RESET, SensorDeviceClass.TIMESTAMP, require_tzinfo=False
|
||||
self, ATTR_LAST_RESET, SensorDeviceClass.TIMESTAMP, require_tzinfo=False
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
ATTR_FAN_SPEED,
|
||||
DOMAIN as VACUUM_DOMAIN,
|
||||
SERVICE_CLEAN_SPOT,
|
||||
SERVICE_LOCATE,
|
||||
@@ -388,7 +389,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
|
||||
|
||||
if script := self._action_scripts.get(SERVICE_SET_FAN_SPEED):
|
||||
await self.async_run_script(
|
||||
script, run_variables={"fan_speed": fan_speed}, context=self._context
|
||||
script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerConfig,
|
||||
TriggerNotTriggeredReporter,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -67,9 +66,7 @@ class TimeRemainingTrigger(Trigger):
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
scheduled: dict[str, CALLBACK_TYPE] = {}
|
||||
|
||||
@@ -16,12 +16,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerConfig,
|
||||
TriggerNotTriggeredReporter,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import TodoItem, TodoListEntity
|
||||
@@ -145,9 +140,7 @@ class ItemTriggerBase(Trigger, abc.ABC):
|
||||
self._target = config.target
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
try:
|
||||
await store.async_save(
|
||||
{
|
||||
key: list(trace_bucket.all_traces())
|
||||
for key, trace_bucket in hass.data[DATA_TRACE].items()
|
||||
key: list(traces.values())
|
||||
for key, traces in hass.data[DATA_TRACE].items()
|
||||
}
|
||||
)
|
||||
except HomeAssistantError as exc:
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import abc
|
||||
from collections import deque
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
@@ -18,7 +16,7 @@ from homeassistant.helpers.trace import (
|
||||
from homeassistant.util import dt as dt_util, uuid as uuid_util
|
||||
from homeassistant.util.limited_size_dict import LimitedSizeDict
|
||||
|
||||
type TraceData = dict[str, TraceBuckets]
|
||||
type TraceData = dict[str, LimitedSizeDict[str, BaseTrace]]
|
||||
|
||||
|
||||
class BaseTrace(abc.ABC):
|
||||
@@ -27,9 +25,6 @@ class BaseTrace(abc.ABC):
|
||||
context: Context
|
||||
key: str
|
||||
run_id: str
|
||||
# True for traces recording that a trigger evaluated a relevant change but
|
||||
# did not fire. These are counted separately from actual runs.
|
||||
not_triggered: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return an dictionary version of this ActionTrace for saving."""
|
||||
@@ -47,27 +42,6 @@ class BaseTrace(abc.ABC):
|
||||
"""Return a brief dictionary version of this ActionTrace."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TraceBuckets:
|
||||
"""The run and not-triggered traces for a single script or automation.
|
||||
|
||||
Not-triggered traces (a trigger evaluated a change but did not fire) are
|
||||
counted and size-limited separately so they never evict actual run traces.
|
||||
"""
|
||||
|
||||
runs: LimitedSizeDict[str, BaseTrace]
|
||||
not_triggered: LimitedSizeDict[str, BaseTrace]
|
||||
|
||||
def bucket(self, not_triggered: bool) -> LimitedSizeDict[str, BaseTrace]:
|
||||
"""Return the bucket holding traces of the requested kind."""
|
||||
return self.not_triggered if not_triggered else self.runs
|
||||
|
||||
def all_traces(self) -> Iterator[BaseTrace]:
|
||||
"""Yield all traces, runs first then not-triggered."""
|
||||
yield from self.runs.values()
|
||||
yield from self.not_triggered.values()
|
||||
|
||||
|
||||
class ActionTrace(BaseTrace):
|
||||
"""Base container for a script or automation trace."""
|
||||
|
||||
@@ -149,7 +123,7 @@ class ActionTrace(BaseTrace):
|
||||
last_step = list(self._trace)[-1]
|
||||
domain, item_id = self.key.split(".", 1)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
result = {
|
||||
"last_step": last_step,
|
||||
"run_id": self.run_id,
|
||||
"state": self._state,
|
||||
@@ -161,8 +135,6 @@ class ActionTrace(BaseTrace):
|
||||
"domain": domain,
|
||||
"item_id": item_id,
|
||||
}
|
||||
if self.not_triggered:
|
||||
result["not_triggered"] = True
|
||||
if self._error is not None:
|
||||
result["error"] = str(self._error)
|
||||
|
||||
@@ -187,7 +159,6 @@ class RestoredTrace(BaseTrace):
|
||||
self.context = context
|
||||
self.key = f"{extended_dict['domain']}.{extended_dict['item_id']}"
|
||||
self.run_id = extended_dict["run_id"]
|
||||
self.not_triggered = short_dict.get("not_triggered", False)
|
||||
self._dict = extended_dict
|
||||
self._short_dict = short_dict
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util.limited_size_dict import LimitedSizeDict
|
||||
|
||||
from .const import DATA_TRACE, DATA_TRACE_STORE, DATA_TRACES_RESTORED
|
||||
from .models import ActionTrace, BaseTrace, RestoredTrace, TraceBuckets, TraceData
|
||||
from .models import ActionTrace, BaseTrace, RestoredTrace, TraceData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,9 +21,7 @@ async def async_get_trace(
|
||||
# Restore saved traces if not done
|
||||
await async_restore_traces(hass)
|
||||
|
||||
trace_bucket = hass.data[DATA_TRACE][key]
|
||||
trace = trace_bucket.runs.get(run_id) or trace_bucket.not_triggered[run_id]
|
||||
return trace.as_extended_dict()
|
||||
return hass.data[DATA_TRACE][key][run_id].as_extended_dict()
|
||||
|
||||
|
||||
async def async_list_contexts(
|
||||
@@ -33,7 +31,7 @@ async def async_list_contexts(
|
||||
# Restore saved traces if not done
|
||||
await async_restore_traces(hass)
|
||||
|
||||
values: Mapping[str, TraceBuckets | None] | TraceData
|
||||
values: Mapping[str, LimitedSizeDict[str, BaseTrace] | None] | TraceData
|
||||
if key is not None:
|
||||
values = {key: hass.data[DATA_TRACE].get(key)}
|
||||
else:
|
||||
@@ -46,16 +44,16 @@ async def async_list_contexts(
|
||||
|
||||
return {
|
||||
trace.context.id: _trace_id(trace.run_id, key)
|
||||
for key, trace_bucket in values.items()
|
||||
if trace_bucket is not None
|
||||
for trace in trace_bucket.all_traces()
|
||||
for key, traces in values.items()
|
||||
if traces is not None
|
||||
for trace in traces.values()
|
||||
}
|
||||
|
||||
|
||||
def _get_debug_traces(hass: HomeAssistant, key: str) -> list[dict[str, Any]]:
|
||||
"""Return a serializable list of debug traces for a script or automation."""
|
||||
if trace_bucket := hass.data[DATA_TRACE].get(key):
|
||||
return [trace.as_short_dict() for trace in trace_bucket.all_traces()]
|
||||
if traces_for_key := hass.data[DATA_TRACE].get(key):
|
||||
return [trace.as_short_dict() for trace in traces_for_key.values()]
|
||||
return []
|
||||
|
||||
|
||||
@@ -81,23 +79,14 @@ async def async_list_traces(
|
||||
def async_store_trace(
|
||||
hass: HomeAssistant, trace: ActionTrace, stored_traces: int
|
||||
) -> None:
|
||||
"""Store a trace if its key is valid.
|
||||
|
||||
Run traces and not-triggered traces are kept in separate, independently
|
||||
size-limited buckets so a flood of not-triggered traces never evicts runs.
|
||||
"""
|
||||
"""Store a trace if its key is valid."""
|
||||
if key := trace.key:
|
||||
traces = hass.data[DATA_TRACE]
|
||||
if key not in traces:
|
||||
traces[key] = TraceBuckets(
|
||||
runs=LimitedSizeDict(size_limit=stored_traces),
|
||||
not_triggered=LimitedSizeDict(size_limit=stored_traces),
|
||||
)
|
||||
trace_bucket = traces[key]
|
||||
trace_bucket.runs.size_limit = stored_traces
|
||||
trace_bucket.not_triggered.size_limit = stored_traces
|
||||
bucket = trace_bucket.bucket(trace.not_triggered)
|
||||
bucket[trace.run_id] = trace
|
||||
traces[key] = LimitedSizeDict(size_limit=stored_traces)
|
||||
else:
|
||||
traces[key].size_limit = stored_traces
|
||||
traces[key][trace.run_id] = trace
|
||||
|
||||
|
||||
def _async_store_restored_trace(hass: HomeAssistant, trace: RestoredTrace) -> None:
|
||||
@@ -105,12 +94,9 @@ def _async_store_restored_trace(hass: HomeAssistant, trace: RestoredTrace) -> No
|
||||
key = trace.key
|
||||
traces = hass.data[DATA_TRACE]
|
||||
if key not in traces:
|
||||
traces[key] = TraceBuckets(
|
||||
runs=LimitedSizeDict(), not_triggered=LimitedSizeDict()
|
||||
)
|
||||
bucket = traces[key].bucket(trace.not_triggered)
|
||||
bucket[trace.run_id] = trace
|
||||
bucket.move_to_end(trace.run_id, last=False)
|
||||
traces[key] = LimitedSizeDict()
|
||||
traces[key][trace.run_id] = trace
|
||||
traces[key].move_to_end(trace.run_id, last=False)
|
||||
|
||||
|
||||
async def async_restore_traces(hass: HomeAssistant) -> None:
|
||||
@@ -130,18 +116,17 @@ async def async_restore_traces(hass: HomeAssistant) -> None:
|
||||
for key, traces in restored_traces.items():
|
||||
# Add stored traces in reversed order to prioritize the newest traces
|
||||
for json_trace in reversed(traces):
|
||||
if (
|
||||
(stored_traces := hass.data[DATA_TRACE].get(key))
|
||||
and stored_traces.size_limit is not None
|
||||
and len(stored_traces) >= stored_traces.size_limit
|
||||
):
|
||||
break
|
||||
|
||||
try:
|
||||
trace = RestoredTrace(json_trace)
|
||||
# Catch any exception to not blow up if the stored trace is invalid
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to restore trace")
|
||||
continue
|
||||
|
||||
# Runs and not-triggered traces are capped independently, so check
|
||||
# the bucket this trace belongs to rather than breaking the loop.
|
||||
if (trace_bucket := hass.data[DATA_TRACE].get(key)) is not None:
|
||||
bucket = trace_bucket.bucket(trace.not_triggered)
|
||||
if bucket.size_limit is not None and len(bucket) >= bucket.size_limit:
|
||||
continue
|
||||
|
||||
_async_store_restored_trace(hass, trace)
|
||||
|
||||
@@ -5,7 +5,7 @@ rules:
|
||||
comment: This integration does not register custom service actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: Live state is pushed over MQTT; the coordinator polls REST every 5 min only for the device roster and player config (firmware, day/night times).
|
||||
comment: 5 minute interval. MQTT carries live state; polling is what surfaces the online -> offline transition since the broker doesn't push disconnect events.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
@@ -48,7 +48,7 @@ rules:
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Cloud connection; DHCP discovery only triggers setup, no network address is persisted to update.
|
||||
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
@@ -70,7 +70,7 @@ rules:
|
||||
comment: Authorization is the only configuration; reauth covers re-linking the account.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Auth failures go through the reauth flow; other errors are transient and retried by the coordinator.
|
||||
comment: No repair issues are raised yet.
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
|
||||
@@ -39,7 +39,6 @@ from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerConfig,
|
||||
TriggerNotTriggeredReporter,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -128,9 +127,7 @@ class LegacyZoneTrigger(Trigger):
|
||||
self._options = config.options
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Listen for state changes based on configuration."""
|
||||
entity_id: list[str] = self._options[CONF_ENTITY_ID]
|
||||
|
||||
@@ -20,12 +20,7 @@ from homeassistant.core import CALLBACK_TYPE, 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,
|
||||
TriggerNotTriggeredReporter,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from ..const import (
|
||||
@@ -173,9 +168,7 @@ class EventTrigger(Trigger):
|
||||
self._options = config.options
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
dev_reg = dr.async_get(self._hass)
|
||||
|
||||
@@ -14,12 +14,7 @@ from homeassistant.core import CALLBACK_TYPE, 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,
|
||||
TriggerNotTriggeredReporter,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from ..config_validation import VALUE_SCHEMA
|
||||
@@ -230,9 +225,7 @@ class ValueUpdatedTrigger(Trigger):
|
||||
self._options = config.options
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
return await async_attach_trigger(self._hass, self._options, run_action)
|
||||
|
||||
Generated
+1
@@ -654,6 +654,7 @@ FLOWS = {
|
||||
"ruuvitag_ble",
|
||||
"rympro",
|
||||
"sabnzbd",
|
||||
"samsung_exlink",
|
||||
"samsung_infrared",
|
||||
"samsungtv",
|
||||
"sanix",
|
||||
|
||||
@@ -6170,6 +6170,12 @@
|
||||
"iot_class": "local_polling",
|
||||
"name": "Samsung Family Hub"
|
||||
},
|
||||
"samsung_exlink": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
"name": "Samsung TV via ExLink"
|
||||
},
|
||||
"samsung_infrared": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
|
||||
@@ -490,11 +490,11 @@ class _HistoryPrimingManager:
|
||||
The flush a condition relies on must begin after that condition started
|
||||
tracking its entities, or the read could miss a change still queued in the
|
||||
recorder and compute too generous an anchor. A condition therefore never
|
||||
relies on a flush that was already running when it arrived (the lobby); it
|
||||
waits that one out and joins the next, re-attempting if the flush it waited
|
||||
for was cancelled before completing. This mirrors `ReloadServiceHelper`
|
||||
minus its target de-duplication, which does not apply because each condition
|
||||
reads its own entities.
|
||||
rides a flush that was already running when it arrived (the lobby); it waits
|
||||
that one out and joins the next, and re-attempts if the flush it rode was
|
||||
cancelled before completing. This mirrors `ReloadServiceHelper` minus its
|
||||
target de-duplication, which does not apply because each condition reads its
|
||||
own entities.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
@@ -516,13 +516,11 @@ class _HistoryPrimingManager:
|
||||
async def _async_flush(self) -> None:
|
||||
"""Return once a recorder flush that began no earlier than this call ends.
|
||||
|
||||
The first condition of a generation performs the flush; the rest rely on
|
||||
it.
|
||||
The first condition of a generation performs the flush; the rest ride it.
|
||||
"""
|
||||
async with self._flush_condition:
|
||||
# Lobby: a flush already running began before we arrived, so it may
|
||||
# not capture our entity's queued changes. Wait it out, don't rely on
|
||||
# it.
|
||||
# not capture our entity's queued changes. Wait it out, don't ride it.
|
||||
if self._flushing:
|
||||
await self._flush_condition.wait()
|
||||
|
||||
@@ -532,8 +530,7 @@ class _HistoryPrimingManager:
|
||||
# First past the lobby this generation: we run the flush.
|
||||
self._flushing = True
|
||||
break
|
||||
# A peer began a fresh flush after we cleared the lobby; wait for
|
||||
# it.
|
||||
# A peer began a fresh flush after we cleared the lobby; ride it.
|
||||
await self._flush_condition.wait()
|
||||
if self._flush_ok:
|
||||
return
|
||||
|
||||
@@ -4,7 +4,6 @@ import abc
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
||||
from contextvars import copy_context
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
import functools
|
||||
@@ -303,15 +302,8 @@ class Trigger(abc.ABC):
|
||||
self,
|
||||
action: TriggerAction,
|
||||
action_payload_builder: TriggerActionPayloadBuilder,
|
||||
*,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action.
|
||||
|
||||
The optional ``did_not_trigger`` reporter is the sibling of the action
|
||||
runner: triggers may call it - in certain interesting cases - when they
|
||||
evaluate a relevant change but decide not to fire.
|
||||
"""
|
||||
"""Attach the trigger to an action."""
|
||||
|
||||
@callback
|
||||
def run_action(
|
||||
@@ -324,13 +316,11 @@ class Trigger(abc.ABC):
|
||||
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, did_not_trigger)
|
||||
return await self.async_attach_runner(run_action)
|
||||
|
||||
@abc.abstractmethod
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@@ -537,9 +527,7 @@ class EntityTriggerBase(Trigger):
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
@@ -589,25 +577,11 @@ class EntityTriggerBase(Trigger):
|
||||
if not from_state or not to_state:
|
||||
return
|
||||
|
||||
@callback
|
||||
def report_not_triggered(reason: str, **data: Any) -> None:
|
||||
"""Report why this state change did not fire the trigger."""
|
||||
if did_not_trigger is None:
|
||||
return
|
||||
did_not_trigger(
|
||||
NotTriggeredInfo(reason=reason, data=data), event.context
|
||||
)
|
||||
|
||||
# The trigger should never fire if the new state is excluded
|
||||
# or not a target state.
|
||||
if to_state.state in self._excluded_states or not self.is_valid_state(
|
||||
to_state
|
||||
):
|
||||
report_not_triggered(
|
||||
"new_state_not_a_match",
|
||||
entity_id=entity_id,
|
||||
to_state=to_state.state,
|
||||
)
|
||||
return
|
||||
|
||||
# The trigger should never fire if the origin state is excluded
|
||||
@@ -616,12 +590,6 @@ class EntityTriggerBase(Trigger):
|
||||
from_state.state in self._excluded_from_states
|
||||
or not self.is_valid_transition(from_state, to_state)
|
||||
):
|
||||
report_not_triggered(
|
||||
"transition_not_a_match",
|
||||
entity_id=entity_id,
|
||||
from_state=from_state.state,
|
||||
to_state=to_state.state,
|
||||
)
|
||||
return
|
||||
|
||||
# Count against the targeted entity states as of this event, not
|
||||
@@ -635,9 +603,6 @@ class EntityTriggerBase(Trigger):
|
||||
target_state_change_data.targeted_entity_states,
|
||||
)
|
||||
if matches != included:
|
||||
report_not_triggered(
|
||||
"not_all_targets_matched", matches=matches, included=included
|
||||
)
|
||||
return
|
||||
elif behavior == BEHAVIOR_FIRST:
|
||||
# Note: It's enough to test for exactly 1 match here because if there
|
||||
@@ -648,9 +613,6 @@ class EntityTriggerBase(Trigger):
|
||||
target_state_change_data.targeted_entity_states,
|
||||
)
|
||||
if matches != 1:
|
||||
report_not_triggered(
|
||||
"behavior_first_not_satisfied", matches=matches
|
||||
)
|
||||
return
|
||||
|
||||
@callback
|
||||
@@ -1243,27 +1205,6 @@ class TriggerConfig:
|
||||
options: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class NotTriggeredInfo:
|
||||
"""Diagnostics describing why a trigger evaluated a change but did not fire.
|
||||
|
||||
Passed by a trigger to its ``did_not_trigger`` reporter, the sibling of the
|
||||
action runner that is called - in certain interesting cases - when the
|
||||
trigger does not fire. ``reason`` is a stable, machine-readable code; the
|
||||
optional ``data`` carries the evaluated context for the trace.
|
||||
"""
|
||||
|
||||
reason: str
|
||||
data: Mapping[str, Any] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a JSON-serializable dict for storing in a trace."""
|
||||
result: dict[str, Any] = {"reason": self.reason}
|
||||
if self.data is not None:
|
||||
result["data"] = dict(self.data)
|
||||
return result
|
||||
|
||||
|
||||
class TriggerActionRunner(Protocol):
|
||||
"""Protocol type for the trigger action runner helper callback."""
|
||||
|
||||
@@ -1281,39 +1222,6 @@ class TriggerActionRunner(Protocol):
|
||||
"""
|
||||
|
||||
|
||||
class TriggerNotTriggeredReporter(Protocol):
|
||||
"""Protocol type for the did_not_trigger reporter passed to a trigger runner.
|
||||
|
||||
A trigger calls this to report that it evaluated a relevant change but
|
||||
decided not to fire, supplying diagnostics for tracing.
|
||||
"""
|
||||
|
||||
@callback
|
||||
def __call__(
|
||||
self,
|
||||
info: NotTriggeredInfo,
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Report that the trigger did not fire."""
|
||||
|
||||
|
||||
class TriggerNotTriggeredAction(Protocol):
|
||||
"""Protocol type for the did_not_trigger consumer callback.
|
||||
|
||||
Sibling of the action callback. Invoked - instead of the action - when a
|
||||
trigger evaluated a relevant change but reported it did not fire.
|
||||
"""
|
||||
|
||||
@callback
|
||||
def __call__(
|
||||
self,
|
||||
run_variables: dict[str, Any],
|
||||
info: NotTriggeredInfo,
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Define did_not_trigger consumer callback type."""
|
||||
|
||||
|
||||
class TriggerActionPayloadBuilder(Protocol):
|
||||
"""Protocol type for the trigger action payload builder."""
|
||||
|
||||
@@ -1598,7 +1506,6 @@ async def _async_attach_trigger_cls(
|
||||
conf: ConfigType,
|
||||
action: Callable,
|
||||
trigger_info: TriggerInfo,
|
||||
did_not_trigger: TriggerNotTriggeredAction | None = None,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Initialize a new Trigger class and attach it."""
|
||||
|
||||
@@ -1619,26 +1526,6 @@ async def _async_attach_trigger_cls(
|
||||
payload.update(trigger_variables.async_render(hass, payload))
|
||||
return payload
|
||||
|
||||
report_not_triggered: TriggerNotTriggeredReporter | None = None
|
||||
if did_not_trigger is not None:
|
||||
not_triggered_action = did_not_trigger
|
||||
|
||||
@callback
|
||||
def report_not_triggered(
|
||||
info: NotTriggeredInfo, context: Context | None = None
|
||||
) -> None:
|
||||
"""Forward a did-not-fire report to the consumer."""
|
||||
run_variables = {
|
||||
"trigger": {
|
||||
**trigger_info["trigger_data"],
|
||||
CONF_PLATFORM: trigger_key,
|
||||
}
|
||||
}
|
||||
# The consumer records a trace using the trace context variables.
|
||||
# Run it in a copied context so it does not disturb the trace of the
|
||||
# run that produced this state change (e.g. a chained automation).
|
||||
copy_context().run(not_triggered_action, run_variables, info, context)
|
||||
|
||||
# Wrap sync action so that it is always async.
|
||||
# This simplifies the Trigger action runner interface by
|
||||
# always returning a coroutine, removing the need for
|
||||
@@ -1677,9 +1564,7 @@ async def _async_attach_trigger_cls(
|
||||
options=conf.get(CONF_OPTIONS),
|
||||
),
|
||||
)
|
||||
return await trigger.async_attach_action(
|
||||
action, action_payload_builder, did_not_trigger=report_not_triggered
|
||||
)
|
||||
return await trigger.async_attach_action(action, action_payload_builder)
|
||||
|
||||
|
||||
async def async_initialize_triggers(
|
||||
@@ -1691,14 +1576,8 @@ async def async_initialize_triggers(
|
||||
log_cb: Callable,
|
||||
home_assistant_start: bool = False,
|
||||
variables: TemplateVarsType = None,
|
||||
did_not_trigger: TriggerNotTriggeredAction | None = None,
|
||||
) -> CALLBACK_TYPE | None:
|
||||
"""Initialize triggers.
|
||||
|
||||
The optional ``did_not_trigger`` consumer is the sibling of ``action``,
|
||||
invoked - for new-style triggers that support it - when a trigger evaluates
|
||||
a relevant change but reports it did not fire. Old-style triggers ignore it.
|
||||
"""
|
||||
"""Initialize triggers."""
|
||||
triggers: list[asyncio.Task[CALLBACK_TYPE]] = []
|
||||
for idx, conf in enumerate(trigger_config):
|
||||
# Skip triggers that are not enabled
|
||||
@@ -1734,7 +1613,7 @@ async def async_initialize_triggers(
|
||||
)
|
||||
trigger_cls = trigger_descriptors[relative_trigger_key]
|
||||
coro = _async_attach_trigger_cls(
|
||||
hass, trigger_cls, trigger_key, conf, action, info, did_not_trigger
|
||||
hass, trigger_cls, trigger_key, conf, action, info
|
||||
)
|
||||
else:
|
||||
action_wrapper = _trigger_action_wrapper(hass, action, conf)
|
||||
|
||||
@@ -29,7 +29,7 @@ cached-ipaddress==1.1.2
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==48.0.1
|
||||
cryptography==48.0.0
|
||||
dbus-fast==5.0.16
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.3
|
||||
|
||||
@@ -31,7 +31,9 @@ DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = {
|
||||
}
|
||||
DEPRECATED_PACKAGES: dict[str, tuple[str, str]] = {
|
||||
# old_package_name: (reason, breaks_in_ha_version)
|
||||
"pyserial-asyncio": ("should be replaced by pyserial-asyncio-fast", "2026.7"),
|
||||
"pyserial": ("should be replaced by serialx", "2027.1"),
|
||||
"pyserial-asyncio": ("should be replaced by serialx", "2027.1"),
|
||||
"pyserial-asyncio-fast": ("should be replaced by serialx", "2027.1"),
|
||||
}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -4717,6 +4717,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.samsung_exlink.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.samsung_infrared.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ dependencies = [
|
||||
"lru-dict==1.4.1",
|
||||
"PyJWT==2.12.1",
|
||||
# PyJWT has loose dependency. We want the latest one.
|
||||
"cryptography==48.0.1",
|
||||
"cryptography==48.0.0",
|
||||
"Pillow==12.2.0",
|
||||
"propcache==0.5.2",
|
||||
"pyOpenSSL==26.2.0",
|
||||
|
||||
Generated
+1
-1
@@ -21,7 +21,7 @@ bcrypt==5.0.0
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==48.0.1
|
||||
cryptography==48.0.0
|
||||
fnv-hash-fast==2.0.3
|
||||
ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==2.2.0
|
||||
|
||||
Generated
+3
@@ -2949,6 +2949,9 @@ ruuvitag-ble==0.4.0
|
||||
# homeassistant.components.yamaha
|
||||
rxv==0.7.0
|
||||
|
||||
# homeassistant.components.samsung_exlink
|
||||
samsung-exlink==1.1.0
|
||||
|
||||
# homeassistant.components.samsungtv
|
||||
samsungctl[websocket]==0.7.1
|
||||
|
||||
|
||||
@@ -6,10 +6,6 @@ set -e
|
||||
|
||||
cd "$(realpath "$(dirname "$0")/..")"
|
||||
|
||||
if [ ! -n "$VIRTUAL_ENV" ]; then
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
|
||||
echo "Installing development dependencies..."
|
||||
uv pip install \
|
||||
-e . \
|
||||
|
||||
@@ -17,7 +17,7 @@ from script.hassfest.model import Config, Integration
|
||||
# Requirements which can't be installed on all systems because they
|
||||
# rely on additional system packages. Requirements listed in
|
||||
# EXCLUDED_REQUIREMENTS_ALL will be commented-out in
|
||||
# requirements_all.txt.
|
||||
# requirements_all.txt and requirements_test_all.txt.
|
||||
EXCLUDED_REQUIREMENTS_ALL = {
|
||||
"atenpdu", # depends on pysnmp which is not maintained at this time
|
||||
"avion",
|
||||
|
||||
@@ -216,15 +216,34 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None:
|
||||
async def test_single_closed_site_no_closed_date(
|
||||
hass: HomeAssistant, single_site_closed_no_close_date_api: Mock
|
||||
) -> None:
|
||||
"""Test single closed site with no closed date is filtered out."""
|
||||
"""Test single closed site with no closed date."""
|
||||
initial_result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert initial_result.get("type") is FlowResultType.FORM
|
||||
assert initial_result.get("step_id") == "user"
|
||||
|
||||
# Test filling in API key
|
||||
enter_api_key_result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data={CONF_API_TOKEN: API_KEY},
|
||||
)
|
||||
assert enter_api_key_result.get("type") is FlowResultType.FORM
|
||||
assert enter_api_key_result.get("step_id") == "user"
|
||||
assert enter_api_key_result.get("errors") == {"api_token": "no_site"}
|
||||
assert enter_api_key_result.get("step_id") == "site"
|
||||
|
||||
select_site_result = await hass.config_entries.flow.async_configure(
|
||||
enter_api_key_result["flow_id"],
|
||||
{CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"},
|
||||
)
|
||||
|
||||
# Show available sites
|
||||
assert select_site_result.get("type") is FlowResultType.CREATE_ENTRY
|
||||
assert select_site_result.get("title") == "Home"
|
||||
data = select_site_result.get("data")
|
||||
assert data
|
||||
assert data[CONF_API_TOKEN] == API_KEY
|
||||
assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH"
|
||||
|
||||
|
||||
async def test_single_site_rejoin(
|
||||
@@ -314,9 +333,13 @@ async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None:
|
||||
assert result.get("errors") == {"api_token": "unknown_error"}
|
||||
|
||||
|
||||
async def test_site_filtering(single_site_rejoin_api: Mock) -> None:
|
||||
"""Test that closed sites are filtered out and remaining sites are deduplicated."""
|
||||
async def test_site_deduplication(single_site_rejoin_api: Mock) -> None:
|
||||
"""Test site deduplication."""
|
||||
filtered = filter_sites(single_site_rejoin_api.get_sites())
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].nmi == "11111111111"
|
||||
assert filtered[0].status == SiteStatus.ACTIVE
|
||||
assert len(filtered) == 2
|
||||
assert (
|
||||
next(s for s in filtered if s.nmi == "11111111111").status == SiteStatus.ACTIVE
|
||||
)
|
||||
assert (
|
||||
next(s for s in filtered if s.nmi == "11111111112").status == SiteStatus.CLOSED
|
||||
)
|
||||
|
||||
@@ -32,10 +32,8 @@ from homeassistant.const import (
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Context,
|
||||
CoreState,
|
||||
Event,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
State,
|
||||
@@ -57,26 +55,16 @@ from homeassistant.helpers.script import (
|
||||
SCRIPT_MODE_SINGLE,
|
||||
_async_stop_scripts_at_shutdown,
|
||||
)
|
||||
from homeassistant.helpers.trigger import (
|
||||
NotTriggeredInfo,
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerNotTriggeredReporter,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util, yaml as yaml_util
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
MockModule,
|
||||
MockUser,
|
||||
assert_setup_component,
|
||||
async_capture_events,
|
||||
async_fire_time_changed,
|
||||
async_mock_service,
|
||||
mock_integration,
|
||||
mock_platform,
|
||||
mock_restore_cache,
|
||||
)
|
||||
from tests.components.logbook.common import MockRow, mock_humanify
|
||||
@@ -4226,249 +4214,3 @@ async def test_automation_changed_entity_id(
|
||||
hass.bus.async_fire("test_event")
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
class _MockDiagnosticTrigger(Trigger):
|
||||
"""A new-style trigger that fires on demand and otherwise reports why not."""
|
||||
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return config
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Fire on `mock_diag_event` with `fire`, else report not-triggered."""
|
||||
|
||||
@callback
|
||||
def handle_event(event: Event) -> None:
|
||||
if event.data.get("fire"):
|
||||
run_action({"extra": "fired"}, "mock fired", event.context)
|
||||
elif did_not_trigger is not None:
|
||||
did_not_trigger(
|
||||
NotTriggeredInfo(reason="mock_reason", data={"x": 1}),
|
||||
event.context,
|
||||
)
|
||||
|
||||
return self._hass.bus.async_listen("mock_diag_event", handle_event)
|
||||
|
||||
|
||||
async def _setup_diagnostic_automation(
|
||||
hass: HomeAssistant, stored_traces: int | None = None
|
||||
) -> list[Any]:
|
||||
"""Set up an automation driven by the mock diagnostic trigger."""
|
||||
|
||||
async def async_get_triggers(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, type[Trigger]]:
|
||||
return {"diag": _MockDiagnosticTrigger}
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
|
||||
calls = async_mock_service(hass, "test", "automation")
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"id": "diag_auto",
|
||||
"trigger": {"platform": "test.diag"},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
if stored_traces is not None:
|
||||
config["trace"] = {"stored_traces": stored_traces}
|
||||
|
||||
assert await async_setup_component(
|
||||
hass, automation.DOMAIN, {automation.DOMAIN: config}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
return calls
|
||||
|
||||
|
||||
async def test_automation_records_not_triggered_trace(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""A non-firing evaluation records a not-triggered trace with diagnostics."""
|
||||
calls = await _setup_diagnostic_automation(hass)
|
||||
client = await hass_ws_client()
|
||||
msg_id = 0
|
||||
|
||||
def next_id() -> int:
|
||||
nonlocal msg_id
|
||||
msg_id += 1
|
||||
return msg_id
|
||||
|
||||
hass.bus.async_fire("mock_diag_event", {"fire": False})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The action did not run, but a not-triggered trace was recorded.
|
||||
assert len(calls) == 0
|
||||
await client.send_json(
|
||||
{
|
||||
"id": next_id(),
|
||||
"type": "trace/list",
|
||||
"domain": "automation",
|
||||
"item_id": "diag_auto",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
traces = response["result"]
|
||||
assert len(traces) == 1
|
||||
assert traces[0]["not_triggered"] is True
|
||||
assert traces[0]["script_execution"] == "not_triggered"
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": next_id(),
|
||||
"type": "trace/get",
|
||||
"domain": "automation",
|
||||
"item_id": "diag_auto",
|
||||
"run_id": traces[0]["run_id"],
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
trace = response["result"]
|
||||
assert trace["trace"]["trigger/0"][0]["result"] == {
|
||||
"reason": "mock_reason",
|
||||
"data": {"x": 1},
|
||||
}
|
||||
|
||||
|
||||
async def test_not_triggered_traces_counted_separately(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Not-triggered traces are capped independently from run traces."""
|
||||
calls = await _setup_diagnostic_automation(hass, stored_traces=2)
|
||||
client = await hass_ws_client()
|
||||
msg_id = 0
|
||||
|
||||
def next_id() -> int:
|
||||
nonlocal msg_id
|
||||
msg_id += 1
|
||||
return msg_id
|
||||
|
||||
# Fire more non-firing and firing evaluations than the stored-traces limit.
|
||||
for _ in range(3):
|
||||
hass.bus.async_fire("mock_diag_event", {"fire": False})
|
||||
await hass.async_block_till_done()
|
||||
for _ in range(3):
|
||||
hass.bus.async_fire("mock_diag_event", {"fire": True})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(calls) == 3
|
||||
await client.send_json(
|
||||
{
|
||||
"id": next_id(),
|
||||
"type": "trace/list",
|
||||
"domain": "automation",
|
||||
"item_id": "diag_auto",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
traces = response["result"]
|
||||
not_triggered = [trace for trace in traces if trace.get("not_triggered")]
|
||||
runs = [trace for trace in traces if not trace.get("not_triggered")]
|
||||
# Each bucket is capped at 2 independently; neither evicts the other.
|
||||
assert len(not_triggered) == 2
|
||||
assert len(runs) == 2
|
||||
|
||||
|
||||
async def test_not_triggered_trace_isolated_from_chained_run(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""A not-triggered trace must not corrupt the trace of the run that caused it.
|
||||
|
||||
Automation "parent" fires the event that automation "child"'s trigger
|
||||
evaluates and declines. The child's not-triggered handler runs inline in the
|
||||
parent's context; without a copied context its ``trace_clear()`` would
|
||||
redirect the parent's remaining action steps into the child's trace.
|
||||
"""
|
||||
|
||||
async def async_get_triggers(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, type[Trigger]]:
|
||||
return {"diag": _MockDiagnosticTrigger}
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: [
|
||||
{
|
||||
"id": "parent",
|
||||
"trigger": {"platform": "event", "event_type": "chain_start"},
|
||||
"action": [
|
||||
# The child trigger evaluates this and declines to fire.
|
||||
{"event": "mock_diag_event"},
|
||||
# The parent's own step after the chained evaluation.
|
||||
{"event": "chain_marker"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "child",
|
||||
"trigger": {"platform": "test.diag"},
|
||||
"action": {"event": "child_ran"},
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
child_ran = async_capture_events(hass, "child_ran")
|
||||
hass.bus.async_fire("chain_start")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The child trigger evaluated the event but did not fire.
|
||||
assert len(child_ran) == 0
|
||||
|
||||
client = await hass_ws_client()
|
||||
msg_id = 0
|
||||
|
||||
def next_id() -> int:
|
||||
nonlocal msg_id
|
||||
msg_id += 1
|
||||
return msg_id
|
||||
|
||||
async def _get_only_trace(item_id: str) -> dict[str, Any]:
|
||||
await client.send_json(
|
||||
{
|
||||
"id": next_id(),
|
||||
"type": "trace/list",
|
||||
"domain": "automation",
|
||||
"item_id": item_id,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert len(response["result"]) == 1
|
||||
run_id = response["result"][0]["run_id"]
|
||||
await client.send_json(
|
||||
{
|
||||
"id": next_id(),
|
||||
"type": "trace/get",
|
||||
"domain": "automation",
|
||||
"item_id": item_id,
|
||||
"run_id": run_id,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
return response["result"]
|
||||
|
||||
# The parent keeps all of its own steps; action/1 must not leak away into
|
||||
# the child's trace. This fails if the context is not copied.
|
||||
parent_trace = await _get_only_trace("parent")
|
||||
assert set(parent_trace["trace"]) == {"trigger/0", "action/0", "action/1"}
|
||||
|
||||
# The child's not-triggered trace holds only its own trigger step.
|
||||
child_trace = await _get_only_trace("child")
|
||||
assert child_trace["not_triggered"] is True
|
||||
assert set(child_trace["trace"]) == {"trigger/0"}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Google',
|
||||
'model': 'gemini-3.1-flash-tts-preview',
|
||||
'model': 'gemini-2.5-flash-preview-tts',
|
||||
'model_id': None,
|
||||
'name': 'Google AI TTS',
|
||||
'name_by_user': None,
|
||||
|
||||
@@ -68,17 +68,17 @@ def get_models_pager():
|
||||
)
|
||||
model_15_pro.name = "models/gemini-1.5-pro-latest"
|
||||
|
||||
model_31_flash_tts = Mock(
|
||||
model_25_flash_tts = Mock(
|
||||
supported_actions=["generateContent"],
|
||||
)
|
||||
model_31_flash_tts.name = "models/gemini-3.1-flash-tts-preview"
|
||||
model_25_flash_tts.name = "models/gemini-2.5-flash-preview-tts"
|
||||
|
||||
async def models_pager():
|
||||
yield model_25_flash
|
||||
yield model_20_flash
|
||||
yield model_15_flash
|
||||
yield model_15_pro
|
||||
yield model_31_flash_tts
|
||||
yield model_25_flash_tts
|
||||
|
||||
return models_pager()
|
||||
|
||||
@@ -278,6 +278,13 @@ async def test_creating_subentry(
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: RECOMMENDED_TTS_MODEL,
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_TOP_P: 1.0,
|
||||
CONF_TOP_K: 1,
|
||||
CONF_MAX_TOKENS: 1024,
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
),
|
||||
(
|
||||
|
||||
@@ -14,7 +14,11 @@ from homeassistant.components import tts
|
||||
from homeassistant.components.google_generative_ai_conversation.const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DOMAIN,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_K,
|
||||
RECOMMENDED_TOP_P,
|
||||
)
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
@@ -181,6 +185,28 @@ async def test_tts_service_speak(
|
||||
)
|
||||
),
|
||||
temperature=RECOMMENDED_TEMPERATURE,
|
||||
top_k=RECOMMENDED_TOP_K,
|
||||
top_p=RECOMMENDED_TOP_P,
|
||||
max_output_tokens=RECOMMENDED_MAX_TOKENS,
|
||||
safety_settings=[
|
||||
types.SafetySetting(
|
||||
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
types.SafetySetting(
|
||||
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
types.SafetySetting(
|
||||
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
types.SafetySetting(
|
||||
category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
],
|
||||
thinking_config=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -228,5 +254,27 @@ async def test_tts_service_speak_error(
|
||||
)
|
||||
),
|
||||
temperature=RECOMMENDED_TEMPERATURE,
|
||||
top_k=RECOMMENDED_TOP_K,
|
||||
top_p=RECOMMENDED_TOP_P,
|
||||
max_output_tokens=RECOMMENDED_MAX_TOKENS,
|
||||
safety_settings=[
|
||||
types.SafetySetting(
|
||||
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
types.SafetySetting(
|
||||
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
types.SafetySetting(
|
||||
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
types.SafetySetting(
|
||||
category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
],
|
||||
thinking_config=None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2754,7 +2754,7 @@ async def test_migrate_config_entry(
|
||||
{
|
||||
"state_topic": "test-topic",
|
||||
"value_template": "{{ value_json.value }}",
|
||||
"other_settings": {"expire_after": 1200, "off_delay": 5},
|
||||
"advanced_settings": {"expire_after": 1200, "off_delay": 5},
|
||||
},
|
||||
(
|
||||
(
|
||||
@@ -3418,10 +3418,10 @@ async def test_migrate_config_entry(
|
||||
(
|
||||
{
|
||||
"command_topic": "test-topic",
|
||||
"other_settings": {"max_kelvin": 2000, "min_kelvin": 2000},
|
||||
"advanced_settings": {"max_kelvin": 2000, "min_kelvin": 2000},
|
||||
},
|
||||
{
|
||||
"other_settings": "max_below_min_kelvin",
|
||||
"advanced_settings": "max_below_min_kelvin",
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -3700,7 +3700,7 @@ async def test_migrate_config_entry(
|
||||
{
|
||||
"state_topic": "test-topic",
|
||||
"value_template": "{{ value_json.value }}",
|
||||
"other_settings": {"expire_after": 30},
|
||||
"advanced_settings": {"expire_after": 30},
|
||||
},
|
||||
(
|
||||
(
|
||||
@@ -3766,7 +3766,7 @@ async def test_migrate_config_entry(
|
||||
"available_tones": ["Happy hour", "Cooling alarm"],
|
||||
"support_duration": True,
|
||||
"support_volume_set": True,
|
||||
"siren_other_settings": {
|
||||
"siren_advanced_settings": {
|
||||
"command_off_template": "{{ value }}",
|
||||
},
|
||||
},
|
||||
@@ -3827,7 +3827,7 @@ async def test_migrate_config_entry(
|
||||
"state_topic": "test-topic",
|
||||
"value_template": "{{ value_json.value }}",
|
||||
"retain": False,
|
||||
"text_other_settings": {
|
||||
"text_advanced_settings": {
|
||||
"min": 0,
|
||||
"max": 10,
|
||||
"mode": "password",
|
||||
@@ -3849,26 +3849,26 @@ async def test_migrate_config_entry(
|
||||
(
|
||||
{
|
||||
"command_topic": "test-topic",
|
||||
"text_other_settings": {
|
||||
"text_advanced_settings": {
|
||||
"min": 20,
|
||||
"max": 10,
|
||||
"mode": "password",
|
||||
"pattern": "^[a-z_]*$",
|
||||
},
|
||||
},
|
||||
{"text_other_settings": "max_below_min"},
|
||||
{"text_advanced_settings": "max_below_min"},
|
||||
),
|
||||
(
|
||||
{
|
||||
"command_topic": "test-topic",
|
||||
"text_other_settings": {
|
||||
"text_advanced_settings": {
|
||||
"min": 0,
|
||||
"max": 10,
|
||||
"mode": "password",
|
||||
"pattern": "(",
|
||||
},
|
||||
},
|
||||
{"text_other_settings": "invalid_regular_expression"},
|
||||
{"text_advanced_settings": "invalid_regular_expression"},
|
||||
),
|
||||
),
|
||||
"Milk notifier MOTD",
|
||||
@@ -4761,7 +4761,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites(
|
||||
"device_class": "battery",
|
||||
"state_class": "measurement",
|
||||
"unit_of_measurement": "%",
|
||||
"other_settings": {"suggested_display_precision": 1},
|
||||
"advanced_settings": {"suggested_display_precision": 1},
|
||||
},
|
||||
{
|
||||
"state_topic": "test-topic1-updated",
|
||||
@@ -5225,21 +5225,21 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
"model_id": {"suggested_value": "mn002"},
|
||||
"manufacturer": {"suggested_value": "Milk Masters"},
|
||||
"configuration_url": {"suggested_value": "https://example.com"},
|
||||
"other_settings": None,
|
||||
"advanced_settings": None,
|
||||
"mqtt_settings": None,
|
||||
}
|
||||
|
||||
other_settings_key_descriptions = {
|
||||
advanced_settings_key_descriptions = {
|
||||
key: key.description
|
||||
for key, value in result["data_schema"]
|
||||
.schema["other_settings"]
|
||||
.schema["advanced_settings"]
|
||||
.schema.schema.items()
|
||||
}
|
||||
assert other_settings_key_descriptions == {
|
||||
assert advanced_settings_key_descriptions == {
|
||||
"sw_version": {"suggested_value": "1.0"},
|
||||
"hw_version": {"suggested_value": "2.1 rev a"},
|
||||
}
|
||||
assert result["data_schema"].schema["other_settings"].options == {
|
||||
assert result["data_schema"].schema["advanced_settings"].options == {
|
||||
"collapsed": False
|
||||
}
|
||||
|
||||
@@ -5264,7 +5264,7 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
"name": "Beer notifier",
|
||||
"other_settings": {"sw_version": "1.1"},
|
||||
"advanced_settings": {"sw_version": "1.1"},
|
||||
"model": "Beer bottle XL",
|
||||
"model_id": "bn003",
|
||||
"manufacturer": "Beer Masters",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Tests for the Samsung ExLink integration."""
|
||||
|
||||
MOCK_DEVICE = "/dev/ttyUSB0"
|
||||
MOCK_MODEL = "frame_2022"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Test fixtures for the Samsung ExLink integration."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from samsung_exlink import MODELS, InputSource, PowerState, SamsungTV, TVState
|
||||
|
||||
from homeassistant.components.samsung_exlink.const import DOMAIN
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MOCK_DEVICE, MOCK_MODEL
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
class MockSamsungTV(SamsungTV):
|
||||
"""Samsung TV test double built on the real controller object."""
|
||||
|
||||
def __init__(self, state: TVState) -> None:
|
||||
"""Initialize the mock TV."""
|
||||
super().__init__(MOCK_DEVICE, model=MODELS[MOCK_MODEL])
|
||||
self._connected = True
|
||||
self._state = state
|
||||
self.connect = AsyncMock(side_effect=self._mock_connect)
|
||||
self.refresh = AsyncMock()
|
||||
self.query_power = AsyncMock(return_value=PowerState.ON)
|
||||
self.disconnect = AsyncMock(side_effect=self._mock_disconnect)
|
||||
self.power_on = AsyncMock()
|
||||
self.power_off = AsyncMock()
|
||||
self.set_volume = AsyncMock()
|
||||
self.set_mute = AsyncMock()
|
||||
self.select_input_source = AsyncMock()
|
||||
|
||||
def mock_state(self, state: TVState | None) -> None:
|
||||
"""Push a state update through the TV."""
|
||||
self._connected = state is not None
|
||||
if state is not None:
|
||||
self._state = state
|
||||
self._notify_subscribers()
|
||||
|
||||
async def _mock_connect(self) -> None:
|
||||
"""Pretend to open the serial connection."""
|
||||
self._connected = True
|
||||
|
||||
async def _mock_disconnect(self) -> None:
|
||||
"""Pretend to close the serial connection."""
|
||||
self._connected = False
|
||||
self._notify_subscribers()
|
||||
|
||||
|
||||
def _default_state() -> TVState:
|
||||
"""Return a TVState with typical defaults."""
|
||||
return TVState(
|
||||
power=True,
|
||||
input_source=InputSource.HDMI1,
|
||||
volume=20,
|
||||
mute=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def initial_tv_state() -> TVState:
|
||||
"""Return the initial TV state for a test."""
|
||||
return _default_state()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_samsung_tv(initial_tv_state: TVState) -> MockSamsungTV:
|
||||
"""Create a mock SamsungTV controller."""
|
||||
return MockSamsungTV(initial_tv_state)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Create a mock config entry."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
title="Samsung TV",
|
||||
entry_id="01KPBBPM6WCQ8148EFR0TCG1WW",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def mock_usb_component(hass: HomeAssistant) -> None:
|
||||
"""Mock the USB component to prevent setup failures."""
|
||||
hass.config.components.add("usb")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_components(
|
||||
hass: HomeAssistant,
|
||||
mock_usb_component: None,
|
||||
mock_samsung_tv: MockSamsungTV,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the Samsung ExLink component."""
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.SamsungTV",
|
||||
return_value=mock_samsung_tv,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -0,0 +1,103 @@
|
||||
# serializer version: 1
|
||||
# name: test_entities_created[media_player.samsung_tv-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'source_list': list([
|
||||
'av1',
|
||||
'av2',
|
||||
'av3',
|
||||
'component1',
|
||||
'component2',
|
||||
'component3',
|
||||
'dvi1',
|
||||
'dvi2',
|
||||
'dvi3',
|
||||
'hdmi1',
|
||||
'hdmi2',
|
||||
'hdmi3',
|
||||
'hdmi4',
|
||||
'pc1',
|
||||
'pc2',
|
||||
'pc3',
|
||||
'rvu',
|
||||
's_video1',
|
||||
's_video2',
|
||||
's_video3',
|
||||
'tv',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'media_player',
|
||||
'entity_category': None,
|
||||
'entity_id': 'media_player.samsung_tv',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <MediaPlayerDeviceClass.TV: 'tv'>,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'samsung_exlink',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <MediaPlayerEntityFeature: 3468>,
|
||||
'translation_key': 'tv',
|
||||
'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_created[media_player.samsung_tv-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'tv',
|
||||
'friendly_name': 'Samsung TV',
|
||||
'is_volume_muted': False,
|
||||
'source': 'hdmi1',
|
||||
'source_list': list([
|
||||
'av1',
|
||||
'av2',
|
||||
'av3',
|
||||
'component1',
|
||||
'component2',
|
||||
'component3',
|
||||
'dvi1',
|
||||
'dvi2',
|
||||
'dvi3',
|
||||
'hdmi1',
|
||||
'hdmi2',
|
||||
'hdmi3',
|
||||
'hdmi4',
|
||||
'pc1',
|
||||
'pc2',
|
||||
'pc3',
|
||||
'rvu',
|
||||
's_video1',
|
||||
's_video2',
|
||||
's_video3',
|
||||
'tv',
|
||||
]),
|
||||
'supported_features': <MediaPlayerEntityFeature: 3468>,
|
||||
'volume_level': 0.2,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'media_player.samsung_tv',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Tests for the Samsung ExLink config flow."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from samsung_exlink import SamsungTVError
|
||||
|
||||
from homeassistant.components.samsung_exlink.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import MOCK_DEVICE, MOCK_MODEL
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_async_setup_entry(mock_samsung_tv: MagicMock) -> Generator[AsyncMock]:
|
||||
"""Prevent config-entry creation tests from setting up the integration."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
async def test_user_form_creates_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_samsung_tv: MagicMock,
|
||||
mock_async_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful config flow creates an entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.config_flow.SamsungTV",
|
||||
return_value=mock_samsung_tv,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Samsung TV"
|
||||
assert result["data"] == {CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL}
|
||||
mock_async_setup_entry.assert_awaited_once()
|
||||
mock_samsung_tv.connect.assert_awaited_once()
|
||||
mock_samsung_tv.query_power.assert_awaited_once()
|
||||
mock_samsung_tv.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_user_form_without_model(
|
||||
hass: HomeAssistant,
|
||||
mock_samsung_tv: MagicMock,
|
||||
mock_async_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test the model field is optional."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.config_flow.SamsungTV",
|
||||
return_value=mock_samsung_tv,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {CONF_DEVICE: MOCK_DEVICE}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[TimeoutError, SamsungTVError("garbled response")],
|
||||
)
|
||||
async def test_user_form_no_tv_shows_troubleshooting(
|
||||
hass: HomeAssistant,
|
||||
mock_samsung_tv: MagicMock,
|
||||
mock_async_setup_entry: AsyncMock,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
"""Test a working port with no responding TV routes to troubleshooting."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
mock_samsung_tv.query_power.side_effect = exception
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.config_flow.SamsungTV",
|
||||
return_value=mock_samsung_tv,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "troubleshoot"
|
||||
|
||||
# Continuing from troubleshooting returns to the user step to retry.
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
mock_samsung_tv.query_power.side_effect = None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.config_flow.SamsungTV",
|
||||
return_value=mock_samsung_tv,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_user_form_query_unexpected_error(
|
||||
hass: HomeAssistant,
|
||||
mock_samsung_tv: MagicMock,
|
||||
mock_async_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test an unexpected error while querying the TV shows the unknown error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
mock_samsung_tv.query_power.side_effect = RuntimeError("boom")
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.config_flow.SamsungTV",
|
||||
return_value=mock_samsung_tv,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
mock_samsung_tv.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(ValueError("Invalid port"), "cannot_connect"),
|
||||
(OSError("No such device"), "cannot_connect"),
|
||||
(ConnectionRefusedError("Connection refused"), "cannot_connect"),
|
||||
(RuntimeError("boom"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_user_form_bad_port_shows_error(
|
||||
hass: HomeAssistant,
|
||||
exception: Exception,
|
||||
error: str,
|
||||
mock_samsung_tv: MagicMock,
|
||||
mock_async_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test a bad serial port keeps the user on the form with an error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
mock_samsung_tv.connect.side_effect = exception
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.config_flow.SamsungTV",
|
||||
return_value=mock_samsung_tv,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
mock_samsung_tv.connect.side_effect = None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.config_flow.SamsungTV",
|
||||
return_value=mock_samsung_tv,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_user_duplicate_aborts(hass: HomeAssistant) -> None:
|
||||
"""Test we abort if the same port is already configured."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL}
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tests for the Samsung ExLink integration init."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import MockSamsungTV
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_setup_connection_error(
|
||||
hass: HomeAssistant,
|
||||
mock_samsung_tv: MockSamsungTV,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test a connection failure results in a retry."""
|
||||
mock_samsung_tv.connect.side_effect = TimeoutError
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.samsung_exlink.SamsungTV",
|
||||
return_value=mock_samsung_tv,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_samsung_tv: MockSamsungTV,
|
||||
init_components: None,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test unloading a config entry disconnects from the TV."""
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
mock_samsung_tv.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_remove_entry_while_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_samsung_tv: MockSamsungTV,
|
||||
init_components: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test removing a config entry does not schedule a reload.
|
||||
|
||||
When removing a loaded entry, disconnect() fires the subscriber callback
|
||||
with state=None. The callback must not schedule a reload because the entry
|
||||
is already being removed (state is no longer LOADED).
|
||||
"""
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await hass.config_entries.async_remove(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
mock_samsung_tv.disconnect.assert_awaited_once()
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Tests for the Samsung ExLink media player platform."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import call
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from samsung_exlink import CommandRejected, InputSource, TVState
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_INPUT_SOURCE,
|
||||
ATTR_INPUT_SOURCE_LIST,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN as MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
MediaPlayerEntityFeature,
|
||||
)
|
||||
from homeassistant.components.samsung_exlink.media_player import (
|
||||
INPUT_SOURCE_SAMSUNG_TO_HA,
|
||||
SCAN_INTERVAL,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
SERVICE_VOLUME_DOWN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
SERVICE_VOLUME_SET,
|
||||
SERVICE_VOLUME_UP,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util.json import load_json
|
||||
|
||||
from .conftest import MockSamsungTV, _default_state
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
ENTITY_ID = "media_player.samsung_tv"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def auto_init_components(init_components: None) -> None:
|
||||
"""Set up the component."""
|
||||
|
||||
|
||||
async def test_entities_created(
|
||||
hass: HomeAssistant,
|
||||
mock_samsung_tv: MockSamsungTV,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test the media player entity is created through config entry setup."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_polling_updates_state(
|
||||
hass: HomeAssistant,
|
||||
mock_samsung_tv: MockSamsungTV,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test the entity polls the TV on the scan interval and reflects changes."""
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_ON
|
||||
|
||||
off_state = _default_state()
|
||||
off_state.power = False
|
||||
mock_samsung_tv.refresh.side_effect = lambda: mock_samsung_tv.mock_state(off_state)
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_samsung_tv.refresh.assert_awaited()
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_OFF
|
||||
|
||||
|
||||
async def test_state_updates(
|
||||
hass: HomeAssistant, mock_samsung_tv: MockSamsungTV
|
||||
) -> None:
|
||||
"""Test the entity updates from TV pushes and disconnects."""
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_ON
|
||||
|
||||
state = _default_state()
|
||||
state.power = False
|
||||
mock_samsung_tv.mock_state(state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_OFF
|
||||
|
||||
mock_samsung_tv.mock_state(None)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_state_unknown_when_not_queried(
|
||||
hass: HomeAssistant, mock_samsung_tv: MockSamsungTV
|
||||
) -> None:
|
||||
"""Test attributes are cleared when the TV reports no state."""
|
||||
mock_samsung_tv.mock_state(TVState())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None
|
||||
|
||||
|
||||
async def test_power_controls(
|
||||
hass: HomeAssistant, mock_samsung_tv: MockSamsungTV
|
||||
) -> None:
|
||||
"""Test power services call the right methods."""
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True
|
||||
)
|
||||
mock_samsung_tv.power_on.assert_awaited_once()
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True
|
||||
)
|
||||
mock_samsung_tv.power_off.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_volume_controls(
|
||||
hass: HomeAssistant, mock_samsung_tv: MockSamsungTV
|
||||
) -> None:
|
||||
"""Test volume state and controls."""
|
||||
assert hass.states.get(ENTITY_ID).attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.2
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_SET,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_samsung_tv.set_volume.await_args == call(50)
|
||||
|
||||
# Volume up/down use the media player base class default step.
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True
|
||||
)
|
||||
assert mock_samsung_tv.set_volume.await_args == call(30)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True
|
||||
)
|
||||
assert mock_samsung_tv.set_volume.await_args == call(10)
|
||||
|
||||
|
||||
async def test_mute_controls(
|
||||
hass: HomeAssistant, mock_samsung_tv: MockSamsungTV
|
||||
) -> None:
|
||||
"""Test mute state and controls."""
|
||||
assert hass.states.get(ENTITY_ID).attributes[ATTR_MEDIA_VOLUME_MUTED] is False
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_samsung_tv.set_mute.await_args == call(True)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: False},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_samsung_tv.set_mute.await_args == call(False)
|
||||
|
||||
|
||||
async def test_volume_features_depend_on_reported_state(
|
||||
hass: HomeAssistant, mock_samsung_tv: MockSamsungTV
|
||||
) -> None:
|
||||
"""Test volume features drop when the TV does not report volume/mute.
|
||||
|
||||
A powered-off TV does not answer status queries, so volume and mute are
|
||||
unknown; their controls and attributes are withheld until known.
|
||||
"""
|
||||
features = hass.states.get(ENTITY_ID).attributes[ATTR_SUPPORTED_FEATURES]
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_STEP
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
state = _default_state()
|
||||
state.volume = None
|
||||
state.mute = None
|
||||
mock_samsung_tv.mock_state(state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_state = hass.states.get(ENTITY_ID)
|
||||
features = entity_state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
assert not features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
assert not features & MediaPlayerEntityFeature.VOLUME_STEP
|
||||
assert not features & MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
assert features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
|
||||
# The volume attributes are dropped along with the controls.
|
||||
assert ATTR_MEDIA_VOLUME_LEVEL not in entity_state.attributes
|
||||
assert ATTR_MEDIA_VOLUME_MUTED not in entity_state.attributes
|
||||
|
||||
|
||||
async def test_no_source_or_volume_controls_when_off(
|
||||
hass: HomeAssistant, mock_samsung_tv: MockSamsungTV
|
||||
) -> None:
|
||||
"""Test source/volume/mute controls are withheld while the TV is off.
|
||||
|
||||
A standby TV only accepts power-on over RS-232, so offering the other
|
||||
controls would only produce failed commands.
|
||||
"""
|
||||
off_state = _default_state()
|
||||
off_state.power = False
|
||||
mock_samsung_tv.mock_state(off_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_state = hass.states.get(ENTITY_ID)
|
||||
features = entity_state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
assert features & MediaPlayerEntityFeature.TURN_ON
|
||||
assert features & MediaPlayerEntityFeature.TURN_OFF
|
||||
assert not features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
assert not features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
assert not features & MediaPlayerEntityFeature.VOLUME_STEP
|
||||
assert not features & MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
# Stale source/volume/mute attributes are cleared while off.
|
||||
assert ATTR_INPUT_SOURCE not in entity_state.attributes
|
||||
assert ATTR_MEDIA_VOLUME_LEVEL not in entity_state.attributes
|
||||
assert ATTR_MEDIA_VOLUME_MUTED not in entity_state.attributes
|
||||
|
||||
|
||||
async def test_source_state_and_controls(
|
||||
hass: HomeAssistant, mock_samsung_tv: MockSamsungTV
|
||||
) -> None:
|
||||
"""Test source state and selection."""
|
||||
entity_state = hass.states.get(ENTITY_ID)
|
||||
assert entity_state.attributes[ATTR_INPUT_SOURCE] == "hdmi1"
|
||||
|
||||
source_list = entity_state.attributes[ATTR_INPUT_SOURCE_LIST]
|
||||
assert "hdmi1" in source_list
|
||||
assert "tv" in source_list
|
||||
assert source_list == sorted(source_list)
|
||||
|
||||
state = _default_state()
|
||||
state.input_source = InputSource.HDMI2
|
||||
mock_samsung_tv.mock_state(state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE] == "hdmi2"
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "hdmi3"},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_samsung_tv.select_input_source.await_args == call(InputSource.HDMI3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[CommandRejected("rejected"), ConnectionError("connection lost"), TimeoutError],
|
||||
)
|
||||
async def test_command_error_raises(
|
||||
hass: HomeAssistant, mock_samsung_tv: MockSamsungTV, exception: Exception
|
||||
) -> None:
|
||||
"""Test library errors raised during an action surface as HomeAssistantError."""
|
||||
mock_samsung_tv.power_on.side_effect = exception
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True
|
||||
)
|
||||
|
||||
|
||||
def test_input_source_translation_keys_cover_all_enum_members() -> None:
|
||||
"""Test all input sources have a declared translation key."""
|
||||
assert set(INPUT_SOURCE_SAMSUNG_TO_HA) == set(InputSource)
|
||||
|
||||
strings = load_json(Path("homeassistant/components/samsung_exlink/strings.json"))
|
||||
assert set(INPUT_SOURCE_SAMSUNG_TO_HA.values()) == set(
|
||||
strings["entity"]["media_player"]["tv"]["state_attributes"]["source"]["state"]
|
||||
)
|
||||
@@ -10,9 +10,8 @@ import pytest
|
||||
|
||||
from homeassistant.components import sun
|
||||
from homeassistant.components.sun import entity
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, Platform
|
||||
from homeassistant.const import EVENT_STATE_CHANGED
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
@@ -246,31 +245,3 @@ async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity.ENTITY_ID) is None
|
||||
|
||||
|
||||
async def test_cleanup_deprecated_solar_rising(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test that the deprecated solar_rising entity is removed on setup."""
|
||||
config_entry = MockConfigEntry(domain=sun.DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
Platform.SENSOR,
|
||||
sun.DOMAIN,
|
||||
unique_id=f"{config_entry.entry_id}-solar_rising",
|
||||
config_entry=config_entry,
|
||||
)
|
||||
assert entity_registry.async_get_entity_id(
|
||||
Platform.SENSOR, sun.DOMAIN, f"{config_entry.entry_id}-solar_rising"
|
||||
)
|
||||
|
||||
now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC)
|
||||
with freeze_time(now):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not entity_registry.async_get_entity_id(
|
||||
Platform.SENSOR, sun.DOMAIN, f"{config_entry.entry_id}-solar_rising"
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ UNIQUE_ID = "abc-123"
|
||||
CONFIG_V1 = {CONF_ACCESS_TOKEN: "abc-123"}
|
||||
|
||||
WAKE_UP_ONLINE = {"response": {"state": TeslemetryState.ONLINE}, "error": None}
|
||||
WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None}
|
||||
|
||||
PRODUCTS = load_json_object_fixture("products.json", DOMAIN)
|
||||
PRODUCTS_MODERN = load_json_object_fixture("products.json", DOMAIN)
|
||||
|
||||
@@ -51,7 +51,6 @@ from .const import (
|
||||
UNIQUE_ID,
|
||||
VEHICLE_DATA,
|
||||
VEHICLE_DATA_ALT,
|
||||
VEHICLE_DATA_ASLEEP,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
@@ -194,23 +193,6 @@ async def test_vehicle_stream(
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
async def test_vehicle_asleep_polling(
|
||||
hass: HomeAssistant,
|
||||
mock_vehicle_data: AsyncMock,
|
||||
mock_legacy: AsyncMock,
|
||||
) -> None:
|
||||
"""Polling an offline/asleep vehicle loads and reports disconnected."""
|
||||
|
||||
mock_vehicle_data.return_value = VEHICLE_DATA_ASLEEP
|
||||
entry = await setup_platform(hass, [Platform.BINARY_SENSOR])
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get("binary_sensor.test_status")
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
async def test_no_live_status(
|
||||
hass: HomeAssistant,
|
||||
mock_live_status: AsyncMock,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Test Trace websocket API."""
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict, deque
|
||||
from collections import defaultdict
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
@@ -9,12 +9,9 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from pytest_unordered import unordered
|
||||
|
||||
from homeassistant.components.trace import ActionTrace
|
||||
from homeassistant.components.trace.const import DATA_TRACE, DEFAULT_STORED_TRACES
|
||||
from homeassistant.components.trace.const import DEFAULT_STORED_TRACES
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Context, CoreState, HomeAssistant, callback
|
||||
from homeassistant.helpers.json import ExtendedJSONEncoder
|
||||
from homeassistant.helpers.trace import TraceElement
|
||||
from homeassistant.helpers.typing import UNDEFINED
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.uuid import random_uuid_hex
|
||||
@@ -1663,83 +1660,3 @@ async def test_trace_blueprint_automation(
|
||||
assert trace["script_execution"] == "error"
|
||||
assert trace["item_id"] == "sun"
|
||||
assert trace.get("trigger", UNDEFINED) == "event 'blueprint_event'"
|
||||
|
||||
|
||||
class _DiagnosticActionTrace(ActionTrace):
|
||||
"""Automation-domain trace used to exercise not-triggered serialization."""
|
||||
|
||||
_domain = "automation"
|
||||
|
||||
|
||||
def _serialize_trace(not_triggered: bool, reason: str) -> dict[str, Any]:
|
||||
"""Build a trace serialized the way the trace Store persists it to disk."""
|
||||
trace = _DiagnosticActionTrace("diag", {"id": "diag"}, None, Context())
|
||||
trace.not_triggered = not_triggered
|
||||
element = TraceElement({"trigger": {"idx": "0"}}, "trigger/0")
|
||||
element.set_result(reason=reason)
|
||||
trace.set_trace({"trigger/0": deque([element])})
|
||||
trace.finished()
|
||||
return json.loads(json.dumps(trace.as_dict(), cls=ExtendedJSONEncoder))
|
||||
|
||||
|
||||
async def test_not_triggered_trace_serialization(
|
||||
hass: HomeAssistant,
|
||||
hass_storage: dict[str, Any],
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Not-triggered traces serialize with their flag and restore to their bucket."""
|
||||
run = _serialize_trace(False, "ran")
|
||||
not_triggered = _serialize_trace(True, "mock_reason")
|
||||
|
||||
# Serialization: only the not-triggered trace carries the flag, and its
|
||||
# diagnostics survive in the extended dict.
|
||||
assert "not_triggered" not in run["short_dict"]
|
||||
assert not_triggered["short_dict"]["not_triggered"] is True
|
||||
assert not_triggered["extended_dict"]["trace"]["trigger/0"][0]["result"] == {
|
||||
"reason": "mock_reason"
|
||||
}
|
||||
|
||||
hass_storage["trace.saved_traces"] = {
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "trace.saved_traces",
|
||||
"data": {"automation.diag": [run, not_triggered]},
|
||||
}
|
||||
assert await async_setup_component(hass, "trace", {})
|
||||
client = await hass_ws_client()
|
||||
|
||||
# Restore (lazily, on the first query) and confirm the flag round-trips.
|
||||
await client.send_json(
|
||||
{"id": 1, "type": "trace/list", "domain": "automation", "item_id": "diag"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert {
|
||||
trace["run_id"]: trace.get("not_triggered", False)
|
||||
for trace in response["result"]
|
||||
} == {
|
||||
run["short_dict"]["run_id"]: False,
|
||||
not_triggered["short_dict"]["run_id"]: True,
|
||||
}
|
||||
|
||||
# Restored traces are routed back into their separate buckets.
|
||||
trace_bucket = hass.data[DATA_TRACE]["automation.diag"]
|
||||
assert list(trace_bucket.runs) == [run["short_dict"]["run_id"]]
|
||||
assert list(trace_bucket.not_triggered) == [not_triggered["short_dict"]["run_id"]]
|
||||
|
||||
# The restored not-triggered trace keeps its diagnostics.
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "trace/get",
|
||||
"domain": "automation",
|
||||
"item_id": "diag",
|
||||
"run_id": not_triggered["short_dict"]["run_id"],
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"]["not_triggered"] is True
|
||||
assert response["result"]["trace"]["trigger/0"][0]["result"] == {
|
||||
"reason": "mock_reason"
|
||||
}
|
||||
|
||||
@@ -69,13 +69,11 @@ from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
|
||||
EntityTriggerBase,
|
||||
NotTriggeredInfo,
|
||||
PluggableAction,
|
||||
StatelessEntityTriggerBase,
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerConfig,
|
||||
TriggerNotTriggeredReporter,
|
||||
_async_get_trigger_platform,
|
||||
async_initialize_triggers,
|
||||
async_validate_trigger_config,
|
||||
@@ -158,9 +156,7 @@ class _MockTrigger(Trigger):
|
||||
self._options = config.options or {}
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to a bus event."""
|
||||
raw_template = self._options.get("option_template")
|
||||
@@ -813,9 +809,7 @@ async def test_platform_multiple_triggers(
|
||||
"""Mock trigger 1."""
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
run_action({"extra": "test_trigger_1"}, "trigger 1 desc")
|
||||
@@ -824,9 +818,7 @@ async def test_platform_multiple_triggers(
|
||||
"""Mock trigger 2."""
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
run_action({"extra": "test_trigger_2"}, "trigger 2 desc")
|
||||
@@ -976,9 +968,7 @@ async def test_get_trigger_platform_registers_triggers(
|
||||
"""Mock trigger."""
|
||||
|
||||
async def async_attach_runner(
|
||||
self,
|
||||
run_action: TriggerActionRunner,
|
||||
did_not_trigger: TriggerNotTriggeredReporter | None = None,
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
return lambda: None
|
||||
|
||||
@@ -3972,132 +3962,6 @@ async def _arm_off_to_on_trigger(
|
||||
)
|
||||
|
||||
|
||||
async def _arm_off_to_on_trigger_with_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
entity_ids: list[str],
|
||||
behavior: str,
|
||||
calls: list[dict[str, Any]],
|
||||
reports: list[tuple[dict[str, Any], NotTriggeredInfo]],
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Set up _OffToOnTrigger with both an action and a did_not_trigger reporter."""
|
||||
|
||||
async def async_get_triggers(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, type[Trigger]]:
|
||||
return {"off_to_on": _OffToOnTrigger}
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
|
||||
|
||||
trigger_config = {
|
||||
CONF_PLATFORM: "test.off_to_on",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: entity_ids},
|
||||
CONF_OPTIONS: {ATTR_BEHAVIOR: behavior},
|
||||
}
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@callback
|
||||
def action(run_variables: dict[str, Any], context: Context | None = None) -> None:
|
||||
calls.append(run_variables["trigger"])
|
||||
|
||||
@callback
|
||||
def did_not_trigger(
|
||||
run_variables: dict[str, Any],
|
||||
info: NotTriggeredInfo,
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
reports.append((run_variables["trigger"], info))
|
||||
|
||||
validated_config = await async_validate_trigger_config(hass, [trigger_config])
|
||||
return await async_initialize_triggers(
|
||||
hass,
|
||||
validated_config,
|
||||
action,
|
||||
domain="test",
|
||||
name="test_off_to_on",
|
||||
log_cb=log.log,
|
||||
did_not_trigger=did_not_trigger,
|
||||
)
|
||||
|
||||
|
||||
async def test_entity_trigger_reports_did_not_trigger(hass: HomeAssistant) -> None:
|
||||
"""An entity trigger reports diagnostics for changes that do not fire it."""
|
||||
entity_id = "test.entity"
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
reports: list[tuple[dict[str, Any], NotTriggeredInfo]] = []
|
||||
unsub = await _arm_off_to_on_trigger_with_diagnostics(
|
||||
hass, [entity_id], BEHAVIOR_EACH, calls, reports
|
||||
)
|
||||
|
||||
# A matching change fires the action and reports nothing.
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert reports == []
|
||||
|
||||
# An invalid transition (already on) does not fire: "transition_not_a_match".
|
||||
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert len(reports) == 1
|
||||
trigger_payload, info = reports[0]
|
||||
assert trigger_payload[CONF_PLATFORM] == "test.off_to_on"
|
||||
assert info.reason == "transition_not_a_match"
|
||||
assert info.data == {
|
||||
"entity_id": entity_id,
|
||||
"from_state": STATE_ON,
|
||||
"to_state": STATE_ON,
|
||||
}
|
||||
|
||||
# A non-target new state does not fire: "new_state_not_a_match".
|
||||
hass.states.async_set(entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert len(reports) == 2
|
||||
_trigger_payload, info = reports[1]
|
||||
assert info.reason == "new_state_not_a_match"
|
||||
assert info.data == {"entity_id": entity_id, "to_state": STATE_OFF}
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_reports_did_not_trigger_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Behavior 'all' reports when not every targeted entity matches."""
|
||||
entity_1 = "test.entity_1"
|
||||
entity_2 = "test.entity_2"
|
||||
hass.states.async_set(entity_1, STATE_OFF)
|
||||
hass.states.async_set(entity_2, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
reports: list[tuple[dict[str, Any], NotTriggeredInfo]] = []
|
||||
unsub = await _arm_off_to_on_trigger_with_diagnostics(
|
||||
hass, [entity_1, entity_2], BEHAVIOR_ALL, calls, reports
|
||||
)
|
||||
|
||||
# Only one of the two targets is on, so the trigger does not fire.
|
||||
hass.states.async_set(entity_1, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert calls == []
|
||||
assert len(reports) == 1
|
||||
_trigger_payload, info = reports[0]
|
||||
assert info.reason == "not_all_targets_matched"
|
||||
assert info.data == {"matches": 1, "included": 2}
|
||||
|
||||
# Now both are on: the trigger fires and reports nothing further.
|
||||
hass.states.async_set(entity_2, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert len(reports) == 1
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
def _set_or_remove_state(
|
||||
hass: HomeAssistant, entity_id: str, state: str | None
|
||||
) -> None:
|
||||
|
||||
@@ -679,12 +679,30 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None:
|
||||
" 2020.12, please create a bug report at https://github.com/home-assistant/"
|
||||
"core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial",
|
||||
False,
|
||||
"Detected that custom integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial>=3.5",
|
||||
True,
|
||||
"Detected that integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial-asyncio",
|
||||
False,
|
||||
"Detected that custom integration",
|
||||
"which should be replaced by pyserial-asyncio-fast. This will stop"
|
||||
" working in Home Assistant 2026.7, please create a bug report at "
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
@@ -692,8 +710,26 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None:
|
||||
"pyserial-asyncio>=0.6",
|
||||
True,
|
||||
"Detected that integration",
|
||||
"which should be replaced by pyserial-asyncio-fast. This will stop"
|
||||
" working in Home Assistant 2026.7, please create a bug report at "
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial-asyncio-fast",
|
||||
False,
|
||||
"Detected that custom integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
(
|
||||
"pyserial-asyncio-fast>=0.6",
|
||||
True,
|
||||
"Detected that integration",
|
||||
"which should be replaced by serialx. This will stop"
|
||||
" working in Home Assistant 2027.1, please create a bug report at "
|
||||
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
|
||||
"label%3A%22integration%3A+test_component%22",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user