329 files
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.7.4", "start_ts": 1785684875.7532482}
|
||||
{"pid": 70, "version": 1, "ha_version": "2026.7.4", "start_ts": 1786024552.5388079}
|
||||
@@ -196,7 +196,7 @@
|
||||
},
|
||||
{
|
||||
"id": "e92ef0caff41454e9f49ea966ed99e41",
|
||||
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=178921037471",
|
||||
"url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=178921037490",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -261,7 +261,7 @@
|
||||
},
|
||||
{
|
||||
"id": "6c3f7d87683d4473b5dc8fe8177535f6",
|
||||
"url": "/hacsfiles/simple-thermostat/simple-thermostat.js?hacstag=1230152807410",
|
||||
"url": "/hacsfiles/simple-thermostat/simple-thermostat.js?hacstag=1230152807420",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ DOMAIN = "ha_mcp_tools"
|
||||
# in CI. The
|
||||
# capability negotiation — not this version — gates each WS command (see
|
||||
# ``websocket_api.CAPABILITIES``).
|
||||
COMPONENT_VERSION = "1.3.1"
|
||||
COMPONENT_VERSION = "1.3.2"
|
||||
|
||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||
# means "tools" so the pre-existing services entry keeps working across the
|
||||
|
||||
@@ -32,6 +32,7 @@ import importlib.metadata
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import site
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -39,6 +40,7 @@ from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.auth.models import TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
|
||||
@@ -49,7 +51,7 @@ from homeassistant.requirements import (
|
||||
async_process_requirements,
|
||||
pip_kwargs,
|
||||
)
|
||||
from homeassistant.util.package import install_package
|
||||
from homeassistant.util.package import is_virtual_env
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import InvalidVersion, Version
|
||||
@@ -133,6 +135,11 @@ _PIP_INSTALL_TIMEOUT_SECONDS = 300
|
||||
# Uninstall just removes files/metadata, so it is quick; cap it so a wedged
|
||||
# subprocess can never tie up an executor thread indefinitely.
|
||||
_PIP_UNINSTALL_TIMEOUT_SECONDS = 120
|
||||
# Upper bound for ONE uv install attempt. Generous (a cold ARM wheel build
|
||||
# is slow but finite) and bounded, so a wedged uv cannot hold the
|
||||
# process-wide tracked-install slot — and with it the next bring-up —
|
||||
# forever. The extra-index fallback can spend this twice.
|
||||
_UV_INSTALL_TIMEOUT_SECONDS = 1800
|
||||
|
||||
# How long a bring-up waits for an install job orphaned by a CANCELLED
|
||||
# previous bring-up before giving up: asyncio cancellation detaches the
|
||||
@@ -651,23 +658,28 @@ class EmbeddedServerManager:
|
||||
With auto-update on (the default) both channels install their
|
||||
distribution UNPINNED, so every entry reload / HA restart must pick up
|
||||
the newest build. Such a spec ALWAYS takes the force-install path
|
||||
(``upgrade=True``, bypassing the requirements manager's is-installed
|
||||
shortcut) — that is what makes the channel auto-update. This runs in a
|
||||
(``--upgrade-package <dist>``, bypassing the requirements manager's
|
||||
is-installed shortcut) — that is what makes the channel auto-update,
|
||||
and scoping the upgrade to our own distribution is what keeps it
|
||||
from replacing packages Home Assistant ships (#2135/#2146). This runs in a
|
||||
background task, so it never blocks HA startup, and uv no-ops quickly
|
||||
when the newest build is already installed.
|
||||
|
||||
Fast path: reserved for a STABLE spec — an explicit pip-spec override (a
|
||||
version pin or tarball URL) or a channel with auto-update turned OFF
|
||||
Fast path: reserved for a stable INDEX spec — an explicit pip-spec
|
||||
override that is a version pin, or a channel with auto-update turned OFF
|
||||
(which pins to the installed version, see :meth:`_resolve_pip_spec`).
|
||||
When that spec matches the one last installed and the package imports,
|
||||
delegate the "already satisfied?" decision to Home Assistant's
|
||||
requirements manager; a pinned spec does not move, so there is nothing to
|
||||
upgrade to. A CHANGED spec (a new override, a cleared override, a
|
||||
upgrade to. A URL override (a tarball or ``file://`` wheel) is
|
||||
deliberately EXCLUDED: HA's is-installed check cannot verify a URL
|
||||
requirement, so delegating one always reaches its bare ``--upgrade``
|
||||
install — see the comment on ``spec_is_stable`` below. A CHANGED spec (a new override, a cleared override, a
|
||||
toggled auto-update, a channel switch) falls through to the
|
||||
force-install path below — and additionally uninstalls the replaced
|
||||
distribution first (:meth:`_async_remove_replaced_source`), because
|
||||
``upgrade=True`` alone decides by version and a changed SOURCE can keep
|
||||
the version string (issue #1914).
|
||||
the upgrade flag alone decides by version and a changed SOURCE can
|
||||
keep the version string (issue #1914).
|
||||
|
||||
On a channel switch the other channel's distribution is uninstalled first
|
||||
(:meth:`_async_remove_conflicting_dist`): ``ha-mcp`` and ``ha-mcp-dev``
|
||||
@@ -737,7 +749,20 @@ class EmbeddedServerManager:
|
||||
# A "stable" spec (an explicit override, or a channel pinned because
|
||||
# auto-update is off) is eligible for the fast path; an unpinned
|
||||
# auto-updating channel never is.
|
||||
spec_is_stable = bool(self._pip_spec_override) or not self._auto_update
|
||||
# A URL spec is never eligible, however stable it looks. The fast
|
||||
# path delegates to HA's requirements manager, and
|
||||
# homeassistant.util.package.is_installed() returns False for ANY
|
||||
# requirement carrying a URL ("we cannot verify versions, so let the
|
||||
# package manager handle it"), so async_process_requirements always
|
||||
# reaches install_package(), whose upgrade default appends a bare
|
||||
# --upgrade. That re-resolves the whole graph and replaces packages
|
||||
# HA only floors — the #2135/#2146 tear, on every restart. Routing
|
||||
# URL specs to the force path costs a scoped --reinstall-package of
|
||||
# OUR distribution only, which is the install HA would have done
|
||||
# anyway, minus the stomp.
|
||||
spec_is_stable = (
|
||||
bool(self._pip_spec_override) or not self._auto_update
|
||||
) and not _spec_is_url_requirement(self._pip_spec)
|
||||
fast_path_ok = (
|
||||
spec_is_stable
|
||||
and stored_spec == self._pip_spec
|
||||
@@ -773,9 +798,10 @@ class EmbeddedServerManager:
|
||||
raise EmbeddedServerError(
|
||||
f"The installer left installed ha-mcp {version}, but this "
|
||||
f"in-process component requires {MIN_EMBEDDED_SERVER_VERSION} "
|
||||
"or newer. Review resolver details logged under "
|
||||
"homeassistant.util.package, correct the package conflict, and "
|
||||
"reload this integration.",
|
||||
"or newer. Review the installer output logged under "
|
||||
"custom_components.ha_mcp_tools.embedded_server (or, for an "
|
||||
"index spec taking the fast path, homeassistant.util.package), "
|
||||
"correct the package conflict, and reload this integration.",
|
||||
kind="package",
|
||||
)
|
||||
_LOGGER.info("HA-MCP in-process server package ready (version %s)", version)
|
||||
@@ -865,7 +891,8 @@ class EmbeddedServerManager:
|
||||
|
||||
Returns None for an override that names an unknown distribution or
|
||||
does not parse as a requirement at all (a direct URL): the installer
|
||||
re-fetches and rebuilds URL requirements under ``upgrade=True``
|
||||
reinstalls a named URL requirement outright
|
||||
(``--reinstall-package``, see :func:`_force_install_package`)
|
||||
regardless of the installed version, so a URL install is already
|
||||
real and nothing needs removing.
|
||||
"""
|
||||
@@ -885,7 +912,7 @@ class EmbeddedServerManager:
|
||||
) -> None:
|
||||
"""Uninstall the replaced distribution when the requested source changed.
|
||||
|
||||
The forced install that follows relies on ``upgrade=True``, and the
|
||||
The forced install that follows relies on its upgrade flag, and the
|
||||
installer decides "already satisfied" by VERSION alone — but a source
|
||||
change can keep the version string. A PR branch's committed
|
||||
``project.version`` equals the release it branched from (only release
|
||||
@@ -903,7 +930,7 @@ class EmbeddedServerManager:
|
||||
Skipped when nothing is installed, when the last-installed spec is
|
||||
unknown (nothing to compare: first install, or entry data predating
|
||||
the spec tracking), when the spec is unchanged (the routine
|
||||
reload/restart path, where ``upgrade=True`` alone is correct and an
|
||||
reload/restart path, where the upgrade flag alone is correct and an
|
||||
uninstall would churn — and briefly break — a healthy install on
|
||||
every restart), when the new spec is a direct URL (always installs
|
||||
for real), when the named distribution is not installed (e.g. a
|
||||
@@ -928,6 +955,19 @@ class EmbeddedServerManager:
|
||||
return
|
||||
if stored_spec == self._pip_spec:
|
||||
return
|
||||
if _spec_is_url_requirement(self._pip_spec):
|
||||
# A URL spec is reinstalled outright (--reinstall-package, see
|
||||
# _scoped_install_flags), so the install cannot be skipped as
|
||||
# "already satisfied" and there is nothing for this uninstall to
|
||||
# unblock. Removing first would only delete the working build
|
||||
# BEFORE the new URL is fetched, so a failed fetch (bad path,
|
||||
# network, moved tarball) leaves no server installed at all —
|
||||
# and it reopens the uninstall-then-extract window on our own
|
||||
# package. _replaced_dist_name() already declines for a BARE
|
||||
# url; a NAMED one ("ha-mcp @ file:///…", the shape the config
|
||||
# flow and the e2e lane use) parses fine and would fall through
|
||||
# to the removal below without this.
|
||||
return
|
||||
replaced_dist = self._replaced_dist_name()
|
||||
if replaced_dist is None:
|
||||
return
|
||||
@@ -936,20 +976,27 @@ class EmbeddedServerManager:
|
||||
# code on disk came from the index too, so "already satisfied by
|
||||
# version" is the truth, not the #1914 lie.
|
||||
return
|
||||
pinned = _exact_pinned_version(self._pip_spec)
|
||||
if pinned is not None:
|
||||
try:
|
||||
version_moves = Version(pinned) != Version(installed_version)
|
||||
except InvalidVersion:
|
||||
version_moves = False # unprovable — keep the uninstall
|
||||
if version_moves:
|
||||
# The new pin cannot be satisfied by the installed version, so
|
||||
# the forced install is guaranteed to be real without any
|
||||
# uninstall — and keeping the working build in place preserves
|
||||
# it as the fallback if that install fails (e.g. offline).
|
||||
return
|
||||
if not await self._hass.async_add_executor_job(_dist_installed, replaced_dist):
|
||||
return
|
||||
# Compare the pin against the version of the distribution actually
|
||||
# being replaced, not the caller's ``installed_version``: that one is
|
||||
# read from whichever dist provides ``ha_mcp`` and is read BEFORE
|
||||
# _async_remove_conflicting_dist() runs, so on a cross-channel switch
|
||||
# it can describe the other channel's dist — or one already
|
||||
# uninstalled. Comparing against it could report "the pin moved" for a
|
||||
# target that is in fact already at the pinned version, skip this
|
||||
# uninstall, and let the install no-op as satisfied (#1914).
|
||||
replaced_version = await self._hass.async_add_executor_job(
|
||||
_installed_dist_version, replaced_dist
|
||||
)
|
||||
if replaced_version is not None and _pin_moves_off_installed(
|
||||
self._pip_spec, replaced_version
|
||||
):
|
||||
# The new pin cannot be satisfied by the installed version, so the
|
||||
# forced install is guaranteed to be real without any uninstall —
|
||||
# and keeping the working build in place preserves it as the
|
||||
# fallback if that install fails (e.g. offline).
|
||||
return
|
||||
_LOGGER.info(
|
||||
"The requested server source changed (%r -> %r); removing the "
|
||||
"installed %r first so the reinstall cannot be skipped as "
|
||||
@@ -992,23 +1039,36 @@ class EmbeddedServerManager:
|
||||
|
||||
Mirrors how ``homeassistant.requirements`` builds its pip invocation
|
||||
(HA's own constraints file + ``config/deps`` target where applicable) so
|
||||
the resolver honors Home Assistant's constraints, then installs with
|
||||
``upgrade=True`` and a generous per-download timeout.
|
||||
the resolver honors Home Assistant's constraints, with one deliberate
|
||||
difference from ``install_package(upgrade=True)``: that maps to uv's
|
||||
EAGER ``--upgrade``, which re-resolves the whole dependency graph to
|
||||
the newest allowed versions and replaces packages Home Assistant
|
||||
already ships even when the installed version satisfies our spec —
|
||||
exactly how the image's websockets kept getting force-replaced
|
||||
(#2135/#2146). ``--upgrade-package`` scopes the upgrade to ha-mcp's
|
||||
own distribution: the server still auto-updates, every other
|
||||
installed package is kept whenever it satisfies the resolution.
|
||||
"""
|
||||
kwargs = pip_kwargs(self._hass.config.config_dir)
|
||||
kwargs["timeout"] = max(
|
||||
int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS
|
||||
)
|
||||
timeout = max(int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS)
|
||||
installed = await self._async_run_tracked_install_job(
|
||||
partial(install_package, self._pip_spec, upgrade=True, **kwargs)
|
||||
partial(
|
||||
_force_install_package,
|
||||
self._pip_spec,
|
||||
channel_dist=dist_for_channel(self._channel),
|
||||
constraints=kwargs.get("constraints"),
|
||||
target=kwargs.get("target"),
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
if not installed:
|
||||
raise EmbeddedServerError(
|
||||
f"Could not install the server ({self._pip_spec!r}). The "
|
||||
f"in-process server requires ha-mcp "
|
||||
f"{MIN_EMBEDDED_SERVER_VERSION} or newer and Home Assistant "
|
||||
f"{MIN_EMBEDDED_HOME_ASSISTANT_VERSION} or newer. Resolver "
|
||||
"details are logged under homeassistant.util.package.",
|
||||
f"{MIN_EMBEDDED_HOME_ASSISTANT_VERSION} or newer. The "
|
||||
"installer's output is logged under "
|
||||
"custom_components.ha_mcp_tools.embedded_server.",
|
||||
kind="package",
|
||||
)
|
||||
|
||||
@@ -1380,7 +1440,16 @@ class EmbeddedServerManager:
|
||||
port=self._port,
|
||||
timeout_graceful_shutdown=2,
|
||||
lifespan="on",
|
||||
ws="websockets-sansio",
|
||||
# HTTP-ONLY listener, so no WebSocket protocol is loaded. uvicorn
|
||||
# resolves its ``ws`` class EAGERLY in Config.load(), and
|
||||
# "websockets-sansio" imports the SHARED websockets package —
|
||||
# the unowned, tearable copy ha-mcp vendors its own copy to stay
|
||||
# clear of (#2135/#2146). With that setting a torn shared install
|
||||
# crashed this server at listener startup no matter what the
|
||||
# client imports. "none" resolves to None and imports nothing;
|
||||
# the MCP app serves Streamable HTTP and registers no WebSocket
|
||||
# route. Pinned by tests/src/unit/test_vendored_websockets.py.
|
||||
ws="none",
|
||||
# Leave Home Assistant's logging untouched — do not let uvicorn
|
||||
# reconfigure the root logger from this thread.
|
||||
log_config=None,
|
||||
@@ -1919,6 +1988,242 @@ def _uninstall_distribution(dist_name: str, *, target: str | None = None) -> boo
|
||||
return True
|
||||
|
||||
|
||||
def _force_install_package(
|
||||
spec: str,
|
||||
*,
|
||||
channel_dist: str | None,
|
||||
constraints: str | None,
|
||||
target: str | None,
|
||||
timeout: int | None,
|
||||
) -> bool:
|
||||
"""Install ``spec``, touching ONLY our own distribution (blocking).
|
||||
|
||||
Mirrors ``homeassistant.util.package.install_package``'s uv invocation
|
||||
(index strategy, constraints, target, the uv --user workaround, and the
|
||||
HTTP_TIMEOUT env) but never its eager ``--upgrade``, which re-resolves
|
||||
EVERY dependency to the newest allowed version and replaces packages the
|
||||
Home Assistant image already ships (#2135/#2146). The replacement flag
|
||||
is chosen per spec shape by :func:`_scoped_install_flags`;
|
||||
``channel_dist`` is the distribution the active channel installs, used
|
||||
to scope a bare URL that names none of its own.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
if timeout:
|
||||
env["HTTP_TIMEOUT"] = str(timeout)
|
||||
args = _uv_install_args(
|
||||
spec,
|
||||
channel_dist=channel_dist,
|
||||
constraints=constraints,
|
||||
target=target,
|
||||
env=env,
|
||||
)
|
||||
_LOGGER.info("Installing the in-process server package: %s", spec)
|
||||
stderr = _run_uv_install(args, env)
|
||||
if stderr is None:
|
||||
return True
|
||||
|
||||
# install_package's extra-index fallback, mirrored: uv treats a failing
|
||||
# extra index as FATAL where pip merely skips it, so a wheels-index
|
||||
# outage would otherwise fail a bring-up that PyPI could satisfy on its
|
||||
# own. When the error names an extra-index host, retry with that host
|
||||
# dropped. Matched on host because wheel files may live outside the
|
||||
# index path. The warning names the failing HOSTS rather than the
|
||||
# configured URLs (which can carry credentials); uv's own stderr is
|
||||
# included as-is, exactly as install_package logs it.
|
||||
extra_urls = env.get("UV_EXTRA_INDEX_URL", "").split()
|
||||
failing = {
|
||||
url: host for url in extra_urls if (host := _url_host(url)) and host in stderr
|
||||
}
|
||||
if failing:
|
||||
_LOGGER.warning(
|
||||
"Could not install %r using extra index host %s: %s; retrying without it",
|
||||
spec,
|
||||
", ".join(failing.values()),
|
||||
stderr,
|
||||
)
|
||||
retry_env = env.copy()
|
||||
if remaining := [url for url in extra_urls if url not in failing]:
|
||||
retry_env["UV_EXTRA_INDEX_URL"] = " ".join(remaining)
|
||||
else:
|
||||
del retry_env["UV_EXTRA_INDEX_URL"]
|
||||
stderr = _run_uv_install(args, retry_env)
|
||||
if stderr is None:
|
||||
return True
|
||||
|
||||
_LOGGER.error("Could not install %r: %s", spec, stderr)
|
||||
return False
|
||||
|
||||
|
||||
def _scoped_install_flags(spec: str, channel_dist: str | None) -> list[str]:
|
||||
"""Return the uv flag that scopes this install to OUR distribution.
|
||||
|
||||
Never a bare ``--upgrade``: that re-resolves the whole graph and
|
||||
replaces packages Home Assistant ships (#2135/#2146). Which scoped flag
|
||||
is right depends on the SPEC SHAPE, not on which distribution it names:
|
||||
|
||||
* A URL requirement must be REINSTALLED. Measured on uv 0.11.33 (the
|
||||
version CI pins), re-running an unchanged ``name @ file://…`` spec
|
||||
reports "Checked 1 package" under both no flag and
|
||||
``--upgrade-package`` — it installs nothing — while
|
||||
``--reinstall-package`` replaces it. Auditing-and-skipping would keep
|
||||
the OLD code running while the bring-up logs success (the #1914
|
||||
shape), and it is exactly the "a URL install is always real"
|
||||
guarantee that ``_replaced_dist_name`` and
|
||||
``_async_remove_replaced_source`` skip their uninstall on.
|
||||
* An index requirement only needs an UPGRADE, scoped to the
|
||||
distribution the spec itself names. Force-reinstalling one instead
|
||||
would reopen the non-atomic uninstall-then-extract window this PR
|
||||
exists to close, on every bring-up, for a spec that never needed it.
|
||||
|
||||
A bare URL names no distribution of its own, and the channel's dist is
|
||||
the wrong guess: a repository tarball installs as ``ha-mcp`` whatever
|
||||
channel is selected (see :meth:`_replaced_dist_name`), so on the dev
|
||||
channel scoping to ``ha-mcp-dev`` would name a package the URL does not
|
||||
provide — uv would report success while leaving the real ``ha-mcp``
|
||||
un-refreshed, and a mutable URL (a branch tarball, a rebuilt artifact)
|
||||
keeps its version string, so nothing else would catch it. Both known
|
||||
dists are therefore named: reinstalling one that is not installed is a
|
||||
harmless no-op for uv (verified: exit 0, package still installed from
|
||||
the URL).
|
||||
"""
|
||||
try:
|
||||
requirement = Requirement(spec)
|
||||
except InvalidRequirement:
|
||||
candidates = [channel_dist] if channel_dist else []
|
||||
candidates += [DIST_NAME_STABLE, DIST_NAME_DEV]
|
||||
flags: list[str] = []
|
||||
for dist in dict.fromkeys(candidates): # ordered, deduplicated
|
||||
flags += ["--reinstall-package", dist]
|
||||
return flags
|
||||
if requirement.url is not None:
|
||||
return ["--reinstall-package", requirement.name]
|
||||
return ["--upgrade-package", requirement.name]
|
||||
|
||||
|
||||
def _url_host(url: str) -> str | None:
|
||||
"""Host of ``url``, or None when it cannot be parsed.
|
||||
|
||||
``urlparse().hostname`` RAISES on a malformed URL (``ValueError:
|
||||
Invalid IPv6 URL`` for an unclosed bracket), and the one caller runs on
|
||||
the INSTALL-FAILURE path — where an operator's typo'd
|
||||
``UV_EXTRA_INDEX_URL`` entry would replace uv's real stderr with a
|
||||
traceback from the error handler.
|
||||
"""
|
||||
try:
|
||||
return urlparse(url).hostname
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _pin_moves_off_installed(spec: str, installed_version: str) -> bool:
|
||||
"""True when an exact-pin ``spec`` CANNOT be satisfied by what's installed.
|
||||
|
||||
Asks the pin's own specifier rather than comparing parsed versions: PEP
|
||||
440 ``==1.0`` matches an installed ``1.0+local``, while
|
||||
``Version("1.0") != Version("1.0+local")`` is True. A version comparison
|
||||
would therefore call that pin "moved", skip the caller's uninstall, and
|
||||
let the installer no-op it as already satisfied — the #1914 shape.
|
||||
|
||||
False when the spec is not an exact pin, and False whenever the answer
|
||||
is unprovable (unparseable requirement or version): "unknown" must not
|
||||
be mistaken for "guaranteed to move", since the caller skips its
|
||||
uninstall on a True.
|
||||
"""
|
||||
if _exact_pinned_version(spec) is None:
|
||||
return False
|
||||
try:
|
||||
requirement = Requirement(spec)
|
||||
# Validate the installed version explicitly: SpecifierSet.contains()
|
||||
# answers False for an unparseable version rather than raising, and
|
||||
# False here would invert to "moved" — the unsafe direction.
|
||||
Version(installed_version)
|
||||
except (InvalidRequirement, InvalidVersion):
|
||||
return False
|
||||
if requirement.marker is not None and not requirement.marker.evaluate():
|
||||
# The requirement does not apply to this interpreter, so the
|
||||
# installer will skip it entirely — the pin cannot make the install
|
||||
# real, whatever version it names.
|
||||
return False
|
||||
return not requirement.specifier.contains(installed_version, prereleases=True)
|
||||
|
||||
|
||||
def _spec_is_url_requirement(spec: str) -> bool:
|
||||
"""True when ``spec`` installs from a URL rather than an index.
|
||||
|
||||
Same shape test :func:`_scoped_install_flags` routes on, and for the
|
||||
same reason — an unparseable spec is treated as URL-ish so it takes the
|
||||
conservative path.
|
||||
"""
|
||||
try:
|
||||
return Requirement(spec).url is not None
|
||||
except InvalidRequirement:
|
||||
return True
|
||||
|
||||
|
||||
def _uv_install_args(
|
||||
spec: str,
|
||||
*,
|
||||
channel_dist: str | None,
|
||||
constraints: str | None,
|
||||
target: str | None,
|
||||
env: dict[str, str],
|
||||
) -> list[str]:
|
||||
"""Build the ``uv pip install`` argv (mirrors install_package's shape)."""
|
||||
args = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--quiet",
|
||||
spec,
|
||||
# Mirrors install_package: custom components may need a different
|
||||
# version of a package than the one HA built wheels for.
|
||||
"--index-strategy",
|
||||
"unsafe-first-match",
|
||||
]
|
||||
args += _scoped_install_flags(spec, channel_dist)
|
||||
if constraints is not None:
|
||||
args += ["--constraint", constraints]
|
||||
if target:
|
||||
args += ["--target", os.path.abspath(target)]
|
||||
elif (
|
||||
not is_virtual_env()
|
||||
# install_package's _UV_ENV_PYTHON_VARS, mirrored: an explicit uv
|
||||
# python selection means uv already installs to the right place.
|
||||
and not any(var in env for var in ("UV_SYSTEM_PYTHON", "UV_PYTHON"))
|
||||
and (user_site := site.getusersitepackages())
|
||||
):
|
||||
# uv has no --user (astral-sh/uv#2077); install_package's workaround.
|
||||
args += ["--python", sys.executable, "--target", os.path.abspath(user_site)]
|
||||
return args
|
||||
|
||||
|
||||
def _run_uv_install(args: list[str], env: dict[str, str]) -> str | None:
|
||||
"""Run one uv install attempt; return None on success, else its stderr.
|
||||
|
||||
Bounded by ``_UV_INSTALL_TIMEOUT_SECONDS``: this runs inside the
|
||||
process-wide tracked-install slot, and the extra-index fallback can run
|
||||
it twice, so a wedged uv would otherwise pin an executor thread (and
|
||||
block the next bring-up) with no upper bound. The budget is deliberately
|
||||
generous — a cold ARM wheel build is slow but finite.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
timeout=_UV_INSTALL_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
return f"{type(err).__name__}: {err}"
|
||||
if result.returncode != 0:
|
||||
return (result.stderr or "").strip() or f"exit code {result.returncode}"
|
||||
return None
|
||||
|
||||
|
||||
def _exact_pinned_version(spec: str) -> str | None:
|
||||
"""Return the version of an exact ``==``/``===`` single-clause pin, or None.
|
||||
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"ruamel.yaml>=0.18.0"
|
||||
],
|
||||
"version": "1.3.1"
|
||||
"version": "1.3.2"
|
||||
}
|
||||
|
||||
@@ -1114,9 +1114,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# here means the split never has anything to split.
|
||||
if minor < 5:
|
||||
if is_object_entry and (device_id := (data.get(CONF_OBJECT) or {}).get("ha_device_id")):
|
||||
from .helpers.device_link import shed_owned_devices
|
||||
from .helpers.device_link import is_self_link, shed_owned_devices
|
||||
|
||||
shed_owned_devices(hass, own_entry_id=entry.entry_id, source_device_id=device_id)
|
||||
# A SELF-link (the stored id names our own device — the old picker
|
||||
# offered the object's doppelgänger) must not be shed: the "source"
|
||||
# is the very device the entities live on, and shedding it deletes
|
||||
# and restores it in one boot (prod 2026-08-01, three Roborocks).
|
||||
# Setup raises the device_link_self notice for these instead.
|
||||
if not is_self_link(hass, device_id, own_entry_id=entry.entry_id):
|
||||
shed_owned_devices(hass, own_entry_id=entry.entry_id, source_device_id=device_id)
|
||||
minor = 5
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=data, minor_version=minor)
|
||||
@@ -1277,16 +1283,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
|
||||
# moment the link resolves again.
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .helpers.device_link import is_self_link
|
||||
|
||||
issue_id = f"device_link_lost_{entry.entry_id}"
|
||||
if resolved is None:
|
||||
# Two distinct stories share one issue id (so resolve/delete
|
||||
# stays single-path): the linked device is GONE, or the link
|
||||
# points at the object's own doppelgänger device — the old
|
||||
# picker offered it under the appliance's exact name. The
|
||||
# advice differs, so the translation key does too. Both are
|
||||
# fixable: the flow relinks to the device the user picks.
|
||||
self_link = is_self_link(hass, stored_device_id, own_entry_id=entry.entry_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
is_fixable=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="device_link_lost",
|
||||
translation_key="device_link_self" if self_link else "device_link_lost",
|
||||
translation_placeholders={"object": obj_data.get("name") or entry.title},
|
||||
data={"entry_id": entry.entry_id},
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
@@ -1347,6 +1363,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
if not is_global:
|
||||
# Parent nesting is written as an explicit registry `via_device_id`
|
||||
# AFTER the platforms created the devices — DeviceInfo's `via_device`
|
||||
# identifier tuple is deprecated (removal HA 2027.8) and its
|
||||
# cross-entry identifier lookup is what 2026.8's scoping ends. Runs
|
||||
# both directions, so a child that boots before its parent still nests.
|
||||
from .helpers.device_link import sync_via_device_links
|
||||
|
||||
sync_via_device_links(hass, entry)
|
||||
|
||||
if linked_device_live:
|
||||
# The entities of a linked object live on the appliance'''s device, so a
|
||||
# device of our own with nothing on it is a leftover — of the object'''s
|
||||
|
||||
@@ -1050,6 +1050,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
used_parts=enriched_used,
|
||||
auto=auto,
|
||||
)
|
||||
# #73: a completed cycle retires its in-cycle checklist ticks — the
|
||||
# snapshot that matters is in the history entry above.
|
||||
self._store.clear_checklist_progress(task_id)
|
||||
|
||||
# Link the completion photo to this task so it also surfaces under the
|
||||
# object's documents and is deref'd correctly on cleanup. Best-effort:
|
||||
@@ -1248,6 +1251,8 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# (not a deliberate skip). An explicit as_missed=True always wins.
|
||||
missed = as_missed or task.status == MaintenanceStatus.OVERDUE
|
||||
task.skip(reason=reason, as_missed=missed)
|
||||
# #73: skipping restarts the cycle — the ticks belong to the old one.
|
||||
self._store.clear_checklist_progress(task_id)
|
||||
|
||||
await self._persist_and_signal_task_change(task_id, task)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from ..const import CONF_OBJECT, DOMAIN, GLOBAL_UNIQUE_ID
|
||||
from ..const import CONF_OBJECT, DOMAIN
|
||||
from ..coordinator import MaintenanceCoordinator
|
||||
from ..helpers import device_link
|
||||
|
||||
@@ -71,12 +71,14 @@ class MaintenanceEntity(CoordinatorEntity[MaintenanceCoordinator]):
|
||||
* linked to an existing device → ``None``. The entity is already
|
||||
attached via ``device_entry`` (see ``__init__``); describing a device
|
||||
here would create one of our own instead.
|
||||
* ``parent_entry_id`` set → our own device, nested under the parent
|
||||
object's device via ``via_device``.
|
||||
* neither → our own stand-alone device (the historical shape).
|
||||
* otherwise → our own device. Parent nesting (``parent_entry_id``) is
|
||||
NOT declared here: DeviceInfo's ``via_device`` identifier tuple is
|
||||
deprecated (removal HA 2027.8) and resolves identifiers across
|
||||
config entries — exactly what 2026.8's registry scoping ends.
|
||||
Setup writes ``via_device_id`` into the registry after the platforms
|
||||
load (``device_link.sync_via_device_links``).
|
||||
"""
|
||||
obj = self._object_data
|
||||
hass = self.coordinator.hass
|
||||
|
||||
if self._linked_device is not None:
|
||||
return None
|
||||
@@ -102,11 +104,6 @@ class MaintenanceEntity(CoordinatorEntity[MaintenanceCoordinator]):
|
||||
if obj.get("area_id"):
|
||||
device_info["suggested_area"] = obj["area_id"]
|
||||
|
||||
if parent_entry_id := obj.get("parent_entry_id"):
|
||||
parent = hass.config_entries.async_get_entry(parent_entry_id)
|
||||
if parent is not None and parent.domain == DOMAIN and parent.unique_id and parent.unique_id != GLOBAL_UNIQUE_ID:
|
||||
device_info["via_device"] = (DOMAIN, parent.unique_id)
|
||||
|
||||
return device_info
|
||||
|
||||
@property
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function mountPanel(
|
||||
objects: unknown[],
|
||||
extraHandlers: Record<string, WsHandler> = {},
|
||||
) {
|
||||
const { hass, sent } = createMockHass({
|
||||
const { hass, sent, subscriptions } = createMockHass({
|
||||
handlers: {
|
||||
"maintenance_supporter/objects": () => ({ objects }),
|
||||
"maintenance_supporter/statistics": () => ({
|
||||
@@ -97,7 +97,7 @@ export async function mountPanel(
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await el.updateComplete;
|
||||
return { el, sent };
|
||||
return { el, sent, subscriptions };
|
||||
}
|
||||
|
||||
export function sr(el: HTMLElement): ShadowRoot {
|
||||
|
||||
@@ -97,7 +97,10 @@ export type WsHandler = (msg: SentMessage) => Promise<unknown> | unknown;
|
||||
export interface CreateMockHassResult {
|
||||
hass: {
|
||||
language: string;
|
||||
connection: { sendMessagePromise: (msg: SentMessage) => Promise<unknown> };
|
||||
connection: {
|
||||
sendMessagePromise: (msg: SentMessage) => Promise<unknown>;
|
||||
subscribeMessage: (cb: (event: unknown) => void, msg: SentMessage) => Promise<() => void>;
|
||||
};
|
||||
callService: (
|
||||
domain: string, service: string,
|
||||
data?: Record<string, unknown>, target?: Record<string, unknown>,
|
||||
@@ -107,6 +110,8 @@ export interface CreateMockHassResult {
|
||||
};
|
||||
sent: SentMessage[];
|
||||
serviceCalls: ServiceCall[];
|
||||
/** Captured subscribeMessage registrations — push events via `.push(ev)`. */
|
||||
subscriptions: Array<{ msg: SentMessage; push: (event: unknown) => void }>;
|
||||
}
|
||||
|
||||
export interface CreateMockHassOptions {
|
||||
@@ -157,15 +162,31 @@ export function createMockHass(opts: CreateMockHassOptions = {}): CreateMockHass
|
||||
serviceCalls.push({ domain, service, data, target });
|
||||
};
|
||||
|
||||
// Captured subscriptions: tests push events into components via
|
||||
// `subscriptions.find(...).push(event)`.
|
||||
const subscriptions: Array<{ msg: SentMessage; push: (event: unknown) => void }> = [];
|
||||
const subscribeMessage = async (
|
||||
cb: (event: unknown) => void,
|
||||
msg: SentMessage,
|
||||
): Promise<() => void> => {
|
||||
const entry = { msg, push: (event: unknown) => cb(event) };
|
||||
subscriptions.push(entry);
|
||||
return () => {
|
||||
const i = subscriptions.indexOf(entry);
|
||||
if (i >= 0) subscriptions.splice(i, 1);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
hass: {
|
||||
language: opts.language ?? "en",
|
||||
connection: { sendMessagePromise },
|
||||
connection: { sendMessagePromise, subscribeMessage },
|
||||
callService,
|
||||
services: opts.services,
|
||||
states: opts.states,
|
||||
},
|
||||
sent,
|
||||
serviceCalls,
|
||||
subscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,31 @@ async function mount(canWrite: boolean): Promise<MaintenancePartsSection> {
|
||||
}
|
||||
|
||||
describe("parts-section", () => {
|
||||
it("shows the inventory value only when a part has price AND tracked stock (#104)", async () => {
|
||||
// PARTS as-is: p1 has stock but no cost, p2 no stock → chip hidden.
|
||||
const bare = await mount(false);
|
||||
expect(bare.shadowRoot!.querySelector(".inventory-value")).to.equal(null);
|
||||
|
||||
const priced: MaintenancePart[] = [
|
||||
{ id: "p1", name: "Filter", cost: 12.5, stock: 3, is_low: false },
|
||||
{ id: "p2", name: "Brush", cost: 4, stock: null, is_low: false }, // untracked → no contribution
|
||||
{ id: "p3", name: "Seal", cost: null, stock: 5, is_low: false }, // unpriced → no contribution
|
||||
];
|
||||
const el = await fixture<MaintenancePartsSection>(html`
|
||||
<maintenance-parts-section
|
||||
.hass=${{ language: "en", connection: { sendMessagePromise: async () => ({}) } } as never}
|
||||
.entryId=${"e1"}
|
||||
.parts=${priced}
|
||||
.currencySymbol=${"€"}
|
||||
></maintenance-parts-section>
|
||||
`);
|
||||
await el.updateComplete;
|
||||
const chip = el.shadowRoot!.querySelector(".inventory-value")!;
|
||||
expect(chip, "value chip rendered").to.exist;
|
||||
expect(chip.textContent).to.include("37.50");
|
||||
expect(chip.textContent).to.include("€");
|
||||
});
|
||||
|
||||
it("renders a row per part with stock badge, identifiers and location", async () => {
|
||||
const el = await mount(false);
|
||||
const rows = el.shadowRoot!.querySelectorAll(".part-row");
|
||||
|
||||
+54
@@ -57,6 +57,9 @@ function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
|
||||
taskId: "t1",
|
||||
objectName: "Pool Pump",
|
||||
objectDocUrl: null,
|
||||
objectManualDocs: [],
|
||||
openManualDoc: () => {},
|
||||
setChecklistItem: () => {},
|
||||
isOperator: false,
|
||||
actionLoading: false,
|
||||
moreMenuOpen: false,
|
||||
@@ -212,6 +215,57 @@ describe("task-detail renderer", () => {
|
||||
expect(host2.querySelector(".kpi-bar")).to.be.null;
|
||||
});
|
||||
|
||||
it("object-manual row falls back to an attached manual when the URL is empty", () => {
|
||||
let opened: unknown = null;
|
||||
const host = mount(task(), ctx({
|
||||
objectDocUrl: null,
|
||||
objectManualDocs: [{ id: "d1", title: "Pump Handbook", kind: "file" }],
|
||||
openManualDoc: (d) => { opened = d; },
|
||||
}));
|
||||
const link = [...host.querySelectorAll(".task-meta-link a")]
|
||||
.find((a) => a.textContent?.includes("Pool Pump")) as HTMLElement | undefined;
|
||||
expect(link, "fallback manual link rendered").to.exist;
|
||||
expect(link!.getAttribute("title")).to.equal("Pump Handbook");
|
||||
link!.click();
|
||||
expect((opened as { id?: string })?.id).to.equal("d1");
|
||||
});
|
||||
|
||||
it("the URL field still wins over attached manuals", () => {
|
||||
const host = mount(task(), ctx({
|
||||
objectDocUrl: "https://vendor.example/manual",
|
||||
objectManualDocs: [{ id: "d1", title: "Pump Handbook", kind: "file" }],
|
||||
}));
|
||||
const link = [...host.querySelectorAll(".task-meta-link a")]
|
||||
.find((a) => a.textContent?.includes("Pool Pump")) as HTMLElement | undefined;
|
||||
expect(link, "manual row rendered").to.exist;
|
||||
expect(link!.getAttribute("href")).to.equal("https://vendor.example/manual");
|
||||
});
|
||||
|
||||
it("checklist ticks render from progress and fire setChecklistItem (#73)", () => {
|
||||
const calls: Array<[string, boolean]> = [];
|
||||
const host = mount(
|
||||
task({ checklist: ["Drain", "Clean", "Refill"], checklist_progress: { Clean: true } }),
|
||||
ctx({
|
||||
features: {
|
||||
adaptive: false, predictions: false, seasonal: false,
|
||||
environmental: false, budget: false, groups: false,
|
||||
checklists: true, schedule_time: false, completion_actions: false,
|
||||
},
|
||||
setChecklistItem: (item, done) => calls.push([item, done]),
|
||||
}),
|
||||
);
|
||||
const header = host.querySelector(".checklist-preview-header")!;
|
||||
expect(header.textContent).to.include("1/3");
|
||||
const boxes = [...host.querySelectorAll<HTMLInputElement>(".checklist-preview-list input")];
|
||||
expect(boxes.length).to.equal(3);
|
||||
expect(boxes[1].checked).to.be.true;
|
||||
expect(boxes[0].checked).to.be.false;
|
||||
expect(host.querySelectorAll(".checklist-preview-list li.checked").length).to.equal(1);
|
||||
|
||||
boxes[0].click();
|
||||
expect(calls).to.deep.equal([["Drain", true]]);
|
||||
});
|
||||
|
||||
it("KPI bar shows warning days and currency symbol", () => {
|
||||
const host = mount(task(), ctx({ currencySymbol: "$" }));
|
||||
const kpi = host.querySelector(".kpi-bar")!;
|
||||
|
||||
+3
@@ -54,6 +54,9 @@ function ctx(overrides: Partial<TaskDetailContext> = {}): TaskDetailContext {
|
||||
taskId: "t1",
|
||||
objectName: "Pool Pump",
|
||||
objectDocUrl: null,
|
||||
objectManualDocs: [],
|
||||
openManualDoc: () => {},
|
||||
setChecklistItem: () => {},
|
||||
isOperator: false,
|
||||
actionLoading: false,
|
||||
moreMenuOpen: false,
|
||||
|
||||
+333
-11
@@ -18,10 +18,35 @@ interface BatteryRow {
|
||||
level: number | null;
|
||||
days_until: number | null;
|
||||
available?: boolean;
|
||||
/** Where the ~date comes from: "trend" (discharge regression) or "typical"
|
||||
* (type-lifetime table). */
|
||||
predicted_source?: "trend" | "typical";
|
||||
prediction_confidence?: "medium" | "high" | null;
|
||||
/** Charged, never bought — the row never feeds the shopping groupings and
|
||||
* a ~date only appears when the discharge trend earned one. */
|
||||
rechargeable?: boolean;
|
||||
/** This battery's own low threshold (Battery Notes' configured value or
|
||||
* the fleet floor, whichever is higher) — the level bar colors against
|
||||
* it, not against a fixed 20 %. */
|
||||
low_threshold?: number;
|
||||
}
|
||||
interface RosterRow extends BatteryRow {
|
||||
status: "low" | "soon" | "ok";
|
||||
}
|
||||
/** 30 d downsampled level history per battery, for the roster sparklines.
|
||||
* threshold = the same low threshold the trend forecast regresses toward,
|
||||
* so the dotted projection ends exactly where the ~date comes from.
|
||||
* jump = an upward step that looks like a swap nobody recorded in Battery
|
||||
* Notes (the forecast still anchors on the dead battery's date) — with the
|
||||
* device to call `battery_notes.set_battery_replaced` on. */
|
||||
type HistorySeries = Record<
|
||||
string,
|
||||
{
|
||||
points: [number, number][];
|
||||
threshold: number;
|
||||
jump?: { at: number; from: number; to: number; device_id: string };
|
||||
}
|
||||
>;
|
||||
interface Overview {
|
||||
available: boolean;
|
||||
configured: boolean;
|
||||
@@ -45,6 +70,13 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
@state() private _loading = false;
|
||||
@state() private _marking = false;
|
||||
@state() private _error = "";
|
||||
@state() private _history: HistorySeries | null = null;
|
||||
// Urgency by default (issue #123: "soon sat in the middle of the list");
|
||||
// the choice is remembered per browser.
|
||||
@state() private _rosterSort: "name" | "urgency" = MaintenanceBatteryFleetSection._storedSort();
|
||||
@state() private _typeFilter: string | null = null;
|
||||
@state() private _recorded: string[] = [];
|
||||
private _historyRequested = false;
|
||||
private _localeReady = false;
|
||||
|
||||
private get _lang(): string {
|
||||
@@ -139,20 +171,165 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy: the recorder-backed history is fetched once, when the roster is
|
||||
* first expanded — most panel visits never open it. */
|
||||
private _loadHistory = async (e: Event): Promise<void> => {
|
||||
if (!(e.target as HTMLDetailsElement).open || this._historyRequested) return;
|
||||
this._historyRequested = true;
|
||||
try {
|
||||
const res = await this.hass.connection.sendMessagePromise<{ series: HistorySeries }>({
|
||||
type: "maintenance_supporter/battery_fleet/overview_history",
|
||||
});
|
||||
this._history = res.series;
|
||||
} catch {
|
||||
this._history = null; // sparklines are an enhancement — rows render without them
|
||||
}
|
||||
};
|
||||
|
||||
/** Inline-SVG sparkline: 30 d level line, a faint threshold line, and —
|
||||
* where the ~date comes from the discharge trend — a dotted projection
|
||||
* from the last reading down to the threshold, so the date is visible
|
||||
* instead of merely stated. */
|
||||
private _sparkline(b: RosterRow) {
|
||||
const h = this._history?.[b.entity_id];
|
||||
if (!h || h.points.length < 2) return nothing;
|
||||
const W = 110, H = 24, P = 2;
|
||||
const t0 = h.points[0][0];
|
||||
const tLast = h.points[h.points.length - 1][0];
|
||||
const nowSec = Date.now() / 1000;
|
||||
const projEnd =
|
||||
b.status !== "low" && b.predicted_source === "trend" && b.days_until != null
|
||||
? nowSec + b.days_until * 86400
|
||||
: null;
|
||||
const tMax = Math.max(tLast, projEnd ?? tLast);
|
||||
const x = (t: number) => (tMax === t0 ? P : P + ((t - t0) / (tMax - t0)) * (W - 2 * P));
|
||||
const y = (v: number) => P + (1 - Math.min(100, Math.max(0, v)) / 100) * (H - 2 * P);
|
||||
const line = h.points.map(([t, v]) => `${x(t).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
const vLast = h.points[h.points.length - 1][1];
|
||||
const yTh = y(h.threshold).toFixed(1);
|
||||
return html`<svg
|
||||
class="bf-spark"
|
||||
viewBox="0 0 ${W} ${H}"
|
||||
role="img"
|
||||
aria-label=${t("battery_fleet_sparkline_hint", this._lang)}
|
||||
>
|
||||
<title>${t("battery_fleet_sparkline_hint", this._lang)}</title>
|
||||
<line class="bf-spark-th" x1="0" y1=${yTh} x2=${W} y2=${yTh}></line>
|
||||
<polyline class="bf-spark-line" points=${line}></polyline>
|
||||
${projEnd !== null
|
||||
? html`<line
|
||||
class="bf-spark-proj"
|
||||
x1=${x(tLast).toFixed(1)}
|
||||
y1=${y(vLast).toFixed(1)}
|
||||
x2=${x(projEnd).toFixed(1)}
|
||||
y2=${yTh}
|
||||
></line>`
|
||||
: nothing}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
private static readonly _SORT_KEY = "ms_bf_roster_sort";
|
||||
|
||||
private static _storedSort(): "name" | "urgency" {
|
||||
try {
|
||||
const v = localStorage.getItem(MaintenanceBatteryFleetSection._SORT_KEY);
|
||||
return v === "name" ? "name" : "urgency";
|
||||
} catch {
|
||||
return "urgency";
|
||||
}
|
||||
}
|
||||
|
||||
private _setSort(mode: "name" | "urgency"): void {
|
||||
this._rosterSort = mode;
|
||||
try {
|
||||
localStorage.setItem(MaintenanceBatteryFleetSection._SORT_KEY, mode);
|
||||
} catch {
|
||||
// storage unavailable — the toggle still works for this visit
|
||||
}
|
||||
}
|
||||
|
||||
/** Urgency (the default, issue #123): low rows first — emptiest first —
|
||||
* then the soonest forecast, dateless rows last. Name mode keeps the
|
||||
* alphabetical lookup list. */
|
||||
private _sortedRoster(rows: RosterRow[]): RosterRow[] {
|
||||
const filtered = this._typeFilter === null ? rows : rows.filter((r) => r.battery_type === this._typeFilter);
|
||||
if (this._rosterSort === "name") return filtered;
|
||||
// Low rows rank far below everything and among themselves by LEVEL
|
||||
// ascending (a 6 % battery before an 18 % one); the rest by days-until.
|
||||
const rank = (r: RosterRow) => (r.status === "low" ? -1000 + (r.level ?? 101) / 101 : (r.days_until ?? Infinity));
|
||||
return [...filtered].sort(
|
||||
(a, b) => rank(a) - rank(b) || a.device_name.localeCompare(b.device_name),
|
||||
);
|
||||
}
|
||||
|
||||
/** The forecast as a date a person can plan with, not a day count.
|
||||
* `days_until` comes from last-replaced + typical lifetime, so it is an
|
||||
* estimate — the tilde in the template says so. Negative values (past the
|
||||
* typical lifetime but not reported low yet) render as past dates, which
|
||||
* is honest: the battery is living on borrowed time. */
|
||||
private _predictedDate(daysUntil: number): string {
|
||||
const when = new Date(Date.now() + daysUntil * 864e5);
|
||||
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(when);
|
||||
return this._fmtDate(Date.now() + daysUntil * 864e5);
|
||||
}
|
||||
|
||||
private _shoppingLine(needs: Record<string, number>): string {
|
||||
return Object.entries(needs)
|
||||
.map(([type, qty]) => `${qty}× ${type}`)
|
||||
.join(" · ");
|
||||
private _fmtDate(epochMs: number): string {
|
||||
return new Intl.DateTimeFormat(this._lang, { day: "numeric", month: "numeric", year: "numeric" }).format(new Date(epochMs));
|
||||
}
|
||||
|
||||
/** The grouped shopping quantities as CLICKABLE chips: a type filters the
|
||||
* roster to the devices that need it — "which devices need those 4× AAA?"
|
||||
* without scanning. Clicking the active chip clears the filter. */
|
||||
private _shoppingLine(needs: Record<string, number>) {
|
||||
return Object.entries(needs).map(
|
||||
([type, qty]) => html`<button
|
||||
class="bf-type-chip ${this._typeFilter === type ? "bf-type-chip-active" : ""}"
|
||||
title=${t("battery_fleet_filter_type", this._lang)}
|
||||
@click=${() => this._toggleTypeFilter(type)}
|
||||
>
|
||||
${qty}× ${type}
|
||||
</button>`,
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleTypeFilter(type: string): void {
|
||||
this._typeFilter = this._typeFilter === type ? null : type;
|
||||
if (this._typeFilter !== null) {
|
||||
const details = this.shadowRoot?.querySelector<HTMLDetailsElement>("details.bf-roster");
|
||||
if (details && !details.open) details.open = true; // fires toggle → history loads
|
||||
}
|
||||
}
|
||||
|
||||
/** One-click fix for a detected-but-unrecorded swap: record the DETECTED
|
||||
* jump time in Battery Notes, so the forecast re-anchors on the real
|
||||
* replacement instead of the dead battery's date. */
|
||||
private async _recordJump(entityId: string, jump: { at: number; device_id: string }): Promise<void> {
|
||||
if (this._marking) return;
|
||||
this._marking = true;
|
||||
this._error = "";
|
||||
try {
|
||||
await this.hass.callService("battery_notes", "set_battery_replaced", {
|
||||
device_id: jump.device_id,
|
||||
datetime_replaced: new Date(jump.at * 1000).toISOString(),
|
||||
});
|
||||
this._recorded = [...this._recorded, entityId];
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._error = describeWsError(e, this._lang);
|
||||
} finally {
|
||||
this._marking = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Purely visual level bar next to the number — scannable at a glance.
|
||||
* Colored against the battery's OWN low threshold: red at/below it,
|
||||
* amber inside a 20-point approach band, green above. */
|
||||
private _levelBar(b: BatteryRow) {
|
||||
const level = b.level;
|
||||
if (level == null) return nothing;
|
||||
const t = b.low_threshold ?? 20;
|
||||
const cls = level <= t ? "bad" : level <= t + 20 ? "warn" : "good";
|
||||
return html`<span class="bf-bar" aria-hidden="true"
|
||||
><span class="bf-bar-fill bf-bar-${cls}" style="width: ${Math.min(100, Math.max(0, level))}%"></span
|
||||
></span>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -199,10 +376,16 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
? html`<span class="bf-offline">${t("battery_fleet_offline", L)}</span>`
|
||||
: nothing}
|
||||
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
|
||||
${b.rechargeable
|
||||
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
|
||||
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
|
||||
></span>`
|
||||
: nothing}
|
||||
${this._levelBar(b)}
|
||||
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
|
||||
<button
|
||||
class="bf-mark"
|
||||
title=${t("battery_fleet_mark_one", L)}
|
||||
title=${b.rechargeable ? t("battery_fleet_mark_recharged", L) : t("battery_fleet_mark_one", L)}
|
||||
.disabled=${this._marking}
|
||||
@click=${() => this._mark([b.entity_id])}
|
||||
>
|
||||
@@ -238,20 +421,57 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
: nothing}
|
||||
${ov.all?.length
|
||||
? html`
|
||||
<details class="bf-roster">
|
||||
<details class="bf-roster" @toggle=${this._loadHistory}>
|
||||
<summary>${t("battery_fleet_all", L)} (${ov.all.length})</summary>
|
||||
<div class="bf-roster-tools">
|
||||
<button
|
||||
class="bf-sort ${this._rosterSort === "urgency" ? "bf-sort-active" : ""}"
|
||||
@click=${() => this._setSort("urgency")}
|
||||
>
|
||||
${t("battery_fleet_sort_urgency", L)}
|
||||
</button>
|
||||
<button
|
||||
class="bf-sort ${this._rosterSort === "name" ? "bf-sort-active" : ""}"
|
||||
@click=${() => this._setSort("name")}
|
||||
>
|
||||
${t("battery_fleet_sort_name", L)}
|
||||
</button>
|
||||
</div>
|
||||
<div class="bf-rows">
|
||||
${ov.all.map(
|
||||
${this._sortedRoster(ov.all).map(
|
||||
(b) => html`
|
||||
<div class="bf-row">
|
||||
<span class="bf-dev">${b.device_name}</span>
|
||||
<span class="bf-status bf-${b.status}">${t("battery_fleet_status_" + b.status, L)}</span>
|
||||
<span class="bf-type">${b.quantity}× ${b.battery_type}</span>
|
||||
${b.rechargeable
|
||||
? html`<span class="bf-recharge" title=${t("battery_fleet_rechargeable", L)}
|
||||
><ha-icon icon="mdi:battery-charging-outline"></ha-icon
|
||||
></span>`
|
||||
: nothing}
|
||||
${this._sparkline(b)}
|
||||
${this._levelBar(b)}
|
||||
${b.level != null ? html`<span class="bf-level">${b.level}%</span>` : nothing}
|
||||
${(() => {
|
||||
const jump = this._history?.[b.entity_id]?.jump;
|
||||
if (!jump || this._recorded.includes(b.entity_id)) return nothing;
|
||||
return html`<button
|
||||
class="bf-mark bf-jump"
|
||||
title=${t("battery_fleet_record_replacement", L).replace("{date}", this._fmtDate(jump.at * 1000))}
|
||||
.disabled=${this._marking}
|
||||
@click=${() => this._recordJump(b.entity_id, jump)}
|
||||
>
|
||||
<ha-icon icon="mdi:calendar-sync"></ha-icon>
|
||||
</button>`;
|
||||
})()}
|
||||
${b.days_until != null
|
||||
? html`<span
|
||||
class="bf-predicted"
|
||||
title=${t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
|
||||
class="bf-predicted ${b.predicted_source === "trend" ? "bf-trend" : ""}"
|
||||
title=${b.predicted_source === "trend"
|
||||
? t("battery_fleet_predicted_trend", L)
|
||||
.replace("{date}", this._predictedDate(b.days_until))
|
||||
.replace("{confidence}", t("cal_confidence_" + (b.prediction_confidence || "medium"), L))
|
||||
: t("battery_fleet_predicted_on", L).replace("{date}", this._predictedDate(b.days_until))}
|
||||
>~${this._predictedDate(b.days_until)}</span
|
||||
>`
|
||||
: nothing}
|
||||
@@ -385,6 +605,102 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
.bf-recharge {
|
||||
color: var(--secondary-text-color);
|
||||
display: inline-flex;
|
||||
cursor: help;
|
||||
}
|
||||
.bf-recharge ha-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
.bf-spark {
|
||||
width: 110px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
cursor: help;
|
||||
}
|
||||
/* On phones the row cannot fit name + chips + curve + bar + date: the
|
||||
* decorations yield (the percentage still carries the number). */
|
||||
@media (max-width: 640px) {
|
||||
.bf-spark,
|
||||
.bf-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.bf-spark-line {
|
||||
fill: none;
|
||||
stroke: var(--primary-color);
|
||||
stroke-width: 1.5;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.bf-spark-proj {
|
||||
stroke: var(--primary-color);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 2 3;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.bf-spark-th {
|
||||
stroke: var(--error-color, #f44336);
|
||||
stroke-width: 1;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.bf-type-chip {
|
||||
background: none;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
margin: 0 4px 2px 0;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bf-type-chip-active {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.bf-bar {
|
||||
width: 30px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--divider-color);
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.bf-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.bf-bar-good {
|
||||
background: var(--success-color, #4caf50);
|
||||
}
|
||||
.bf-bar-warn {
|
||||
background: var(--warning-color, #ff9800);
|
||||
}
|
||||
.bf-bar-bad {
|
||||
background: var(--error-color, #f44336);
|
||||
}
|
||||
.bf-jump ha-icon {
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
.bf-roster-tools {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin: 8px 0 2px;
|
||||
}
|
||||
.bf-sort {
|
||||
background: none;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bf-sort-active {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.bf-level {
|
||||
font-size: 12px;
|
||||
color: var(--error-color, #f44336);
|
||||
@@ -448,6 +764,12 @@ export class MaintenanceBatteryFleetSection extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Trend-based dates (discharge regression) get a dotted underline — the
|
||||
tooltip carries source + confidence. */
|
||||
.bf-predicted.bf-trend {
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.bf-total {
|
||||
font-size: 12px;
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
@@ -52,6 +52,10 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
* objects can carry the same part id, so part_id alone would merge pools. */
|
||||
@state() private _usedParts: Record<string, TaskPartLink> = {};
|
||||
|
||||
/** #73: in-cycle ticks (keyed by item TEXT) recorded on the task detail —
|
||||
* prefill the dialog so nobody re-ticks what is already done. */
|
||||
@property({ attribute: false }) public checklistPrefill: Record<string, boolean> = {};
|
||||
|
||||
public open(): void {
|
||||
if (this._open) return;
|
||||
this._open = true;
|
||||
@@ -59,7 +63,13 @@ export class MaintenanceCompleteDialog extends LitElement {
|
||||
this._cost = "";
|
||||
this._duration = "";
|
||||
this._error = "";
|
||||
this._checklistState = {};
|
||||
// The dialog's own state is INDEX-keyed (historical shape, flows into the
|
||||
// history entry as-is) — map the text-keyed in-cycle ticks onto indices.
|
||||
this._checklistState = Object.fromEntries(
|
||||
this.checklist
|
||||
.map((item, i) => [String(i), !!this.checklistPrefill[item]] as const)
|
||||
.filter(([, done]) => done),
|
||||
);
|
||||
this._feedback = "needed";
|
||||
this._photoDocId = "";
|
||||
this._photoPreview = "";
|
||||
|
||||
@@ -59,6 +59,7 @@ export class MaintenancePartsSection extends LitElement {
|
||||
@property({ attribute: false }) public entryId!: string;
|
||||
@property({ attribute: false }) public parts: MaintenancePart[] = [];
|
||||
@property({ type: Boolean }) public canWrite = false;
|
||||
@property({ attribute: false }) public currencySymbol = "€";
|
||||
|
||||
@state() private _editing: PartForm | null = null;
|
||||
@state() private _busy = false;
|
||||
@@ -335,6 +336,19 @@ export class MaintenancePartsSection extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Inventory value = Σ unit cost × tracked stock (#104). Parts without a
|
||||
* price or without tracked stock contribute nothing; null = no part has
|
||||
* both, so the chip stays hidden rather than showing a misleading 0. */
|
||||
private _inventoryValue(): number | null {
|
||||
let sum = 0, any = false;
|
||||
for (const p of this.parts) {
|
||||
const cost = typeof p.cost === "number" ? p.cost : null;
|
||||
const stock = typeof p.stock === "number" ? p.stock : null;
|
||||
if (cost !== null && stock !== null) { sum += cost * stock; any = true; }
|
||||
}
|
||||
return any ? sum : null;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const L = this._lang;
|
||||
if (!this.parts.length && !this.canWrite) return nothing;
|
||||
@@ -343,6 +357,11 @@ export class MaintenancePartsSection extends LitElement {
|
||||
<h3>
|
||||
<ha-icon icon="mdi:package-variant"></ha-icon>
|
||||
${t("parts_section", L)} (${this.parts.length})
|
||||
${this._inventoryValue() !== null
|
||||
? html`<span class="inventory-value" title=${t("parts_inventory_value", L)}
|
||||
>${t("parts_inventory_value", L)}:
|
||||
${this._inventoryValue()!.toFixed(2)} ${this.currencySymbol}</span>`
|
||||
: nothing}
|
||||
</h3>
|
||||
${this.canWrite && !this._editing
|
||||
? html`<ha-button appearance="plain" @click=${() => this._openAdd()}>
|
||||
@@ -361,6 +380,13 @@ export class MaintenancePartsSection extends LitElement {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.inventory-value {
|
||||
margin-left: 8px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 400;
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Počet opakování",
|
||||
"series_end_until_label": "Datum konce",
|
||||
"parts_section": "Díly a spotřební materiál",
|
||||
"parts_inventory_value": "Hodnota zásob",
|
||||
"part_add": "Přidat díl",
|
||||
"part_name": "Název",
|
||||
"part_vendor": "Výrobce",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Brzy",
|
||||
"battery_fleet_status_ok": "V pořádku",
|
||||
"battery_fleet_predicted_on": "Očekáváno kolem {date}",
|
||||
"battery_fleet_predicted_trend": "Předpověď z trendu vybíjení této baterie: přibližně {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Akumulátor: nabíjí se místo výměny — nikdy na nákupním seznamu",
|
||||
"battery_fleet_sort_name": "Řadit podle názvu",
|
||||
"battery_fleet_sort_urgency": "Řadit podle naléhavosti",
|
||||
"battery_fleet_mark_recharged": "Označit jako nabitou",
|
||||
"battery_fleet_sparkline_hint": "Stav baterie za posledních 30 dní — tečkovaně: projekce k prahu vybití",
|
||||
"battery_fleet_filter_type": "Zobrazit pouze tento typ baterie",
|
||||
"battery_fleet_record_replacement": "Stav poskočil kolem {date} — zaznamenat tuto výměnu do Battery Notes",
|
||||
"battery_fleet_total": "Sledováno baterií: {n}",
|
||||
"battery_fleet_setup_button": "Flotila baterií",
|
||||
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Antal gange",
|
||||
"series_end_until_label": "Slutdato",
|
||||
"parts_section": "Dele & forbrugsvarer",
|
||||
"parts_inventory_value": "Lagerværdi",
|
||||
"part_add": "Tilføj del",
|
||||
"part_name": "Navn",
|
||||
"part_vendor": "Producent",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Snart",
|
||||
"battery_fleet_status_ok": "I orden",
|
||||
"battery_fleet_predicted_on": "Forventes omkring {date}",
|
||||
"battery_fleet_predicted_trend": "Forudsagt ud fra batteriets afladningstendens: omkring {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Genopladeligt: oplad i stedet for at udskifte — aldrig på indkøbslisten",
|
||||
"battery_fleet_sort_name": "Sortér efter navn",
|
||||
"battery_fleet_sort_urgency": "Sortér efter hastende grad",
|
||||
"battery_fleet_mark_recharged": "Markér som genopladet",
|
||||
"battery_fleet_sparkline_hint": "Batteriniveau de seneste 30 dage — stiplet: fremskrivning ned til lav-tærsklen",
|
||||
"battery_fleet_filter_type": "Vis kun denne batteritype",
|
||||
"battery_fleet_record_replacement": "Niveauet sprang omkring {date} — registrér denne udskiftning i Battery Notes",
|
||||
"battery_fleet_total": "{n} batterier overvåges",
|
||||
"battery_fleet_setup_button": "Batteriflåde",
|
||||
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Anzahl",
|
||||
"series_end_until_label": "Enddatum",
|
||||
"parts_section": "Teile & Verbrauchsmaterial",
|
||||
"parts_inventory_value": "Lagerwert",
|
||||
"part_add": "Teil hinzufügen",
|
||||
"part_name": "Name",
|
||||
"part_vendor": "Hersteller",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Bald",
|
||||
"battery_fleet_status_ok": "In Ordnung",
|
||||
"battery_fleet_predicted_on": "Voraussichtlich um {date}",
|
||||
"battery_fleet_predicted_trend": "Aus dem Entladetrend dieser Batterie vorhergesagt: etwa {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Wiederaufladbar: laden statt ersetzen — nie auf der Einkaufsliste",
|
||||
"battery_fleet_sort_name": "Nach Name sortieren",
|
||||
"battery_fleet_sort_urgency": "Nach Dringlichkeit sortieren",
|
||||
"battery_fleet_mark_recharged": "Als aufgeladen markieren",
|
||||
"battery_fleet_sparkline_hint": "Batteriestand der letzten 30 Tage — gepunktet: Prognose bis zur Low-Schwelle",
|
||||
"battery_fleet_filter_type": "Nur diesen Batterietyp anzeigen",
|
||||
"battery_fleet_record_replacement": "Der Batteriestand ist um den {date} gesprungen — diesen Wechsel in Battery Notes nachtragen",
|
||||
"battery_fleet_total": "{n} Batterien überwacht",
|
||||
"battery_fleet_setup_button": "Batterie-Flotte",
|
||||
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "Number of times",
|
||||
"series_end_until_label": "End date",
|
||||
"parts_section": "Parts & consumables",
|
||||
"parts_inventory_value": "Inventory value",
|
||||
"part_add": "Add part",
|
||||
"part_name": "Name",
|
||||
"part_vendor": "Manufacturer",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Soon",
|
||||
"battery_fleet_status_ok": "Healthy",
|
||||
"battery_fleet_predicted_on": "Expected around {date}",
|
||||
"battery_fleet_predicted_trend": "Predicted from this battery's discharge trend: around {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Rechargeable: charge instead of replacing — never on the shopping list",
|
||||
"battery_fleet_sort_name": "Sort by name",
|
||||
"battery_fleet_sort_urgency": "Sort by urgency",
|
||||
"battery_fleet_mark_recharged": "Mark as recharged",
|
||||
"battery_fleet_sparkline_hint": "Battery level over the last 30 days — dotted: projected until the low threshold",
|
||||
"battery_fleet_filter_type": "Show only this battery type",
|
||||
"battery_fleet_record_replacement": "The level jumped around {date} — record this replacement in Battery Notes",
|
||||
"battery_fleet_total": "{n} batteries tracked",
|
||||
"battery_fleet_setup_button": "Battery fleet",
|
||||
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Número de veces",
|
||||
"series_end_until_label": "Fecha de fin",
|
||||
"parts_section": "Piezas y consumibles",
|
||||
"parts_inventory_value": "Valor del inventario",
|
||||
"part_add": "Añadir pieza",
|
||||
"part_name": "Nombre",
|
||||
"part_vendor": "Fabricante",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Pronto",
|
||||
"battery_fleet_status_ok": "Correcta",
|
||||
"battery_fleet_predicted_on": "Previsto hacia {date}",
|
||||
"battery_fleet_predicted_trend": "Predicción según la tendencia de descarga de esta pila: hacia {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Recargable: se recarga en lugar de sustituirse — nunca en la lista de compra",
|
||||
"battery_fleet_sort_name": "Ordenar por nombre",
|
||||
"battery_fleet_sort_urgency": "Ordenar por urgencia",
|
||||
"battery_fleet_mark_recharged": "Marcar como recargada",
|
||||
"battery_fleet_sparkline_hint": "Nivel de batería de los últimos 30 días — punteado: proyección hasta el umbral bajo",
|
||||
"battery_fleet_filter_type": "Mostrar solo este tipo de pila",
|
||||
"battery_fleet_record_replacement": "El nivel dio un salto hacia el {date} — registrar esta sustitución en Battery Notes",
|
||||
"battery_fleet_total": "{n} baterías monitorizadas",
|
||||
"battery_fleet_setup_button": "Flota de baterías",
|
||||
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Kertojen määrä",
|
||||
"series_end_until_label": "Päättymispäivä",
|
||||
"parts_section": "Osat ja tarvikkeet",
|
||||
"parts_inventory_value": "Varaston arvo",
|
||||
"part_add": "Lisää osa",
|
||||
"part_name": "Nimi",
|
||||
"part_vendor": "Valmistaja",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Pian",
|
||||
"battery_fleet_status_ok": "Kunnossa",
|
||||
"battery_fleet_predicted_on": "Odotettavissa noin {date}",
|
||||
"battery_fleet_predicted_trend": "Ennustettu tämän pariston purkautumistrendistä: noin {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Ladattava: lataa vaihtamisen sijaan — ei koskaan ostoslistalle",
|
||||
"battery_fleet_sort_name": "Lajittele nimen mukaan",
|
||||
"battery_fleet_sort_urgency": "Lajittele kiireellisyyden mukaan",
|
||||
"battery_fleet_mark_recharged": "Merkitse ladatuksi",
|
||||
"battery_fleet_sparkline_hint": "Akun varaustaso viimeisten 30 päivän ajalta — pisteviiva: ennuste alarajaan asti",
|
||||
"battery_fleet_filter_type": "Näytä vain tämä paristotyyppi",
|
||||
"battery_fleet_record_replacement": "Varaustaso hyppäsi noin {date} — kirjaa tämä vaihto Battery Notesiin",
|
||||
"battery_fleet_total": "{n} paristoa seurannassa",
|
||||
"battery_fleet_setup_button": "Paristokanta",
|
||||
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Nombre de fois",
|
||||
"series_end_until_label": "Date de fin",
|
||||
"parts_section": "Pièces & consommables",
|
||||
"parts_inventory_value": "Valeur du stock",
|
||||
"part_add": "Ajouter une pièce",
|
||||
"part_name": "Nom",
|
||||
"part_vendor": "Fabricant",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Bientôt",
|
||||
"battery_fleet_status_ok": "Bon état",
|
||||
"battery_fleet_predicted_on": "Prévu vers {date}",
|
||||
"battery_fleet_predicted_trend": "Prédit à partir de la tendance de décharge de cette pile : vers {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Rechargeable : à recharger plutôt qu'à remplacer — jamais sur la liste d'achats",
|
||||
"battery_fleet_sort_name": "Trier par nom",
|
||||
"battery_fleet_sort_urgency": "Trier par urgence",
|
||||
"battery_fleet_mark_recharged": "Marquer comme rechargée",
|
||||
"battery_fleet_sparkline_hint": "Niveau de batterie des 30 derniers jours — pointillé : projection jusqu'au seuil bas",
|
||||
"battery_fleet_filter_type": "Afficher uniquement ce type de pile",
|
||||
"battery_fleet_record_replacement": "Le niveau a bondi vers le {date} — enregistrer ce remplacement dans Battery Notes",
|
||||
"battery_fleet_total": "{n} piles suivies",
|
||||
"battery_fleet_setup_button": "Parc de piles",
|
||||
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "कितनी बार",
|
||||
"series_end_until_label": "समाप्ति तिथि",
|
||||
"parts_section": "पुर्ज़े और उपभोग्य",
|
||||
"parts_inventory_value": "इन्वेंट्री मूल्य",
|
||||
"part_add": "पुर्ज़ा जोड़ें",
|
||||
"part_name": "नाम",
|
||||
"part_vendor": "निर्माता",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "जल्द",
|
||||
"battery_fleet_status_ok": "ठीक",
|
||||
"battery_fleet_predicted_on": "{date} के आसपास अपेक्षित",
|
||||
"battery_fleet_predicted_trend": "इस बैटरी के डिस्चार्ज रुझान से अनुमानित: लगभग {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "रिचार्जेबल: बदलने के बजाय चार्ज करें — खरीदारी सूची में कभी नहीं",
|
||||
"battery_fleet_sort_name": "नाम के अनुसार क्रमबद्ध करें",
|
||||
"battery_fleet_sort_urgency": "तात्कालिकता के अनुसार क्रमबद्ध करें",
|
||||
"battery_fleet_mark_recharged": "रिचार्ज हो गई के रूप में चिह्नित करें",
|
||||
"battery_fleet_sparkline_hint": "पिछले 30 दिनों का बैटरी स्तर — बिंदीदार: लो थ्रेशोल्ड तक का अनुमान",
|
||||
"battery_fleet_filter_type": "केवल यही बैटरी प्रकार दिखाएँ",
|
||||
"battery_fleet_record_replacement": "स्तर लगभग {date} को उछला — इस बदलाव को Battery Notes में दर्ज करें",
|
||||
"battery_fleet_total": "{n} बैटरियाँ ट्रैक की गईं",
|
||||
"battery_fleet_setup_button": "बैटरी फ्लीट",
|
||||
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "Alkalmak száma",
|
||||
"series_end_until_label": "Befejezés dátuma",
|
||||
"parts_section": "Alkatrészek és fogyóeszközök",
|
||||
"parts_inventory_value": "Készletérték",
|
||||
"part_add": "Alkatrész hozzáadása",
|
||||
"part_name": "Név",
|
||||
"part_vendor": "Gyártó",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Hamarosan",
|
||||
"battery_fleet_status_ok": "Rendben",
|
||||
"battery_fleet_predicted_on": "Várhatóan {date} körül",
|
||||
"battery_fleet_predicted_trend": "Az elem merülési trendjéből előrejelezve: kb. {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Újratölthető: cserélés helyett töltse fel — soha nem kerül a bevásárlólistára",
|
||||
"battery_fleet_sort_name": "Rendezés név szerint",
|
||||
"battery_fleet_sort_urgency": "Rendezés sürgősség szerint",
|
||||
"battery_fleet_mark_recharged": "Megjelölés feltöltöttként",
|
||||
"battery_fleet_sparkline_hint": "Akkumulátorszint az elmúlt 30 napban — pontozott: előrejelzés az alacsony küszöbig",
|
||||
"battery_fleet_filter_type": "Csak ez az elemtípus megjelenítése",
|
||||
"battery_fleet_record_replacement": "A szint {date} körül megugrott — rögzítse ezt a cserét a Battery Notes-ban",
|
||||
"battery_fleet_total": "{n} elem követve",
|
||||
"battery_fleet_setup_button": "Elemflotta",
|
||||
"battery_fleet_setup_done": "Elemflotta beállítva — egyetlen feladat követi az összes elemet.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Numero di volte",
|
||||
"series_end_until_label": "Data di fine",
|
||||
"parts_section": "Ricambi e consumabili",
|
||||
"parts_inventory_value": "Valore delle scorte",
|
||||
"part_add": "Aggiungi ricambio",
|
||||
"part_name": "Nome",
|
||||
"part_vendor": "Produttore",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "A breve",
|
||||
"battery_fleet_status_ok": "A posto",
|
||||
"battery_fleet_predicted_on": "Previsto intorno al {date}",
|
||||
"battery_fleet_predicted_trend": "Previsto dalla tendenza di scarica di questa batteria: intorno al {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Ricaricabile: da ricaricare anziché sostituire — mai nella lista della spesa",
|
||||
"battery_fleet_sort_name": "Ordina per nome",
|
||||
"battery_fleet_sort_urgency": "Ordina per urgenza",
|
||||
"battery_fleet_mark_recharged": "Segna come ricaricata",
|
||||
"battery_fleet_sparkline_hint": "Livello batteria degli ultimi 30 giorni — tratteggiato: proiezione fino alla soglia di batteria scarica",
|
||||
"battery_fleet_filter_type": "Mostra solo questo tipo di batteria",
|
||||
"battery_fleet_record_replacement": "Il livello è balzato intorno al {date} — registra questa sostituzione in Battery Notes",
|
||||
"battery_fleet_total": "{n} batterie monitorate",
|
||||
"battery_fleet_setup_button": "Parco batterie",
|
||||
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "回数",
|
||||
"series_end_until_label": "終了日",
|
||||
"parts_section": "部品・消耗品",
|
||||
"parts_inventory_value": "在庫金額",
|
||||
"part_add": "部品を追加",
|
||||
"part_name": "名前",
|
||||
"part_vendor": "メーカー",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "まもなく",
|
||||
"battery_fleet_status_ok": "良好",
|
||||
"battery_fleet_predicted_on": "{date} 頃の見込み",
|
||||
"battery_fleet_predicted_trend": "この電池の放電傾向から予測: {date} 頃 ({confidence})",
|
||||
"battery_fleet_rechargeable": "充電式:交換ではなく充電——買い物リストには載りません",
|
||||
"battery_fleet_sort_name": "名前順に並べ替え",
|
||||
"battery_fleet_sort_urgency": "緊急度順に並べ替え",
|
||||
"battery_fleet_mark_recharged": "充電済みにする",
|
||||
"battery_fleet_sparkline_hint": "過去30日間のバッテリー残量——点線:低残量しきい値までの予測",
|
||||
"battery_fleet_filter_type": "この電池タイプのみ表示",
|
||||
"battery_fleet_record_replacement": "{date}頃に残量が急上昇——この交換をBattery Notesに記録する",
|
||||
"battery_fleet_total": "{n} 個の電池を監視",
|
||||
"battery_fleet_setup_button": "電池フリート",
|
||||
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "횟수",
|
||||
"series_end_until_label": "종료일",
|
||||
"parts_section": "부품 및 소모품",
|
||||
"parts_inventory_value": "재고 가치",
|
||||
"part_add": "부품 추가",
|
||||
"part_name": "이름",
|
||||
"part_vendor": "제조사",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "곧",
|
||||
"battery_fleet_status_ok": "정상",
|
||||
"battery_fleet_predicted_on": "{date}쯤 예상",
|
||||
"battery_fleet_predicted_trend": "이 배터리의 방전 추세로 예측: 약 {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "충전식: 교체 대신 충전 — 쇼핑 목록에 오르지 않습니다",
|
||||
"battery_fleet_sort_name": "이름순 정렬",
|
||||
"battery_fleet_sort_urgency": "긴급도순 정렬",
|
||||
"battery_fleet_mark_recharged": "충전 완료로 표시",
|
||||
"battery_fleet_sparkline_hint": "지난 30일간의 배터리 잔량 — 점선: 낮음 임계값까지의 예측",
|
||||
"battery_fleet_filter_type": "이 배터리 유형만 표시",
|
||||
"battery_fleet_record_replacement": "{date}쯤 잔량이 급상승했습니다 — 이 교체를 Battery Notes에 기록",
|
||||
"battery_fleet_total": "배터리 {n}개 추적 중",
|
||||
"battery_fleet_setup_button": "배터리 플릿",
|
||||
"battery_fleet_setup_done": "배터리 플릿이 설정되었습니다 — 하나의 작업이 모든 배터리를 추적합니다.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Antall ganger",
|
||||
"series_end_until_label": "Sluttdato",
|
||||
"parts_section": "Deler & forbruksvarer",
|
||||
"parts_inventory_value": "Lagerverdi",
|
||||
"part_add": "Legg til del",
|
||||
"part_name": "Navn",
|
||||
"part_vendor": "Produsent",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Snart",
|
||||
"battery_fleet_status_ok": "I orden",
|
||||
"battery_fleet_predicted_on": "Forventes rundt {date}",
|
||||
"battery_fleet_predicted_trend": "Forutsagt ut fra batteriets utladingstrend: rundt {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Oppladbart: lad i stedet for å bytte — aldri på handlelisten",
|
||||
"battery_fleet_sort_name": "Sorter etter navn",
|
||||
"battery_fleet_sort_urgency": "Sorter etter hastegrad",
|
||||
"battery_fleet_mark_recharged": "Merk som oppladet",
|
||||
"battery_fleet_sparkline_hint": "Batterinivå de siste 30 dagene — stiplet: fremskrevet ned til lavnivåterskelen",
|
||||
"battery_fleet_filter_type": "Vis bare denne batteritypen",
|
||||
"battery_fleet_record_replacement": "Nivået hoppet rundt {date} — registrer dette byttet i Battery Notes",
|
||||
"battery_fleet_total": "{n} batterier spores",
|
||||
"battery_fleet_setup_button": "Batteriflåte",
|
||||
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Aantal keer",
|
||||
"series_end_until_label": "Einddatum",
|
||||
"parts_section": "Onderdelen & verbruiksartikelen",
|
||||
"parts_inventory_value": "Voorraadwaarde",
|
||||
"part_add": "Onderdeel toevoegen",
|
||||
"part_name": "Naam",
|
||||
"part_vendor": "Fabrikant",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Binnenkort",
|
||||
"battery_fleet_status_ok": "In orde",
|
||||
"battery_fleet_predicted_on": "Verwacht rond {date}",
|
||||
"battery_fleet_predicted_trend": "Voorspeld uit de ontladingstrend van deze batterij: rond {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Oplaadbaar: opladen in plaats van vervangen — nooit op het boodschappenlijstje",
|
||||
"battery_fleet_sort_name": "Sorteren op naam",
|
||||
"battery_fleet_sort_urgency": "Sorteren op urgentie",
|
||||
"battery_fleet_mark_recharged": "Markeren als opgeladen",
|
||||
"battery_fleet_sparkline_hint": "Batterijniveau van de afgelopen 30 dagen — gestippeld: prognose tot de lage drempel",
|
||||
"battery_fleet_filter_type": "Alleen dit batterijtype tonen",
|
||||
"battery_fleet_record_replacement": "Het niveau maakte rond {date} een sprong — deze vervanging vastleggen in Battery Notes",
|
||||
"battery_fleet_total": "{n} batterijen gevolgd",
|
||||
"battery_fleet_setup_button": "Batterijvloot",
|
||||
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Liczba razy",
|
||||
"series_end_until_label": "Data końcowa",
|
||||
"parts_section": "Części i materiały",
|
||||
"parts_inventory_value": "Wartość zapasów",
|
||||
"part_add": "Dodaj część",
|
||||
"part_name": "Nazwa",
|
||||
"part_vendor": "Producent",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Wkrótce",
|
||||
"battery_fleet_status_ok": "W porządku",
|
||||
"battery_fleet_predicted_on": "Przewidywane około {date}",
|
||||
"battery_fleet_predicted_trend": "Prognoza na podstawie trendu rozładowania tej baterii: około {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Akumulator: ładowanie zamiast wymiany — nigdy na liście zakupów",
|
||||
"battery_fleet_sort_name": "Sortuj według nazwy",
|
||||
"battery_fleet_sort_urgency": "Sortuj według pilności",
|
||||
"battery_fleet_mark_recharged": "Oznacz jako naładowaną",
|
||||
"battery_fleet_sparkline_hint": "Poziom baterii z ostatnich 30 dni — kropkowana linia: prognoza do progu niskiego poziomu",
|
||||
"battery_fleet_filter_type": "Pokaż tylko ten typ baterii",
|
||||
"battery_fleet_record_replacement": "Poziom skoczył około {date} — zapisz tę wymianę w Battery Notes",
|
||||
"battery_fleet_total": "Śledzone baterie: {n}",
|
||||
"battery_fleet_setup_button": "Flota baterii",
|
||||
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "Número de vezes",
|
||||
"series_end_until_label": "Data final",
|
||||
"parts_section": "Peças e consumíveis",
|
||||
"parts_inventory_value": "Valor do estoque",
|
||||
"part_add": "Adicionar peça",
|
||||
"part_name": "Nome",
|
||||
"part_vendor": "Fabricante",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Em breve",
|
||||
"battery_fleet_status_ok": "Em bom estado",
|
||||
"battery_fleet_predicted_on": "Previsto por volta de {date}",
|
||||
"battery_fleet_predicted_trend": "Previsto pela tendência de descarga desta bateria: por volta de {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Recarregável: recarregue em vez de substituir — nunca na lista de compras",
|
||||
"battery_fleet_sort_name": "Ordenar por nome",
|
||||
"battery_fleet_sort_urgency": "Ordenar por urgência",
|
||||
"battery_fleet_mark_recharged": "Marcar como recarregada",
|
||||
"battery_fleet_sparkline_hint": "Nível da bateria dos últimos 30 dias — pontilhado: projeção até o limite baixo",
|
||||
"battery_fleet_filter_type": "Mostrar apenas este tipo de pilha",
|
||||
"battery_fleet_record_replacement": "O nível deu um salto por volta de {date} — registrar esta substituição no Battery Notes",
|
||||
"battery_fleet_total": "{n} baterias acompanhadas",
|
||||
"battery_fleet_setup_button": "Frota de baterias",
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma única tarefa acompanha todas as suas baterias.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Número de vezes",
|
||||
"series_end_until_label": "Data final",
|
||||
"parts_section": "Peças e consumíveis",
|
||||
"parts_inventory_value": "Valor do stock",
|
||||
"part_add": "Adicionar peça",
|
||||
"part_name": "Nome",
|
||||
"part_vendor": "Fabricante",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Em breve",
|
||||
"battery_fleet_status_ok": "Em bom estado",
|
||||
"battery_fleet_predicted_on": "Previsto por volta de {date}",
|
||||
"battery_fleet_predicted_trend": "Previsto pela tendência de descarga desta pilha: por volta de {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Recarregável: carrega-se em vez de se substituir — nunca na lista de compras",
|
||||
"battery_fleet_sort_name": "Ordenar por nome",
|
||||
"battery_fleet_sort_urgency": "Ordenar por urgência",
|
||||
"battery_fleet_mark_recharged": "Marcar como recarregada",
|
||||
"battery_fleet_sparkline_hint": "Nível da bateria dos últimos 30 dias — pontilhado: projeção até ao limiar baixo",
|
||||
"battery_fleet_filter_type": "Mostrar apenas este tipo de pilha",
|
||||
"battery_fleet_record_replacement": "O nível deu um salto por volta de {date} — registar esta substituição no Battery Notes",
|
||||
"battery_fleet_total": "{n} baterias monitorizadas",
|
||||
"battery_fleet_setup_button": "Frota de baterias",
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Число раз",
|
||||
"series_end_until_label": "Дата окончания",
|
||||
"parts_section": "Детали и расходники",
|
||||
"parts_inventory_value": "Стоимость запасов",
|
||||
"part_add": "Добавить деталь",
|
||||
"part_name": "Название",
|
||||
"part_vendor": "Производитель",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Скоро",
|
||||
"battery_fleet_status_ok": "В норме",
|
||||
"battery_fleet_predicted_on": "Ожидается примерно {date}",
|
||||
"battery_fleet_predicted_trend": "Прогноз по тренду разряда этой батареи: примерно {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Аккумулятор: заряжается, а не заменяется — никогда не попадает в список покупок",
|
||||
"battery_fleet_sort_name": "Сортировать по имени",
|
||||
"battery_fleet_sort_urgency": "Сортировать по срочности",
|
||||
"battery_fleet_mark_recharged": "Отметить как заряженную",
|
||||
"battery_fleet_sparkline_hint": "Уровень заряда за последние 30 дней — пунктир: прогноз до порога разряда",
|
||||
"battery_fleet_filter_type": "Показать только этот тип батарей",
|
||||
"battery_fleet_record_replacement": "Уровень резко вырос примерно {date} — записать эту замену в Battery Notes",
|
||||
"battery_fleet_total": "Отслеживается батарей: {n}",
|
||||
"battery_fleet_setup_button": "Парк батарей",
|
||||
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Antal gånger",
|
||||
"series_end_until_label": "Slutdatum",
|
||||
"parts_section": "Delar & förbrukning",
|
||||
"parts_inventory_value": "Lagervärde",
|
||||
"part_add": "Lägg till del",
|
||||
"part_name": "Namn",
|
||||
"part_vendor": "Tillverkare",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Snart",
|
||||
"battery_fleet_status_ok": "I ordning",
|
||||
"battery_fleet_predicted_on": "Väntas omkring {date}",
|
||||
"battery_fleet_predicted_trend": "Förutspått utifrån batteriets urladdningstrend: omkring {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Uppladdningsbart: ladda i stället för att byta — aldrig på inköpslistan",
|
||||
"battery_fleet_sort_name": "Sortera efter namn",
|
||||
"battery_fleet_sort_urgency": "Sortera efter angelägenhet",
|
||||
"battery_fleet_mark_recharged": "Markera som uppladdad",
|
||||
"battery_fleet_sparkline_hint": "Batterinivå de senaste 30 dagarna — prickad: prognos ner till lågnivåtröskeln",
|
||||
"battery_fleet_filter_type": "Visa endast denna batterityp",
|
||||
"battery_fleet_record_replacement": "Nivån hoppade omkring {date} — registrera detta byte i Battery Notes",
|
||||
"battery_fleet_total": "{n} batterier spåras",
|
||||
"battery_fleet_setup_button": "Batteriflotta",
|
||||
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "Tekrar sayısı",
|
||||
"series_end_until_label": "Bitiş tarihi",
|
||||
"parts_section": "Parçalar ve sarf malzemeleri",
|
||||
"parts_inventory_value": "Stok değeri",
|
||||
"part_add": "Parça ekle",
|
||||
"part_name": "Ad",
|
||||
"part_vendor": "Üretici",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Yakında",
|
||||
"battery_fleet_status_ok": "İyi durumda",
|
||||
"battery_fleet_predicted_on": "Yaklaşık {date} tarihinde bekleniyor",
|
||||
"battery_fleet_predicted_trend": "Bu pilin deşarj eğiliminden tahmin edildi: yaklaşık {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Şarj edilebilir: değiştirmek yerine şarj edin — alışveriş listesine asla girmez",
|
||||
"battery_fleet_sort_name": "Ada göre sırala",
|
||||
"battery_fleet_sort_urgency": "Aciliyete göre sırala",
|
||||
"battery_fleet_mark_recharged": "Şarj edildi olarak işaretle",
|
||||
"battery_fleet_sparkline_hint": "Son 30 günün pil seviyesi — noktalı: düşük eşiğe kadar projeksiyon",
|
||||
"battery_fleet_filter_type": "Yalnızca bu pil türünü göster",
|
||||
"battery_fleet_record_replacement": "Seviye {date} civarında sıçradı — bu değişimi Battery Notes'a kaydet",
|
||||
"battery_fleet_total": "{n} pil takip ediliyor",
|
||||
"battery_fleet_setup_button": "Pil filosu",
|
||||
"battery_fleet_setup_done": "Pil filosu kuruldu — tek bir görev tüm pillerinizi takip ediyor.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Кількість разів",
|
||||
"series_end_until_label": "Дата завершення",
|
||||
"parts_section": "Деталі та витратні матеріали",
|
||||
"parts_inventory_value": "Вартість запасів",
|
||||
"part_add": "Додати деталь",
|
||||
"part_name": "Назва",
|
||||
"part_vendor": "Виробник",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Незабаром",
|
||||
"battery_fleet_status_ok": "У нормі",
|
||||
"battery_fleet_predicted_on": "Очікується приблизно {date}",
|
||||
"battery_fleet_predicted_trend": "Прогноз за трендом розряду цієї батареї: приблизно {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Акумулятор: заряджається, а не замінюється — ніколи не потрапляє до списку покупок",
|
||||
"battery_fleet_sort_name": "Сортувати за назвою",
|
||||
"battery_fleet_sort_urgency": "Сортувати за терміновістю",
|
||||
"battery_fleet_mark_recharged": "Позначити як заряджену",
|
||||
"battery_fleet_sparkline_hint": "Рівень заряду за останні 30 днів — пунктир: прогноз до порогу розряду",
|
||||
"battery_fleet_filter_type": "Показати лише цей тип батарей",
|
||||
"battery_fleet_record_replacement": "Рівень різко зріс приблизно {date} — записати цю заміну в Battery Notes",
|
||||
"battery_fleet_total": "Відстежується батарей: {n}",
|
||||
"battery_fleet_setup_button": "Парк батарей",
|
||||
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "次数",
|
||||
"series_end_until_label": "结束日期",
|
||||
"parts_section": "配件与耗材",
|
||||
"parts_inventory_value": "库存价值",
|
||||
"part_add": "添加配件",
|
||||
"part_name": "名称",
|
||||
"part_vendor": "制造商",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "即将",
|
||||
"battery_fleet_status_ok": "正常",
|
||||
"battery_fleet_predicted_on": "预计在 {date} 前后",
|
||||
"battery_fleet_predicted_trend": "根据此电池的放电趋势预测:约 {date}({confidence})",
|
||||
"battery_fleet_rechargeable": "可充电电池:充电即可,无需更换——不会出现在购物清单中",
|
||||
"battery_fleet_sort_name": "按名称排序",
|
||||
"battery_fleet_sort_urgency": "按紧急程度排序",
|
||||
"battery_fleet_mark_recharged": "标记为已充电",
|
||||
"battery_fleet_sparkline_hint": "过去 30 天的电池电量——虚线:外推至低电量阈值",
|
||||
"battery_fleet_filter_type": "仅显示此电池类型",
|
||||
"battery_fleet_record_replacement": "电量在 {date} 前后跳升——将此次更换记录到 Battery Notes",
|
||||
"battery_fleet_total": "已跟踪 {n} 个电池",
|
||||
"battery_fleet_setup_button": "电池群",
|
||||
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Maintenance Supporter Lovelace Card. */
|
||||
|
||||
import { LitElement, html, css, nothing } from "lit";
|
||||
import { mergeSubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs, formatDueDays } from "./styles";
|
||||
import type {
|
||||
@@ -274,10 +275,14 @@ export class MaintenanceSupporterCard extends LitElement {
|
||||
try {
|
||||
const unsub = await this.hass.connection.subscribeMessage(
|
||||
(msg: unknown) => {
|
||||
const data = msg as { objects: MaintenanceObjectResponse[] };
|
||||
this._objects = data.objects;
|
||||
const next = mergeSubscriptionEvent(
|
||||
this._objects,
|
||||
msg as SubscriptionEvent<MaintenanceObjectResponse>,
|
||||
);
|
||||
if (next !== null) this._objects = next;
|
||||
},
|
||||
{ type: "maintenance_supporter/subscribe" }
|
||||
// deltas: only changed entries arrive — see helpers/subscription-merge.
|
||||
{ type: "maintenance_supporter/subscribe", deltas: true }
|
||||
);
|
||||
// Detached mid-subscribe → drop the orphaned subscription.
|
||||
if (!this.isConnected) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "./helpers/url";
|
||||
import { mergeSubscriptionEvent, type SubscriptionEvent } from "./helpers/subscription-merge";
|
||||
import { isStaleBundle } from "./helpers/bundle-version";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
StatisticsPoint,
|
||||
SavedView,
|
||||
SavedViewFilters,
|
||||
ManualDocRef,
|
||||
} from "./types";
|
||||
import { StatisticsService } from "./statistics-service";
|
||||
import { UserService } from "./user-service";
|
||||
@@ -385,10 +387,18 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
]);
|
||||
if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || [];
|
||||
if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects;
|
||||
// Battery Fleet availability (Battery Notes present + not yet set up).
|
||||
// A data refresh means the open task's history may have grown (complete,
|
||||
// history edit) — the truncated list payload can't tell, so refetch.
|
||||
if (this._view === "task" && this._selectedEntryId && this._selectedTaskId) {
|
||||
this._fetchFullHistory(this._selectedEntryId, this._selectedTaskId);
|
||||
}
|
||||
// Battery Fleet availability (batteries present + not yet set up).
|
||||
// The slim status check, NOT the overview: the full overview runs the
|
||||
// trend machinery server-side (one recorder regression per healthy
|
||||
// battery on a cold cache) — far too expensive for hiding a button.
|
||||
this.hass.connection
|
||||
.sendMessagePromise<{ available: boolean; configured: boolean }>({
|
||||
type: "maintenance_supporter/battery_fleet/overview",
|
||||
type: "maintenance_supporter/battery_fleet/status",
|
||||
})
|
||||
.then((ov) => {
|
||||
this._batteryFleetSetupAvailable = !!ov.available && !ov.configured;
|
||||
@@ -591,10 +601,15 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
try {
|
||||
const unsub = await this.hass.connection.subscribeMessage(
|
||||
(msg: unknown) => {
|
||||
const data = msg as { objects: MaintenanceObjectResponse[] };
|
||||
this._objects = data.objects;
|
||||
const next = mergeSubscriptionEvent(
|
||||
this._objects,
|
||||
msg as SubscriptionEvent<MaintenanceObjectResponse>,
|
||||
);
|
||||
if (next !== null) this._objects = next;
|
||||
},
|
||||
{ type: "maintenance_supporter/subscribe" }
|
||||
// deltas: only entries whose rebuilt response actually changed —
|
||||
// no-op timer waves send nothing, a real change ships one object.
|
||||
{ type: "maintenance_supporter/subscribe", deltas: true }
|
||||
);
|
||||
// If the element was detached while the subscribe was in flight, drop the
|
||||
// now-orphaned subscription instead of storing it on a dead component.
|
||||
@@ -880,6 +895,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
this._activeTab = "overview";
|
||||
this._historyFilter = null;
|
||||
this._scrollContentToTop();
|
||||
// Payload diet: list responses carry only the most recent history
|
||||
// window — the detail's full timeline/charts load here, on demand.
|
||||
this._fetchFullHistory(entryId, taskId);
|
||||
|
||||
// Lazy-load statistics for the task's trigger entity
|
||||
const task = this._getTask(entryId, taskId);
|
||||
@@ -1785,6 +1803,52 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a manual-tagged document from the objects table / object header:
|
||||
* web-links directly, stored files through a signed path (the same
|
||||
* Companion-safe recipe as the documents section). */
|
||||
private _openManualDoc(doc: ManualDocRef): void {
|
||||
if (doc.kind !== "file") {
|
||||
if (isSafeHttpUrl(doc.url)) window.open(doc.url!, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
// Open the tab synchronously (inside the click gesture) so it isn't
|
||||
// popup-blocked, then point it at the freshly signed URL.
|
||||
const win = window.open("about:blank", "_blank");
|
||||
void this.hass.connection
|
||||
.sendMessagePromise<{ path: string }>({
|
||||
type: "auth/sign_path",
|
||||
path: `/api/maintenance_supporter/document/${doc.id}`,
|
||||
expires: 300,
|
||||
})
|
||||
.then((signed) => {
|
||||
if (win) win.location.href = new URL(signed.path, window.location.origin).href;
|
||||
})
|
||||
.catch(() => win?.close());
|
||||
}
|
||||
|
||||
/** #73: persist one checklist tick. Sends the FULL current state (the
|
||||
* server replaces, not merges — idempotent) and reloads so the progress
|
||||
* header and any other open surface agree. */
|
||||
private async _setChecklistItem(entryId: string, taskId: string, item: string, done: boolean): Promise<void> {
|
||||
const obj = this._getObject(entryId);
|
||||
const task = obj?.tasks.find((x) => x.id === taskId);
|
||||
if (!task) return;
|
||||
const state: Record<string, boolean> = {};
|
||||
for (const step of task.checklist || []) {
|
||||
const current = task.checklist_progress?.[step] ?? false;
|
||||
state[step] = step === item ? done : current;
|
||||
}
|
||||
try {
|
||||
await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/checklist_progress",
|
||||
entry_id: entryId, task_id: taskId, checklist_state: state,
|
||||
});
|
||||
await this._loadData();
|
||||
} catch {
|
||||
this._showToast(t("action_error", this._lang));
|
||||
}
|
||||
}
|
||||
|
||||
private _openCompleteDialog(entryId: string, taskId: string, taskName: string, checklist?: string[], adaptiveEnabled?: boolean): void {
|
||||
const dlg = this.shadowRoot!.querySelector<MaintenanceCompleteDialog>("maintenance-complete-dialog");
|
||||
if (!dlg) return;
|
||||
@@ -1801,6 +1865,8 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
?.tasks.find((tsk) => tsk.id === taskId);
|
||||
dlg.taskType = tk?.type || "";
|
||||
dlg.readingUnit = tk?.reading_unit || "";
|
||||
// #73: ticks recorded during the cycle prefill the dialog's checklist.
|
||||
dlg.checklistPrefill = tk?.checklist_progress || {};
|
||||
dlg.requiredFields = tk?.required_completion_fields || [];
|
||||
// Spare parts: a buy task gets an editable restock-qty field; a consuming
|
||||
// task shows what it will decrement (incl. the storage location).
|
||||
@@ -2765,13 +2831,22 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const area = o.area_id ? (this.hass?.areas?.[o.area_id]?.name || o.area_id) : "—";
|
||||
return html`<td class="oc-area_id">${area}</td>`;
|
||||
}
|
||||
case "documentation_url":
|
||||
case "documentation_url": {
|
||||
// Fallback: an UPLOADED manual (category "manual") is the object's
|
||||
// manual just as much as the legacy URL field — an object with its
|
||||
// handbook attached must not render "—" here (prod: Easee vs Epson).
|
||||
const manualDoc = (o.manual_docs || [])[0];
|
||||
return html`<td class="oc-documentation_url">${
|
||||
isSafeHttpUrl(o.documentation_url)
|
||||
? html`<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer"
|
||||
@click=${(e: Event) => e.stopPropagation()}><ha-icon icon="mdi:file-document-outline"></ha-icon></a>`
|
||||
: "—"
|
||||
: manualDoc
|
||||
? html`<a href="#" title=${manualDoc.title}
|
||||
@click=${(e: Event) => { e.preventDefault(); e.stopPropagation(); this._openManualDoc(manualDoc); }}
|
||||
><ha-icon icon="mdi:file-document-outline"></ha-icon></a>`
|
||||
: "—"
|
||||
}</td>`;
|
||||
}
|
||||
case "notes":
|
||||
return html`<td class="oc-notes" title=${o.notes || ""}>${o.notes || "—"}</td>`;
|
||||
case "task_count":
|
||||
@@ -3085,7 +3160,16 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
? html`<p class="meta">${t("documentation_url_label", L)}:
|
||||
<a href=${o.documentation_url} target="_blank" rel="noopener noreferrer">${o.documentation_url}</a>
|
||||
</p>`
|
||||
: nothing}
|
||||
: (o.manual_docs || []).length
|
||||
? html`<p class="meta">${t("documentation_url_label", L)}:
|
||||
${o.manual_docs!.slice(0, 3).map(
|
||||
(m, i) => html`${i > 0 ? " · " : ""}<a href="#"
|
||||
@click=${(e: Event) => { e.preventDefault(); this._openManualDoc(m); }}>${m.title}</a>`,
|
||||
)}${o.manual_docs!.length > 3
|
||||
? html` … +${o.manual_docs!.length - 3}`
|
||||
: nothing}
|
||||
</p>`
|
||||
: nothing}
|
||||
${o.installation_date ? html`<p class="meta">${t("installed", L)}: ${formatDate(o.installation_date, L)}</p>` : nothing}
|
||||
${o.warranty_expiry ? this._renderWarrantyMeta(o.warranty_expiry, L) : nothing}
|
||||
${o.notes
|
||||
@@ -3157,6 +3241,7 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
.entryId=${obj.entry_id}
|
||||
.parts=${obj.parts || []}
|
||||
.canWrite=${!isOperator}
|
||||
.currencySymbol=${this._budget?.currency_symbol || DEFAULT_CURRENCY_SYMBOL}
|
||||
@parts-changed=${() => this._loadData()}
|
||||
></maintenance-parts-section>
|
||||
</div>
|
||||
@@ -3221,7 +3306,16 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
const task = this._selectedEntryId && this._selectedTaskId
|
||||
? this._getObject(this._selectedEntryId)?.tasks.find((tk) => tk.id === this._selectedTaskId)
|
||||
: undefined;
|
||||
const readings = (task?.history || [])
|
||||
// Payload diet: the summary's history is truncated to the recent window —
|
||||
// the reading DELTAS must come from the full record (the oldest visible
|
||||
// reading would otherwise lose or falsify its delta), which
|
||||
// _fetchFullHistory loads for the open task.
|
||||
const fh = this._fullHistory;
|
||||
const fullHistory =
|
||||
fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task?.history || []).length
|
||||
? fh.entries
|
||||
: task?.history || [];
|
||||
const readings = fullHistory
|
||||
.filter((h) => h.reading_value != null)
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
return {
|
||||
@@ -3256,6 +3350,9 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
taskId,
|
||||
objectName: obj?.object.name || "",
|
||||
objectDocUrl: obj?.object?.documentation_url ?? null,
|
||||
objectManualDocs: obj?.object?.manual_docs ?? [],
|
||||
openManualDoc: (doc) => this._openManualDoc(doc),
|
||||
setChecklistItem: (item, done) => this._setChecklistItem(entryId, taskId, item, done),
|
||||
isOperator: this._isOperator,
|
||||
actionLoading: this._actionLoading,
|
||||
moreMenuOpen: this._moreMenuOpen,
|
||||
@@ -3293,12 +3390,37 @@ export class MaintenanceSupporterPanel extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
/** Full history for the OPEN task (list payloads are truncated to the
|
||||
* most recent window). null until loaded; a failure — e.g. an older
|
||||
* backend without `task/history` — falls back to the truncated list. */
|
||||
@state() private _fullHistory: { entryId: string; taskId: string; entries: HistoryEntry[] } | null = null;
|
||||
|
||||
private async _fetchFullHistory(entryId: string, taskId: string): Promise<void> {
|
||||
try {
|
||||
const res = (await this.hass.connection.sendMessagePromise({
|
||||
type: "maintenance_supporter/task/history",
|
||||
entry_id: entryId,
|
||||
task_id: taskId,
|
||||
})) as { history: HistoryEntry[] };
|
||||
if (this._selectedEntryId === entryId && this._selectedTaskId === taskId) {
|
||||
this._fullHistory = { entryId, taskId, entries: res.history || [] };
|
||||
}
|
||||
} catch {
|
||||
this._fullHistory = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _renderTaskDetail() {
|
||||
if (!this._selectedEntryId || !this._selectedTaskId) return nothing;
|
||||
const task = this._getTask(this._selectedEntryId, this._selectedTaskId);
|
||||
if (!task) return html`<p>Task not found.</p>`;
|
||||
const fh = this._fullHistory;
|
||||
const detailTask =
|
||||
fh && fh.entryId === this._selectedEntryId && fh.taskId === this._selectedTaskId && fh.entries.length > (task.history || []).length
|
||||
? { ...task, history: fh.entries }
|
||||
: task;
|
||||
return html`<maintenance-task-detail-view
|
||||
.task=${task}
|
||||
.task=${detailTask}
|
||||
.ctx=${this._taskDetailCtx()}
|
||||
></maintenance-task-detail-view>`;
|
||||
}
|
||||
|
||||
@@ -31,14 +31,25 @@
|
||||
* (all of which only matter once a dashboard is actually being generated).
|
||||
*/
|
||||
|
||||
import { BUNDLE_VERSION } from "./helpers/bundle-version";
|
||||
|
||||
const STRATEGY_TYPE = "maintenance-supporter";
|
||||
const STRATEGY_TAG = `ll-strategy-dashboard-${STRATEGY_TYPE}`;
|
||||
const EDITOR_TAG = "hui-maintenance-supporter-strategy-editor";
|
||||
|
||||
// Absolute URL of the full strategy bundle (served at STRATEGY_URL by the
|
||||
// integration). The shim's only dependency, loaded on demand.
|
||||
//
|
||||
// Version-busted (issue #124): the bundle's chunk names are content-hashed
|
||||
// and change every release, but browsers heuristically cache this entry
|
||||
// (static serving sends no Cache-Control). A stale cached entry then imports
|
||||
// chunk names the update deleted → 404 → the strategy dashboard dies until
|
||||
// a hard refresh. The `?v=` makes the URL change with the release, so a
|
||||
// fresh entry always pulls its matching chunks. BUNDLE_VERSION is inlined
|
||||
// by esbuild — the built shim keeps its zero-import guarantee.
|
||||
const BUNDLE_URL =
|
||||
"/maintenance_supporter_strategy/maintenance-dashboard-strategy.js";
|
||||
"/maintenance_supporter_strategy/maintenance-dashboard-strategy.js" +
|
||||
`?v=${BUNDLE_VERSION}`;
|
||||
|
||||
let _bundle: Promise<unknown> | null = null;
|
||||
function loadBundle(): Promise<unknown> {
|
||||
|
||||
+46
-667
@@ -9,10 +9,10 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"playwright": "1.61"
|
||||
"playwright": "1.62"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@open-wc/testing": "^4.0.0",
|
||||
"@open-wc/testing": "^5.0.0",
|
||||
"@web/dev-server-esbuild": "^2.0.0",
|
||||
"@web/test-runner": "^1.0.0",
|
||||
"@web/test-runner-playwright": "^1.0.0",
|
||||
@@ -864,25 +864,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@open-wc/semantic-dom-diff": {
|
||||
"version": "0.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.20.1.tgz",
|
||||
"integrity": "sha512-mPF/RPT2TU7Dw41LEDdaeP6eyTOWBD4z0+AHP4/d0SbgcfJZVRymlIB6DQmtz0fd2CImIS9kszaMmwMt92HBPA==",
|
||||
"version": "0.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.21.0.tgz",
|
||||
"integrity": "sha512-2hrNt9MWhz4kfuIWI6M6zyK+UJXd2ehjaP+nJ1shf9HZAperr+braPToTbRODx1oE6EvoVvTJGQlsCqHv7NKQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^4.3.1",
|
||||
"@web/test-runner-commands": "^0.9.0"
|
||||
"@web/test-runner-commands": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@open-wc/testing": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-wc/testing/-/testing-4.0.0.tgz",
|
||||
"integrity": "sha512-KI70O0CJEpBWs3jrTju4BFCy7V/d4tFfYWkg8pMzncsDhD7TYNHLw5cy+s1FHXIgVFetnMDhPpwlKIPvtTQW7w==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@open-wc/testing/-/testing-5.0.0.tgz",
|
||||
"integrity": "sha512-2IcM2py1wtz4pyTW5whhltP5L71TVqt0l0IeA2KqHILyxOzI77YnDc9drfN7ezl0+fZbGOXM6qXe4Y4o0R7xpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@esm-bundle/chai": "^4.3.4-fix.0",
|
||||
"@open-wc/semantic-dom-diff": "^0.20.0",
|
||||
"@open-wc/semantic-dom-diff": "^0.21.0",
|
||||
"@open-wc/testing-helpers": "^3.0.0",
|
||||
"@types/chai-dom": "^1.11.0",
|
||||
"@types/sinon-chai": "^3.2.3",
|
||||
@@ -2011,16 +2011,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@web/browser-logs": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-0.4.1.tgz",
|
||||
"integrity": "sha512-ypmMG+72ERm+LvP+loj9A64MTXvWMXHUOu773cPO4L1SV/VWg6xA9Pv7vkvkXQX+ItJtCJt+KQ+U6ui2HhSFUw==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
|
||||
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"errorstacks": "^2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/config-loader": {
|
||||
@@ -2064,56 +2064,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@web/dev-server-core": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.5.tgz",
|
||||
"integrity": "sha512-Da65zsiN6iZPMRuj4Oa6YPwvsmZmo5gtPWhW2lx3GTUf5CAEapjVpZVlUXnKPL7M7zRuk72jSsIl8lo+XpTCtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/koa": "^2.11.6",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@web/parse5-utils": "^2.1.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone": "^2.1.2",
|
||||
"es-module-lexer": "^1.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"isbinaryfile": "^5.0.0",
|
||||
"koa": "^2.13.0",
|
||||
"koa-etag": "^4.0.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"lru-cache": "^8.0.4",
|
||||
"mime-types": "^2.1.27",
|
||||
"parse5": "^6.0.1",
|
||||
"picomatch": "^2.2.2",
|
||||
"ws": "^7.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/dev-server-esbuild": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-esbuild/-/dev-server-esbuild-2.0.0.tgz",
|
||||
"integrity": "sha512-D4BPYj3jO3kTDmytKWYB97xIVpR/Mdpy+zOyY1rEpFfFAcM59LCmO76Y8hp78Ssm4+4c2Gvd27gBVLSqWTPN3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdn/browser-compat-data": "^4.0.0",
|
||||
"@web/dev-server-core": "^1.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"parse5": "^6.0.1",
|
||||
"ua-parser-js": "^1.0.33"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/dev-server-esbuild/node_modules/@web/dev-server-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
|
||||
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.1.tgz",
|
||||
"integrity": "sha512-hwps4+UoNDoAP5oM2wLuwWi7AB9drW6k1ybRx0eLSly966zqj3gRSHCJmtGtC0u77HdnlED4PDFSdJfrq2Oo3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2140,15 +2093,18 @@
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/dev-server-esbuild/node_modules/@web/parse5-utils": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
|
||||
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
|
||||
"node_modules/@web/dev-server-esbuild": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-esbuild/-/dev-server-esbuild-2.0.0.tgz",
|
||||
"integrity": "sha512-D4BPYj3jO3kTDmytKWYB97xIVpR/Mdpy+zOyY1rEpFfFAcM59LCmO76Y8hp78Ssm4+4c2Gvd27gBVLSqWTPN3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse5": "^6.0.1",
|
||||
"parse5": "^6.0.1"
|
||||
"@mdn/browser-compat-data": "^4.0.0",
|
||||
"@web/dev-server-core": "^1.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"parse5": "^6.0.1",
|
||||
"ua-parser-js": "^1.0.33"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -2172,98 +2128,10 @@
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/dev-server-rollup/node_modules/@web/dev-server-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
|
||||
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/koa": "^2.11.6",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@web/parse5-utils": "^3.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone": "^2.1.2",
|
||||
"es-module-lexer": "^1.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"isbinaryfile": "^5.0.0",
|
||||
"koa": "^2.16.1",
|
||||
"koa-etag": "^4.0.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"lru-cache": "^8.0.4",
|
||||
"mime-types": "^2.1.27",
|
||||
"parse5": "^6.0.1",
|
||||
"picomatch": "^2.3.2",
|
||||
"ws": "^7.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/dev-server-rollup/node_modules/@web/parse5-utils": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
|
||||
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse5": "^6.0.1",
|
||||
"parse5": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/dev-server/node_modules/@web/dev-server-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
|
||||
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/koa": "^2.11.6",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@web/parse5-utils": "^3.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone": "^2.1.2",
|
||||
"es-module-lexer": "^1.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"isbinaryfile": "^5.0.0",
|
||||
"koa": "^2.16.1",
|
||||
"koa-etag": "^4.0.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"lru-cache": "^8.0.4",
|
||||
"mime-types": "^2.1.27",
|
||||
"parse5": "^6.0.1",
|
||||
"picomatch": "^2.3.2",
|
||||
"ws": "^7.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/dev-server/node_modules/@web/parse5-utils": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
|
||||
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse5": "^6.0.1",
|
||||
"parse5": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/parse5-utils": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-2.1.1.tgz",
|
||||
"integrity": "sha512-7rBVZEMGfrq2iPcAEwJ0KSNSvmA2a6jT2CK8/gyIOHgn4reg7bSSRbzyWIEYWyIkeRoYEukX/aW+nAeCgSSqhQ==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
|
||||
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2271,7 +2139,7 @@
|
||||
"parse5": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner": {
|
||||
@@ -2322,64 +2190,21 @@
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-chrome/node_modules/@web/browser-logs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
|
||||
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
|
||||
"node_modules/@web/test-runner-commands": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-1.0.1.tgz",
|
||||
"integrity": "sha512-rvAvHJKlNzh5Tv8L2wGjnNX/hSkPmQx5eyKqeyExEvA7A2pVXiNYFstJzUGCD52TlAJSPF3BtW1yOwpf6vG7Eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"errorstacks": "^2.4.1"
|
||||
"@web/test-runner-core": "^1.0.0",
|
||||
"mkdirp": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-chrome/node_modules/@web/dev-server-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
|
||||
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/koa": "^2.11.6",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@web/parse5-utils": "^3.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone": "^2.1.2",
|
||||
"es-module-lexer": "^1.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"isbinaryfile": "^5.0.0",
|
||||
"koa": "^2.16.1",
|
||||
"koa-etag": "^4.0.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"lru-cache": "^8.0.4",
|
||||
"mime-types": "^2.1.27",
|
||||
"parse5": "^6.0.1",
|
||||
"picomatch": "^2.3.2",
|
||||
"ws": "^7.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-chrome/node_modules/@web/parse5-utils": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
|
||||
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse5": "^6.0.1",
|
||||
"parse5": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-chrome/node_modules/@web/test-runner-core": {
|
||||
"node_modules/@web/test-runner-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
|
||||
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
|
||||
@@ -2417,58 +2242,6 @@
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-commands": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-0.9.0.tgz",
|
||||
"integrity": "sha512-zeLI6QdH0jzzJMDV5O42Pd8WLJtYqovgdt0JdytgHc0d1EpzXDsc7NTCJSImboc2NcayIsWAvvGGeRF69SMMYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@web/test-runner-core": "^0.13.0",
|
||||
"mkdirp": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-core": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.13.4.tgz",
|
||||
"integrity": "sha512-84E1025aUSjvZU1j17eCTwV7m5Zg3cZHErV3+CaJM9JPCesZwLraIa0ONIQ9w4KLgcDgJFw9UnJ0LbFf42h6tg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.12.11",
|
||||
"@types/babel__code-frame": "^7.0.2",
|
||||
"@types/co-body": "^6.1.0",
|
||||
"@types/convert-source-map": "^2.0.0",
|
||||
"@types/debounce": "^1.2.0",
|
||||
"@types/istanbul-lib-coverage": "^2.0.3",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@web/browser-logs": "^0.4.0",
|
||||
"@web/dev-server-core": "^0.7.3",
|
||||
"chokidar": "^4.0.1",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"co-body": "^6.1.0",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debounce": "^1.2.0",
|
||||
"dependency-graph": "^0.11.0",
|
||||
"globby": "^11.0.1",
|
||||
"internal-ip": "^6.2.0",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.0.2",
|
||||
"log-update": "^4.0.0",
|
||||
"nanocolors": "^0.2.1",
|
||||
"nanoid": "^3.1.25",
|
||||
"open": "^8.0.2",
|
||||
"picomatch": "^2.2.2",
|
||||
"source-map": "^0.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-coverage-v8": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-1.0.0.tgz",
|
||||
@@ -2486,101 +2259,6 @@
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-coverage-v8/node_modules/@web/browser-logs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
|
||||
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"errorstacks": "^2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-coverage-v8/node_modules/@web/dev-server-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
|
||||
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/koa": "^2.11.6",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@web/parse5-utils": "^3.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone": "^2.1.2",
|
||||
"es-module-lexer": "^1.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"isbinaryfile": "^5.0.0",
|
||||
"koa": "^2.16.1",
|
||||
"koa-etag": "^4.0.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"lru-cache": "^8.0.4",
|
||||
"mime-types": "^2.1.27",
|
||||
"parse5": "^6.0.1",
|
||||
"picomatch": "^2.3.2",
|
||||
"ws": "^7.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-coverage-v8/node_modules/@web/parse5-utils": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
|
||||
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse5": "^6.0.1",
|
||||
"parse5": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-coverage-v8/node_modules/@web/test-runner-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
|
||||
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.12.11",
|
||||
"@types/babel__code-frame": "^7.0.2",
|
||||
"@types/co-body": "^6.1.0",
|
||||
"@types/convert-source-map": "^2.0.0",
|
||||
"@types/debounce": "^1.2.0",
|
||||
"@types/istanbul-lib-coverage": "^2.0.3",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@web/browser-logs": "^1.0.0",
|
||||
"@web/dev-server-core": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"co-body": "^6.1.0",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debounce": "^1.2.0",
|
||||
"dependency-graph": "^0.11.0",
|
||||
"globby": "^11.0.1",
|
||||
"internal-ip": "^6.2.0",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.0.2",
|
||||
"log-update": "^4.0.0",
|
||||
"nanocolors": "^0.2.1",
|
||||
"nanoid": "^3.1.25",
|
||||
"open": "^8.0.2",
|
||||
"picomatch": "^2.3.2",
|
||||
"source-map": "^0.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-mocha": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-mocha/-/test-runner-mocha-1.0.0.tgz",
|
||||
@@ -2594,101 +2272,6 @@
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-mocha/node_modules/@web/browser-logs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
|
||||
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"errorstacks": "^2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-mocha/node_modules/@web/dev-server-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
|
||||
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/koa": "^2.11.6",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@web/parse5-utils": "^3.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone": "^2.1.2",
|
||||
"es-module-lexer": "^1.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"isbinaryfile": "^5.0.0",
|
||||
"koa": "^2.16.1",
|
||||
"koa-etag": "^4.0.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"lru-cache": "^8.0.4",
|
||||
"mime-types": "^2.1.27",
|
||||
"parse5": "^6.0.1",
|
||||
"picomatch": "^2.3.2",
|
||||
"ws": "^7.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-mocha/node_modules/@web/parse5-utils": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
|
||||
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse5": "^6.0.1",
|
||||
"parse5": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-mocha/node_modules/@web/test-runner-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
|
||||
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.12.11",
|
||||
"@types/babel__code-frame": "^7.0.2",
|
||||
"@types/co-body": "^6.1.0",
|
||||
"@types/convert-source-map": "^2.0.0",
|
||||
"@types/debounce": "^1.2.0",
|
||||
"@types/istanbul-lib-coverage": "^2.0.3",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@web/browser-logs": "^1.0.0",
|
||||
"@web/dev-server-core": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"co-body": "^6.1.0",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debounce": "^1.2.0",
|
||||
"dependency-graph": "^0.11.0",
|
||||
"globby": "^11.0.1",
|
||||
"internal-ip": "^6.2.0",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.0.2",
|
||||
"log-update": "^4.0.0",
|
||||
"nanocolors": "^0.2.1",
|
||||
"nanoid": "^3.1.25",
|
||||
"open": "^8.0.2",
|
||||
"picomatch": "^2.3.2",
|
||||
"source-map": "^0.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-playwright": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-playwright/-/test-runner-playwright-1.0.0.tgz",
|
||||
@@ -2704,210 +2287,6 @@
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-playwright/node_modules/@web/browser-logs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
|
||||
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"errorstacks": "^2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-playwright/node_modules/@web/dev-server-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
|
||||
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/koa": "^2.11.6",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@web/parse5-utils": "^3.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone": "^2.1.2",
|
||||
"es-module-lexer": "^1.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"isbinaryfile": "^5.0.0",
|
||||
"koa": "^2.16.1",
|
||||
"koa-etag": "^4.0.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"lru-cache": "^8.0.4",
|
||||
"mime-types": "^2.1.27",
|
||||
"parse5": "^6.0.1",
|
||||
"picomatch": "^2.3.2",
|
||||
"ws": "^7.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-playwright/node_modules/@web/parse5-utils": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
|
||||
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse5": "^6.0.1",
|
||||
"parse5": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner-playwright/node_modules/@web/test-runner-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
|
||||
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.12.11",
|
||||
"@types/babel__code-frame": "^7.0.2",
|
||||
"@types/co-body": "^6.1.0",
|
||||
"@types/convert-source-map": "^2.0.0",
|
||||
"@types/debounce": "^1.2.0",
|
||||
"@types/istanbul-lib-coverage": "^2.0.3",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@web/browser-logs": "^1.0.0",
|
||||
"@web/dev-server-core": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"co-body": "^6.1.0",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debounce": "^1.2.0",
|
||||
"dependency-graph": "^0.11.0",
|
||||
"globby": "^11.0.1",
|
||||
"internal-ip": "^6.2.0",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.0.2",
|
||||
"log-update": "^4.0.0",
|
||||
"nanocolors": "^0.2.1",
|
||||
"nanoid": "^3.1.25",
|
||||
"open": "^8.0.2",
|
||||
"picomatch": "^2.3.2",
|
||||
"source-map": "^0.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner/node_modules/@web/browser-logs": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-1.0.0.tgz",
|
||||
"integrity": "sha512-hK/pDqdw8SV29QDtFaI/tjU5xBPUCZ2lfi2AnG8NoWZadRJziC5fMDZDR23yWtvqVHyrxJaogBvfOKgzMubSLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"errorstacks": "^2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner/node_modules/@web/dev-server-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-1.0.0.tgz",
|
||||
"integrity": "sha512-xe7hE/MMHy0mE8PKSu6lLEYTgk0AKqCKC2Ait80OokQ8RD9n1D/GF4aU3CwFP0KgLZUPkiWj2D4qq6mbgaW6Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/koa": "^2.11.6",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@web/parse5-utils": "^3.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone": "^2.1.2",
|
||||
"es-module-lexer": "^1.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"isbinaryfile": "^5.0.0",
|
||||
"koa": "^2.16.1",
|
||||
"koa-etag": "^4.0.0",
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"lru-cache": "^8.0.4",
|
||||
"mime-types": "^2.1.27",
|
||||
"parse5": "^6.0.1",
|
||||
"picomatch": "^2.3.2",
|
||||
"ws": "^7.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner/node_modules/@web/parse5-utils": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-3.0.0.tgz",
|
||||
"integrity": "sha512-NmmZneHvBHjZ5rHpEtD7AnvrUQQL4KUyLhj5YH8CDagIfTC/PTTEl7fXYNwA7+diJuaOPXGfYzpxzhAy829ijg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/parse5": "^6.0.1",
|
||||
"parse5": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner/node_modules/@web/test-runner-commands": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-1.0.0.tgz",
|
||||
"integrity": "sha512-8wXJkwvWWCc6GTPsdjbEN141kz+4MQPFjsId47hoibkY3FhkgeKJUm+cnwMYCIXHuWA0e7V+L2fmH087UPrXXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@web/test-runner-core": "^1.0.0",
|
||||
"mkdirp": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@web/test-runner/node_modules/@web/test-runner-core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-1.0.0.tgz",
|
||||
"integrity": "sha512-rn+yYVQGrnr0dW7e/6CjwiefjXAGbQV+ddKJ1PLQ6WOvDAzqPBs7rBYFasy3nkJdgyDRhZStvWtunYGmiR53DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.12.11",
|
||||
"@types/babel__code-frame": "^7.0.2",
|
||||
"@types/co-body": "^6.1.0",
|
||||
"@types/convert-source-map": "^2.0.0",
|
||||
"@types/debounce": "^1.2.0",
|
||||
"@types/istanbul-lib-coverage": "^2.0.3",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@web/browser-logs": "^1.0.0",
|
||||
"@web/dev-server-core": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"cli-cursor": "^3.1.0",
|
||||
"co-body": "^6.1.0",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debounce": "^1.2.0",
|
||||
"dependency-graph": "^0.11.0",
|
||||
"globby": "^11.0.1",
|
||||
"internal-ip": "^6.2.0",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.0.2",
|
||||
"log-update": "^4.0.0",
|
||||
"nanocolors": "^0.2.1",
|
||||
"nanoid": "^3.1.25",
|
||||
"open": "^8.0.2",
|
||||
"picomatch": "^2.3.2",
|
||||
"source-map": "^0.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -5366,33 +4745,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/portfinder": {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"test:watch": "web-test-runner --config web-test-runner.config.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@open-wc/testing": "^4.0.0",
|
||||
"@open-wc/testing": "^5.0.0",
|
||||
"@web/dev-server-esbuild": "^2.0.0",
|
||||
"@web/test-runner": "^1.0.0",
|
||||
"@web/test-runner-playwright": "^1.0.0",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"playwright": "1.61"
|
||||
"playwright": "1.62"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "$esbuild",
|
||||
|
||||
@@ -81,7 +81,10 @@ export const panelStyles = css`
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 4px 0 8px;
|
||||
justify-content: flex-end;
|
||||
/* Left-aligned on purpose: flex-end mimicked the pre-v2.37 look (buttons
|
||||
trailing the filter row), but on wide desktops it strands the primary
|
||||
"new task" action at the far right of an otherwise empty row. */
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
:host([narrow]) .actions-bar {
|
||||
@@ -1009,6 +1012,21 @@ export const panelStyles = css`
|
||||
.checklist-preview-list li {
|
||||
padding: 1px 0;
|
||||
}
|
||||
/* #73: interactive in-cycle ticks. */
|
||||
.checklist-preview-list label {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checklist-preview-list input[type="checkbox"] {
|
||||
accent-color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.checklist-preview-list li.checked label span {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Recommendation Card */
|
||||
.recommendation-card {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { isSafeHttpUrl } from "../helpers/url";
|
||||
import { t, formatDate, formatDateTime, formatRecurrence } from "../styles";
|
||||
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask } from "../types";
|
||||
import type { AdvancedFeatures, HomeAssistant, MaintenanceTask, ManualDocRef } from "../types";
|
||||
import { renderTriggerSection, type SparklineContext } from "./sparkline";
|
||||
import { renderPredictionSection } from "./prediction";
|
||||
import { renderWeibullSection } from "./weibull";
|
||||
@@ -32,6 +32,13 @@ export interface TaskDetailContext {
|
||||
objectName: string;
|
||||
/** Parent object's documentation_url (raw; sanitised here). */
|
||||
objectDocUrl: string | null | undefined;
|
||||
/** Parent object's manual-tagged documents — the fallback for the manual
|
||||
* row when documentation_url is empty (same rule as the objects table). */
|
||||
objectManualDocs: ManualDocRef[];
|
||||
/** Opens a manual document (signed path / weblink) — panel-owned. */
|
||||
openManualDoc: (doc: ManualDocRef) => void;
|
||||
/** #73: persist one checklist tick (panel sends the full state via WS). */
|
||||
setChecklistItem: (item: string, done: boolean) => void;
|
||||
isOperator: boolean;
|
||||
actionLoading: boolean;
|
||||
moreMenuOpen: boolean;
|
||||
@@ -178,22 +185,38 @@ function collapsible(key: string, titleKey: string, body: unknown, ctx: TaskDeta
|
||||
`;
|
||||
}
|
||||
|
||||
/** Read-only preview of the configured checklist steps so users can see
|
||||
* the steps without having to open the Edit or Complete dialog. Only
|
||||
* rendered when the Checklists feature is enabled and steps are set. */
|
||||
/** Interactive checklist (#73): steps can be ticked off DURING the cycle
|
||||
* without completing the task — progress persists server-side (survives
|
||||
* reloads, prefills the complete dialog) and resets when the task is
|
||||
* completed or skipped. Only rendered when the Checklists feature is
|
||||
* enabled and steps are set. */
|
||||
function renderChecklistCard(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
if (!ctx.features.checklists) return nothing;
|
||||
const items = task.checklist || [];
|
||||
if (items.length === 0) return nothing;
|
||||
const L = ctx.lang;
|
||||
const progress = task.checklist_progress || {};
|
||||
const done = items.filter((item) => progress[item]).length;
|
||||
return html`
|
||||
<div class="checklist-preview-card">
|
||||
<div class="checklist-preview-header">
|
||||
<ha-icon icon="mdi:format-list-checks"></ha-icon>
|
||||
<span>${t("checklist", L)} (${items.length})</span>
|
||||
<span>${t("checklist", L)} (${done}/${items.length})</span>
|
||||
</div>
|
||||
<ol class="checklist-preview-list">
|
||||
${items.map((item) => html`<li>${item}</li>`)}
|
||||
${items.map((item) => html`
|
||||
<li class=${progress[item] ? "checked" : ""}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${!!progress[item]}
|
||||
@change=${(e: Event) =>
|
||||
ctx.setChecklistItem(item, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>${item}</span>
|
||||
</label>
|
||||
</li>
|
||||
`)}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
@@ -206,7 +229,10 @@ function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
const safeTaskUrl = isSafeHttpUrl(task.documentation_url)
|
||||
? task.documentation_url : null;
|
||||
const safeObjUrl = isSafeHttpUrl(ctx.objectDocUrl) ? ctx.objectDocUrl : null;
|
||||
if (!task.notes && !safeTaskUrl && !safeObjUrl) return nothing;
|
||||
// Same fallback rule as the objects table: an UPLOADED manual (category
|
||||
// "manual") stands in when the object's URL field is empty.
|
||||
const manualDoc = safeObjUrl ? null : (ctx.objectManualDocs || [])[0];
|
||||
if (!task.notes && !safeTaskUrl && !safeObjUrl && !manualDoc) return nothing;
|
||||
const L = ctx.lang;
|
||||
return html`
|
||||
<div class="task-meta-card">
|
||||
@@ -227,6 +253,13 @@ function renderTaskMeta(task: MaintenanceTask, ctx: TaskDetailContext) {
|
||||
<ha-icon icon="mdi:book-open-variant"></ha-icon>
|
||||
<a href="${safeObjUrl}" target="_blank" rel="noopener noreferrer">${t("documentation_url_label", L)} (${ctx.objectName})</a>
|
||||
</div>
|
||||
` : manualDoc ? html`
|
||||
<div class="task-meta-row task-meta-link">
|
||||
<ha-icon icon="mdi:book-open-variant"></ha-icon>
|
||||
<a href="#" title=${manualDoc.title}
|
||||
@click=${(e: Event) => { e.preventDefault(); ctx.openManualDoc(manualDoc); }}
|
||||
>${t("documentation_url_label", L)} (${ctx.objectName})</a>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -31,6 +31,17 @@ export interface MaintenanceObject {
|
||||
/** (roadmap P2) number of attached documents (files + web-links); drives the
|
||||
* objects-table paperclip badge. Computed server-side, not persisted. */
|
||||
document_count?: number;
|
||||
/** Attached documents tagged as manuals — the fallback for the "manual"
|
||||
* column/header when documentation_url is unset. Computed server-side. */
|
||||
manual_docs?: ManualDocRef[];
|
||||
}
|
||||
|
||||
/** Slim reference to a manual-tagged document (subset of MaintenanceDocument). */
|
||||
export interface ManualDocRef {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: string; // "file" | "weblink"
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export interface TriggerConfig {
|
||||
@@ -161,6 +172,9 @@ export interface MaintenanceTask {
|
||||
notes?: string | null;
|
||||
documentation_url?: string | null;
|
||||
checklist?: string[];
|
||||
/** #73: in-cycle ticks ({item text: bool}); persists server-side, resets on
|
||||
* complete/skip. */
|
||||
checklist_progress?: Record<string, boolean>;
|
||||
labels?: string[];
|
||||
assignee_pool?: string[];
|
||||
rotation_strategy?: string | null;
|
||||
@@ -184,7 +198,11 @@ export interface MaintenanceTask {
|
||||
trigger_entity_infos?: TriggerEntityInfo[] | null;
|
||||
/** Battery Fleet: the single aggregate task renders the battery section. */
|
||||
battery_fleet_task?: boolean;
|
||||
/** LIST payloads carry only the most recent window (payload diet) — the
|
||||
* task detail fetches the full record via `task/history`. */
|
||||
history: HistoryEntry[];
|
||||
/** Total entries that exist, including those beyond the list window. */
|
||||
history_count?: number;
|
||||
// Computed
|
||||
status: string; // "ok" | "due_soon" | "overdue" | "triggered" | "archived"
|
||||
/** True for a one-time task that has been completed (done; never re-arms). */
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..const import (
|
||||
STRATEGY_URL,
|
||||
VENDOR_URL,
|
||||
)
|
||||
from ..panel import _async_file_hash
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,10 +94,24 @@ async def async_register_card(hass: HomeAssistant) -> None:
|
||||
# whenDefined race even under heavy HACS plugin load (the bundle is loaded
|
||||
# lazily by the shim). The heavy bundle's static path stays mounted above
|
||||
# so the shim's import() can fetch it on demand.
|
||||
#
|
||||
# Every URL carries a content-hash query (issue #124): these module
|
||||
# scripts are served without Cache-Control, so browsers cache them
|
||||
# heuristically. After an update a stale cached strategy entry kept
|
||||
# importing chunk names the update had deleted → 404 → the strategy
|
||||
# dashboard rendered nothing until a hard refresh (and a rollback
|
||||
# "fixed" it by putting the old chunks back). A hash-busted URL changes
|
||||
# with the file, so each release starts from a fresh entry point; the
|
||||
# chunk layer below is content-hashed already. Same trick the panel has
|
||||
# used since #112.
|
||||
extra = hass.data.setdefault(DATA_EXTRA_MODULE_URL, set())
|
||||
extra.add(CARD_URL)
|
||||
extra.add(STRATEGY_SHIM_URL)
|
||||
extra.add(CALENDAR_CARD_URL)
|
||||
for url, filename in (
|
||||
(CARD_URL, "maintenance-card.js"),
|
||||
(STRATEGY_SHIM_URL, "maintenance-strategy-shim.js"),
|
||||
(CALENDAR_CARD_URL, "maintenance-calendar-card.js"),
|
||||
):
|
||||
digest = await _async_file_hash(hass, frontend_dir / filename)
|
||||
extra.add(f"{url}?v={digest}")
|
||||
|
||||
hass.data[_CARD_REGISTERED_KEY] = True
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Počet opakování",
|
||||
"series_end_until_label": "Datum konce",
|
||||
"parts_section": "Díly a spotřební materiál",
|
||||
"parts_inventory_value": "Hodnota zásob",
|
||||
"part_add": "Přidat díl",
|
||||
"part_name": "Název",
|
||||
"part_vendor": "Výrobce",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Brzy",
|
||||
"battery_fleet_status_ok": "V pořádku",
|
||||
"battery_fleet_predicted_on": "Očekáváno kolem {date}",
|
||||
"battery_fleet_predicted_trend": "Předpověď z trendu vybíjení této baterie: přibližně {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Akumulátor: nabíjí se místo výměny — nikdy na nákupním seznamu",
|
||||
"battery_fleet_sort_name": "Řadit podle názvu",
|
||||
"battery_fleet_sort_urgency": "Řadit podle naléhavosti",
|
||||
"battery_fleet_mark_recharged": "Označit jako nabitou",
|
||||
"battery_fleet_sparkline_hint": "Stav baterie za posledních 30 dní — tečkovaně: projekce k prahu vybití",
|
||||
"battery_fleet_filter_type": "Zobrazit pouze tento typ baterie",
|
||||
"battery_fleet_record_replacement": "Stav poskočil kolem {date} — zaznamenat tuto výměnu do Battery Notes",
|
||||
"battery_fleet_total": "Sledováno baterií: {n}",
|
||||
"battery_fleet_setup_button": "Flotila baterií",
|
||||
"battery_fleet_setup_done": "Flotila baterií nastavena — jeden úkol sleduje všechny.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Antal gange",
|
||||
"series_end_until_label": "Slutdato",
|
||||
"parts_section": "Dele & forbrugsvarer",
|
||||
"parts_inventory_value": "Lagerværdi",
|
||||
"part_add": "Tilføj del",
|
||||
"part_name": "Navn",
|
||||
"part_vendor": "Producent",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Snart",
|
||||
"battery_fleet_status_ok": "I orden",
|
||||
"battery_fleet_predicted_on": "Forventes omkring {date}",
|
||||
"battery_fleet_predicted_trend": "Forudsagt ud fra batteriets afladningstendens: omkring {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Genopladeligt: oplad i stedet for at udskifte — aldrig på indkøbslisten",
|
||||
"battery_fleet_sort_name": "Sortér efter navn",
|
||||
"battery_fleet_sort_urgency": "Sortér efter hastende grad",
|
||||
"battery_fleet_mark_recharged": "Markér som genopladet",
|
||||
"battery_fleet_sparkline_hint": "Batteriniveau de seneste 30 dage — stiplet: fremskrivning ned til lav-tærsklen",
|
||||
"battery_fleet_filter_type": "Vis kun denne batteritype",
|
||||
"battery_fleet_record_replacement": "Niveauet sprang omkring {date} — registrér denne udskiftning i Battery Notes",
|
||||
"battery_fleet_total": "{n} batterier overvåges",
|
||||
"battery_fleet_setup_button": "Batteriflåde",
|
||||
"battery_fleet_setup_done": "Batteriflåde opsat — én opgave følger alle batterier.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Anzahl",
|
||||
"series_end_until_label": "Enddatum",
|
||||
"parts_section": "Teile & Verbrauchsmaterial",
|
||||
"parts_inventory_value": "Lagerwert",
|
||||
"part_add": "Teil hinzufügen",
|
||||
"part_name": "Name",
|
||||
"part_vendor": "Hersteller",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Bald",
|
||||
"battery_fleet_status_ok": "In Ordnung",
|
||||
"battery_fleet_predicted_on": "Voraussichtlich um {date}",
|
||||
"battery_fleet_predicted_trend": "Aus dem Entladetrend dieser Batterie vorhergesagt: etwa {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Wiederaufladbar: laden statt ersetzen — nie auf der Einkaufsliste",
|
||||
"battery_fleet_sort_name": "Nach Name sortieren",
|
||||
"battery_fleet_sort_urgency": "Nach Dringlichkeit sortieren",
|
||||
"battery_fleet_mark_recharged": "Als aufgeladen markieren",
|
||||
"battery_fleet_sparkline_hint": "Batteriestand der letzten 30 Tage — gepunktet: Prognose bis zur Low-Schwelle",
|
||||
"battery_fleet_filter_type": "Nur diesen Batterietyp anzeigen",
|
||||
"battery_fleet_record_replacement": "Der Batteriestand ist um den {date} gesprungen — diesen Wechsel in Battery Notes nachtragen",
|
||||
"battery_fleet_total": "{n} Batterien überwacht",
|
||||
"battery_fleet_setup_button": "Batterie-Flotte",
|
||||
"battery_fleet_setup_done": "Batterie-Flotte eingerichtet — ein Task überwacht alle Batterien.",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "Number of times",
|
||||
"series_end_until_label": "End date",
|
||||
"parts_section": "Parts & consumables",
|
||||
"parts_inventory_value": "Inventory value",
|
||||
"part_add": "Add part",
|
||||
"part_name": "Name",
|
||||
"part_vendor": "Manufacturer",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Soon",
|
||||
"battery_fleet_status_ok": "Healthy",
|
||||
"battery_fleet_predicted_on": "Expected around {date}",
|
||||
"battery_fleet_predicted_trend": "Predicted from this battery's discharge trend: around {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Rechargeable: charge instead of replacing — never on the shopping list",
|
||||
"battery_fleet_sort_name": "Sort by name",
|
||||
"battery_fleet_sort_urgency": "Sort by urgency",
|
||||
"battery_fleet_mark_recharged": "Mark as recharged",
|
||||
"battery_fleet_sparkline_hint": "Battery level over the last 30 days — dotted: projected until the low threshold",
|
||||
"battery_fleet_filter_type": "Show only this battery type",
|
||||
"battery_fleet_record_replacement": "The level jumped around {date} — record this replacement in Battery Notes",
|
||||
"battery_fleet_total": "{n} batteries tracked",
|
||||
"battery_fleet_setup_button": "Battery fleet",
|
||||
"battery_fleet_setup_done": "Battery fleet set up — one task tracks all your batteries.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Número de veces",
|
||||
"series_end_until_label": "Fecha de fin",
|
||||
"parts_section": "Piezas y consumibles",
|
||||
"parts_inventory_value": "Valor del inventario",
|
||||
"part_add": "Añadir pieza",
|
||||
"part_name": "Nombre",
|
||||
"part_vendor": "Fabricante",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Pronto",
|
||||
"battery_fleet_status_ok": "Correcta",
|
||||
"battery_fleet_predicted_on": "Previsto hacia {date}",
|
||||
"battery_fleet_predicted_trend": "Predicción según la tendencia de descarga de esta pila: hacia {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Recargable: se recarga en lugar de sustituirse — nunca en la lista de compra",
|
||||
"battery_fleet_sort_name": "Ordenar por nombre",
|
||||
"battery_fleet_sort_urgency": "Ordenar por urgencia",
|
||||
"battery_fleet_mark_recharged": "Marcar como recargada",
|
||||
"battery_fleet_sparkline_hint": "Nivel de batería de los últimos 30 días — punteado: proyección hasta el umbral bajo",
|
||||
"battery_fleet_filter_type": "Mostrar solo este tipo de pila",
|
||||
"battery_fleet_record_replacement": "El nivel dio un salto hacia el {date} — registrar esta sustitución en Battery Notes",
|
||||
"battery_fleet_total": "{n} baterías monitorizadas",
|
||||
"battery_fleet_setup_button": "Flota de baterías",
|
||||
"battery_fleet_setup_done": "Flota de baterías configurada — una tarea controla todas.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Kertojen määrä",
|
||||
"series_end_until_label": "Päättymispäivä",
|
||||
"parts_section": "Osat ja tarvikkeet",
|
||||
"parts_inventory_value": "Varaston arvo",
|
||||
"part_add": "Lisää osa",
|
||||
"part_name": "Nimi",
|
||||
"part_vendor": "Valmistaja",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Pian",
|
||||
"battery_fleet_status_ok": "Kunnossa",
|
||||
"battery_fleet_predicted_on": "Odotettavissa noin {date}",
|
||||
"battery_fleet_predicted_trend": "Ennustettu tämän pariston purkautumistrendistä: noin {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Ladattava: lataa vaihtamisen sijaan — ei koskaan ostoslistalle",
|
||||
"battery_fleet_sort_name": "Lajittele nimen mukaan",
|
||||
"battery_fleet_sort_urgency": "Lajittele kiireellisyyden mukaan",
|
||||
"battery_fleet_mark_recharged": "Merkitse ladatuksi",
|
||||
"battery_fleet_sparkline_hint": "Akun varaustaso viimeisten 30 päivän ajalta — pisteviiva: ennuste alarajaan asti",
|
||||
"battery_fleet_filter_type": "Näytä vain tämä paristotyyppi",
|
||||
"battery_fleet_record_replacement": "Varaustaso hyppäsi noin {date} — kirjaa tämä vaihto Battery Notesiin",
|
||||
"battery_fleet_total": "{n} paristoa seurannassa",
|
||||
"battery_fleet_setup_button": "Paristokanta",
|
||||
"battery_fleet_setup_done": "Paristokanta määritetty — yksi tehtävä seuraa kaikkia.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Nombre de fois",
|
||||
"series_end_until_label": "Date de fin",
|
||||
"parts_section": "Pièces & consommables",
|
||||
"parts_inventory_value": "Valeur du stock",
|
||||
"part_add": "Ajouter une pièce",
|
||||
"part_name": "Nom",
|
||||
"part_vendor": "Fabricant",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Bientôt",
|
||||
"battery_fleet_status_ok": "Bon état",
|
||||
"battery_fleet_predicted_on": "Prévu vers {date}",
|
||||
"battery_fleet_predicted_trend": "Prédit à partir de la tendance de décharge de cette pile : vers {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Rechargeable : à recharger plutôt qu'à remplacer — jamais sur la liste d'achats",
|
||||
"battery_fleet_sort_name": "Trier par nom",
|
||||
"battery_fleet_sort_urgency": "Trier par urgence",
|
||||
"battery_fleet_mark_recharged": "Marquer comme rechargée",
|
||||
"battery_fleet_sparkline_hint": "Niveau de batterie des 30 derniers jours — pointillé : projection jusqu'au seuil bas",
|
||||
"battery_fleet_filter_type": "Afficher uniquement ce type de pile",
|
||||
"battery_fleet_record_replacement": "Le niveau a bondi vers le {date} — enregistrer ce remplacement dans Battery Notes",
|
||||
"battery_fleet_total": "{n} piles suivies",
|
||||
"battery_fleet_setup_button": "Parc de piles",
|
||||
"battery_fleet_setup_done": "Parc de piles configuré — une tâche suit toutes vos piles.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "कितनी बार",
|
||||
"series_end_until_label": "समाप्ति तिथि",
|
||||
"parts_section": "पुर्ज़े और उपभोग्य",
|
||||
"parts_inventory_value": "इन्वेंट्री मूल्य",
|
||||
"part_add": "पुर्ज़ा जोड़ें",
|
||||
"part_name": "नाम",
|
||||
"part_vendor": "निर्माता",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "जल्द",
|
||||
"battery_fleet_status_ok": "ठीक",
|
||||
"battery_fleet_predicted_on": "{date} के आसपास अपेक्षित",
|
||||
"battery_fleet_predicted_trend": "इस बैटरी के डिस्चार्ज रुझान से अनुमानित: लगभग {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "रिचार्जेबल: बदलने के बजाय चार्ज करें — खरीदारी सूची में कभी नहीं",
|
||||
"battery_fleet_sort_name": "नाम के अनुसार क्रमबद्ध करें",
|
||||
"battery_fleet_sort_urgency": "तात्कालिकता के अनुसार क्रमबद्ध करें",
|
||||
"battery_fleet_mark_recharged": "रिचार्ज हो गई के रूप में चिह्नित करें",
|
||||
"battery_fleet_sparkline_hint": "पिछले 30 दिनों का बैटरी स्तर — बिंदीदार: लो थ्रेशोल्ड तक का अनुमान",
|
||||
"battery_fleet_filter_type": "केवल यही बैटरी प्रकार दिखाएँ",
|
||||
"battery_fleet_record_replacement": "स्तर लगभग {date} को उछला — इस बदलाव को Battery Notes में दर्ज करें",
|
||||
"battery_fleet_total": "{n} बैटरियाँ ट्रैक की गईं",
|
||||
"battery_fleet_setup_button": "बैटरी फ्लीट",
|
||||
"battery_fleet_setup_done": "बैटरी फ्लीट सेट — एक कार्य सभी बैटरियों को ट्रैक करता है।",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "Alkalmak száma",
|
||||
"series_end_until_label": "Befejezés dátuma",
|
||||
"parts_section": "Alkatrészek és fogyóeszközök",
|
||||
"parts_inventory_value": "Készletérték",
|
||||
"part_add": "Alkatrész hozzáadása",
|
||||
"part_name": "Név",
|
||||
"part_vendor": "Gyártó",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Hamarosan",
|
||||
"battery_fleet_status_ok": "Rendben",
|
||||
"battery_fleet_predicted_on": "Várhatóan {date} körül",
|
||||
"battery_fleet_predicted_trend": "Az elem merülési trendjéből előrejelezve: kb. {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Újratölthető: cserélés helyett töltse fel — soha nem kerül a bevásárlólistára",
|
||||
"battery_fleet_sort_name": "Rendezés név szerint",
|
||||
"battery_fleet_sort_urgency": "Rendezés sürgősség szerint",
|
||||
"battery_fleet_mark_recharged": "Megjelölés feltöltöttként",
|
||||
"battery_fleet_sparkline_hint": "Akkumulátorszint az elmúlt 30 napban — pontozott: előrejelzés az alacsony küszöbig",
|
||||
"battery_fleet_filter_type": "Csak ez az elemtípus megjelenítése",
|
||||
"battery_fleet_record_replacement": "A szint {date} körül megugrott — rögzítse ezt a cserét a Battery Notes-ban",
|
||||
"battery_fleet_total": "{n} elem követve",
|
||||
"battery_fleet_setup_button": "Elemflotta",
|
||||
"battery_fleet_setup_done": "Elemflotta beállítva — egyetlen feladat követi az összes elemet.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Numero di volte",
|
||||
"series_end_until_label": "Data di fine",
|
||||
"parts_section": "Ricambi e consumabili",
|
||||
"parts_inventory_value": "Valore delle scorte",
|
||||
"part_add": "Aggiungi ricambio",
|
||||
"part_name": "Nome",
|
||||
"part_vendor": "Produttore",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "A breve",
|
||||
"battery_fleet_status_ok": "A posto",
|
||||
"battery_fleet_predicted_on": "Previsto intorno al {date}",
|
||||
"battery_fleet_predicted_trend": "Previsto dalla tendenza di scarica di questa batteria: intorno al {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Ricaricabile: da ricaricare anziché sostituire — mai nella lista della spesa",
|
||||
"battery_fleet_sort_name": "Ordina per nome",
|
||||
"battery_fleet_sort_urgency": "Ordina per urgenza",
|
||||
"battery_fleet_mark_recharged": "Segna come ricaricata",
|
||||
"battery_fleet_sparkline_hint": "Livello batteria degli ultimi 30 giorni — tratteggiato: proiezione fino alla soglia di batteria scarica",
|
||||
"battery_fleet_filter_type": "Mostra solo questo tipo di batteria",
|
||||
"battery_fleet_record_replacement": "Il livello è balzato intorno al {date} — registra questa sostituzione in Battery Notes",
|
||||
"battery_fleet_total": "{n} batterie monitorate",
|
||||
"battery_fleet_setup_button": "Parco batterie",
|
||||
"battery_fleet_setup_done": "Parco batterie configurato — un'attività monitora tutte.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "回数",
|
||||
"series_end_until_label": "終了日",
|
||||
"parts_section": "部品・消耗品",
|
||||
"parts_inventory_value": "在庫金額",
|
||||
"part_add": "部品を追加",
|
||||
"part_name": "名前",
|
||||
"part_vendor": "メーカー",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "まもなく",
|
||||
"battery_fleet_status_ok": "良好",
|
||||
"battery_fleet_predicted_on": "{date} 頃の見込み",
|
||||
"battery_fleet_predicted_trend": "この電池の放電傾向から予測: {date} 頃 ({confidence})",
|
||||
"battery_fleet_rechargeable": "充電式:交換ではなく充電——買い物リストには載りません",
|
||||
"battery_fleet_sort_name": "名前順に並べ替え",
|
||||
"battery_fleet_sort_urgency": "緊急度順に並べ替え",
|
||||
"battery_fleet_mark_recharged": "充電済みにする",
|
||||
"battery_fleet_sparkline_hint": "過去30日間のバッテリー残量——点線:低残量しきい値までの予測",
|
||||
"battery_fleet_filter_type": "この電池タイプのみ表示",
|
||||
"battery_fleet_record_replacement": "{date}頃に残量が急上昇——この交換をBattery Notesに記録する",
|
||||
"battery_fleet_total": "{n} 個の電池を監視",
|
||||
"battery_fleet_setup_button": "電池フリート",
|
||||
"battery_fleet_setup_done": "電池フリートを設定 — 1つのタスクで全電池を管理。",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "횟수",
|
||||
"series_end_until_label": "종료일",
|
||||
"parts_section": "부품 및 소모품",
|
||||
"parts_inventory_value": "재고 가치",
|
||||
"part_add": "부품 추가",
|
||||
"part_name": "이름",
|
||||
"part_vendor": "제조사",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "곧",
|
||||
"battery_fleet_status_ok": "정상",
|
||||
"battery_fleet_predicted_on": "{date}쯤 예상",
|
||||
"battery_fleet_predicted_trend": "이 배터리의 방전 추세로 예측: 약 {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "충전식: 교체 대신 충전 — 쇼핑 목록에 오르지 않습니다",
|
||||
"battery_fleet_sort_name": "이름순 정렬",
|
||||
"battery_fleet_sort_urgency": "긴급도순 정렬",
|
||||
"battery_fleet_mark_recharged": "충전 완료로 표시",
|
||||
"battery_fleet_sparkline_hint": "지난 30일간의 배터리 잔량 — 점선: 낮음 임계값까지의 예측",
|
||||
"battery_fleet_filter_type": "이 배터리 유형만 표시",
|
||||
"battery_fleet_record_replacement": "{date}쯤 잔량이 급상승했습니다 — 이 교체를 Battery Notes에 기록",
|
||||
"battery_fleet_total": "배터리 {n}개 추적 중",
|
||||
"battery_fleet_setup_button": "배터리 플릿",
|
||||
"battery_fleet_setup_done": "배터리 플릿이 설정되었습니다 — 하나의 작업이 모든 배터리를 추적합니다.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Antall ganger",
|
||||
"series_end_until_label": "Sluttdato",
|
||||
"parts_section": "Deler & forbruksvarer",
|
||||
"parts_inventory_value": "Lagerverdi",
|
||||
"part_add": "Legg til del",
|
||||
"part_name": "Navn",
|
||||
"part_vendor": "Produsent",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Snart",
|
||||
"battery_fleet_status_ok": "I orden",
|
||||
"battery_fleet_predicted_on": "Forventes rundt {date}",
|
||||
"battery_fleet_predicted_trend": "Forutsagt ut fra batteriets utladingstrend: rundt {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Oppladbart: lad i stedet for å bytte — aldri på handlelisten",
|
||||
"battery_fleet_sort_name": "Sorter etter navn",
|
||||
"battery_fleet_sort_urgency": "Sorter etter hastegrad",
|
||||
"battery_fleet_mark_recharged": "Merk som oppladet",
|
||||
"battery_fleet_sparkline_hint": "Batterinivå de siste 30 dagene — stiplet: fremskrevet ned til lavnivåterskelen",
|
||||
"battery_fleet_filter_type": "Vis bare denne batteritypen",
|
||||
"battery_fleet_record_replacement": "Nivået hoppet rundt {date} — registrer dette byttet i Battery Notes",
|
||||
"battery_fleet_total": "{n} batterier spores",
|
||||
"battery_fleet_setup_button": "Batteriflåte",
|
||||
"battery_fleet_setup_done": "Batteriflåte satt opp — én oppgave følger alle batteriene.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Aantal keer",
|
||||
"series_end_until_label": "Einddatum",
|
||||
"parts_section": "Onderdelen & verbruiksartikelen",
|
||||
"parts_inventory_value": "Voorraadwaarde",
|
||||
"part_add": "Onderdeel toevoegen",
|
||||
"part_name": "Naam",
|
||||
"part_vendor": "Fabrikant",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Binnenkort",
|
||||
"battery_fleet_status_ok": "In orde",
|
||||
"battery_fleet_predicted_on": "Verwacht rond {date}",
|
||||
"battery_fleet_predicted_trend": "Voorspeld uit de ontladingstrend van deze batterij: rond {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Oplaadbaar: opladen in plaats van vervangen — nooit op het boodschappenlijstje",
|
||||
"battery_fleet_sort_name": "Sorteren op naam",
|
||||
"battery_fleet_sort_urgency": "Sorteren op urgentie",
|
||||
"battery_fleet_mark_recharged": "Markeren als opgeladen",
|
||||
"battery_fleet_sparkline_hint": "Batterijniveau van de afgelopen 30 dagen — gestippeld: prognose tot de lage drempel",
|
||||
"battery_fleet_filter_type": "Alleen dit batterijtype tonen",
|
||||
"battery_fleet_record_replacement": "Het niveau maakte rond {date} een sprong — deze vervanging vastleggen in Battery Notes",
|
||||
"battery_fleet_total": "{n} batterijen gevolgd",
|
||||
"battery_fleet_setup_button": "Batterijvloot",
|
||||
"battery_fleet_setup_done": "Batterijvloot ingesteld — één taak volgt al je batterijen.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Liczba razy",
|
||||
"series_end_until_label": "Data końcowa",
|
||||
"parts_section": "Części i materiały",
|
||||
"parts_inventory_value": "Wartość zapasów",
|
||||
"part_add": "Dodaj część",
|
||||
"part_name": "Nazwa",
|
||||
"part_vendor": "Producent",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Wkrótce",
|
||||
"battery_fleet_status_ok": "W porządku",
|
||||
"battery_fleet_predicted_on": "Przewidywane około {date}",
|
||||
"battery_fleet_predicted_trend": "Prognoza na podstawie trendu rozładowania tej baterii: około {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Akumulator: ładowanie zamiast wymiany — nigdy na liście zakupów",
|
||||
"battery_fleet_sort_name": "Sortuj według nazwy",
|
||||
"battery_fleet_sort_urgency": "Sortuj według pilności",
|
||||
"battery_fleet_mark_recharged": "Oznacz jako naładowaną",
|
||||
"battery_fleet_sparkline_hint": "Poziom baterii z ostatnich 30 dni — kropkowana linia: prognoza do progu niskiego poziomu",
|
||||
"battery_fleet_filter_type": "Pokaż tylko ten typ baterii",
|
||||
"battery_fleet_record_replacement": "Poziom skoczył około {date} — zapisz tę wymianę w Battery Notes",
|
||||
"battery_fleet_total": "Śledzone baterie: {n}",
|
||||
"battery_fleet_setup_button": "Flota baterii",
|
||||
"battery_fleet_setup_done": "Flota baterii skonfigurowana — jedno zadanie śledzi wszystkie.",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "Número de vezes",
|
||||
"series_end_until_label": "Data final",
|
||||
"parts_section": "Peças e consumíveis",
|
||||
"parts_inventory_value": "Valor do estoque",
|
||||
"part_add": "Adicionar peça",
|
||||
"part_name": "Nome",
|
||||
"part_vendor": "Fabricante",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Em breve",
|
||||
"battery_fleet_status_ok": "Em bom estado",
|
||||
"battery_fleet_predicted_on": "Previsto por volta de {date}",
|
||||
"battery_fleet_predicted_trend": "Previsto pela tendência de descarga desta bateria: por volta de {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Recarregável: recarregue em vez de substituir — nunca na lista de compras",
|
||||
"battery_fleet_sort_name": "Ordenar por nome",
|
||||
"battery_fleet_sort_urgency": "Ordenar por urgência",
|
||||
"battery_fleet_mark_recharged": "Marcar como recarregada",
|
||||
"battery_fleet_sparkline_hint": "Nível da bateria dos últimos 30 dias — pontilhado: projeção até o limite baixo",
|
||||
"battery_fleet_filter_type": "Mostrar apenas este tipo de pilha",
|
||||
"battery_fleet_record_replacement": "O nível deu um salto por volta de {date} — registrar esta substituição no Battery Notes",
|
||||
"battery_fleet_total": "{n} baterias acompanhadas",
|
||||
"battery_fleet_setup_button": "Frota de baterias",
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma única tarefa acompanha todas as suas baterias.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Número de vezes",
|
||||
"series_end_until_label": "Data final",
|
||||
"parts_section": "Peças e consumíveis",
|
||||
"parts_inventory_value": "Valor do stock",
|
||||
"part_add": "Adicionar peça",
|
||||
"part_name": "Nome",
|
||||
"part_vendor": "Fabricante",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Em breve",
|
||||
"battery_fleet_status_ok": "Em bom estado",
|
||||
"battery_fleet_predicted_on": "Previsto por volta de {date}",
|
||||
"battery_fleet_predicted_trend": "Previsto pela tendência de descarga desta pilha: por volta de {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Recarregável: carrega-se em vez de se substituir — nunca na lista de compras",
|
||||
"battery_fleet_sort_name": "Ordenar por nome",
|
||||
"battery_fleet_sort_urgency": "Ordenar por urgência",
|
||||
"battery_fleet_mark_recharged": "Marcar como recarregada",
|
||||
"battery_fleet_sparkline_hint": "Nível da bateria dos últimos 30 dias — pontilhado: projeção até ao limiar baixo",
|
||||
"battery_fleet_filter_type": "Mostrar apenas este tipo de pilha",
|
||||
"battery_fleet_record_replacement": "O nível deu um salto por volta de {date} — registar esta substituição no Battery Notes",
|
||||
"battery_fleet_total": "{n} baterias monitorizadas",
|
||||
"battery_fleet_setup_button": "Frota de baterias",
|
||||
"battery_fleet_setup_done": "Frota de baterias configurada — uma tarefa acompanha todas.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Число раз",
|
||||
"series_end_until_label": "Дата окончания",
|
||||
"parts_section": "Детали и расходники",
|
||||
"parts_inventory_value": "Стоимость запасов",
|
||||
"part_add": "Добавить деталь",
|
||||
"part_name": "Название",
|
||||
"part_vendor": "Производитель",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Скоро",
|
||||
"battery_fleet_status_ok": "В норме",
|
||||
"battery_fleet_predicted_on": "Ожидается примерно {date}",
|
||||
"battery_fleet_predicted_trend": "Прогноз по тренду разряда этой батареи: примерно {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Аккумулятор: заряжается, а не заменяется — никогда не попадает в список покупок",
|
||||
"battery_fleet_sort_name": "Сортировать по имени",
|
||||
"battery_fleet_sort_urgency": "Сортировать по срочности",
|
||||
"battery_fleet_mark_recharged": "Отметить как заряженную",
|
||||
"battery_fleet_sparkline_hint": "Уровень заряда за последние 30 дней — пунктир: прогноз до порога разряда",
|
||||
"battery_fleet_filter_type": "Показать только этот тип батарей",
|
||||
"battery_fleet_record_replacement": "Уровень резко вырос примерно {date} — записать эту замену в Battery Notes",
|
||||
"battery_fleet_total": "Отслеживается батарей: {n}",
|
||||
"battery_fleet_setup_button": "Парк батарей",
|
||||
"battery_fleet_setup_done": "Парк батарей настроен — одна задача следит за всеми.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Antal gånger",
|
||||
"series_end_until_label": "Slutdatum",
|
||||
"parts_section": "Delar & förbrukning",
|
||||
"parts_inventory_value": "Lagervärde",
|
||||
"part_add": "Lägg till del",
|
||||
"part_name": "Namn",
|
||||
"part_vendor": "Tillverkare",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Snart",
|
||||
"battery_fleet_status_ok": "I ordning",
|
||||
"battery_fleet_predicted_on": "Väntas omkring {date}",
|
||||
"battery_fleet_predicted_trend": "Förutspått utifrån batteriets urladdningstrend: omkring {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Uppladdningsbart: ladda i stället för att byta — aldrig på inköpslistan",
|
||||
"battery_fleet_sort_name": "Sortera efter namn",
|
||||
"battery_fleet_sort_urgency": "Sortera efter angelägenhet",
|
||||
"battery_fleet_mark_recharged": "Markera som uppladdad",
|
||||
"battery_fleet_sparkline_hint": "Batterinivå de senaste 30 dagarna — prickad: prognos ner till lågnivåtröskeln",
|
||||
"battery_fleet_filter_type": "Visa endast denna batterityp",
|
||||
"battery_fleet_record_replacement": "Nivån hoppade omkring {date} — registrera detta byte i Battery Notes",
|
||||
"battery_fleet_total": "{n} batterier spåras",
|
||||
"battery_fleet_setup_button": "Batteriflotta",
|
||||
"battery_fleet_setup_done": "Batteriflotta konfigurerad — en uppgift följer alla batterier.",
|
||||
|
||||
@@ -722,6 +722,7 @@
|
||||
"series_end_count_label": "Tekrar sayısı",
|
||||
"series_end_until_label": "Bitiş tarihi",
|
||||
"parts_section": "Parçalar ve sarf malzemeleri",
|
||||
"parts_inventory_value": "Stok değeri",
|
||||
"part_add": "Parça ekle",
|
||||
"part_name": "Ad",
|
||||
"part_vendor": "Üretici",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Yakında",
|
||||
"battery_fleet_status_ok": "İyi durumda",
|
||||
"battery_fleet_predicted_on": "Yaklaşık {date} tarihinde bekleniyor",
|
||||
"battery_fleet_predicted_trend": "Bu pilin deşarj eğiliminden tahmin edildi: yaklaşık {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Şarj edilebilir: değiştirmek yerine şarj edin — alışveriş listesine asla girmez",
|
||||
"battery_fleet_sort_name": "Ada göre sırala",
|
||||
"battery_fleet_sort_urgency": "Aciliyete göre sırala",
|
||||
"battery_fleet_mark_recharged": "Şarj edildi olarak işaretle",
|
||||
"battery_fleet_sparkline_hint": "Son 30 günün pil seviyesi — noktalı: düşük eşiğe kadar projeksiyon",
|
||||
"battery_fleet_filter_type": "Yalnızca bu pil türünü göster",
|
||||
"battery_fleet_record_replacement": "Seviye {date} civarında sıçradı — bu değişimi Battery Notes'a kaydet",
|
||||
"battery_fleet_total": "{n} pil takip ediliyor",
|
||||
"battery_fleet_setup_button": "Pil filosu",
|
||||
"battery_fleet_setup_done": "Pil filosu kuruldu — tek bir görev tüm pillerinizi takip ediyor.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "Кількість разів",
|
||||
"series_end_until_label": "Дата завершення",
|
||||
"parts_section": "Деталі та витратні матеріали",
|
||||
"parts_inventory_value": "Вартість запасів",
|
||||
"part_add": "Додати деталь",
|
||||
"part_name": "Назва",
|
||||
"part_vendor": "Виробник",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "Незабаром",
|
||||
"battery_fleet_status_ok": "У нормі",
|
||||
"battery_fleet_predicted_on": "Очікується приблизно {date}",
|
||||
"battery_fleet_predicted_trend": "Прогноз за трендом розряду цієї батареї: приблизно {date} ({confidence})",
|
||||
"battery_fleet_rechargeable": "Акумулятор: заряджається, а не замінюється — ніколи не потрапляє до списку покупок",
|
||||
"battery_fleet_sort_name": "Сортувати за назвою",
|
||||
"battery_fleet_sort_urgency": "Сортувати за терміновістю",
|
||||
"battery_fleet_mark_recharged": "Позначити як заряджену",
|
||||
"battery_fleet_sparkline_hint": "Рівень заряду за останні 30 днів — пунктир: прогноз до порогу розряду",
|
||||
"battery_fleet_filter_type": "Показати лише цей тип батарей",
|
||||
"battery_fleet_record_replacement": "Рівень різко зріс приблизно {date} — записати цю заміну в Battery Notes",
|
||||
"battery_fleet_total": "Відстежується батарей: {n}",
|
||||
"battery_fleet_setup_button": "Парк батарей",
|
||||
"battery_fleet_setup_done": "Парк батарей налаштовано — одне завдання стежить за всіма.",
|
||||
|
||||
@@ -716,6 +716,7 @@
|
||||
"series_end_count_label": "次数",
|
||||
"series_end_until_label": "结束日期",
|
||||
"parts_section": "配件与耗材",
|
||||
"parts_inventory_value": "库存价值",
|
||||
"part_add": "添加配件",
|
||||
"part_name": "名称",
|
||||
"part_vendor": "制造商",
|
||||
@@ -820,6 +821,14 @@
|
||||
"battery_fleet_status_soon": "即将",
|
||||
"battery_fleet_status_ok": "正常",
|
||||
"battery_fleet_predicted_on": "预计在 {date} 前后",
|
||||
"battery_fleet_predicted_trend": "根据此电池的放电趋势预测:约 {date}({confidence})",
|
||||
"battery_fleet_rechargeable": "可充电电池:充电即可,无需更换——不会出现在购物清单中",
|
||||
"battery_fleet_sort_name": "按名称排序",
|
||||
"battery_fleet_sort_urgency": "按紧急程度排序",
|
||||
"battery_fleet_mark_recharged": "标记为已充电",
|
||||
"battery_fleet_sparkline_hint": "过去 30 天的电池电量——虚线:外推至低电量阈值",
|
||||
"battery_fleet_filter_type": "仅显示此电池类型",
|
||||
"battery_fleet_record_replacement": "电量在 {date} 前后跳升——将此次更换记录到 Battery Notes",
|
||||
"battery_fleet_total": "已跟踪 {n} 个电池",
|
||||
"battery_fleet_setup_button": "电池群",
|
||||
"battery_fleet_setup_done": "电池群已设置 — 一个任务跟踪所有电池。",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.46.0 */
|
||||
var d="maintenance-supporter",C=`ll-strategy-dashboard-${d}`,L="hui-maintenance-supporter-strategy-editor",D="/maintenance_supporter_strategy/maintenance-dashboard-strategy.js",m=null;function k(){return m||(m=import(D)),m}async function N(){let r=await k();if(!r.MaintenanceDashboardStrategy)throw new Error("[maintenance-supporter] strategy bundle loaded but did not export MaintenanceDashboardStrategy");return r.MaintenanceDashboardStrategy}var p=class extends HTMLElement{static getCreateSuggestions(c){return{title:"Maintenance Supporter",icon:"mdi:wrench-clock"}}static async getConfigElement(){return await k(),document.createElement(L)}static async generate(c,h){return(await N()).generate(c,h)}};function M(){try{customElements.define(C,p)}catch{}}M();var w=window;w.customStrategies=w.customStrategies||[];w.customStrategies.some(r=>r.type===d&&r.strategyType==="dashboard")||w.customStrategies.push({type:d,strategyType:"dashboard",name:"Maintenance Supporter",description:"Auto-generated dashboard. Group views by area, status, floor, or due date \u2014 picked from the strategy editor or YAML.",documentationURL:"https://github.com/iluebbe/maintenance_supporter#dashboard-strategy"});(()=>{let r=window;if(r.__msStrategyHealActive)return;r.__msStrategyHealActive=!0;let c=/^\/(auth|config|developer-tools|profile|hassio|history|logbook|map|media-browser|energy|todo|calendar)\b/,h=/Timeout waiting for strategy element ll-strategy-(dashboard-)?maintenance-supporter/i,g=`custom:${d}`;function E(a){let t=[document.documentElement],n=0;for(;t.length&&n<9e3;){let o=t.pop();if(n++,!o)continue;let e=o;if(e.nodeType===1&&e.tagName&&e.tagName.toLowerCase()===a)return e;e.shadowRoot&&t.push(e.shadowRoot);let i=o.children;if(i)for(let l of Array.from(i))t.push(l)}return null}function S(a){let t=a?.views;if(!Array.isArray(t)||!t.length)return null;let n=window.location.pathname.split("/").filter(Boolean).pop()||"",o=t.find(i=>i?.path===n);if(o)return o;let e=Number(n);return Number.isInteger(e)&&t[e]?t[e]:t[0]}function R(){try{let t=E("ha-panel-lovelace")?.lovelace;if(!t)return!1;let n=o=>o?.type;for(let o of[t.config,t.rawConfig]){if(!o)continue;if(n(o.strategy)===g)return!0;let e=S(o);if(e&&n(e.strategy)===g)return!0}return!1}catch{return!1}}function _(){let a=!1,t=0,n=!1,o=!1,e=[document.documentElement],i=0;for(;e.length&&i<9e3;){let l=e.pop();if(i++,!l)continue;let u=l;if(u.nodeType===1&&u.tagName){let s=u.tagName.toLowerCase();(s==="hui-view"||s==="hui-sections-view")&&(a=!0),(s==="ha-card"||s==="hui-card")&&t++,s==="hui-empty-state-card"&&(o=!0),s==="hui-error-card"&&h.test(u.textContent||"")&&(n=!0)}u.shadowRoot&&e.push(u.shadowRoot);let b=l.children;if(b)for(let s of Array.from(b))e.push(s)}return n?!0:o?!1:a&&t<3&&R()}let A="/maintenance_supporter_strategy_shim.js",y=0,v=0;function T(){let a=Date.now();a-v<5e3||y>=3||(v=a,y+=1,import(`${A}?heal=${a}`).catch(()=>{}).finally(()=>{let t=window.location.pathname+window.location.search;history.pushState(null,"","/lovelace"),window.dispatchEvent(new CustomEvent("location-changed")),window.setTimeout(()=>{history.pushState(null,"",t),window.dispatchEvent(new CustomEvent("location-changed"))},200)}))}function f(){if(c.test(window.location.pathname))return;let a=0,t=Date.now(),n=window.setInterval(()=>{a++;try{if(Date.now()-t<6e3)return;if(c.test(window.location.pathname)){window.clearInterval(n);return}_()?T():window.clearInterval(n),a>=30&&window.clearInterval(n)}catch{window.clearInterval(n)}},500)}try{document.readyState==="loading"?window.addEventListener("DOMContentLoaded",f):f(),window.addEventListener("location-changed",()=>{c.test(window.location.pathname)||f()})}catch{}})();
|
||||
/*! maintenance_supporter frontend 2.52.0 */
|
||||
var S="2.52.0";var l="maintenance-supporter",T=`ll-strategy-dashboard-${l}`,D="hui-maintenance-supporter-strategy-editor",C=`/maintenance_supporter_strategy/maintenance-dashboard-strategy.js?v=${S}`,m=null;function v(){return m||(m=import(C)),m}async function I(){let r=await v();if(!r.MaintenanceDashboardStrategy)throw new Error("[maintenance-supporter] strategy bundle loaded but did not export MaintenanceDashboardStrategy");return r.MaintenanceDashboardStrategy}var p=class extends HTMLElement{static getCreateSuggestions(c){return{title:"Maintenance Supporter",icon:"mdi:wrench-clock"}}static async getConfigElement(){return await v(),document.createElement(D)}static async generate(c,f){return(await I()).generate(c,f)}};function M(){try{customElements.define(T,p)}catch{}}M();var w=window;w.customStrategies=w.customStrategies||[];w.customStrategies.some(r=>r.type===l&&r.strategyType==="dashboard")||w.customStrategies.push({type:l,strategyType:"dashboard",name:"Maintenance Supporter",description:"Auto-generated dashboard. Group views by area, status, floor, or due date \u2014 picked from the strategy editor or YAML.",documentationURL:"https://github.com/iluebbe/maintenance_supporter#dashboard-strategy"});(()=>{let r=window;if(r.__msStrategyHealActive)return;r.__msStrategyHealActive=!0;let c=/^\/(auth|config|developer-tools|profile|hassio|history|logbook|map|media-browser|energy|todo|calendar)\b/,f=/Timeout waiting for strategy element ll-strategy-(dashboard-)?maintenance-supporter/i,g=`custom:${l}`;function R(a){let t=[document.documentElement],n=0;for(;t.length&&n<9e3;){let o=t.pop();if(n++,!o)continue;let e=o;if(e.nodeType===1&&e.tagName&&e.tagName.toLowerCase()===a)return e;e.shadowRoot&&t.push(e.shadowRoot);let i=o.children;if(i)for(let d of Array.from(i))t.push(d)}return null}function k(a){let t=a?.views;if(!Array.isArray(t)||!t.length)return null;let n=window.location.pathname.split("/").filter(Boolean).pop()||"",o=t.find(i=>i?.path===n);if(o)return o;let e=Number(n);return Number.isInteger(e)&&t[e]?t[e]:t[0]}function b(){try{let t=R("ha-panel-lovelace")?.lovelace;if(!t)return!1;let n=o=>o?.type;for(let o of[t.config,t.rawConfig]){if(!o)continue;if(n(o.strategy)===g)return!0;let e=k(o);if(e&&n(e.strategy)===g)return!0}return!1}catch{return!1}}function A(){let a=!1,t=0,n=!1,o=!1,e=[document.documentElement],i=0;for(;e.length&&i<9e3;){let d=e.pop();if(i++,!d)continue;let u=d;if(u.nodeType===1&&u.tagName){let s=u.tagName.toLowerCase();(s==="hui-view"||s==="hui-sections-view")&&(a=!0),(s==="ha-card"||s==="hui-card")&&t++,s==="hui-empty-state-card"&&(o=!0),s==="hui-error-card"&&f.test(u.textContent||"")&&(n=!0)}u.shadowRoot&&e.push(u.shadowRoot);let E=d.children;if(E)for(let s of Array.from(E))e.push(s)}return n?!0:o?!1:a&&t<3&&b()}let N="/maintenance_supporter_strategy_shim.js",y=0,_=0;function L(){let a=Date.now();a-_<5e3||y>=3||(_=a,y+=1,import(`${N}?heal=${a}`).catch(()=>{}).finally(()=>{let t=window.location.pathname+window.location.search;history.pushState(null,"","/lovelace"),window.dispatchEvent(new CustomEvent("location-changed")),window.setTimeout(()=>{history.pushState(null,"",t),window.dispatchEvent(new CustomEvent("location-changed"))},200)}))}function h(){if(c.test(window.location.pathname))return;let a=0,t=Date.now(),n=window.setInterval(()=>{a++;try{if(Date.now()-t<6e3)return;if(c.test(window.location.pathname)){window.clearInterval(n);return}A()?L():window.clearInterval(n),a>=30&&window.clearInterval(n)}catch{window.clearInterval(n)}},500)}try{document.readyState==="loading"?window.addEventListener("DOMContentLoaded",h):h(),window.addEventListener("location-changed",()=>{c.test(window.location.pathname)||h()})}catch{}})();
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*! maintenance_supporter frontend 2.52.0 */
|
||||
import{a as v}from"./chunk-7IBGRLM5.js";import{a as u,b as o,d as h,e as b,f as y,g as l,h as g,i as r,k as m,t as p}from"./chunk-C6VY6OOC.js";import{a as n}from"./chunk-D4IFN5R3.js";var x=80,s=class extends b{constructor(){super(...arguments);this._config={type:""};this._status=null;this._busy=!1;this._error="";this._localMonthly="";this._localYearly="";this._dirty=!1;this._loaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return this.hass?.language||"en"}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._loaded&&(this._loaded=!0,this._load(),m(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/budget_status"});this._status=t,this._localMonthly=t.monthly_budget?String(t.monthly_budget):"",this._localYearly=t.yearly_budget?String(t.yearly_budget):"",this._dirty=!1}catch(t){this._error=p(t,this._lang)}}async _save(){if(this._isAdmin){this._busy=!0,this._error="";try{let t=parseFloat(this._localMonthly),a=parseFloat(this._localYearly),i={};!isNaN(t)&&t>=0&&(i.budget_monthly=t),!isNaN(a)&&a>=0&&(i.budget_yearly=a),await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/global/update",settings:i}),await this._load()}catch(t){this._error=p(t,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_budget"),window.dispatchEvent(new CustomEvent("location-changed"))}render(){let t=this._lang,a=this._status;if(!a)return o`<ha-card><div class="loading">${r("loading",t)||"Loading\u2026"}</div></ha-card>`;let i=a.currency_symbol||g,_=a.alert_threshold_pct??x,f=[{label:r("budget_monthly",t)||"Monthly",spent:a.monthly_spent||0,budget:a.monthly_budget||0},{label:r("budget_yearly",t)||"Yearly",spent:a.yearly_spent||0,budget:a.yearly_budget||0}];return o`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span class="emoji">💰</span>
|
||||
<span>${this._config.title||r("settings_budget",t)||"Budget"}</span>
|
||||
</div>
|
||||
<span class="currency">${i}</span>
|
||||
</div>
|
||||
|
||||
${this._error?o`<div class="error">${this._error}</div>`:h}
|
||||
|
||||
${f.map(e=>{if(!(e.budget>0))return o`
|
||||
<div class="track spent-only">
|
||||
<div class="track-label-row">
|
||||
<label>${e.label}</label>
|
||||
<span class="track-numbers ok">${e.spent.toFixed(0)} ${i}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;let d=Math.min(100,Math.max(0,e.spent/e.budget*100)),c=d>=100?"danger":d>=_?"warning":"ok";return o`
|
||||
<div class="track">
|
||||
<div class="track-label-row">
|
||||
<label>${e.label}</label>
|
||||
<span class="track-numbers ${c}">
|
||||
${e.spent.toFixed(0)} / ${e.budget.toFixed(0)} ${i}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill ${c}" style="width:${d}%"></div></div>
|
||||
</div>
|
||||
`})}
|
||||
|
||||
${this._isAdmin?o`
|
||||
<div class="inputs-row">
|
||||
<div class="input-field">
|
||||
<label>${r("budget_monthly_set",t)||"Set monthly"}</label>
|
||||
<div class="input-wrap">
|
||||
<input type="number" min="0" step="1"
|
||||
.value=${this._localMonthly}
|
||||
?disabled=${this._busy}
|
||||
@input=${e=>{this._localMonthly=e.target.value,this._dirty=!0}} />
|
||||
<span class="input-suffix">${i}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>${r("budget_yearly_set",t)||"Set yearly"}</label>
|
||||
<div class="input-wrap">
|
||||
<input type="number" min="0" step="1"
|
||||
.value=${this._localYearly}
|
||||
?disabled=${this._busy}
|
||||
@input=${e=>{this._localYearly=e.target.value,this._dirty=!0}} />
|
||||
<span class="input-suffix">${i}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn ${this._dirty?"primary":"muted"}"
|
||||
@click=${this._save}
|
||||
?disabled=${this._busy||!this._dirty}>
|
||||
<ha-icon icon="${this._dirty?"mdi:content-save":"mdi:check"}"></ha-icon>
|
||||
${this._dirty?r("save",t)||"Save":r("saved",t)||"Saved"}
|
||||
</button>
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${r("budget_advanced",t)||"Currency, alerts\u2026"}
|
||||
</button>
|
||||
</div>
|
||||
`:o`
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${r("budget_open_panel",t)||"Open in panel"}
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</ha-card>
|
||||
`}};s.styles=[v,u`
|
||||
.currency {
|
||||
font-size: 14px; font-weight: 600;
|
||||
color: var(--secondary-text-color);
|
||||
background: var(--secondary-background-color);
|
||||
padding: 2px 10px; border-radius: 999px;
|
||||
}
|
||||
.track { display: flex; flex-direction: column; gap: 4px; }
|
||||
.track-label-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.track-label-row label {
|
||||
font-size: 12px; color: var(--secondary-text-color);
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.track-numbers { font-size: 13px; font-weight: 600; }
|
||||
.track-numbers.ok { color: var(--primary-text-color); }
|
||||
.track-numbers.warning { color: #ff9800; }
|
||||
.track-numbers.danger { color: var(--error-color, #f44336); }
|
||||
.bar {
|
||||
height: 6px; background: var(--secondary-background-color);
|
||||
border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.bar-fill { height: 100%; transition: width 0.3s; border-radius: 3px; }
|
||||
.bar-fill.ok { background: var(--primary-color); }
|
||||
.bar-fill.warning { background: #ff9800; }
|
||||
.bar-fill.danger { background: var(--error-color, #f44336); }
|
||||
.inputs-row {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
|
||||
padding-top: 4px; border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.input-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.input-field label {
|
||||
font-size: 11px; color: var(--secondary-text-color);
|
||||
text-transform: uppercase; letter-spacing: 0.3px;
|
||||
}
|
||||
.input-wrap { position: relative; display: flex; align-items: center; }
|
||||
.input-wrap input {
|
||||
flex: 1; padding: 6px 32px 6px 8px; font-size: 13px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.input-suffix {
|
||||
position: absolute; right: 8px;
|
||||
color: var(--secondary-text-color); font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; align-items: center; }
|
||||
`],n([y({attribute:!1})],s.prototype,"hass",2),n([l()],s.prototype,"_config",2),n([l()],s.prototype,"_status",2),n([l()],s.prototype,"_busy",2),n([l()],s.prototype,"_error",2),n([l()],s.prototype,"_localMonthly",2),n([l()],s.prototype,"_localYearly",2),n([l()],s.prototype,"_dirty",2);customElements.get("maintenance-budget-section-card")||customElements.define("maintenance-budget-section-card",s);window.customCards=window.customCards||[];window.customCards.push({type:"maintenance-budget-section-card",name:"Maintenance Supporter \u2014 Budget",description:"Inline monthly + yearly budget editor",preview:!1});export{s as MaintenanceBudgetSectionCard};
|
||||
@@ -0,0 +1,60 @@
|
||||
/*! maintenance_supporter frontend 2.52.0 */
|
||||
import{a as r}from"./chunk-C6VY6OOC.js";var a=r`
|
||||
ha-card { overflow: hidden; }
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.title {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 16px; font-weight: 500;
|
||||
}
|
||||
.emoji { font-size: 20px; }
|
||||
|
||||
/* Button family — primary action / muted-saved-state / link / icon-with-text */
|
||||
.btn {
|
||||
padding: 6px 12px; font-size: 13px;
|
||||
border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid var(--divider-color);
|
||||
background: var(--secondary-background-color, transparent);
|
||||
color: var(--primary-text-color);
|
||||
font-weight: 500;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.btn:hover { background: var(--state-icon-color, rgba(255,255,255,0.06)); }
|
||||
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn.primary {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-primary-color, white);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.btn.primary[disabled] { opacity: 0.6; }
|
||||
.btn.muted {
|
||||
background: transparent;
|
||||
color: var(--secondary-text-color);
|
||||
border-style: dashed;
|
||||
}
|
||||
.btn.muted[disabled] { opacity: 1; cursor: default; }
|
||||
.btn.muted ha-icon, .btn.primary ha-icon { --mdc-icon-size: 14px; }
|
||||
.btn.link {
|
||||
background: transparent; border: none; padding: 6px 4px;
|
||||
color: var(--primary-color); margin-left: auto;
|
||||
}
|
||||
.btn.link:hover { background: transparent; text-decoration: underline; }
|
||||
|
||||
/* Error + loading states */
|
||||
.error {
|
||||
padding: 8px; border-radius: 6px;
|
||||
background: rgba(211, 47, 47, 0.1);
|
||||
color: var(--error-color, #d32f2f); font-size: 13px;
|
||||
}
|
||||
.loading {
|
||||
padding: 24px; text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;export{a};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
/*! maintenance_supporter frontend 2.52.0 */
|
||||
var s=Object.defineProperty;var l=Object.getOwnPropertyDescriptor;var t=(a,r,c,o)=>{for(var e=o>1?void 0:o?l(r,c):r,i=a.length-1,d;i>=0;i--)(d=a[i])&&(e=(o?d(r,c,e):d(e))||e);return o&&e&&s(r,c,e),e};var m={ok:"var(--success-color, #4caf50)",due_soon:"var(--warning-color, #ff9800)",overdue:"var(--error-color, #f44336)",triggered:"var(--deep-orange-color, #ff5722)",archived:"var(--disabled-color, #9e9e9e)",paused:"var(--info-color, #2196f3)"},v={ok:"mdi:check-circle",due_soon:"mdi:alert-circle",overdue:"mdi:alert-octagon",triggered:"mdi:bell-alert",archived:"mdi:archive-outline",paused:"mdi:pause-circle-outline",completed:"mdi:check-circle",skipped:"mdi:skip-next",missed:"mdi:calendar-remove",reset:"mdi:refresh"};export{t as a,m as b,v as c};
|
||||
+2435
File diff suppressed because one or more lines are too long
+137
@@ -0,0 +1,137 @@
|
||||
/*! maintenance_supporter frontend 2.52.0 */
|
||||
import{a as m}from"./chunk-7IBGRLM5.js";import{a as u,b as s,d as l,e as h,f as g,g as o,i,k as _,t as p}from"./chunk-C6VY6OOC.js";import{a}from"./chunk-D4IFN5R3.js";var e=class extends h{constructor(){super(...arguments);this._config={type:""};this._groups={};this._loaded=!1;this._busy=!1;this._error="";this._newName="";this._editingId=null;this._editingName="";this._hasInitiallyLoaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return this.hass?.language||"en"}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._hasInitiallyLoaded&&(this._hasInitiallyLoaded=!0,this._load(),_(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/groups"});this._groups=t.groups||{},this._loaded=!0}catch(t){this._error=p(t,this._lang)}}async _addGroup(){if(!this._isAdmin)return;let t=this._newName.trim();if(t){this._busy=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/create",name:t}),this._newName="",await this._load()}catch(r){this._error=p(r,this._lang)}finally{this._busy=!1}}}_startEdit(t){this._editingId=t,this._editingName=this._groups[t]?.name||""}async _saveEdit(){if(!this._isAdmin||!this._editingId)return;let t=this._editingName.trim();if(t){this._busy=!0,this._error="";try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/update",group_id:this._editingId,name:t}),this._editingId=null,this._editingName="",await this._load()}catch(r){this._error=p(r,this._lang)}finally{this._busy=!1}}}async _deleteGroup(t,r){if(!this._isAdmin)return;let n=(i("group_delete_confirm",this._lang)||'Delete group "{name}"?').replace("{name}",r);if(window.confirm(n)){this._busy=!0;try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/group/delete",group_id:t}),await this._load()}catch(d){this._error=p(d,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_groups"),window.dispatchEvent(new CustomEvent("location-changed"))}_onKeyDown(t,r){t.key==="Enter"?(t.preventDefault(),r()):t.key==="Escape"&&(t.preventDefault(),this._editingId=null,this._editingName="")}render(){let t=this._lang;if(!this._loaded)return s`<ha-card><div class="loading">${i("loading",t)||"Loading\u2026"}</div></ha-card>`;let r=Object.keys(this._groups);return s`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span class="emoji">🏷️</span>
|
||||
<span>${this._config.title||i("groups",t)||"Groups"}</span>
|
||||
<span class="count">${r.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._error?s`<div class="error">${this._error}</div>`:l}
|
||||
|
||||
${r.length===0?s`<div class="empty">${i("groups_empty",t)||"No groups yet."}</div>`:s`
|
||||
<div class="group-list">
|
||||
${r.map(n=>{let d=this._groups[n],v=d.task_refs?.length??0,b=this._editingId===n;return s`
|
||||
<div class="group-row">
|
||||
${b?s`
|
||||
<input class="edit-input" type="text"
|
||||
.value=${this._editingName}
|
||||
?disabled=${this._busy}
|
||||
@input=${c=>{this._editingName=c.target.value}}
|
||||
@keydown=${c=>this._onKeyDown(c,this._saveEdit.bind(this))} />
|
||||
<button class="btn small primary"
|
||||
@click=${this._saveEdit}
|
||||
?disabled=${this._busy||!this._editingName.trim()}>
|
||||
${i("save",t)||"Save"}
|
||||
</button>
|
||||
<button class="btn small"
|
||||
@click=${()=>{this._editingId=null}}>
|
||||
${i("cancel",t)||"Cancel"}
|
||||
</button>
|
||||
`:s`
|
||||
<span class="group-name">${d.name||"Unnamed"}</span>
|
||||
<span class="task-count">${v}</span>
|
||||
${this._isAdmin?s`
|
||||
<button class="icon-btn"
|
||||
title="${i("edit",t)||"Edit"}"
|
||||
@click=${()=>this._startEdit(n)}
|
||||
?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:pencil"></ha-icon>
|
||||
</button>
|
||||
<button class="icon-btn danger"
|
||||
title="${i("delete",t)||"Delete"}"
|
||||
@click=${()=>this._deleteGroup(n,d.name||"Unnamed")}
|
||||
?disabled=${this._busy}>
|
||||
<ha-icon icon="mdi:delete"></ha-icon>
|
||||
</button>
|
||||
`:l}
|
||||
`}
|
||||
</div>
|
||||
`})}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${this._isAdmin?s`
|
||||
<div class="add-row">
|
||||
<input type="text"
|
||||
placeholder="${i("group_new_placeholder",t)||"Add group\u2026"}"
|
||||
.value=${this._newName}
|
||||
?disabled=${this._busy}
|
||||
@input=${n=>{this._newName=n.target.value}}
|
||||
@keydown=${n=>this._onKeyDown(n,this._addGroup.bind(this))} />
|
||||
<button class="btn primary"
|
||||
@click=${this._addGroup}
|
||||
?disabled=${this._busy||!this._newName.trim()}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>
|
||||
${i("add",t)||"Add"}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${i("groups_manage_tasks",t)||"Manage task assignments\u2026"}
|
||||
</button>
|
||||
`:s`
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${i("groups_open_panel",t)||"Open in panel"}
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</ha-card>
|
||||
`}};e.styles=[m,u`
|
||||
.count {
|
||||
font-size: 12px; color: var(--secondary-text-color);
|
||||
background: var(--secondary-background-color);
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
}
|
||||
.empty {
|
||||
padding: 16px; text-align: center;
|
||||
color: var(--secondary-text-color); font-style: italic;
|
||||
}
|
||||
.group-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.group-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 8px; border-radius: 6px;
|
||||
background: var(--secondary-background-color, rgba(255,255,255,0.03));
|
||||
}
|
||||
.group-name { flex: 1; font-size: 14px; }
|
||||
.task-count {
|
||||
font-size: 11px; color: var(--secondary-text-color);
|
||||
background: var(--card-background-color, rgba(0,0,0,0.2));
|
||||
padding: 1px 8px; border-radius: 999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.edit-input {
|
||||
flex: 1; padding: 4px 8px; font-size: 14px;
|
||||
background: var(--card-background-color, #1c1c1c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--primary-color); border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.icon-btn {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
color: var(--secondary-text-color); padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--state-icon-color, rgba(255,255,255,0.06));
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.icon-btn.danger:hover { color: var(--error-color); }
|
||||
.icon-btn ha-icon { --mdc-icon-size: 18px; }
|
||||
.add-row {
|
||||
display: flex; gap: 6px;
|
||||
padding-top: 8px; border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
.add-row input {
|
||||
flex: 1; padding: 6px 8px; font-size: 13px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
/* Card-specific overrides on the shared .btn */
|
||||
.btn.small { padding: 4px 8px; font-size: 12px; }
|
||||
.btn ha-icon { --mdc-icon-size: 16px; }
|
||||
`],a([g({attribute:!1})],e.prototype,"hass",2),a([o()],e.prototype,"_config",2),a([o()],e.prototype,"_groups",2),a([o()],e.prototype,"_loaded",2),a([o()],e.prototype,"_busy",2),a([o()],e.prototype,"_error",2),a([o()],e.prototype,"_newName",2),a([o()],e.prototype,"_editingId",2),a([o()],e.prototype,"_editingName",2);customElements.get("maintenance-groups-section-card")||customElements.define("maintenance-groups-section-card",e);window.customCards=window.customCards||[];window.customCards.push({type:"maintenance-groups-section-card",name:"Maintenance Supporter \u2014 Groups",description:"Inline group CRUD",preview:!1});export{e as MaintenanceGroupsSectionCard};
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*! maintenance_supporter frontend 2.52.0 */
|
||||
import{a as m}from"./chunk-7IBGRLM5.js";import{a as h,b as n,d as c,e as _,f as v,g as r,i as e,k as f,t as l}from"./chunk-C6VY6OOC.js";import{a as i}from"./chunk-D4IFN5R3.js";var a=class extends _{constructor(){super(...arguments);this._config={type:""};this._state=null;this._busy=!1;this._error="";this._localStart="";this._localEnd="";this._localBuffer=7;this._dirty=!1;this._loaded=!1}setConfig(t){this._config=t}getCardSize(){return 2}get _lang(){return this.hass?.language||"en"}get _isAdmin(){return this.hass?.user?.is_admin??!0}updated(t){super.updated(t),t.has("hass")&&this.hass&&!this._loaded&&(this._loaded=!0,this._load(),f(this._lang).then(()=>this.requestUpdate()))}async _load(){try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/state"});this._state=t,this._localStart=t.start||"",this._localEnd=t.end||"",this._localBuffer=t.buffer_days??7,this._dirty=!1}catch(t){this._error=l(t,this._lang)}}async _toggleEnabled(t){this._busy=!0,this._error="";try{let s=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/update",enabled:t});this._state=s}catch(s){this._error=l(s,this._lang)}finally{this._busy=!1}}async _save(){if(this._isAdmin){this._busy=!0,this._error="";try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/update",start:this._localStart||null,end:this._localEnd||null,buffer_days:this._localBuffer});this._state=t,this._dirty=!1}catch(t){this._error=l(t,this._lang)}finally{this._busy=!1}}}async _endNow(){if(this._isAdmin&&window.confirm(e("vacation_end_now_confirm",this._lang)||"End vacation immediately?")){this._busy=!0;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/vacation/end_now"});this._state=t,this._localStart=t.start||"",this._localEnd=t.end||""}catch(t){this._error=l(t,this._lang)}finally{this._busy=!1}}}_onDeepLink(){history.pushState(null,"","/maintenance-supporter?ms_action=open_vacation"),window.dispatchEvent(new CustomEvent("location-changed"))}render(){let t=this._lang,s=this._state;if(!s)return n`<ha-card><div class="loading">${e("loading",t)||"Loading\u2026"}</div></ha-card>`;let p=s.is_active===!0,d=s.enabled===!0,u=s.exempt_task_ids?.length??0,b=p?e("vacation_status_active",t)||"Active now":d?e("vacation_status_scheduled",t)||"Scheduled":e("vacation_status_inactive",t)||"Inactive",g=p?"active":d?"scheduled":"inactive";return n`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="header">
|
||||
<div class="title">
|
||||
<span class="emoji">🏖️</span>
|
||||
<span>${this._config.title||e("vacation_mode",t)||"Vacation mode"}</span>
|
||||
</div>
|
||||
<span class="status-pill ${g}">${b}</span>
|
||||
</div>
|
||||
|
||||
${this._error?n`<div class="error">${this._error}</div>`:c}
|
||||
|
||||
${this._isAdmin?n`
|
||||
<div class="row toggle-row">
|
||||
<label>${e("enable",t)||"Enable"}</label>
|
||||
<ha-switch
|
||||
.checked=${d}
|
||||
.disabled=${this._busy}
|
||||
@change=${o=>this._toggleEnabled(o.target.checked)}
|
||||
></ha-switch>
|
||||
</div>
|
||||
|
||||
<div class="dates-row">
|
||||
<div class="date-field">
|
||||
<label>${e("vacation_start",t)||"Start"}</label>
|
||||
<input type="date" .value=${this._localStart}
|
||||
?disabled=${this._busy}
|
||||
@input=${o=>{this._localStart=o.target.value,this._dirty=!0}} />
|
||||
</div>
|
||||
<div class="date-field">
|
||||
<label>${e("vacation_end",t)||"End"}</label>
|
||||
<input type="date" .value=${this._localEnd}
|
||||
?disabled=${this._busy}
|
||||
@input=${o=>{this._localEnd=o.target.value,this._dirty=!0}} />
|
||||
</div>
|
||||
<div class="date-field buffer">
|
||||
<label>${e("vacation_buffer",t)||"Buffer days"}</label>
|
||||
<input type="number" min="0" max="14"
|
||||
.value=${String(this._localBuffer)}
|
||||
?disabled=${this._busy}
|
||||
@input=${o=>{this._localBuffer=parseInt(o.target.value,10)||0,this._dirty=!0}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn ${this._dirty?"primary":"muted"}"
|
||||
@click=${this._save}
|
||||
?disabled=${this._busy||!this._dirty}>
|
||||
<ha-icon icon="${this._dirty?"mdi:content-save":"mdi:check"}"></ha-icon>
|
||||
${this._dirty?e("save",t)||"Save":e("saved",t)||"Saved"}
|
||||
</button>
|
||||
${p?n`<button class="btn"
|
||||
@click=${this._endNow}
|
||||
?disabled=${this._busy}>
|
||||
${e("vacation_end_now",t)||"End now"}
|
||||
</button>`:c}
|
||||
${u>0?n`<button class="btn link"
|
||||
@click=${this._onDeepLink}>
|
||||
${u} ${e("vacation_exempt_count",t)||"exempt"}…
|
||||
</button>`:n`<button class="btn link"
|
||||
@click=${this._onDeepLink}>
|
||||
${e("vacation_advanced",t)||"Advanced\u2026"}
|
||||
</button>`}
|
||||
</div>
|
||||
`:n`
|
||||
<div class="readonly">
|
||||
${d&&s.start&&s.end?n`<div>${s.start} → ${s.end}</div>`:c}
|
||||
<button class="btn link" @click=${this._onDeepLink}>
|
||||
${e("vacation_open_panel",t)||"Open in panel"}
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</ha-card>
|
||||
`}};a.styles=[m,h`
|
||||
.status-pill {
|
||||
font-size: 11px; font-weight: 600;
|
||||
padding: 3px 8px; border-radius: 999px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.status-pill.active {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: #4caf50;
|
||||
}
|
||||
.status-pill.scheduled {
|
||||
background: rgba(255, 152, 0, 0.15);
|
||||
color: #ff9800;
|
||||
}
|
||||
.status-pill.inactive {
|
||||
background: rgba(158, 158, 158, 0.15);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.row.toggle-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.row.toggle-row label {
|
||||
font-size: 14px; color: var(--primary-text-color);
|
||||
}
|
||||
.dates-row {
|
||||
display: grid; grid-template-columns: 1fr 1fr 100px; gap: 10px;
|
||||
}
|
||||
.date-field.buffer label { white-space: nowrap; }
|
||||
.date-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.date-field label {
|
||||
font-size: 11px; color: var(--secondary-text-color);
|
||||
text-transform: uppercase; letter-spacing: 0.3px;
|
||||
}
|
||||
.date-field input {
|
||||
padding: 6px 8px; font-size: 13px;
|
||||
background: var(--secondary-background-color, #2c2c2c);
|
||||
color: var(--primary-text-color);
|
||||
border: 1px solid var(--divider-color); border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.date-field input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.actions {
|
||||
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.readonly { display: flex; flex-direction: column; gap: 8px; }
|
||||
`],i([v({attribute:!1})],a.prototype,"hass",2),i([r()],a.prototype,"_config",2),i([r()],a.prototype,"_state",2),i([r()],a.prototype,"_busy",2),i([r()],a.prototype,"_error",2),i([r()],a.prototype,"_localStart",2),i([r()],a.prototype,"_localEnd",2),i([r()],a.prototype,"_localBuffer",2),i([r()],a.prototype,"_dirty",2);customElements.get("maintenance-vacation-section-card")||customElements.define("maintenance-vacation-section-card",a);window.customCards=window.customCards||[];window.customCards.push({type:"maintenance-vacation-section-card",name:"Maintenance Supporter \u2014 Vacation",description:"Inline vacation mode toggle + dates",preview:!1});export{a as MaintenanceVacationSectionCard};
|
||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -6,11 +6,18 @@ sensor into ONE fleet view: which batteries are low now, grouped by battery
|
||||
type (so you know *what to buy*), plus a simple deterministic forecast of what
|
||||
will be needed soon (so you can order in time).
|
||||
|
||||
Battery Notes exposes everything we need as ATTRIBUTES on the single
|
||||
``battery_plus`` sensor (device_class ``battery``): ``battery_type``,
|
||||
``battery_quantity``, ``battery_low``, ``battery_low_threshold``,
|
||||
``battery_last_replaced``. We read that one sensor kind — no dependency on the
|
||||
(optional, often-disabled) battery-low binary.
|
||||
Battery Notes exposes everything we need as ATTRIBUTES on its entities
|
||||
(device_class ``battery``): ``battery_type``, ``battery_quantity``,
|
||||
``battery_low``, ``battery_low_threshold``, ``battery_last_replaced``. The
|
||||
percentage sensor is the primary source; LOW-ONLY sources (a Matter lock with
|
||||
just a battery-low binary, #121) are read from their ``…_battery_plus_low``
|
||||
binary instead.
|
||||
|
||||
Forecast (#114 + follow-up): the ~replacement date comes from the DISCHARGE
|
||||
TREND where recorder data supports it (``async_trend_predictions`` — the
|
||||
SensorPredictor regression asking "when does the level fall below the low
|
||||
threshold?", medium/high confidence only, cached 6 h) and falls back to
|
||||
``battery_last_replaced`` + the type-lifetime table everywhere else.
|
||||
|
||||
The pure builder ``build_overview`` takes plain battery dicts + an injected
|
||||
``today`` so the forecast is unit-testable with synthetic dates; ``read_batteries``
|
||||
@@ -19,6 +26,8 @@ is the thin HA-reading adapter.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
@@ -27,6 +36,8 @@ from typing import Any
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Editorial typical service life per battery type, in MONTHS — the forecast
|
||||
# anchor (battery_last_replaced + lifetime = predicted replacement). These are
|
||||
# deliberately conservative sensor-use estimates and are meant to be tunable;
|
||||
@@ -103,6 +114,24 @@ def _norm_type(raw: Any) -> str:
|
||||
return s.upper() if s else "UNKNOWN"
|
||||
|
||||
|
||||
# Battery Notes' library labels rechargeable packs with type strings like
|
||||
# "Rechargeable", "Nuki Battery Pack" or li-ion cell names. Such a battery is
|
||||
# CHARGED, never bought — so it must not enter the shopping groupings, and the
|
||||
# type-lifetime table (a primary-cell prior) has nothing honest to say about
|
||||
# it. Low tracking and the discharge-trend forecast stay: "charge the lock in
|
||||
# ~20 days" is exactly what the roster is for.
|
||||
_RECHARGEABLE_TYPE_RE = re.compile(
|
||||
r"rechargeable|akku|accu|li[- ]?ion|li[- ]?po|lifepo|ni[- ]?mh|nicd|18650|21700|"
|
||||
r"power ?pack|battery ?pack|built[- ]?in",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_rechargeable_type(battery_type: Any) -> bool:
|
||||
"""Whether a battery-type label describes a rechargeable pack/cell."""
|
||||
return bool(_RECHARGEABLE_TYPE_RE.search(str(battery_type or "")))
|
||||
|
||||
|
||||
def lifetime_months(battery_type: str) -> int:
|
||||
"""Typical service life for a (canonicalized) battery type."""
|
||||
return TYPICAL_LIFETIME_MONTHS.get(_norm_type(battery_type), DEFAULT_LIFETIME_MONTHS)
|
||||
@@ -122,6 +151,11 @@ class Battery:
|
||||
last_replaced: date | None
|
||||
available: bool = True
|
||||
source: str = "battery_notes"
|
||||
# The level at which THIS battery counts low: Battery Notes' configured
|
||||
# threshold or the fleet-wide floor, whichever is higher (the one that
|
||||
# crosses first on the way down). One field feeds the trend regression,
|
||||
# the sparkline threshold line and the level-bar colors alike.
|
||||
low_threshold: float = float(NATIVE_LOW_PERCENT)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -166,6 +200,7 @@ def build_overview(
|
||||
*,
|
||||
today: date,
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
||||
trend_predictions: dict[str, tuple[int, str]] | None = None,
|
||||
) -> BatteryOverview:
|
||||
"""Aggregate batteries into the fleet view.
|
||||
|
||||
@@ -174,7 +209,8 @@ def build_overview(
|
||||
``horizon_days`` (deterministic last_replaced + typical-lifetime forecast).
|
||||
A battery already low is never double-counted into soon.
|
||||
* ``needs_now`` / ``needs_soon`` = summed quantities per type — the shopping
|
||||
grouping ("2× AA, 4× AAA").
|
||||
grouping ("2× AA, 4× AAA"). Rechargeable types never enter it: a low
|
||||
rechargeable means "charge it", not "buy one".
|
||||
* ``all`` = every battery with its status, so a healthy device can be
|
||||
excluded BEFORE it ever becomes noisy.
|
||||
"""
|
||||
@@ -184,20 +220,37 @@ def build_overview(
|
||||
for bat in sorted(batteries, key=lambda b: b.device_name.lower()):
|
||||
t = _norm_type(bat.battery_type)
|
||||
types_seen[t] = None
|
||||
pred = _predicted_date(bat)
|
||||
days = (pred - today).days if pred is not None else None
|
||||
rechargeable = is_rechargeable_type(bat.battery_type)
|
||||
# Blend (#114 follow-up): the DISCHARGE TREND wins where the recorder
|
||||
# data supports it (medium/high confidence, filtered upstream) — it is
|
||||
# device-specific and usage-aware; the type's typical lifetime is the
|
||||
# prior everything else falls back to. For rechargeables the table is
|
||||
# no prior at all (its lifetimes describe primary cells, and Battery
|
||||
# Notes seeds last_replaced at note creation — a real fleet showed
|
||||
# "replace the vacuum's pack" dated from the day the device was added),
|
||||
# so they get a ~date only when the trend has earned one.
|
||||
trend = (trend_predictions or {}).get(bat.entity_id)
|
||||
if trend is not None:
|
||||
days: int | None = max(0, trend[0])
|
||||
source, confidence = "trend", trend[1]
|
||||
else:
|
||||
pred = None if rechargeable else _predicted_date(bat)
|
||||
days = (pred - today).days if pred is not None else None
|
||||
source, confidence = "typical", None
|
||||
if bat.low:
|
||||
ov.low.append(_row(bat, t, None))
|
||||
ov.needs_now[t] = ov.needs_now.get(t, 0) + bat.quantity
|
||||
ov.low.append(_row(bat, t, None, rechargeable=rechargeable))
|
||||
if not rechargeable:
|
||||
ov.needs_now[t] = ov.needs_now.get(t, 0) + bat.quantity
|
||||
# A battery reported low has no meaningful forecast left to show.
|
||||
ov.all.append({**_row(bat, t, None), "status": "low"})
|
||||
ov.all.append({**_row(bat, t, None, rechargeable=rechargeable), "status": "low"})
|
||||
continue
|
||||
if days is not None and days <= horizon_days:
|
||||
ov.soon.append(_row(bat, t, days))
|
||||
ov.needs_soon[t] = ov.needs_soon.get(t, 0) + bat.quantity
|
||||
ov.all.append({**_row(bat, t, days), "status": "soon"})
|
||||
ov.soon.append(_row(bat, t, days, source, confidence, rechargeable=rechargeable))
|
||||
if not rechargeable:
|
||||
ov.needs_soon[t] = ov.needs_soon.get(t, 0) + bat.quantity
|
||||
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable), "status": "soon"})
|
||||
continue
|
||||
ov.all.append({**_row(bat, t, days), "status": "ok"})
|
||||
ov.all.append({**_row(bat, t, days, source, confidence, rechargeable=rechargeable), "status": "ok"})
|
||||
|
||||
ov.soon.sort(key=lambda r: r["days_until"] if r["days_until"] is not None else 1 << 30)
|
||||
ov.types = sorted(types_seen)
|
||||
@@ -206,7 +259,15 @@ def build_overview(
|
||||
return ov
|
||||
|
||||
|
||||
def _row(bat: Battery, canon_type: str, days_until: int | None) -> dict[str, Any]:
|
||||
def _row(
|
||||
bat: Battery,
|
||||
canon_type: str,
|
||||
days_until: int | None,
|
||||
predicted_source: str = "typical",
|
||||
prediction_confidence: str | None = None,
|
||||
*,
|
||||
rechargeable: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"entity_id": bat.entity_id,
|
||||
"device_name": bat.device_name,
|
||||
@@ -215,6 +276,15 @@ def _row(bat: Battery, canon_type: str, days_until: int | None) -> dict[str, Any
|
||||
"level": bat.level,
|
||||
"days_until": days_until,
|
||||
"available": bat.available,
|
||||
# #114 follow-up: where the ~date comes from — "trend" (discharge
|
||||
# regression, with confidence) or "typical" (type-lifetime table).
|
||||
"predicted_source": predicted_source,
|
||||
"prediction_confidence": prediction_confidence,
|
||||
# Charged, never bought: low means "recharge", and the row never
|
||||
# contributes to the shopping groupings.
|
||||
"rechargeable": rechargeable,
|
||||
# This battery's own low threshold — the level bars color against it.
|
||||
"low_threshold": bat.low_threshold,
|
||||
}
|
||||
|
||||
|
||||
@@ -256,10 +326,15 @@ def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
|
||||
"""Whether a device recharges itself — its battery is never REPLACED.
|
||||
|
||||
Issue #107: a Roborock's native battery sensor reads "low" mid-clean, but
|
||||
nobody swaps its cells. Heuristics (native pickup only — an explicit
|
||||
Battery Notes note always wins): the device also has a vacuum/lawn_mower
|
||||
entity, exposes a ``battery_charging`` binary, or is a Companion-app
|
||||
phone/tablet (``mobile_app`` identifiers).
|
||||
nobody swaps its cells. Heuristics: the device also has a
|
||||
vacuum/lawn_mower entity, exposes a ``battery_charging`` binary, or is a
|
||||
Companion-app phone/tablet (``mobile_app`` identifiers).
|
||||
|
||||
Applied to BOTH passes. This originally spared Battery Notes entries on
|
||||
the theory that an explicit note is deliberate intent — but Battery Notes
|
||||
auto-discovery proposes notes for vacuums straight from its library
|
||||
(type "Rechargeable"), so a real fleet ended up telling its owner to buy
|
||||
a "RECHARGEABLE" for the vacuum.
|
||||
"""
|
||||
if not device_id:
|
||||
return False
|
||||
@@ -272,7 +347,10 @@ def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
|
||||
for reg_entry in er.async_entries_for_device(er.async_get(hass), device_id, include_disabled_entities=True):
|
||||
if reg_entry.domain in ("vacuum", "lawn_mower"):
|
||||
return True
|
||||
if reg_entry.domain == "binary_sensor" and (reg_entry.device_class or reg_entry.original_device_class) == "battery_charging":
|
||||
if (
|
||||
reg_entry.domain == "binary_sensor"
|
||||
and (reg_entry.device_class or reg_entry.original_device_class) == "battery_charging"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -284,7 +362,17 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
``battery_type`` attribute) give the rich view: type, quantity, low,
|
||||
last-replaced. When the source goes offline the sensor reads
|
||||
unavailable/unknown but RETAINS its last-known ``battery_low`` — so a
|
||||
dead battery that took its device offline stays visible.
|
||||
dead battery that took its device offline stays visible. A device whose
|
||||
source reports no percentage at all (a Matter lock with only a
|
||||
battery-low binary, #121) gets NO percentage sensor from Battery Notes —
|
||||
its metadata lives solely on the ``…_battery_plus_low`` BINARY, so a
|
||||
second sweep picks those up for devices the sensor sweep did not cover.
|
||||
Devices with BOTH stay one row (the binary carries the same attributes
|
||||
and would otherwise duplicate every battery and dodge exclusions).
|
||||
Self-charging devices (vacuums, mowers, phones — see
|
||||
:func:`_is_self_charging`) are skipped here too: Battery Notes
|
||||
auto-discovery notes them from its library, so a note is no proof of
|
||||
intent to track a replaceable cell.
|
||||
* **Native** ``device_class: battery`` entities (a %-sensor and/or a
|
||||
battery-low binary) — plus %-sensors matching the strict battery-name
|
||||
heuristic for devices that ship no device class — grouped per device,
|
||||
@@ -311,54 +399,97 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
covered_devices: set[str] = set()
|
||||
|
||||
# ── Pass 1: Battery Notes battery_plus ──────────────────────────────────
|
||||
for state in hass.states.async_all("sensor"):
|
||||
attrs = state.attributes
|
||||
if attrs.get("device_class") != "battery" or "battery_type" not in attrs:
|
||||
continue
|
||||
level = _level_of(state.state)
|
||||
available = state.state not in _NO_READING and level is not None
|
||||
# B2 (roadmap 2026-07-22 audit): ONE low floor across both passes.
|
||||
# Battery Notes' own threshold (default 10 %) still counts via its
|
||||
# battery_low flag, but the fleet-wide NATIVE_LOW_PERCENT floor is
|
||||
# OR-ed in — a CR2032 at 11.5 % was "healthy" here while the same
|
||||
# level counted low in the native pass. A HIGHER Battery Notes
|
||||
# threshold (e.g. 30 %) still wins through battery_low.
|
||||
low = bool(attrs.get("battery_low")) or (level is not None and level <= NATIVE_LOW_PERCENT)
|
||||
last_replaced = _parse_last_replaced(attrs.get("battery_last_replaced"))
|
||||
# B1 (roadmap 2026-07-22 audit): a forecast-only note — no level
|
||||
# sensor, so the state reads unknown forever — must SURVIVE when it
|
||||
# carries a replacement date: that date is all `_predicted_date`
|
||||
# needs, and dropping these hid 11 overdue batteries in a live fleet.
|
||||
# Offline AND not low AND no date = pure connectivity noise → drop.
|
||||
if not available and not low and last_replaced is None:
|
||||
continue
|
||||
# B3: only a KEPT note covers its source/device — a dropped dead note
|
||||
# must not suppress the native fallback for its own device (a device
|
||||
# with a dead note and a working level sensor was invisible in BOTH
|
||||
# passes).
|
||||
src = attrs.get("source_entity_id")
|
||||
if src:
|
||||
covered_sources.add(src)
|
||||
reg = ent_reg.async_get(state.entity_id)
|
||||
if reg and reg.device_id:
|
||||
covered_devices.add(reg.device_id)
|
||||
# An EXCLUDED note still covers (above): exclusion hides the battery —
|
||||
# it must not resurrect as a degraded native "Unknown" row.
|
||||
if state.entity_id in excluded:
|
||||
continue
|
||||
out.append(
|
||||
Battery(
|
||||
entity_id=state.entity_id,
|
||||
device_name=attrs.get("device_name") or attrs.get("friendly_name") or state.entity_id,
|
||||
battery_type=str(attrs.get("battery_type") or "Unknown"),
|
||||
quantity=int(attrs.get("battery_quantity") or 1),
|
||||
low=low,
|
||||
level=level,
|
||||
last_replaced=last_replaced,
|
||||
available=available,
|
||||
source="battery_notes",
|
||||
# Percentage SENSORS first, then LOW-ONLY BINARIES (#121): a source with
|
||||
# no percentage (a Matter lock's plain battery-low binary) gets no
|
||||
# ``battery_plus`` sensor from Battery Notes, so the type/quantity/
|
||||
# last-replaced metadata exists only on the ``…_battery_plus_low`` binary.
|
||||
# The binary sweep is restricted to devices the sensor sweep did NOT
|
||||
# cover: a percentage note's own low binary carries the SAME attributes,
|
||||
# and taking it too would put every battery in the roster twice — and let
|
||||
# an exclusion set on the sensor row resurrect through the binary.
|
||||
note_sensor_ids: set[str] = set()
|
||||
for domain, binary_pass in (("sensor", False), ("binary_sensor", True)):
|
||||
for state in hass.states.async_all(domain):
|
||||
attrs = state.attributes
|
||||
if attrs.get("device_class") != "battery" or "battery_type" not in attrs:
|
||||
continue
|
||||
if not binary_pass:
|
||||
# EVERY matching percentage note counts as sibling coverage —
|
||||
# kept, dropped or excluded: its low binary describes the same
|
||||
# battery and must never become a second (or resurrected) row.
|
||||
note_sensor_ids.add(state.entity_id)
|
||||
reg = ent_reg.async_get(state.entity_id)
|
||||
dev_id = reg.device_id if reg else None
|
||||
if binary_pass:
|
||||
if dev_id and dev_id in covered_devices:
|
||||
continue
|
||||
# Registry-based dedupe is not enough on its own (caught live:
|
||||
# state-only entities have no registry entry, and every fleet
|
||||
# battery doubled). Two fallbacks: the shared source entity,
|
||||
# and Battery Notes' naming contract —
|
||||
# ``sensor.X_battery_plus`` ↔ ``binary_sensor.X_battery_plus_low``.
|
||||
src_attr = attrs.get("source_entity_id")
|
||||
if src_attr and src_attr in covered_sources:
|
||||
continue
|
||||
object_id = state.entity_id.split(".", 1)[1]
|
||||
if object_id.endswith("_low") and f"sensor.{object_id[: -len('_low')]}" in note_sensor_ids:
|
||||
continue
|
||||
# No percentage to read — the binary state IS the low signal.
|
||||
level = None
|
||||
available = state.state not in _NO_READING
|
||||
low = bool(attrs.get("battery_low")) or str(state.state).lower() == "on"
|
||||
else:
|
||||
level = _level_of(state.state)
|
||||
available = state.state not in _NO_READING and level is not None
|
||||
# B2 (roadmap 2026-07-22 audit): ONE low floor across both
|
||||
# passes. Battery Notes' own threshold (default 10 %) still
|
||||
# counts via its battery_low flag, but the fleet-wide
|
||||
# NATIVE_LOW_PERCENT floor is OR-ed in — a CR2032 at 11.5 %
|
||||
# was "healthy" here while the same level counted low in the
|
||||
# native pass. A HIGHER Battery Notes threshold (e.g. 30 %)
|
||||
# still wins through battery_low.
|
||||
low = bool(attrs.get("battery_low")) or (level is not None and level <= NATIVE_LOW_PERCENT)
|
||||
last_replaced = _parse_last_replaced(attrs.get("battery_last_replaced"))
|
||||
# B1 (roadmap 2026-07-22 audit): a forecast-only note — no level
|
||||
# sensor, so the state reads unknown forever — must SURVIVE when it
|
||||
# carries a replacement date: that date is all `_predicted_date`
|
||||
# needs, and dropping these hid 11 overdue batteries in a live fleet.
|
||||
# Offline AND not low AND no date = pure connectivity noise → drop.
|
||||
if not available and not low and last_replaced is None:
|
||||
continue
|
||||
# B3: only a KEPT note covers its source/device — a dropped dead note
|
||||
# must not suppress the native fallback for its own device (a device
|
||||
# with a dead note and a working level sensor was invisible in BOTH
|
||||
# passes).
|
||||
src = attrs.get("source_entity_id")
|
||||
if src:
|
||||
covered_sources.add(src)
|
||||
if dev_id:
|
||||
covered_devices.add(dev_id)
|
||||
# An EXCLUDED note still covers (above): exclusion hides the battery —
|
||||
# it must not resurrect as a degraded native "Unknown" row.
|
||||
if state.entity_id in excluded:
|
||||
continue
|
||||
# #107 follow-up: the skip covers noted devices too (it covers
|
||||
# above for the same reason exclusion does). Battery Notes
|
||||
# auto-discovers vacuums/phones from its library, so a note is
|
||||
# not evidence anyone means to swap cells there.
|
||||
if _is_self_charging(hass, dev_id):
|
||||
continue
|
||||
out.append(
|
||||
Battery(
|
||||
entity_id=state.entity_id,
|
||||
device_name=attrs.get("device_name") or attrs.get("friendly_name") or state.entity_id,
|
||||
battery_type=str(attrs.get("battery_type") or "Unknown"),
|
||||
quantity=int(attrs.get("battery_quantity") or 1),
|
||||
low=low,
|
||||
level=level,
|
||||
last_replaced=last_replaced,
|
||||
available=available,
|
||||
source="battery_notes",
|
||||
low_threshold=_note_low_threshold(attrs),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# ── Pass 2: native battery entities, grouped per device ─────────────────
|
||||
# {group_key: {"level_state": s, "low_state": s, "name": ..., "device_id": ..., "eid": ...}}
|
||||
@@ -445,11 +576,16 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
|
||||
|
||||
|
||||
def has_battery_notes(hass: HomeAssistant) -> bool:
|
||||
"""Whether the Battery Notes integration is present (any battery_plus)."""
|
||||
for state in hass.states.async_all("sensor"):
|
||||
a = state.attributes
|
||||
if a.get("device_class") == "battery" and "battery_type" in a:
|
||||
return True
|
||||
"""Whether the Battery Notes integration is present (any battery_plus).
|
||||
|
||||
Binaries count too (#121): an install whose only noted devices are
|
||||
low-only sources has no ``battery_plus`` sensor at all.
|
||||
"""
|
||||
for domain in ("sensor", "binary_sensor"):
|
||||
for state in hass.states.async_all(domain):
|
||||
a = state.attributes
|
||||
if a.get("device_class") == "battery" and "battery_type" in a:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -461,16 +597,229 @@ def has_batteries(hass: HomeAssistant) -> bool:
|
||||
|
||||
|
||||
def compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON_DAYS) -> BatteryOverview:
|
||||
"""Read + aggregate in one call (HA-side entry point)."""
|
||||
"""Read + aggregate in one call (SYNC entry point — table forecast only).
|
||||
|
||||
The summary sensors call this from their update path; recorder-backed
|
||||
trend regression stays out of it deliberately. The panel goes through
|
||||
:func:`async_compute_overview` instead.
|
||||
"""
|
||||
today = dt_util.now().date()
|
||||
return build_overview(read_batteries(hass), today=today, horizon_days=horizon_days)
|
||||
|
||||
|
||||
# ── discharge-trend forecast (#114 follow-up) ───────────────────────────────
|
||||
|
||||
_TREND_CACHE_KEY = "maintenance_supporter_battery_trend_cache"
|
||||
_TREND_CACHE_TTL = timedelta(hours=6)
|
||||
_TREND_MIN_CONFIDENCE = ("medium", "high")
|
||||
# Beyond this the regression extrapolates >12x its 30 d observation window —
|
||||
# a real prod evaluation produced "empty in 1142 d" at medium confidence for a
|
||||
# barely-draining motion sensor, where the type table is the honest answer.
|
||||
_TREND_MAX_DAYS = 365
|
||||
# Reject a series whose level ROSE by more than this (percent points) after a
|
||||
# minimum inside the window: real discharges are monotone-ish, big recoveries
|
||||
# mean the percentage tracks something else (cold-dip voltage bounce on a
|
||||
# CR2032 is the classic). Small relaxation bounces (+3-4 %, seen on a real
|
||||
# LYWSD03MMC) stay below it.
|
||||
_TREND_MAX_RECOVERY_PCT = 10.0
|
||||
|
||||
|
||||
async def async_trend_predictions(hass: HomeAssistant, batteries: list[Battery]) -> dict[str, tuple[int, str]]:
|
||||
"""Per-battery discharge-trend forecast: {entity_id: (days_until, confidence)}.
|
||||
|
||||
Reuses the SensorPredictor's recorder regression, asking "when does this
|
||||
level sensor fall below its low threshold?". Only batteries with a live
|
||||
percentage reading are analysed (low-only binaries have no level to
|
||||
regress); low-confidence, non-falling, and far-out trends (beyond
|
||||
``_TREND_MAX_DAYS``) are dropped so the caller can fall back to the
|
||||
type-lifetime table.
|
||||
|
||||
Cached for 6 h per entity (misses included) — batteries drain over weeks,
|
||||
and the overview is fetched on every panel visit; 30+ recorder regressions
|
||||
per click would be waste.
|
||||
"""
|
||||
from .sensor_predictor import SensorPredictor
|
||||
|
||||
cache: dict[str, tuple[Any, tuple[int, str] | None]] = hass.data.setdefault(_TREND_CACHE_KEY, {})
|
||||
now = dt_util.utcnow()
|
||||
predictor = SensorPredictor(hass)
|
||||
out: dict[str, tuple[int, str]] = {}
|
||||
|
||||
for bat in batteries:
|
||||
if bat.level is None or not bat.available or bat.low:
|
||||
continue
|
||||
cached = cache.get(bat.entity_id)
|
||||
if cached is not None and now - cached[0] < _TREND_CACHE_TTL:
|
||||
if cached[1] is not None:
|
||||
out[bat.entity_id] = cached[1]
|
||||
continue
|
||||
|
||||
# The replacement moment is the fleet's low signal — the battery's
|
||||
# own low_threshold (shared with the sparkline and the level bars).
|
||||
threshold = bat.low_threshold
|
||||
|
||||
result: tuple[int, str] | None = None
|
||||
try:
|
||||
pred = await predictor.async_predict_below(bat.entity_id, threshold, max_recovery=_TREND_MAX_RECOVERY_PCT)
|
||||
if (
|
||||
pred is not None
|
||||
and pred.days_until_threshold is not None
|
||||
and pred.confidence in _TREND_MIN_CONFIDENCE
|
||||
and pred.days_until_threshold <= _TREND_MAX_DAYS
|
||||
):
|
||||
result = (int(pred.days_until_threshold), pred.confidence)
|
||||
except Exception: # noqa: BLE001 - a recorder hiccup must never break the overview
|
||||
_LOGGER.debug("Trend prediction failed for %s", bat.entity_id, exc_info=True)
|
||||
cache[bat.entity_id] = (now, result)
|
||||
if result is not None:
|
||||
out[bat.entity_id] = result
|
||||
return out
|
||||
|
||||
|
||||
async def async_compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON_DAYS) -> BatteryOverview:
|
||||
"""Read + trend-enrich + aggregate (the panel's entry point)."""
|
||||
batteries = read_batteries(hass)
|
||||
trends = await async_trend_predictions(hass, batteries)
|
||||
return build_overview(batteries, today=dt_util.now().date(), horizon_days=horizon_days, trend_predictions=trends)
|
||||
|
||||
|
||||
# ── level history for the roster sparklines ────────────────────────────────
|
||||
|
||||
_HISTORY_CACHE_KEY = "maintenance_supporter_battery_history_cache"
|
||||
_HISTORY_CACHE_TTL = timedelta(hours=6)
|
||||
# ~60 points draw a smooth 30 d line; hourly stats would be 720.
|
||||
_HISTORY_MAX_POINTS = 60
|
||||
|
||||
|
||||
def _downsample(points: list[tuple[float, float]], max_points: int = _HISTORY_MAX_POINTS) -> list[tuple[float, float]]:
|
||||
"""Bucket-mean a point series down to at most ``max_points``.
|
||||
|
||||
Mean per bucket (not every-Nth) so a short voltage dip still leaves a
|
||||
visible dent instead of being skipped entirely.
|
||||
"""
|
||||
if len(points) <= max_points:
|
||||
return points
|
||||
size = (len(points) + max_points - 1) // max_points
|
||||
out: list[tuple[float, float]] = []
|
||||
for i in range(0, len(points), size):
|
||||
bucket = points[i : i + size]
|
||||
out.append((bucket[-1][0], sum(v for _, v in bucket) / len(bucket)))
|
||||
return out
|
||||
|
||||
|
||||
# A real cell swap shows as a large upward step between adjacent 12 h buckets
|
||||
# (+40..+90 typically); relaxation bounces stay under ~5. Between them: 25.
|
||||
_JUMP_MIN_RISE = 25.0
|
||||
# A jump already recorded within this many days of battery_last_replaced is
|
||||
# NOT flagged — the user pressed the button, nothing to fix.
|
||||
_JUMP_RECORDED_SLACK_DAYS = 2
|
||||
|
||||
|
||||
def _detect_unrecorded_jump(
|
||||
points: list[tuple[float, float]],
|
||||
last_replaced: date | None,
|
||||
*,
|
||||
rechargeable: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""An upward level step that looks like a swap nobody recorded.
|
||||
|
||||
A real fleet had a sensor sit at 16 % for three weeks, get fresh cells and
|
||||
jump to 100 % — while ``battery_last_replaced`` stayed 21 months old,
|
||||
silently anchoring the type-lifetime forecast to the DEAD battery. The
|
||||
step is unmistakable in the recorder, so surface it and offer to record
|
||||
it. Rechargeables are exempt: their packs jump on every routine charge.
|
||||
"""
|
||||
from itertools import pairwise
|
||||
|
||||
if rechargeable:
|
||||
return None
|
||||
for (_, v_prev), (ts, v) in pairwise(points):
|
||||
if v - v_prev < _JUMP_MIN_RISE:
|
||||
continue
|
||||
jump_date = dt_util.utc_from_timestamp(ts).date()
|
||||
if last_replaced is not None and abs((jump_date - last_replaced).days) <= _JUMP_RECORDED_SLACK_DAYS:
|
||||
continue # already recorded
|
||||
return {"at": round(ts), "from": round(v_prev, 1), "to": round(v, 1)}
|
||||
return None
|
||||
|
||||
|
||||
def _note_low_threshold(attrs: dict[str, Any]) -> float:
|
||||
"""The Battery-Notes-configured threshold OR the fleet floor — the higher."""
|
||||
raw = attrs.get("battery_low_threshold")
|
||||
if isinstance(raw, (int, float)):
|
||||
return float(max(raw, NATIVE_LOW_PERCENT))
|
||||
return float(NATIVE_LOW_PERCENT)
|
||||
|
||||
|
||||
async def async_level_history(hass: HomeAssistant, batteries: list[Battery]) -> dict[str, dict[str, Any]]:
|
||||
"""Per-battery downsampled level history: {entity_id: {points, threshold}}.
|
||||
|
||||
Feeds the roster sparklines. Same 30 d recorder window the trend
|
||||
regression sees (so the drawn line IS what the forecast reasoned about),
|
||||
same 6 h cache-including-misses discipline as the trend — the roster is
|
||||
opened per panel visit and batteries drain over weeks. Low batteries are
|
||||
included (unlike the trend): the dive INTO low is exactly what the
|
||||
sparkline should show.
|
||||
"""
|
||||
from .sensor_predictor import SensorPredictor
|
||||
|
||||
cache: dict[str, tuple[Any, list[tuple[float, float]]]] = hass.data.setdefault(_HISTORY_CACHE_KEY, {})
|
||||
now = dt_util.utcnow()
|
||||
predictor = SensorPredictor(hass)
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for bat in batteries:
|
||||
if bat.level is None and not bat.low:
|
||||
continue # low-only binaries have no level series to draw
|
||||
cached = cache.get(bat.entity_id)
|
||||
if cached is not None and now - cached[0] < _HISTORY_CACHE_TTL:
|
||||
points = cached[1]
|
||||
else:
|
||||
try:
|
||||
# Deliberate reuse of the predictor's fetch so the sparkline
|
||||
# and the regression see the same series.
|
||||
points = _downsample(await predictor._async_fetch_statistics_points(bat.entity_id, 30))
|
||||
except Exception: # noqa: BLE001 - a recorder hiccup must never break the roster
|
||||
_LOGGER.debug("Level history failed for %s", bat.entity_id, exc_info=True)
|
||||
points = []
|
||||
cache[bat.entity_id] = (now, points)
|
||||
if points:
|
||||
entry: dict[str, Any] = {
|
||||
"points": [[round(ts), round(v, 1)] for ts, v in points],
|
||||
"threshold": bat.low_threshold,
|
||||
}
|
||||
jump = _detect_unrecorded_jump(points, bat.last_replaced, rechargeable=is_rechargeable_type(bat.battery_type))
|
||||
if jump is not None:
|
||||
# The Battery Notes service that records a replacement takes
|
||||
# the DEVICE — resolve it here so the panel's one-click fix
|
||||
# doesn't need a registry lookup of its own.
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
reg = er.async_get(hass).async_get(bat.entity_id)
|
||||
if reg and reg.device_id:
|
||||
entry["jump"] = {**jump, "device_id": reg.device_id}
|
||||
out[bat.entity_id] = entry
|
||||
return out
|
||||
|
||||
|
||||
def discover_battery_types(hass: HomeAssistant) -> OrderedDict[str, int]:
|
||||
"""Battery types present across the fleet → total quantity, for part setup."""
|
||||
"""Battery types present across the fleet → total quantity, for part setup.
|
||||
|
||||
Rechargeable types are left out: nobody stocks a "RECHARGEABLE" spare, so
|
||||
setup must not mint a part (with a reorder threshold!) for one. The
|
||||
UNKNOWN bucket is left out for the same reason — native batteries without
|
||||
a type once minted an "UNKNOWN battery" part whose buy link was an
|
||||
Amazon search for the literal word UNKNOWN (seen on a real fleet at
|
||||
0 of 22). Give the battery a type (a Battery Notes note) and it gets a
|
||||
real part.
|
||||
"""
|
||||
totals: OrderedDict[str, int] = OrderedDict()
|
||||
for bat in read_batteries(hass):
|
||||
if is_rechargeable_type(bat.battery_type):
|
||||
continue
|
||||
t = _norm_type(bat.battery_type)
|
||||
if t == "UNKNOWN":
|
||||
continue
|
||||
totals[t] = totals.get(t, 0) + bat.quantity
|
||||
return OrderedDict(sorted(totals.items()))
|
||||
|
||||
@@ -481,12 +830,16 @@ __all__ = [
|
||||
"TYPICAL_LIFETIME_MONTHS",
|
||||
"Battery",
|
||||
"BatteryOverview",
|
||||
"async_compute_overview",
|
||||
"async_level_history",
|
||||
"async_trend_predictions",
|
||||
"build_overview",
|
||||
"compute_overview",
|
||||
"discover_battery_types",
|
||||
"fleet_excluded_entities",
|
||||
"has_batteries",
|
||||
"has_battery_notes",
|
||||
"is_rechargeable_type",
|
||||
"lifetime_months",
|
||||
"read_batteries",
|
||||
]
|
||||
|
||||
@@ -57,13 +57,25 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
|
||||
|
||||
existing = find_fleet_entry(hass)
|
||||
if existing is not None:
|
||||
added = _reconcile_type_parts(hass, existing, types, lang)
|
||||
added_pids = _reconcile_type_parts(hass, existing, types, lang)
|
||||
# Track stock at 0 for the parts just added — the CREATE path below
|
||||
# does, and a part left untracked (stock None) shows no stock line
|
||||
# and never flags for reorder. Found on a real fleet: types added by
|
||||
# a later reconcile sat untracked next to setup-created "0 pcs/2"
|
||||
# siblings, silently disarming their reorder thresholds.
|
||||
if added_pids:
|
||||
rd = getattr(existing, "runtime_data", None)
|
||||
store = getattr(rd, "store", None) if rd else None
|
||||
if store is not None:
|
||||
for pid in added_pids:
|
||||
store.set_part_stock(pid, 0)
|
||||
await store.async_save()
|
||||
repaired = await _reconcile_fleet_task(hass, existing, lang)
|
||||
return {
|
||||
"entry_id": existing.entry_id,
|
||||
"created": False,
|
||||
"types": list(types),
|
||||
"parts_added": added,
|
||||
"parts_added": len(added_pids),
|
||||
"task_repaired": repaired,
|
||||
}
|
||||
|
||||
@@ -242,10 +254,7 @@ def retranslate_seeded_texts(hass: HomeAssistant, entry: ConfigEntry, lang: str)
|
||||
new_task = dict(task)
|
||||
if new_task.get("name") in _template_variants("Replace low batteries"):
|
||||
new_task["name"] = _localized("Replace low batteries")
|
||||
notes_en = (
|
||||
"Aggregate battery check. The detail view lists which devices are low "
|
||||
"and which battery types to buy."
|
||||
)
|
||||
notes_en = "Aggregate battery check. The detail view lists which devices are low and which battery types to buy."
|
||||
if new_task.get("notes") in _template_variants(notes_en):
|
||||
new_task["notes"] = _localized(notes_en)
|
||||
if new_task != task:
|
||||
@@ -261,9 +270,7 @@ def retranslate_seeded_texts(hass: HomeAssistant, entry: ConfigEntry, lang: str)
|
||||
btype = _extract_placeholder(str(new_part.get("name") or ""), "{type} battery", "type")
|
||||
if btype is not None:
|
||||
new_part["name"] = (_localized("{type} battery")).format(type=btype)
|
||||
months = _extract_placeholder(
|
||||
str(new_part.get("notes") or ""), "Typical service life ~{months} months.", "months"
|
||||
)
|
||||
months = _extract_placeholder(str(new_part.get("notes") or ""), "Typical service life ~{months} months.", "months")
|
||||
if months is not None:
|
||||
new_part["notes"] = (_localized("Typical service life ~{months} months.")).format(months=months)
|
||||
if new_part != part:
|
||||
@@ -404,18 +411,19 @@ async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry, lang: s
|
||||
return True
|
||||
|
||||
|
||||
def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[str, int], lang: str) -> int:
|
||||
"""Add parts for battery types newly seen since setup. Returns count added."""
|
||||
def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[str, int], lang: str) -> list[str]:
|
||||
"""Add parts for battery types newly seen since setup. Returns added ids
|
||||
(the caller initializes their stock, mirroring the create path)."""
|
||||
from .parts import normalize_part
|
||||
|
||||
parts = dict(entry.data.get(CONF_PARTS) or {})
|
||||
existing_ids = set(parts)
|
||||
added = 0
|
||||
added: list[str] = []
|
||||
for btype, total_qty in types.items():
|
||||
pid = f"batt_{btype.lower()}"
|
||||
if pid not in existing_ids:
|
||||
parts[pid] = normalize_part(_type_part(btype, total_qty, lang))
|
||||
added += 1
|
||||
added.append(pid)
|
||||
if added:
|
||||
new_data = dict(entry.data)
|
||||
new_data[CONF_PARTS] = parts
|
||||
|
||||
@@ -109,6 +109,42 @@ class SensorPredictor:
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def async_predict_below(
|
||||
self,
|
||||
entity_id: str,
|
||||
threshold: float,
|
||||
lookback_days: int = DEFAULT_DEGRADATION_LOOKBACK_DAYS,
|
||||
max_recovery: float | None = None,
|
||||
) -> ThresholdPrediction | None:
|
||||
"""Entity-level convenience: when does this sensor FALL BELOW threshold?
|
||||
|
||||
Reuses the task machinery (recorder statistics → linear regression →
|
||||
threshold crossing with r²-based confidence) without needing a task
|
||||
shape around it. Built for the battery fleet's discharge-trend
|
||||
forecast; returns ``None`` when the trend is flat, rising, or the
|
||||
statistics are too thin to regress.
|
||||
|
||||
``max_recovery``: reject the series when the value ROSE by more than
|
||||
this (same unit as the sensor) after a minimum within the window. A
|
||||
real discharge is monotone-ish; a big recovery means the readings
|
||||
track something else — the classic case is a voltage-derived battery
|
||||
percentage dipping in the cold and bouncing back, which a regression
|
||||
happily turns into a confident false "empty soon".
|
||||
"""
|
||||
points = await self._async_fetch_statistics_points(entity_id, lookback_days)
|
||||
if max_recovery is not None and points:
|
||||
min_seen = math.inf
|
||||
for _, value in points:
|
||||
min_seen = min(min_seen, value)
|
||||
if value - min_seen > max_recovery:
|
||||
return None
|
||||
degradation = await self._async_compute_degradation(
|
||||
entity_id, None, lookback_days, points=points
|
||||
)
|
||||
return self._compute_threshold_prediction(
|
||||
degradation, {"type": "threshold", "trigger_below": threshold}
|
||||
)
|
||||
|
||||
async def async_analyze(
|
||||
self,
|
||||
task_data: dict[str, Any],
|
||||
@@ -168,9 +204,15 @@ class SensorPredictor:
|
||||
entity_id: str,
|
||||
attribute: str | None,
|
||||
lookback_days: int,
|
||||
points: list[tuple[float, float]] | None = None,
|
||||
) -> DegradationAnalysis:
|
||||
"""Compute degradation rate using linear regression on recorder data."""
|
||||
points = await self._async_fetch_statistics_points(entity_id, lookback_days)
|
||||
"""Compute degradation rate using linear regression on recorder data.
|
||||
|
||||
``points`` lets a caller that already fetched the series (to inspect
|
||||
it) avoid a second recorder query.
|
||||
"""
|
||||
if points is None:
|
||||
points = await self._async_fetch_statistics_points(entity_id, lookback_days)
|
||||
|
||||
if len(points) < DEFAULT_DEGRADATION_MIN_POINTS:
|
||||
return DegradationAnalysis(
|
||||
|
||||
@@ -233,14 +233,24 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"ha_washdata": IntegrationSignature(
|
||||
name="WashData (smart-plug cycles)",
|
||||
verified="2026-07-20 @ 3dg1luk43/ha_washdata main sensor.py (HACS default)",
|
||||
verified="2026-08-02 @ 3dg1luk43/ha_washdata main sensor.py + const.py (HACS default)",
|
||||
source=(
|
||||
"HACS ha_washdata: tk 'cycle_count' (unit 'cycles') — lifetime "
|
||||
"count of appliance cycles DETECTED from smart-plug power "
|
||||
"monitoring. Brings the tub-clean cadence to washers with no "
|
||||
"smarts at all (LG's official 30-cycle interval)."
|
||||
"monitoring. The integration ships its OWN maintenance taxonomy "
|
||||
"(MAINTENANCE_EVENT_TYPES + DEFAULT_MAINTENANCE_REMINDER_CYCLES: "
|
||||
"descale 30 / filter_clean 50 / drum_clean 100), but shows it "
|
||||
"only inside its panel — no due-entity, no notifications. These "
|
||||
"tasks mirror that taxonomy 1:1, so what its panel counts "
|
||||
"silently becomes a real reminder here; type-agnostic on purpose "
|
||||
"because WashData offers the types for every appliance class "
|
||||
"itself (washer, dryer, dishwasher, air fryer, …)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("cycle_count",), "Descaling", "usage_delta", delta_units=30),
|
||||
ConsumableSignature(("cycle_count",), "Filter Cleaning", "usage_delta", delta_units=50),
|
||||
ConsumableSignature(("cycle_count",), "Clean Tub", "usage_delta", delta_units=100),
|
||||
),
|
||||
tasks=(ConsumableSignature(("cycle_count",), "Clean Tub", "usage_delta", delta_units=30),),
|
||||
),
|
||||
"traeger": IntegrationSignature(
|
||||
name="Traeger grill",
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"pypdf>=4.3.0"
|
||||
],
|
||||
"version": "2.46.0"
|
||||
"version": "2.52.0"
|
||||
}
|
||||
|
||||
@@ -655,6 +655,140 @@ class MissingGlobalEntryRepairFlow(RepairsFlow):
|
||||
return self.async_show_form(step_id="init", data_schema=vol.Schema({}))
|
||||
|
||||
|
||||
class DeviceLinkRepairFlow(RepairsFlow):
|
||||
"""Relink or unlink an object whose stored device link is unusable.
|
||||
|
||||
One flow serves both translation keys behind the ``device_link_lost_…``
|
||||
issue id: the linked device VANISHED (``device_link_lost``), and the link
|
||||
points at the object's own doppelgänger device (``device_link_self`` —
|
||||
the old picker offered it under the appliance's exact name).
|
||||
|
||||
Two menu options:
|
||||
|
||||
1. Relink — a device picker, pre-filled with the best name/manufacturer
|
||||
match among devices that are NOT ours; Maintenance Supporter devices
|
||||
are rejected on submit (the same guard the WS write path applies).
|
||||
2. Unlink — clear the stored link; the object keeps a device of its own.
|
||||
|
||||
``self.data`` carries ``{"entry_id": str}``.
|
||||
"""
|
||||
|
||||
_match_cached = False
|
||||
_match: Any = None
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
|
||||
entry = _entry_for_issue(self.hass, self.data)
|
||||
if entry is None:
|
||||
return self.async_abort(reason="entry_gone")
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
menu_options=["relink", "unlink"],
|
||||
description_placeholders=self._placeholders(entry),
|
||||
)
|
||||
|
||||
async def async_step_relink(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .helpers.device_link import is_maintenance_device
|
||||
|
||||
entry = _entry_for_issue(self.hass, self.data)
|
||||
if entry is None:
|
||||
return self.async_abort(reason="entry_gone")
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
device_id = str(user_input.get("device", "")).strip()
|
||||
device = dr.async_get(self.hass).async_get(device_id) if device_id else None
|
||||
if device is None:
|
||||
errors["device"] = "device_gone"
|
||||
elif is_maintenance_device(self.hass, device):
|
||||
errors["device"] = "self_link"
|
||||
else:
|
||||
self._set_link(entry, device_id)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
suggestion = self._best_match(entry)
|
||||
device_key = vol.Required("device", default=suggestion.id) if suggestion else vol.Required("device")
|
||||
return self.async_show_form(
|
||||
step_id="relink",
|
||||
data_schema=vol.Schema({device_key: selector.DeviceSelector()}),
|
||||
errors=errors,
|
||||
description_placeholders=self._placeholders(entry),
|
||||
)
|
||||
|
||||
async def async_step_unlink(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
|
||||
entry = _entry_for_issue(self.hass, self.data)
|
||||
if entry is None:
|
||||
return self.async_abort(reason="entry_gone")
|
||||
if user_input is not None:
|
||||
self._set_link(entry, None)
|
||||
return self.async_create_entry(data={})
|
||||
return self.async_show_form(
|
||||
step_id="unlink",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders=self._placeholders(entry),
|
||||
)
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _placeholders(self, entry: Any) -> dict[str, str]:
|
||||
from .const import CONF_OBJECT
|
||||
|
||||
obj = entry.data.get(CONF_OBJECT, {}) or {}
|
||||
best = self._best_match(entry)
|
||||
return {
|
||||
"object": str(obj.get("name") or entry.title),
|
||||
"suggestion": str((best.name_by_user or best.name) if best else "—"),
|
||||
}
|
||||
|
||||
def _best_match(self, entry: Any) -> Any:
|
||||
"""The non-maintenance device whose identity best matches the object.
|
||||
|
||||
Same scoring the prod diagnosis used: count the object's name /
|
||||
manufacturer / model words appearing in the device's name-plus-model
|
||||
string. A device RE-CREATED under a new id (the usual reason a link
|
||||
dies) keeps its name, so it surfaces as the natural suggestion.
|
||||
"""
|
||||
if self._match_cached:
|
||||
return self._match
|
||||
self._match_cached = True
|
||||
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_OBJECT
|
||||
from .helpers.device_link import is_maintenance_device
|
||||
|
||||
def norm(value: Any) -> str:
|
||||
import re
|
||||
|
||||
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
|
||||
|
||||
obj = entry.data.get(CONF_OBJECT, {}) or {}
|
||||
words = {w for w in norm(obj.get("name")).split() if len(w) > 2}
|
||||
words |= {w for w in (norm(obj.get("manufacturer")), norm(obj.get("model"))) if w}
|
||||
|
||||
best, best_score = None, 0
|
||||
for device in dr.async_get(self.hass).devices.values():
|
||||
if is_maintenance_device(self.hass, device):
|
||||
continue
|
||||
hay = norm(f"{device.name_by_user or ''} {device.name or ''} {device.manufacturer or ''} {device.model or ''}")
|
||||
score = sum(1 for w in words if w and w in hay)
|
||||
if score > best_score:
|
||||
best, best_score = device, score
|
||||
self._match = best
|
||||
return best
|
||||
|
||||
def _set_link(self, entry: Any, device_id: str | None) -> None:
|
||||
"""Write the new link and reload — the entity→device attachment only
|
||||
changes on entity re-add (the same reason the WS update path reloads)."""
|
||||
from .const import CONF_OBJECT
|
||||
|
||||
obj = dict(entry.data.get(CONF_OBJECT, {}) or {})
|
||||
obj["ha_device_id"] = device_id
|
||||
self.hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_OBJECT: obj})
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
@@ -665,6 +799,8 @@ async def async_create_fix_flow(
|
||||
return OrphanAdminPanelUserRepairFlow()
|
||||
if issue_id.startswith("stale_action_entity_"):
|
||||
return StaleActionEntityRepairFlow()
|
||||
if issue_id.startswith("device_link_lost_"):
|
||||
return DeviceLinkRepairFlow()
|
||||
if issue_id == "document_storage_issues":
|
||||
return DocumentStorageRepairFlow()
|
||||
if issue_id == "missing_global_entry":
|
||||
|
||||
@@ -165,6 +165,25 @@ class MaintenanceStore:
|
||||
"""Remove all state for a deleted task."""
|
||||
self._data.get("tasks", {}).pop(task_id, None)
|
||||
|
||||
# --- in-cycle checklist progress (#73) -----------------------------------
|
||||
|
||||
def set_checklist_progress(self, task_id: str, state: dict[str, bool]) -> None:
|
||||
"""Replace the in-cycle checklist ticks (keys = item texts).
|
||||
|
||||
Keyed by item TEXT, not index: reordering the checklist keeps the
|
||||
ticks with their steps, and renaming a step deliberately drops its
|
||||
tick (the wording changed — re-confirm it). An empty dict clears.
|
||||
"""
|
||||
task_state = self._ensure_task(task_id)
|
||||
if state:
|
||||
task_state["checklist_progress"] = dict(state)
|
||||
else:
|
||||
task_state.pop("checklist_progress", None)
|
||||
|
||||
def clear_checklist_progress(self, task_id: str) -> None:
|
||||
"""Drop the in-cycle ticks — a completed/skipped cycle starts fresh."""
|
||||
self._data.get("tasks", {}).get(task_id, {}).pop("checklist_progress", None)
|
||||
|
||||
def prune_orphans(self, valid_task_ids: set[str]) -> int:
|
||||
"""Drop task states whose ids are not in *valid_task_ids*.
|
||||
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} lost its device link",
|
||||
"description": "**{object}** was attached to a Home Assistant device, so its maintenance entities appeared on that device's page. That device no longer exists — deleted, or its integration removed — and the object now shows a device of its own. Nothing else changed: its tasks and their history are untouched.\n\nPick a device again in the Maintenance panel under *Link to existing device*, or ignore this if an own device is fine. The stored link is deliberately kept, so the attachment resumes by itself if the device comes back."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Repair the device link of {object}",
|
||||
"description": "**{object}** was attached to a Home Assistant device, so its maintenance entities appeared on that device's page. That device no longer exists — deleted, or its integration removed — and the object now shows a device of its own. Nothing else changed: its tasks and their history are untouched.\n\nChoose how to proceed. Best guess for the intended device: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Link the appliance's device",
|
||||
"unlink": "Remove the link"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Pick the appliance's device",
|
||||
"description": "Pick the device that **{object}** belongs to. The best match — **{suggestion}** — is pre-selected; check it before saving. Maintenance Supporter's own twin devices are rejected.",
|
||||
"data": {
|
||||
"device": "Device"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Remove the device link",
|
||||
"description": "**{object}** keeps a device of its own. Tasks and history stay untouched; you can link a device again any time in the Maintenance panel."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "That is a Maintenance Supporter device (the object's twin, not the appliance) — pick the appliance's own device.",
|
||||
"device_gone": "That device does not exist (anymore)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "This object no longer exists — nothing to repair."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} is linked to its own maintenance device",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Repair the device link of {object}",
|
||||
"description": "**{object}**'s device link points at the twin device Maintenance Supporter created for the object — it carries the appliance's name, and the device picker used to offer it. The appliance's real device page never showed the maintenance entities.\n\nChoose how to proceed. Best guess for the intended device: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Link the appliance's device",
|
||||
"unlink": "Remove the link"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Pick the appliance's device",
|
||||
"description": "Pick the device that **{object}** belongs to. The best match — **{suggestion}** — is pre-selected; check it before saving. Maintenance Supporter's own twin devices are rejected.",
|
||||
"data": {
|
||||
"device": "Device"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Remove the device link",
|
||||
"description": "**{object}** keeps a device of its own. Tasks and history stay untouched; you can link a device again any time in the Maintenance panel."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "That is a Maintenance Supporter device (the object's twin, not the appliance) — pick the appliance's own device.",
|
||||
"device_gone": "That device does not exist (anymore)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "This object no longer exists — nothing to repair."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} přišel o propojení se zařízením",
|
||||
"description": "**{object}** byl propojen se zařízením Home Assistant, takže se jeho údržbové entity zobrazovaly na stránce tohoto zařízení. Zařízení už neexistuje — bylo smazáno nebo byla odebrána jeho integrace — a objekt nyní ukazuje vlastní zařízení. Nic dalšího se nemění: úkoly i jejich historie zůstávají nedotčené.\n\nVyberte zařízení znovu v panelu Údržba v části *Propojit s existujícím zařízením*, nebo toto upozornění ignorujte, pokud vlastní zařízení stačí. Uložené propojení je záměrně zachováno: vrátí-li se zařízení, propojení začne fungovat samo."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Opravit propojení zařízení objektu {object}",
|
||||
"description": "**{object}** byl propojen se zařízením Home Assistant, takže se jeho údržbové entity zobrazovaly na stránce tohoto zařízení. Zařízení už neexistuje — bylo smazáno nebo byla odebrána jeho integrace — a objekt nyní ukazuje vlastní zařízení. Nic dalšího se nemění: úkoly i jejich historie zůstávají nedotčené.\n\nZvolte postup. Nejlepší odhad zamýšleného zařízení: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Propojit skutečný přístroj",
|
||||
"unlink": "Odebrat propojení"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Vybrat skutečný přístroj",
|
||||
"description": "Vyberte zařízení, ke kterému **{object}** patří. Nejlepší shoda — **{suggestion}** — je předvybraná; před uložením ji zkontrolujte. Vlastní zařízení Maintenance Supporteru jsou odmítnuta.",
|
||||
"data": {
|
||||
"device": "Zařízení"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Odebrat propojení zařízení",
|
||||
"description": "**{object}** si ponechá vlastní zařízení. Úkoly a historie zůstávají beze změny; zařízení můžete kdykoli znovu propojit v panelu údržby."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "To je zařízení Maintenance Supporteru (dvojník objektu, ne skutečný přístroj) — vyberte zařízení původní integrace.",
|
||||
"device_gone": "Toto zařízení (už) neexistuje."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Tento objekt už neexistuje — není co opravovat."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} je propojen se svým vlastním údržbovým zařízením",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Opravit propojení zařízení objektu {object}",
|
||||
"description": "Propojení objektu **{object}** ukazuje na dvojníka, kterého Maintenance Supporter pro objekt sám vytvořil — nese název skutečného přístroje a výběr zařízení jej dříve nabízel. Na skutečné stránce přístroje se proto údržbové entity nikdy nezobrazovaly.\n\nZvolte postup. Nejlepší odhad zamýšleného zařízení: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Propojit skutečný přístroj",
|
||||
"unlink": "Odebrat propojení"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Vybrat skutečný přístroj",
|
||||
"description": "Vyberte zařízení, ke kterému **{object}** patří. Nejlepší shoda — **{suggestion}** — je předvybraná; před uložením ji zkontrolujte. Vlastní zařízení Maintenance Supporteru jsou odmítnuta.",
|
||||
"data": {
|
||||
"device": "Zařízení"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Odebrat propojení zařízení",
|
||||
"description": "**{object}** si ponechá vlastní zařízení. Úkoly a historie zůstávají beze změny; zařízení můžete kdykoli znovu propojit v panelu údržby."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "To je zařízení Maintenance Supporteru (dvojník objektu, ne skutečný přístroj) — vyberte zařízení původní integrace.",
|
||||
"device_gone": "Toto zařízení (už) neexistuje."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Tento objekt už neexistuje — není co opravovat."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} mistede sin enhedstilknytning",
|
||||
"description": "**{object}** var tilknyttet en Home Assistant-enhed, så dets vedligeholdelsesenheder blev vist på den enheds side. Den enhed findes ikke længere — slettet, eller dens integration fjernet — og objektet viser nu sin egen enhed. Intet andet ændrer sig: opgaverne og deres historik er urørte.\n\nVælg en enhed igen i vedligeholdelsespanelet under *Tilknyt eksisterende enhed*, eller se bort fra dette, hvis en egen enhed er fin. Den gemte tilknytning bevares med vilje: kommer enheden tilbage, virker tilknytningen af sig selv igen."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Reparer enhedskoblingen for {object}",
|
||||
"description": "**{object}** var tilknyttet en Home Assistant-enhed, så dets vedligeholdelsesenheder blev vist på den enheds side. Den enhed findes ikke længere — slettet, eller dens integration fjernet — og objektet viser nu sin egen enhed. Intet andet ændrer sig: opgaverne og deres historik er urørte.\n\nVælg fremgangsmåde. Bedste bud på den tilsigtede enhed: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Kobl det rigtige apparat",
|
||||
"unlink": "Fjern koblingen"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Vælg det rigtige apparat",
|
||||
"description": "Vælg den enhed, som **{object}** hører til. Det bedste match — **{suggestion}** — er forvalgt; kontroller det, før du gemmer. Maintenance Supporters egne enheder afvises.",
|
||||
"data": {
|
||||
"device": "Enhed"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Fjern enhedskoblingen",
|
||||
"description": "**{object}** beholder sin egen enhed. Opgaver og historik forbliver urørt; du kan altid koble en enhed igen i vedligeholdelsespanelet."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Det er en Maintenance Supporter-enhed (objektets tvilling, ikke apparatet) — vælg apparatets egen enhed.",
|
||||
"device_gone": "Den enhed findes ikke (længere)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Dette objekt findes ikke længere — intet at reparere."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} er koblet til sin egen vedligeholdelsesenhed",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Reparer enhedskoblingen for {object}",
|
||||
"description": "Enhedskoblingen for **{object}** peger på den tvillingeenhed, som Maintenance Supporter selv oprettede til objektet — den bærer apparatets navn, og vælgeren tilbød den tidligere. Apparatets rigtige enhedsside viste derfor aldrig vedligeholdelsesentiteterne.\n\nVælg fremgangsmåde. Bedste bud på den tilsigtede enhed: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Kobl det rigtige apparat",
|
||||
"unlink": "Fjern koblingen"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Vælg det rigtige apparat",
|
||||
"description": "Vælg den enhed, som **{object}** hører til. Det bedste match — **{suggestion}** — er forvalgt; kontroller det, før du gemmer. Maintenance Supporters egne enheder afvises.",
|
||||
"data": {
|
||||
"device": "Enhed"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Fjern enhedskoblingen",
|
||||
"description": "**{object}** beholder sin egen enhed. Opgaver og historik forbliver urørt; du kan altid koble en enhed igen i vedligeholdelsespanelet."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Det er en Maintenance Supporter-enhed (objektets tvilling, ikke apparatet) — vælg apparatets egen enhed.",
|
||||
"device_gone": "Den enhed findes ikke (længere)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Dette objekt findes ikke længere — intet at reparere."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} hat seine Geräteverknüpfung verloren",
|
||||
"description": "**{object}** war mit einem Home-Assistant-Gerät verknüpft, sodass seine Wartungsentitäten auf dessen Geräteseite erschienen. Dieses Gerät existiert nicht mehr — gelöscht oder seine Integration entfernt — und das Objekt zeigt jetzt ein eigenes Gerät. Sonst ändert sich nichts: Aufgaben und Verlauf bleiben unberührt.\n\nWähle im Wartungs-Panel unter *Mit vorhandenem Gerät verknüpfen* erneut ein Gerät, oder ignoriere diesen Hinweis, wenn ein eigenes Gerät genügt. Die gespeicherte Verknüpfung bleibt absichtlich erhalten — kehrt das Gerät zurück, greift sie von selbst wieder."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Geräteverknüpfung von {object} reparieren",
|
||||
"description": "**{object}** war mit einem Home-Assistant-Gerät verknüpft, sodass seine Wartungsentitäten auf dessen Geräteseite erschienen. Dieses Gerät existiert nicht mehr — gelöscht oder seine Integration entfernt — und das Objekt zeigt jetzt ein eigenes Gerät. Sonst ändert sich nichts: Aufgaben und Verlauf bleiben unberührt.\n\nWähle das Vorgehen. Bester Treffer für das gemeinte Gerät: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Echtes Gerät verknüpfen",
|
||||
"unlink": "Verknüpfung entfernen"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Echtes Gerät auswählen",
|
||||
"description": "Wähle das Gerät, zu dem **{object}** gehört. Der beste Treffer — **{suggestion}** — ist vorausgewählt; prüfe ihn vor dem Speichern. Eigene Geräte von Maintenance Supporter werden abgelehnt.",
|
||||
"data": {
|
||||
"device": "Gerät"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Geräteverknüpfung entfernen",
|
||||
"description": "**{object}** behält ein eigenes Gerät. Aufgaben und Verlauf bleiben unberührt; im Wartungs-Panel kannst du jederzeit wieder ein Gerät verknüpfen."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Das ist ein Gerät von Maintenance Supporter (der Zwilling des Objekts, nicht das eigentliche Gerät) — wähle das Gerät der Geräte-Integration.",
|
||||
"device_gone": "Dieses Gerät existiert nicht (mehr)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Dieses Objekt existiert nicht mehr — nichts zu reparieren."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} ist mit seinem eigenen Wartungsgerät verknüpft",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Geräteverknüpfung von {object} reparieren",
|
||||
"description": "Die Geräteverknüpfung von **{object}** zeigt auf das Zwillingsgerät, das Maintenance Supporter selbst für das Objekt angelegt hat — es trägt denselben Namen wie das eigentliche Gerät, und die Geräteauswahl hat es früher mit angeboten. Auf der echten Geräteseite erschienen die Wartungsentitäten dadurch nie.\n\nWähle das Vorgehen. Bester Treffer für das gemeinte Gerät: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Echtes Gerät verknüpfen",
|
||||
"unlink": "Verknüpfung entfernen"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Echtes Gerät auswählen",
|
||||
"description": "Wähle das Gerät, zu dem **{object}** gehört. Der beste Treffer — **{suggestion}** — ist vorausgewählt; prüfe ihn vor dem Speichern. Eigene Geräte von Maintenance Supporter werden abgelehnt.",
|
||||
"data": {
|
||||
"device": "Gerät"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Geräteverknüpfung entfernen",
|
||||
"description": "**{object}** behält ein eigenes Gerät. Aufgaben und Verlauf bleiben unberührt; im Wartungs-Panel kannst du jederzeit wieder ein Gerät verknüpfen."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Das ist ein Gerät von Maintenance Supporter (der Zwilling des Objekts, nicht das eigentliche Gerät) — wähle das Gerät der Geräte-Integration.",
|
||||
"device_gone": "Dieses Gerät existiert nicht (mehr)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Dieses Objekt existiert nicht mehr — nichts zu reparieren."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} lost its device link",
|
||||
"description": "**{object}** was attached to a Home Assistant device, so its maintenance entities appeared on that device's page. That device no longer exists — deleted, or its integration removed — and the object now shows a device of its own. Nothing else changed: its tasks and their history are untouched.\n\nPick a device again in the Maintenance panel under *Link to existing device*, or ignore this if an own device is fine. The stored link is deliberately kept, so the attachment resumes by itself if the device comes back."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Repair the device link of {object}",
|
||||
"description": "**{object}** was attached to a Home Assistant device, so its maintenance entities appeared on that device's page. That device no longer exists — deleted, or its integration removed — and the object now shows a device of its own. Nothing else changed: its tasks and their history are untouched.\n\nChoose how to proceed. Best guess for the intended device: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Link the appliance's device",
|
||||
"unlink": "Remove the link"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Pick the appliance's device",
|
||||
"description": "Pick the device that **{object}** belongs to. The best match — **{suggestion}** — is pre-selected; check it before saving. Maintenance Supporter's own twin devices are rejected.",
|
||||
"data": {
|
||||
"device": "Device"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Remove the device link",
|
||||
"description": "**{object}** keeps a device of its own. Tasks and history stay untouched; you can link a device again any time in the Maintenance panel."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "That is a Maintenance Supporter device (the object's twin, not the appliance) — pick the appliance's own device.",
|
||||
"device_gone": "That device does not exist (anymore)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "This object no longer exists — nothing to repair."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} is linked to its own maintenance device",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Repair the device link of {object}",
|
||||
"description": "**{object}**'s device link points at the twin device Maintenance Supporter created for the object — it carries the appliance's name, and the device picker used to offer it. The appliance's real device page never showed the maintenance entities.\n\nChoose how to proceed. Best guess for the intended device: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Link the appliance's device",
|
||||
"unlink": "Remove the link"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Pick the appliance's device",
|
||||
"description": "Pick the device that **{object}** belongs to. The best match — **{suggestion}** — is pre-selected; check it before saving. Maintenance Supporter's own twin devices are rejected.",
|
||||
"data": {
|
||||
"device": "Device"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Remove the device link",
|
||||
"description": "**{object}** keeps a device of its own. Tasks and history stay untouched; you can link a device again any time in the Maintenance panel."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "That is a Maintenance Supporter device (the object's twin, not the appliance) — pick the appliance's own device.",
|
||||
"device_gone": "That device does not exist (anymore)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "This object no longer exists — nothing to repair."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} ha perdido su vínculo con el dispositivo",
|
||||
"description": "**{object}** estaba vinculado a un dispositivo de Home Assistant, de modo que sus entidades de mantenimiento aparecían en la página de ese dispositivo. Ese dispositivo ya no existe — eliminado, o retirada su integración — y el objeto muestra ahora un dispositivo propio. Nada más cambia: sus tareas y su historial siguen intactos.\n\nElige de nuevo un dispositivo en el panel de Mantenimiento en *Vincular a un dispositivo existente*, o ignora este aviso si te vale un dispositivo propio. El vínculo guardado se conserva a propósito: si el dispositivo vuelve, la vinculación se restablece sola."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Reparar el vínculo de dispositivo de {object}",
|
||||
"description": "**{object}** estaba vinculado a un dispositivo de Home Assistant, de modo que sus entidades de mantenimiento aparecían en la página de ese dispositivo. Ese dispositivo ya no existe — eliminado, o retirada su integración — y el objeto muestra ahora un dispositivo propio. Nada más cambia: sus tareas y su historial siguen intactos.\n\nElige cómo proceder. Mejor coincidencia para el dispositivo previsto: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Vincular el dispositivo real",
|
||||
"unlink": "Quitar el vínculo"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Elegir el dispositivo real",
|
||||
"description": "Elige el dispositivo al que pertenece **{object}**. La mejor coincidencia — **{suggestion}** — está preseleccionada; compruébala antes de guardar. Los dispositivos propios de Maintenance Supporter se rechazan.",
|
||||
"data": {
|
||||
"device": "Dispositivo"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Quitar el vínculo de dispositivo",
|
||||
"description": "**{object}** conserva un dispositivo propio. Las tareas y el historial no cambian; puedes vincular un dispositivo de nuevo en cualquier momento desde el panel de mantenimiento."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Ese es un dispositivo de Maintenance Supporter (el gemelo del objeto, no el aparato real): elige el dispositivo de la integración original.",
|
||||
"device_gone": "Ese dispositivo no existe (ya)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Este objeto ya no existe: no hay nada que reparar."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} está vinculado a su propio dispositivo de mantenimiento",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Reparar el vínculo de dispositivo de {object}",
|
||||
"description": "El vínculo de dispositivo de **{object}** apunta al dispositivo gemelo que Maintenance Supporter creó para el objeto: lleva el nombre del aparato real y el selector lo ofrecía antes. Por eso la página real del aparato nunca mostró las entidades de mantenimiento.\n\nElige cómo proceder. Mejor coincidencia para el dispositivo previsto: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Vincular el dispositivo real",
|
||||
"unlink": "Quitar el vínculo"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Elegir el dispositivo real",
|
||||
"description": "Elige el dispositivo al que pertenece **{object}**. La mejor coincidencia — **{suggestion}** — está preseleccionada; compruébala antes de guardar. Los dispositivos propios de Maintenance Supporter se rechazan.",
|
||||
"data": {
|
||||
"device": "Dispositivo"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Quitar el vínculo de dispositivo",
|
||||
"description": "**{object}** conserva un dispositivo propio. Las tareas y el historial no cambian; puedes vincular un dispositivo de nuevo en cualquier momento desde el panel de mantenimiento."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Ese es un dispositivo de Maintenance Supporter (el gemelo del objeto, no el aparato real): elige el dispositivo de la integración original.",
|
||||
"device_gone": "Ese dispositivo no existe (ya)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Este objeto ya no existe: no hay nada que reparar."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} menetti laitelinkityksensä",
|
||||
"description": "**{object}** oli linkitetty Home Assistant -laitteeseen, joten sen huoltoentiteetit näkyivät kyseisen laitteen sivulla. Laitetta ei enää ole — se poistettiin tai sen integraatio poistettiin — ja kohde näyttää nyt oman laitteensa. Muu ei muutu: tehtävät ja niiden historia säilyvät koskemattomina.\n\nValitse laite uudelleen Huolto-paneelissa kohdasta *Linkitä olemassa olevaan laitteeseen*, tai jätä tämä huomiotta, jos oma laite riittää. Tallennettu linkitys säilytetään tarkoituksella: jos laite palaa, linkitys toimii taas itsestään."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Korjaa kohteen {object} laitelinkitys",
|
||||
"description": "**{object}** oli linkitetty Home Assistant -laitteeseen, joten sen huoltoentiteetit näkyivät kyseisen laitteen sivulla. Laitetta ei enää ole — se poistettiin tai sen integraatio poistettiin — ja kohde näyttää nyt oman laitteensa. Muu ei muutu: tehtävät ja niiden historia säilyvät koskemattomina.\n\nValitse toimintatapa. Paras arvaus tarkoitetusta laitteesta: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Linkitä oikea laite",
|
||||
"unlink": "Poista linkitys"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Valitse oikea laite",
|
||||
"description": "Valitse laite, johon **{object}** kuuluu. Paras osuma — **{suggestion}** — on esivalittu; tarkista se ennen tallennusta. Maintenance Supporterin omat laitteet hylätään.",
|
||||
"data": {
|
||||
"device": "Laite"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Poista laitelinkitys",
|
||||
"description": "**{object}** säilyttää oman laitteensa. Tehtävät ja historia eivät muutu; voit linkittää laitteen uudelleen milloin tahansa huoltopaneelissa."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Tuo on Maintenance Supporterin laite (kohteen kaksonen, ei oikea laite) — valitse laitteen oman integraation laite.",
|
||||
"device_gone": "Tuota laitetta ei (enää) ole."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Tätä kohdetta ei enää ole — ei korjattavaa."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} on linkitetty omaan huoltolaitteeseensa",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Korjaa kohteen {object} laitelinkitys",
|
||||
"description": "Kohteen **{object}** laitelinkitys osoittaa kaksoislaitteeseen, jonka Maintenance Supporter itse loi kohteelle — sillä on oikean laitteen nimi, ja valitsin tarjosi sitä aiemmin. Oikean laitteen sivulla huoltoentiteetit eivät siksi koskaan näkyneet.\n\nValitse toimintatapa. Paras arvaus tarkoitetusta laitteesta: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Linkitä oikea laite",
|
||||
"unlink": "Poista linkitys"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Valitse oikea laite",
|
||||
"description": "Valitse laite, johon **{object}** kuuluu. Paras osuma — **{suggestion}** — on esivalittu; tarkista se ennen tallennusta. Maintenance Supporterin omat laitteet hylätään.",
|
||||
"data": {
|
||||
"device": "Laite"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Poista laitelinkitys",
|
||||
"description": "**{object}** säilyttää oman laitteensa. Tehtävät ja historia eivät muutu; voit linkittää laitteen uudelleen milloin tahansa huoltopaneelissa."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Tuo on Maintenance Supporterin laite (kohteen kaksonen, ei oikea laite) — valitse laitteen oman integraation laite.",
|
||||
"device_gone": "Tuota laitetta ei (enää) ole."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Tätä kohdetta ei enää ole — ei korjattavaa."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} a perdu son lien vers l'appareil",
|
||||
"description": "**{object}** était rattaché à un appareil Home Assistant, si bien que ses entités de maintenance apparaissaient sur la page de cet appareil. Cet appareil n'existe plus — supprimé, ou son intégration retirée — et l'objet affiche désormais un appareil qui lui est propre. Rien d'autre ne change : ses tâches et leur historique sont intacts.\n\nChoisissez à nouveau un appareil dans le panneau Maintenance sous *Lier à un appareil existant*, ou ignorez ceci si un appareil propre convient. Le lien enregistré est volontairement conservé : si l'appareil revient, le rattachement reprend de lui-même."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Réparer le lien d'appareil de {object}",
|
||||
"description": "**{object}** était rattaché à un appareil Home Assistant, si bien que ses entités de maintenance apparaissaient sur la page de cet appareil. Cet appareil n'existe plus — supprimé, ou son intégration retirée — et l'objet affiche désormais un appareil qui lui est propre. Rien d'autre ne change : ses tâches et leur historique sont intacts.\n\nChoisissez comment procéder. Meilleure correspondance pour l'appareil visé : **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Lier le véritable appareil",
|
||||
"unlink": "Supprimer le lien"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Choisir le véritable appareil",
|
||||
"description": "Choisissez l'appareil auquel **{object}** appartient. La meilleure correspondance — **{suggestion}** — est présélectionnée ; vérifiez-la avant d'enregistrer. Les appareils propres à Maintenance Supporter sont refusés.",
|
||||
"data": {
|
||||
"device": "Appareil"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Supprimer le lien d'appareil",
|
||||
"description": "**{object}** conserve un appareil qui lui est propre. Les tâches et l'historique restent intacts ; vous pourrez relier un appareil à tout moment dans le panneau Maintenance."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "C'est un appareil de Maintenance Supporter (le jumeau de l'objet, pas l'appareil réel) — choisissez l'appareil de l'intégration d'origine.",
|
||||
"device_gone": "Cet appareil n'existe pas (ou plus)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Cet objet n'existe plus — rien à réparer."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} est lié à son propre appareil de maintenance",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Réparer le lien d'appareil de {object}",
|
||||
"description": "Le lien d'appareil de **{object}** pointe vers l'appareil jumeau que Maintenance Supporter a créé pour l'objet — il porte le nom de l'appareil réel, et le sélecteur le proposait autrefois. La vraie page de l'appareil n'a donc jamais affiché les entités de maintenance.\n\nChoisissez comment procéder. Meilleure correspondance pour l'appareil visé : **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Lier le véritable appareil",
|
||||
"unlink": "Supprimer le lien"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Choisir le véritable appareil",
|
||||
"description": "Choisissez l'appareil auquel **{object}** appartient. La meilleure correspondance — **{suggestion}** — est présélectionnée ; vérifiez-la avant d'enregistrer. Les appareils propres à Maintenance Supporter sont refusés.",
|
||||
"data": {
|
||||
"device": "Appareil"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Supprimer le lien d'appareil",
|
||||
"description": "**{object}** conserve un appareil qui lui est propre. Les tâches et l'historique restent intacts ; vous pourrez relier un appareil à tout moment dans le panneau Maintenance."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "C'est un appareil de Maintenance Supporter (le jumeau de l'objet, pas l'appareil réel) — choisissez l'appareil de l'intégration d'origine.",
|
||||
"device_gone": "Cet appareil n'existe pas (ou plus)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Cet objet n'existe plus — rien à réparer."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} का डिवाइस लिंक टूट गया",
|
||||
"description": "**{object}** एक Home Assistant डिवाइस से जुड़ा था, इसलिए इसकी रखरखाव एंटिटी उसी डिवाइस के पेज पर दिखती थीं। वह डिवाइस अब मौजूद नहीं है — हटा दिया गया, या उसका इंटीग्रेशन निकाल दिया गया — और अब यह ऑब्जेक्ट अपना अलग डिवाइस दिखाता है। बाकी कुछ नहीं बदला: कार्य और उनका इतिहास वैसे ही हैं।\n\nरखरखाव पैनल में *मौजूदा डिवाइस से लिंक करें* के अंतर्गत दोबारा कोई डिवाइस चुनें, या अपना अलग डिवाइस ठीक लगे तो इसे अनदेखा करें। सहेजा गया लिंक जानबूझकर रखा गया है — डिवाइस लौटने पर यह अपने आप फिर काम करने लगेगा।"
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "{object} का डिवाइस लिंक ठीक करें",
|
||||
"description": "**{object}** एक Home Assistant डिवाइस से जुड़ा था, इसलिए इसकी रखरखाव एंटिटी उसी डिवाइस के पेज पर दिखती थीं। वह डिवाइस अब मौजूद नहीं है — हटा दिया गया, या उसका इंटीग्रेशन निकाल दिया गया — और अब यह ऑब्जेक्ट अपना अलग डिवाइस दिखाता है। बाकी कुछ नहीं बदला: कार्य और उनका इतिहास वैसे ही हैं।\n\nआगे का तरीका चुनें। इच्छित डिवाइस के लिए सबसे अच्छा अनुमान: **{suggestion}**।",
|
||||
"menu_options": {
|
||||
"relink": "असली उपकरण जोड़ें",
|
||||
"unlink": "लिंक हटाएँ"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "असली उपकरण चुनें",
|
||||
"description": "वह डिवाइस चुनें जिससे **{object}** संबंधित है। सबसे अच्छा मिलान — **{suggestion}** — पहले से चुना हुआ है; सहेजने से पहले जाँच लें। Maintenance Supporter के अपने डिवाइस अस्वीकार किए जाते हैं।",
|
||||
"data": {
|
||||
"device": "डिवाइस"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "डिवाइस लिंक हटाएँ",
|
||||
"description": "**{object}** का अपना डिवाइस बना रहेगा। कार्य और इतिहास अपरिवर्तित रहते हैं; रखरखाव पैनल में आप कभी भी फिर से कोई डिवाइस जोड़ सकते हैं।"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "यह Maintenance Supporter का अपना डिवाइस है (ऑब्जेक्ट का जुड़वां, असली उपकरण नहीं) — मूल इंटीग्रेशन का डिवाइस चुनें।",
|
||||
"device_gone": "वह डिवाइस (अब) मौजूद नहीं है।"
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "यह ऑब्जेक्ट अब मौजूद नहीं है — ठीक करने को कुछ नहीं।"
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} अपने ही रखरखाव डिवाइस से जुड़ा है",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "{object} का डिवाइस लिंक ठीक करें",
|
||||
"description": "**{object}** का डिवाइस लिंक उस जुड़वां डिवाइस की ओर इशारा करता है जिसे Maintenance Supporter ने ऑब्जेक्ट के लिए स्वयं बनाया था — इसका नाम असली उपकरण जैसा है, और डिवाइस चयनकर्ता इसे पहले दिखाता था। इसलिए असली उपकरण के पेज पर रखरखाव इकाइयाँ कभी नहीं दिखीं।\n\nआगे का तरीका चुनें। इच्छित डिवाइस के लिए सबसे अच्छा अनुमान: **{suggestion}**।",
|
||||
"menu_options": {
|
||||
"relink": "असली उपकरण जोड़ें",
|
||||
"unlink": "लिंक हटाएँ"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "असली उपकरण चुनें",
|
||||
"description": "वह डिवाइस चुनें जिससे **{object}** संबंधित है। सबसे अच्छा मिलान — **{suggestion}** — पहले से चुना हुआ है; सहेजने से पहले जाँच लें। Maintenance Supporter के अपने डिवाइस अस्वीकार किए जाते हैं।",
|
||||
"data": {
|
||||
"device": "डिवाइस"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "डिवाइस लिंक हटाएँ",
|
||||
"description": "**{object}** का अपना डिवाइस बना रहेगा। कार्य और इतिहास अपरिवर्तित रहते हैं; रखरखाव पैनल में आप कभी भी फिर से कोई डिवाइस जोड़ सकते हैं।"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "यह Maintenance Supporter का अपना डिवाइस है (ऑब्जेक्ट का जुड़वां, असली उपकरण नहीं) — मूल इंटीग्रेशन का डिवाइस चुनें।",
|
||||
"device_gone": "वह डिवाइस (अब) मौजूद नहीं है।"
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "यह ऑब्जेक्ट अब मौजूद नहीं है — ठीक करने को कुछ नहीं।"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} elvesztette az eszközkapcsolatát",
|
||||
"description": "**{object}** egy Home Assistant eszközhöz volt kapcsolva, így a karbantartási entitásai annak az eszköznek az oldalán jelentek meg. Az az eszköz már nem létezik — törölték, vagy eltávolították az integrációját — és az objektum most saját eszközt mutat. Más nem változik: a feladatok és az előzményeik érintetlenek.\n\nVálasszon újra eszközt a Karbantartás panelen a *Kapcsolás meglévő eszközhöz* alatt, vagy hagyja figyelmen kívül, ha a saját eszköz is megfelel. A mentett kapcsolat szándékosan megmarad: ha az eszköz visszatér, a kapcsolat magától újra él."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "{object} eszköz-összekapcsolásának javítása",
|
||||
"description": "**{object}** egy Home Assistant eszközhöz volt kapcsolva, így a karbantartási entitásai annak az eszköznek az oldalán jelentek meg. Az az eszköz már nem létezik — törölték, vagy eltávolították az integrációját — és az objektum most saját eszközt mutat. Más nem változik: a feladatok és az előzményeik érintetlenek.\n\nVálaszd ki a teendőt. A legjobb tipp a szánt eszközre: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "A valódi készülék összekapcsolása",
|
||||
"unlink": "Összekapcsolás eltávolítása"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "A valódi készülék kiválasztása",
|
||||
"description": "Válaszd ki azt az eszközt, amelyhez **{object}** tartozik. A legjobb találat — **{suggestion}** — előre ki van választva; mentés előtt ellenőrizd. A Maintenance Supporter saját eszközeit a rendszer elutasítja.",
|
||||
"data": {
|
||||
"device": "Eszköz"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Eszköz-összekapcsolás eltávolítása",
|
||||
"description": "**{object}** saját eszközt tart meg. A feladatok és az előzmények érintetlenek maradnak; a karbantartási panelen bármikor újra összekapcsolhatsz egy eszközt."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Ez a Maintenance Supporter saját eszköze (az objektum ikertestvére, nem a valódi készülék) — válaszd az eredeti integráció eszközét.",
|
||||
"device_gone": "Ez az eszköz (már) nem létezik."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Ez az objektum már nem létezik — nincs mit javítani."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} a saját karbantartási eszközéhez van kapcsolva",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "{object} eszköz-összekapcsolásának javítása",
|
||||
"description": "A(z) **{object}** eszköz-összekapcsolása arra az ikereszközre mutat, amelyet a Maintenance Supporter maga hozott létre az objektumhoz — a valódi készülék nevét viseli, és a választó korábban felkínálta. Ezért a készülék valódi eszközoldalán a karbantartási entitások sosem jelentek meg.\n\nVálaszd ki a teendőt. A legjobb tipp a szánt eszközre: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "A valódi készülék összekapcsolása",
|
||||
"unlink": "Összekapcsolás eltávolítása"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "A valódi készülék kiválasztása",
|
||||
"description": "Válaszd ki azt az eszközt, amelyhez **{object}** tartozik. A legjobb találat — **{suggestion}** — előre ki van választva; mentés előtt ellenőrizd. A Maintenance Supporter saját eszközeit a rendszer elutasítja.",
|
||||
"data": {
|
||||
"device": "Eszköz"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Eszköz-összekapcsolás eltávolítása",
|
||||
"description": "**{object}** saját eszközt tart meg. A feladatok és az előzmények érintetlenek maradnak; a karbantartási panelen bármikor újra összekapcsolhatsz egy eszközt."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Ez a Maintenance Supporter saját eszköze (az objektum ikertestvére, nem a valódi készülék) — válaszd az eredeti integráció eszközét.",
|
||||
"device_gone": "Ez az eszköz (már) nem létezik."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Ez az objektum már nem létezik — nincs mit javítani."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} ha perso il collegamento al dispositivo",
|
||||
"description": "**{object}** era collegato a un dispositivo di Home Assistant, così le sue entità di manutenzione comparivano nella pagina di quel dispositivo. Quel dispositivo non esiste più — eliminato, o rimossa la sua integrazione — e l'oggetto ora mostra un dispositivo proprio. Nient'altro cambia: le attività e la loro cronologia restano intatte.\n\nScegli di nuovo un dispositivo nel pannello Manutenzione sotto *Collega a un dispositivo esistente*, oppure ignora questo avviso se un dispositivo proprio va bene. Il collegamento salvato viene mantenuto di proposito: se il dispositivo torna, il collegamento riprende da solo."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Ripara il collegamento dispositivo di {object}",
|
||||
"description": "**{object}** era collegato a un dispositivo di Home Assistant, così le sue entità di manutenzione comparivano nella pagina di quel dispositivo. Quel dispositivo non esiste più — eliminato, o rimossa la sua integrazione — e l'oggetto ora mostra un dispositivo proprio. Nient'altro cambia: le attività e la loro cronologia restano intatte.\n\nScegli come procedere. Miglior corrispondenza per il dispositivo inteso: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Collega il dispositivo reale",
|
||||
"unlink": "Rimuovi il collegamento"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Scegli il dispositivo reale",
|
||||
"description": "Scegli il dispositivo a cui appartiene **{object}**. La corrispondenza migliore — **{suggestion}** — è preselezionata; verificala prima di salvare. I dispositivi propri di Maintenance Supporter vengono rifiutati.",
|
||||
"data": {
|
||||
"device": "Dispositivo"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Rimuovi il collegamento dispositivo",
|
||||
"description": "**{object}** mantiene un dispositivo proprio. Attività e cronologia restano intatte; puoi collegare di nuovo un dispositivo in qualsiasi momento dal pannello di manutenzione."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Questo è un dispositivo di Maintenance Supporter (il gemello dell'oggetto, non l'apparecchio reale): scegli il dispositivo dell'integrazione originale.",
|
||||
"device_gone": "Questo dispositivo non esiste (più)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Questo oggetto non esiste più: niente da riparare."
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} è collegato al proprio dispositivo di manutenzione",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Ripara il collegamento dispositivo di {object}",
|
||||
"description": "Il collegamento di **{object}** punta al dispositivo gemello creato da Maintenance Supporter per l'oggetto: porta il nome dell'apparecchio reale e in passato il selettore lo proponeva. Per questo la pagina reale dell'apparecchio non ha mai mostrato le entità di manutenzione.\n\nScegli come procedere. Miglior corrispondenza per il dispositivo inteso: **{suggestion}**.",
|
||||
"menu_options": {
|
||||
"relink": "Collega il dispositivo reale",
|
||||
"unlink": "Rimuovi il collegamento"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "Scegli il dispositivo reale",
|
||||
"description": "Scegli il dispositivo a cui appartiene **{object}**. La corrispondenza migliore — **{suggestion}** — è preselezionata; verificala prima di salvare. I dispositivi propri di Maintenance Supporter vengono rifiutati.",
|
||||
"data": {
|
||||
"device": "Dispositivo"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "Rimuovi il collegamento dispositivo",
|
||||
"description": "**{object}** mantiene un dispositivo proprio. Attività e cronologia restano intatte; puoi collegare di nuovo un dispositivo in qualsiasi momento dal pannello di manutenzione."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "Questo è un dispositivo di Maintenance Supporter (il gemello dell'oggetto, non l'apparecchio reale): scegli il dispositivo dell'integrazione originale.",
|
||||
"device_gone": "Questo dispositivo non esiste (più)."
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "Questo oggetto non esiste più: niente da riparare."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -1825,7 +1825,69 @@
|
||||
},
|
||||
"device_link_lost": {
|
||||
"title": "{object} のデバイス連携が失われました",
|
||||
"description": "**{object}** は Home Assistant のデバイスに連携していたため、メンテナンスのエンティティがそのデバイスのページに表示されていました。そのデバイスはもう存在しません(削除された、または統合が取り除かれた)ので、オブジェクトは自前のデバイスを表示しています。ほかは変わりません。タスクと履歴はそのままです。\n\nメンテナンスパネルの*既存のデバイスに連携*からデバイスを選び直すか、自前のデバイスで構わなければこの通知を無視してください。保存された連携先はあえて残してあるため、デバイスが戻れば連携も自動的に復帰します。"
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "{object} のデバイスリンクを修復",
|
||||
"description": "**{object}** は Home Assistant のデバイスに連携していたため、メンテナンスのエンティティがそのデバイスのページに表示されていました。そのデバイスはもう存在しません(削除された、または統合が取り除かれた)ので、オブジェクトは自前のデバイスを表示しています。ほかは変わりません。タスクと履歴はそのままです。\n\n進め方を選択してください。対象と思われるデバイスの最有力候補: **{suggestion}**。",
|
||||
"menu_options": {
|
||||
"relink": "実際の機器をリンクする",
|
||||
"unlink": "リンクを削除する"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "実際の機器を選択",
|
||||
"description": "**{object}** が属するデバイスを選択してください。最有力候補 — **{suggestion}** — が事前選択されています。保存前に確認してください。Maintenance Supporter 自身のデバイスは拒否されます。",
|
||||
"data": {
|
||||
"device": "デバイス"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "デバイスリンクを削除",
|
||||
"description": "**{object}** は自身のデバイスを保持します。タスクと履歴は変わりません。メンテナンスパネルからいつでも再度デバイスをリンクできます。"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "これは Maintenance Supporter 自身のデバイス(オブジェクトの双子であり、実際の機器ではありません)です。元のインテグレーションのデバイスを選択してください。",
|
||||
"device_gone": "そのデバイスは(もう)存在しません。"
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "このオブジェクトはもう存在しません。修復するものはありません。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_link_self": {
|
||||
"title": "{object} が自身のメンテナンスデバイスにリンクされています",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "{object} のデバイスリンクを修復",
|
||||
"description": "**{object}** のデバイスリンクは、Maintenance Supporter がオブジェクト用に作成した双子デバイスを指しています。実際の機器と同じ名前を持ち、以前はデバイス選択に表示されていました。そのため、実際の機器のデバイスページにはメンテナンスエンティティが一度も表示されていませんでした。\n\n進め方を選択してください。対象と思われるデバイスの最有力候補: **{suggestion}**。",
|
||||
"menu_options": {
|
||||
"relink": "実際の機器をリンクする",
|
||||
"unlink": "リンクを削除する"
|
||||
}
|
||||
},
|
||||
"relink": {
|
||||
"title": "実際の機器を選択",
|
||||
"description": "**{object}** が属するデバイスを選択してください。最有力候補 — **{suggestion}** — が事前選択されています。保存前に確認してください。Maintenance Supporter 自身のデバイスは拒否されます。",
|
||||
"data": {
|
||||
"device": "デバイス"
|
||||
}
|
||||
},
|
||||
"unlink": {
|
||||
"title": "デバイスリンクを削除",
|
||||
"description": "**{object}** は自身のデバイスを保持します。タスクと履歴は変わりません。メンテナンスパネルからいつでも再度デバイスをリンクできます。"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"self_link": "これは Maintenance Supporter 自身のデバイス(オブジェクトの双子であり、実際の機器ではありません)です。元のインテグレーションのデバイスを選択してください。",
|
||||
"device_gone": "そのデバイスは(もう)存在しません。"
|
||||
},
|
||||
"abort": {
|
||||
"entry_gone": "このオブジェクトはもう存在しません。修復するものはありません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user