189 files
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user