198 files

This commit is contained in:
Home Assistant Version Control
2026-10-11 23:51:59 +00:00
parent 4a64770282
commit 669d229519
198 changed files with 4891 additions and 2815 deletions
@@ -397,7 +397,7 @@ def _build_object_response(
from ..const import CONF_PART_SEARCH_URL_TEMPLATE, CONF_PARTS
from ..helpers.global_options import get_global_options
from ..helpers.i18n import normalize_language
from ..helpers.parts import part_is_low, resolve_shopping_url
from ..helpers.parts import on_hand, part_is_low, resolve_shopping_url
part_store = get_store(hass, entry.entry_id)
search_template = get_global_options(hass).get(CONF_PART_SEARCH_URL_TEMPLATE)
@@ -405,11 +405,14 @@ def _build_object_response(
parts_payload = []
for part in (entry.data.get(CONF_PARTS) or {}).values():
stock = part_store.get_part_stock(part["id"]) if part_store else None
# #208: used items of a reusable part waiting to be ready again.
restoring = part_store.get_part_restoring(part["id"]) if part_store else 0
parts_payload.append(
{
**part,
"stock": stock,
"is_low": part_is_low(part, stock),
"restoring": restoring,
"is_low": part_is_low(part, on_hand(part, stock, restoring)),
"shopping_url": resolve_shopping_url(part, search_template, lang, hass.config.country),
}
)
@@ -809,6 +812,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .parts import (
ws_create_part,
ws_delete_part,
ws_part_ready_again,
ws_parts_overview,
ws_restock_part,
ws_update_part,
@@ -898,6 +902,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_delete_part)
websocket_api.async_register_command(hass, ws_parts_overview)
websocket_api.async_register_command(hass, ws_restock_part)
websocket_api.async_register_command(hass, ws_part_ready_again)
websocket_api.async_register_command(hass, ws_update_history_entry)
websocket_api.async_register_command(hass, ws_delete_history_entry)
websocket_api.async_register_command(hass, ws_get_templates)
@@ -40,6 +40,33 @@ from ..helpers.permissions import require_write
from ..helpers.ws_errors import send_translated_error
def _charging(hass: HomeAssistant, fleet: Any) -> list[dict[str, Any]]:
"""The fleet's counted reusable parts: ready and waiting (#208)."""
if fleet is None:
return []
from ..const import CONF_PARTS
from ..helpers.aggregate import get_store
store = get_store(hass, fleet.entry_id)
if store is None:
return []
rows: list[dict[str, Any]] = []
for part_id, part in sorted((fleet.data.get(CONF_PARTS) or {}).items()):
stock = store.get_part_stock(part_id)
if not part.get("reuse") or stock is None:
continue
rows.append(
{
"part_id": part_id,
"name": part.get("name"),
"reuse": part["reuse"],
"stock": stock,
"restoring": store.get_part_restoring(part_id),
}
)
return rows
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/overview"})
@websocket_api.async_response
async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
@@ -73,6 +100,11 @@ async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_a
"needs_now": dict(ov.needs_now),
"needs_soon": dict(ov.needs_soon),
"types": ov.types,
# #208: the fleet's reusable parts (rechargeable cells) with how
# many are charged and how many wait to be charged — only those
# whose spares are counted (an untracked part is a device charged
# in place, or nobody counts).
"charging": _charging(hass, fleet),
# #135 follow-up: the roster's track-self-charging toggle state.
"track_self_charging": fleet_track_self_charging(hass),
# D#162: whether a passed forecast on a sensorless note counts as
@@ -30,9 +30,9 @@ 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.url_safety import is_safe_link
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
if TYPE_CHECKING:
from ..helpers.documents import DocumentStore
@@ -129,11 +129,11 @@ async def ws_documents_add_link(
return
url = msg["url"].strip()
# Web-links must be absolute http/https — a relative or scheme-less URL is
# 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):
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
# Web-links must be absolute (http(s) or an app link, #207) — a relative
# or scheme-less URL is meaningless as a stored external reference (and
# _is_safe_url treats those as "safe", so is_safe_link asks for a scheme).
if not is_safe_link(url):
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
return
title = msg.get("title")
@@ -1134,6 +1134,10 @@ async def ws_import_json(
stock = part_entry.get("stock")
if isinstance(stock, (int, float)) and not isinstance(stock, bool) and stock >= 0:
pdata["stock"] = stock
# #208: the used ones waiting go with the stock.
waiting = part_entry.get("restoring")
if pdata.get("reuse") and isinstance(waiting, (int, float)) and not isinstance(waiting, bool) and waiting > 0:
pdata["restoring"] = waiting
import_parts[new_id] = pdata
if old_id:
part_id_map[old_id] = new_id
@@ -336,7 +336,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):
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
return
# v1.4.10 (#46): notes (free-form, may contain newlines)
@@ -432,7 +432,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"]):
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
return
# v1.4.10 (#46): notes — strip but keep newlines, empty -> None
@@ -1060,6 +1060,7 @@ async def ws_replace_object(
stock = src_store.get_part_stock(old_pid)
if stock is not None:
new_store.set_part_stock(new_pid, stock)
new_store.set_part_restoring(new_pid, src_store.get_part_restoring(old_pid))
await new_store.async_save()
# Objects BORROWING the predecessor's pool (#111) now draw on the
# successor's shelf. They kept linking to the archived predecessor —
@@ -14,6 +14,7 @@ from ..helpers.aggregate import get_store, object_name
from ..helpers.parts import (
MAX_PART_STOCK,
MAX_PARTS_PER_OBJECT,
REUSE_KINDS,
PartValidationError,
normalize_part,
)
@@ -50,9 +51,13 @@ _PART_FIELDS_SCHEMA: VolDictType = {
vol.Optional("package_size"): vol.Any(int, float, None),
vol.Optional("auto_buy_task"): bool,
vol.Optional("doc_id"): vol.Any(ID_FIELD, None),
# #208: what readies a used one again (charge, sharpen, ...); None = used up.
vol.Optional("reuse"): vol.Any(vol.In(REUSE_KINDS), None),
# Initial / edited stock travels WITH the definition for dialog simplicity,
# but is stored in the per-entry Store (dynamic), not entry.data.
# but is stored in the per-entry Store (dynamic), not entry.data — as does
# the count of used ones waiting (#208).
vol.Optional("stock"): vol.Any(int, float, None),
vol.Optional("restoring"): vol.Any(vol.All(vol.Any(int, float), vol.Range(min=0, max=MAX_PART_STOCK)), None),
}
@@ -90,9 +95,11 @@ async def ws_create_part(
stock = msg.get("stock")
if stock is not None:
from ..parts_runtime import async_change_part_stock
from ..parts_runtime import async_change_part_stock, async_set_part_restoring
await async_change_part_stock(hass, entry, part["id"], absolute=float(stock))
if part.get("reuse") and msg.get("restoring"):
await async_set_part_restoring(hass, entry, part["id"], float(msg["restoring"]))
# Reload so the part's stock sensor appears (entities are created at setup).
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"part_id": part["id"]})
@@ -132,8 +139,14 @@ async def ws_update_part(
parts[part["id"]] = part
_persist_parts(hass, entry, parts)
from ..parts_runtime import async_change_part_stock, schedule_buy_task_reconcile
from ..parts_runtime import async_change_part_stock, async_set_part_restoring, schedule_buy_task_reconcile
# #208: the waiting pile goes with the part's reuse — set when given, and
# gone when the part is no longer reusable.
if not part.get("reuse") and existing.get("reuse"):
await async_set_part_restoring(hass, entry, part["id"], 0)
elif part.get("reuse") and msg.get("restoring") is not None:
await async_set_part_restoring(hass, entry, part["id"], float(msg["restoring"]))
if "stock" in msg:
stock = msg.get("stock")
if stock is None:
@@ -151,7 +164,7 @@ async def ws_update_part(
# str() of a voluptuous marker is its key name (Marker.__str__ -> str(schema)).
_PART_FIELD_KEYS = {str(k) for k in _PART_FIELDS_SCHEMA if str(k) != "stock"}
_PART_FIELD_KEYS = {str(k) for k in _PART_FIELDS_SCHEMA if str(k) not in ("stock", "restoring")}
@websocket_api.websocket_command(
@@ -271,6 +284,36 @@ async def ws_restock_part(
connection.send_result(msg["id"], {"stock": new})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/ready_again",
vol.Required("entry_id"): ID_FIELD,
vol.Required("part_id"): ID_FIELD,
# How many are ready again; all waiting ones when left out.
vol.Optional("quantity"): vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0, max=MAX_PART_STOCK)),
}
)
@require_write
@websocket_api.async_response
async def ws_part_ready_again(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Used items of a reusable part are ready again (#208): charged,
sharpened, cleaned, dried or refilled — back from the pile to the stock."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
from ..parts_runtime import async_part_ready_again
result = await async_part_ready_again(hass, entry, msg["part_id"], msg.get("quantity"))
if result is None:
connection.send_error(msg["id"], "not_found", "No reusable part with a tracked stock")
return
connection.send_result(msg["id"], result)
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/parts/overview"})
@websocket_api.async_response
async def ws_parts_overview(
@@ -284,7 +327,7 @@ async def ws_parts_overview(
every consuming task — the object's own tasks and pooled #111 links from
other objects. Read-only; the per-object CRUD stays on part/*.
"""
from ..helpers.parts import part_is_low
from ..helpers.parts import on_hand, part_is_low
from . import _get_merged_tasks, _get_object_entries
entries = _get_object_entries(hass)
@@ -317,6 +360,7 @@ async def ws_parts_overview(
store = get_store(hass, e.entry_id)
for part_id, part in parts.items():
stock = store.get_part_stock(part_id) if store is not None else None
restoring = store.get_part_restoring(part_id) if store is not None else 0
rows.append(
{
**part,
@@ -324,7 +368,8 @@ async def ws_parts_overview(
"entry_id": e.entry_id,
"object_name": names.get(e.entry_id),
"stock": stock,
"low": part_is_low(part, stock),
"restoring": restoring,
"low": part_is_low(part, on_hand(part, stock, restoring)),
"consumers": consumers.get((e.entry_id, part_id), []),
}
)
@@ -41,7 +41,6 @@ from .tasks_persist import (
async_persist_task,
)
from .tasks_validation import (
_SAFE_URL_SCHEMES,
_TRIGGER_ALLOWED_KEYS,
_TRIGGER_REQUIRED_FIELDS,
_VALID_TRIGGER_TYPES,
@@ -52,7 +51,6 @@ from .tasks_validation import (
)
__all__ = [
"_SAFE_URL_SCHEMES",
"_TRIGGER_ALLOWED_KEYS",
"_TRIGGER_REQUIRED_FIELDS",
"_VALID_TRIGGER_TYPES",
@@ -487,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"]):
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
return
task_data["documentation_url"] = msg["documentation_url"]
if msg.get("responsible_user_id") is not None:
@@ -703,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"]):
send_translated_error(connection, msg["id"], "invalid_url", "Only http/https URLs are allowed", translation_key="unsafe_url")
send_translated_error(connection, msg["id"], "invalid_url", "This link is not allowed: links that run scripts or open local files (javascript:, data:, file: and the like) are refused.", translation_key="unsafe_url")
return
# D#183: mirror targets — same sanitize + own-platform refusal as create;
@@ -20,7 +20,6 @@ from ..const import (
)
from ..helpers.aggregate import object_name
from ..helpers.trigger_fallback import threshold_limits_overlap
from ..helpers.url_safety import _SAFE_URL_SCHEMES as _SAFE_URL_SCHEMES
from ..helpers.url_safety import is_safe_url
from . import (
_get_object_entries,
@@ -14,6 +14,7 @@ from ..const import (
MAX_META_LENGTH,
)
from ..helpers.aggregate import get_coordinator_data, get_runtime_data, object_name
from ..helpers.assignees import task_is_for
from ..helpers.entry_tasks import write_task
from ..helpers.permissions import require_write, user_may_write
from . import (
@@ -134,7 +135,8 @@ async def ws_tasks_by_user(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return all tasks assigned to a specific user across all objects."""
"""Return all tasks meant for a user across all objects: theirs, and
shared ones nobody is responsible for (helpers/assignees)."""
user_id = msg["user_id"]
# Authorization: a user may query their OWN assignments; querying another
# user's requires write access (admin or operator) — prevents a plain user
@@ -151,7 +153,7 @@ async def ws_tasks_by_user(
obj_name = object_name(entry)
for tid, tdata in tasks_data.items():
if tdata.get("responsible_user_id") == user_id:
if task_is_for(tdata, user_id):
task_summary = _build_task_summary(hass, tid, tdata, ct_tasks.get(tid))
task_summary["object_name"] = obj_name
task_summary["entry_id"] = entry.entry_id