This commit is contained in:
Home Assistant Version Control committed 2026-08-18 14:36:46 +00:00
1 parent 13dc64b70f
commit 88e26358aa
89 files changed
+26068 -18351

No files matched your search

+119 -1
View File
@@ -53,6 +53,7 @@ from .const import (
CONF_ANTI_WRINKLE_ENABLED,
CONF_ANTI_WRINKLE_EXIT_POWER,
CONF_ANTI_WRINKLE_IDLE_TIMEOUT,
CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
CONF_ANTI_WRINKLE_MAX_DURATION,
CONF_ANTI_WRINKLE_MAX_POWER,
CONF_COMPLETION_MIN_SECONDS,
@@ -74,19 +75,24 @@ from .const import (
CONF_STOP_THRESHOLD_W,
CYCLE_OVERRUN_ANOMALY_RATIO,
CYCLE_UNDERRUN_ANOMALY_RATIO,
DEFAULT_DTW_BANDWIDTH,
DEFAULT_MATCH_PERSISTENCE,
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
DEFAULT_NOTIFY_MILESTONES,
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
MATCH_CORR_WEIGHT,
MATCH_DDTW_DIST_SCALE,
MATCH_DTW_BLEND,
MATCH_DTW_DIST_SCALE,
MATCH_DTW_ENSEMBLE_W,
MATCH_DTW_REFINE_TOP_N,
MATCH_DTW_RESAMPLE_N,
MATCH_DURATION_SCALE,
MATCH_DURATION_WEIGHT,
MATCH_ENERGY_SCALE,
MATCH_ENERGY_WEIGHT,
MATCH_KEEP_MIN_SCORE,
MATCH_MAE_PEAK_FLOOR,
MATCH_MAE_REF_PEAK,
MATCH_MAE_SCALE,
@@ -161,6 +167,7 @@ _OVERRIDE_FIELD_MAP: dict[str, tuple[str, Callable[[Any], Any]]] = {
CONF_ANTI_WRINKLE_MAX_DURATION: ("anti_wrinkle_max_duration", float),
CONF_ANTI_WRINKLE_EXIT_POWER: ("anti_wrinkle_exit_power", float),
CONF_ANTI_WRINKLE_IDLE_TIMEOUT: ("anti_wrinkle_idle_timeout", float),
CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE: ("dishwasher_end_spike_quiet_release", float),
CONF_OFF_DELAY: ("off_delay", int),
CONF_MIN_OFF_GAP: ("min_off_gap", int),
CONF_COMPLETION_MIN_SECONDS: ("completion_min_seconds", int),
@@ -200,6 +207,112 @@ _MATCH_OVERRIDE_KEYS: dict[str, tuple[str, Callable[[Any], Any]]] = {
}
# Canonical default for every matching override key, keyed by the OPTION key the
# Playground uses. The Stage 2-4 entries are code constants (not stored options),
# so this table is the only place the panel can read them from; ``ws_get_constants``
# ships it as ``pg_match_defaults`` and ``effective_settings`` falls back to it for
# any key the live matcher config does not carry.
MATCH_DEFAULTS_BY_OPTION: dict[str, Any] = {
CONF_PROFILE_MATCH_MIN_DURATION_RATIO: DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
CONF_PROFILE_MATCH_MAX_DURATION_RATIO: DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
"corr_weight": MATCH_CORR_WEIGHT,
"keep_min_score": MATCH_KEEP_MIN_SCORE,
"dtw_bandwidth": DEFAULT_DTW_BANDWIDTH,
"dtw_blend": MATCH_DTW_BLEND,
"dtw_ensemble_w": MATCH_DTW_ENSEMBLE_W,
"dtw_ddtw_scale": MATCH_DDTW_DIST_SCALE,
"dtw_refine_top_n": MATCH_DTW_REFINE_TOP_N,
"duration_weight": MATCH_DURATION_WEIGHT,
"energy_weight": MATCH_ENERGY_WEIGHT,
"duration_scale": MATCH_DURATION_SCALE,
"energy_scale": MATCH_ENERGY_SCALE,
}
# Every option key the Playground control panel may carry (detection + matching).
# This is the allow-list for a saved Playground preset: anything else submitted by
# a client is dropped rather than stored.
SETTING_KEYS: frozenset[str] = frozenset(_OVERRIDE_FIELD_MAP) | frozenset(_MATCH_OVERRIDE_KEYS)
# The subset a user may publish from the Playground back into the live config:
# exactly the override keys that are REAL config-entry options (``CONF_*``). The
# Stage 2-4 scoring knobs above are sandbox-only code constants - there is no
# option behind them, so writing them into ``entry.options`` would create dead
# keys the integration never reads. The panel gates its publish buttons on this
# list (shipped by ``get_playground_settings``).
PUBLISHABLE_SETTING_KEYS: frozenset[str] = frozenset(_OVERRIDE_FIELD_MAP) | {
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
}
def effective_settings(
base_config: CycleDetectorConfig, match_config: dict[str, Any] | None
) -> dict[str, Any]:
"""Option-keyed view of the values a simulation runs with when NO override is
staged - i.e. the device's live, fully-resolved settings.
The exact inverse of ``build_sim_config`` / ``apply_match_overrides``: it reads
back the same fields those two write, so the Playground control panel shows the
values the integration actually uses (device-type defaults included) instead of
a static schema default that may have drifted. Never raises.
"""
out: dict[str, Any] = {}
for opt_key, (field, coerce) in _OVERRIDE_FIELD_MAP.items():
value = getattr(base_config, field, None)
if value is None:
continue
try:
out[opt_key] = coerce(value)
except (TypeError, ValueError): # pragma: no cover - defensive
continue
cfg = match_config or {}
for opt_key, (cfg_key, coerce) in _MATCH_OVERRIDE_KEYS.items():
value = cfg.get(cfg_key, MATCH_DEFAULTS_BY_OPTION.get(opt_key))
if value is None:
continue
try:
out[opt_key] = coerce(value)
except (TypeError, ValueError): # pragma: no cover - defensive
continue
return out
def sanitize_setting_values(values: Any) -> dict[str, Any]:
"""Filter a client-supplied settings map down to storable Playground values.
Keeps only keys in :data:`SETTING_KEYS`, coerced with the same coercers the
simulation uses, so a preset can never carry an unknown key or a value that
would be silently ignored at replay time. Never raises.
"""
if not isinstance(values, dict):
return {}
out: dict[str, Any] = {}
for key, value in values.items():
if value is None:
continue
mapping = _OVERRIDE_FIELD_MAP.get(key) or _MATCH_OVERRIDE_KEYS.get(key)
if mapping is None:
continue
_target, coerce = mapping
try:
coerced = coerce(value)
except (TypeError, ValueError):
continue
if isinstance(coerced, float) and not math.isfinite(coerced):
continue
# Every Playground setting is a physical quantity - watts, seconds, a
# count, or a ratio - so a negative value is structurally meaningless and
# would make the replayed detector behave in ways the live one never can
# (e.g. an off_delay that expires before it starts). Rejected rather than
# clamped: silently rewriting a value the user typed would make the sim
# disagree with the control panel showing it back.
if isinstance(coerced, (int, float)) and not isinstance(coerced, bool):
if coerced < 0:
continue
out[key] = coerced
return out
def apply_match_overrides(
match_config: dict[str, Any], settings_override: dict[str, Any] | None
) -> dict[str, Any]:
@@ -207,7 +320,8 @@ def apply_match_overrides(
``settings_override`` overlaid onto the matcher-config keys they drive.
Unknown/None/malformed values are ignored, so a detection-only override leaves
matching byte-identical to the live config."""
if not settings_override:
settings_override = sanitize_setting_values(settings_override)
if not isinstance(settings_override, dict) or not settings_override:
return match_config
out = dict(match_config)
for opt_key, (cfg_key, coerce) in _MATCH_OVERRIDE_KEYS.items():
@@ -229,6 +343,7 @@ def build_sim_config(
Unknown keys and un-coercible values are ignored so a malformed override can
never break a simulation. ``base`` is left untouched.
"""
settings_override = sanitize_setting_values(settings_override)
if not isinstance(settings_override, dict) or not settings_override:
return base
changes: dict[str, Any] = {}
@@ -348,6 +463,8 @@ def _matching_config(store: Any) -> dict[str, Any]:
"min_duration_ratio": float(getattr(store, "_min_duration_ratio", 0.07)),
"max_duration_ratio": float(getattr(store, "_max_duration_ratio", 1.5)),
"dtw_bandwidth": float(getattr(store, "dtw_bandwidth", 0.2)),
# Mirror the live Stage-4 energy discriminator so the sim is byte-identical.
"energy_mode": str(getattr(store, "energy_mode", "mean")),
}
try:
overrides = store._matching_overrides() # pylint: disable=protected-access
@@ -1238,6 +1355,7 @@ def _sim_config_summary(config: CycleDetectorConfig) -> dict[str, Any]:
"anti_wrinkle_max_duration": getattr(config, "anti_wrinkle_max_duration", None),
"anti_wrinkle_exit_power": getattr(config, "anti_wrinkle_exit_power", None),
"anti_wrinkle_idle_timeout": getattr(config, "anti_wrinkle_idle_timeout", None),
"dishwasher_end_spike_quiet_release": getattr(config, "dishwasher_end_spike_quiet_release", None),
}