189 files

This commit is contained in:
Home Assistant Version Control
2026-09-08 18:05:32 +00:00
parent 79fefc0a7c
commit 303886f212
189 changed files with 21402 additions and 3514 deletions
@@ -60,6 +60,7 @@ from .const import (
CONF_WEEKLY_DIGEST_ENABLED,
DEFAULT_PANEL_ENABLED,
DEFAULT_WARNING_DAYS,
DOCUMENT_TEXT_INDEX_KEY,
DOMAIN,
EVENT_UNSUBS_KEY,
GLOBAL_UNIQUE_ID,
@@ -137,12 +138,15 @@ type MaintenanceSupporterConfigEntry = ConfigEntry[MaintenanceSupporterData]
# --- Service Schemas ---
SERVICE_COMPLETE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_ENTITY_ID): vol.All(cv.entity_ids, vol.Length(min=1)),
vol.Optional("notes"): vol.All(cv.string, vol.Length(max=2000)),
vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)),
vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)),
# Meter readings (v2.20, #83): recorded value for `reading` tasks.
vol.Optional("reading_value"): vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)),
# #161 phase 2: {reading name: value} for a task with reading slots —
# automations know meters by name; resolved case-insensitively.
vol.Optional("reading_values"): {cv.string: vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None)},
# #128: who did it — a person ENTITY (validated picker, no free text);
# resolved to the linked HA user id. Omitted -> the calling user.
vol.Optional("completed_by"): cv.entity_id,
@@ -155,14 +159,14 @@ SERVICE_COMPLETE_SCHEMA = vol.Schema(
SERVICE_RESET_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_ENTITY_ID): vol.All(cv.entity_ids, vol.Length(min=1)),
vol.Optional("date"): cv.date,
}
)
SERVICE_SKIP_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_ENTITY_ID): vol.All(cv.entity_ids, vol.Length(min=1)),
vol.Optional("reason"): vol.All(cv.string, vol.Length(max=2000)),
}
)
@@ -455,6 +459,24 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
await doc_store.async_load()
hass.data[DOMAIN][DOCUMENT_STORE_KEY] = doc_store
# Full-text index over the blobs (#171): extraction meta loads now, the
# sidecars are read on the first search, new blobs are extracted as they
# arrive and the existing library is backfilled after HA started.
from .helpers.document_text import DocumentTextIndex
text_index = DocumentTextIndex(hass, doc_store)
await text_index.async_load()
doc_store.text_index = text_index
hass.data[DOMAIN][DOCUMENT_TEXT_INDEX_KEY] = text_index
# Reference numbers (#170): the object-level counter lives in its own
# small Store; objects are numbered when their entry is set up.
from .helpers.reference_numbers import REFERENCE_NUMBERS_KEY, ReferenceNumbers
reference_numbers = ReferenceNumbers(hass)
await reference_numbers.async_load()
hass.data[DOMAIN][REFERENCE_NUMBERS_KEY] = reference_numbers
# Authenticated upload + serve endpoints for document blobs (the blobs live
# under /config, so they must never be exposed via an unauthenticated path).
@@ -536,11 +558,15 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
# Per-task values must not fan out: a reading belongs to ONE
# meter, cost/duration would multiply into the budget and the
# averages, and one physical scan proves exactly one tag.
for field in ("reading_value", "cost", "duration", "via_tag_scan"):
for field in ("reading_value", "reading_values", "cost", "duration"):
if call.data.get(field) is not None:
raise ServiceValidationError(
f"'{field}' can only be used with a single entity_id"
)
# An explicit ``via_tag_scan: false`` claims nothing — only a
# TRUE flag is the one-tag-one-task proof that must not fan out.
if call.data.get("via_tag_scan"):
raise ServiceValidationError("'via_tag_scan' can only be used with a single entity_id")
# #128: explicit person beats the call context; the context covers the
# common case for free (a dashboard tap propagates the tapping user).
if call.data.get("completed_by"):
@@ -549,12 +575,29 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
completed_by = call.context.user_id if call.context else None
async def _one(coordinator: MaintenanceCoordinator, task_id: str) -> None:
reading_values = None
if call.data.get("reading_values"):
from .helpers.reading_slots import resolve_reading_values_by_name
slot_task = coordinator._get_merged_tasks_data().get(task_id) or {}
try:
reading_values = (
resolve_reading_values_by_name(
slot_task.get("readings") or [],
call.data["reading_values"],
default_unit=slot_task.get("reading_unit"),
)
or None
)
except ValueError as err:
raise ServiceValidationError(str(err)) from err
await coordinator.complete_maintenance(
task_id=task_id,
notes=call.data.get("notes"),
cost=call.data.get("cost"),
duration=call.data.get("duration"),
reading_value=call.data.get("reading_value"),
reading_values=reading_values,
completed_by=completed_by,
completed_at=call.data.get("completed_at"),
# Proof of presence: an automation that reacted to the physical
@@ -1409,6 +1452,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
# HA-started so the store has finished loading.
entry.async_on_unload(async_at_started(hass, _check_document_storage_issues))
# Backfill the document search index (#171) a couple of minutes after
# start — pypdf is CPU work, and boot is the wrong moment for it.
entry.async_on_unload(async_at_started(hass, _schedule_document_text_backfill))
# A global entry now exists — clear the orphan repair issue immediately
# (e.g. right after the repair flow recreated it, when HA is already
# started and the async_at_started check above won't fire again).
@@ -1544,6 +1591,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
if retranslate_seeded_texts(hass, entry, normalize_language(hass)):
_LOGGER.info("Retranslated the battery fleet's seeded texts for %s", entry.title)
# #170: give this object (and any other still unnumbered one) its
# reference number before the first refresh numbers the tasks.
from .helpers.reference_numbers import REFERENCE_NUMBERS_KEY, ReferenceNumbers
reference_numbers = hass.data[DOMAIN].get(REFERENCE_NUMBERS_KEY)
if isinstance(reference_numbers, ReferenceNumbers):
await reference_numbers.async_assign_objects()
coordinator = MaintenanceCoordinator(hass, entry, store)
entry.runtime_data = MaintenanceSupporterData(coordinator=coordinator, store=store)
await coordinator.async_config_entry_first_refresh()
@@ -1573,10 +1628,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
result["pruned"],
result["orphans_removed"],
)
if result.get("trigger_healed"):
if result.get("trigger_healed") or result["added"]:
# #156: the recovery flag only reaches the LIVE trigger
# after a reload — do it once; the next start finds it set.
_LOGGER.info("Battery-fleet trigger healed (auto_complete_on_recovery) — reloading %s", entry.title)
# after a reload — and so does the stock SENSOR of a part
# added here: the sensor platform was set up before this
# ran, so the new part had no entity until the next
# restart (bug review 2026-09-04). Reload once; the next
# start finds both in place and adds nothing.
_LOGGER.info(
"Battery-fleet reconcile needs a reload of %s (trigger healed: %s, parts added: %s)",
entry.title,
bool(result.get("trigger_healed")),
result["added"] or "none",
)
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
entry.async_on_unload(async_at_started(hass, _fleet_parts_at_start))
@@ -1693,6 +1757,16 @@ def _verify_notify_service(hass: HomeAssistant) -> None:
_DOC_STORAGE_ISSUE_ID = "document_storage_issues"
@callback
def _schedule_document_text_backfill(hass: HomeAssistant) -> None:
"""Kick off the delayed full-text backfill (see helpers/document_text)."""
from .helpers.document_text import DocumentTextIndex
index = hass.data.get(DOMAIN, {}).get(DOCUMENT_TEXT_INDEX_KEY)
if isinstance(index, DocumentTextIndex):
index.schedule_backfill()
async def _check_document_storage_issues(hass: HomeAssistant) -> None:
"""Sync the document-storage repair issue with the current on-disk reality.
@@ -1912,6 +1986,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon
shopping_sync = hass.data.get(DOMAIN, {}).pop(SHOPPING_SYNC_KEY, None)
if shopping_sync is not None:
shopping_sync.async_teardown()
text_index = hass.data.get(DOMAIN, {}).get(DOCUMENT_TEXT_INDEX_KEY)
if text_index is not None:
text_index.cancel()
# Flush a pending debounced store save BEFORE tearing down — belt and
# suspenders next to the store cache: disk is current the moment the entry
@@ -2020,11 +2097,15 @@ def _get_coordinator_for_entity(hass: HomeAssistant, entity_id: str) -> Maintena
return None
config_entry = hass.config_entries.async_get_entry(config_entry_id)
if config_entry is None:
# Only OUR entries: a foreign integration's entry may carry its own
# runtime_data, and reading ``.coordinator`` off it raised a bare
# AttributeError that escaped the per-task error collection and aborted
# the rest of a multi-entity call (bug review 2026-09-04).
if config_entry is None or config_entry.domain != DOMAIN:
return None
runtime_data: MaintenanceSupporterData | None = getattr(config_entry, "runtime_data", None)
if runtime_data is None:
runtime_data = getattr(config_entry, "runtime_data", None)
if not isinstance(runtime_data, MaintenanceSupporterData):
return None
return runtime_data.coordinator