456 files
This commit is contained in:
@@ -28,6 +28,7 @@ from ..const import (
|
||||
from ..helpers.aggregate import get_object_entries, get_runtime_data, get_store, is_object_entry, object_name, task_entity_id
|
||||
from ..helpers.aggregate import object_slug as aggregate_object_slug
|
||||
from ..helpers.phases import current_phase_summary
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -608,7 +609,7 @@ def _parse_iso_date(
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if parsed > dt_util.now().date():
|
||||
connection.send_error(msg_id, "invalid_date", f"{field} must not be in the future")
|
||||
send_translated_error(connection, msg_id, "invalid_date", f"{field} must not be in the future", translation_key="date_in_future")
|
||||
return None
|
||||
return parsed
|
||||
|
||||
@@ -748,6 +749,8 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
ws_adopt_integration_setups,
|
||||
ws_discover_integration_setups,
|
||||
ws_preview_integration_setup,
|
||||
ws_reset_offers,
|
||||
ws_wire_resets,
|
||||
)
|
||||
from .io import (
|
||||
ws_batch_generate_qr,
|
||||
@@ -887,6 +890,8 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_battery_fleet_set_due_without_sensor)
|
||||
websocket_api.async_register_command(hass, ws_discover_integration_setups)
|
||||
websocket_api.async_register_command(hass, ws_preview_integration_setup)
|
||||
websocket_api.async_register_command(hass, ws_reset_offers)
|
||||
websocket_api.async_register_command(hass, ws_wire_resets)
|
||||
websocket_api.async_register_command(hass, ws_adopt_integration_setups)
|
||||
websocket_api.async_register_command(hass, ws_list_saved_views)
|
||||
websocket_api.async_register_command(hass, ws_save_saved_view)
|
||||
|
||||
@@ -37,6 +37,7 @@ from ..helpers.battery_fleet_setup import (
|
||||
set_track_self_charging,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/overview"})
|
||||
@@ -141,7 +142,7 @@ async def ws_battery_fleet_history(hass: HomeAssistant, connection: websocket_ap
|
||||
async def ws_battery_fleet_setup(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
|
||||
"""Create (or reconcile) the Battery Fleet object + type-parts + task."""
|
||||
if not has_batteries(hass):
|
||||
connection.send_error(msg["id"], "not_available", "No battery devices found")
|
||||
send_translated_error(connection, msg["id"], "not_available", "No battery devices found", translation_key="no_battery_devices")
|
||||
return
|
||||
result = await async_setup_battery_fleet(hass, language=msg.get("language"))
|
||||
connection.send_result(msg["id"], result)
|
||||
@@ -161,7 +162,7 @@ async def ws_battery_fleet_set_excluded(
|
||||
) -> None:
|
||||
"""Manually exclude a battery from the fleet (or take it back in) — #107."""
|
||||
if not set_battery_excluded(hass, msg["entity_id"], msg["excluded"]):
|
||||
connection.send_error(msg["id"], "not_configured", "Battery Fleet is not set up")
|
||||
send_translated_error(connection, msg["id"], "not_configured", "Battery Fleet is not set up", translation_key="fleet_not_set_up")
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
@@ -180,7 +181,7 @@ async def ws_battery_fleet_set_included(
|
||||
) -> None:
|
||||
"""Manually ADD a battery the discovery heuristics miss (#135)."""
|
||||
if not set_battery_included(hass, msg["entity_id"], msg["included"]):
|
||||
connection.send_error(msg["id"], "not_configured", "Battery Fleet is not set up")
|
||||
send_translated_error(connection, msg["id"], "not_configured", "Battery Fleet is not set up", translation_key="fleet_not_set_up")
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
@@ -198,7 +199,7 @@ async def ws_battery_fleet_set_track_self_charging(
|
||||
) -> None:
|
||||
"""Fleet-wide opt-in: keep self-charging devices in the roster (#135)."""
|
||||
if not set_track_self_charging(hass, msg["enabled"]):
|
||||
connection.send_error(msg["id"], "not_configured", "Battery Fleet is not set up")
|
||||
send_translated_error(connection, msg["id"], "not_configured", "Battery Fleet is not set up", translation_key="fleet_not_set_up")
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
@@ -216,7 +217,7 @@ async def ws_battery_fleet_set_due_without_sensor(
|
||||
) -> None:
|
||||
"""Fleet-wide option: a passed forecast on a sensorless note is due (D#162)."""
|
||||
if not set_due_without_sensor(hass, msg["enabled"]):
|
||||
connection.send_error(msg["id"], "not_configured", "Battery Fleet is not set up")
|
||||
send_translated_error(connection, msg["id"], "not_configured", "Battery Fleet is not set up", translation_key="fleet_not_set_up")
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
@@ -265,6 +266,6 @@ async def ws_battery_fleet_record_replacement(
|
||||
elif code == "invalid_date":
|
||||
connection.send_error(msg["id"], "invalid_date", "replaced_at is not an ISO datetime")
|
||||
else:
|
||||
connection.send_error(msg["id"], "not_available", "Battery Notes is not available")
|
||||
send_translated_error(connection, msg["id"], "not_available", "Battery Notes is not available", translation_key="battery_notes_unavailable")
|
||||
return
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
@@ -91,7 +91,7 @@ from ..const import (
|
||||
TIME_HHMMSS_PATTERN,
|
||||
)
|
||||
from ..helpers.aggregate import compute_status_counts
|
||||
from ..helpers.global_options import get_global_options
|
||||
from ..helpers.global_options import default_panel_title, get_global_options
|
||||
from ..helpers.notify_targets import build_notify_targets
|
||||
from ..helpers.region import is_region_setting
|
||||
from ..helpers.settings_registry import (
|
||||
@@ -192,6 +192,7 @@ def _build_full_settings(
|
||||
battery_notes: dict[str, Any] | None = None,
|
||||
battery_lifetimes: list[dict[str, Any]] | None = None,
|
||||
part_search_url_default: str = "",
|
||||
panel_title_default: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a full settings dict from global entry options.
|
||||
|
||||
@@ -271,6 +272,8 @@ def _build_full_settings(
|
||||
"notify_targets": notify_targets or [],
|
||||
"panel_enabled": _opt(options, CONF_PANEL_ENABLED),
|
||||
"panel_title": _opt(options, CONF_PANEL_TITLE),
|
||||
# What an empty title means — the default in the server's language.
|
||||
"panel_title_default": panel_title_default,
|
||||
# Opt-in copy of the shipped Assist sentences into
|
||||
# <config>/custom_sentences/ (the only place the classic
|
||||
# conversation agent reads them from).
|
||||
@@ -364,6 +367,7 @@ async def ws_get_settings(
|
||||
battery_notes=bn,
|
||||
battery_lifetimes=_battery_lifetime_catalog(hass),
|
||||
part_search_url_default=_search_url_default(hass),
|
||||
panel_title_default=default_panel_title(hass),
|
||||
),
|
||||
)
|
||||
return
|
||||
@@ -377,6 +381,7 @@ async def ws_get_settings(
|
||||
battery_notes=bn,
|
||||
battery_lifetimes=_battery_lifetime_catalog(hass),
|
||||
part_search_url_default=_search_url_default(hass),
|
||||
panel_title_default=default_panel_title(hass),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -965,6 +970,7 @@ async def ws_update_global_settings(
|
||||
notify_targets=build_notify_targets(hass, current=merged.get(CONF_NOTIFY_SERVICE, "")),
|
||||
battery_lifetimes=_battery_lifetime_catalog(hass),
|
||||
part_search_url_default=_search_url_default(hass),
|
||||
panel_title_default=default_panel_title(hass),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..const import (
|
||||
from ..helpers.aggregate import object_name
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.search_match import query_tokens, score_fields, snippet
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from . import ID_FIELD, _get_object_entries, _load_object_entry, object_id_for_entry
|
||||
from .tasks import _is_safe_url
|
||||
|
||||
@@ -132,7 +133,7 @@ async def ws_documents_add_link(
|
||||
# meaningless as a stored external reference (and _is_safe_url treats those
|
||||
# as "safe", so the explicit prefix check is required here).
|
||||
if not url.lower().startswith(("http://", "https://")) or not _is_safe_url(url):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
return
|
||||
|
||||
title = msg.get("title")
|
||||
|
||||
@@ -16,6 +16,7 @@ from ..const import (
|
||||
MAX_TEXT_LENGTH,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from . import ID_FIELD, _get_global_entry, _load_global_options, _save_global_options
|
||||
|
||||
|
||||
@@ -71,7 +72,7 @@ async def ws_create_group(
|
||||
|
||||
name = msg["name"].strip()
|
||||
if not name:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
send_translated_error(connection, msg["id"], "invalid_input", "Name must not be empty", translation_key="name_empty")
|
||||
return
|
||||
|
||||
group_id = uuid4().hex
|
||||
@@ -132,7 +133,7 @@ async def ws_update_group(
|
||||
# renamed the group to nothing (bug audit 2026-09-26).
|
||||
name = msg["name"].strip()
|
||||
if not name:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
send_translated_error(connection, msg["id"], "invalid_input", "Name must not be empty", translation_key="name_empty")
|
||||
return
|
||||
group["name"] = name
|
||||
if "description" in msg:
|
||||
|
||||
@@ -27,6 +27,8 @@ from ..helpers.integration_signatures import (
|
||||
discover_integration_setups,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.reset_wiring import apply_reset_action
|
||||
from ..helpers.task_origin import ORIGIN_KEY, integration_origin
|
||||
from . import ID_FIELD
|
||||
from .adopt_batch import AdoptBatch
|
||||
|
||||
@@ -123,77 +125,78 @@ async def ws_adopt_integration_setups(
|
||||
|
||||
batch = AdoptBatch(hass)
|
||||
|
||||
for sel in msg["selections"]:
|
||||
device_id = sel["device_id"]
|
||||
setup = setups.get(device_id)
|
||||
if setup is None:
|
||||
batch.errors.append({"device_id": device_id, "reason": "no suggestion for this device"})
|
||||
continue
|
||||
wanted = set(sel.get("task_names") or [t["task_name"] for t in setup["tasks"]])
|
||||
# Keyed by (task_name, direction): one integration can ship a task name
|
||||
# in two directions (LG ThinQ filter: hours vs percent), and the trigger
|
||||
# must be built from the signature matching the discovered direction.
|
||||
sig_by_key = {
|
||||
(s.task_name, s.direction): s for s in SIGNATURES[setup["integration"]].tasks
|
||||
}
|
||||
batch.begin()
|
||||
try:
|
||||
entry_id = sel.get("entry_id") or setup["suggested_entry_id"]
|
||||
if not entry_id:
|
||||
entry_id = await batch.create_object(
|
||||
name=sel.get("object_name") or setup["suggested_object_name"],
|
||||
ha_device_id=device_id,
|
||||
)
|
||||
|
||||
entry = batch.target_entry(entry_id)
|
||||
if entry is None:
|
||||
batch.errors.append({"device_id": device_id, "reason": "target object not found"})
|
||||
# Reload the touched objects even when a selection blew up mid-way —
|
||||
# their tasks are already stored.
|
||||
try:
|
||||
for sel in msg["selections"]:
|
||||
device_id = sel["device_id"]
|
||||
setup = setups.get(device_id)
|
||||
if setup is None:
|
||||
batch.errors.append({"device_id": device_id, "reason": "no suggestion for this device"})
|
||||
continue
|
||||
|
||||
# #105: adopting into a user-picked existing object that isn't
|
||||
# device-bound yet — bind it, so model/sibling gates work and
|
||||
# future discovery suggests this object instead of a new one.
|
||||
# (Objects reached via suggested_entry_id are bound by definition;
|
||||
# the guard makes this a no-op for them.)
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
if not obj_data.get("ha_device_id"):
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_OBJECT] = {**obj_data, "ha_device_id": device_id}
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
refreshed = hass.config_entries.async_get_entry(entry_id)
|
||||
if refreshed is not None:
|
||||
entry = refreshed
|
||||
|
||||
# Second dedup layer (discovery already hides these): never create
|
||||
# a task whose name — in any language — already exists on the
|
||||
# target object.
|
||||
from ..helpers.integration_signatures import proposal_name_variants
|
||||
|
||||
existing_names = {
|
||||
str(t.get("name", "")).lower()
|
||||
for t in entry.data.get(CONF_TASKS, {}).values()
|
||||
wanted = set(sel.get("task_names") or [t["task_name"] for t in setup["tasks"]])
|
||||
# Keyed by (task_name, direction): one integration can ship a task name
|
||||
# in two directions (LG ThinQ filter: hours vs percent), and the trigger
|
||||
# must be built from the signature matching the discovered direction.
|
||||
sig_by_key = {
|
||||
(s.task_name, s.direction): s for s in SIGNATURES[setup["integration"]].tasks
|
||||
}
|
||||
batch.begin()
|
||||
try:
|
||||
entry_id = sel.get("entry_id") or setup["suggested_entry_id"]
|
||||
if not entry_id:
|
||||
entry_id = await batch.create_object(
|
||||
name=sel.get("object_name") or setup["suggested_object_name"],
|
||||
ha_device_id=device_id,
|
||||
)
|
||||
|
||||
baselines = sel.get("baselines") or {}
|
||||
for task in setup["tasks"]:
|
||||
if task["task_name"] not in wanted:
|
||||
entry = batch.target_entry(entry_id)
|
||||
if entry is None:
|
||||
batch.errors.append({"device_id": device_id, "reason": "target object not found"})
|
||||
continue
|
||||
# Per-entity duties (colour cartridges) carry the entity label
|
||||
# in task_name; the catalog key and the label travel separately.
|
||||
catalog_name = task.get("catalog_task_name") or task["task_name"]
|
||||
if existing_names & proposal_name_variants(catalog_name, task.get("entity_label")):
|
||||
continue
|
||||
sig = sig_by_key[(catalog_name, task["direction"])]
|
||||
trigger = build_setup_trigger(sig, hass, task["entity_ids"])
|
||||
# #102: "last service was at reading X" — usage_delta only.
|
||||
# Other directions either already have absolute semantics
|
||||
# (usage_above's explicit 0) or no baseline concept at all.
|
||||
baseline = baselines.get(task["task_name"])
|
||||
if baseline is not None and sig.direction == "usage_delta":
|
||||
trigger["trigger_baseline_value"] = float(baseline)
|
||||
await batch.persist_task(
|
||||
entry,
|
||||
{
|
||||
|
||||
# #105: adopting into a user-picked existing object that isn't
|
||||
# device-bound yet — bind it, so model/sibling gates work and
|
||||
# future discovery suggests this object instead of a new one.
|
||||
# (Objects reached via suggested_entry_id are bound by definition;
|
||||
# the guard makes this a no-op for them.)
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
if not obj_data.get("ha_device_id"):
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_OBJECT] = {**obj_data, "ha_device_id": device_id}
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
refreshed = hass.config_entries.async_get_entry(entry_id)
|
||||
if refreshed is not None:
|
||||
entry = refreshed
|
||||
|
||||
# Second dedup layer (discovery already hides these): never create
|
||||
# a task whose name — in any language — already exists on the
|
||||
# target object.
|
||||
from ..helpers.integration_signatures import proposal_name_variants
|
||||
|
||||
existing_names = {
|
||||
str(t.get("name", "")).lower()
|
||||
for t in entry.data.get(CONF_TASKS, {}).values()
|
||||
}
|
||||
|
||||
baselines = sel.get("baselines") or {}
|
||||
for task in setup["tasks"]:
|
||||
if task["task_name"] not in wanted:
|
||||
continue
|
||||
# Per-entity duties (colour cartridges) carry the entity label
|
||||
# in task_name; the catalog key and the label travel separately.
|
||||
catalog_name = task.get("catalog_task_name") or task["task_name"]
|
||||
if existing_names & proposal_name_variants(catalog_name, task.get("entity_label")):
|
||||
continue
|
||||
sig = sig_by_key[(catalog_name, task["direction"])]
|
||||
trigger = build_setup_trigger(sig, hass, task["entity_ids"])
|
||||
# #102: "last service was at reading X" — usage_delta only.
|
||||
# Other directions either already have absolute semantics
|
||||
# (usage_above's explicit 0) or no baseline concept at all.
|
||||
baseline = baselines.get(task["task_name"])
|
||||
if baseline is not None and sig.direction == "usage_delta":
|
||||
trigger["trigger_baseline_value"] = float(baseline)
|
||||
task_data: dict[str, Any] = {
|
||||
"id": uuid4().hex,
|
||||
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
||||
"name": task.get("task_name_localized")
|
||||
@@ -203,12 +206,61 @@ async def ws_adopt_integration_setups(
|
||||
"enabled": True,
|
||||
"schedule": {"kind": "manual"},
|
||||
"trigger_config": trigger,
|
||||
},
|
||||
)
|
||||
except (ValueError, KeyError) as err:
|
||||
# Removes an object created for this device together with the
|
||||
# tasks already persisted into it — and un-counts both (the
|
||||
# tasks were over-reported before the DRY audit 2026-09-26).
|
||||
await batch.fail({"device_id": device_id, "reason": str(err)})
|
||||
|
||||
# 2.95: the fingerprint — recognised as this duty after a rename.
|
||||
ORIGIN_KEY: integration_origin(setup["integration"], sig.task_name, sig.direction, device_id, task.get("entity_label")),
|
||||
}
|
||||
# 2.95: completing the task also resets the integration's
|
||||
# own counter (its reset button, enabled if it shipped off).
|
||||
if task.get("reset"):
|
||||
apply_reset_action(hass, task_data, task["reset"]["entity_id"], connection.user.id if connection.user else None)
|
||||
await batch.persist_task(entry, task_data)
|
||||
# One duty through two sensors (a softener's salt in % and in
|
||||
# days) is one task — the second proposal of the name is
|
||||
# skipped instead of creating a twin.
|
||||
existing_names.add(str(task_data["name"]).lower())
|
||||
except (ValueError, KeyError) as err:
|
||||
# Removes an object created for this device together with the
|
||||
# tasks already persisted into it — and un-counts both (the
|
||||
# tasks were over-reported before the DRY audit 2026-09-26).
|
||||
await batch.fail({"device_id": device_id, "reason": str(err)})
|
||||
finally:
|
||||
await batch.finish()
|
||||
connection.send_result(msg["id"], batch.result())
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/integration_setups/reset_offers"})
|
||||
@websocket_api.async_response
|
||||
async def ws_reset_offers(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""2.95: existing tasks whose completion could also reset the
|
||||
integration's own counter (helpers/reset_wiring.reset_offers)."""
|
||||
from ..helpers.reset_wiring import reset_offers
|
||||
|
||||
connection.send_result(msg["id"], {"offers": reset_offers(hass)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/integration_setups/wire_resets",
|
||||
vol.Required("items"): vol.All(
|
||||
[{vol.Required("entry_id"): ID_FIELD, vol.Required("task_id"): ID_FIELD}],
|
||||
vol.Length(min=1, max=200),
|
||||
),
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_wire_resets(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""2.95: wire the chosen offers — the reset button becomes the task's
|
||||
completion action (enabled when the integration shipped it off)."""
|
||||
from ..helpers.reset_wiring import wire_resets
|
||||
|
||||
count = wire_resets(hass, msg["items"], connection.user.id if connection.user else None)
|
||||
connection.send_result(msg["id"], {"wired": count})
|
||||
|
||||
@@ -44,6 +44,7 @@ from ..helpers.qr_generator import (
|
||||
generate_qr_svg,
|
||||
generate_qr_svg_data_uri,
|
||||
)
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from ..websocket.tasks import _check_nfc_tag_duplicate, _validate_trigger_config
|
||||
from . import ID_FIELD, _get_object_entries, _load_object_entry, _load_object_task, _merge_global_options
|
||||
|
||||
@@ -529,7 +530,7 @@ async def ws_get_templates(
|
||||
|
||||
result = {
|
||||
"categories": {cat_id: {k: v for k, v in cat.items()} for cat_id, cat in TEMPLATE_CATEGORIES.items()},
|
||||
"profile": profile.as_dict(),
|
||||
"profile": profile.as_dict(lang),
|
||||
"templates": [
|
||||
{
|
||||
"id": t.id,
|
||||
@@ -652,16 +653,16 @@ async def ws_import_csv(
|
||||
csv_content = msg["csv_content"]
|
||||
# Guard against oversized payloads (max 1MB / 1000 objects)
|
||||
if len(csv_content) > MAX_IMPORT_PAYLOAD_BYTES:
|
||||
connection.send_error(msg["id"], "too_large", "CSV content exceeds 1MB limit")
|
||||
send_translated_error(connection, msg["id"], "too_large", "CSV content exceeds 1MB limit", translation_key="import_too_large", translation_placeholders={"limit": "1 MB"})
|
||||
return
|
||||
|
||||
objects = import_objects_csv(csv_content, hass=hass)
|
||||
if len(objects) > 1000:
|
||||
connection.send_error(msg["id"], "too_many", "CSV contains more than 1000 objects")
|
||||
send_translated_error(connection, msg["id"], "too_many", "CSV contains more than 1000 objects", translation_key="import_too_many", translation_placeholders={"max": "1000"})
|
||||
return
|
||||
|
||||
if not objects:
|
||||
connection.send_error(msg["id"], "empty_csv", "No valid objects found in CSV")
|
||||
send_translated_error(connection, msg["id"], "empty_csv", "No valid objects found in CSV", translation_key="import_csv_no_objects")
|
||||
return
|
||||
|
||||
created = []
|
||||
@@ -906,18 +907,18 @@ async def ws_import_json(
|
||||
|
||||
raw = msg["json_content"]
|
||||
if len(raw) > MAX_JSON_IMPORT_PAYLOAD_BYTES:
|
||||
connection.send_error(msg["id"], "too_large", "Content exceeds 10MB limit")
|
||||
send_translated_error(connection, msg["id"], "too_large", "Content exceeds 10MB limit", translation_key="import_too_large", translation_placeholders={"limit": "10 MB"})
|
||||
return
|
||||
|
||||
try:
|
||||
data = _parse_structured(raw)
|
||||
except ValueError:
|
||||
connection.send_error(msg["id"], "invalid_format", "Content is not valid JSON or YAML")
|
||||
send_translated_error(connection, msg["id"], "invalid_format", "Content is not valid JSON or YAML", translation_key="import_not_json_yaml")
|
||||
return
|
||||
|
||||
has_settings = isinstance(data, dict) and isinstance(data.get("global_settings"), dict)
|
||||
if not isinstance(data, dict) or ("objects" not in data and not has_settings):
|
||||
connection.send_error(msg["id"], "invalid_format", "JSON must contain an 'objects' array")
|
||||
send_translated_error(connection, msg["id"], "invalid_format", "JSON must contain an 'objects' array", translation_key="import_no_objects_list")
|
||||
return
|
||||
|
||||
# A settings export (see export.build_settings_export) may travel alone or
|
||||
@@ -928,15 +929,15 @@ async def ws_import_json(
|
||||
|
||||
objects = data.get("objects", [])
|
||||
if not isinstance(objects, list):
|
||||
connection.send_error(msg["id"], "invalid_format", "'objects' must be an array")
|
||||
send_translated_error(connection, msg["id"], "invalid_format", "'objects' must be an array", translation_key="import_no_objects_list")
|
||||
return
|
||||
|
||||
if len(objects) > 1000:
|
||||
connection.send_error(msg["id"], "too_many", "JSON contains more than 1000 objects")
|
||||
send_translated_error(connection, msg["id"], "too_many", "JSON contains more than 1000 objects", translation_key="import_too_many", translation_placeholders={"max": "1000"})
|
||||
return
|
||||
|
||||
if not objects and not settings_applied:
|
||||
connection.send_error(msg["id"], "empty", "No objects found in JSON")
|
||||
send_translated_error(connection, msg["id"], "empty", "No objects found in JSON", translation_key="import_no_objects")
|
||||
return
|
||||
|
||||
created = []
|
||||
@@ -1136,6 +1137,13 @@ async def ws_import_json(
|
||||
task_data[BATTERY_FLEET_TASK_FLAG] = True
|
||||
fleet_task_seen = True
|
||||
|
||||
# 2.95: the task's fingerprint (catalog duty / template task) —
|
||||
# shape-checked, it is untrusted input like everything imported.
|
||||
from ..helpers.task_origin import ORIGIN_KEY, sanitize_origin
|
||||
|
||||
if (origin := sanitize_origin(task_entry.get(ORIGIN_KEY))) is not None:
|
||||
task_data[ORIGIN_KEY] = origin
|
||||
|
||||
# In-cycle checklist ticks: keyed by item TEXT so they survive the
|
||||
# id regeneration; keys are filtered against the imported checklist
|
||||
# exactly like the live checklist_progress WS write. Rides
|
||||
@@ -1441,7 +1449,7 @@ async def ws_generate_qr(
|
||||
url_mode=url_mode,
|
||||
)
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], "no_url", str(err))
|
||||
send_translated_error(connection, msg["id"], "no_url", str(err), translation_key="qr_no_url")
|
||||
return
|
||||
from functools import partial
|
||||
|
||||
@@ -1526,17 +1534,21 @@ async def ws_batch_generate_qr(
|
||||
# Build the flat (entry_id, object_name, task_id, task_name) target list,
|
||||
# honouring the optional task_ids filter.
|
||||
task_filter = set(msg["task_ids"]) if msg.get("task_ids") else None
|
||||
targets: list[tuple[str, str, str, str]] = []
|
||||
targets: list[tuple[str, str, str, str, bool]] = []
|
||||
for entry in entries:
|
||||
obj_name = object_name(entry)
|
||||
tasks_data = entry.data.get(CONF_TASKS, {})
|
||||
for task_id, task_data in tasks_data.items():
|
||||
if task_filter is not None and task_id not in task_filter:
|
||||
continue
|
||||
targets.append((entry.entry_id, obj_name, task_id, task_data.get("name", "")))
|
||||
has_quick = bool(task_data.get("quick_complete_defaults"))
|
||||
targets.append((entry.entry_id, obj_name, task_id, task_data.get("name", ""), has_quick))
|
||||
|
||||
actions: list[str] = msg["actions"]
|
||||
total = len(targets) * len(actions)
|
||||
# A quick-complete code only for tasks with quick-complete defaults — for
|
||||
# the others it would just open the complete dialog (#192).
|
||||
pairs = [(target, action) for target in targets for action in actions if action != "quick_complete" or target[4]]
|
||||
total = len(pairs)
|
||||
if total == 0:
|
||||
connection.send_result(msg["id"], {"qrs": [], "total": 0})
|
||||
return
|
||||
@@ -1556,33 +1568,32 @@ async def ws_batch_generate_qr(
|
||||
# since it's CPU-bound (~30-40 ms/QR). Each SVG passes through the LRU
|
||||
# cache so re-runs after a filter change are near-instant.
|
||||
results: list[dict[str, Any]] = []
|
||||
for entry_id, obj_name, task_id, task_name in targets:
|
||||
for action in actions:
|
||||
try:
|
||||
url = build_qr_url(
|
||||
hass,
|
||||
entry_id,
|
||||
task_id=task_id,
|
||||
action=action,
|
||||
base_url_override=base_url,
|
||||
url_mode=url_mode,
|
||||
)
|
||||
except ValueError:
|
||||
# No HA URL configured — skip this row rather than fail the
|
||||
# whole batch. "server" mode is the only path that raises;
|
||||
# "companion" and "local" always resolve.
|
||||
continue
|
||||
icon = _ACTION_ICON_MAP.get(action) # None for "skip" (no icon)
|
||||
svg = await hass.async_add_executor_job(_cached_qr_svg, url, icon)
|
||||
results.append(
|
||||
{
|
||||
"entry_id": entry_id,
|
||||
"task_id": task_id,
|
||||
"object_name": obj_name,
|
||||
"task_name": task_name,
|
||||
"action": action,
|
||||
"svg": svg,
|
||||
}
|
||||
for (entry_id, obj_name, task_id, task_name, _has_quick), action in pairs:
|
||||
try:
|
||||
url = build_qr_url(
|
||||
hass,
|
||||
entry_id,
|
||||
task_id=task_id,
|
||||
action=action,
|
||||
base_url_override=base_url,
|
||||
url_mode=url_mode,
|
||||
)
|
||||
except ValueError:
|
||||
# No HA URL configured — skip this row rather than fail the
|
||||
# whole batch. "server" mode is the only path that raises;
|
||||
# "companion" and "local" always resolve.
|
||||
continue
|
||||
icon = _ACTION_ICON_MAP.get(action) # None for "skip" (no icon)
|
||||
svg = await hass.async_add_executor_job(_cached_qr_svg, url, icon)
|
||||
results.append(
|
||||
{
|
||||
"entry_id": entry_id,
|
||||
"task_id": task_id,
|
||||
"object_name": obj_name,
|
||||
"task_name": task_name,
|
||||
"action": action,
|
||||
"svg": svg,
|
||||
}
|
||||
)
|
||||
|
||||
connection.send_result(msg["id"], {"qrs": results, "total": len(results)})
|
||||
|
||||
@@ -35,6 +35,7 @@ from ..helpers.aggregate import get_coordinator_data, get_store, is_object_entry
|
||||
from ..helpers.pause import reanchor_recurring_task
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.sanitize import cap_object_fields, strip_object_reference, strip_task_runtime_state
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from . import (
|
||||
ID_FIELD,
|
||||
_build_object_response,
|
||||
@@ -309,7 +310,7 @@ async def ws_create_object(
|
||||
"""Create a new maintenance object via config flow."""
|
||||
name = msg["name"].strip()
|
||||
if not name:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
send_translated_error(connection, msg["id"], "invalid_input", "Name must not be empty", translation_key="name_empty")
|
||||
return
|
||||
|
||||
manufacturer = (msg.get("manufacturer") or "").strip() or None
|
||||
@@ -325,7 +326,7 @@ async def ws_create_object(
|
||||
# v1.4.0 (#43): documentation_url
|
||||
documentation_url = (msg.get("documentation_url") or "").strip() or None
|
||||
if documentation_url and not _is_safe_url(documentation_url):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
return
|
||||
|
||||
# v1.4.10 (#46): notes (free-form, may contain newlines)
|
||||
@@ -386,7 +387,7 @@ async def ws_update_object(
|
||||
if "name" in msg:
|
||||
msg["name"] = msg["name"].strip()
|
||||
if not msg["name"]:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
send_translated_error(connection, msg["id"], "invalid_input", "Name must not be empty", translation_key="name_empty")
|
||||
return
|
||||
# The same name rule as every create path (helpers.object_names): a
|
||||
# rename onto another object's name went through, and the next
|
||||
@@ -397,7 +398,7 @@ async def ws_update_object(
|
||||
from ..helpers.object_names import name_changed_onto_taken
|
||||
|
||||
if name_changed_onto_taken(hass, entry, msg["name"]):
|
||||
connection.send_error(msg["id"], "invalid_input", "Another object already has this name")
|
||||
send_translated_error(connection, msg["id"], "invalid_input", "Another object already has this name", translation_key="object_name_taken")
|
||||
return
|
||||
|
||||
# Strip manufacturer/model/serial_number
|
||||
@@ -418,7 +419,7 @@ async def ws_update_object(
|
||||
stripped = (msg["documentation_url"] or "").strip()
|
||||
msg["documentation_url"] = stripped or None
|
||||
if msg["documentation_url"] and not _is_safe_url(msg["documentation_url"]):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
return
|
||||
|
||||
# v1.4.10 (#46): notes — strip but keep newlines, empty -> None
|
||||
@@ -534,7 +535,7 @@ async def ws_duplicate_object(
|
||||
from ..const import BATTERY_FLEET_OBJECT_FLAG
|
||||
|
||||
if src_obj.get(BATTERY_FLEET_OBJECT_FLAG):
|
||||
connection.send_error(msg["id"], "invalid_input", "The battery fleet object cannot be duplicated")
|
||||
send_translated_error(connection, msg["id"], "invalid_input", "The battery fleet object cannot be duplicated", translation_key="fleet_not_duplicable")
|
||||
return
|
||||
new_obj = deepcopy(dict(src_obj))
|
||||
new_obj["id"] = uuid4().hex
|
||||
@@ -824,7 +825,7 @@ async def ws_pause_object(
|
||||
|
||||
obj = dict(entry.data.get(CONF_OBJECT, {}))
|
||||
if obj.get("archived_at") is not None:
|
||||
connection.send_error(msg["id"], "archived", "An archived object cannot be paused")
|
||||
send_translated_error(connection, msg["id"], "archived", "An archived object cannot be paused", translation_key="archived_cannot_pause")
|
||||
return
|
||||
if obj.get("paused_at") is not None:
|
||||
connection.send_error(msg["id"], "already_paused", "Object already paused")
|
||||
@@ -836,7 +837,7 @@ async def ws_pause_object(
|
||||
if until_date is None:
|
||||
return
|
||||
if until_date <= dt_util.now().date():
|
||||
connection.send_error(msg["id"], "invalid_date", "until must be a future date")
|
||||
send_translated_error(connection, msg["id"], "invalid_date", "until must be a future date", translation_key="until_in_past")
|
||||
return
|
||||
|
||||
now_iso = dt_util.now().isoformat()
|
||||
@@ -928,7 +929,7 @@ async def ws_replace_object(
|
||||
|
||||
src_obj = entry.data.get(CONF_OBJECT, {})
|
||||
if src_obj.get("archived_at") is not None:
|
||||
connection.send_error(msg["id"], "archived", "An archived object cannot be replaced")
|
||||
send_translated_error(connection, msg["id"], "archived", "An archived object cannot be replaced", translation_key="archived_cannot_replace")
|
||||
return
|
||||
|
||||
name = (msg.get("name") or "").strip() or str(src_obj.get(CONF_OBJECT_NAME, "")).strip() or "Object"
|
||||
|
||||
@@ -18,6 +18,7 @@ from ..helpers.parts import (
|
||||
normalize_part,
|
||||
)
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from . import ID_FIELD, _get_runtime_data, _load_object_entry
|
||||
|
||||
|
||||
@@ -72,7 +73,7 @@ async def ws_create_part(
|
||||
return
|
||||
parts = _parts_of(entry)
|
||||
if len(parts) >= MAX_PARTS_PER_OBJECT:
|
||||
connection.send_error(msg["id"], "limit_reached", f"At most {MAX_PARTS_PER_OBJECT} parts per object")
|
||||
send_translated_error(connection, msg["id"], "limit_reached", f"At most {MAX_PARTS_PER_OBJECT} parts per object", translation_key="parts_limit", translation_placeholders={"max": str(MAX_PARTS_PER_OBJECT)})
|
||||
return
|
||||
try:
|
||||
# msg["id"] is the WS envelope's message id — NEVER the part id; force
|
||||
|
||||
@@ -141,98 +141,106 @@ async def ws_adopt_problem_sensors(
|
||||
# dialog lets the user say which sensors belong together.
|
||||
name_to_entry: dict[str, str] = {}
|
||||
|
||||
for sel in msg["selections"]:
|
||||
entity_id = sel["entity_id"]
|
||||
entry_id = sel.get("entry_id")
|
||||
device_id = sel.get("device_id")
|
||||
group_name = str(sel.get("object_name") or "").strip().casefold()
|
||||
batch.begin()
|
||||
try:
|
||||
if not entry_id and device_id and device_id in device_to_entry:
|
||||
entry_id = device_to_entry[device_id]
|
||||
if not entry_id and group_name and group_name in name_to_entry:
|
||||
entry_id = name_to_entry[group_name]
|
||||
if not entry_id:
|
||||
entry_id = await batch.create_object(
|
||||
name=sel.get("object_name") or sel["name"],
|
||||
ha_device_id=device_id or None,
|
||||
# Reload the touched objects even when a selection blew up mid-way —
|
||||
# their tasks are already stored.
|
||||
try:
|
||||
for sel in msg["selections"]:
|
||||
entity_id = sel["entity_id"]
|
||||
entry_id = sel.get("entry_id")
|
||||
device_id = sel.get("device_id")
|
||||
group_name = str(sel.get("object_name") or "").strip().casefold()
|
||||
batch.begin()
|
||||
try:
|
||||
if not entry_id and device_id and device_id in device_to_entry:
|
||||
entry_id = device_to_entry[device_id]
|
||||
if not entry_id and group_name and group_name in name_to_entry:
|
||||
entry_id = name_to_entry[group_name]
|
||||
if not entry_id:
|
||||
entry_id = await batch.create_object(
|
||||
name=sel.get("object_name") or sel["name"],
|
||||
ha_device_id=device_id or None,
|
||||
)
|
||||
if device_id:
|
||||
device_to_entry[device_id] = entry_id
|
||||
if group_name:
|
||||
name_to_entry[group_name] = entry_id
|
||||
|
||||
entry = batch.target_entry(entry_id)
|
||||
if entry is None:
|
||||
batch.errors.append({"entity_id": entity_id, "reason": "target object not found"})
|
||||
continue
|
||||
# 2.94: the sensor goes to THE object its device most likely is
|
||||
# (the dialog's default) and that object has no device yet —
|
||||
# link it, so the next discovery finds it by device, as the
|
||||
# suggested setups do. Any other pick stays unlinked.
|
||||
if device_id and not (entry.data.get(CONF_OBJECT) or {}).get("ha_device_id"):
|
||||
from ..helpers.adopt_match import candidate_object
|
||||
|
||||
match = candidate_object(hass, device_id)
|
||||
if match is not None and match["entry_id"] == entry.entry_id:
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_OBJECT] = {**new_data.get(CONF_OBJECT, {}), "ha_device_id": device_id}
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
entry = hass.config_entries.async_get_entry(entry.entry_id) or entry
|
||||
|
||||
task = build_problem_task(entity_id, sel["name"], for_minutes=sel.get("for_minutes") or 0)
|
||||
task_data = {
|
||||
"id": uuid4().hex,
|
||||
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
||||
"name": task["name"],
|
||||
"type": task["task_type"],
|
||||
"enabled": True,
|
||||
"schedule": task["schedule"],
|
||||
"trigger_config": task["trigger_config"],
|
||||
}
|
||||
# Un-adopt → re-adopt: restore (and consume) the notes and one-time
|
||||
# setup the deleted predecessor task had accumulated for this
|
||||
# sensor. Restored part links are re-validated below alongside the
|
||||
# dialog's suggestion (the target object/parts may have changed).
|
||||
from ..const import CONF_PARTS
|
||||
from ..helpers.parts import sanitize_consumes_parts
|
||||
|
||||
stashed = pop_stashed_config(hass, entity_id) or {}
|
||||
for field in ("notes", "responsible_user_id", "priority", "labels"):
|
||||
if stashed.get(field):
|
||||
task_data[field] = stashed[field]
|
||||
# An explicit dialog pick wins over the stashed responsible user.
|
||||
if sel.get("responsible_user_id"):
|
||||
task_data["responsible_user_id"] = sel["responsible_user_id"]
|
||||
# Link the suggested spare part — or, absent one, the stashed link —
|
||||
# validated against the target object's parts (an unknown id is
|
||||
# silently dropped, same as the task-CRUD path).
|
||||
raw_links = (
|
||||
[{"part_id": sel["part_id"], "quantity": 1}] if sel.get("part_id") else stashed.get("consumes_parts") or []
|
||||
)
|
||||
if device_id:
|
||||
device_to_entry[device_id] = entry_id
|
||||
if group_name:
|
||||
name_to_entry[group_name] = entry_id
|
||||
if raw_links:
|
||||
# foreign_part_ids keeps pooled #111 cross-object links — the
|
||||
# CRUD paths pass it, this copy had forgotten it (a re-adopted
|
||||
# task silently lost its pooled part link).
|
||||
from . import foreign_part_resolver
|
||||
|
||||
entry = batch.target_entry(entry_id)
|
||||
if entry is None:
|
||||
batch.errors.append({"entity_id": entity_id, "reason": "target object not found"})
|
||||
continue
|
||||
# 2.94: the sensor goes to THE object its device most likely is
|
||||
# (the dialog's default) and that object has no device yet —
|
||||
# link it, so the next discovery finds it by device, as the
|
||||
# suggested setups do. Any other pick stays unlinked.
|
||||
if device_id and not (entry.data.get(CONF_OBJECT) or {}).get("ha_device_id"):
|
||||
from ..helpers.adopt_match import candidate_object
|
||||
|
||||
match = candidate_object(hass, device_id)
|
||||
if match is not None and match["entry_id"] == entry.entry_id:
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_OBJECT] = {**new_data.get(CONF_OBJECT, {}), "ha_device_id": device_id}
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
entry = hass.config_entries.async_get_entry(entry.entry_id) or entry
|
||||
|
||||
task = build_problem_task(entity_id, sel["name"], for_minutes=sel.get("for_minutes") or 0)
|
||||
task_data = {
|
||||
"id": uuid4().hex,
|
||||
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
||||
"name": task["name"],
|
||||
"type": task["task_type"],
|
||||
"enabled": True,
|
||||
"schedule": task["schedule"],
|
||||
"trigger_config": task["trigger_config"],
|
||||
}
|
||||
# Un-adopt → re-adopt: restore (and consume) the notes and one-time
|
||||
# setup the deleted predecessor task had accumulated for this
|
||||
# sensor. Restored part links are re-validated below alongside the
|
||||
# dialog's suggestion (the target object/parts may have changed).
|
||||
from ..const import CONF_PARTS
|
||||
from ..helpers.parts import sanitize_consumes_parts
|
||||
|
||||
stashed = pop_stashed_config(hass, entity_id) or {}
|
||||
for field in ("notes", "responsible_user_id", "priority", "labels"):
|
||||
if stashed.get(field):
|
||||
task_data[field] = stashed[field]
|
||||
# An explicit dialog pick wins over the stashed responsible user.
|
||||
if sel.get("responsible_user_id"):
|
||||
task_data["responsible_user_id"] = sel["responsible_user_id"]
|
||||
# Link the suggested spare part — or, absent one, the stashed link —
|
||||
# validated against the target object's parts (an unknown id is
|
||||
# silently dropped, same as the task-CRUD path).
|
||||
raw_links = (
|
||||
[{"part_id": sel["part_id"], "quantity": 1}] if sel.get("part_id") else stashed.get("consumes_parts") or []
|
||||
)
|
||||
if raw_links:
|
||||
# foreign_part_ids keeps pooled #111 cross-object links — the
|
||||
# CRUD paths pass it, this copy had forgotten it (a re-adopted
|
||||
# task silently lost its pooled part link).
|
||||
from . import foreign_part_resolver
|
||||
|
||||
links = sanitize_consumes_parts(
|
||||
raw_links,
|
||||
set(entry.data.get(CONF_PARTS) or {}),
|
||||
foreign_part_ids=foreign_part_resolver(hass),
|
||||
)
|
||||
if links:
|
||||
task_data["consumes_parts"] = links
|
||||
await batch.persist_task(entry, task_data)
|
||||
created.append({"entry_id": entry_id, "task_id": task_data["id"], "name": task_data["name"]})
|
||||
except (ValueError, KeyError) as err:
|
||||
# Roll back an object created in THIS iteration whose task failed —
|
||||
# never leave an empty, task-less orphan object behind (and undo the
|
||||
# device/name reuse pointers so a later selection re-creates it).
|
||||
if await batch.fail({"entity_id": entity_id, "reason": str(err)}):
|
||||
if device_id:
|
||||
device_to_entry.pop(device_id, None)
|
||||
if group_name:
|
||||
name_to_entry.pop(group_name, None)
|
||||
links = sanitize_consumes_parts(
|
||||
raw_links,
|
||||
set(entry.data.get(CONF_PARTS) or {}),
|
||||
foreign_part_ids=foreign_part_resolver(hass),
|
||||
)
|
||||
if links:
|
||||
task_data["consumes_parts"] = links
|
||||
# 2.95: the fingerprint — the sensor this task was adopted from.
|
||||
from ..helpers.task_origin import ORIGIN_KEY, problem_sensor_origin
|
||||
|
||||
task_data[ORIGIN_KEY] = problem_sensor_origin(entity_id)
|
||||
await batch.persist_task(entry, task_data)
|
||||
created.append({"entry_id": entry_id, "task_id": task_data["id"], "name": task_data["name"]})
|
||||
except (ValueError, KeyError) as err:
|
||||
# Roll back an object created in THIS iteration whose task failed —
|
||||
# never leave an empty, task-less orphan object behind (and undo the
|
||||
# device/name reuse pointers so a later selection re-creates it).
|
||||
if await batch.fail({"entity_id": entity_id, "reason": str(err)}):
|
||||
if device_id:
|
||||
device_to_entry.pop(device_id, None)
|
||||
if group_name:
|
||||
name_to_entry.pop(group_name, None)
|
||||
finally:
|
||||
await batch.finish()
|
||||
connection.send_result(msg["id"], batch.result(created=created))
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant
|
||||
from ..const import CONF_SAVED_FILTER_VIEWS, DOMAIN, MAX_ID_LENGTH, MAX_VIEW_NAME_LENGTH
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.saved_views import list_saved_views, remove_view, sanitize_view, upsert_view
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from . import ID_FIELD, _get_global_entry, _merge_global_options
|
||||
|
||||
|
||||
@@ -62,7 +63,7 @@ async def ws_save_saved_view(
|
||||
view_id=msg.get("view_id"),
|
||||
)
|
||||
if clean is None:
|
||||
connection.send_error(msg["id"], "invalid_view", "A view needs a non-empty name")
|
||||
send_translated_error(connection, msg["id"], "invalid_view", "A view needs a non-empty name", translation_key="view_name_empty")
|
||||
return
|
||||
try:
|
||||
views, saved_id = upsert_view(list_saved_views(hass), clean)
|
||||
|
||||
@@ -21,6 +21,7 @@ from ..const import (
|
||||
MAX_TIMESTAMP_LENGTH,
|
||||
)
|
||||
from ..helpers.completion_photos import MAX_COMPLETION_PHOTOS, normalize_photo_doc_ids
|
||||
from ..helpers.ws_errors import send_exception_error, send_translated_error
|
||||
from ..models.maintenance_task import MaintenanceTask
|
||||
from . import (
|
||||
ID_FIELD,
|
||||
@@ -218,7 +219,7 @@ async def ws_complete_task(
|
||||
# The dialog normally prevents these, so reaching here means an
|
||||
# older/cached frontend or a scripted call — answer with the
|
||||
# exception's own key rather than a traceback.
|
||||
connection.send_error(msg["id"], err.translation_key or "completion_details_required", str(err))
|
||||
send_exception_error(connection, msg["id"], err, "completion_details_required")
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
@@ -276,7 +277,7 @@ async def ws_quick_complete_task(
|
||||
# The task demands details the quick-complete defaults do not cover —
|
||||
# same fallback as `no_defaults`: the caller opens the full dialog.
|
||||
# Other refusals (too_early, task_inactive) keep their own key.
|
||||
connection.send_error(msg["id"], err.translation_key or "completion_details_required", str(err))
|
||||
send_exception_error(connection, msg["id"], err, "completion_details_required")
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True, "via": "quick"})
|
||||
|
||||
@@ -313,7 +314,7 @@ async def ws_skip_task(
|
||||
except ServiceValidationError as err:
|
||||
# #150: the task carries a skip lock; an inactive task keeps its own
|
||||
# key (task_inactive_skip) so the panel can say why.
|
||||
connection.send_error(msg["id"], err.translation_key or "skip_disabled", str(err))
|
||||
send_exception_error(connection, msg["id"], err, "skip_disabled")
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
@@ -354,7 +355,7 @@ async def ws_reset_task(
|
||||
)
|
||||
except ServiceValidationError as err:
|
||||
# An archived / disabled / paused task keeps its own key (task_inactive).
|
||||
connection.send_error(msg["id"], err.translation_key or "task_inactive", str(err))
|
||||
send_exception_error(connection, msg["id"], err, "task_inactive")
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
@@ -428,13 +429,13 @@ async def ws_postpone_task(
|
||||
from ..const import MAX_INTERVAL_DAYS
|
||||
|
||||
if until > dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS):
|
||||
connection.send_error(msg["id"], "invalid_date", f"until must be within {MAX_INTERVAL_DAYS} days")
|
||||
send_translated_error(connection, msg["id"], "invalid_date", f"until must be within {MAX_INTERVAL_DAYS} days", translation_key="postpone_too_far", translation_placeholders={"days": str(MAX_INTERVAL_DAYS)})
|
||||
return
|
||||
|
||||
try:
|
||||
await rd.coordinator.async_postpone_task(msg["task_id"], until)
|
||||
except ServiceValidationError as err:
|
||||
connection.send_error(msg["id"], err.translation_key or "task_inactive", str(err))
|
||||
send_exception_error(connection, msg["id"], err, "task_inactive")
|
||||
return
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
@@ -465,7 +466,7 @@ async def ws_snooze_task(
|
||||
|
||||
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
||||
if nm is None:
|
||||
connection.send_error(msg["id"], "unavailable", "Notifications not configured")
|
||||
send_translated_error(connection, msg["id"], "unavailable", "Notifications not configured", translation_key="notify_not_configured")
|
||||
return
|
||||
nm.snooze_task(msg["entry_id"], msg["task_id"])
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
@@ -34,6 +34,7 @@ from ..const import (
|
||||
MAX_NFC_TAG_LENGTH,
|
||||
MAX_NOTIFY_ICON_LENGTH,
|
||||
MAX_READING_UNIT_LENGTH,
|
||||
MAX_TASKS_PER_OBJECT,
|
||||
MAX_TEXT_LENGTH,
|
||||
MAX_TYPE_LENGTH,
|
||||
MAX_URL_LENGTH,
|
||||
@@ -62,6 +63,7 @@ from ..helpers.task_fields import (
|
||||
TASK_PRIORITIES,
|
||||
WARNING_DAYS_RANGE,
|
||||
)
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from . import (
|
||||
ID_FIELD,
|
||||
_load_object_entry,
|
||||
@@ -144,7 +146,7 @@ def _refuse_archived_object(connection: websocket_api.ActiveConnection, msg: dic
|
||||
"""
|
||||
if not (entry.data.get(CONF_OBJECT) or {}).get("archived_at"):
|
||||
return False
|
||||
connection.send_error(msg["id"], "archived", "An archived object cannot get new tasks")
|
||||
send_translated_error(connection, msg["id"], "archived", "An archived object cannot get new tasks", translation_key="archived_no_new_tasks")
|
||||
return True
|
||||
|
||||
|
||||
@@ -400,7 +402,7 @@ async def ws_create_task(
|
||||
task_id = uuid4().hex
|
||||
name = msg["name"].strip()
|
||||
if not name:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
send_translated_error(connection, msg["id"], "invalid_input", "Name must not be empty", translation_key="name_empty")
|
||||
return
|
||||
if not _valid_due_date(connection, msg):
|
||||
return
|
||||
@@ -485,7 +487,7 @@ async def ws_create_task(
|
||||
task_data["notes"] = msg["notes"]
|
||||
if msg.get("documentation_url") is not None:
|
||||
if not _is_safe_url(msg["documentation_url"]):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
return
|
||||
task_data["documentation_url"] = msg["documentation_url"]
|
||||
if msg.get("responsible_user_id") is not None:
|
||||
@@ -604,7 +606,7 @@ async def ws_create_task(
|
||||
history=initial_history,
|
||||
)
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], "limit_reached", str(err))
|
||||
send_translated_error(connection, msg["id"], "limit_reached", str(err), translation_key="task_limit", translation_placeholders={"max": str(MAX_TASKS_PER_OBJECT)})
|
||||
return
|
||||
|
||||
result = {"task_id": task_id}
|
||||
@@ -649,7 +651,7 @@ async def ws_update_task(
|
||||
if "name" in msg:
|
||||
msg["name"] = msg["name"].strip()
|
||||
if not msg["name"]:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
send_translated_error(connection, msg["id"], "invalid_input", "Name must not be empty", translation_key="name_empty")
|
||||
return
|
||||
|
||||
# Battery Fleet guard (issue #106): the single fleet task without its
|
||||
@@ -701,7 +703,7 @@ async def ws_update_task(
|
||||
|
||||
# Validate documentation_url if provided
|
||||
if "documentation_url" in msg and not _is_safe_url(msg["documentation_url"]):
|
||||
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
|
||||
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
|
||||
return
|
||||
|
||||
# D#183: mirror targets — same sanitize + own-platform refusal as create;
|
||||
@@ -1069,7 +1071,7 @@ async def ws_duplicate_task(
|
||||
from ..helpers.parts import PART_REF_FIELD
|
||||
|
||||
if source.get(PART_REF_FIELD) or source.get(BATTERY_FLEET_TASK_FLAG):
|
||||
connection.send_error(msg["id"], "invalid_input", "A spare-part buy task or the battery fleet task cannot be duplicated")
|
||||
send_translated_error(connection, msg["id"], "invalid_input", "A spare-part buy task or the battery fleet task cannot be duplicated", translation_key="task_not_duplicable")
|
||||
return
|
||||
|
||||
new_task = deepcopy(dict(source))
|
||||
@@ -1079,6 +1081,11 @@ async def ws_duplicate_task(
|
||||
new_task["created_at"] = dt_util.now().date().isoformat()
|
||||
# Never carry over per-task-unique keys or any stray dynamic state.
|
||||
strip_task_runtime_state(new_task)
|
||||
# The copy is the user's own task, not the duty / template task the
|
||||
# original was created as (2.95 fingerprint).
|
||||
from ..helpers.task_origin import ORIGIN_KEY
|
||||
|
||||
new_task.pop(ORIGIN_KEY, None)
|
||||
|
||||
await async_persist_task(hass, entry, new_task)
|
||||
|
||||
@@ -1113,10 +1120,10 @@ async def ws_move_task(
|
||||
if target is None:
|
||||
return
|
||||
if target.entry_id == entry.entry_id:
|
||||
connection.send_error(msg["id"], "invalid_target", "The task already belongs to that object")
|
||||
send_translated_error(connection, msg["id"], "invalid_target", "The task already belongs to that object", translation_key="move_same_object")
|
||||
return
|
||||
if (target.data.get(CONF_OBJECT) or {}).get("archived_at"):
|
||||
connection.send_error(msg["id"], "invalid_target", "The target object is archived")
|
||||
send_translated_error(connection, msg["id"], "invalid_target", "The target object is archived", translation_key="move_target_archived")
|
||||
return
|
||||
from .tasks_persist import TaskMoveRefused, async_move_task
|
||||
|
||||
@@ -1124,10 +1131,10 @@ async def ws_move_task(
|
||||
await async_move_task(hass, entry, target, msg["task_id"])
|
||||
except TaskMoveRefused as err:
|
||||
# task_not_movable / object_not_loaded — raised before anything changed.
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
send_translated_error(connection, msg["id"], err.code, str(err), translation_key=err.translation_key)
|
||||
return
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], "limit_reached", str(err))
|
||||
send_translated_error(connection, msg["id"], "limit_reached", str(err), translation_key="task_limit", translation_placeholders={"max": str(MAX_TASKS_PER_OBJECT)})
|
||||
return
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.config_entries.async_reload(target.entry_id)
|
||||
|
||||
@@ -243,9 +243,10 @@ class TaskMoveRefused(ValueError):
|
||||
trigger state).
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
def __init__(self, code: str, message: str, translation_key: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.translation_key = translation_key
|
||||
|
||||
|
||||
def _stamp_part_links(links: Any, entry_id: str) -> Any:
|
||||
@@ -293,9 +294,9 @@ async def async_move_task(
|
||||
# A buy task follows its spare part and the fleet task IS the battery
|
||||
# fleet — neither can live on another object (bug audit 2026-09-12).
|
||||
if task_data.get(PART_REF_FIELD):
|
||||
raise TaskMoveRefused("task_not_movable", "A spare-part buy task stays with its part")
|
||||
raise TaskMoveRefused("task_not_movable", "A spare-part buy task stays with its part", "move_part_task")
|
||||
if task_data.get(BATTERY_FLEET_TASK_FLAG):
|
||||
raise TaskMoveRefused("task_not_movable", "The battery fleet task cannot be moved")
|
||||
raise TaskMoveRefused("task_not_movable", "The battery fleet task cannot be moved", "move_fleet_task")
|
||||
|
||||
# Both Stores must be loaded (entry disabled / setup-retry / mid-reload
|
||||
# = no runtime_data): the config would move while history, readings and
|
||||
@@ -303,7 +304,7 @@ async def async_move_task(
|
||||
src_store = get_store(hass, source.entry_id)
|
||||
tgt_store = get_store(hass, target.entry_id)
|
||||
if src_store is None or tgt_store is None:
|
||||
raise TaskMoveRefused("object_not_loaded", "Both objects must be loaded to move a task")
|
||||
raise TaskMoveRefused("object_not_loaded", "Both objects must be loaded to move a task", "move_objects_not_loaded")
|
||||
|
||||
task_data.pop("ref_no", None)
|
||||
# The task now belongs to the target object — like the duplicate /
|
||||
|
||||
@@ -26,6 +26,7 @@ from ..helpers.aggregate import object_name
|
||||
from ..helpers.dates import parse_iso_date
|
||||
from ..helpers.pause import is_task_inert
|
||||
from ..helpers.vacation import compute_preview, get_vacation_state
|
||||
from ..helpers.ws_errors import send_translated_error
|
||||
from . import ID_FIELD, _get_merged_tasks, _get_object_entries, _load_global_options, _parse_iso_date, _save_global_options
|
||||
|
||||
|
||||
@@ -100,7 +101,7 @@ async def ws_vacation_update(
|
||||
sd = parse_iso_date(options.get(CONF_VACATION_START))
|
||||
ed = parse_iso_date(options.get(CONF_VACATION_END))
|
||||
if sd is not None and ed is not None and ed < sd:
|
||||
connection.send_error(msg["id"], "invalid_range", "end must be on or after start")
|
||||
send_translated_error(connection, msg["id"], "invalid_range", "end must be on or after start", translation_key="range_end_before_start")
|
||||
return
|
||||
|
||||
if "buffer_days" in msg:
|
||||
|
||||
Reference in New Issue
Block a user