124 files
This commit is contained in:
@@ -29,11 +29,13 @@ from ..const import (
|
||||
CONF_ADVANCED_SCHEDULE_TIME,
|
||||
CONF_ADVANCED_SEASONAL,
|
||||
CONF_ARCHIVE_ONEOFF_DAYS,
|
||||
CONF_BATTERY_LOW_PERCENT,
|
||||
CONF_BUDGET_ALERT_THRESHOLD,
|
||||
CONF_BUDGET_ALERTS_ENABLED,
|
||||
CONF_BUDGET_CURRENCY,
|
||||
CONF_BUDGET_MONTHLY,
|
||||
CONF_BUDGET_YEARLY,
|
||||
CONF_DEFAULT_CONSUMABLE_THRESHOLD,
|
||||
CONF_DEFAULT_WARNING_DAYS,
|
||||
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
CONF_DISABLED_TEMPLATE_IDS,
|
||||
@@ -59,16 +61,21 @@ from ..const import (
|
||||
CONF_QUIET_HOURS_END,
|
||||
CONF_QUIET_HOURS_START,
|
||||
CONF_REMINDER_LEAD_DAYS,
|
||||
CONF_ROW_ACTION_NOTICE,
|
||||
CONF_ROW_ACTION_STYLE,
|
||||
CONF_SHOPPING_LIST_ENTITY,
|
||||
CONF_SNOOZE_DURATION_HOURS,
|
||||
CONF_WARRANTY_REMINDER_DAYS,
|
||||
CONF_WARRANTY_REMINDER_ENABLED,
|
||||
CONF_WEEKLY_DIGEST_ENABLED,
|
||||
DEFAULT_ARCHIVE_ONEOFF_DAYS,
|
||||
DEFAULT_BATTERY_LOW_PERCENT,
|
||||
DEFAULT_BUDGET_CURRENCY,
|
||||
DEFAULT_CONSUMABLE_THRESHOLD,
|
||||
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
DEFAULT_OBJECTS_TABLE_COLUMNS,
|
||||
DEFAULT_PANEL_ENABLED,
|
||||
DEFAULT_ROW_ACTION_STYLE,
|
||||
DEFAULT_SNOOZE_DURATION_HOURS,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DEFAULT_WARRANTY_REMINDER_DAYS,
|
||||
@@ -105,7 +112,12 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_ALLOWED_SETTING_KEYS = ALLOWED_SETTING_KEYS
|
||||
|
||||
|
||||
def _build_full_settings(options: Mapping[str, Any], *, notify_targets: list[str] | None = None) -> dict[str, Any]:
|
||||
def _build_full_settings(
|
||||
options: Mapping[str, Any],
|
||||
*,
|
||||
notify_targets: list[str] | None = None,
|
||||
battery_notes: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a full settings dict from global entry options.
|
||||
|
||||
``notify_targets`` is the shared pickable-notify-target list (see
|
||||
@@ -148,6 +160,12 @@ def _build_full_settings(options: Mapping[str, Any], *, notify_targets: list[str
|
||||
},
|
||||
"general": {
|
||||
"default_warning_days": options.get(CONF_DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS),
|
||||
# #146: household "low" floors for discovery + the battery fleet.
|
||||
"default_consumable_threshold": options.get(CONF_DEFAULT_CONSUMABLE_THRESHOLD, DEFAULT_CONSUMABLE_THRESHOLD),
|
||||
"battery_low_percent": options.get(CONF_BATTERY_LOW_PERCENT, DEFAULT_BATTERY_LOW_PERCENT),
|
||||
# Computed, never stored: what Battery Notes currently reports
|
||||
# (default + up to 5 named override devices) — the Settings hint.
|
||||
"battery_notes": battery_notes,
|
||||
"notifications_enabled": options.get(CONF_NOTIFICATIONS_ENABLED, False),
|
||||
"notify_service": options.get(CONF_NOTIFY_SERVICE, ""),
|
||||
# v2.67: buy-task shopping sync target ("" = off).
|
||||
@@ -161,6 +179,11 @@ def _build_full_settings(options: Mapping[str, Any], *, notify_targets: list[str
|
||||
# <config>/custom_sentences/ (the only place the classic
|
||||
# conversation agent reads them from).
|
||||
"install_assist_sentences": options.get(CONF_INSTALL_ASSIST_SENTENCES, False),
|
||||
# #145: task-row action style + the one-time "new look" notice
|
||||
# (set by the 5→6 migration for existing installs, cleared by the
|
||||
# panel banner).
|
||||
"row_action_style": options.get(CONF_ROW_ACTION_STYLE, DEFAULT_ROW_ACTION_STYLE),
|
||||
"row_action_notice_pending": options.get(CONF_ROW_ACTION_NOTICE, False),
|
||||
},
|
||||
"notifications": {
|
||||
"due_soon_enabled": options.get(CONF_NOTIFY_DUE_SOON_ENABLED, True),
|
||||
@@ -229,11 +252,14 @@ async def ws_get_settings(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all global settings."""
|
||||
from ..helpers.battery_fleet import battery_notes_summary
|
||||
|
||||
bn = battery_notes_summary(hass)
|
||||
global_entry = _get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
_build_full_settings({}, notify_targets=build_notify_targets(hass)),
|
||||
_build_full_settings({}, notify_targets=build_notify_targets(hass), battery_notes=bn),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -243,6 +269,7 @@ async def ws_get_settings(
|
||||
_build_full_settings(
|
||||
options,
|
||||
notify_targets=build_notify_targets(hass, current=options.get(CONF_NOTIFY_SERVICE, "")),
|
||||
battery_notes=bn,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -621,6 +648,12 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
|
||||
if CONF_NOTIFICATION_TITLE_STYLE in filtered and filtered[CONF_NOTIFICATION_TITLE_STYLE] not in NOTIFICATION_TITLE_STYLES:
|
||||
del filtered[CONF_NOTIFICATION_TITLE_STYLE]
|
||||
|
||||
# #145: same treatment for the row-action style.
|
||||
from ..const import ROW_ACTION_STYLES
|
||||
|
||||
if CONF_ROW_ACTION_STYLE in filtered and filtered[CONF_ROW_ACTION_STYLE] not in ROW_ACTION_STYLES:
|
||||
del filtered[CONF_ROW_ACTION_STYLE]
|
||||
|
||||
# v1.4.6 (#44 follow-up): drop quiet-hours time strings that aren't valid
|
||||
# HH:MM[:SS]. The HA TimeSelector in the options-flow rejects empty / bad
|
||||
# strings as "Invalid time" and that error blocks the entire form save —
|
||||
|
||||
@@ -6,6 +6,7 @@ several sibling modules (objects, documents, io) + tests.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -187,6 +188,7 @@ def _validate_trigger_config(
|
||||
)
|
||||
|
||||
_validate_combinator(trigger_config, errors)
|
||||
_validate_trigger_values(trigger_type, trigger_config, errors)
|
||||
|
||||
# Runtime: validate trigger_on_states if provided
|
||||
if trigger_type == "runtime":
|
||||
@@ -226,6 +228,115 @@ def _validate_trigger_config(
|
||||
return errors, warnings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value-level checks (security review 2026-08-21): the key allowlist and the
|
||||
# per-type required fields never looked at the VALUES, so a write-tier client
|
||||
# could store ``trigger_for_minutes: "abc"`` or a negative target. Nothing
|
||||
# exploitable — the trigger classes fail closed — but the failure surfaced at
|
||||
# trigger setup, far from its cause. Ranges mirror the options-flow selectors.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Numeric fields that may hold any finite number (thresholds, counter targets,
|
||||
# baselines) — a numeric string is accepted and stored as a float.
|
||||
_NUMBER_FIELDS: tuple[str, ...] = (
|
||||
"trigger_above",
|
||||
"trigger_below",
|
||||
"trigger_equals",
|
||||
"trigger_not_equals",
|
||||
"trigger_target_value",
|
||||
"trigger_baseline_value",
|
||||
)
|
||||
# Whole-number fields with an inclusive range.
|
||||
_INT_FIELDS: dict[str, tuple[int, int]] = {
|
||||
"trigger_for_minutes": (0, 1440),
|
||||
"trigger_target_changes": (1, 10_000),
|
||||
}
|
||||
TRIGGER_RUNTIME_HOURS_MAX = 100_000
|
||||
# Optional fields where an explicit null means "unset" — dropped rather than
|
||||
# refused, so a client clearing a field never trips the validator.
|
||||
_OPTIONAL_VALUE_FIELDS: tuple[str, ...] = (
|
||||
"trigger_for_minutes",
|
||||
"trigger_target_changes",
|
||||
"trigger_baseline_value",
|
||||
"trigger_delta_mode",
|
||||
"trigger_runtime_hours",
|
||||
)
|
||||
|
||||
|
||||
def _coerce_number(value: Any) -> float | None:
|
||||
"""int/float/numeric-string → float; bools, non-numbers, NaN/inf → None."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
num = float(value)
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
num = float(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return num if math.isfinite(num) else None
|
||||
|
||||
|
||||
def _validate_trigger_values(trigger_type: str, trigger_config: dict[str, Any], errors: list[str]) -> None:
|
||||
"""Type/range-check the numeric trigger fields and normalise their types.
|
||||
|
||||
Numeric strings (an automation template, a YAML import) are accepted and
|
||||
stored as numbers; everything else is refused with the field named, so
|
||||
the panel and the service caller see the cause instead of a trigger that
|
||||
silently never fires.
|
||||
"""
|
||||
for key in _OPTIONAL_VALUE_FIELDS:
|
||||
if key in trigger_config and trigger_config[key] is None:
|
||||
del trigger_config[key]
|
||||
|
||||
for key in _NUMBER_FIELDS:
|
||||
raw = trigger_config.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
num = _coerce_number(raw)
|
||||
if num is None:
|
||||
errors.append(f"trigger_config.{key} must be a number, got {raw!r}")
|
||||
elif isinstance(raw, str):
|
||||
trigger_config[key] = num
|
||||
|
||||
for key, (low, high) in _INT_FIELDS.items():
|
||||
raw = trigger_config.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
num = _coerce_number(raw)
|
||||
if num is None or not num.is_integer() or not low <= num <= high:
|
||||
errors.append(f"trigger_config.{key} must be a whole number between {low} and {high}, got {raw!r}")
|
||||
else:
|
||||
trigger_config[key] = int(num)
|
||||
|
||||
raw = trigger_config.get("trigger_runtime_hours")
|
||||
if raw is not None:
|
||||
num = _coerce_number(raw)
|
||||
if num is None or not 0 < num <= TRIGGER_RUNTIME_HOURS_MAX:
|
||||
errors.append(
|
||||
f"trigger_config.trigger_runtime_hours must be a number greater than 0 "
|
||||
f"and at most {TRIGGER_RUNTIME_HOURS_MAX}, got {raw!r}"
|
||||
)
|
||||
elif isinstance(raw, str):
|
||||
trigger_config["trigger_runtime_hours"] = num
|
||||
|
||||
raw = trigger_config.get("trigger_delta_mode")
|
||||
if raw is not None and not isinstance(raw, bool):
|
||||
errors.append(f"trigger_config.trigger_delta_mode must be true or false, got {raw!r}")
|
||||
|
||||
# A delta counter fires every N units of accumulated use — N must be
|
||||
# positive, or the task would fire on every reading.
|
||||
if trigger_type == "counter" and trigger_config.get("trigger_delta_mode") is True:
|
||||
target = _coerce_number(trigger_config.get("trigger_target_value"))
|
||||
if target is not None and target <= 0:
|
||||
errors.append(
|
||||
"trigger_config.trigger_target_value must be greater than 0 when trigger_delta_mode is on, "
|
||||
f"got {trigger_config.get('trigger_target_value')!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_combinator(trigger_config: dict[str, Any], errors: list[str]) -> None:
|
||||
"""Validate the trigger ∧/∨ safety-interval combinator (any | all).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user