187 files
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
{"pid": 72, "version": 1, "ha_version": "2026.10.0", "start_ts": 1791476271.5855424}
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.10.0", "start_ts": 1791546769.0837624}
|
||||
+39
-39
@@ -41,7 +41,7 @@
|
||||
},
|
||||
{
|
||||
"id": "0b28e2d01d0d4033b2f0a339cc7d86c1",
|
||||
"url": "/ha_washdata/ha-washdata-card.js?v=cad53dea8b",
|
||||
"url": "/ha_washdata/ha-washdata-card.js?v=5d52b92f80",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -81,117 +81,117 @@
|
||||
},
|
||||
{
|
||||
"id": "88b90f2b1d6b45f0b44bee786ee1a525",
|
||||
"url": "/taskmate/taskmate-attr-resolver.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-attr-resolver.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0796c92931dd458e81dc9c2677248274",
|
||||
"url": "/taskmate/taskmate-localize.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-localize.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2e281920ac7409583eafff1f1db69bd",
|
||||
"url": "/taskmate/taskmate-design.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-design.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9eae9632f0734480be5edd784b030673",
|
||||
"url": "/taskmate/taskmate-badges-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-badges-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5df49cd8a4eb420497b5df22cb31362c",
|
||||
"url": "/taskmate/taskmate-child-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-child-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
|
||||
"url": "/taskmate/taskmate-rewards-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-rewards-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "787a96f9c30548708165a48457c99feb",
|
||||
"url": "/taskmate/taskmate-approvals-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-approvals-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "84031597de424ad68e7c2bc66d06b72e",
|
||||
"url": "/taskmate/taskmate-points-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-points-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "02613225844746b699bfbfac9642eca2",
|
||||
"url": "/taskmate/taskmate-reorder-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-reorder-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "7a06a07e17ba4b31b712bc91b77360b4",
|
||||
"url": "/taskmate/taskmate-overview-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-overview-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "08b42b53b33b4fa88db932455cf8d5aa",
|
||||
"url": "/taskmate/taskmate-activity-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-activity-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "15907eff51784acdba88ff694b574509",
|
||||
"url": "/taskmate/taskmate-streak-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-streak-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
|
||||
"url": "/taskmate/taskmate-weekly-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-weekly-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "289ce5943f5a4972a593697db4c96ece",
|
||||
"url": "/taskmate/taskmate-graph-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-graph-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2904acee4c74a398dcc7b996c23e7e7",
|
||||
"url": "/taskmate/taskmate-reward-progress-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-reward-progress-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "46d378736fd54a26be253f2dfb27202e",
|
||||
"url": "/taskmate/taskmate-leaderboard-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-leaderboard-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "db11ae36457842fa826c7991e1d9c6f4",
|
||||
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
|
||||
"url": "/taskmate/taskmate-penalties-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-penalties-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1959e1629de2422daa8adaf87069c288",
|
||||
"url": "/taskmate/taskmate-bonuses-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-bonuses-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "2548524b269c4959bc7e0e967df943af",
|
||||
"url": "/taskmate/taskmate-points-display-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-points-display-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "36ada49368db4187a283fbbbef45454f",
|
||||
"url": "/taskmate/taskmate-calendar-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-calendar-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "67c32687acd2497b90030ef141f4b6db",
|
||||
"url": "/taskmate/taskmate-photo-gallery-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-photo-gallery-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
|
||||
"url": "/taskmate/taskmate-family-goal-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-family-goal-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -276,7 +276,7 @@
|
||||
},
|
||||
{
|
||||
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
||||
"url": "/taskmate/taskmate-routine-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-routine-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -291,47 +291,47 @@
|
||||
},
|
||||
{
|
||||
"id": "1a4939ce1d2b47f89502176d3c5ac6e7",
|
||||
"url": "/taskmate/taskmate-sounds.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-sounds.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "f4b0f22e5ce74727afbde9d7168a141e",
|
||||
"url": "/ha_creality_ws/k_printer_card.js?v=efead36a13",
|
||||
"url": "/ha_creality_ws/k_printer_card.js?v=fd0786eb1e",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5e4ca4c959f04b4c9b1dd8da0c4cb3b7",
|
||||
"url": "/ha_creality_ws/k_cfs_card.js?v=71e833984f",
|
||||
"url": "/ha_creality_ws/k_cfs_card.js?v=04dd7dff75",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "2cc757922cd142b69ce8844afc0e8410",
|
||||
"url": "/taskmate/taskmate-wishlist-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-wishlist-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "107319877a72413fbbea40b7f8adb06c",
|
||||
"url": "/taskmate/taskmate-kiosk-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-kiosk-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5a2899c29e9046c78f060580ba113d9f",
|
||||
"url": "/taskmate/taskmate-bounty-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-bounty-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "a1aae7e334ba4c2d8dd3849178f780f6",
|
||||
"url": "/taskmate/taskmate-auction-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-auction-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "464b73198b7d4fe480c09bbf386a4e5d",
|
||||
"url": "/taskmate/taskmate-recap-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-recap-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "35f1f324116d4b589f3087065519360b",
|
||||
"url": "/taskmate/taskmate-chore-board-card.js?v=6.1.0",
|
||||
"url": "/taskmate/taskmate-chore-board-card.js?v=6.2.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -340,18 +340,18 @@
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0fe51f5857344078bf96e51e13015ab3",
|
||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.13.0",
|
||||
"id": "14283fbeb8494a2d970378245aaa012f",
|
||||
"url": "/hacsfiles/Filament-Card/filament-card.js?hacstag=1290882685050",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d6cb6831dea04e7d9e127fde97503e21",
|
||||
"id": "2f9c3d3bdf2e46c2b9607a28d505d4a4",
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "14283fbeb8494a2d970378245aaa012f",
|
||||
"url": "/hacsfiles/Filament-Card/filament-card.js?hacstag=1290882685050",
|
||||
"id": "147a07760eec4fe69bd509064f81e3cd",
|
||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.14.0",
|
||||
"type": "module"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import async_timeout
|
||||
from aiohttp import ClientResponseError
|
||||
from PIL import Image
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
@@ -1581,12 +1582,26 @@ class AlbumSlideshowCamera(Camera):
|
||||
return dict(headers)
|
||||
return None
|
||||
|
||||
def _image_request_params(self, url: str) -> dict[str, str] | None:
|
||||
"""Session query params added at fetch time only (UGREEN ``ugk``)."""
|
||||
params = getattr(self.coordinator, "image_request_params", None)
|
||||
if params and isinstance(url, str) and url.startswith("http"):
|
||||
return dict(params)
|
||||
return None
|
||||
|
||||
async def _http_get(self, url: str) -> bytes | None:
|
||||
"""Fetch one remote image with validation and a hard timeout."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
session = async_get_clientsession(
|
||||
self.hass,
|
||||
verify_ssl=getattr(self.coordinator, "image_request_verify_ssl", True),
|
||||
)
|
||||
try:
|
||||
async with async_timeout.timeout(30):
|
||||
async with session.get(url, headers=self._image_request_headers(url)) as resp:
|
||||
async with session.get(
|
||||
url,
|
||||
headers=self._image_request_headers(url),
|
||||
params=self._image_request_params(url),
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
@@ -1636,6 +1651,12 @@ class AlbumSlideshowCamera(Camera):
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
except ClientResponseError as err:
|
||||
# str(err) includes the full request URL, fetch-time params and all.
|
||||
_LOGGER.warning(
|
||||
"Album Slideshow: failed to fetch image %s: HTTP %s", url, err.status
|
||||
)
|
||||
return None
|
||||
except Exception as err:
|
||||
_LOGGER.warning("Album Slideshow: failed to fetch image: %s", err)
|
||||
return None
|
||||
|
||||
@@ -99,6 +99,13 @@ from .const import (
|
||||
DEFAULT_ENTE_IMAGE_SIZE,
|
||||
ENTE_IMAGE_FULL,
|
||||
ENTE_IMAGE_PREVIEW,
|
||||
CONF_UGREEN_URL,
|
||||
CONF_UGREEN_USERNAME,
|
||||
CONF_UGREEN_PASSWORD,
|
||||
CONF_UGREEN_ALBUM_UUID,
|
||||
CONF_UGREEN_ALBUM_TYPE,
|
||||
CONF_UGREEN_ALBUM_NAME,
|
||||
CONF_UGREEN_VERIFY_SSL,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
PROVIDER_GOOGLE_SHARED,
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
@@ -109,6 +116,7 @@ from .const import (
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
PROVIDER_UGREEN,
|
||||
DEFAULT_RECURSIVE,
|
||||
)
|
||||
|
||||
@@ -374,6 +382,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._syn_places: dict[str, str] = {}
|
||||
self._syn_tags: dict[str, str] = {}
|
||||
self._syn_subjects: dict[str, str] = {}
|
||||
# UGREEN flow state carried between steps.
|
||||
self._ugr_url: str | None = None
|
||||
self._ugr_username: str | None = None
|
||||
self._ugr_password: str | None = None
|
||||
self._ugr_verify_ssl: bool = True
|
||||
self._ugr_albums: list[dict[str, Any]] = []
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -382,8 +396,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> config_entries.OptionsFlow:
|
||||
"""Return the options flow handler.
|
||||
|
||||
Local-folder, Nextcloud and Ente entries expose the reverse-geocode
|
||||
toggle; Immich entries reopen the albums/people/favorites picker.
|
||||
Local-folder, Nextcloud, Ente and UGREEN entries expose the
|
||||
reverse-geocode toggle; Immich entries reopen the
|
||||
albums/people/favorites picker.
|
||||
Other providers get a no-op handler so that the "Configure" button
|
||||
doesn't appear empty in the UI.
|
||||
|
||||
@@ -397,6 +412,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_LOCAL_FOLDER,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
PROVIDER_UGREEN,
|
||||
):
|
||||
return LocalFolderOptionsFlow()
|
||||
if config_entry.data.get(CONF_PROVIDER) == PROVIDER_IMMICH:
|
||||
@@ -424,6 +440,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_nextcloud()
|
||||
if self._provider == PROVIDER_ENTE:
|
||||
return await self.async_step_ente()
|
||||
if self._provider == PROVIDER_UGREEN:
|
||||
return await self.async_step_ugreen()
|
||||
return await self.async_step_google_shared()
|
||||
|
||||
schema = vol.Schema(
|
||||
@@ -437,6 +455,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)",
|
||||
PROVIDER_NEXTCLOUD: "Nextcloud (WebDAV folder or public album link)",
|
||||
PROVIDER_ENTE: "Ente Photos (public album link)",
|
||||
PROVIDER_UGREEN: "UGREEN NAS (UGOS Photos, experimental)",
|
||||
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
|
||||
})
|
||||
}
|
||||
@@ -1116,6 +1135,123 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="synology_select", data_schema=vol.Schema(fields), errors=errors
|
||||
)
|
||||
|
||||
async def async_step_ugreen(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Collect the UGREEN NAS URL + account and log in.
|
||||
|
||||
The album is picked from a live dropdown in the next step rather
|
||||
than typed in, so logging in here (and listing albums) doubles as
|
||||
credential validation.
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_UGREEN_URL].strip()
|
||||
username = user_input[CONF_UGREEN_USERNAME].strip()
|
||||
password = user_input.get(CONF_UGREEN_PASSWORD) or ""
|
||||
verify_ssl = bool(user_input.get(CONF_UGREEN_VERIFY_SSL, True))
|
||||
|
||||
from . import ugreen as ugr_api
|
||||
|
||||
client = ugr_api.UGreenClient(
|
||||
self.hass, url, username, password, verify_ssl=verify_ssl
|
||||
)
|
||||
albums: list[dict[str, Any]] = []
|
||||
try:
|
||||
await client.async_login()
|
||||
albums = await client.async_list_albums()
|
||||
except ugr_api.UGreenAuthError as err:
|
||||
_LOGGER.warning(
|
||||
"UGREEN login failed for %s: %s", url, _describe_error(err)
|
||||
)
|
||||
errors["base"] = "ugreen_cannot_connect"
|
||||
except ugr_api.UGreenApiError as err:
|
||||
_LOGGER.warning(
|
||||
"UGREEN album listing failed for %s: %s", url, _describe_error(err)
|
||||
)
|
||||
errors["base"] = "ugreen_cannot_connect"
|
||||
|
||||
named_albums = [
|
||||
a for a in albums if isinstance(a, dict) and a.get("album_name")
|
||||
]
|
||||
if not errors and not named_albums:
|
||||
errors["base"] = "ugreen_no_albums"
|
||||
|
||||
if not errors:
|
||||
self._ugr_url = client.base_url
|
||||
self._ugr_username = username
|
||||
self._ugr_password = password
|
||||
self._ugr_verify_ssl = verify_ssl
|
||||
self._ugr_albums = named_albums
|
||||
return await self.async_step_ugreen_select()
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_UGREEN_URL): str,
|
||||
vol.Required(CONF_UGREEN_USERNAME): str,
|
||||
vol.Required(CONF_UGREEN_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Optional(CONF_UGREEN_VERIFY_SSL, default=True): bool,
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="ugreen", data_schema=schema, errors=errors)
|
||||
|
||||
async def async_step_ugreen_select(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Pick the UGOS Photos album from a live, searchable dropdown."""
|
||||
from . import ugreen as ugr_api
|
||||
|
||||
# Keyed by uuid since album names aren't guaranteed unique.
|
||||
albums_by_uuid = {a["album_uuid"]: a for a in self._ugr_albums}
|
||||
|
||||
if user_input is not None:
|
||||
album_uuid = user_input[CONF_UGREEN_ALBUM_UUID]
|
||||
album = albums_by_uuid[album_uuid]
|
||||
unique = (
|
||||
f"{DOMAIN}:{PROVIDER_UGREEN}:{self._ugr_url}:"
|
||||
f"{self._ugr_username}:{album_uuid}"
|
||||
)
|
||||
await self.async_set_unique_id(unique)
|
||||
self._abort_if_unique_id_configured()
|
||||
data = {
|
||||
CONF_PROVIDER: PROVIDER_UGREEN,
|
||||
CONF_UGREEN_URL: self._ugr_url,
|
||||
CONF_UGREEN_USERNAME: self._ugr_username,
|
||||
CONF_UGREEN_PASSWORD: self._ugr_password,
|
||||
CONF_UGREEN_VERIFY_SSL: self._ugr_verify_ssl,
|
||||
CONF_UGREEN_ALBUM_UUID: album_uuid,
|
||||
CONF_UGREEN_ALBUM_TYPE: album.get(
|
||||
"album_type", ugr_api.ALBUM_TYPE_REGULAR
|
||||
),
|
||||
CONF_UGREEN_ALBUM_NAME: album["album_name"],
|
||||
}
|
||||
return self.async_create_entry(title=album["album_name"], data=data)
|
||||
|
||||
# A searchable dropdown (rather than a plain vol.In) keeps this
|
||||
# usable for accounts with a large number of albums.
|
||||
options = [
|
||||
selector.SelectOptionDict(
|
||||
value=a["album_uuid"],
|
||||
label=f"{a['album_name']} ({ugr_api.describe_album_type(a.get('album_type'))})",
|
||||
)
|
||||
for a in self._ugr_albums
|
||||
]
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_UGREEN_ALBUM_UUID): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=options,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
custom_value=False,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="ugreen_select", data_schema=schema)
|
||||
|
||||
async def async_step_nextcloud(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
|
||||
@@ -75,6 +75,7 @@ PROVIDER_ICLOUD = "icloud"
|
||||
PROVIDER_SYNOLOGY = "synology"
|
||||
PROVIDER_NEXTCLOUD = "nextcloud"
|
||||
PROVIDER_ENTE = "ente"
|
||||
PROVIDER_UGREEN = "ugreen"
|
||||
|
||||
# Providers whose coordinator runs a background enrichment pass: per-photo
|
||||
# metadata reads, reverse-geocoding, or both. These are the entries that get
|
||||
@@ -85,6 +86,7 @@ ENRICHING_PROVIDERS = (
|
||||
PROVIDER_IMMICH,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
PROVIDER_UGREEN,
|
||||
)
|
||||
|
||||
# Nextcloud provider - two auth modes against the same PROVIDER_NEXTCLOUD id:
|
||||
@@ -239,6 +241,23 @@ DEFAULT_PHOTOPRISM_IMAGE_SIZE = PHOTOPRISM_IMAGE_PREVIEW
|
||||
PHOTOPRISM_SELECTION_COMPOSITE = "composite"
|
||||
|
||||
|
||||
# UGREEN NAS (UGOS Photos) provider. Talks to the undocumented UGOS Photos
|
||||
# web API (see ``ugreen.py``); login is username + password, with the
|
||||
# password RSA-encrypted before it ever leaves HA, same as the UGOS web app.
|
||||
# TLS certificates are verified unless the user turns that off for a NAS with
|
||||
# a self-signed certificate.
|
||||
CONF_UGREEN_URL = "ugreen_url"
|
||||
CONF_UGREEN_USERNAME = "ugreen_username"
|
||||
CONF_UGREEN_PASSWORD = "ugreen_password"
|
||||
CONF_UGREEN_VERIFY_SSL = "ugreen_verify_ssl"
|
||||
# The album is stored by ``album_uuid`` + ``album_type`` (what the API needs),
|
||||
# so renaming it in UGOS Photos does not break the slideshow. The name is kept
|
||||
# for display only.
|
||||
CONF_UGREEN_ALBUM_UUID = "ugreen_album_uuid"
|
||||
CONF_UGREEN_ALBUM_TYPE = "ugreen_album_type"
|
||||
CONF_UGREEN_ALBUM_NAME = "ugreen_album_name"
|
||||
|
||||
|
||||
FILL_COVER = "cover"
|
||||
FILL_CONTAIN = "contain"
|
||||
FILL_BLUR = "blur"
|
||||
|
||||
@@ -89,6 +89,12 @@ from .const import (
|
||||
CONF_ENTE_IMAGE_SIZE,
|
||||
DEFAULT_ENTE_IMAGE_SIZE,
|
||||
ENTE_IMAGE_PREVIEW,
|
||||
CONF_UGREEN_URL,
|
||||
CONF_UGREEN_USERNAME,
|
||||
CONF_UGREEN_PASSWORD,
|
||||
CONF_UGREEN_ALBUM_UUID,
|
||||
CONF_UGREEN_ALBUM_TYPE,
|
||||
CONF_UGREEN_VERIFY_SSL,
|
||||
DEFAULT_REVERSE_GEOCODE,
|
||||
DOMAIN,
|
||||
ENRICHING_PROVIDERS,
|
||||
@@ -101,6 +107,7 @@ from .const import (
|
||||
PROVIDER_SYNOLOGY,
|
||||
PROVIDER_NEXTCLOUD,
|
||||
PROVIDER_ENTE,
|
||||
PROVIDER_UGREEN,
|
||||
)
|
||||
from .store import SlideshowStore
|
||||
|
||||
@@ -1094,9 +1101,20 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
# Extra headers the camera must send when fetching image bytes
|
||||
# (Immich API key). Empty for providers that need no auth.
|
||||
self.image_request_headers: dict[str, str] = {}
|
||||
# Query params added only at fetch time (UGREEN ``ugk``), so stored
|
||||
# URLs, attributes and logs never carry them.
|
||||
self.image_request_params: dict[str, str] = {}
|
||||
# False when the entry opted out of TLS certificate checks (UGREEN NAS
|
||||
# with a self-signed certificate).
|
||||
self.image_request_verify_ssl: bool = True
|
||||
# Ente only: file id -> {key, header, thumbnail}, the material needed
|
||||
# to decrypt each image. Rebuilt on every album refresh.
|
||||
self._ente_fetch_meta: dict[str, dict[str, Any]] = {}
|
||||
# UGREEN only: logged-in client + selected album, reused by the
|
||||
# background enrichment pass instead of logging in again per photo.
|
||||
self._ugreen_client: Any = None
|
||||
self._ugreen_album_uuid: str | None = None
|
||||
self._ugreen_album_type: int = 1
|
||||
|
||||
# Persist the most recent successful album fetch so that a transient
|
||||
# network/Google failure doesn't blank the slideshow on restart.
|
||||
@@ -1172,6 +1190,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
data = await self._update_nextcloud()
|
||||
elif self.provider == PROVIDER_ENTE:
|
||||
data = await self._update_ente()
|
||||
elif self.provider == PROVIDER_UGREEN:
|
||||
data = await self._update_ugreen()
|
||||
else:
|
||||
raise UpdateFailed(f"Unsupported provider: {self.provider}")
|
||||
except UpdateFailed:
|
||||
@@ -1993,6 +2013,102 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_ugreen(self) -> dict[str, Any]:
|
||||
"""Fetch photos from a UGREEN NAS UGOS Photos album.
|
||||
|
||||
Logs in fresh on every refresh, like the Synology provider. The
|
||||
album is addressed by its stored uuid, so renaming it in UGOS Photos
|
||||
needs no reconfiguration.
|
||||
"""
|
||||
from . import ugreen as ugr_api
|
||||
|
||||
url = self.entry.data.get(CONF_UGREEN_URL)
|
||||
username = self.entry.data.get(CONF_UGREEN_USERNAME)
|
||||
password = self.entry.data.get(CONF_UGREEN_PASSWORD)
|
||||
album_uuid = self.entry.data.get(CONF_UGREEN_ALBUM_UUID)
|
||||
album_type = self.entry.data.get(
|
||||
CONF_UGREEN_ALBUM_TYPE, ugr_api.ALBUM_TYPE_REGULAR
|
||||
)
|
||||
verify_ssl = bool(self.entry.data.get(CONF_UGREEN_VERIFY_SSL, True))
|
||||
if not url or not username or not password or not album_uuid:
|
||||
raise UpdateFailed("UGREEN provider is missing URL, credentials or album")
|
||||
|
||||
client = ugr_api.UGreenClient(
|
||||
self.hass, url, username, password, verify_ssl=verify_ssl
|
||||
)
|
||||
try:
|
||||
await client.async_login()
|
||||
photos = await client.async_list_album_pictures(album_uuid, album_type)
|
||||
except ugr_api.UGreenAuthError as err:
|
||||
raise UpdateFailed(f"UGREEN login failed: {err}") from err
|
||||
except ugr_api.UGreenApiError as err:
|
||||
raise UpdateFailed(f"Error listing UGREEN album photos: {err}") from err
|
||||
|
||||
if not photos:
|
||||
raise UpdateFailed(f"No images found in UGREEN album '{self.entry.title}'")
|
||||
|
||||
# Stored so the camera can fetch image bytes server-side (session
|
||||
# cookie plus ``ugk``, added per request so stored URLs never carry it)
|
||||
# with the same TLS setting as the login.
|
||||
self.image_request_headers = dict(client.image_headers)
|
||||
self.image_request_params = dict(client.image_params)
|
||||
self.image_request_verify_ssl = verify_ssl
|
||||
# Kept for the background enrichment pass (GPS/address lookups),
|
||||
# which needs a logged-in client and the album context per photo.
|
||||
self._ugreen_client = client
|
||||
self._ugreen_album_uuid = album_uuid
|
||||
self._ugreen_album_type = album_type
|
||||
|
||||
items: list[MediaItem] = []
|
||||
for p in photos:
|
||||
picture_id = p.get("picture_id")
|
||||
if picture_id is None:
|
||||
continue
|
||||
# Backstop for type_option; a video would only show its poster frame.
|
||||
ext = p.get("real_ext_name") or Path(str(p.get("file_name") or "")).suffix
|
||||
if f".{str(ext).lstrip('.').lower()}" in _VIDEO_EXTS:
|
||||
continue
|
||||
meta = ugr_api.parse_photo_meta(p)
|
||||
items.append(
|
||||
MediaItem(
|
||||
url=ugr_api.build_image_url(
|
||||
client.base_url,
|
||||
picture_id,
|
||||
album_uuid,
|
||||
source_album_type=album_type,
|
||||
upload_time=p.get("upload_time", 0),
|
||||
),
|
||||
width=meta.get("width"),
|
||||
height=meta.get("height"),
|
||||
mime_type=None,
|
||||
filename=p.get("file_name"),
|
||||
captured_at=meta.get("captured_at"),
|
||||
byte_size=meta.get("byte_size"),
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
location=None,
|
||||
description=None,
|
||||
source_id=str(picture_id),
|
||||
# GPS/address isn't in this listing - the background
|
||||
# enrichment pass fills it in via picture/info.
|
||||
exif_scanned=False,
|
||||
)
|
||||
)
|
||||
|
||||
if not items:
|
||||
raise UpdateFailed("Could not resolve any UGREEN images")
|
||||
|
||||
try:
|
||||
album_name = await client.async_get_album_name(album_uuid)
|
||||
except Exception as err: # noqa: BLE001 - the title is cosmetic
|
||||
_LOGGER.debug("UGREEN: could not read the album name: %s", err)
|
||||
album_name = None
|
||||
|
||||
return {
|
||||
"title": album_name or self.entry.title,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
async def _update_nextcloud(self) -> dict[str, Any]:
|
||||
"""List photos from Nextcloud, dispatching on the configured auth mode."""
|
||||
mode = self.entry.data.get(
|
||||
@@ -2502,6 +2618,40 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
if completed:
|
||||
self.async_set_updated_data(data)
|
||||
|
||||
async def _enrich_ugreen_item(self, item: MediaItem) -> None:
|
||||
"""Fetch GPS coordinates and a location label for one UGREEN photo.
|
||||
|
||||
Reuses the client stashed by ``_update_ugreen`` instead of logging in
|
||||
again per photo. Image URLs are stable across refreshes, so
|
||||
``_merge_prior_enrichment`` carries results forward and only new
|
||||
photos are looked up.
|
||||
"""
|
||||
from . import ugreen as ugr_api
|
||||
|
||||
client = self._ugreen_client
|
||||
if client is None or not item.source_id or not self._ugreen_album_uuid:
|
||||
item.exif_scanned = True
|
||||
return
|
||||
try:
|
||||
info = await client.async_get_picture_info(
|
||||
item.source_id, self._ugreen_album_uuid, self._ugreen_album_type
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except ugr_api.UGreenApiError as err:
|
||||
_LOGGER.debug(
|
||||
"UGREEN: picture/info failed for %s: %s", item.source_id, err
|
||||
)
|
||||
item.exif_scanned = True
|
||||
return
|
||||
meta = ugr_api.parse_picture_location(info)
|
||||
if "latitude" in meta and "longitude" in meta:
|
||||
item.latitude = meta["latitude"]
|
||||
item.longitude = meta["longitude"]
|
||||
if "location" in meta:
|
||||
item.location = meta["location"]
|
||||
item.exif_scanned = True
|
||||
|
||||
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
|
||||
"""Read EXIF for unscanned local files, then reverse-geocode.
|
||||
|
||||
@@ -2564,6 +2714,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
|
||||
self.async_set_updated_data(data)
|
||||
continue
|
||||
|
||||
if self.provider == PROVIDER_UGREEN:
|
||||
try:
|
||||
await self._enrich_ugreen_item(item)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.debug("UGREEN enrich error: %s", err)
|
||||
item.exif_scanned = True
|
||||
scanned_since_save += 1
|
||||
self._enrich_progress["exif_done"] = (
|
||||
self._enrich_progress.get("exif_done", 0) + 1
|
||||
)
|
||||
if scanned_since_save >= _EXIF_BATCH_SAVE:
|
||||
scanned_since_save = 0
|
||||
await self._save_cached_items(data)
|
||||
self.async_set_updated_data(data)
|
||||
continue
|
||||
|
||||
url = item.url
|
||||
if not url.startswith("file://"):
|
||||
item.exif_scanned = True
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
|
||||
"requirements": ["PyNaCl>=1.5.0"],
|
||||
"version": "1.13.0"
|
||||
"version": "1.14.0"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,13 @@ class _BaseAlbumSensor(SensorEntity):
|
||||
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
|
||||
self.entry = entry
|
||||
self.coordinator = coordinator
|
||||
coordinator.async_add_listener(self.async_write_ha_state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
# Disabled entities are constructed but never added, so subscribe here.
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||
)
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
@@ -80,7 +86,10 @@ class HiddenPhotoCountSensor(_BaseAlbumSensor):
|
||||
super().__init__(entry, coordinator)
|
||||
self._attr_unique_id = f"{entry.entry_id}_hidden_photo_count"
|
||||
self._attr_name = "Hidden photos"
|
||||
coordinator.store.add_listener(self.async_write_ha_state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
await super().async_added_to_hass()
|
||||
self.coordinator.store.add_listener(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
|
||||
@@ -110,6 +110,23 @@
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"ugreen": {
|
||||
"title": "UGREEN NAS (UGOS Photos)",
|
||||
"description": "Experimental: connect to the Photos app on a UGREEN NAS (UGOS Pro). Enter the NAS address including port (e.g. https://192.168.1.10:9443) and an account. This provider talks to an undocumented, reverse-engineered API and may break with future UGOS updates.",
|
||||
"data": {
|
||||
"ugreen_url": "NAS URL",
|
||||
"ugreen_username": "Username",
|
||||
"ugreen_password": "Password",
|
||||
"ugreen_verify_ssl": "Verify SSL certificate"
|
||||
}
|
||||
},
|
||||
"ugreen_select": {
|
||||
"title": "UGREEN album",
|
||||
"description": "Pick the UGOS Photos album to show.",
|
||||
"data": {
|
||||
"ugreen_album_uuid": "Album"
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud",
|
||||
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
|
||||
@@ -173,7 +190,9 @@
|
||||
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
|
||||
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
|
||||
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #.",
|
||||
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
|
||||
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected.",
|
||||
"ugreen_cannot_connect": "Could not connect to the UGREEN NAS. Check the URL, username and password. If the NAS uses a self-signed certificate, turn off Verify SSL certificate.",
|
||||
"ugreen_no_albums": "No albums were found on this UGREEN NAS."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -29,11 +29,6 @@ class PairDividerColorText(TextEntity, RestoreEntity):
|
||||
self._attr_unique_id = f"{entry.entry_id}_pair_divider_color"
|
||||
self._attr_name = "Pair divider color"
|
||||
|
||||
def _on_store_change() -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
store.add_listener(_on_store_change)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
return self.store.pair_divider_color
|
||||
|
||||
@@ -110,6 +110,23 @@
|
||||
"synology_image_size": "Image quality"
|
||||
}
|
||||
},
|
||||
"ugreen": {
|
||||
"title": "UGREEN NAS (UGOS Photos)",
|
||||
"description": "Experimental: connect to the Photos app on a UGREEN NAS (UGOS Pro). Enter the NAS address including port (e.g. https://192.168.1.10:9443) and an account. This provider talks to an undocumented, reverse-engineered API and may break with future UGOS updates.",
|
||||
"data": {
|
||||
"ugreen_url": "NAS URL",
|
||||
"ugreen_username": "Username",
|
||||
"ugreen_password": "Password",
|
||||
"ugreen_verify_ssl": "Verify SSL certificate"
|
||||
}
|
||||
},
|
||||
"ugreen_select": {
|
||||
"title": "UGREEN album",
|
||||
"description": "Pick the UGOS Photos album to show.",
|
||||
"data": {
|
||||
"ugreen_album_uuid": "Album"
|
||||
}
|
||||
},
|
||||
"nextcloud": {
|
||||
"title": "Nextcloud",
|
||||
"description": "Choose how to connect: an authenticated WebDAV folder in your Nextcloud files, or a public Nextcloud Photos album share link.",
|
||||
@@ -173,7 +190,9 @@
|
||||
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
|
||||
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
|
||||
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #.",
|
||||
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
|
||||
"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected.",
|
||||
"ugreen_cannot_connect": "Could not connect to the UGREEN NAS. Check the URL, username and password. If the NAS uses a self-signed certificate, turn off Verify SSL certificate.",
|
||||
"ugreen_no_albums": "No albums were found on this UGREEN NAS."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* tap_action: none # none | more-info
|
||||
*/
|
||||
|
||||
const VERSION = "1.13.0";
|
||||
const VERSION = "1.14.0";
|
||||
|
||||
const ANIMATED_TRANSITIONS = [
|
||||
"fade",
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["lovelace", "http", "frontend", "persistent_notification"],
|
||||
"documentation": "https://github.com/3dg1luk43/ha_creality_ws",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/3dg1luk43/ha_creality_ws/issues",
|
||||
"loggers": ["custom_components.ha_creality_ws"],
|
||||
"loggers": ["websockets", "go2rtc_client"],
|
||||
"requirements": [
|
||||
"websockets>=10.4",
|
||||
"go2rtc-client>=0.1.0"
|
||||
],
|
||||
"version": "0.9.8",
|
||||
"version": "0.9.9",
|
||||
"zeroconf": [
|
||||
{ "type": "_http._tcp.local.", "name": "*creality*" },
|
||||
{ "type": "_workstation._tcp.local.", "name": "*creality*" },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to Creality Printer",
|
||||
@@ -8,12 +9,14 @@
|
||||
"host": "IP Address",
|
||||
"name": "Friendly Name"
|
||||
}
|
||||
},
|
||||
"zeroconf_confirm": {
|
||||
"title": "Add discovered printer",
|
||||
"description": "Add the Creality printer {name} at {host}?"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to printer. Ensure IP is correct and printer is ON.",
|
||||
"not_K": "Connection refused or not a supported Creality K-series printer.",
|
||||
"already_configured": "This printer is already configured."
|
||||
"cannot_connect": "Failed to connect to printer. Ensure IP is correct and printer is ON."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
@@ -34,6 +37,7 @@
|
||||
"status_starting": "Starting",
|
||||
"status_paused": "Paused",
|
||||
"status_finishing": "Finishing",
|
||||
"status_printing": "Printing",
|
||||
"body_progress": "{progress}%",
|
||||
"body_paused": "Paused at {progress}%",
|
||||
"body_paused_unknown": "Paused",
|
||||
@@ -48,12 +52,60 @@
|
||||
"stopped": "{device} stopped at {progress}%",
|
||||
"completed_detailed": "{device} finished. {filament} consumed.",
|
||||
"error": "{device} error - {key} [{code}]",
|
||||
"filament_runout": "{device} filament ran out. Print is {state}. Change the filament.",
|
||||
"finishing_soon": "{device} will finish in {minutes} minutes."
|
||||
"filament_runout": "{device} filament ran out. Print status: {state}. Change the filament.",
|
||||
"finishing_soon": "{device} will finish in {minutes} min.",
|
||||
"cfs_info_title": "CFS information request",
|
||||
"cfs_info_failed": "Could not reach {printers}.",
|
||||
"cfs_material_failed_title": "CFS material not changed",
|
||||
"cfs_material_failed": "{printer} is not connected, so box {box} slot {slot} was not changed.",
|
||||
"diagnostic_title": "Creality diagnostic data",
|
||||
"diagnostic_collected": "Diagnostic data was collected (printers: {printers}). It is in this action's response and in the Home Assistant log."
|
||||
},
|
||||
"exceptions": {
|
||||
"unsupported_ha_version": {
|
||||
"message": "This version of the Creality WebSocket integration needs Home Assistant {minimum} or newer, but this system is running {running}. Update Home Assistant, or install integration version 0.9.7 instead."
|
||||
},
|
||||
"cfs_material_write_failed": {
|
||||
"message": "The CFS material was not changed on {printers}: the printer is not connected."
|
||||
},
|
||||
"cfs_material_needs_device": {
|
||||
"message": "Choose the printer to change. This action only writes to the printers you select, never to all of them."
|
||||
},
|
||||
"no_printer_matched": {
|
||||
"message": "None of the selected devices is a Creality printer set up in this integration."
|
||||
},
|
||||
"cfs_material_printer_busy": {
|
||||
"message": "{printer} is in the middle of a print, so its CFS material was not changed. Try again once the print is over."
|
||||
},
|
||||
"material_type_empty": {
|
||||
"message": "The material type must not be empty."
|
||||
},
|
||||
"material_not_a_number": {
|
||||
"message": "{field} must be a number, not {value}."
|
||||
},
|
||||
"material_temp_order": {
|
||||
"message": "The maximum temperature ({high}) must not be below the minimum temperature ({low})."
|
||||
},
|
||||
"material_pressure_range": {
|
||||
"message": "The pressure advance must be between 0 and 1, not {value}."
|
||||
},
|
||||
"material_colour_list": {
|
||||
"message": "The colour must be a hex value such as #ff8800, not a list of RGB values."
|
||||
},
|
||||
"material_colour_multi": {
|
||||
"message": "{value} holds several colours, and a slot can only store one."
|
||||
},
|
||||
"material_colour_invalid": {
|
||||
"message": "The colour must be six hex digits such as #ff8800, not {value}."
|
||||
},
|
||||
"printer_not_connected": {
|
||||
"message": "The printer is not connected, so the command was not sent. Check that it is switched on and reachable, then try again."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"missing_power_switch": {
|
||||
"title": "Power switch for {printer} not found",
|
||||
"description": "The power switch set for {printer}, `{entity_id}`, does not exist, so it was renamed or removed. The printer is connected to as if no power switch were set. To use one again, choose it under Settings > Devices & services > Creality WebSocket Integration > Configure > Power switch."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -173,7 +225,9 @@
|
||||
},
|
||||
"error": {
|
||||
"invalid_camera_url": "Enter a full URL including the scheme, e.g. http://192.168.1.50:8080/?action=stream or rtsp://192.168.1.50:554/stream.",
|
||||
"unknown_placeholder": "That is not a placeholder this integration can fill. Check the list in the description above, and mind the spelling."
|
||||
"unknown_placeholder": "That is not a placeholder this integration can fill. Check the list in the description above, and mind the spelling.",
|
||||
"cannot_connect": "Failed to connect to the printer at this address. Check the IP address and that the printer is switched on.",
|
||||
"host_in_use": "Another printer in this integration is already set up at this address."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -189,6 +243,7 @@
|
||||
"position_z": { "name": "Position Z" },
|
||||
"feedrate_pct": { "name": "Print Speed" },
|
||||
"flowrate_pct": { "name": "Flow Rate" },
|
||||
"real_time_speed": { "name": "Real-Time Speed" },
|
||||
"model_info": { "name": "Model" },
|
||||
"filament_status": {
|
||||
"name": "Filament Status",
|
||||
@@ -220,7 +275,10 @@
|
||||
"print_job_time": { "name": "Print Job Time" },
|
||||
"print_left_time": { "name": "Print Time Left" },
|
||||
"real_time_flow": { "name": "Real-Time Flow" },
|
||||
"current_object": { "name": "Current Object" },
|
||||
"current_object": {
|
||||
"name": "Current Object",
|
||||
"state": { "not_printing": "Not printing" }
|
||||
},
|
||||
"object_count": { "name": "Object Count" },
|
||||
"print_control": {
|
||||
"name": "Print Control",
|
||||
@@ -233,7 +291,28 @@
|
||||
"max_nozzle_temp": { "name": "Max Nozzle Temperature" },
|
||||
"max_bed_temp": { "name": "Max Bed Temperature" },
|
||||
"max_chamber_temp": { "name": "Max Chamber Temperature" },
|
||||
"active_filament_slot": { "name": "Active Filament Slot" },
|
||||
"active_filament_slot": {
|
||||
"name": "Active Filament Slot",
|
||||
"state": {
|
||||
"external": "External",
|
||||
"box_1_slot_1": "Box 1 Slot 1",
|
||||
"box_1_slot_2": "Box 1 Slot 2",
|
||||
"box_1_slot_3": "Box 1 Slot 3",
|
||||
"box_1_slot_4": "Box 1 Slot 4",
|
||||
"box_2_slot_1": "Box 2 Slot 1",
|
||||
"box_2_slot_2": "Box 2 Slot 2",
|
||||
"box_2_slot_3": "Box 2 Slot 3",
|
||||
"box_2_slot_4": "Box 2 Slot 4",
|
||||
"box_3_slot_1": "Box 3 Slot 1",
|
||||
"box_3_slot_2": "Box 3 Slot 2",
|
||||
"box_3_slot_3": "Box 3 Slot 3",
|
||||
"box_3_slot_4": "Box 3 Slot 4",
|
||||
"box_4_slot_1": "Box 4 Slot 1",
|
||||
"box_4_slot_2": "Box 4 Slot 2",
|
||||
"box_4_slot_3": "Box 4 Slot 3",
|
||||
"box_4_slot_4": "Box 4 Slot 4"
|
||||
}
|
||||
},
|
||||
"cfs_box_temp": { "name": "CFS Box {box_id} Temperature" },
|
||||
"cfs_box_humidity": { "name": "CFS Box {box_id} Humidity" },
|
||||
"cfs_slot_filament": { "name": "CFS Box {box_id} Slot {slot} Filament" },
|
||||
@@ -251,7 +330,8 @@
|
||||
"reconnect": { "name": "Reconnect" }
|
||||
},
|
||||
"number": {
|
||||
"print_tuning_pct": { "name": "Print Tuning" },
|
||||
"print_tuning_pct": { "name": "Speed Factor" },
|
||||
"flow_rate_pct": { "name": "Flow Factor" },
|
||||
"nozzle_target": { "name": "Nozzle Target" },
|
||||
"bed_target": { "name": "Bed Target" },
|
||||
"chamber_target": { "name": "Chamber Target" }
|
||||
@@ -274,11 +354,11 @@
|
||||
"services": {
|
||||
"diagnostic_dump": {
|
||||
"name": "Diagnostic Dump",
|
||||
"description": "Dump all WebSocket telemetry data from connected Creality printers to JSON format for troubleshooting and analysis.",
|
||||
"description": "Collect diagnostics for every Creality printer and return them as the response, for troubleshooting. Also written to the log.",
|
||||
"fields": {
|
||||
"include_sensitive_data": {
|
||||
"name": "Include Sensitive Data",
|
||||
"description": "Whether to include potentially sensitive data in the diagnostic output (currently unused, reserved for future use)"
|
||||
"description": "Show printer addresses and network names, notify targets and access tokens instead of hiding them. Leave this off for anything you share publicly."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to Creality Printer",
|
||||
@@ -8,12 +9,14 @@
|
||||
"host": "IP Address",
|
||||
"name": "Friendly Name"
|
||||
}
|
||||
},
|
||||
"zeroconf_confirm": {
|
||||
"title": "Add discovered printer",
|
||||
"description": "Add the Creality printer {name} at {host}?"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to printer. Ensure IP is correct and printer is ON.",
|
||||
"not_K": "Connection refused or not a supported Creality K-series printer.",
|
||||
"already_configured": "This printer is already configured."
|
||||
"cannot_connect": "Failed to connect to printer. Ensure IP is correct and printer is ON."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
@@ -34,6 +37,7 @@
|
||||
"status_starting": "Starting",
|
||||
"status_paused": "Paused",
|
||||
"status_finishing": "Finishing",
|
||||
"status_printing": "Printing",
|
||||
"body_progress": "{progress}%",
|
||||
"body_paused": "Paused at {progress}%",
|
||||
"body_paused_unknown": "Paused",
|
||||
@@ -48,12 +52,60 @@
|
||||
"stopped": "{device} stopped at {progress}%",
|
||||
"completed_detailed": "{device} finished. {filament} consumed.",
|
||||
"error": "{device} error - {key} [{code}]",
|
||||
"filament_runout": "{device} filament ran out. Print is {state}. Change the filament.",
|
||||
"finishing_soon": "{device} will finish in {minutes} minutes."
|
||||
"filament_runout": "{device} filament ran out. Print status: {state}. Change the filament.",
|
||||
"finishing_soon": "{device} will finish in {minutes} min.",
|
||||
"cfs_info_title": "CFS information request",
|
||||
"cfs_info_failed": "Could not reach {printers}.",
|
||||
"cfs_material_failed_title": "CFS material not changed",
|
||||
"cfs_material_failed": "{printer} is not connected, so box {box} slot {slot} was not changed.",
|
||||
"diagnostic_title": "Creality diagnostic data",
|
||||
"diagnostic_collected": "Diagnostic data was collected (printers: {printers}). It is in this action's response and in the Home Assistant log."
|
||||
},
|
||||
"exceptions": {
|
||||
"unsupported_ha_version": {
|
||||
"message": "This version of the Creality WebSocket integration needs Home Assistant {minimum} or newer, but this system is running {running}. Update Home Assistant, or install integration version 0.9.7 instead."
|
||||
},
|
||||
"cfs_material_write_failed": {
|
||||
"message": "The CFS material was not changed on {printers}: the printer is not connected."
|
||||
},
|
||||
"cfs_material_needs_device": {
|
||||
"message": "Choose the printer to change. This action only writes to the printers you select, never to all of them."
|
||||
},
|
||||
"no_printer_matched": {
|
||||
"message": "None of the selected devices is a Creality printer set up in this integration."
|
||||
},
|
||||
"cfs_material_printer_busy": {
|
||||
"message": "{printer} is in the middle of a print, so its CFS material was not changed. Try again once the print is over."
|
||||
},
|
||||
"material_type_empty": {
|
||||
"message": "The material type must not be empty."
|
||||
},
|
||||
"material_not_a_number": {
|
||||
"message": "{field} must be a number, not {value}."
|
||||
},
|
||||
"material_temp_order": {
|
||||
"message": "The maximum temperature ({high}) must not be below the minimum temperature ({low})."
|
||||
},
|
||||
"material_pressure_range": {
|
||||
"message": "The pressure advance must be between 0 and 1, not {value}."
|
||||
},
|
||||
"material_colour_list": {
|
||||
"message": "The colour must be a hex value such as #ff8800, not a list of RGB values."
|
||||
},
|
||||
"material_colour_multi": {
|
||||
"message": "{value} holds several colours, and a slot can only store one."
|
||||
},
|
||||
"material_colour_invalid": {
|
||||
"message": "The colour must be six hex digits such as #ff8800, not {value}."
|
||||
},
|
||||
"printer_not_connected": {
|
||||
"message": "The printer is not connected, so the command was not sent. Check that it is switched on and reachable, then try again."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"missing_power_switch": {
|
||||
"title": "Power switch for {printer} not found",
|
||||
"description": "The power switch set for {printer}, `{entity_id}`, does not exist, so it was renamed or removed. The printer is connected to as if no power switch were set. To use one again, choose it under Settings > Devices & services > Creality WebSocket Integration > Configure > Power switch."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -173,7 +225,9 @@
|
||||
},
|
||||
"error": {
|
||||
"invalid_camera_url": "Enter a full URL including the scheme, e.g. http://192.168.1.50:8080/?action=stream or rtsp://192.168.1.50:554/stream.",
|
||||
"unknown_placeholder": "That is not a placeholder this integration can fill. Check the list in the description above, and mind the spelling."
|
||||
"unknown_placeholder": "That is not a placeholder this integration can fill. Check the list in the description above, and mind the spelling.",
|
||||
"cannot_connect": "Failed to connect to the printer at this address. Check the IP address and that the printer is switched on.",
|
||||
"host_in_use": "Another printer in this integration is already set up at this address."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -189,6 +243,7 @@
|
||||
"position_z": { "name": "Position Z" },
|
||||
"feedrate_pct": { "name": "Print Speed" },
|
||||
"flowrate_pct": { "name": "Flow Rate" },
|
||||
"real_time_speed": { "name": "Real-Time Speed" },
|
||||
"model_info": { "name": "Model" },
|
||||
"filament_status": {
|
||||
"name": "Filament Status",
|
||||
@@ -220,7 +275,10 @@
|
||||
"print_job_time": { "name": "Print Job Time" },
|
||||
"print_left_time": { "name": "Print Time Left" },
|
||||
"real_time_flow": { "name": "Real-Time Flow" },
|
||||
"current_object": { "name": "Current Object" },
|
||||
"current_object": {
|
||||
"name": "Current Object",
|
||||
"state": { "not_printing": "Not printing" }
|
||||
},
|
||||
"object_count": { "name": "Object Count" },
|
||||
"print_control": {
|
||||
"name": "Print Control",
|
||||
@@ -233,7 +291,28 @@
|
||||
"max_nozzle_temp": { "name": "Max Nozzle Temperature" },
|
||||
"max_bed_temp": { "name": "Max Bed Temperature" },
|
||||
"max_chamber_temp": { "name": "Max Chamber Temperature" },
|
||||
"active_filament_slot": { "name": "Active Filament Slot" },
|
||||
"active_filament_slot": {
|
||||
"name": "Active Filament Slot",
|
||||
"state": {
|
||||
"external": "External",
|
||||
"box_1_slot_1": "Box 1 Slot 1",
|
||||
"box_1_slot_2": "Box 1 Slot 2",
|
||||
"box_1_slot_3": "Box 1 Slot 3",
|
||||
"box_1_slot_4": "Box 1 Slot 4",
|
||||
"box_2_slot_1": "Box 2 Slot 1",
|
||||
"box_2_slot_2": "Box 2 Slot 2",
|
||||
"box_2_slot_3": "Box 2 Slot 3",
|
||||
"box_2_slot_4": "Box 2 Slot 4",
|
||||
"box_3_slot_1": "Box 3 Slot 1",
|
||||
"box_3_slot_2": "Box 3 Slot 2",
|
||||
"box_3_slot_3": "Box 3 Slot 3",
|
||||
"box_3_slot_4": "Box 3 Slot 4",
|
||||
"box_4_slot_1": "Box 4 Slot 1",
|
||||
"box_4_slot_2": "Box 4 Slot 2",
|
||||
"box_4_slot_3": "Box 4 Slot 3",
|
||||
"box_4_slot_4": "Box 4 Slot 4"
|
||||
}
|
||||
},
|
||||
"cfs_box_temp": { "name": "CFS Box {box_id} Temperature" },
|
||||
"cfs_box_humidity": { "name": "CFS Box {box_id} Humidity" },
|
||||
"cfs_slot_filament": { "name": "CFS Box {box_id} Slot {slot} Filament" },
|
||||
@@ -251,7 +330,8 @@
|
||||
"reconnect": { "name": "Reconnect" }
|
||||
},
|
||||
"number": {
|
||||
"print_tuning_pct": { "name": "Print Tuning" },
|
||||
"print_tuning_pct": { "name": "Speed Factor" },
|
||||
"flow_rate_pct": { "name": "Flow Factor" },
|
||||
"nozzle_target": { "name": "Nozzle Target" },
|
||||
"bed_target": { "name": "Bed Target" },
|
||||
"chamber_target": { "name": "Chamber Target" }
|
||||
@@ -274,11 +354,11 @@
|
||||
"services": {
|
||||
"diagnostic_dump": {
|
||||
"name": "Diagnostic Dump",
|
||||
"description": "Dump all WebSocket telemetry data from connected Creality printers to JSON format for troubleshooting and analysis.",
|
||||
"description": "Collect diagnostics for every Creality printer and return them as the response, for troubleshooting. Also written to the log.",
|
||||
"fields": {
|
||||
"include_sensitive_data": {
|
||||
"name": "Include Sensitive Data",
|
||||
"description": "Whether to include potentially sensitive data in the diagnostic output (currently unused, reserved for future use)"
|
||||
"description": "Show printer addresses and network names, notify targets and access tokens instead of hiding them. Leave this off for anything you share publicly."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Conectar a la impresora Creality",
|
||||
@@ -8,12 +9,14 @@
|
||||
"host": "Dirección IP",
|
||||
"name": "Nombre descriptivo"
|
||||
}
|
||||
},
|
||||
"zeroconf_confirm": {
|
||||
"title": "Añadir impresora descubierta",
|
||||
"description": "¿Quieres añadir la impresora Creality {name} en {host}?"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Error al conectar con la impresora. Asegúrate de que la dirección IP sea correcta y de que la impresora esté encendida.",
|
||||
"not_K": "Conexión rechazada o no es una impresora Creality de la serie K compatible.",
|
||||
"already_configured": "Esta impresora ya está configurada."
|
||||
"cannot_connect": "Error al conectar con la impresora. Asegúrate de que la dirección IP sea correcta y de que la impresora esté encendida."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "El dispositivo ya está configurado",
|
||||
@@ -34,6 +37,7 @@
|
||||
"status_starting": "Iniciando",
|
||||
"status_paused": "En pausa",
|
||||
"status_finishing": "Finalizando",
|
||||
"status_printing": "Imprimiendo",
|
||||
"body_progress": "{progress}%",
|
||||
"body_paused": "En pausa al {progress}%",
|
||||
"body_paused_unknown": "En pausa",
|
||||
@@ -49,11 +53,59 @@
|
||||
"completed_detailed": "{device} ha terminado. Filamento consumido: {filament}.",
|
||||
"error": "Error de {device} - {key} [{code}]",
|
||||
"filament_runout": "{device} se ha quedado sin filamento. Estado de la impresión: {state}. Cambia el filamento.",
|
||||
"finishing_soon": "{device} terminará en {minutes} minutos."
|
||||
"finishing_soon": "{device} terminará en {minutes} min.",
|
||||
"cfs_info_title": "Solicitud de información del CFS",
|
||||
"cfs_info_failed": "No se ha podido contactar con {printers}.",
|
||||
"cfs_material_failed_title": "Material del CFS no cambiado",
|
||||
"cfs_material_failed": "{printer} no está conectada, así que no se ha cambiado la ranura {slot} de la caja {box}.",
|
||||
"diagnostic_title": "Datos de diagnóstico de Creality",
|
||||
"diagnostic_collected": "Se han recopilado los datos de diagnóstico (impresoras: {printers}). Están en la respuesta de esta acción y en el registro de Home Assistant."
|
||||
},
|
||||
"exceptions": {
|
||||
"unsupported_ha_version": {
|
||||
"message": "Esta versión de la integración Creality WebSocket necesita Home Assistant {minimum} o posterior, pero este sistema ejecuta la versión {running}. Actualiza Home Assistant o instala en su lugar la versión 0.9.7 de la integración."
|
||||
},
|
||||
"cfs_material_write_failed": {
|
||||
"message": "No se ha cambiado el material del CFS en {printers}: la impresora no está conectada."
|
||||
},
|
||||
"cfs_material_needs_device": {
|
||||
"message": "Elige la impresora que quieres cambiar. Esta acción solo escribe en las impresoras que selecciones, nunca en todas."
|
||||
},
|
||||
"no_printer_matched": {
|
||||
"message": "Ninguno de los dispositivos seleccionados es una impresora Creality configurada en esta integración."
|
||||
},
|
||||
"cfs_material_printer_busy": {
|
||||
"message": "{printer} está en mitad de una impresión, así que no se ha cambiado su material del CFS. Vuelve a intentarlo cuando termine la impresión."
|
||||
},
|
||||
"material_type_empty": {
|
||||
"message": "El tipo de material no puede estar vacío."
|
||||
},
|
||||
"material_not_a_number": {
|
||||
"message": "{field} debe ser un número, no {value}."
|
||||
},
|
||||
"material_temp_order": {
|
||||
"message": "La temperatura máxima ({high}) no puede ser inferior a la temperatura mínima ({low})."
|
||||
},
|
||||
"material_pressure_range": {
|
||||
"message": "El avance de presión debe estar entre 0 y 1, no {value}."
|
||||
},
|
||||
"material_colour_list": {
|
||||
"message": "El color debe ser un valor hexadecimal como #ff8800, no una lista de valores RGB."
|
||||
},
|
||||
"material_colour_multi": {
|
||||
"message": "{value} contiene varios colores, y una ranura solo puede guardar uno."
|
||||
},
|
||||
"material_colour_invalid": {
|
||||
"message": "El color debe tener seis dígitos hexadecimales, como #ff8800, no {value}."
|
||||
},
|
||||
"printer_not_connected": {
|
||||
"message": "La impresora no está conectada, así que no se ha enviado el comando. Comprueba que esté encendida y que se pueda acceder a ella, y vuelve a intentarlo."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"missing_power_switch": {
|
||||
"title": "No se encuentra el interruptor de encendido de {printer}",
|
||||
"description": "El interruptor de encendido configurado para {printer}, `{entity_id}`, no existe, así que se ha cambiado de nombre o se ha eliminado. La integración se conecta a la impresora como si no se hubiera configurado ninguno. Para volver a usar uno, elígelo en Ajustes > Dispositivos y servicios > Creality WebSocket Integration > Configurar > Interruptor de encendido."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -173,7 +225,9 @@
|
||||
},
|
||||
"error": {
|
||||
"invalid_camera_url": "Introduce una URL completa incluyendo el esquema, p. ej. http://192.168.1.50:8080/?action=stream o rtsp://192.168.1.50:554/stream.",
|
||||
"unknown_placeholder": "Ese no es un marcador de posición que esta integración pueda rellenar. Consulta la lista que aparece en la descripción de arriba y comprueba que esté bien escrito."
|
||||
"unknown_placeholder": "Ese no es un marcador de posición que esta integración pueda rellenar. Consulta la lista que aparece en la descripción de arriba y comprueba que esté bien escrito.",
|
||||
"cannot_connect": "No se ha podido conectar con la impresora en esta dirección. Comprueba la dirección IP y que la impresora esté encendida.",
|
||||
"host_in_use": "Otra impresora de esta integración ya está configurada en esta dirección."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -189,6 +243,7 @@
|
||||
"position_z": { "name": "Posición Z" },
|
||||
"feedrate_pct": { "name": "Velocidad de impresión" },
|
||||
"flowrate_pct": { "name": "Tasa de flujo" },
|
||||
"real_time_speed": { "name": "Velocidad en tiempo real" },
|
||||
"model_info": { "name": "Modelo" },
|
||||
"filament_status": {
|
||||
"name": "Estado del filamento",
|
||||
@@ -220,7 +275,10 @@
|
||||
"print_job_time": { "name": "Tiempo de trabajo de impresión" },
|
||||
"print_left_time": { "name": "Tiempo restante de impresión" },
|
||||
"real_time_flow": { "name": "Flujo en tiempo real" },
|
||||
"current_object": { "name": "Objeto actual" },
|
||||
"current_object": {
|
||||
"name": "Objeto actual",
|
||||
"state": { "not_printing": "Sin impresión" }
|
||||
},
|
||||
"object_count": { "name": "Cantidad de objetos" },
|
||||
"print_control": {
|
||||
"name": "Control de impresión",
|
||||
@@ -233,7 +291,28 @@
|
||||
"max_nozzle_temp": { "name": "Temperatura máxima de la boquilla" },
|
||||
"max_bed_temp": { "name": "Temperatura máxima de la cama" },
|
||||
"max_chamber_temp": { "name": "Temperatura máxima de la cámara" },
|
||||
"active_filament_slot": { "name": "Ranura de filamento activa" },
|
||||
"active_filament_slot": {
|
||||
"name": "Ranura de filamento activa",
|
||||
"state": {
|
||||
"external": "Externa",
|
||||
"box_1_slot_1": "Caja 1, ranura 1",
|
||||
"box_1_slot_2": "Caja 1, ranura 2",
|
||||
"box_1_slot_3": "Caja 1, ranura 3",
|
||||
"box_1_slot_4": "Caja 1, ranura 4",
|
||||
"box_2_slot_1": "Caja 2, ranura 1",
|
||||
"box_2_slot_2": "Caja 2, ranura 2",
|
||||
"box_2_slot_3": "Caja 2, ranura 3",
|
||||
"box_2_slot_4": "Caja 2, ranura 4",
|
||||
"box_3_slot_1": "Caja 3, ranura 1",
|
||||
"box_3_slot_2": "Caja 3, ranura 2",
|
||||
"box_3_slot_3": "Caja 3, ranura 3",
|
||||
"box_3_slot_4": "Caja 3, ranura 4",
|
||||
"box_4_slot_1": "Caja 4, ranura 1",
|
||||
"box_4_slot_2": "Caja 4, ranura 2",
|
||||
"box_4_slot_3": "Caja 4, ranura 3",
|
||||
"box_4_slot_4": "Caja 4, ranura 4"
|
||||
}
|
||||
},
|
||||
"cfs_box_temp": { "name": "Temperatura de la caja CFS {box_id}" },
|
||||
"cfs_box_humidity": { "name": "Humedad de la caja CFS {box_id}" },
|
||||
"cfs_slot_filament": { "name": "Filamento (caja CFS {box_id}, ranura {slot})" },
|
||||
@@ -251,7 +330,8 @@
|
||||
"reconnect": { "name": "Reconectar" }
|
||||
},
|
||||
"number": {
|
||||
"print_tuning_pct": { "name": "Ajuste de impresión" },
|
||||
"print_tuning_pct": { "name": "Factor de velocidad" },
|
||||
"flow_rate_pct": { "name": "Factor de flujo" },
|
||||
"nozzle_target": { "name": "Objetivo de la boquilla" },
|
||||
"bed_target": { "name": "Objetivo de la cama" },
|
||||
"chamber_target": { "name": "Objetivo de la cámara" }
|
||||
@@ -274,11 +354,11 @@
|
||||
"services": {
|
||||
"diagnostic_dump": {
|
||||
"name": "Volcado de diagnóstico",
|
||||
"description": "Vuelca todos los datos de telemetría WebSocket de las impresoras Creality conectadas en formato JSON para resolución de problemas y análisis.",
|
||||
"description": "Recopila los diagnósticos de todas las impresoras Creality y los devuelve como respuesta, para resolver problemas. También se escriben en el registro.",
|
||||
"fields": {
|
||||
"include_sensitive_data": {
|
||||
"name": "Incluir datos sensibles",
|
||||
"description": "Si se deben incluir datos potencialmente sensibles en la salida de diagnóstico (actualmente sin uso, reservado para uso futuro)"
|
||||
"description": "Muestra las direcciones y los nombres de red de las impresoras, los destinos de notificación y los tokens de acceso en lugar de ocultarlos. Déjalo desactivado para todo lo que compartas públicamente."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"printer_card": {
|
||||
"default_name": "3D Printer",
|
||||
"picker_name": "Creality Printer Card",
|
||||
"picker_description": "Standalone card for Creality K-Series printers",
|
||||
"status_unknown": "Unknown",
|
||||
"confirm_stop": "Are you sure you want to stop the print?",
|
||||
"confirm_power_off": "Are you sure you want to power off the printer?",
|
||||
@@ -77,6 +80,7 @@
|
||||
"label_custom_btn": "Custom Action Entity",
|
||||
"label_custom_btn_icon": "Custom Button Icon",
|
||||
"label_custom_btn_hidden": "Hide Custom Button",
|
||||
"label_hidden_buttons": "Hidden Buttons",
|
||||
"label_button_order": "Button Order (list)",
|
||||
"label_hide_box_temp": "Hide Chamber Temperature",
|
||||
"label_pause_btn_icon": "Pause Icon Override",
|
||||
@@ -104,6 +108,7 @@
|
||||
"helper_custom_btn": "Any entity to trigger (Button, Script, Switch, etc.)",
|
||||
"helper_custom_btn_icon": "Icon for the custom button",
|
||||
"helper_custom_btn_hidden": "Hide the custom button",
|
||||
"helper_hidden_buttons": "Never shown on the card, whatever the printer is doing",
|
||||
"helper_button_order": "List of buttons to show in order (pause, resume, stop, light, power, custom)",
|
||||
"helper_hide_box_temp": "Hide the chamber temperature pill even when a sensor is configured",
|
||||
"editor_error_title": "Editor Error",
|
||||
@@ -111,9 +116,14 @@
|
||||
"editor_error_prefix": "Error:"
|
||||
},
|
||||
"cfs_card": {
|
||||
"picker_name": "Creality CFS Card",
|
||||
"label_device": "Printer",
|
||||
"btn_fill_from_device": "Fill all fields from the printer",
|
||||
"status_device_filled": "Filled {filled} of {total} fields.",
|
||||
"status_device_empty": "This printer has no CFS sensors yet.",
|
||||
"picker_description": "A card to control the Creality Filament System (CFS)",
|
||||
"no_data": "No CFS data available",
|
||||
"ext_label": "EXT",
|
||||
"cfs_label": "CFS",
|
||||
"cfs_number_label": "CFS {number}",
|
||||
"label_card_title": "Card Title",
|
||||
"label_external_filament": "External Filament",
|
||||
@@ -124,7 +134,6 @@
|
||||
"label_slot_filament": "Box {box} Slot {slot} Filament",
|
||||
"label_slot_color": "Box {box} Slot {slot} Color",
|
||||
"label_slot_percent": "Box {box} Slot {slot} Remaining Percent",
|
||||
"schema_compact_view": "Compact View (Mini Mode)",
|
||||
"schema_show_type_in_mini": "Show Filament Type in Mini Mode",
|
||||
"tab_entities": "Entities",
|
||||
"tab_theme": "Theme",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"printer_card": {
|
||||
"default_name": "Impresora 3D",
|
||||
"picker_name": "Tarjeta de impresora Creality",
|
||||
"picker_description": "Tarjeta independiente para impresoras Creality de la serie K",
|
||||
"status_unknown": "Desconocido",
|
||||
"confirm_stop": "¿Seguro que deseas detener la impresión?",
|
||||
"confirm_power_off": "¿Seguro que deseas apagar la impresora?",
|
||||
@@ -77,6 +80,7 @@
|
||||
"label_custom_btn": "Entidad de acción personalizada",
|
||||
"label_custom_btn_icon": "Icono del botón personalizado",
|
||||
"label_custom_btn_hidden": "Ocultar el botón personalizado",
|
||||
"label_hidden_buttons": "Botones ocultos",
|
||||
"label_button_order": "Orden de los botones (lista)",
|
||||
"label_hide_box_temp": "Ocultar la temperatura de la cámara",
|
||||
"label_pause_btn_icon": "Icono de pausa (sustitución)",
|
||||
@@ -104,6 +108,7 @@
|
||||
"helper_custom_btn": "Cualquier entidad a activar (botón, script, interruptor, etc.)",
|
||||
"helper_custom_btn_icon": "Icono del botón personalizado",
|
||||
"helper_custom_btn_hidden": "Ocultar el botón personalizado",
|
||||
"helper_hidden_buttons": "Nunca se muestran en la tarjeta, sea cual sea el estado de la impresora",
|
||||
"helper_button_order": "Lista de botones en orden (pause, resume, stop, light, power, custom)",
|
||||
"helper_hide_box_temp": "Ocultar el indicador de temperatura de la cámara aunque haya un sensor configurado",
|
||||
"editor_error_title": "Error del editor",
|
||||
@@ -111,9 +116,14 @@
|
||||
"editor_error_prefix": "Error:"
|
||||
},
|
||||
"cfs_card": {
|
||||
"picker_name": "Tarjeta CFS de Creality",
|
||||
"picker_description": "Tarjeta para controlar el Creality Filament System (CFS)",
|
||||
"label_device": "Impresora",
|
||||
"btn_fill_from_device": "Rellenar todos los campos desde la impresora",
|
||||
"status_device_filled": "Se han rellenado {filled} de {total} campos.",
|
||||
"status_device_empty": "Esta impresora aún no tiene sensores del CFS.",
|
||||
"no_data": "No hay datos del CFS disponibles",
|
||||
"ext_label": "EXT",
|
||||
"cfs_label": "CFS",
|
||||
"cfs_number_label": "CFS {number}",
|
||||
"label_card_title": "Título de la tarjeta",
|
||||
"label_external_filament": "Filamento externo",
|
||||
@@ -124,7 +134,6 @@
|
||||
"label_slot_filament": "Filamento (caja {box}, ranura {slot})",
|
||||
"label_slot_color": "Color (caja {box}, ranura {slot})",
|
||||
"label_slot_percent": "Restante (caja {box}, ranura {slot})",
|
||||
"schema_compact_view": "Vista compacta (modo mini)",
|
||||
"schema_show_type_in_mini": "Mostrar el tipo de filamento en modo mini",
|
||||
"tab_entities": "Entidades",
|
||||
"tab_theme": "Tema",
|
||||
|
||||
@@ -6,20 +6,28 @@ const mdi = (name) => `mdi:${name}`;
|
||||
|
||||
const ASSET_URL_BASE = "/ha_creality_ws/";
|
||||
const I18N_URL_BASE = `${ASSET_URL_BASE}i18n/`;
|
||||
const _i18nData = {};
|
||||
const _i18nPromises = {};
|
||||
// One cache for both cards, on the page: each module used to fetch en.json for
|
||||
// itself, and a language with no file (a 404) was asked for again by every new
|
||||
// card. A 404 is now remembered; only a failed request is retried. `no-cache`
|
||||
// revalidates, so an updated translation is not served stale (R45).
|
||||
const _i18nShared = (globalThis.__haCrealityWsI18n = globalThis.__haCrealityWsI18n || { data: {}, promises: {} });
|
||||
const _i18nData = _i18nShared.data;
|
||||
function _loadI18n(lang) {
|
||||
if (_i18nData[lang]) return Promise.resolve(_i18nData[lang]);
|
||||
if (_i18nPromises[lang]) return _i18nPromises[lang];
|
||||
_i18nPromises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
if (lang in _i18nData) return Promise.resolve(_i18nData[lang]);
|
||||
const promises = _i18nShared.promises;
|
||||
if (promises[lang]) return promises[lang];
|
||||
promises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`, { cache: "no-cache" })
|
||||
.then((res) => {
|
||||
if (res.ok) return res.json();
|
||||
if (res.status === 404) return null;
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
})
|
||||
.then((data) => {
|
||||
if (data) _i18nData[lang] = data;
|
||||
else _i18nPromises[lang] = null;
|
||||
_i18nData[lang] = data;
|
||||
return data;
|
||||
})
|
||||
.catch(() => { _i18nPromises[lang] = null; return null; });
|
||||
return _i18nPromises[lang];
|
||||
.catch(() => { promises[lang] = null; return null; });
|
||||
return promises[lang];
|
||||
}
|
||||
function _resolveLang(hass) {
|
||||
return hass?.locale?.language || hass?.language || "en";
|
||||
@@ -33,9 +41,12 @@ function _translate(hass, section, fallbackDict, key, vars) {
|
||||
: (remoteEn && key in remoteEn) ? remoteEn[key]
|
||||
: (fallbackDict[lang]?.[key] ?? fallbackDict[short]?.[key] ?? fallbackDict["en"]?.[key] ?? key);
|
||||
if (vars) {
|
||||
Object.entries(vars).forEach(([k, v]) => {
|
||||
text = text.replace(new RegExp(`\\{${k}\\}`, "g"), v);
|
||||
});
|
||||
// One pass with a function: a replacement *string* expands `$&` and
|
||||
// friends, so a preset named "Teal $& Co" toasted as "Teal {name} Co", and
|
||||
// a value containing "{other}" was substituted again by a later key.
|
||||
text = text.replace(/\{(\w+)\}/g, (match, name) => (
|
||||
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
|
||||
));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -153,15 +164,6 @@ class ColourPresetsManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
rename(from, to) {
|
||||
const target = String(to || "").trim();
|
||||
if (!target || !(from in this.presets) || target === from) return false;
|
||||
this.presets[target] = this.presets[from];
|
||||
delete this.presets[from];
|
||||
this._persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
remove(name) {
|
||||
if (!(name in this.presets)) return false;
|
||||
delete this.presets[name];
|
||||
@@ -219,9 +221,14 @@ const BUSY_PRINT_STATES = new Set([
|
||||
|
||||
const CFS_TRANSLATIONS = {
|
||||
en: {
|
||||
picker_name: "Creality CFS Card",
|
||||
label_device: "Printer",
|
||||
btn_fill_from_device: "Fill all fields from the printer",
|
||||
status_device_filled: "Filled {filled} of {total} fields.",
|
||||
status_device_empty: "This printer has no CFS sensors yet.",
|
||||
picker_description: "A card to control the Creality Filament System (CFS)",
|
||||
no_data: "No CFS data available",
|
||||
ext_label: "EXT",
|
||||
cfs_label: "CFS",
|
||||
cfs_number_label: "CFS {number}",
|
||||
// Editor
|
||||
label_card_title: "Card Title",
|
||||
@@ -290,6 +297,19 @@ class KCFSCard extends HTMLElement {
|
||||
* used to prefill it like a real value -- so changing only the material type
|
||||
* and saving wrote `#cccccc` to the spool as though the printer had said so.
|
||||
*/
|
||||
/**
|
||||
* The external spool's label: its name, plus the material type only when
|
||||
* the name does not already say it. The filament sensor's state is already
|
||||
* "Generic PLA", so appending the type read "Generic PLA PLA" (#115).
|
||||
*/
|
||||
static _nameWithType(name, type) {
|
||||
const n = String(name ?? "").trim();
|
||||
const t = String(type ?? "").trim();
|
||||
if (!t || t === "-") return n;
|
||||
const words = n.toLowerCase().split(/\s+/);
|
||||
return words.includes(t.toLowerCase()) ? n : `${n} ${t}`;
|
||||
}
|
||||
|
||||
static _parseColor(value) {
|
||||
if (isSentinel(value)) return null;
|
||||
const raw = String(value).trim();
|
||||
@@ -411,7 +431,25 @@ class KCFSCard extends HTMLElement {
|
||||
return '#f44336'; // Red (60-100%) - Critical
|
||||
}
|
||||
|
||||
static getStubConfig() {
|
||||
/**
|
||||
* What a new card starts with: the first printer that has CFS sensors, else
|
||||
* nothing. Only what differs from the defaults goes into the dashboard
|
||||
* (R44); every default used to be written there.
|
||||
*/
|
||||
static getStubConfig(hass) {
|
||||
const registry = hass?.entities || {};
|
||||
const devices = [...new Set(Object.values(registry)
|
||||
.filter((e) => e?.platform === "ha_creality_ws" && e.device_id)
|
||||
.map((e) => e.device_id))];
|
||||
for (const deviceId of devices) {
|
||||
const found = cfsEntitiesForDevice(hass, deviceId);
|
||||
if (Object.keys(found).length) return { device: deviceId, ...found };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Every option with its default value. */
|
||||
static defaultConfig() {
|
||||
const cfg = {
|
||||
name: "CFS",
|
||||
view_mode: "full",
|
||||
@@ -439,7 +477,7 @@ class KCFSCard extends HTMLElement {
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
this._cfg = { ...KCFSCard.getStubConfig(), ...KCFSCard._migrateConfig(config) };
|
||||
this._cfg = { ...KCFSCard.defaultConfig(), ...KCFSCard._migrateConfig(config) };
|
||||
if (!this._root) {
|
||||
this._root = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
@@ -460,9 +498,6 @@ class KCFSCard extends HTMLElement {
|
||||
}
|
||||
|
||||
// i18n helpers -------------------------------------------------------
|
||||
_resolveLanguage() {
|
||||
return _resolveLang(this._hass);
|
||||
}
|
||||
_t(key, vars) {
|
||||
return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
|
||||
}
|
||||
@@ -741,6 +776,10 @@ class KCFSCard extends HTMLElement {
|
||||
50% { opacity: 0.7; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-badge { animation: none; }
|
||||
}
|
||||
|
||||
/* === COMPACT MODE === */
|
||||
.compact-mode {
|
||||
padding: 14px;
|
||||
@@ -858,14 +897,16 @@ class KCFSCard extends HTMLElement {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.env-mini .temp {
|
||||
color: #ffb74d;
|
||||
/* The hue says warm, dry or damp; the theme's text colour carries the
|
||||
contrast. The bare #ffb74d was 1.73:1 on a light theme (R42). */
|
||||
.env-mini .temp, .env-temp {
|
||||
color: color-mix(in srgb, #ffb74d 45%, var(--primary-text-color));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.env-mini .hum {
|
||||
.env-mini .hum, .env-hum {
|
||||
color: color-mix(in srgb, var(--hum-color, #64b5f6) 45%, var(--primary-text-color));
|
||||
font-weight: 600;
|
||||
/* Cor aplicada dinamicamente via inline style */
|
||||
}
|
||||
|
||||
/* === EXTERNAL SECTION === */
|
||||
@@ -1079,7 +1120,17 @@ class KCFSCard extends HTMLElement {
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
/* Undo the <dialog> defaults: the element is the full-screen backdrop. */
|
||||
border: none;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
}
|
||||
.edit-overlay::backdrop { background: transparent; }
|
||||
.edit-dialog {
|
||||
width: min(420px, 100%);
|
||||
max-height: 85vh;
|
||||
@@ -1293,8 +1344,14 @@ class KCFSCard extends HTMLElement {
|
||||
const name = filamentObj?.state;
|
||||
const type = filamentObj?.attributes?.type;
|
||||
const selected = filamentObj?.attributes?.selected;
|
||||
const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex;
|
||||
const parsedColor = KCFSCard._parseColor(rawColor);
|
||||
// Parsed in turn, not `||`: "unknown"/"unavailable" are truthy, so a
|
||||
// colour sensor reading either hid the slot's color_hex attribute and
|
||||
// the spool went grey (R28).
|
||||
const rawColor = isSentinel(colorObj?.state)
|
||||
? filamentObj?.attributes?.color_hex
|
||||
: colorObj.state;
|
||||
const parsedColor = KCFSCard._parseColor(rawColor)
|
||||
?? KCFSCard._parseColor(filamentObj?.attributes?.color_hex);
|
||||
const color = parsedColor ?? "#cccccc";
|
||||
const percent = KCFSCard._parsePercent(percentObj);
|
||||
const percentText = fmtState(percentObj);
|
||||
@@ -1361,8 +1418,11 @@ class KCFSCard extends HTMLElement {
|
||||
const name = filamentObj?.state;
|
||||
const type = filamentObj?.attributes?.type;
|
||||
const selected = filamentObj?.attributes?.selected;
|
||||
const rawColor = colorObj?.state || filamentObj?.attributes?.color_hex;
|
||||
const parsedColor = KCFSCard._parseColor(rawColor);
|
||||
const rawColor = isSentinel(colorObj?.state)
|
||||
? filamentObj?.attributes?.color_hex
|
||||
: colorObj.state;
|
||||
const parsedColor = KCFSCard._parseColor(rawColor)
|
||||
?? KCFSCard._parseColor(filamentObj?.attributes?.color_hex);
|
||||
const color = parsedColor ?? "#cccccc";
|
||||
const percent = KCFSCard._parsePercent(percentObj);
|
||||
const percentText = fmtState(percentObj);
|
||||
@@ -1523,7 +1583,7 @@ class KCFSCard extends HTMLElement {
|
||||
const env = [];
|
||||
if (box?.temp && box.temp !== "-") env.push(`<span class="env-temp">${esc(box.temp)}</span>`);
|
||||
if (box?.humidity && box.humidity !== "-") {
|
||||
env.push(`<span class="env-hum" style="color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(box.humidity)}</span>`);
|
||||
env.push(`<span class="env-hum" style="--hum-color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(box.humidity)}</span>`);
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -1571,7 +1631,7 @@ class KCFSCard extends HTMLElement {
|
||||
|
||||
if (tempStr || humStr) {
|
||||
const tempHtml = tempStr ? `<span class="env-temp">${esc(tempStr)}</span>` : '';
|
||||
const humHtml = humStr ? `<span class="env-hum" style="color: ${KCFSCard._sanitizeColor(selectedBox.humidityColor)}">${esc(humStr)}</span>` : '';
|
||||
const humHtml = humStr ? `<span class="env-hum" style="--hum-color: ${KCFSCard._sanitizeColor(selectedBox.humidityColor)}">${esc(humStr)}</span>` : '';
|
||||
const separator = tempStr && humStr ? ' <span style="color: var(--divider-color)">•</span> ' : '';
|
||||
envInfo = `<div class="env-info">${tempHtml}${separator}${humHtml}</div>`;
|
||||
}
|
||||
@@ -1604,7 +1664,7 @@ class KCFSCard extends HTMLElement {
|
||||
const hasFilament = safeType !== "-" && safeName !== "-";
|
||||
const pct = hasFilament && external.percent !== null ? external.percent : 0;
|
||||
const percentTextDisplay = hasFilament ? (external.percentText || '-') : '-';
|
||||
const displayName = hasFilament ? `${safeName} ${safeType}` : '-';
|
||||
const displayName = hasFilament ? KCFSCard._nameWithType(safeName, safeType) : '-';
|
||||
externalSection = `
|
||||
<div class="external-section">
|
||||
<div class="external-normal" data-eid="${esc(external.entity_id)}">
|
||||
@@ -1658,7 +1718,7 @@ class KCFSCard extends HTMLElement {
|
||||
const safeName = !isSentinel(external.name) ? String(external.name).trim() : "-";
|
||||
const hasFilament = safeType !== "-" && safeName !== "-";
|
||||
const percentTextDisplay = hasFilament ? (external.percentText || '-') : '-';
|
||||
const displayName = hasFilament ? `${safeName} ${safeType}` : '-';
|
||||
const displayName = hasFilament ? KCFSCard._nameWithType(safeName, safeType) : '-';
|
||||
return `
|
||||
<div class="external-section">
|
||||
<div class="external-compact" data-eid="${esc(external.entity_id)}">
|
||||
@@ -1682,7 +1742,7 @@ class KCFSCard extends HTMLElement {
|
||||
envHtml = `
|
||||
<div class="env-mini">
|
||||
${tempStr ? `<div class="temp">${esc(tempStr)}</div>` : ''}
|
||||
${humStr ? `<div class="hum" style="color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(humStr)}</div>` : ''}
|
||||
${humStr ? `<div class="hum" style="--hum-color: ${KCFSCard._sanitizeColor(box.humidityColor)}">${esc(humStr)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1823,8 +1883,7 @@ class KCFSCard extends HTMLElement {
|
||||
* rather than silently resolved to one of them.
|
||||
*
|
||||
* Reads hass.entities (EntityRegistryDisplayEntry carries device_id, platform
|
||||
* and translation_key), so no WebSocket round trip and no admin permission is
|
||||
* needed -- unlike config/entity_registry/list.
|
||||
* and translation_key), so no WebSocket round trip is needed.
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async _resolveDeviceId() {
|
||||
@@ -1844,9 +1903,8 @@ class KCFSCard extends HTMLElement {
|
||||
}
|
||||
|
||||
// Anything hass.entities could not answer for is asked individually. It is
|
||||
// not a version fallback: hass.entities can be *partially* populated, and it
|
||||
// is absent entirely for a non-admin user, whose browser is not allowed
|
||||
// config/entity_registry/get either.
|
||||
// not a version fallback: hass.entities can be *partially* populated, for
|
||||
// one while the frontend is still loading it.
|
||||
//
|
||||
// Every unresolved entity is asked, not just enough to find one device.
|
||||
// Accepting the first answer resolved a card spanning two printers to
|
||||
@@ -1866,9 +1924,9 @@ class KCFSCard extends HTMLElement {
|
||||
});
|
||||
return { deviceId: entry?.device_id || null, failed: false };
|
||||
} catch (_) {
|
||||
// config/entity_registry/get is admin-only, so for a non-admin
|
||||
// dashboard user every one of these fails. Treating that as "this
|
||||
// entity has no device" is what let a two-printer card resolve to
|
||||
// A failed lookup (an entity missing from the registry, a dropped
|
||||
// connection) is not "this entity has no device". Treating it so
|
||||
// is what let a two-printer card resolve to
|
||||
// whichever printer *was* in hass.entities, and _saveMaterial then
|
||||
// sent the other printer's box and slot ids to it.
|
||||
return { deviceId: null, failed: true };
|
||||
@@ -1985,8 +2043,13 @@ class KCFSCard extends HTMLElement {
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "cfs-toast";
|
||||
// Announced by screen readers without taking focus (R42).
|
||||
toast.setAttribute("role", "status");
|
||||
toast.setAttribute("aria-live", "polite");
|
||||
toast.textContent = message;
|
||||
this._root.appendChild(toast);
|
||||
// Inside the open edit dialog when there is one: it is in the top layer,
|
||||
// and a toast on the card would be painted under it.
|
||||
(this._dialogEl || this._root).appendChild(toast);
|
||||
this._toastEl = toast;
|
||||
this._toastTimer = setTimeout(() => {
|
||||
if (toast.remove) toast.remove();
|
||||
@@ -2038,7 +2101,11 @@ class KCFSCard extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
// A native <dialog> opened with showModal() renders in the top layer. The
|
||||
// <div> overlay before it stayed inside this card's stacking context
|
||||
// (`:host { z-index: 1 }`), so a second CFS card further down the page was
|
||||
// painted over the bottom of the dialog (R46, seen in Chromium).
|
||||
const overlay = document.createElement("dialog");
|
||||
overlay.className = "edit-overlay";
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "edit-dialog";
|
||||
@@ -2049,11 +2116,16 @@ class KCFSCard extends HTMLElement {
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
dialog.setAttribute("aria-label", this._t("dialog_edit_title"));
|
||||
dialog.tabIndex = -1;
|
||||
overlay.appendChild(dialog);
|
||||
|
||||
// Back to whatever opened the dialog afterwards, so a keyboard user is not
|
||||
// dropped at the top of the page (R42).
|
||||
const opener = this._root.activeElement || null;
|
||||
const close = () => {
|
||||
overlay.removeEventListener("keydown", onKeyDown);
|
||||
if (overlay.close && overlay.open) overlay.close();
|
||||
if (overlay.remove) overlay.remove();
|
||||
if (this._dialogEl === overlay) this._dialogEl = null;
|
||||
if (opener && opener.focus) opener.focus();
|
||||
};
|
||||
const onKeyDown = (ev) => {
|
||||
if (ev.key === "Escape" || ev.key === "Esc") {
|
||||
@@ -2061,11 +2133,38 @@ class KCFSCard extends HTMLElement {
|
||||
close();
|
||||
}
|
||||
};
|
||||
// Tab stays inside the dialog: it is modal, and focus wandering to the
|
||||
// card behind it left a keyboard user editing an invisible page (R42).
|
||||
// Sentinels rather than counting fields, because ha-form keeps its inputs
|
||||
// in its own shadow root where the dialog cannot see which one has focus.
|
||||
const sentinel = (onFocus) => {
|
||||
const el = document.createElement("span");
|
||||
el.tabIndex = 0;
|
||||
el.className = "focus-sentinel";
|
||||
el.addEventListener("focus", onFocus);
|
||||
return el;
|
||||
};
|
||||
const before = sentinel(() => {
|
||||
const buttons = Array.from(dialog.querySelectorAll("button")).filter((btn) => !btn.disabled);
|
||||
const last = buttons[buttons.length - 1];
|
||||
if (last && last.focus) last.focus();
|
||||
});
|
||||
const after = sentinel(() => dialog.focus && dialog.focus());
|
||||
overlay.appendChild(before);
|
||||
overlay.appendChild(dialog);
|
||||
overlay.appendChild(after);
|
||||
overlay.addEventListener("keydown", onKeyDown);
|
||||
// Escape on a modal <dialog> arrives as `cancel`; close it our way.
|
||||
overlay.addEventListener("cancel", (ev) => {
|
||||
ev.preventDefault?.();
|
||||
close();
|
||||
});
|
||||
overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
|
||||
|
||||
dialog.appendChild(this._renderEditForm(slot, close));
|
||||
this._root.appendChild(overlay);
|
||||
this._dialogEl = overlay;
|
||||
if (overlay.showModal) overlay.showModal();
|
||||
// Focus the dialog itself rather than the first field: ha-form upgrades
|
||||
// asynchronously, so its inputs may not exist yet.
|
||||
if (dialog.focus) dialog.focus();
|
||||
@@ -2274,18 +2373,55 @@ class KCFSCard extends HTMLElement {
|
||||
swatch.title = name;
|
||||
swatch.setAttribute("aria-label", name);
|
||||
swatch.style.background = colour;
|
||||
swatch.addEventListener("click", () => apply(colour));
|
||||
let longPressed = false;
|
||||
swatch.addEventListener("click", () => {
|
||||
// The click that ends a long press is not a pick.
|
||||
if (longPressed) {
|
||||
longPressed = false;
|
||||
return;
|
||||
}
|
||||
apply(colour);
|
||||
});
|
||||
|
||||
if (this._presets.isCustom(name)) {
|
||||
swatch.classList?.add?.("custom");
|
||||
// Long-press-free management: a modifier click removes a custom preset,
|
||||
// which keeps the row compact without a second list.
|
||||
swatch.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault?.();
|
||||
const removePreset = () => {
|
||||
if (this._presets.remove(name)) {
|
||||
this._showToast(this._t("toast_preset_deleted", { name }));
|
||||
rebuild();
|
||||
}
|
||||
};
|
||||
// A right-click removes a custom preset, which keeps the row compact
|
||||
// without a second list.
|
||||
swatch.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault?.();
|
||||
removePreset();
|
||||
});
|
||||
// iOS Safari fires no contextmenu on a long press, so a preset could
|
||||
// not be removed there at all: a touch held for 600 ms does it, and
|
||||
// Delete does it from the keyboard (R46).
|
||||
let pressTimer = null;
|
||||
const cancelPress = () => {
|
||||
clearTimeout(pressTimer);
|
||||
pressTimer = null;
|
||||
};
|
||||
swatch.addEventListener("pointerdown", (ev) => {
|
||||
if (ev.pointerType === "mouse") return;
|
||||
cancelPress();
|
||||
pressTimer = setTimeout(() => {
|
||||
pressTimer = null;
|
||||
longPressed = true;
|
||||
removePreset();
|
||||
}, 600);
|
||||
});
|
||||
for (const type of ["pointerup", "pointercancel", "pointerleave"]) {
|
||||
swatch.addEventListener(type, cancelPress);
|
||||
}
|
||||
swatch.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Delete" || ev.key === "Backspace") {
|
||||
ev.preventDefault?.();
|
||||
removePreset();
|
||||
}
|
||||
});
|
||||
}
|
||||
swatches.appendChild(swatch);
|
||||
@@ -2469,30 +2605,20 @@ class KCFSCard extends HTMLElement {
|
||||
return 5;
|
||||
}
|
||||
|
||||
getLayoutOptions() {
|
||||
// Count configured boxes for dynamic sizing
|
||||
let boxCount = 0;
|
||||
if (this._cfg) {
|
||||
for (let box = 0; box < 4; box++) {
|
||||
const hasBox = this._cfg[`box${box}_temp`] || this._cfg[`box${box}_humidity`] ||
|
||||
[0, 1, 2, 3].some(s => this._cfg[`box${box}_slot${s}_filament`] || this._cfg[`box${box}_slot${s}_color`] || this._cfg[`box${box}_slot${s}_percent`]);
|
||||
if (hasBox) boxCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for external filament
|
||||
const hasExternal = this._cfg?.external_filament || this._cfg?.external_color || this._cfg?.external_percent;
|
||||
const externalRows = hasExternal ? 1 : 0;
|
||||
|
||||
// Add extra space when more than 2 rows
|
||||
const totalRows = boxCount + externalRows;
|
||||
const extraPadding = totalRows > 2 ? 1 : 0;
|
||||
|
||||
const minRows = this._cfg?.view_mode === "compact" ? Math.max(1, totalRows + extraPadding) : 5;
|
||||
|
||||
/**
|
||||
* Sections-view sizing: let the grid size the cell to the card.
|
||||
*
|
||||
* `getLayoutOptions` (deprecated) reserved a fixed number of rows, five for
|
||||
* the full view, and the card forces `height: auto` so its content is not
|
||||
* clipped (#71) -- together that drew the card over whatever sat below it,
|
||||
* by 176 px for a one-box CFS at phone width (R23). `rows: "auto"` makes the
|
||||
* reserved height the card's own.
|
||||
*/
|
||||
getGridOptions() {
|
||||
return {
|
||||
grid_rows: minRows,
|
||||
grid_min_rows: minRows,
|
||||
columns: 12,
|
||||
rows: "auto",
|
||||
min_columns: 6,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2521,11 +2647,76 @@ function defineOnce(tag, cls) {
|
||||
|
||||
defineOnce(CARD_TAG, KCFSCard);
|
||||
|
||||
/**
|
||||
* This integration's CFS sensors for one device, as a card config patch.
|
||||
*
|
||||
* CFS units take card positions 0-3 in the printer's box order; the external
|
||||
* spool holder is box 0 on the printer and goes to the external fields. Box and
|
||||
* slot come from the sensors' `box_id`/`slot_id` attributes, falling back to
|
||||
* the default entity id ("..._cfs_box_1_slot_2_filament", slot 1-based) for a
|
||||
* sensor with no reading yet. Without this, a CFS card meant filling up to 51
|
||||
* entity pickers by hand (R43).
|
||||
* @param {?Object} hass
|
||||
* @param {string} deviceId
|
||||
* @return {!Object<string, string>} Config key -> entity id.
|
||||
*/
|
||||
function cfsEntitiesForDevice(hass, deviceId) {
|
||||
const registry = hass?.entities || {};
|
||||
const states = hass?.states || {};
|
||||
const patch = {};
|
||||
if (!deviceId) return patch;
|
||||
const slots = [];
|
||||
const boxes = [];
|
||||
for (const [entityId, entry] of Object.entries(registry)) {
|
||||
if (!entry || entry.device_id !== deviceId) continue;
|
||||
if (entry.platform && entry.platform !== "ha_creality_ws") continue;
|
||||
const key = entry.translation_key || "";
|
||||
const attrs = states[entityId]?.attributes || {};
|
||||
let m = /^cfs_ext_(filament|color|percent)$/.exec(key);
|
||||
if (m) {
|
||||
patch[`external_${m[1]}`] = entityId;
|
||||
continue;
|
||||
}
|
||||
m = /^cfs_slot_(filament|color|percent)$/.exec(key);
|
||||
if (m) {
|
||||
let box = attrs.box_id;
|
||||
let slot = attrs.slot_id;
|
||||
if (box === undefined || box === null || slot === undefined || slot === null) {
|
||||
const id = /_cfs_box_(\d+)_slot_(\d+)_/.exec(entityId);
|
||||
if (!id) continue;
|
||||
box = Number(id[1]);
|
||||
slot = Number(id[2]) - 1;
|
||||
}
|
||||
// Box 0 is the external spool holder; an install from before R20 still
|
||||
// has "Box 0 Slot 1" sensors for it next to the External ones.
|
||||
if (Number(box) >= 1) slots.push({ box: Number(box), slot: Number(slot), kind: m[1], entityId });
|
||||
continue;
|
||||
}
|
||||
m = /^cfs_box_(temp|humidity)$/.exec(key);
|
||||
if (m) {
|
||||
let box = attrs.box_id;
|
||||
if (box === undefined || box === null) {
|
||||
const id = /_cfs_box_(\d+)_(temperature|humidity)/.exec(entityId);
|
||||
if (!id) continue;
|
||||
box = Number(id[1]);
|
||||
}
|
||||
if (Number(box) >= 1) boxes.push({ box: Number(box), kind: m[1], entityId });
|
||||
}
|
||||
}
|
||||
const order = [...new Set([...slots, ...boxes].map((e) => e.box))].sort((a, b) => a - b).slice(0, 4);
|
||||
for (const e of slots) {
|
||||
const pos = order.indexOf(e.box);
|
||||
if (pos >= 0 && e.slot >= 0 && e.slot < 4) patch[`box${pos}_slot${e.slot}_${e.kind}`] = e.entityId;
|
||||
}
|
||||
for (const e of boxes) {
|
||||
const pos = order.indexOf(e.box);
|
||||
if (pos >= 0) patch[`box${pos}_${e.kind}`] = e.entityId;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
class KCFSCardEditor extends HTMLElement {
|
||||
// i18n helpers -------------------------------------------------------
|
||||
_resolveLanguage() {
|
||||
return _resolveLang(this._hass);
|
||||
}
|
||||
_t(key, vars) {
|
||||
return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
|
||||
}
|
||||
@@ -2533,42 +2724,84 @@ class KCFSCardEditor extends HTMLElement {
|
||||
|
||||
set hass(hass) {
|
||||
this._hass = hass;
|
||||
if (this._form) this._form.hass = hass;
|
||||
_requestI18n(this, hass, () => { if (this._root) this._render(); });
|
||||
_requestI18n(this, hass, () => this._refresh(true));
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
this._cfg = { ...KCFSCard.getStubConfig(), ...KCFSCard._migrateConfig(config) };
|
||||
this._render();
|
||||
this._cfg = { ...KCFSCard.defaultConfig(), ...KCFSCard._migrateConfig(config) };
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._render();
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
_render() {
|
||||
/**
|
||||
* Build once, then only update.
|
||||
*
|
||||
* Lovelace answers every config-changed with a fresh setConfig. Rebuilding
|
||||
* the editor's DOM on each one, as this did, lost the focused field after
|
||||
* every keystroke and jumped back to the first tab (R22). Data is only
|
||||
* reassigned when it really differs from what the forms already show.
|
||||
*/
|
||||
_refresh(relabel = false) {
|
||||
if (!this._cfg) return;
|
||||
if (!this._root) {
|
||||
this._root = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
if (!this._form) {
|
||||
this._build();
|
||||
relabel = true;
|
||||
}
|
||||
this._form.hass = this._hass;
|
||||
this._themeForm.hass = this._hass;
|
||||
this._deviceForm.hass = this._hass;
|
||||
if (relabel) this._applyLabels();
|
||||
this._setFormData();
|
||||
}
|
||||
|
||||
_setFormData() {
|
||||
const key = JSON.stringify(this._cfg);
|
||||
if (key === this._shownKey) return;
|
||||
this._shownKey = key;
|
||||
this._form.data = this._cfg;
|
||||
this._themeForm.data = this._cfg;
|
||||
this._deviceForm.data = { device: this._cfg.device || "" };
|
||||
this._root.getElementById("refill").disabled = !this._cfg.device;
|
||||
}
|
||||
|
||||
_build() {
|
||||
const style = `
|
||||
.editor-container { padding: 16px; }
|
||||
.tabs { display: flex; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; }
|
||||
.tab { padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
.tab { padding: 8px 16px; cursor: pointer; border: none; border-bottom: 2px solid transparent; background: none; color: inherit; font: inherit; }
|
||||
.tab:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
|
||||
.tab.active { border-bottom-color: var(--primary-color); color: var(--primary-color); }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
.input-helper { font-size: 0.9em; color: var(--secondary-text-color); margin-top: 4px; padding: 0 8px; }
|
||||
.device-row { display: flex; align-items: center; gap: 12px; margin: 4px 0 16px; flex-wrap: wrap; }
|
||||
.ghost-btn { background: none; border: 1px solid var(--divider-color); border-radius: 6px; padding: 6px 12px;
|
||||
color: var(--primary-color); font: inherit; cursor: pointer; }
|
||||
.ghost-btn:disabled { color: var(--disabled-text-color); cursor: default; }
|
||||
.ghost-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
|
||||
.status { color: var(--secondary-text-color); font-size: 0.9em; }
|
||||
`;
|
||||
|
||||
this._root.innerHTML = `
|
||||
<style>${style}</style>
|
||||
<div class="editor-container">
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="entities">${this._t("tab_entities")}</div>
|
||||
<div class="tab" data-tab="theme">${this._t("tab_theme")}</div>
|
||||
<div class="tabs" role="tablist">
|
||||
<button type="button" class="tab active" role="tab" aria-selected="true" data-tab="entities" id="tab-entities"></button>
|
||||
<button type="button" class="tab" role="tab" aria-selected="false" data-tab="theme" id="tab-theme"></button>
|
||||
</div>
|
||||
<div class="tab-content active" id="entities-tab">
|
||||
<ha-form id="device-form"></ha-form>
|
||||
<div class="device-row">
|
||||
<button type="button" class="ghost-btn" id="refill"></button>
|
||||
<span class="status" id="refill-status" role="status"></span>
|
||||
</div>
|
||||
<ha-form id="form"></ha-form>
|
||||
</div>
|
||||
<div class="tab-content" id="theme-tab">
|
||||
@@ -2578,18 +2811,82 @@ class KCFSCardEditor extends HTMLElement {
|
||||
`;
|
||||
|
||||
this._setupTabs();
|
||||
this._setupDeviceForm();
|
||||
this._setupEntitiesForm();
|
||||
this._setupThemeForm();
|
||||
}
|
||||
|
||||
_setupDeviceForm() {
|
||||
this._deviceForm = this._root.getElementById("device-form");
|
||||
this._deviceForm.schema = [{
|
||||
name: "device",
|
||||
selector: { device: { filter: [{ integration: "ha_creality_ws" }] } },
|
||||
}];
|
||||
this._deviceForm.addEventListener("value-changed", (ev) => this._onDeviceChanged(ev.detail.value || {}));
|
||||
this._root.getElementById("refill").addEventListener("click", () => this._applyDeviceFill(true));
|
||||
}
|
||||
|
||||
_onDeviceChanged(value) {
|
||||
const deviceId = value.device || "";
|
||||
if (deviceId === (this._cfg.device || "")) return;
|
||||
this._cfg = { ...this._cfg, device: deviceId };
|
||||
if (deviceId) {
|
||||
// Picking a device fills what is still blank; the button replaces too.
|
||||
this._applyDeviceFill(false);
|
||||
return;
|
||||
}
|
||||
this._setRefillStatus("");
|
||||
this._setFormData();
|
||||
this._dispatchConfigChange();
|
||||
}
|
||||
|
||||
_applyDeviceFill(overwrite) {
|
||||
const found = cfsEntitiesForDevice(this._hass, this._cfg.device);
|
||||
const patch = {};
|
||||
for (const [key, entityId] of Object.entries(found)) {
|
||||
if (overwrite || !this._cfg[key]) patch[key] = entityId;
|
||||
}
|
||||
this._cfg = { ...this._cfg, ...patch };
|
||||
const total = Object.keys(found).length;
|
||||
this._setRefillStatus(total
|
||||
? this._t("status_device_filled", { filled: Object.keys(patch).length, total })
|
||||
: this._t("status_device_empty"));
|
||||
this._setFormData();
|
||||
this._dispatchConfigChange();
|
||||
}
|
||||
|
||||
_setRefillStatus(text) {
|
||||
const el = this._root?.getElementById("refill-status");
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
/** Everything that reads a translation, so a late language load relabels. */
|
||||
_applyLabels() {
|
||||
this._root.getElementById("tab-entities").textContent = this._t("tab_entities");
|
||||
this._root.getElementById("tab-theme").textContent = this._t("tab_theme");
|
||||
this._root.getElementById("refill").textContent = this._t("btn_fill_from_device");
|
||||
this._deviceForm.computeLabel = () => this._t("label_device");
|
||||
// New function objects, so ha-form re-renders its labels.
|
||||
this._form.computeLabel = (s) => this._entityLabel(s);
|
||||
this._themeForm.schema = this._themeSchema();
|
||||
this._themeForm.computeLabel = (s) => ({
|
||||
view_mode: this._t("schema_view_mode"),
|
||||
show_type_in_mini: this._t("schema_show_type_in_mini"),
|
||||
}[s.name] || s.name);
|
||||
}
|
||||
|
||||
_setupTabs() {
|
||||
const tabs = this._root.querySelectorAll(".tab");
|
||||
const contents = this._root.querySelectorAll(".tab-content");
|
||||
tabs.forEach((tab) => {
|
||||
tab.onclick = () => {
|
||||
tabs.forEach((t) => t.classList.remove("active"));
|
||||
tabs.forEach((t) => {
|
||||
t.classList.remove("active");
|
||||
t.setAttribute("aria-selected", "false");
|
||||
});
|
||||
contents.forEach((c) => c.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
tab.setAttribute("aria-selected", "true");
|
||||
this._root.getElementById(`${tab.dataset.tab}-tab`).classList.add("active");
|
||||
};
|
||||
});
|
||||
@@ -2597,8 +2894,6 @@ class KCFSCardEditor extends HTMLElement {
|
||||
|
||||
_setupEntitiesForm() {
|
||||
this._form = this._root.getElementById("form");
|
||||
this._form.hass = this._hass;
|
||||
this._form.data = this._cfg;
|
||||
const schema = [
|
||||
{ name: "name", selector: { text: {} } },
|
||||
{ name: "external_filament", selector: { entity: { domain: "sensor" } } },
|
||||
@@ -2617,7 +2912,15 @@ class KCFSCardEditor extends HTMLElement {
|
||||
}
|
||||
|
||||
this._form.schema = schema;
|
||||
this._form.computeLabel = (s) => {
|
||||
// Assigned unconditionally: ha-form leaves computeHelper undefined until
|
||||
// someone sets it, so guarding on it meant this never ran. Harmless here
|
||||
// only because ha-form's own default is "no helper" either way.
|
||||
this._form.computeHelper = () => "";
|
||||
|
||||
this._form.addEventListener("value-changed", (ev) => this._edited(ev.detail.value));
|
||||
}
|
||||
|
||||
_entityLabel(s) {
|
||||
if (s.name === "name") return this._t("label_card_title");
|
||||
if (s.name === "external_filament") return this._t("label_external_filament");
|
||||
if (s.name === "external_color") return this._t("label_external_color");
|
||||
@@ -2642,23 +2945,22 @@ class KCFSCardEditor extends HTMLElement {
|
||||
}
|
||||
|
||||
return s.name;
|
||||
};
|
||||
// Assigned unconditionally: ha-form leaves computeHelper undefined until
|
||||
// someone sets it, so guarding on it meant this never ran. Harmless here
|
||||
// only because ha-form's own default is "no helper" either way.
|
||||
this._form.computeHelper = () => "";
|
||||
|
||||
this._form.addEventListener("value-changed", (ev) => {
|
||||
this._cfg = { ...this._cfg, ...ev.detail.value };
|
||||
this._dispatchConfigChange();
|
||||
});
|
||||
}
|
||||
|
||||
_setupThemeForm() {
|
||||
const themeForm = this._root.getElementById("theme-form");
|
||||
themeForm.hass = this._hass;
|
||||
themeForm.data = this._cfg;
|
||||
themeForm.schema = [
|
||||
this._themeForm = this._root.getElementById("theme-form");
|
||||
this._themeForm.addEventListener("value-changed", (ev) => this._edited(ev.detail.value));
|
||||
}
|
||||
|
||||
/** One edit: show it, then tell Lovelace, whose echo is then a no-op. */
|
||||
_edited(value) {
|
||||
this._cfg = { ...this._cfg, ...value };
|
||||
this._setFormData();
|
||||
this._dispatchConfigChange();
|
||||
}
|
||||
|
||||
_themeSchema() {
|
||||
return [
|
||||
{
|
||||
name: "view_mode",
|
||||
selector: {
|
||||
@@ -2674,20 +2976,15 @@ class KCFSCardEditor extends HTMLElement {
|
||||
},
|
||||
{ name: "show_type_in_mini", selector: { boolean: {} } },
|
||||
];
|
||||
themeForm.computeLabel = (s) => ({
|
||||
view_mode: this._t("schema_view_mode"),
|
||||
show_type_in_mini: this._t("schema_show_type_in_mini"),
|
||||
}[s.name] || s.name);
|
||||
|
||||
themeForm.addEventListener("value-changed", (ev) => {
|
||||
this._cfg = { ...this._cfg, ...ev.detail.value };
|
||||
this._dispatchConfigChange();
|
||||
});
|
||||
}
|
||||
|
||||
_dispatchConfigChange() {
|
||||
// Only what differs from the defaults (R44): the card merges them back.
|
||||
const defaults = KCFSCard.defaultConfig();
|
||||
const config = Object.fromEntries(Object.entries(this._cfg)
|
||||
.filter(([key, value]) => !(key in defaults) || value !== defaults[key]));
|
||||
this.dispatchEvent(new CustomEvent("config-changed", {
|
||||
detail: { config: this._cfg },
|
||||
detail: { config },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
@@ -2697,9 +2994,21 @@ class KCFSCardEditor extends HTMLElement {
|
||||
defineOnce(EDITOR_TAG, KCFSCardEditor);
|
||||
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: "k-cfs-card",
|
||||
name: "Creality CFS Card",
|
||||
preview: true,
|
||||
description: "A card to control the Creality Filament System (CFS)"
|
||||
});
|
||||
// Once per page, like the element itself: a second copy of this module (two
|
||||
// resource entries with different ?v=) listed the card twice in the picker.
|
||||
if (!window.customCards.some((card) => card.type === "k-cfs-card")) {
|
||||
const pickerEntry = {
|
||||
type: "k-cfs-card",
|
||||
name: CFS_TRANSLATIONS.en.picker_name,
|
||||
preview: true,
|
||||
description: CFS_TRANSLATIONS.en.picker_description,
|
||||
};
|
||||
window.customCards.push(pickerEntry);
|
||||
// The picker reads the entry when it opens, so the page's language can be
|
||||
// applied once its strings arrive (R33).
|
||||
const pageHass = document.querySelector?.("home-assistant")?.hass;
|
||||
_requestI18n({}, pageHass, () => {
|
||||
pickerEntry.name = _translate(pageHass, "cfs_card", CFS_TRANSLATIONS, "picker_name");
|
||||
pickerEntry.description = _translate(pageHass, "cfs_card", CFS_TRANSLATIONS, "picker_description");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,20 +2,28 @@ const CARD_TAG = "k-printer-card";
|
||||
const EDITOR_TAG = "k-printer-card-editor";
|
||||
|
||||
const I18N_URL_BASE = "/ha_creality_ws/i18n/";
|
||||
const _i18nData = {};
|
||||
const _i18nPromises = {};
|
||||
// One cache for both cards, on the page: each module used to fetch en.json for
|
||||
// itself, and a language with no file (a 404) was asked for again by every new
|
||||
// card. A 404 is now remembered; only a failed request is retried. `no-cache`
|
||||
// revalidates, so an updated translation is not served stale (R45).
|
||||
const _i18nShared = (globalThis.__haCrealityWsI18n = globalThis.__haCrealityWsI18n || { data: {}, promises: {} });
|
||||
const _i18nData = _i18nShared.data;
|
||||
function _loadI18n(lang) {
|
||||
if (_i18nData[lang]) return Promise.resolve(_i18nData[lang]);
|
||||
if (_i18nPromises[lang]) return _i18nPromises[lang];
|
||||
_i18nPromises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
if (lang in _i18nData) return Promise.resolve(_i18nData[lang]);
|
||||
const promises = _i18nShared.promises;
|
||||
if (promises[lang]) return promises[lang];
|
||||
promises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`, { cache: "no-cache" })
|
||||
.then((res) => {
|
||||
if (res.ok) return res.json();
|
||||
if (res.status === 404) return null;
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
})
|
||||
.then((data) => {
|
||||
if (data) _i18nData[lang] = data;
|
||||
else _i18nPromises[lang] = null;
|
||||
_i18nData[lang] = data;
|
||||
return data;
|
||||
})
|
||||
.catch(() => { _i18nPromises[lang] = null; return null; });
|
||||
return _i18nPromises[lang];
|
||||
.catch(() => { promises[lang] = null; return null; });
|
||||
return promises[lang];
|
||||
}
|
||||
function _resolveLang(hass) {
|
||||
return hass?.locale?.language || hass?.language || "en";
|
||||
@@ -29,9 +37,12 @@ function _translate(hass, section, fallbackDict, key, vars) {
|
||||
: (remoteEn && key in remoteEn) ? remoteEn[key]
|
||||
: (fallbackDict[lang]?.[key] ?? fallbackDict[short]?.[key] ?? fallbackDict["en"]?.[key] ?? key);
|
||||
if (vars) {
|
||||
for (const [name, value] of Object.entries(vars)) {
|
||||
text = text.replace(new RegExp(`\\{${name}\\}`, "g"), value);
|
||||
}
|
||||
// One pass with a function: a replacement *string* expands `$&` and
|
||||
// friends, so a preset named "Teal $& Co" toasted as "Teal {name} Co", and
|
||||
// a value containing "{other}" was substituted again by a later key.
|
||||
text = text.replace(/\{(\w+)\}/g, (match, name) => (
|
||||
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
|
||||
));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -48,6 +59,13 @@ function _requestI18n(instance, hass, onLoaded) {
|
||||
// loop if size measurement on the fresh instance keeps producing the same delta.
|
||||
const LL_REBUILD_MIN_INTERVAL_MS = 2000;
|
||||
const _lastCardRebuildDispatch = new Map();
|
||||
// The size each card last measured, by its full name/status key. Lovelace answers
|
||||
// ll-rebuild by building a NEW element, which used to start at size 3, measure
|
||||
// the same wrapped row again and fire again once the throttle cleared: a rebuild
|
||||
// every two seconds on a narrow screen, with the reported size never sticking.
|
||||
// A fresh element reads its predecessor's size from here, so it measures no
|
||||
// change and stays put.
|
||||
const _measuredCardSize = new Map();
|
||||
|
||||
// How much wider, in px, the telemetry row has to get before the units it
|
||||
// dropped are worth retrying. Retrying at the width that rejected them would
|
||||
@@ -57,7 +75,13 @@ const TELEMETRY_COMPACT_HYSTERESIS = 8;
|
||||
|
||||
const INTEGRATION_DOMAIN = "ha_creality_ws";
|
||||
// The name a card carries until the user (or the device picker) names it.
|
||||
const DEFAULT_CARD_NAME = "3D Printer";
|
||||
// What the card picker used to write into every new card's YAML. Still read as
|
||||
// "no name", so such a card shows the translated default and is renamed from
|
||||
// its device like an unnamed one (R33).
|
||||
const LEGACY_DEFAULT_CARD_NAME = "3D Printer";
|
||||
function isUnnamed(name) {
|
||||
return !name || name === LEGACY_DEFAULT_CARD_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card roles the printer's own device can fill, keyed by the translation_key
|
||||
@@ -249,9 +273,31 @@ function loadThemeFromStorage(cardId) {
|
||||
* @returns {string} Unique card identifier
|
||||
*/
|
||||
function generateCardId(config) {
|
||||
// Generate a unique ID based on the card configuration
|
||||
const key = `${config.name || "printer"}-${config.status || "unknown"}`;
|
||||
return btoa(key).replace(/[^a-zA-Z0-9]/g, '').substring(0, 16);
|
||||
return cardIdForKey(cardKey(config));
|
||||
}
|
||||
|
||||
/** The full identity a card's id is derived from: its name and status entity. */
|
||||
function cardKey(config) {
|
||||
return `${config.name || "printer"}-${config.status || "unknown"}`;
|
||||
}
|
||||
|
||||
function cardIdForKey(key) {
|
||||
// `btoa` takes Latin-1 only and throws on anything above U+00FF, so a card
|
||||
// named "Tiskárna č.1" (or with an en dash, CJK or an emoji in its name)
|
||||
// threw out of setConfig and became an error card, and in the editor the
|
||||
// throw landed inside the debounce, so the edit was silently never saved.
|
||||
// Latin-1 names keep the id they always had, so a theme stored under it is
|
||||
// still found; anything else is encoded as UTF-8 bytes first.
|
||||
let encoded;
|
||||
try {
|
||||
encoded = btoa(key);
|
||||
} catch (_err) {
|
||||
const bytes = new TextEncoder().encode(key);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
encoded = btoa(binary);
|
||||
}
|
||||
return encoded.replace(/[^a-zA-Z0-9]/g, '').substring(0, 16);
|
||||
}
|
||||
|
||||
// Home Assistant rewrites a DURATION sensor's state into whichever display unit
|
||||
@@ -321,10 +367,68 @@ function computeColor(status) {
|
||||
return "var(--secondary-text-color)";
|
||||
}
|
||||
|
||||
/**
|
||||
* `config` minus what equals its default, the theme likewise (R44).
|
||||
* @param {!Object} config
|
||||
* @param {!Object} defaults
|
||||
* @return {!Object}
|
||||
*/
|
||||
function withoutDefaults(config, defaults) {
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key === "theme" && value && typeof value === "object") {
|
||||
const theme = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
if (!same(v, defaults.theme?.[k])) theme[k] = v;
|
||||
}
|
||||
if (Object.keys(theme).length) out.theme = theme;
|
||||
} else if (!(key in defaults) || !same(value, defaults[key])) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A theme value safe inside a <style> element, or "" when it is not (R46). */
|
||||
function cssValue(value) {
|
||||
const text = String(value ?? "");
|
||||
return /^[#\w\s(),.%+\-]*$/.test(text) ? text : "";
|
||||
}
|
||||
|
||||
/** Text safe inside a double-quoted HTML attribute. */
|
||||
function attr(value) {
|
||||
return String(value ?? "").replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Entities the custom chip switches on and off rather than pressing (R46).
|
||||
const CUSTOM_TOGGLE_DOMAINS = ["switch", "light", "input_boolean", "fan", "cover"];
|
||||
|
||||
/** A theme colour, or `fallback` when it is unset or "auto". */
|
||||
function autoColour(value, fallback) {
|
||||
return !value || value === "auto" ? fallback : value;
|
||||
}
|
||||
|
||||
class KPrinterCard extends HTMLElement {
|
||||
static getStubConfig() {
|
||||
/**
|
||||
* What a new card starts with: the first printer's entities when there is
|
||||
* one, else nothing. Only what differs from the defaults goes into the
|
||||
* dashboard; every default used to be written there and then outlived any
|
||||
* later change to it (R44).
|
||||
*/
|
||||
static getStubConfig(hass) {
|
||||
const registry = hass?.entities || {};
|
||||
const deviceId = Object.values(registry).find((e) => e?.platform === INTEGRATION_DOMAIN && e.device_id)?.device_id;
|
||||
if (!deviceId) return {};
|
||||
const device = hass?.devices?.[deviceId];
|
||||
const name = device?.name_by_user || device?.name || "";
|
||||
return { ...(name ? { name } : {}), device: deviceId, ...entitiesForDevice(hass, deviceId) };
|
||||
}
|
||||
|
||||
/** Every option with its default value. */
|
||||
static defaultConfig() {
|
||||
return {
|
||||
name: DEFAULT_CARD_NAME,
|
||||
name: "",
|
||||
// Device the entity fields were filled from. Stored so the editor's
|
||||
// "fill from device" button has something to re-read; the card itself
|
||||
// resolves nothing from it, so a config written by hand needs no device.
|
||||
@@ -349,7 +453,9 @@ class KPrinterCard extends HTMLElement {
|
||||
resume_icon: "#fff",
|
||||
stop_icon: "#fff",
|
||||
light_icon_on: "#000",
|
||||
light_icon_off: "#000",
|
||||
// "auto": the theme's text colour. A fixed black was 2.26:1 against the
|
||||
// off-state grey on a dark theme (R42).
|
||||
light_icon_off: "auto",
|
||||
// Status icon and progress circle
|
||||
status_icon: "auto", // auto, or specific color
|
||||
progress_ring: "auto", // auto, or specific color
|
||||
@@ -363,18 +469,20 @@ class KPrinterCard extends HTMLElement {
|
||||
// Off-state colours for the custom button, used when it drives a
|
||||
// toggleable entity (switch/light/input_boolean).
|
||||
custom_off_bg: "rgba(150,150,150,.35)",
|
||||
custom_icon_off: "#000",
|
||||
custom_icon_off: "auto",
|
||||
// Power button. The chip CSS has always read these, but nothing ever
|
||||
// set them, so the button was the one chip the theme could not reach.
|
||||
power_on_bg: "rgba(76, 175, 80, .90)",
|
||||
power_off_bg: "rgba(150,150,150,.35)",
|
||||
power_icon_on: "#fff",
|
||||
power_icon_off: "#000",
|
||||
power_icon_off: "auto",
|
||||
},
|
||||
// Config for custom button
|
||||
custom_btn: "",
|
||||
custom_btn_icon: "", // Moved to theme editor but stored here
|
||||
custom_btn_hidden: false,
|
||||
// Buttons never shown, whatever the printer is doing (#37)
|
||||
hidden_buttons: [],
|
||||
// Order of buttons
|
||||
button_order: ['pause', 'resume', 'stop', 'light', 'power', 'custom'],
|
||||
// Icon overrides
|
||||
@@ -431,14 +539,21 @@ class KPrinterCard extends HTMLElement {
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
const defaultConfig = KPrinterCard.getStubConfig();
|
||||
const defaultConfig = KPrinterCard.defaultConfig();
|
||||
const migrated = KPrinterCard._migrateConfig(config);
|
||||
this._cfg = { ...defaultConfig, ...migrated };
|
||||
// A dashboard now holds only the colours that differ from the defaults.
|
||||
this._cfg.theme = { ...defaultConfig.theme, ...(migrated.theme || {}) };
|
||||
// Init optimistic state overrides map
|
||||
if (!this._optimisticStates) this._optimisticStates = {};
|
||||
|
||||
// Generate card ID for theme persistence
|
||||
this._cardId = generateCardId(this._cfg);
|
||||
this._sizeKey = cardKey(this._cfg);
|
||||
this._cardId = cardIdForKey(this._sizeKey);
|
||||
// A rebuilt element starts from the size its predecessor measured.
|
||||
this._cardSize = _measuredCardSize.get(this._sizeKey);
|
||||
// A new config can name new entities: the next hass must update.
|
||||
this._seenStates = null;
|
||||
|
||||
// Load saved theme if no theme is provided in config
|
||||
if (!config?.theme) {
|
||||
@@ -461,23 +576,9 @@ class KPrinterCard extends HTMLElement {
|
||||
|
||||
// Always re-render when config changes to apply new theme
|
||||
this._render();
|
||||
|
||||
// Apply theme after render to ensure DOM is ready
|
||||
this._applyTheme();
|
||||
}
|
||||
_applyTheme() {
|
||||
if (!this._root || !this._cfg.theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-render with updated CSS to apply theme changes
|
||||
this._render();
|
||||
}
|
||||
|
||||
// i18n helpers -------------------------------------------------------
|
||||
_resolveLanguage() {
|
||||
return _resolveLang(this._hass);
|
||||
}
|
||||
_t(key) {
|
||||
return _translate(this._hass, "printer_card", CARD_TRANSLATIONS, key);
|
||||
}
|
||||
@@ -486,28 +587,72 @@ class KPrinterCard extends HTMLElement {
|
||||
set hass(hass) {
|
||||
this._hass = hass;
|
||||
_requestI18n(this, hass, () => { if (this._root) this._update(); });
|
||||
if (this._root) {
|
||||
// Apply theme first, then update
|
||||
this._applyTheme();
|
||||
this._update();
|
||||
// Schedule a follow-up update shortly after initial attach to absorb entity states once Home Assistant populates them
|
||||
clearTimeout(this._initialUpdateTimer);
|
||||
this._initialUpdateTimer = setTimeout(() => {
|
||||
try { this._update(); } catch (_) { }
|
||||
}, 150);
|
||||
// Home Assistant assigns a new hass for every state change in the whole
|
||||
// instance. This used to rebuild the shadow DOM each time (the theme only
|
||||
// changes in setConfig, which renders anyway), re-parsing the stylesheet,
|
||||
// recreating every icon and dropping keyboard focus, then update twice
|
||||
// more. Now only a change to something this card shows updates it.
|
||||
if (this._root && this._relevantChange(hass)) this._update();
|
||||
}
|
||||
|
||||
/** Whether `hass` changed anything `_update` reads, recording what it saw. */
|
||||
_relevantChange(hass) {
|
||||
const ids = this._watchedEntityIds();
|
||||
const prev = this._seenStates;
|
||||
const next = {};
|
||||
let changed = !prev;
|
||||
for (const id of ids) {
|
||||
const st = hass?.states?.[id];
|
||||
next[id] = st;
|
||||
if (prev && prev[id] !== st) changed = true;
|
||||
}
|
||||
// The language picks the card's strings; the formatter, the units.
|
||||
const lang = _resolveLang(hass);
|
||||
const fmt = typeof hass?.formatEntityState;
|
||||
if (lang !== this._seenLang || fmt !== this._seenFormatter) changed = true;
|
||||
this._seenLang = lang;
|
||||
this._seenFormatter = fmt;
|
||||
this._seenStates = next;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every entity id the config names, plus the switch/light twin that
|
||||
* `_resolveEntityId` may substitute for it. Cached per config object.
|
||||
*/
|
||||
_watchedEntityIds() {
|
||||
if (this._watchedFor === this._cfg && this._watched) return this._watched;
|
||||
const ids = new Set();
|
||||
for (const value of Object.values(this._cfg || {})) {
|
||||
if (typeof value !== "string" || !/^[a-z0-9_]+\.[a-z0-9_]+$/.test(value)) continue;
|
||||
ids.add(value);
|
||||
const objectId = value.split(".")[1];
|
||||
ids.add(`switch.${objectId}`);
|
||||
ids.add(`light.${objectId}`);
|
||||
}
|
||||
this._watched = ids;
|
||||
this._watchedFor = this._cfg;
|
||||
return ids;
|
||||
}
|
||||
|
||||
getCardSize() {
|
||||
return this._cardSize ?? _measuredCardSize.get(this._sizeKey) ?? 3;
|
||||
}
|
||||
getCardSize() { return this._cardSize || 3; }
|
||||
|
||||
_render() {
|
||||
if (!this._root) return;
|
||||
|
||||
// Ensure theme is always properly initialized
|
||||
const defaultConfig = KPrinterCard.getStubConfig();
|
||||
const defaultConfig = KPrinterCard.defaultConfig();
|
||||
this._cfg.theme = { ...defaultConfig.theme, ...(this._cfg.theme || {}) };
|
||||
|
||||
// Apply theme variables to CSS custom properties
|
||||
const theme = this._cfg.theme;
|
||||
// Apply theme variables to CSS custom properties. Each value lands inside a
|
||||
// <style> element, so anything that is not plainly a colour is dropped
|
||||
// (and its default used): a value with `;`, `}` or `</style>` could
|
||||
// otherwise rewrite the card's CSS or break out into markup (R46).
|
||||
const theme = Object.fromEntries(
|
||||
Object.entries(this._cfg.theme).map(([key, value]) => [key, cssValue(value)]),
|
||||
);
|
||||
|
||||
// Theme CSS custom properties - embedded directly in CSS
|
||||
const themeCSS = `
|
||||
@@ -521,7 +666,7 @@ class KPrinterCard extends HTMLElement {
|
||||
--resume-icon: ${theme.resume_icon || '#fff'};
|
||||
--stop-icon: ${theme.stop_icon || '#fff'};
|
||||
--light-icon-on: ${theme.light_icon_on || '#000'};
|
||||
--light-icon-off: ${theme.light_icon_off || '#000'};
|
||||
--light-icon-off: ${autoColour(theme.light_icon_off, 'var(--primary-text-color)')};
|
||||
--status-bg: ${theme.status_bg === 'auto' ? 'radial-gradient(var(--card-background-color) 62%, transparent 0)' : (theme.status_bg || 'radial-gradient(var(--card-background-color) 62%, transparent 0)')};
|
||||
--telemetry-icon: ${theme.telemetry_icon === 'auto' ? 'var(--secondary-text-color)' : (theme.telemetry_icon || 'var(--secondary-text-color)')};
|
||||
--telemetry-text: ${theme.telemetry_text === 'auto' ? 'var(--primary-text-color)' : (theme.telemetry_text || 'var(--primary-text-color)')};
|
||||
@@ -529,11 +674,11 @@ class KPrinterCard extends HTMLElement {
|
||||
--custom-on-bg: ${theme.custom_on_bg || theme.custom_bg || 'rgba(33, 150, 243, .90)'};
|
||||
--custom-off-bg: ${theme.custom_off_bg || 'rgba(150,150,150,.35)'};
|
||||
--custom-icon: ${theme.custom_icon || '#fff'};
|
||||
--custom-icon-off: ${theme.custom_icon_off || '#000'};
|
||||
--custom-icon-off: ${autoColour(theme.custom_icon_off, 'var(--primary-text-color)')};
|
||||
--power-on-bg: ${theme.power_on_bg || 'rgba(76, 175, 80, .90)'};
|
||||
--power-off-bg: ${theme.power_off_bg || 'rgba(150,150,150,.35)'};
|
||||
--power-icon-on: ${theme.power_icon_on || '#fff'};
|
||||
--power-icon-off: ${theme.power_icon_off || '#000'};
|
||||
--power-icon-off: ${autoColour(theme.power_icon_off, 'var(--primary-text-color)')};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -595,6 +740,11 @@ class KPrinterCard extends HTMLElement {
|
||||
color:var(--chip-fg, var(--primary-text-color));
|
||||
cursor:pointer; user-select:none; border:none; outline:none;
|
||||
}
|
||||
/* The outline is removed for mouse clicks only: a keyboard user has to
|
||||
see where focus is (R42). */
|
||||
.chip:focus-visible, .title.click:focus-visible {
|
||||
outline: 2px solid var(--primary-color); outline-offset: 2px;
|
||||
}
|
||||
.chip[hidden]{ display:none !important; }
|
||||
.chip:active { transform: translateY(1px); }
|
||||
.chip.danger { --chip-bg: var(--stop-bg, rgba(244, 67, 54, .95)); --chip-fg: var(--stop-icon, #fff); }
|
||||
@@ -737,7 +887,8 @@ class KPrinterCard extends HTMLElement {
|
||||
// the stronger warning as much as a running print does.
|
||||
const printing = ["printing", "paused", "processing"].includes(st);
|
||||
const msg = printing ? this._t("confirm_power_off_printing") : this._t("confirm_power_off");
|
||||
if (!confirm(msg)) return;
|
||||
this._confirmedAction(msg, "homeassistant.turn_off", eid);
|
||||
return;
|
||||
}
|
||||
this._toggleEntity(eid);
|
||||
} else if (id === "light") {
|
||||
@@ -748,18 +899,23 @@ class KPrinterCard extends HTMLElement {
|
||||
} else if (id === "resume") {
|
||||
this._pressButtonEntity(this._cfg.resume_btn);
|
||||
} else if (id === "stop") {
|
||||
if (confirm(this._t("confirm_stop"))) {
|
||||
this._pressButtonEntity(this._cfg.stop_btn);
|
||||
}
|
||||
const eid = this._cfg.stop_btn;
|
||||
const domain = (eid || "").split(".")[0];
|
||||
const service = ["button", "input_button"].includes(domain) ? `${domain}.press` : "homeassistant.turn_on";
|
||||
this._confirmedAction(this._t("confirm_stop"), service, eid);
|
||||
} else if (id === "custom") {
|
||||
// Custom button can be a button (press), script (turn_on/run), switch (toggle), automation (trigger), etc.
|
||||
// For simplicity, treat as toggle if switch/light/input_boolean, else press/turn_on
|
||||
// What a tap means for each kind of entity: on/off things toggle (the
|
||||
// chip already shows fans and covers as on/off, but only ever turned
|
||||
// them on), an automation runs rather than being enabled, and buttons,
|
||||
// scripts and scenes are pressed or turned on (R46).
|
||||
const eid = this._cfg.custom_btn;
|
||||
const domain = eid ? (eid.split(".")[0] || "").toLowerCase() : "";
|
||||
if (["switch", "light", "input_boolean"].includes(domain)) {
|
||||
if (CUSTOM_TOGGLE_DOMAINS.includes(domain)) {
|
||||
this._toggleEntity(eid);
|
||||
} else if (domain === "automation") {
|
||||
this._hass?.callService("automation", "trigger", { entity_id: eid });
|
||||
} else {
|
||||
this._pressButtonEntity(eid); // Fallback to press (works for button domain, or generic turn_on if mapped)
|
||||
this._pressButtonEntity(eid);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -783,7 +939,6 @@ class KPrinterCard extends HTMLElement {
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
clearTimeout(this._initialUpdateTimer);
|
||||
if (this._telemetryResizeObserver) {
|
||||
if (this._telemetryObservedNode) {
|
||||
this._telemetryResizeObserver.unobserve(this._telemetryObservedNode);
|
||||
@@ -900,25 +1055,45 @@ class KPrinterCard extends HTMLElement {
|
||||
const currentSize = this._cardSize ?? 3;
|
||||
if (nextSize === currentSize) return;
|
||||
|
||||
const cardKey = this._cardId || CARD_TAG;
|
||||
const throttleKey = this._sizeKey || CARD_TAG;
|
||||
const now = Date.now();
|
||||
const lastDispatch = _lastCardRebuildDispatch.get(cardKey) || 0;
|
||||
const lastDispatch = _lastCardRebuildDispatch.get(throttleKey) || 0;
|
||||
// Defer the _cardSize update until the throttle clears: otherwise a throttled
|
||||
// call would record the new size locally without telling Lovelace, and the
|
||||
// next measurement would short-circuit on the equality check above -- leaving
|
||||
// the rebuild permanently suppressed.
|
||||
if (now - lastDispatch < LL_REBUILD_MIN_INTERVAL_MS) return;
|
||||
_lastCardRebuildDispatch.set(cardKey, now);
|
||||
_lastCardRebuildDispatch.set(throttleKey, now);
|
||||
this._cardSize = nextSize;
|
||||
_measuredCardSize.set(this._sizeKey, nextSize);
|
||||
|
||||
this.dispatchEvent(new CustomEvent("ll-rebuild", { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
// Re-implement _pressButtonEntity to be smarter about non-button domains if needed,
|
||||
// but existing implementation calls button.press.
|
||||
// For custom buttons (e.g. scripts), we might want to default to homeassistant.turn_on if button.press fails is overkill,
|
||||
// but let's keep it simple: if it's a script/automation, button.press might not work.
|
||||
// Let's refine _pressButtonEntity to handle more types or create a generic helper.
|
||||
/**
|
||||
* Run `service` on `entityId` behind Home Assistant's own confirmation
|
||||
* dialog. window.confirm() is switched off in some kiosk browsers and
|
||||
* webviews, where it answers "no" without asking, so Stop and power-off
|
||||
* silently did nothing there (R46).
|
||||
*/
|
||||
_confirmedAction(text, service, entityId) {
|
||||
if (!entityId) return;
|
||||
this.dispatchEvent(new CustomEvent("hass-action", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
action: "tap",
|
||||
config: {
|
||||
tap_action: {
|
||||
action: "perform-action",
|
||||
perform_action: service,
|
||||
target: { entity_id: entityId },
|
||||
confirmation: { text },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async _pressButtonEntity(eid) {
|
||||
if (!this._hass || !eid) return;
|
||||
@@ -926,7 +1101,7 @@ class KPrinterCard extends HTMLElement {
|
||||
if (domain === "button" || domain === "input_button") {
|
||||
await this._hass.callService(domain, "press", { entity_id: eid });
|
||||
} else {
|
||||
// Fallback for scripts, automations, scenes which act like "press" via turn_on
|
||||
// Scripts and scenes run through turn_on.
|
||||
await this._hass.callService("homeassistant", "turn_on", { entity_id: eid });
|
||||
}
|
||||
}
|
||||
@@ -1014,7 +1189,7 @@ class KPrinterCard extends HTMLElement {
|
||||
};
|
||||
const fmtWithUnit = (eid) => fmtState(gObj(eid));
|
||||
|
||||
const name = this._cfg.name || DEFAULT_CARD_NAME;
|
||||
const name = isUnnamed(this._cfg.name) ? this._t("default_name") : this._cfg.name;
|
||||
const status = g(this._cfg.status) ?? "unknown";
|
||||
const pct = clamp(Number.isFinite(gNum(this._cfg.progress)) ? gNum(this._cfg.progress) : 0, 0, 100);
|
||||
const timeLeft = durationToSeconds(gObj(this._cfg.time_left));
|
||||
@@ -1139,11 +1314,12 @@ class KPrinterCard extends HTMLElement {
|
||||
if (!uniqueOrder.includes(k) && buttons[k]) uniqueOrder.push(k);
|
||||
});
|
||||
|
||||
const hiddenByConfig = new Set(Array.isArray(this._cfg.hidden_buttons) ? this._cfg.hidden_buttons : []);
|
||||
let chipsHtml = "";
|
||||
uniqueOrder.forEach(key => {
|
||||
const btn = buttons[key];
|
||||
if (btn && !btn.hidden) {
|
||||
chipsHtml += `<button class="chip ${btn.class}" id="${key}" title="${btn.title}"><ha-icon icon="${btn.icon}"></ha-icon></button>`;
|
||||
if (btn && !btn.hidden && !hiddenByConfig.has(key)) {
|
||||
chipsHtml += `<button class="chip ${btn.class}" id="${key}" title="${attr(btn.title)}" aria-label="${attr(btn.title)}"><ha-icon icon="${attr(btn.icon)}"></ha-icon></button>`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1189,6 +1365,9 @@ class KPrinterCard extends HTMLElement {
|
||||
}
|
||||
const CARD_TRANSLATIONS = {
|
||||
en: {
|
||||
default_name: "3D Printer",
|
||||
picker_name: "Creality Printer Card",
|
||||
picker_description: "Standalone card for Creality K-Series printers",
|
||||
status_unknown: "Unknown",
|
||||
confirm_stop: "Are you sure you want to stop the print?",
|
||||
confirm_power_off: "Are you sure you want to power off the printer?",
|
||||
@@ -1266,6 +1445,7 @@ const CARD_TRANSLATIONS = {
|
||||
label_custom_btn: "Custom Action Entity",
|
||||
label_custom_btn_icon: "Custom Button Icon",
|
||||
label_custom_btn_hidden: "Hide Custom Button",
|
||||
label_hidden_buttons: "Hidden Buttons",
|
||||
label_button_order: "Button Order (list)",
|
||||
label_hide_box_temp: "Hide Chamber Temperature",
|
||||
label_pause_btn_icon: "Pause Icon Override",
|
||||
@@ -1293,6 +1473,7 @@ const CARD_TRANSLATIONS = {
|
||||
helper_custom_btn: "Any entity to trigger (Button, Script, Switch, etc.)",
|
||||
helper_custom_btn_icon: "Icon for the custom button",
|
||||
helper_custom_btn_hidden: "Hide the custom button",
|
||||
helper_hidden_buttons: "Never shown on the card, whatever the printer is doing",
|
||||
helper_button_order: "List of buttons to show in order (pause, resume, stop, light, power, custom)",
|
||||
helper_hide_box_temp: "Hide the chamber temperature pill even when a sensor is configured",
|
||||
editor_error_title: "Editor Error",
|
||||
@@ -1323,7 +1504,6 @@ function defineOnce(tag, cls) {
|
||||
}
|
||||
}
|
||||
|
||||
defineOnce(CARD_TAG, KPrinterCard);
|
||||
|
||||
/**
|
||||
* Colour controls in the theme tab, grouped the way they are rendered.
|
||||
@@ -1355,15 +1535,15 @@ const THEME_COLOR_GROUPS = [
|
||||
{ key: "light_on_bg", alpha: true },
|
||||
{ key: "light_icon_on" },
|
||||
{ key: "light_off_bg", alpha: true },
|
||||
{ key: "light_icon_off" },
|
||||
{ key: "light_icon_off", auto: true, seed: "#000000" },
|
||||
{ key: "power_on_bg", alpha: true },
|
||||
{ key: "power_icon_on" },
|
||||
{ key: "power_off_bg", alpha: true },
|
||||
{ key: "power_icon_off" },
|
||||
{ key: "power_icon_off", auto: true, seed: "#000000" },
|
||||
{ key: "custom_bg", alpha: true },
|
||||
{ key: "custom_icon" },
|
||||
{ key: "custom_off_bg", alpha: true },
|
||||
{ key: "custom_icon_off" },
|
||||
{ key: "custom_icon_off", auto: true, seed: "#000000" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1405,7 +1585,7 @@ const AUTO_SUFFIX = "_auto";
|
||||
|
||||
/** Top-level config keys the theme tab owns, and so the reset button clears. */
|
||||
const LAYOUT_RESET_KEYS = [
|
||||
"button_order", "custom_btn_hidden", "hide_box_temp",
|
||||
"button_order", "custom_btn_hidden", "hidden_buttons", "hide_box_temp",
|
||||
"pause_btn_icon", "resume_btn_icon", "stop_btn_icon",
|
||||
"light_btn_icon", "power_btn_icon", "custom_btn_icon",
|
||||
];
|
||||
@@ -1419,7 +1599,8 @@ const EDITOR_STYLE = `
|
||||
.editor-container { padding: 16px; max-width: 1200px; margin: 0 auto; }
|
||||
.editor-title { margin: 0 0 16px 0; font-size: 18px; color: var(--primary-text-color); }
|
||||
.tabs { display: flex; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; }
|
||||
.tab { padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
.tab { padding: 8px 16px; cursor: pointer; border: none; border-bottom: 2px solid transparent; background: none; color: inherit; font: inherit; }
|
||||
.tab:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
|
||||
.tab.active { border-bottom-color: var(--primary-color); color: var(--primary-color); }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
@@ -1493,10 +1674,23 @@ function entitiesSchema() {
|
||||
];
|
||||
}
|
||||
|
||||
function layoutSchema() {
|
||||
/** The chips `hidden_buttons` can name, in their default order. */
|
||||
const HIDEABLE_BUTTONS = ["pause", "resume", "stop", "light", "power", "custom"];
|
||||
|
||||
function layoutSchema(t = (key) => key) {
|
||||
return [
|
||||
{ name: "button_order", selector: { text: {} } },
|
||||
{ name: "custom_btn_hidden", selector: { boolean: {} } },
|
||||
{
|
||||
name: "hidden_buttons",
|
||||
selector: {
|
||||
select: {
|
||||
multiple: true,
|
||||
mode: "list",
|
||||
options: HIDEABLE_BUTTONS.map((key) => ({ value: key, label: t(`chip_${key}`) })),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "hide_box_temp", selector: { boolean: {} } },
|
||||
{ name: "pause_btn_icon", selector: { icon: {} } },
|
||||
{ name: "resume_btn_icon", selector: { icon: {} } },
|
||||
@@ -1602,9 +1796,6 @@ function colorData(cfg, group) {
|
||||
/* Visual editor: entity wiring on one tab, appearance on the other. */
|
||||
class KPrinterCardEditor extends HTMLElement {
|
||||
// i18n helpers -------------------------------------------------------
|
||||
_resolveLanguage() {
|
||||
return _resolveLang(this._hass);
|
||||
}
|
||||
_t(key, vars) {
|
||||
return _translate(this._hass, "printer_card", CARD_TRANSLATIONS, key, vars);
|
||||
}
|
||||
@@ -1622,7 +1813,7 @@ class KPrinterCardEditor extends HTMLElement {
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
const defaults = KPrinterCard.getStubConfig();
|
||||
const defaults = KPrinterCard.defaultConfig();
|
||||
this._cfg = { ...defaults, ...KPrinterCard._migrateConfig(config) };
|
||||
this._cfg.theme = { ...defaults.theme, ...(this._cfg.theme || {}) };
|
||||
this._refresh();
|
||||
@@ -1665,9 +1856,9 @@ class KPrinterCardEditor extends HTMLElement {
|
||||
<style>${EDITOR_STYLE}</style>
|
||||
<div class="editor-container">
|
||||
<h2 class="editor-title" id="editor-title"></h2>
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="entities" id="tab-entities"></div>
|
||||
<div class="tab" data-tab="theme" id="tab-theme"></div>
|
||||
<div class="tabs" role="tablist">
|
||||
<button type="button" class="tab active" role="tab" aria-selected="true" data-tab="entities" id="tab-entities"></button>
|
||||
<button type="button" class="tab" role="tab" aria-selected="false" data-tab="theme" id="tab-theme"></button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="entities-tab">
|
||||
@@ -1751,6 +1942,7 @@ class KPrinterCardEditor extends HTMLElement {
|
||||
_selectTab(name) {
|
||||
for (const tab of this._root.querySelectorAll(".tab")) {
|
||||
tab.classList.toggle("active", tab.dataset.tab === name);
|
||||
tab.setAttribute("aria-selected", String(tab.dataset.tab === name));
|
||||
}
|
||||
for (const content of this._root.querySelectorAll(".tab-content")) {
|
||||
content.classList.toggle("active", content.id === `${name}-tab`);
|
||||
@@ -1852,7 +2044,7 @@ class KPrinterCardEditor extends HTMLElement {
|
||||
|
||||
this._applyForm("device-form", deviceSchema(), { device: this._cfg.device || "" });
|
||||
this._applyForm("entities-form", entitiesSchema(), entitiesData(this._cfg));
|
||||
this._applyForm("layout-form", layoutSchema(), layoutData(this._cfg));
|
||||
this._applyForm("layout-form", layoutSchema((key) => this._t(key)), layoutData(this._cfg));
|
||||
|
||||
THEME_COLOR_GROUPS.forEach((group, index) => {
|
||||
this._root.getElementById(`group-color-${index}`).textContent = this._t(group.title);
|
||||
@@ -1903,7 +2095,7 @@ class KPrinterCardEditor extends HTMLElement {
|
||||
}
|
||||
|
||||
_onColorChanged(group, value) {
|
||||
const defaults = KPrinterCard.getStubConfig().theme;
|
||||
const defaults = KPrinterCard.defaultConfig().theme;
|
||||
const theme = { ...this._cfg.theme };
|
||||
const wasAuto = new Map(group.fields.map((field) => [field.key, isAutoColor(this._cfg, field)]));
|
||||
for (const field of group.fields) {
|
||||
@@ -1976,9 +2168,9 @@ class KPrinterCardEditor extends HTMLElement {
|
||||
}
|
||||
const device = this._hass?.devices?.[deviceId];
|
||||
const deviceName = device?.name_by_user || device?.name || "";
|
||||
// Every card starts life named "3D Printer", so that counts as unset --
|
||||
// Cards used to start life named "3D Printer", so that counts as unset --
|
||||
// otherwise the field the user most expects to be filled never would be.
|
||||
const nameUnset = !this._cfg.name || this._cfg.name === DEFAULT_CARD_NAME;
|
||||
const nameUnset = isUnnamed(this._cfg.name);
|
||||
if (deviceName && (overwrite || nameUnset)) patch.name = deviceName;
|
||||
return patch;
|
||||
}
|
||||
@@ -2008,7 +2200,7 @@ class KPrinterCardEditor extends HTMLElement {
|
||||
// Reset ----------------------------------------------------------------
|
||||
|
||||
_resetTheme() {
|
||||
const defaults = KPrinterCard.getStubConfig();
|
||||
const defaults = KPrinterCard.defaultConfig();
|
||||
const cfg = { ...this._cfg, theme: { ...defaults.theme } };
|
||||
for (const key of LAYOUT_RESET_KEYS) cfg[key] = defaults[key];
|
||||
|
||||
@@ -2042,20 +2234,36 @@ class KPrinterCardEditor extends HTMLElement {
|
||||
// localStorage write. The card saves the same thing again from setConfig.
|
||||
saveThemeToStorage(generateCardId(this._cfg), this._cfg.theme);
|
||||
this.dispatchEvent(new CustomEvent("config-changed", {
|
||||
detail: { config: this._cfg },
|
||||
detail: { config: withoutDefaults(this._cfg, KPrinterCard.defaultConfig()) },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Defined last, once every module-level constant the classes read exists:
|
||||
// defining the card upgrades elements already in the page on the spot, and
|
||||
// THEME_COLOR_FIELDS (used by _migrateTheme) was declared after it (R46).
|
||||
defineOnce(CARD_TAG, KPrinterCard);
|
||||
defineOnce(EDITOR_TAG, KPrinterCardEditor);
|
||||
|
||||
try {
|
||||
window.customCards = window.customCards || [];
|
||||
window.customCards.push({
|
||||
type: CARD_TAG,
|
||||
name: "Creality Printer Card",
|
||||
description: "Standalone card for Creality K-Series printers",
|
||||
preview: true,
|
||||
});
|
||||
// Once per page, like the element itself: a second copy of this module (two
|
||||
// resource entries with different ?v=) listed the card twice in the picker.
|
||||
if (!window.customCards.some((card) => card.type === CARD_TAG)) {
|
||||
const pickerEntry = {
|
||||
type: CARD_TAG,
|
||||
name: CARD_TRANSLATIONS.en.picker_name,
|
||||
description: CARD_TRANSLATIONS.en.picker_description,
|
||||
preview: true,
|
||||
};
|
||||
window.customCards.push(pickerEntry);
|
||||
// The picker reads the entry when it opens, so the page's language can be
|
||||
// applied once its strings arrive (R33).
|
||||
const pageHass = document.querySelector?.("home-assistant")?.hass;
|
||||
_requestI18n({}, pageHass, () => {
|
||||
pickerEntry.name = _translate(pageHass, "printer_card", CARD_TRANSLATIONS, "picker_name");
|
||||
pickerEntry.description = _translate(pageHass, "printer_card", CARD_TRANSLATIONS, "picker_description");
|
||||
});
|
||||
}
|
||||
} catch (_) { }
|
||||
|
||||
@@ -21,6 +21,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -28,10 +30,23 @@ from homeassistant.components import persistent_notification
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
Unauthorized,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
FAILED_RESTORE_STORE_SUFFIX,
|
||||
NOTIFY_QUEUE_STORE_SUFFIX,
|
||||
PRE_IMPORT_STORE_SUFFIX,
|
||||
STORAGE_KEY,
|
||||
)
|
||||
from .const import (
|
||||
DEVICE_COMPLETION_THRESHOLDS,
|
||||
DOMAIN,
|
||||
CONFIG_ENTRY_MINOR_VERSION,
|
||||
CONFIG_ENTRY_VERSION,
|
||||
@@ -84,9 +99,6 @@ from .const import (
|
||||
CONF_PROFILE_MATCH_INTERVAL,
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
CONF_MAX_PAST_CYCLES,
|
||||
CONF_MAX_FULL_TRACES_PER_PROFILE,
|
||||
CONF_MAX_FULL_TRACES_UNLABELED,
|
||||
CONF_WATCHDOG_INTERVAL,
|
||||
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
||||
CONF_COMPLETION_MIN_SECONDS,
|
||||
@@ -94,16 +106,11 @@ from .const import (
|
||||
DEFAULT_PROFILE_MATCH_INTERVAL,
|
||||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
DEFAULT_MAX_PAST_CYCLES,
|
||||
DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
|
||||
DEFAULT_MAX_FULL_TRACES_UNLABELED,
|
||||
DEFAULT_WATCHDOG_INTERVAL,
|
||||
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
||||
DEFAULT_COMPLETION_MIN_SECONDS,
|
||||
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
|
||||
DEFAULT_DEVICE_TYPE,
|
||||
DEVICE_TYPE_OTHER,
|
||||
DEFAULT_START_DURATION_THRESHOLD,
|
||||
CONF_START_DURATION_THRESHOLD,
|
||||
resolve_watchdog_interval_default,
|
||||
resolve_start_duration_default,
|
||||
@@ -155,6 +162,88 @@ def _require_str(value: Any, name: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _svc(fields: dict[Any, Any]) -> vol.Schema:
|
||||
# ALLOW_EXTRA: type the declared fields without rejecting a key an automation
|
||||
# already passes (submit_cycle_feedback's `dismiss` is read but undeclared).
|
||||
return vol.Schema({vol.Required("device_id"): cv.string, **fields}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
# One schema per service, mirroring services.yaml (audit PLATFORM-10): none had
|
||||
# one, so `profile_name: 123` raised AttributeError, a non-numeric trim_start_s a
|
||||
# ValueError traceback, and `unlabel_cycles: "false"` read as true.
|
||||
def _float(value: Any) -> float:
|
||||
"""``vol.Coerce(float)`` that also rejects an oversized JSON integer: voluptuous
|
||||
catches only ValueError/TypeError, so ``float(10**400)`` escaped as OverflowError."""
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError, OverflowError) as err:
|
||||
raise vol.Invalid("expected a number") from err
|
||||
|
||||
|
||||
_OPT_STR = vol.Any(None, cv.string)
|
||||
_OPT_NUM = vol.Any(None, _float)
|
||||
_SERVICE_SCHEMAS: dict[str, vol.Schema] = {
|
||||
"label_cycle": _svc({vol.Required("cycle_id"): cv.string,
|
||||
vol.Optional("profile_name"): _OPT_STR}),
|
||||
"create_profile": _svc({vol.Required("profile_name"): cv.string,
|
||||
vol.Optional("reference_cycle_id"): _OPT_STR}),
|
||||
"delete_profile": _svc({vol.Required("profile_name"): cv.string,
|
||||
vol.Optional("unlabel_cycles"): cv.boolean}),
|
||||
"auto_label_cycles": _svc({vol.Optional("confidence_threshold"): vol.Any(None, vol.All(
|
||||
_float, vol.Range(min=0.0, max=1.0)))}),
|
||||
"export_config": _svc({vol.Optional("path"): _OPT_STR}),
|
||||
"import_config": _svc({vol.Required("path"): cv.string}),
|
||||
"submit_cycle_feedback": vol.Schema({
|
||||
vol.Optional("device_id"): _OPT_STR,
|
||||
vol.Optional("entry_id"): _OPT_STR,
|
||||
vol.Required("cycle_id"): cv.string,
|
||||
vol.Optional("user_confirmed"): cv.boolean,
|
||||
vol.Optional("corrected_profile"): _OPT_STR,
|
||||
# As services.yaml's selector: YAML automations bypass it, and NaN, inf or a
|
||||
# negative value reached the stored cycle's duration.
|
||||
vol.Optional("corrected_duration"): vol.Any(
|
||||
None, vol.All(_float, vol.Range(min=0, max=86400))
|
||||
),
|
||||
vol.Optional("notes"): _OPT_STR,
|
||||
vol.Optional("dismiss"): cv.boolean,
|
||||
}, extra=vol.ALLOW_EXTRA),
|
||||
"record_start": _svc({}),
|
||||
"record_stop": _svc({}),
|
||||
"trim_cycle": _svc({vol.Required("cycle_id"): cv.string,
|
||||
vol.Optional("trim_start_s"): _float,
|
||||
vol.Optional("trim_end_s"): _OPT_NUM}),
|
||||
"pause_cycle": _svc({}),
|
||||
"resume_cycle": _svc({}),
|
||||
"mark_unloaded": _svc({}),
|
||||
"trigger_ml_training": _svc({}),
|
||||
}
|
||||
|
||||
|
||||
def _service_manager(hass: HomeAssistant, device_id: str) -> tuple[str, Any]:
|
||||
"""``(entry_id, manager)`` for a service call's device (audit PLATFORM-10).
|
||||
|
||||
One resolver for every service: each used to copy-paste it, nine raising a bare
|
||||
ValueError (an "unknown error" plus traceback in the UI) and all taking
|
||||
``next(iter(device.config_entries))`` - an arbitrary entry of the device, not
|
||||
necessarily ours.
|
||||
"""
|
||||
device = dr.async_get(hass).async_get(device_id)
|
||||
if not device:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="device_not_found"
|
||||
)
|
||||
loaded = hass.data.get(DOMAIN, {})
|
||||
entry_id = next((eid for eid in device.config_entries if eid in loaded), None)
|
||||
if entry_id is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=(
|
||||
"integration_not_loaded" if device.config_entries else "no_config_entry"
|
||||
),
|
||||
)
|
||||
return entry_id, loaded[entry_id]
|
||||
|
||||
|
||||
# Options keys that no code reads any more, stripped by the 3.10 -> 3.11 step and
|
||||
# again by the one-pass legacy migration. Named once so the two cannot drift.
|
||||
# The value `DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO` held before item 311
|
||||
@@ -167,6 +256,36 @@ _DEAD_ABRUPT_KEYS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _heal_seeded_cadence(options: dict[str, Any], device_type: Any) -> list[str]:
|
||||
"""Replace the seeded 30/5 cadence with the device default (#396), in place.
|
||||
|
||||
The pre-3.9 legacy migration seeded watchdog_interval=30 and
|
||||
start_duration_threshold=5. On the coarse (30 s) sampling device types those
|
||||
fall below the panel's watchdog>=2*sampling and start_duration>=sampling gates,
|
||||
so they are replaced with the device-resolved default, but ONLY where they still
|
||||
equal the old scalar default: a value the migration seeded, never a deliberate
|
||||
choice. An absent key stays absent, so the runtime default applies.
|
||||
|
||||
One helper for the 3.9 -> 3.10 step and the one-pass legacy path (audit
|
||||
PLATFORM-09): the bulk path copied the 3.10 -> 3.11 heal but not this one, so a
|
||||
dryer migrating from 3.1 or 3.5 kept 30/5 while the same entry at 3.9 got 61/30.
|
||||
A falsy device type means DEFAULT_DEVICE_TYPE, not the coarse scalar fallback.
|
||||
"""
|
||||
dev = device_type or DEFAULT_DEVICE_TYPE
|
||||
healed: list[str] = []
|
||||
if options.get(CONF_WATCHDOG_INTERVAL) == 30:
|
||||
resolved = resolve_watchdog_interval_default(dev)
|
||||
if resolved != 30:
|
||||
options[CONF_WATCHDOG_INTERVAL] = resolved
|
||||
healed.append(CONF_WATCHDOG_INTERVAL)
|
||||
if options.get(CONF_START_DURATION_THRESHOLD) == 5:
|
||||
resolved_start = resolve_start_duration_default(dev)
|
||||
if resolved_start != 5:
|
||||
options[CONF_START_DURATION_THRESHOLD] = resolved_start
|
||||
healed.append(CONF_START_DURATION_THRESHOLD)
|
||||
return healed
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate config entry to the latest version while preserving settings."""
|
||||
_log = DeviceLoggerAdapter(_LOGGER, entry.title)
|
||||
@@ -229,36 +348,17 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
_log.debug("Migrated WashData entry from 3.8 to 3.9 (no null options)")
|
||||
|
||||
# 3.9 → 3.10: heal cadence defaults that violate the panel's own conflict rules
|
||||
# (#396). The pre-3.9 legacy migration seeded watchdog_interval=30 and
|
||||
# start_duration_threshold=5 into options. With sampling_interval now resolved
|
||||
# per device type, those seeded values fall below the watchdog>=2*sampling and
|
||||
# start_duration>=sampling gates on the coarse (30 s) sampling device types.
|
||||
# Replace them with the device-resolved default ONLY where they still equal the
|
||||
# old scalar default (30 / 5) - i.e. a value the migration seeded, never a
|
||||
# deliberate user choice (both violated the rule). A never-seeded (absent) key
|
||||
# is left absent so the runtime device-resolved default applies.
|
||||
# (#396); see _heal_seeded_cadence, which the one-pass legacy path shares.
|
||||
if version == 3 and minor_version == 9:
|
||||
new_opts = dict(entry.options)
|
||||
# `or` (not `.get(..., default)`) so a present-but-null device type also falls
|
||||
# through to the data value / DEFAULT_DEVICE_TYPE: a null would otherwise resolve
|
||||
# to the coarse scalar defaults and wrongly heal a washing-machine-equivalent
|
||||
# entry's 30/5 up to 61/30.
|
||||
_dt = (
|
||||
new_opts.get(CONF_DEVICE_TYPE)
|
||||
or entry.data.get(CONF_DEVICE_TYPE)
|
||||
or DEFAULT_DEVICE_TYPE
|
||||
_healed = _heal_seeded_cadence(
|
||||
new_opts,
|
||||
new_opts.get(CONF_DEVICE_TYPE) or entry.data.get(CONF_DEVICE_TYPE),
|
||||
)
|
||||
_healed = []
|
||||
if new_opts.get(CONF_WATCHDOG_INTERVAL) == 30:
|
||||
_resolved = resolve_watchdog_interval_default(_dt)
|
||||
if _resolved != 30:
|
||||
new_opts[CONF_WATCHDOG_INTERVAL] = _resolved
|
||||
_healed.append(CONF_WATCHDOG_INTERVAL)
|
||||
if new_opts.get(CONF_START_DURATION_THRESHOLD) == 5:
|
||||
_resolved = resolve_start_duration_default(_dt)
|
||||
if _resolved != 5:
|
||||
new_opts[CONF_START_DURATION_THRESHOLD] = _resolved
|
||||
_healed.append(CONF_START_DURATION_THRESHOLD)
|
||||
# LITERAL 10, not CONFIG_ENTRY_MINOR_VERSION, like every other step. These
|
||||
# blocks form a chain - each advances minor_version to exactly N+1 so the next
|
||||
# block picks it up - so a step that wrote "whatever is current" would, after a
|
||||
@@ -387,13 +487,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# lands. The min ratio above keeps its seed because that gate is inert for
|
||||
# ranking (it has never removed a true candidate on the corpus), so freezing
|
||||
# it costs nothing.
|
||||
options.setdefault(CONF_MAX_PAST_CYCLES, DEFAULT_MAX_PAST_CYCLES)
|
||||
options.setdefault(
|
||||
CONF_MAX_FULL_TRACES_PER_PROFILE, DEFAULT_MAX_FULL_TRACES_PER_PROFILE
|
||||
)
|
||||
options.setdefault(
|
||||
CONF_MAX_FULL_TRACES_UNLABELED, DEFAULT_MAX_FULL_TRACES_UNLABELED
|
||||
)
|
||||
options.setdefault(
|
||||
CONF_WATCHDOG_INTERVAL,
|
||||
resolve_watchdog_interval_default(options[CONF_DEVICE_TYPE]),
|
||||
@@ -401,7 +494,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
options.setdefault(
|
||||
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD, DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD
|
||||
)
|
||||
options.setdefault(CONF_COMPLETION_MIN_SECONDS, DEFAULT_COMPLETION_MIN_SECONDS)
|
||||
# The device's own floor, not the scalar 600 s: seeded for a pump it made every
|
||||
# run shorter than 10 min `interrupted` (audit DETECT-07).
|
||||
options.setdefault(
|
||||
CONF_COMPLETION_MIN_SECONDS,
|
||||
DEVICE_COMPLETION_THRESHOLDS.get(
|
||||
options[CONF_DEVICE_TYPE], DEFAULT_COMPLETION_MIN_SECONDS
|
||||
),
|
||||
)
|
||||
options.setdefault(
|
||||
CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
|
||||
)
|
||||
@@ -485,6 +585,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# so no stale removed value can linger there; the flow/manager read it from
|
||||
# options (options-first).
|
||||
|
||||
# Same heal as the 3.9 -> 3.10 step (audit PLATFORM-09), after the remap so it
|
||||
# resolves against the device type the entry ends up on. The `setdefault` above
|
||||
# keeps a stored 30/5, which is exactly what the chain heals.
|
||||
_heal_seeded_cadence(options, options.get(CONF_DEVICE_TYPE))
|
||||
|
||||
# Strip the now-retired running_dead_zone from options in the bulk path too
|
||||
# (covers entries that migrate straight from v1/v2/early-v3 in one pass).
|
||||
options.pop(CONF_RUNNING_DEAD_ZONE, None)
|
||||
@@ -559,7 +664,7 @@ async def _async_preload_ml_modules(hass: HomeAssistant) -> None:
|
||||
"""Import the ML modules off the event loop (issue #328).
|
||||
|
||||
``ml.engine.resolve_scorer`` / ``resolve_regressor`` are called from the event
|
||||
loop (live matching, end detection, quality gating), and Home Assistant flags
|
||||
loop (end detection, ETA / energy projection), and Home Assistant flags
|
||||
the lazy ``importlib.import_module`` they used to do there as a blocking call.
|
||||
Warming the module cache once per setup in the import executor makes every
|
||||
later resolution a ``sys.modules`` lookup. Best effort: a failure here only
|
||||
@@ -627,6 +732,14 @@ async def _async_setup_shared(
|
||||
the panel logs and retries on the next setup), so a missing UI never fails
|
||||
the entry.
|
||||
"""
|
||||
# Files left behind by appliances deleted before 0.5.8 (no remove hook then):
|
||||
# swept once per start, in the background, so setup never waits on disk I/O.
|
||||
if not hass.data.get(_ORPHAN_SWEEP_KEY):
|
||||
hass.data[_ORPHAN_SWEEP_KEY] = True
|
||||
hass.async_create_background_task(
|
||||
_async_sweep_orphaned_stores(hass), "ha_washdata orphaned store sweep"
|
||||
)
|
||||
|
||||
# Register custom card via frontend.py - once per HA instance only.
|
||||
if not hass.data.get("ha_washdata_card_registered") and not hass.data.get(
|
||||
"ha_washdata_card_deferred"
|
||||
@@ -727,6 +840,82 @@ async def _async_setup_shared(
|
||||
hass.data["ha_washdata_intents_registered"] = True
|
||||
|
||||
|
||||
# Panel access levels, lowest first (ws_api._effective_level resolves a user's).
|
||||
_SERVICE_LEVEL_ORDER = {"none": 0, "read": 1, "edit": 2, "full": 3}
|
||||
|
||||
# Services that read or write files / replace a whole device's data: admin-only,
|
||||
# like their WS twins in ws_api._ADMIN_COMMANDS.
|
||||
_ADMIN_SERVICES = frozenset({"export_config", "import_config", "trigger_ml_training"})
|
||||
|
||||
|
||||
def _service_entry_ids(hass: HomeAssistant, call: ServiceCall) -> list[str | None]:
|
||||
"""Every config entry a service call names: its ``entry_id`` and its device's.
|
||||
|
||||
Both are returned, and the caller authorizes each, because handlers differ in
|
||||
which one they act on (``submit_cycle_feedback`` prefers ``entry_id``, every
|
||||
other handler resolves ``device_id``, and the schemas allow extra keys). Checking
|
||||
only ``entry_id`` let a user with edit access to entry B pass B's id beside
|
||||
entry A's device and change A. ``[None]`` when the call names no entry.
|
||||
"""
|
||||
ids: list[str | None] = []
|
||||
entry_id = call.data.get("entry_id")
|
||||
if isinstance(entry_id, str) and entry_id:
|
||||
ids.append(entry_id)
|
||||
device_id = call.data.get("device_id")
|
||||
if isinstance(device_id, str) and device_id:
|
||||
device = dr.async_get(hass).async_get(device_id)
|
||||
if device is not None:
|
||||
loaded = hass.data.get(DOMAIN, {})
|
||||
dev_entry = next(
|
||||
(e for e in device.config_entries if e in loaded),
|
||||
next(iter(device.config_entries), None),
|
||||
)
|
||||
if dev_entry is not None and dev_entry not in ids:
|
||||
ids.append(dev_entry)
|
||||
return ids or [None]
|
||||
|
||||
|
||||
async def _async_check_service_access(
|
||||
hass: HomeAssistant, call: ServiceCall, level: str
|
||||
) -> None:
|
||||
"""Authorize a service call the way the panel authorizes its WS twin.
|
||||
|
||||
Every service used to skip both the WS admin gate and the panel RBAC: a
|
||||
read-only user could run ``import_config`` (replace all data) or
|
||||
``export_config`` (write the store under ``/config/www``, served without
|
||||
login at ``/local/``), and an RBAC "read" user could delete profiles
|
||||
(audit PLATFORM-03). Calls without a user (automations, scripts, the system)
|
||||
stay allowed, as for every HA admin service.
|
||||
"""
|
||||
user_id = call.context.user_id
|
||||
if user_id is None:
|
||||
return
|
||||
user = await hass.auth.async_get_user(user_id)
|
||||
if user is None:
|
||||
raise Unauthorized(context=call.context)
|
||||
if user.is_admin:
|
||||
return
|
||||
if level == "admin":
|
||||
raise Unauthorized(context=call.context)
|
||||
from .ws_api import _effective_level # pylint: disable=import-outside-toplevel
|
||||
|
||||
for entry_id in _service_entry_ids(hass, call):
|
||||
granted = _effective_level(hass, user, entry_id)
|
||||
if _SERVICE_LEVEL_ORDER.get(granted, 0) < _SERVICE_LEVEL_ORDER.get(level, 2):
|
||||
raise Unauthorized(context=call.context)
|
||||
|
||||
|
||||
def _guarded_service(hass: HomeAssistant, name: str, handler: Any) -> Any:
|
||||
"""Wrap a service handler with the access check for its level."""
|
||||
level = "admin" if name in _ADMIN_SERVICES else "edit"
|
||||
|
||||
async def _wrapped(call: ServiceCall) -> Any:
|
||||
await _async_check_service_access(hass, call, level)
|
||||
return await handler(call)
|
||||
|
||||
return _wrapped
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up WashData from a config entry."""
|
||||
_log = DeviceLoggerAdapter(_LOGGER, entry.title)
|
||||
@@ -812,6 +1001,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
ent_reg.async_remove(old_entity)
|
||||
|
||||
# Heal a non-numeric numeric setting stored by an older version (audit
|
||||
# PLATFORM-13 / register item 279): it raised in the manager's constructor,
|
||||
# so the entry could never set up again. Dropped, so its default applies.
|
||||
from .const import drop_invalid_numeric_options # pylint: disable=import-outside-toplevel
|
||||
|
||||
_clean, _dropped = drop_invalid_numeric_options(dict(entry.options))
|
||||
if _dropped:
|
||||
_log.warning(
|
||||
"Dropped non-numeric value(s) for %s; their defaults apply", ", ".join(_dropped)
|
||||
)
|
||||
hass.config_entries.async_update_entry(entry, options=_clean)
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from .manager import WashDataManager
|
||||
|
||||
@@ -840,21 +1041,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def handle_label_cycle(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
cycle_id = _require_str(call.data.get("cycle_id"), "cycle_id")
|
||||
profile_name = call.data.get("profile_name", "").strip()
|
||||
profile_name = (call.data.get("profile_name") or "").strip()
|
||||
|
||||
# Find the config entry for this device
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry_id, manager = _service_manager(hass, device_id)
|
||||
|
||||
# Assign existing profile or remove label
|
||||
target_profile = profile_name if profile_name else None
|
||||
@@ -878,7 +1068,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
manager.notify_update()
|
||||
|
||||
hass.services.async_register(DOMAIN, "label_cycle", handle_label_cycle)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "label_cycle", _guarded_service(hass, "label_cycle", handle_label_cycle),
|
||||
schema=_SERVICE_SCHEMAS["label_cycle"]
|
||||
)
|
||||
|
||||
# Register create_profile service
|
||||
if not hass.services.has_service(DOMAIN, "create_profile"):
|
||||
@@ -888,18 +1081,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
profile_name = _require_str(call.data.get("profile_name"), "profile_name")
|
||||
reference_cycle_id = call.data.get("reference_cycle_id")
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry_id, manager = _service_manager(hass, device_id)
|
||||
try:
|
||||
await manager.profile_store.create_profile_standalone(
|
||||
profile_name, reference_cycle_id
|
||||
@@ -912,7 +1094,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
) from exc
|
||||
manager.notify_update()
|
||||
|
||||
hass.services.async_register(DOMAIN, "create_profile", handle_create_profile)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_profile", _guarded_service(hass, "create_profile", handle_create_profile),
|
||||
schema=_SERVICE_SCHEMAS["create_profile"]
|
||||
)
|
||||
|
||||
# Register delete_profile service
|
||||
if not hass.services.has_service(DOMAIN, "delete_profile"):
|
||||
@@ -922,55 +1107,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
profile_name = _require_str(call.data.get("profile_name"), "profile_name")
|
||||
unlabel_cycles = call.data.get("unlabel_cycles", True)
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry_id, manager = _service_manager(hass, device_id)
|
||||
await manager.profile_store.delete_profile(profile_name, unlabel_cycles)
|
||||
manager.notify_update()
|
||||
|
||||
hass.services.async_register(DOMAIN, "delete_profile", handle_delete_profile)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "delete_profile", _guarded_service(hass, "delete_profile", handle_delete_profile),
|
||||
schema=_SERVICE_SCHEMAS["delete_profile"]
|
||||
)
|
||||
|
||||
# Register auto_label_cycles service
|
||||
if not hass.services.has_service(DOMAIN, "auto_label_cycles"):
|
||||
|
||||
async def handle_auto_label_cycles(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
confidence_threshold = call.data.get("confidence_threshold", 0.75)
|
||||
confidence_threshold = call.data.get("confidence_threshold")
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
entry_id, _manager = _service_manager(hass, device_id)
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
stats = await manager.profile_store.auto_label_cycles(
|
||||
confidence_threshold
|
||||
# The same registry task the panel starts (audit PLATFORM-05): under
|
||||
# the write lock, visible as a header pill, cancellable; awaited so an
|
||||
# automation step still waits for the result.
|
||||
from .ws_api import ( # noqa: PLC0415
|
||||
configured_auto_label_threshold,
|
||||
start_auto_label_task,
|
||||
)
|
||||
manager.notify_update()
|
||||
|
||||
manager._logger.info(
|
||||
"Auto-label complete: %s labeled, %s skipped",
|
||||
stats["labeled"],
|
||||
stats["skipped"],
|
||||
)
|
||||
# Omitted: the device's own Auto-Label Confidence, as the WS command
|
||||
# does (audit UI-10), not a hardcoded 0.75 that ignored the setting.
|
||||
if confidence_threshold is None:
|
||||
confidence_threshold = configured_auto_label_threshold(
|
||||
hass.config_entries.async_get_entry(entry_id)
|
||||
)
|
||||
|
||||
_task, raw = start_auto_label_task(hass, entry_id, float(confidence_threshold))
|
||||
if raw is not None:
|
||||
await raw
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, "auto_label_cycles", handle_auto_label_cycles
|
||||
DOMAIN,
|
||||
"auto_label_cycles",
|
||||
_guarded_service(hass, "auto_label_cycles", handle_auto_label_cycles),
|
||||
schema=_SERVICE_SCHEMAS["auto_label_cycles"],
|
||||
)
|
||||
|
||||
# Register trim_cycle service
|
||||
@@ -981,27 +1159,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
cycle_id = _require_str(call.data.get("cycle_id"), "cycle_id")
|
||||
trim_start_s = max(0.0, float(call.data.get("trim_start_s", 0)))
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
)
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry",
|
||||
)
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_loaded",
|
||||
)
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry_id, manager = _service_manager(hass, device_id)
|
||||
store = manager.profile_store
|
||||
|
||||
# Determine trim end - default to full cycle duration if not supplied
|
||||
@@ -1032,14 +1190,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
manager.notify_update()
|
||||
|
||||
hass.services.async_register(DOMAIN, "trim_cycle", handle_trim_cycle)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "trim_cycle", _guarded_service(hass, "trim_cycle", handle_trim_cycle),
|
||||
schema=_SERVICE_SCHEMAS["trim_cycle"]
|
||||
)
|
||||
|
||||
# Belt and braces for the hoist above: the panel's static routes need
|
||||
# hass.http, which is an after_dependencies entry rather than a hard one, so
|
||||
# it is guaranteed up before setup in a real HA but not in every harness.
|
||||
# By here the entity platforms have pulled the whole frontend stack in, which
|
||||
# is where this block used to live. Every step guards on its own "already
|
||||
# done" flag, so this is a no-op whenever the early call did its job.
|
||||
# hass.http. It is a hard manifest dependency (item 487), so HA sets it up
|
||||
# before this entry; a caller that invokes async_setup_entry directly skips
|
||||
# that, and there the platform forward is what processes the manifest.
|
||||
# Every step guards on its own "already done" flag, so this is a no-op
|
||||
# whenever the early call did its job.
|
||||
await _async_setup_shared(hass, _log)
|
||||
|
||||
# Register feedback service
|
||||
@@ -1057,26 +1218,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if entry_id is None:
|
||||
# Prefer device_id for user-facing workflows.
|
||||
device_id = _require_str(device_id_raw, "device_id")
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
|
||||
if not entry_id:
|
||||
raise ValueError("entry_id or device_id is required")
|
||||
entry_id, _manager = _service_manager(hass, device_id)
|
||||
|
||||
cycle_id = _require_str(call.data.get("cycle_id"), "cycle_id")
|
||||
user_confirmed = call.data.get("user_confirmed", False)
|
||||
corrected_profile = call.data.get("corrected_profile")
|
||||
corrected_duration = call.data.get("corrected_duration") # in seconds
|
||||
notes = call.data.get("notes", "")
|
||||
notes = call.data.get("notes") or ""
|
||||
dismiss = call.data.get("dismiss", False)
|
||||
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this entry")
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="integration_not_loaded"
|
||||
)
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
success = await manager.learning_manager.async_submit_cycle_feedback(
|
||||
@@ -1104,7 +1258,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SUBMIT_FEEDBACK.rsplit(".", maxsplit=1)[-1],
|
||||
handle_submit_feedback,
|
||||
_guarded_service(hass, "submit_cycle_feedback", handle_submit_feedback),
|
||||
schema=_SERVICE_SCHEMAS["submit_cycle_feedback"],
|
||||
)
|
||||
|
||||
# Export store to file (per entry/device)
|
||||
@@ -1114,18 +1269,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
file_path = call.data.get("path")
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry_id, manager = _service_manager(hass, device_id)
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
raise ValueError(f"Config entry not found: {entry_id}")
|
||||
@@ -1155,8 +1299,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# existing file even when is_allowed_path() accepts it; exclusive
|
||||
# creation ("x") makes that no-overwrite check atomic (no TOCTOU
|
||||
# window). The default generated path may be re-written freely.
|
||||
# Serialised like the WS export (audit PERF-11): HA's orjson encoder,
|
||||
# compact. indent=2 put every power-trace number on its own line.
|
||||
from .ws_api import _export_json # noqa: PLC0415
|
||||
|
||||
def _dump_and_write():
|
||||
text = json.dumps(payload, indent=2)
|
||||
text = _export_json(payload)
|
||||
try:
|
||||
if file_path:
|
||||
# Exclusive creation ("x") makes the no-overwrite check
|
||||
@@ -1185,7 +1333,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await hass.async_add_executor_job(_dump_and_write)
|
||||
manager._logger.info("Exported ha_washdata entry %s to %s", entry_id, target)
|
||||
|
||||
hass.services.async_register(DOMAIN, "export_config", handle_export_config)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "export_config", _guarded_service(hass, "export_config", handle_export_config),
|
||||
schema=_SERVICE_SCHEMAS["export_config"]
|
||||
)
|
||||
|
||||
# Import store from file into the target entry/device
|
||||
if not hass.services.has_service(DOMAIN, "import_config"):
|
||||
@@ -1197,18 +1348,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if not file_path:
|
||||
raise ValueError("path is required for import")
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id:
|
||||
raise ValueError("No config entry found for device")
|
||||
if entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded for this device")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry_id, manager = _service_manager(hass, device_id)
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
raise ValueError(f"Config entry not found: {entry_id}")
|
||||
@@ -1236,36 +1376,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise ValueError(f"Failed to read import file: {err}") from err
|
||||
|
||||
config_updates = await manager.profile_store.async_import_data(payload)
|
||||
# Same path as the WS import (audit PLATFORM-02): under the per-entry
|
||||
# write lock, local sensor/device bindings dropped, options written
|
||||
# under their lock with a changelog entry, entry.data left alone. The
|
||||
# service used to copy the exporter's power and door sensors over this
|
||||
# device's, which silently disconnects it (register item 317).
|
||||
from .ws_api import ( # pylint: disable=import-outside-toplevel
|
||||
_entry_write_lock,
|
||||
async_apply_imported_entry_options,
|
||||
)
|
||||
|
||||
# Apply imported settings to config entry if present
|
||||
entry_data = config_updates.get("entry_data", {})
|
||||
entry_options = config_updates.get("entry_options", {})
|
||||
|
||||
if entry_data or entry_options:
|
||||
new_data: dict[str, Any] = dict(entry.data)
|
||||
new_options: dict[str, Any] = dict(entry.options)
|
||||
|
||||
# Only update min_power/off_delay from data (don't overwrite power_sensor/name)
|
||||
for key in [CONF_MIN_POWER, CONF_OFF_DELAY]:
|
||||
if key in entry_data:
|
||||
new_data[key] = entry_data[key]
|
||||
|
||||
# Update all options from import, then strip any option persisted as
|
||||
# null: an export taken from an already-broken entry (or a hand-edited
|
||||
# file) can carry a `null`, which `.get()` hands back verbatim and the
|
||||
# numeric casts that build CycleDetectorConfig then raise on - the #389
|
||||
# bricked-setup failure. The WS import paths already do this; the legacy
|
||||
# import_config service is the last writer that did not.
|
||||
new_options.update(entry_options)
|
||||
new_options = strip_null_options(new_options)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=new_data,
|
||||
options=new_options,
|
||||
async with _entry_write_lock(hass, entry_id):
|
||||
# This device's options ride along so "Undo last import" in the
|
||||
# panel can put them back (register item 195).
|
||||
config_updates = await manager.profile_store.async_import_data(
|
||||
payload,
|
||||
entry_options=dict(entry.options),
|
||||
source="import_config_service",
|
||||
)
|
||||
manager._logger.info("Applied imported settings to config entry %s", entry_id)
|
||||
if config_updates:
|
||||
await async_apply_imported_entry_options(
|
||||
hass, entry, config_updates, "import_config_service"
|
||||
)
|
||||
manager._logger.info(
|
||||
"Applied imported settings to config entry %s", entry_id
|
||||
)
|
||||
|
||||
# An old payload re-arms the one-time banked-tail repair. Nothing here
|
||||
# reloads the entry unless the payload brought settings with it, so
|
||||
@@ -1274,40 +1409,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
manager._logger.info("Imported ha_washdata entry %s from %s", entry_id, source)
|
||||
|
||||
hass.services.async_register(DOMAIN, "import_config", handle_import_config)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "import_config", _guarded_service(hass, "import_config", handle_import_config),
|
||||
schema=_SERVICE_SCHEMAS["import_config"]
|
||||
)
|
||||
|
||||
# Register recorder services
|
||||
if not hass.services.has_service(DOMAIN, "record_start"):
|
||||
async def handle_record_start(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id or entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry_id, manager = _service_manager(hass, device_id)
|
||||
await manager.async_start_recording()
|
||||
|
||||
hass.services.async_register(DOMAIN, "record_start", handle_record_start)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "record_start", _guarded_service(hass, "record_start", handle_record_start),
|
||||
schema=_SERVICE_SCHEMAS["record_start"]
|
||||
)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, "record_stop"):
|
||||
async def handle_record_stop(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id or entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry_id, manager = _service_manager(hass, device_id)
|
||||
await manager.async_stop_recording()
|
||||
|
||||
hass.services.async_register(DOMAIN, "record_stop", handle_record_stop)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "record_stop", _guarded_service(hass, "record_stop", handle_record_stop),
|
||||
schema=_SERVICE_SCHEMAS["record_stop"]
|
||||
)
|
||||
|
||||
# Register on-device ML training trigger (Stage 4, gated by ENABLE_ML_TRAINING)
|
||||
from .const import ENABLE_ML_TRAINING, SERVICE_TRIGGER_ML_TRAINING
|
||||
@@ -1317,20 +1445,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
):
|
||||
async def handle_trigger_ml_training(call: ServiceCall) -> None:
|
||||
device_id = _require_str(call.data.get("device_id"), "device_id")
|
||||
registry = dr.async_get(hass)
|
||||
device = registry.async_get(device_id)
|
||||
if not device:
|
||||
raise ValueError("Device not found")
|
||||
entry_id = next(iter(device.config_entries), None)
|
||||
if not entry_id or entry_id not in hass.data[DOMAIN]:
|
||||
raise ValueError("Integration not loaded")
|
||||
|
||||
manager = hass.data[DOMAIN][entry_id]
|
||||
entry_id, manager = _service_manager(hass, device_id)
|
||||
summary = await manager.async_run_ml_training(force=True)
|
||||
manager._logger.info("Manual ML training: %s", summary)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_TRIGGER_ML_TRAINING, handle_trigger_ml_training
|
||||
DOMAIN,
|
||||
SERVICE_TRIGGER_ML_TRAINING,
|
||||
_guarded_service(hass, "trigger_ml_training", handle_trigger_ml_training),
|
||||
schema=_SERVICE_SCHEMAS["trigger_ml_training"],
|
||||
)
|
||||
|
||||
# Register pause/resume services
|
||||
@@ -1367,7 +1490,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
translation_key="no_active_cycle",
|
||||
)
|
||||
|
||||
hass.services.async_register(DOMAIN, "pause_cycle", handle_pause_cycle)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "pause_cycle", _guarded_service(hass, "pause_cycle", handle_pause_cycle),
|
||||
schema=_SERVICE_SCHEMAS["pause_cycle"]
|
||||
)
|
||||
|
||||
if not hass.services.has_service(DOMAIN, "resume_cycle"):
|
||||
async def handle_resume_cycle(call: ServiceCall) -> None:
|
||||
@@ -1402,7 +1528,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
translation_key="no_active_cycle",
|
||||
)
|
||||
|
||||
hass.services.async_register(DOMAIN, "resume_cycle", handle_resume_cycle)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "resume_cycle", _guarded_service(hass, "resume_cycle", handle_resume_cycle),
|
||||
schema=_SERVICE_SCHEMAS["resume_cycle"]
|
||||
)
|
||||
|
||||
# Unload confirmation for a device with no door sensor (#451). Deliberately
|
||||
# not an error when nothing is waiting: an automation wired to a physical
|
||||
@@ -1434,7 +1563,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
hass.data[DOMAIN][entry_id].mark_unloaded("mark_unloaded service")
|
||||
|
||||
hass.services.async_register(DOMAIN, "mark_unloaded", handle_mark_unloaded)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "mark_unloaded", _guarded_service(hass, "mark_unloaded", handle_mark_unloaded),
|
||||
schema=_SERVICE_SCHEMAS["mark_unloaded"]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1491,7 +1623,7 @@ def _apply_device_link(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
registry.async_update_device(
|
||||
washdata_device.id, via_device_id=linked_device_id
|
||||
)
|
||||
except (HomeAssistantError, ValueError) as err:
|
||||
except (HomeAssistantError, ValueError, OverflowError) as err:
|
||||
# The via_device link is cosmetic (it only nests the device in the HA
|
||||
# registry UI). A registry rule we do not know about yet must never be
|
||||
# able to take the whole entry down with it, as the self-link did in
|
||||
@@ -1510,14 +1642,102 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
# Update configuration without interrupting detector
|
||||
await manager.async_reload_config(entry)
|
||||
# Options changes (e.g. linked device) reload in place without
|
||||
# recreating entities, so apply the device link explicitly here.
|
||||
# recreating entities, so apply the device link explicitly here, and add or
|
||||
# drop the pump-only sensor if the device type changed.
|
||||
_apply_device_link(hass, entry)
|
||||
from .sensor import async_reconcile_device_type_sensors # pylint: disable=import-outside-toplevel
|
||||
|
||||
async_reconcile_device_type_sensors(hass, manager, entry)
|
||||
else:
|
||||
# Full reload if manager not found
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_setup_entry(hass, entry)
|
||||
|
||||
|
||||
_ORPHAN_SWEEP_KEY = "ha_washdata_orphan_sweep"
|
||||
# Per-appliance store keys: the profile store, its active-cycle snapshot (0.5.8), its
|
||||
# pre-import restore point (register item 195), its held-notification queue (audit
|
||||
# MANAGER-16), its last failed snapshot restore (item 266) and the manual recorder. Global keys
|
||||
# (``ha_washdata_panel``, ``ha_washdata_online``) use an underscore and never match.
|
||||
_ENTRY_STORE_RE = re.compile(
|
||||
r"^ha_washdata\.(?:recorder\.)?([0-9A-Za-z]{20,40})"
|
||||
rf"(?:\.active|\.{PRE_IMPORT_STORE_SUFFIX}|\.{NOTIFY_QUEUE_STORE_SUFFIX}"
|
||||
rf"|\.{FAILED_RESTORE_STORE_SUFFIX})?$"
|
||||
)
|
||||
|
||||
|
||||
def _entry_store_keys(entry_id: str) -> list[str]:
|
||||
return [
|
||||
f"{STORAGE_KEY}.{entry_id}",
|
||||
f"{STORAGE_KEY}.{entry_id}.active",
|
||||
f"{STORAGE_KEY}.{entry_id}.{PRE_IMPORT_STORE_SUFFIX}",
|
||||
f"{STORAGE_KEY}.{entry_id}.{NOTIFY_QUEUE_STORE_SUFFIX}",
|
||||
f"{STORAGE_KEY}.{entry_id}.{FAILED_RESTORE_STORE_SUFFIX}",
|
||||
f"{STORAGE_KEY}.recorder.{entry_id}",
|
||||
]
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Delete an appliance's stored data when the user deletes the appliance.
|
||||
|
||||
Until 0.5.8 there was no remove hook, so every deleted appliance left its
|
||||
profiles, cycles and traces in ``.storage`` for good (6.9 MB from 15 deleted
|
||||
devices on one install). HA calls this only for a deliberate delete, after the
|
||||
entry has unloaded; an unload or a reload never reaches it.
|
||||
"""
|
||||
from homeassistant.helpers.storage import Store # noqa: PLC0415
|
||||
|
||||
for key in _entry_store_keys(entry.entry_id):
|
||||
try:
|
||||
await Store(hass, 1, key).async_remove()
|
||||
except Exception: # noqa: BLE001 - a leftover file must not block the delete
|
||||
_LOGGER.warning("Could not delete WashData store %s", key, exc_info=True)
|
||||
|
||||
|
||||
async def _async_sweep_orphaned_stores(hass: HomeAssistant) -> None:
|
||||
"""Delete per-appliance store files whose config entry no longer exists.
|
||||
|
||||
Every entry (loaded, disabled or failed) is listed by the config-entries
|
||||
manager before any integration sets up, so a file whose id matches none of them
|
||||
belongs to an appliance the user deleted. Never raises.
|
||||
"""
|
||||
try:
|
||||
known = {e.entry_id for e in hass.config_entries.async_entries(DOMAIN)}
|
||||
if not known:
|
||||
return
|
||||
storage_dir = hass.config.path(".storage")
|
||||
|
||||
def _orphans() -> list[tuple[str, int]]:
|
||||
out = []
|
||||
for name in os.listdir(storage_dir):
|
||||
m = _ENTRY_STORE_RE.match(name)
|
||||
if m and m.group(1) not in known:
|
||||
try:
|
||||
out.append((name, os.path.getsize(os.path.join(storage_dir, name))))
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
orphans = await hass.async_add_executor_job(_orphans)
|
||||
if not orphans:
|
||||
return
|
||||
from homeassistant.helpers.storage import Store # noqa: PLC0415
|
||||
|
||||
removed = 0
|
||||
for name, _size in orphans:
|
||||
try:
|
||||
await Store(hass, 1, name).async_remove()
|
||||
removed += 1
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug("Could not delete orphaned store %s", name, exc_info=True)
|
||||
_LOGGER.info(
|
||||
"Deleted %d WashData store file(s) left by deleted appliances (%.1f MB)",
|
||||
removed, sum(size for _n, size in orphans) / 1e6,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug("Orphaned store sweep failed", exc_info=True)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
|
||||
@@ -20,6 +20,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .const import (
|
||||
@@ -36,21 +38,22 @@ from .const import (
|
||||
MATCH_DTW_RESAMPLE_N,
|
||||
MATCH_DURATION_SCALE,
|
||||
MATCH_DURATION_SCALE_OVERRUN,
|
||||
MATCH_PREFIX_MIN_POINTS,
|
||||
MATCH_PREFIX_SHAPE_MAX_RATIO,
|
||||
MATCH_DURATION_WEIGHT,
|
||||
MATCH_DURATION_WEIGHT_IN_PROGRESS,
|
||||
MATCH_MIN_RATIO_GRACE_S,
|
||||
MATCH_ENERGY_SCALE,
|
||||
MATCH_ENERGY_REF_MIN_CYCLES,
|
||||
MATCH_ENERGY_WEIGHT,
|
||||
MATCH_KEEP_MIN_SCORE,
|
||||
MATCH_MAE_PEAK_FLOOR,
|
||||
MATCH_MAE_REF_PEAK,
|
||||
MATCH_MAE_SCALE,
|
||||
MAX_ALIGN_GRID_POINTS,
|
||||
SMART_TERM_PREFIX_MAX_CANDIDATES,
|
||||
SMART_TERM_PREFIX_MIN_COVERAGE,
|
||||
SMART_TERM_PREFIX_MIN_POINTS,
|
||||
SMART_TERM_PREFIX_MIN_RATIO,
|
||||
STAGE4_INTEGRATED_ENERGY_DEVICE_TYPES,
|
||||
)
|
||||
from .signal_processing import integrate_wh
|
||||
|
||||
|
||||
def stage4_energy_mode(device_type: str | None) -> str:
|
||||
@@ -157,21 +160,35 @@ def find_best_alignment(
|
||||
best_mae = float("inf")
|
||||
final_offset = best_offset
|
||||
|
||||
# ~0.2 n^2 element work on a complete cycle (audit MATCH-CORE-11). It is
|
||||
# byte-identical to the old ``np.mean(np.abs(c_seg - r_seg))``: the same
|
||||
# subtract, abs and pairwise ``add.reduce`` divided by the count, written into
|
||||
# one reused buffer. Allocating three fresh n-element arrays per offset, and
|
||||
# np.mean's Python wrapper, cost more than the arithmetic. There is no exact
|
||||
# shortcut for an L1 distance over shifts, and a 2-D batch over offsets was
|
||||
# measured slower (the overlap length differs per offset). Any other dtype
|
||||
# keeps the old expression (np.mean sums an integer array through a cast).
|
||||
buf = (
|
||||
np.empty(min(n_curr, n_ref))
|
||||
if curr.dtype == np.float64 and ref.dtype == np.float64
|
||||
else None
|
||||
)
|
||||
for off in range(int(min_off), int(max_off) + 1):
|
||||
# intersection
|
||||
c_start = max(0, off)
|
||||
c_end = min(n_curr, n_ref + off)
|
||||
|
||||
r_start = max(0, -off)
|
||||
r_end = min(n_ref, n_curr - off)
|
||||
|
||||
if (c_end - c_start) < 10:
|
||||
length = c_end - c_start
|
||||
if length < 10:
|
||||
continue
|
||||
r_start = max(0, -off)
|
||||
|
||||
c_seg = curr[c_start:c_end]
|
||||
r_seg = ref[r_start:r_end]
|
||||
|
||||
mae = np.mean(np.abs(c_seg - r_seg))
|
||||
if buf is None:
|
||||
mae = np.mean(np.abs(curr[c_start:c_end] - ref[r_start:r_start + length]))
|
||||
else:
|
||||
seg = buf[:length]
|
||||
np.subtract(curr[c_start:c_end], ref[r_start:r_start + length], out=seg)
|
||||
np.abs(seg, out=seg)
|
||||
mae = np.add.reduce(seg) / length
|
||||
if mae < best_mae:
|
||||
best_mae = mae
|
||||
final_offset = off
|
||||
@@ -198,8 +215,10 @@ def find_best_alignment(
|
||||
corr = 0.0
|
||||
|
||||
# Scale-invariant MAE: express the error relative to the current cycle's
|
||||
# peak (common to every candidate, so ranking is unaffected) and calibrate
|
||||
# to the legacy behaviour at MATCH_MAE_REF_PEAK. See const.py for rationale.
|
||||
# peak and calibrate to the legacy behaviour at MATCH_MAE_REF_PEAK (see
|
||||
# const.py). On a complete cycle the peak is common to every candidate, so
|
||||
# ranking is unaffected; in prefix mode the compared slice, and so its peak,
|
||||
# depends on each candidate's span (deep-dive 02, audit MR-09).
|
||||
current_peak = float(np.max(np.abs(curr))) if curr.size else 0.0
|
||||
scaled_mae = mae * MATCH_MAE_REF_PEAK / max(current_peak, MATCH_MAE_PEAK_FLOOR)
|
||||
mae_score = MATCH_MAE_SCALE / (MATCH_MAE_SCALE + scaled_mae)
|
||||
@@ -250,7 +269,7 @@ def compute_dtw_lite(
|
||||
for each row are written back as a single slice assignment. For the typical
|
||||
matching case (n=m=200, band=0.1 → w=20, ~41 cells/row) this is ~1.9× faster
|
||||
than the original element-by-element NumPy indexing loop. The anti-diagonal
|
||||
vectorized fill from :func:`_dtw_cost_matrix_vectorized` is NOT used here
|
||||
vectorized fill from :func:`_dtw_cost_banded` is NOT used here
|
||||
because its per-diagonal Python setup overhead dominates for small n (it is
|
||||
2× *slower* than the scalar loop for n=200 — the opposite of its large-n
|
||||
envelope-rebuild behaviour where it wins by 1.6–8×).
|
||||
@@ -317,6 +336,85 @@ def compute_dtw_lite(
|
||||
_LOGGER.debug("compute_dtw_lite vectorized path failed; using scalar fallback", exc_info=True)
|
||||
return _dtw_lite_scalar(xf, yf, n, m, w)
|
||||
|
||||
def dtw_lite_batch(x: np.ndarray, y: np.ndarray, band_width_ratio: float) -> np.ndarray:
|
||||
"""``compute_dtw_lite`` for ``k`` equal-length pairs at once (rows of ``x``, ``y``).
|
||||
|
||||
The Stage-3 refines are all 200x200 with one band, so the ``top_n x 2`` DP
|
||||
fills run as one row scan (audit MR-05): within a row, ``cell[j] = min(c[j],
|
||||
local[j] + cell[j-1])`` with ``c = local + min(up, diag)`` unrolls to ``S[j] +
|
||||
cummin(c[k] - S[k])`` over the row's prefix sums ``S``. ~8x faster for 10 DTWs;
|
||||
the prefix sums reorder the additions, so results agree to ~1e-15 relative,
|
||||
not bit for bit.
|
||||
"""
|
||||
xs = np.asarray(x, dtype=float)
|
||||
ys = np.asarray(y, dtype=float)
|
||||
k, n = xs.shape
|
||||
m = ys.shape[1]
|
||||
if n == 0 or m == 0:
|
||||
return np.full(k, np.inf)
|
||||
w = max(1, int(min(n, m) * band_width_ratio))
|
||||
centers = (np.arange(1, n + 1, dtype=float) * (m / n)).astype(np.intp)
|
||||
start_js = np.maximum(1, centers - w)
|
||||
end_js = np.minimum(m, centers + w + 1)
|
||||
prev = np.full((k, m + 1), np.inf)
|
||||
prev[:, 0] = 0.0
|
||||
zeros = np.zeros((k, 1))
|
||||
for i in range(n):
|
||||
a, b = int(start_js[i]), int(end_js[i])
|
||||
local = np.abs(xs[:, i:i + 1] - ys[:, a - 1:b])
|
||||
up_diag = np.minimum(prev[:, a:b + 1], prev[:, a - 1:b])
|
||||
csum = np.cumsum(local, axis=1)
|
||||
before = np.concatenate((zeros, csum[:, :-1]), axis=1)
|
||||
row = csum + np.minimum.accumulate(up_diag - before, axis=1)
|
||||
cur = np.full((k, m + 1), np.inf)
|
||||
cur[:, a:b + 1] = row
|
||||
prev = cur
|
||||
return prev[:, m]
|
||||
|
||||
|
||||
def _stage3_scores_batched(
|
||||
pairs: list[tuple[np.ndarray, np.ndarray]],
|
||||
current_peak: float,
|
||||
*,
|
||||
dtw_mode: str,
|
||||
dtw_bandwidth: float,
|
||||
l1_scale: float,
|
||||
ddtw_scale: float,
|
||||
ensemble_w: float,
|
||||
) -> list[float]:
|
||||
"""Stage-3 scores for ``scaled`` / ``ddtw`` / ``ensemble`` in one batched DTW.
|
||||
|
||||
``pairs`` are each candidate's (current, sample) series already on the
|
||||
``MATCH_DTW_RESAMPLE_N`` grid; the arithmetic is `_dtw_component_score`'s.
|
||||
"""
|
||||
level = dtw_mode in ("scaled", "ensemble")
|
||||
deriv = dtw_mode in ("ddtw", "ensemble")
|
||||
xs: list[np.ndarray] = []
|
||||
ys: list[np.ndarray] = []
|
||||
if level:
|
||||
xs.extend(a for a, _ in pairs)
|
||||
ys.extend(b for _, b in pairs)
|
||||
if deriv:
|
||||
xs.extend(np.gradient(a) for a, _ in pairs)
|
||||
ys.extend(np.gradient(b) for _, b in pairs)
|
||||
dists = dtw_lite_batch(np.vstack(xs), np.vstack(ys), dtw_bandwidth)
|
||||
peak = max(current_peak, MATCH_MAE_PEAK_FLOOR)
|
||||
|
||||
def _score(dist: float, scale: float) -> float:
|
||||
scaled = (dist / MATCH_DTW_RESAMPLE_N) * MATCH_MAE_REF_PEAK / peak
|
||||
return scale / (scale + scaled)
|
||||
|
||||
k = len(pairs)
|
||||
if dtw_mode == "ensemble":
|
||||
return [
|
||||
ensemble_w * _score(float(dists[i]), l1_scale)
|
||||
+ (1.0 - ensemble_w) * _score(float(dists[k + i]), ddtw_scale)
|
||||
for i in range(k)
|
||||
]
|
||||
scale = ddtw_scale if deriv else l1_scale
|
||||
return [_score(float(d), scale) for d in dists]
|
||||
|
||||
|
||||
def _resample_to(arr: np.ndarray, n: int) -> np.ndarray:
|
||||
"""Linearly resample a 1-D array to exactly ``n`` points over its index span.
|
||||
|
||||
@@ -364,14 +462,15 @@ def _stage3_dtw_score(
|
||||
ddtw_scale: float,
|
||||
ensemble_w: float,
|
||||
curr_resampled: np.ndarray | None = None,
|
||||
) -> tuple[float, float]:
|
||||
"""``(dtw_score, norm_dist)`` for one candidate: the four-way ``dtw_mode``
|
||||
branch of the Stage-3 refinement.
|
||||
) -> float:
|
||||
"""The DTW score for one candidate: the four-way ``dtw_mode`` branch of the
|
||||
Stage-3 refinement.
|
||||
|
||||
Lifted verbatim out of ``compute_matches_worker`` so the Stage-3 loop and the
|
||||
Stage-6 prefix pass (#364) share one implementation and cannot drift apart.
|
||||
Behaviour-identical to the inlined version, including ``legacy`` mode's
|
||||
``dtw_dist / len(curr_arr)`` normalisation and its ``norm_dist`` bookkeeping.
|
||||
Lifted verbatim out of ``compute_matches_worker`` (for the #364 Stage-6 prefix
|
||||
pass, removed in 0.5.8). Behaviour-identical to the inlined version, including ``legacy`` mode's
|
||||
``dtw_dist / len(curr_arr)`` normalisation. (The normalised distance it also
|
||||
returned until audit MR-12 went to a ``dtw_dist`` candidate field nothing
|
||||
read, always 0.0 under the default ``ensemble``.)
|
||||
"""
|
||||
if dtw_mode == "legacy":
|
||||
# Original behaviour: raw sequences, distance / len(current),
|
||||
@@ -379,14 +478,13 @@ def _stage3_dtw_score(
|
||||
dtw_dist = compute_dtw_lite(curr_arr, sample_arr, band_width_ratio=dtw_bandwidth)
|
||||
n_points = len(curr_arr)
|
||||
norm_dist = (dtw_dist / n_points) if n_points > 0 else 999.0
|
||||
return 1.0 / (1.0 + norm_dist / MATCH_DTW_DIST_SCALE), norm_dist
|
||||
return 1.0 / (1.0 + norm_dist / MATCH_DTW_DIST_SCALE)
|
||||
if dtw_mode == "ensemble":
|
||||
# Blend the level-based (L1) and shape-based (derivative) DTW
|
||||
# scores; they are complementary signals.
|
||||
s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale, curr_resampled=curr_resampled)
|
||||
s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale, curr_resampled=curr_resampled)
|
||||
# composite; per-component distance not meaningful
|
||||
return ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd, 0.0
|
||||
return ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd
|
||||
# "scaled" (default) or "ddtw": resample both onto one grid so the
|
||||
# band and normalisation are consistent, then express the distance
|
||||
# relative to the current peak (behaviour-neutral at
|
||||
@@ -395,7 +493,16 @@ def _stage3_dtw_score(
|
||||
scale = ddtw_scale if use_deriv else l1_scale
|
||||
return _dtw_component_score(
|
||||
curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale, curr_resampled=curr_resampled
|
||||
), 0.0
|
||||
)
|
||||
|
||||
|
||||
def _rank_key(candidate: dict[str, Any]) -> float:
|
||||
"""Sort key for candidate ranking: a non-finite score ranks last, never first."""
|
||||
try:
|
||||
score = float(candidate.get("score", 0.0))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return float("-inf")
|
||||
return score if math.isfinite(score) else float("-inf")
|
||||
|
||||
|
||||
def compute_matches_worker(
|
||||
@@ -419,6 +526,10 @@ def compute_matches_worker(
|
||||
keep_min = float(config.get("keep_min_score", MATCH_KEEP_MIN_SCORE))
|
||||
corr_weight = float(config.get("corr_weight", MATCH_CORR_WEIGHT))
|
||||
dur_weight = float(config.get("duration_weight", MATCH_DURATION_WEIGHT))
|
||||
if config.get("in_progress"):
|
||||
dur_weight = float(
|
||||
config.get("duration_weight_in_progress", MATCH_DURATION_WEIGHT_IN_PROGRESS)
|
||||
)
|
||||
en_weight = float(config.get("energy_weight", MATCH_ENERGY_WEIGHT))
|
||||
dur_scale = float(config.get("duration_scale", MATCH_DURATION_SCALE))
|
||||
dur_overrun_scale = float(
|
||||
@@ -432,6 +543,7 @@ def compute_matches_worker(
|
||||
# match path only) so the final match at cycle end - where the whole-cycle
|
||||
# figures are the right comparison - is byte-identical.
|
||||
in_progress = bool(config.get("in_progress"))
|
||||
min_ratio_grace_s = float(config.get("min_ratio_grace_s", MATCH_MIN_RATIO_GRACE_S))
|
||||
# ...and Stages 2/3 score the SHAPE against the same truncated stretch, while the
|
||||
# cycle is still clearly mid-run (MATCH_PREFIX_SHAPE_MAX_RATIO). On by default
|
||||
# for a live match; `prefix_shape: False` turns it off for the A/B harnesses.
|
||||
@@ -447,10 +559,14 @@ def compute_matches_worker(
|
||||
profile_duration = item["avg_duration"]
|
||||
sample_power = item["sample_power"]
|
||||
|
||||
# Duration Check
|
||||
# Duration Check. The lower bound waits out MATCH_MIN_RATIO_GRACE_S of a live
|
||||
# match: early on it rejects every programme longer than the cycle is old.
|
||||
if profile_duration > 0:
|
||||
ratio = current_duration / profile_duration
|
||||
if ratio < min_duration_ratio or ratio > max_duration_ratio:
|
||||
lower_applies = not (
|
||||
in_progress and current_duration < min_ratio_grace_s
|
||||
)
|
||||
if (lower_applies and ratio < min_duration_ratio) or ratio > max_duration_ratio:
|
||||
continue
|
||||
|
||||
# Core Similarity. While the cycle is mid-run this compares it against the
|
||||
@@ -468,12 +584,14 @@ def compute_matches_worker(
|
||||
shape_pair = prefix_shape_arrays(
|
||||
curr_arr, sample_power, current_duration, span_s
|
||||
)
|
||||
# The alignment offset is not kept: nothing read it, and it was in different
|
||||
# units on the two paths (shared-grid index vs native index; audit MR-12).
|
||||
if shape_pair is not None:
|
||||
score, metrics, offset = find_best_alignment(
|
||||
score, metrics, _offset = find_best_alignment(
|
||||
shape_pair[0], shape_pair[1], 1.0, corr_weight=corr_weight
|
||||
)
|
||||
else:
|
||||
score, metrics, offset = find_best_alignment(
|
||||
score, metrics, _offset = find_best_alignment(
|
||||
current_power, sample_power, 1.0, corr_weight=corr_weight
|
||||
)
|
||||
|
||||
@@ -490,12 +608,11 @@ def compute_matches_worker(
|
||||
"_shape_pair": shape_pair,
|
||||
# True wall-clock span of `sample`, for prefix truncation (#364).
|
||||
# Falls back to profile_duration so the other snapshot builders
|
||||
# (devtools, matching_tuner, playground) keep working unchanged.
|
||||
# (devtools, playground) keep working unchanged.
|
||||
"sample_span_s": float(item.get("sample_span_s") or profile_duration or 0.0),
|
||||
"offset": offset
|
||||
})
|
||||
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
candidates.sort(key=_rank_key, reverse=True)
|
||||
|
||||
# Stage 3: DTW Refinement on the top N candidates
|
||||
if dtw_bandwidth > 0.0 and len(candidates) > 0:
|
||||
@@ -512,7 +629,35 @@ def compute_matches_worker(
|
||||
# Resample the current trace once — it's the same for every candidate.
|
||||
curr_resampled = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
|
||||
|
||||
for cand in to_refine:
|
||||
batched: list[float] | None = None
|
||||
if dtw_mode in ("scaled", "ddtw", "ensemble") and to_refine:
|
||||
# One batched DTW for every refine (audit MR-05: Stage 3 was ~89% of
|
||||
# matcher CPU). Falls back to the per-candidate path on any error.
|
||||
try:
|
||||
grid_pairs = []
|
||||
for cand in to_refine:
|
||||
pair = cand.get("_shape_pair")
|
||||
if pair is not None:
|
||||
a = _resample_to(pair[0], MATCH_DTW_RESAMPLE_N)
|
||||
b = _resample_to(pair[1], MATCH_DTW_RESAMPLE_N)
|
||||
else:
|
||||
a = curr_resampled
|
||||
b = _resample_to(np.array(cand["sample"]), MATCH_DTW_RESAMPLE_N)
|
||||
grid_pairs.append((a, b))
|
||||
batched = _stage3_scores_batched(
|
||||
grid_pairs, current_peak, dtw_mode=dtw_mode,
|
||||
dtw_bandwidth=dtw_bandwidth, l1_scale=l1_scale,
|
||||
ddtw_scale=ddtw_scale, ensemble_w=ensemble_w,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.debug("batched Stage-3 DTW failed; per-candidate path", exc_info=True)
|
||||
batched = None
|
||||
|
||||
for idx, cand in enumerate(to_refine):
|
||||
if batched is not None:
|
||||
cand["original_score"] = float(cand["score"])
|
||||
cand["score"] = float(blend * cand["score"] + (1.0 - blend) * batched[idx])
|
||||
continue
|
||||
pair = cand.get("_shape_pair")
|
||||
if pair is not None:
|
||||
warp_curr, sample_arr, cand_resampled = pair[0], pair[1], None
|
||||
@@ -521,7 +666,7 @@ def compute_matches_worker(
|
||||
curr_arr, np.array(cand["sample"]), curr_resampled
|
||||
)
|
||||
|
||||
dtw_score, norm_dist = _stage3_dtw_score(
|
||||
dtw_score = _stage3_dtw_score(
|
||||
warp_curr,
|
||||
sample_arr,
|
||||
current_peak,
|
||||
@@ -535,9 +680,8 @@ def compute_matches_worker(
|
||||
|
||||
cand["original_score"] = float(cand["score"])
|
||||
cand["score"] = float(blend * cand["score"] + (1.0 - blend) * dtw_score)
|
||||
cand["dtw_dist"] = float(norm_dist)
|
||||
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
candidates.sort(key=_rank_key, reverse=True)
|
||||
|
||||
# The truncated pair is scratch for the two shape stages; it must not reach the
|
||||
# MatchResult ranking (numpy arrays, and the store/WS serialise that dict).
|
||||
@@ -565,6 +709,10 @@ def compute_matches_worker(
|
||||
# "integrated" compares true integrated energy (mean x duration). Opt-in so
|
||||
# the historical default is byte-for-byte preserved. See register item 99.
|
||||
integrated = config.get("energy_mode", "mean") == "integrated"
|
||||
own_energy = (
|
||||
{str(s.get("name")): s.get("energy_ref") for s in snapshots}
|
||||
if integrated and not in_progress else {}
|
||||
)
|
||||
cur_mean = float(np.mean(curr_arr))
|
||||
cur_energy = cur_mean * current_duration if integrated else cur_mean
|
||||
for cand in candidates:
|
||||
@@ -597,6 +745,17 @@ def compute_matches_worker(
|
||||
# time as cur_energy does, so a prefix mean is scaled by the elapsed
|
||||
# duration and a whole-template mean by the candidate's own duration.
|
||||
cand_energy = cand_mean * cand_span if integrated else cand_mean
|
||||
if integrated and not in_progress:
|
||||
# A COMPLETED cycle is graded against the median energy of the
|
||||
# profile's own cycles, not mean(template) x duration: on a warped
|
||||
# envelope that inherits the reference cycle's heating length (w7g).
|
||||
# Not while running: rescaling the prefix by median/template gained
|
||||
# 0.8-1.6pp top-1 at 25-75% elapsed, but the live matches it moved
|
||||
# are the ones the end gates read (end_gate_eval --loo: 2 new ends
|
||||
# > 5 min early, washer-dryer median lag +0.7 min).
|
||||
own = own_energy_ws(own_energy.get(str(cand["name"])))
|
||||
if own is not None:
|
||||
cand_energy = own
|
||||
en_ag = _agreement(cur_energy, cand_energy, en_scale)
|
||||
cand["shape_score"] = float(cand["score"])
|
||||
cand["score"] = float(
|
||||
@@ -604,14 +763,17 @@ def compute_matches_worker(
|
||||
+ dur_w * dur_ag
|
||||
+ en_w * en_ag
|
||||
)
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
candidates.sort(key=_rank_key, reverse=True)
|
||||
|
||||
# Stage 6 (#364): prefix scores for the few candidates materially LONGER than
|
||||
# the winner. Purely additive - it writes `prefix_score` and never touches
|
||||
# `score`, so ranking is provably unchanged. Must run after the Stage-4
|
||||
# re-sort because the anchor is the winner's duration.
|
||||
annotate_prefix_scores(candidates, curr_arr, current_duration, config)
|
||||
# A non-finite score (a NaN in an imported template, a NaN avg_duration) used to
|
||||
# sort to rank 1 with a NaN margin that was never "ambiguous" (audit
|
||||
# MATCH-CORE-05). It carries no evidence: drop it.
|
||||
candidates[:] = [c for c in candidates if math.isfinite(_rank_key(c))]
|
||||
|
||||
# (Stage 6, the #364 prefix scores for the Smart-Termination guard, was removed
|
||||
# in 0.5.8: Stages 2/3 already score a running cycle on each candidate's
|
||||
# truncated curve, so the guard fired on 0 of 713 genuine ends and 0 of 7
|
||||
# quiet split positives on the shipped matcher - devtools/prefix_guard_eval.py.)
|
||||
return candidates
|
||||
|
||||
def prefix_mean(
|
||||
@@ -630,7 +792,7 @@ def prefix_mean(
|
||||
Falls back to the whole template (and the candidate's own duration) when the
|
||||
cycle has already outlasted it: that candidate has finished, so its total is
|
||||
the honest comparison. Deliberately NOT ``_prefix_point_count``: that helper's
|
||||
12-sample floor exists because Stage 6 *correlates* the prefix, while a mean
|
||||
12-sample floor exists because Stages 2/3 *correlate* the prefix, while a mean
|
||||
over a handful of leading samples is perfectly well defined - applying the
|
||||
floor here would silently restore whole-template energy for the first few
|
||||
percent of every cycle, which is exactly the window #400 is about.
|
||||
@@ -643,6 +805,69 @@ def prefix_mean(
|
||||
return float(np.mean(sample)), profile_duration
|
||||
|
||||
|
||||
def member_energy_reference(
|
||||
members: list[tuple[list[float], list[float], float]],
|
||||
) -> dict[str, Any] | None:
|
||||
"""A profile's energy taken from its OWN cycles, for Stage 4.
|
||||
|
||||
``members`` are the ``(offsets, watts, duration)`` triples the envelope is built
|
||||
from. Each member's energy is measured the way Stage 4 measures the cycle being
|
||||
matched - mean of the linearly interpolated trace x duration - so both sides of
|
||||
the agreement are the same quantity. ``{"n": members used, "median_wh": median
|
||||
whole-cycle Wh}``, or None without a usable member.
|
||||
|
||||
Why (w7g): Stage 4 took a profile's energy as ``mean(template) x avg_duration``.
|
||||
On a DTW-warped envelope that inherits the heating length of the cycle the
|
||||
members were warped onto: > 20% off its own members' median on 17 of 85 washer
|
||||
profiles in the corpus, 2.5x on one.
|
||||
"""
|
||||
totals: list[float] = []
|
||||
for offsets, watts, duration in members:
|
||||
t = np.asarray(offsets, dtype=float)
|
||||
p = np.asarray(watts, dtype=float)
|
||||
if t.size != p.size or t.size < 2:
|
||||
continue
|
||||
ok = np.isfinite(t) & np.isfinite(p)
|
||||
t, p = t[ok], p[ok]
|
||||
if t.size < 2:
|
||||
continue
|
||||
order = np.argsort(t, kind="mergesort")
|
||||
t, p = t[order], p[order]
|
||||
span = float(t[-1] - t[0])
|
||||
if span <= 0:
|
||||
continue
|
||||
try:
|
||||
dur = float(duration)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
dur = 0.0
|
||||
if not math.isfinite(dur) or dur <= 0:
|
||||
dur = span
|
||||
energy_ws = integrate_wh(t, p) * 3600.0 / span * dur
|
||||
if math.isfinite(energy_ws) and energy_ws > 0:
|
||||
totals.append(energy_ws)
|
||||
if not totals:
|
||||
return None
|
||||
return {"n": len(totals), "median_wh": round(float(np.median(totals)) / 3600.0, 3)}
|
||||
|
||||
|
||||
def own_energy_ws(ref: dict[str, Any] | None) -> float | None:
|
||||
"""The whole-cycle energy (W*s) Stage 4 expects from a profile's own cycles.
|
||||
|
||||
The median of a :func:`member_energy_reference`, or None (keep the template's
|
||||
``mean x duration``) without one of at least ``MATCH_ENERGY_REF_MIN_CYCLES``
|
||||
cycles or with a malformed one.
|
||||
"""
|
||||
if not isinstance(ref, dict):
|
||||
return None
|
||||
try:
|
||||
if int(ref.get("n") or 0) < MATCH_ENERGY_REF_MIN_CYCLES:
|
||||
return None
|
||||
own_ws = float(ref["median_wh"]) * 3600.0
|
||||
except (TypeError, ValueError, KeyError, OverflowError):
|
||||
return None
|
||||
return own_ws if math.isfinite(own_ws) and own_ws > 0 else None
|
||||
|
||||
|
||||
def _prefix_point_count(
|
||||
n_points: int, current_duration: float, sample_span_s: float
|
||||
) -> int:
|
||||
@@ -652,12 +877,12 @@ def _prefix_point_count(
|
||||
the whole template (then it is not a prefix), or when too few points remain to
|
||||
judge. Fraction-of-array is the right operator because every snapshot flavour
|
||||
is uniform in time over its own span (envelope: np.linspace; sample cycle:
|
||||
resample_uniform at a fixed dt; group aggregate: np.interp onto 200 points).
|
||||
resample_uniform at a fixed dt). (The group aggregate snapshot is gone, #400.)
|
||||
"""
|
||||
if n_points < SMART_TERM_PREFIX_MIN_POINTS or sample_span_s <= 0 or current_duration <= 0:
|
||||
if n_points < MATCH_PREFIX_MIN_POINTS or sample_span_s <= 0 or current_duration <= 0:
|
||||
return 0
|
||||
k = int(round(n_points * (current_duration / sample_span_s)))
|
||||
if k < SMART_TERM_PREFIX_MIN_POINTS or k >= n_points:
|
||||
if k < MATCH_PREFIX_MIN_POINTS or k >= n_points:
|
||||
return 0
|
||||
return k
|
||||
|
||||
@@ -671,140 +896,38 @@ def prefix_shape_arrays(
|
||||
"""``(current, template)`` on one grid, with the template TRUNCATED to the
|
||||
elapsed time - or None when it cannot be truncated meaningfully.
|
||||
|
||||
One definition of "the same stretch of both curves", shared by the two callers
|
||||
that need it: the live Stage-2/3 shape scoring (#400) and the Stage-6 prefix
|
||||
guard (#364). Both series go onto a shared grid so an index offset equals a time
|
||||
offset regardless of the template's native cadence; the grid also honours the
|
||||
#388 OOM cap. The 12-sample floor is real here (unlike in ``prefix_mean``):
|
||||
these arrays get correlated and warped, not averaged.
|
||||
One definition of "the same stretch of both curves" for the live Stage-2/3
|
||||
shape scoring (#400). (It also fed the #364 Stage-6 prefix guard, removed in
|
||||
0.5.8; ``devtools/prefix_guard_eval.py`` keeps a copy of that rule.) Both
|
||||
series go onto a shared grid so an index offset equals a time offset
|
||||
regardless of the template's native cadence; the grid also honours the #388
|
||||
OOM cap. The 12-sample floor is real here (unlike in ``prefix_mean``): these
|
||||
arrays get correlated and warped, not averaged.
|
||||
|
||||
The grid is shared **between the two series**, not across candidates: ``k``
|
||||
is the candidate's own truncated point count, so a longer template can be
|
||||
scored on a finer grid than a shorter one. That asymmetry is deliberate and
|
||||
measured. Capping the grid at ``k`` is what stops ``arr[:k]`` being upsampled
|
||||
past the points it actually has, which would invent template detail the
|
||||
recording never contained. Dropping the ``k`` term to make the grid purely
|
||||
candidate-independent (``min(curr_arr.size, MAX_ALIGN_GRID_POINTS)``) was
|
||||
tried and measured on ``devtools/prefix_guard_eval.py``: at the shipped
|
||||
constants it takes the #364 split guard from **59/114 caught (52%) to 52/114
|
||||
(46%)** while removing only 2 of 13 false blocks. A miss there is a SPLIT
|
||||
CYCLE and a false block is merely a later finish, so that trade is
|
||||
net-negative. ``devtools/dtw_ab_eval.py`` is byte-identical either way
|
||||
(it scores only complete cycles, which never take the prefix path), so it
|
||||
cannot be used to judge this function - use ``prefix_guard_eval.py``.
|
||||
scored on a finer grid than a shorter one. Capping the grid at ``k`` is what
|
||||
stops ``arr[:k]`` being upsampled past the points it actually has, which
|
||||
would invent template detail the recording never contained. (The one A/B of
|
||||
dropping the ``k`` term was measured on the removed guard, not on matching.)
|
||||
``devtools/dtw_ab_eval.py`` scores only complete cycles, which never take
|
||||
the prefix path, so it cannot judge this function - use ``devtools/eval.py``.
|
||||
"""
|
||||
arr = np.asarray(sample, dtype=float)
|
||||
k = _prefix_point_count(arr.size, current_duration, sample_span_s)
|
||||
if k == 0:
|
||||
return None
|
||||
grid = int(min(curr_arr.size, k, MAX_ALIGN_GRID_POINTS))
|
||||
if grid < SMART_TERM_PREFIX_MIN_POINTS:
|
||||
if grid < MATCH_PREFIX_MIN_POINTS:
|
||||
return None
|
||||
return _resample_to(curr_arr, grid), _resample_to(arr[:k], grid)
|
||||
|
||||
|
||||
def prefix_shape_score(
|
||||
curr_arr: np.ndarray,
|
||||
sample: list[float] | np.ndarray,
|
||||
current_duration: float,
|
||||
sample_span_s: float,
|
||||
current_peak: float,
|
||||
config: dict[str, Any],
|
||||
) -> float | None:
|
||||
"""Score the live trace against ``sample`` TRUNCATED to ``current_duration``.
|
||||
|
||||
The #288 landscape guard asks whether a longer candidate has a decent shape
|
||||
score against its **whole** curve - which a part-way-through trace cannot
|
||||
have. This asks the question that actually matters: does the trace look like
|
||||
the *beginning* of that longer programme? (#364)
|
||||
|
||||
Same scale as ``shape_score`` by construction: identical Stage-2 formula
|
||||
(``find_best_alignment``) and identical Stage-3 DTW blend, only the reference
|
||||
array differs. Returns None when the template cannot be truncated meaningfully.
|
||||
|
||||
NB prefix scoring normalizes on the shared resample ``grid`` (both series are
|
||||
resampled to it), so it does not support the non-default ``dtw_mode="legacy"``
|
||||
absolute-watt/length normalization - under which cross-candidate prefix scores of
|
||||
differing native length would not be comparable. This is inert in production: the
|
||||
default is ``"ensemble"`` and the live ProfileStore path never sets ``dtw_mode``;
|
||||
``"legacy"`` exists only for the devtools re-sweep harness.
|
||||
"""
|
||||
pair = prefix_shape_arrays(curr_arr, sample, current_duration, sample_span_s)
|
||||
if pair is None:
|
||||
return None
|
||||
a, b = pair
|
||||
|
||||
corr_weight = float(config.get("corr_weight", MATCH_CORR_WEIGHT))
|
||||
score, _metrics, _offset = find_best_alignment(a, b, 1.0, corr_weight=corr_weight)
|
||||
|
||||
# The SAME default as `compute_matches_worker` (register item 309). Both read
|
||||
# the same unmutated `config` in one match, so a caller that omits the key
|
||||
# would otherwise get DEFAULT_DTW_BANDWIDTH for Stage 3 and the old 0.1 here,
|
||||
# and the two stages would disagree about which candidates look like a prefix.
|
||||
dtw_bandwidth = float(config.get("dtw_bandwidth", DEFAULT_DTW_BANDWIDTH))
|
||||
if dtw_bandwidth > 0.0:
|
||||
dtw_score, _ = _stage3_dtw_score(
|
||||
a,
|
||||
b,
|
||||
current_peak,
|
||||
dtw_mode=str(config.get("dtw_mode", DEFAULT_DTW_MODE)),
|
||||
dtw_bandwidth=dtw_bandwidth,
|
||||
l1_scale=float(config.get("dtw_l1_scale", MATCH_DTW_DIST_SCALE)),
|
||||
ddtw_scale=float(config.get("dtw_ddtw_scale", MATCH_DDTW_DIST_SCALE)),
|
||||
ensemble_w=float(config.get("dtw_ensemble_w", MATCH_DTW_ENSEMBLE_W)),
|
||||
)
|
||||
blend = float(config.get("dtw_blend", MATCH_DTW_BLEND))
|
||||
return float(blend * score + (1.0 - blend) * dtw_score)
|
||||
return float(score)
|
||||
|
||||
|
||||
def annotate_prefix_scores(
|
||||
candidates: list[dict[str, Any]],
|
||||
curr_arr: np.ndarray,
|
||||
current_duration: float,
|
||||
config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Stage 6 (#364): write ``prefix_score`` on the few non-winning candidates
|
||||
that are materially longer than the winner.
|
||||
|
||||
Mutates in place and never touches ``score``/``shape_score``, so candidate
|
||||
ranking is unaffected - this only feeds the Smart-Termination prefix guard.
|
||||
Every test before the first array touch is a scalar compare, so the common
|
||||
case (no candidate is materially longer) costs nothing.
|
||||
"""
|
||||
if current_duration <= 0 or len(candidates) < 2 or curr_arr.size == 0:
|
||||
return
|
||||
best_dur = float(candidates[0].get("profile_duration") or 0.0)
|
||||
if best_dur <= 0:
|
||||
return
|
||||
min_dur = best_dur * SMART_TERM_PREFIX_MIN_RATIO
|
||||
current_peak = float(np.max(curr_arr))
|
||||
scored = 0
|
||||
for cand in candidates[1:]:
|
||||
prof_dur = float(cand.get("profile_duration") or 0.0)
|
||||
if prof_dur <= min_dur:
|
||||
continue # not a longer look-alike
|
||||
if prof_dur <= current_duration:
|
||||
continue # we already outlasted it, so we are not inside its prefix
|
||||
span = float(cand.get("sample_span_s") or prof_dur)
|
||||
if span < prof_dur * SMART_TERM_PREFIX_MIN_COVERAGE:
|
||||
continue # gap-truncated template: may not start at the programme's start
|
||||
score = prefix_shape_score(
|
||||
curr_arr, cand.get("sample") or [], current_duration, span, current_peak, config
|
||||
)
|
||||
if score is None:
|
||||
continue
|
||||
cand["prefix_score"] = float(score)
|
||||
scored += 1
|
||||
if scored >= SMART_TERM_PREFIX_MAX_CANDIDATES:
|
||||
break
|
||||
|
||||
|
||||
def _dtw_cost_matrix_scalar(
|
||||
x: np.ndarray, y: np.ndarray, n: int, m: int, w: int
|
||||
) -> np.ndarray:
|
||||
"""Reference (scalar) Sakoe-Chiba DTW cost-matrix fill. Kept verbatim as the
|
||||
fallback for :func:`_dtw_cost_matrix_vectorized` so behavior can never regress."""
|
||||
fallback for :func:`_dtw_cost_banded` so behavior can never regress."""
|
||||
cost_matrix = np.full((n + 1, m + 1), float("inf"))
|
||||
cost_matrix[0, 0] = 0
|
||||
for i in range(1, n + 1):
|
||||
@@ -819,48 +942,106 @@ def _dtw_cost_matrix_scalar(
|
||||
return cost_matrix
|
||||
|
||||
|
||||
def _dtw_cost_matrix_vectorized(
|
||||
class _BandedCostMatrix:
|
||||
"""A Sakoe-Chiba DTW cost matrix that stores only its in-band cells.
|
||||
|
||||
Cells are kept per anti-diagonal ``d = i + j`` (the order the fill computes
|
||||
them), each diagonal padded with one ``inf`` cell on either side. ``get``
|
||||
returns exactly what the full ``(n+1) x (m+1)`` matrix held at ``(i, j)``:
|
||||
the computed value in band, ``inf`` everywhere else (audit LIVE-20).
|
||||
"""
|
||||
|
||||
__slots__ = ("_vals", "_first", "_count", "_start")
|
||||
|
||||
def __init__(self, vals: np.ndarray, first: list[int], count: list[int], start: list[int]) -> None:
|
||||
self._vals = vals
|
||||
self._first = first
|
||||
self._count = count
|
||||
self._start = start
|
||||
|
||||
def get(self, i: int, j: int) -> float:
|
||||
d = i + j
|
||||
k = i - self._first[d]
|
||||
if 0 <= k < self._count[d]:
|
||||
# .item(): a Python float, without boxing the whole array (a .tolist()
|
||||
# copy of a 2000-point band costs ~4x the band itself).
|
||||
return self._vals.item(self._start[d] + 1 + k)
|
||||
return math.inf
|
||||
|
||||
|
||||
def _dtw_cost_banded(
|
||||
x: np.ndarray, y: np.ndarray, n: int, m: int, w: int
|
||||
) -> np.ndarray:
|
||||
"""Bit-identical vectorized fill of the scalar cost matrix.
|
||||
) -> _BandedCostMatrix:
|
||||
"""Bit-identical vectorized fill of the scalar cost matrix, in-band cells only.
|
||||
|
||||
The DTW recurrence is sequential, but all cells on one anti-diagonal
|
||||
(``i + j`` constant) depend only on earlier anti-diagonals, so each diagonal
|
||||
is one vectorized NumPy update instead of thousands of Python ``min``/``abs``
|
||||
calls. The Sakoe-Chiba band, the per-row bounds (``int`` truncation), the
|
||||
``local + min(up, left, diag)`` recurrence and out-of-band ``inf`` cells all
|
||||
match the scalar loop exactly, so the resulting matrix - and the backtracked
|
||||
path - is identical. (#311 follow-up: this fill dominates envelope rebuilds.)
|
||||
(``i + j`` constant) depend only on the two before it, so each diagonal is
|
||||
one vectorized NumPy update. The Sakoe-Chiba band, the per-row bounds
|
||||
(``int`` truncation), the ``local + min(min(up, left), diag)`` recurrence and
|
||||
the out-of-band ``inf`` cells match the scalar loop exactly, so every cell -
|
||||
and the backtracked path - is identical. (#311 follow-up: this fill
|
||||
dominates envelope rebuilds.)
|
||||
|
||||
Until audit LIVE-20 it filled a full ``(n+1) x (m+1)`` matrix and masked all
|
||||
of every anti-diagonal down to the band. Both band edges are non-decreasing
|
||||
in ``i``, so the in-band cells of a diagonal are one contiguous run of rows,
|
||||
found by a binary search; and a run moves by at most one row from one
|
||||
diagonal to the next, so one ``inf`` pad cell per side makes every
|
||||
predecessor lookup a slice. A 2000 x 2000 pair at the default 20% band
|
||||
stores ~2.5x fewer cells and fills ~4-8x faster.
|
||||
"""
|
||||
xf = np.asarray(x, dtype=float)
|
||||
yf = np.asarray(y, dtype=float)
|
||||
cost_matrix = np.full((n + 1, m + 1), np.inf)
|
||||
cost_matrix[0, 0] = 0.0
|
||||
# Per-row band bounds, identical to the scalar start_j/end_j (int truncates
|
||||
# toward zero, matching Python int()).
|
||||
i_idx = np.arange(1, n + 1)
|
||||
center = i_idx * (m / n)
|
||||
lo = np.maximum(1, (center - w).astype(np.int64))
|
||||
hi = np.minimum(m, (center + w).astype(np.int64) + 1)
|
||||
# Rows in band on diagonal d: i + lo[i] <= d <= i + hi[i] (both sides strictly
|
||||
# increasing in i), inside the matrix (1 <= i <= n, 1 <= d - i <= m).
|
||||
d_all = np.arange(n + m + 1)
|
||||
first = np.searchsorted(i_idx + hi, d_all, side="left") + 1
|
||||
last = np.searchsorted(i_idx + lo, d_all, side="right")
|
||||
first = np.maximum(first, np.maximum(1, d_all - m))
|
||||
last = np.minimum(last, np.minimum(n, d_all - 1))
|
||||
count = np.maximum(0, last - first + 1)
|
||||
first[0], count[0] = 0, 1 # the origin (0, 0)
|
||||
start = np.zeros(n + m + 2, dtype=np.int64)
|
||||
np.cumsum(count + 2, out=start[1:])
|
||||
vals = np.full(int(start[-1]), np.inf)
|
||||
vals[1] = 0.0
|
||||
first_l: list[int] = first.tolist()
|
||||
count_l: list[int] = count.tolist()
|
||||
start_l: list[int] = start.tolist()
|
||||
|
||||
def run(e: int, p: int, q: int) -> np.ndarray:
|
||||
"""Cells of rows ``p..q`` on diagonal ``e`` (``inf`` outside its band)."""
|
||||
fe, ce = first_l[e], count_l[e]
|
||||
base = start_l[e] + 1 - fe
|
||||
if p >= fe - 1 and q <= fe + ce:
|
||||
return vals[base + p: base + q + 1]
|
||||
out = np.full(q - p + 1, np.inf)
|
||||
a, b = max(p, fe), min(q, fe + ce - 1)
|
||||
if a <= b:
|
||||
out[a - p: b - p + 1] = vals[base + a: base + b + 1]
|
||||
return out
|
||||
|
||||
for d in range(2, n + m + 1):
|
||||
i_lo = max(1, d - m)
|
||||
i_hi = min(n, d - 1)
|
||||
if i_lo > i_hi:
|
||||
c = count_l[d]
|
||||
if c <= 0:
|
||||
continue
|
||||
ii = np.arange(i_lo, i_hi + 1)
|
||||
jj = d - ii
|
||||
inb = (jj >= lo[ii - 1]) & (jj <= hi[ii - 1])
|
||||
if not inb.any():
|
||||
continue
|
||||
ib = ii[inb]
|
||||
jb = jj[inb]
|
||||
local = np.abs(xf[ib - 1] - yf[jb - 1])
|
||||
p = first_l[d]
|
||||
q = p + c - 1
|
||||
# cells (i, d - i) for i = p..q: x[i - 1] against y[d - i - 1]
|
||||
local = np.abs(xf[p - 1: q] - yf[d - q - 1: d - p][::-1])
|
||||
best = np.minimum(
|
||||
np.minimum(cost_matrix[ib - 1, jb], cost_matrix[ib, jb - 1]),
|
||||
cost_matrix[ib - 1, jb - 1],
|
||||
np.minimum(run(d - 1, p - 1, q - 1), run(d - 1, p, q)), # up, left
|
||||
run(d - 2, p - 1, q - 1), # diag
|
||||
)
|
||||
cost_matrix[ib, jb] = local + best
|
||||
return cost_matrix
|
||||
s = start_l[d] + 1
|
||||
np.add(local, best, out=vals[s: s + c])
|
||||
return _BandedCostMatrix(vals, first_l, count_l, start_l)
|
||||
|
||||
|
||||
def compute_dtw_path(
|
||||
@@ -874,11 +1055,13 @@ def compute_dtw_path(
|
||||
if n == 0 or m == 0:
|
||||
return []
|
||||
|
||||
# Pre-flight memory guard: the cost matrix is (n+1)x(m+1) float64. An
|
||||
# uncapped call from a 1 Hz long cycle can request >1 GB here. If the
|
||||
# allocation would exceed ~80 MB, skip DTW and return an empty path so
|
||||
# Pre-flight memory guard: the scalar fallback's cost matrix is (n+1)x(m+1)
|
||||
# float64. An uncapped call from a 1 Hz long cycle can request >1 GB there.
|
||||
# If the allocation would exceed ~80 MB, skip DTW and return an empty path so
|
||||
# the caller falls back to linear interpolation (graceful degrade rather
|
||||
# than OOM-killing Home Assistant — issue #388).
|
||||
# than OOM-killing Home Assistant, issue #388). The banded fill needs far
|
||||
# less, but the cap stays where it was so which pairs get a path is unchanged
|
||||
# (``_closed_end_path_exists`` mirrors it).
|
||||
_DTW_CELL_BUDGET = 10_000_000 # 10 M cells x 8 B ≈ 80 MB
|
||||
if (n + 1) * (m + 1) > _DTW_CELL_BUDGET:
|
||||
_LOGGER.warning(
|
||||
@@ -890,12 +1073,13 @@ def compute_dtw_path(
|
||||
|
||||
w = max(1, int(min(n, m) * band_width_ratio))
|
||||
try:
|
||||
cost_matrix = _dtw_cost_matrix_vectorized(x, y, n, m, w)
|
||||
cost = _dtw_cost_banded(x, y, n, m, w).get
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
cost_matrix = _dtw_cost_matrix_scalar(x, y, n, m, w)
|
||||
full = _dtw_cost_matrix_scalar(x, y, n, m, w)
|
||||
cost = lambda i, j: full[i, j] # noqa: E731
|
||||
|
||||
# Backtracking
|
||||
if np.isinf(cost_matrix[n, m]):
|
||||
if math.isinf(cost(n, m)):
|
||||
# Endpoint is unreachable (e.g. Sakoe-Chiba band excluded it); no valid path.
|
||||
return []
|
||||
|
||||
@@ -912,9 +1096,9 @@ def compute_dtw_path(
|
||||
i -= 1
|
||||
else:
|
||||
candidates_cost = [
|
||||
(cost_matrix[i - 1, j], 0), # deletion (i-1)
|
||||
(cost_matrix[i, j - 1], 1), # insertion (j-1)
|
||||
(cost_matrix[i - 1, j - 1], 2) # match (both)
|
||||
(cost(i - 1, j), 0), # deletion (i-1)
|
||||
(cost(i, j - 1), 1), # insertion (j-1)
|
||||
(cost(i - 1, j - 1), 2) # match (both)
|
||||
]
|
||||
candidates_cost.sort(key=lambda item: item[0])
|
||||
best_move = candidates_cost[0][1]
|
||||
@@ -967,7 +1151,7 @@ def compute_envelope_worker(
|
||||
try:
|
||||
offsets_list, values_list, *rest = curve
|
||||
curve_duration = rest[0] if rest else None
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
continue
|
||||
|
||||
if not offsets_list or not values_list:
|
||||
@@ -986,7 +1170,7 @@ def compute_envelope_worker(
|
||||
try:
|
||||
offsets = np.asarray(offsets_list, dtype=float)
|
||||
values = np.asarray(values_list, dtype=float)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
|
||||
# Drop paired entries where either coordinate is non-finite.
|
||||
@@ -1202,7 +1386,8 @@ def align_trace_to_envelope(
|
||||
|
||||
**Known limit, measured and accepted (register item 347).** The observed span
|
||||
here is ``t_obs[-1]``, while ``_rebuild_envelope_sync`` builds each member
|
||||
over ``manual_duration or max(last_offset, stored_duration)``. Where the
|
||||
over ``manual_duration`` (when plausible for its trace, audit MATCH-EVAL-17)
|
||||
or ``max(last_offset, stored_duration)``. Where the
|
||||
stored duration runs past the last sample the build covered a slightly longer
|
||||
span than this re-derivation does, so the warp is not bit-for-bit the build's
|
||||
own. Measured over the 703 stored cycles in the maintainer's corpus it
|
||||
@@ -1285,6 +1470,28 @@ def align_trace_to_envelope(
|
||||
return _proportional(), False
|
||||
|
||||
|
||||
_DTW_PATH_CELL_BUDGET = 10_000_000 # compute_dtw_path's matrix cap
|
||||
|
||||
|
||||
def _closed_end_path_exists(n: int, m: int, band_width_ratio: float) -> bool:
|
||||
"""Whether ``compute_dtw_path`` would return a path for an ``n x m`` pair.
|
||||
|
||||
Exactly its three failure cases without filling the matrix: an empty side,
|
||||
the cell budget, and an end cell the Sakoe-Chiba band cannot reach. Each row's
|
||||
band is ``[lo, hi]`` (the fill's own truncation); cells are reachable left to
|
||||
right from the diagonal, so the end is reachable iff no row's band starts more
|
||||
than one column past where the previous row's ends (row 0 is the origin).
|
||||
"""
|
||||
if n == 0 or m == 0 or (n + 1) * (m + 1) > _DTW_PATH_CELL_BUDGET:
|
||||
return False
|
||||
w = max(1, int(min(n, m) * band_width_ratio))
|
||||
center = np.arange(1, n + 1) * (m / n)
|
||||
lo = np.maximum(1, (center - w).astype(np.int64))
|
||||
hi = np.minimum(m, (center + w).astype(np.int64) + 1)
|
||||
prev_hi = np.concatenate(([0], hi[:-1]))
|
||||
return bool(np.all(lo <= prev_hi + 1) and np.all(lo <= hi) and hi[-1] == m)
|
||||
|
||||
|
||||
def verify_profile_alignment_worker(
|
||||
current_power: list[float],
|
||||
envelope_avg_curve: list[float],
|
||||
@@ -1321,17 +1528,19 @@ def verify_profile_alignment_worker(
|
||||
if offset < 0:
|
||||
curr_seg = curr[-offset:]
|
||||
|
||||
path = compute_dtw_path(curr_seg, ref_seg, band_width_ratio=dtw_bandwidth)
|
||||
|
||||
if not path:
|
||||
# The DTW that used to run here was closed-end: its backtrack starts at the last
|
||||
# cell, so the mapped index was ALWAYS the window's last index whenever a path
|
||||
# existed - 34x the CPU and a 33-42 MiB matrix per tick for a constant (audit
|
||||
# LIVE-03). Same answer, same fallback, no matrix: a path exists unless the
|
||||
# window is empty, over the old cell budget, or the band cannot reach the end
|
||||
# cell. The resulting position runs ALIGNMENT_CONTEXT_BUFFER // 2 steps ahead
|
||||
# of the trace; measured harmless-to-helpful, so it is kept, not "fixed".
|
||||
if _closed_end_path_exists(len(curr_seg), len(ref_seg), dtw_bandwidth):
|
||||
mapped_idx = end_ref - 1
|
||||
else:
|
||||
# Fallback to linear mapping based on offset
|
||||
mapped_idx = min(len(ref)-1, offset + len(curr) - 1)
|
||||
mapped_idx = max(0, mapped_idx)
|
||||
else:
|
||||
# Map the final point of the current trace to the reference index
|
||||
last_pair = path[-1]
|
||||
ref_seg_idx = last_pair[1]
|
||||
mapped_idx = start_ref + ref_seg_idx
|
||||
|
||||
# Ensure sequences are non-empty before indexing
|
||||
if not envelope_time_grid or len(ref) == 0:
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
@@ -26,10 +27,12 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.translation import async_get_cached_translations
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
STATE_RUNNING,
|
||||
CYCLE_IN_PROGRESS_STATES,
|
||||
SIGNAL_WASHER_UPDATE,
|
||||
CONF_EXPOSE_DEBUG_ENTITIES,
|
||||
)
|
||||
@@ -44,7 +47,10 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the binary sensor."""
|
||||
manager: WashDataManager = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = [WasherRunningBinarySensor(manager, entry)]
|
||||
entities = [
|
||||
WasherRunningBinarySensor(manager, entry),
|
||||
WasherMaintenanceDueBinarySensor(manager, entry),
|
||||
]
|
||||
|
||||
if entry.options.get(CONF_EXPOSE_DEBUG_ENTITIES):
|
||||
entities.append(WasherAmbiguitySensor(manager, entry))
|
||||
@@ -56,6 +62,8 @@ async def async_setup_entry(
|
||||
class WasherRunningBinarySensor(BinarySensorEntity):
|
||||
"""Binary sensor indicating if washer is running."""
|
||||
|
||||
_attr_should_poll = False # pushed by the manager's update signal (PERF-02)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
_attr_translation_key = "running"
|
||||
@@ -74,7 +82,9 @@ class WasherRunningBinarySensor(BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self._manager.check_state() == STATE_RUNNING
|
||||
# Any in-progress state, not only `running`: a soak, a pause or the end
|
||||
# wait is not "done" (audit PLATFORM-06).
|
||||
return self._manager.check_state() in CYCLE_IN_PROGRESS_STATES
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@@ -114,3 +124,102 @@ class WasherAmbiguitySensor(WasherRunningBinarySensor):
|
||||
"""Return ambiguous candidate info."""
|
||||
details = self._manager.last_match_details
|
||||
return {"margin": details.get("ambiguity_margin", 0.0) if details else 0.0}
|
||||
|
||||
|
||||
# How often a day-based reminder is re-checked when nothing else happens. The cycle
|
||||
# and log paths refresh the sensor through the update signal; only the passing of
|
||||
# time needs a clock, and hourly is plenty for an interval counted in days.
|
||||
_MAINTENANCE_RECHECK = timedelta(hours=1)
|
||||
|
||||
|
||||
class WasherMaintenanceDueBinarySensor(BinarySensorEntity):
|
||||
"""On while any maintenance task is due (#461): build your own notification.
|
||||
|
||||
WashData itself never notifies about maintenance (no new notification type);
|
||||
this entity is the hook for a user's automation. Attributes list the due tasks
|
||||
with how far past their interval each is. Built-in task names are translated
|
||||
into Home Assistant's language; custom task names are the user's own text.
|
||||
"""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "maintenance_due"
|
||||
_attr_icon = "mdi:wrench-clock"
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
self._manager = manager
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_maintenance_due"
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, entry.entry_id)},
|
||||
"name": entry.title,
|
||||
"manufacturer": "WashData",
|
||||
}
|
||||
self._attr_is_on = False
|
||||
self._attr_extra_state_attributes = {"due_task_ids": [], "due_tasks": []}
|
||||
|
||||
def _builtin_names(self) -> dict[str, str]:
|
||||
"""Home Assistant's loaded entity strings for this integration ({} if none)."""
|
||||
try:
|
||||
return async_get_cached_translations(
|
||||
self.hass, self.hass.config.language, "entity", DOMAIN
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a missing translation is not an error
|
||||
return {}
|
||||
|
||||
def _refresh(self) -> bool:
|
||||
"""Recompute state and attributes; return whether either changed.
|
||||
|
||||
The update signal fires on every power reading, so the state is written
|
||||
only when it changes (audit PERF-08 counts entity writes per reading).
|
||||
"""
|
||||
due = [row for row in self._manager.maintenance_status if row.get("due")]
|
||||
strings = self._builtin_names() if any(not r.get("custom") for r in due) else {}
|
||||
prefix = (
|
||||
f"component.{DOMAIN}.entity.binary_sensor.maintenance_due"
|
||||
".state_attributes.due_tasks.state."
|
||||
)
|
||||
tasks = [
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": (
|
||||
row["name"] if row.get("custom")
|
||||
else strings.get(prefix + row["id"]) or row["id"]
|
||||
),
|
||||
"cycles_since": row.get("cycles_since"),
|
||||
"cycles_interval": row.get("cycles_interval"),
|
||||
"days_since": row.get("days_since"),
|
||||
"days_interval": row.get("days_interval"),
|
||||
}
|
||||
for row in due
|
||||
]
|
||||
attrs = {"due_task_ids": [t["id"] for t in tasks], "due_tasks": tasks}
|
||||
is_on = bool(tasks)
|
||||
if is_on == self._attr_is_on and attrs == self._attr_extra_state_attributes:
|
||||
return False
|
||||
self._attr_is_on = is_on
|
||||
self._attr_extra_state_attributes = attrs
|
||||
return True
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks and compute the first state."""
|
||||
self._refresh()
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_WASHER_UPDATE.format(self._entry.entry_id),
|
||||
self._update_callback,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_track_time_interval(
|
||||
self.hass, self._update_callback, _MAINTENANCE_RECHECK
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_callback(self, *_args: Any) -> None:
|
||||
"""Write the state only when it changed."""
|
||||
if self._refresh():
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -52,6 +52,8 @@ async def async_setup_entry(
|
||||
class WashDataTerminateButton(ButtonEntity):
|
||||
"""Button to force terminate the current cycle."""
|
||||
|
||||
_attr_should_poll = False # state is pushed; nothing to poll (PERF-02)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "force_end_cycle"
|
||||
_attr_icon = "mdi:stop-circle-outline"
|
||||
@@ -79,6 +81,8 @@ class WashDataTerminateButton(ButtonEntity):
|
||||
class WashDataPauseCycleButton(ButtonEntity):
|
||||
"""Button to pause the current cycle (user-triggered)."""
|
||||
|
||||
_attr_should_poll = False # state is pushed; nothing to poll (PERF-02)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "pause_cycle"
|
||||
_attr_icon = "mdi:pause-circle-outline"
|
||||
@@ -124,6 +128,8 @@ class WashDataPauseCycleButton(ButtonEntity):
|
||||
class WashDataResumeCycleButton(ButtonEntity):
|
||||
"""Button to resume a user-paused cycle."""
|
||||
|
||||
_attr_should_poll = False # state is pushed; nothing to poll (PERF-02)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "resume_cycle"
|
||||
_attr_icon = "mdi:play-circle-outline"
|
||||
@@ -166,6 +172,8 @@ class WashDataResumeCycleButton(ButtonEntity):
|
||||
class WashDataRecordStartButton(ButtonEntity):
|
||||
"""Button to start manually recording a clean cycle."""
|
||||
|
||||
_attr_should_poll = False # state is pushed; nothing to poll (PERF-02)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "record_start"
|
||||
_attr_icon = "mdi:record-circle-outline"
|
||||
@@ -200,7 +208,10 @@ class WashDataRecordStartButton(ButtonEntity):
|
||||
"""Only available when not already recording and no active cycle is running."""
|
||||
return (
|
||||
not self._manager.recorder.is_recording
|
||||
and self._manager.detector.state == "off"
|
||||
# The state entities show, so a hidden standby re-probe (item 501)
|
||||
# does not toggle availability on every reading. Idle (#452) is a
|
||||
# switched-on appliance between cycles: recording starts from there too.
|
||||
and self._manager.detector.exposed_state in ("off", "idle")
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
@@ -211,6 +222,8 @@ class WashDataRecordStartButton(ButtonEntity):
|
||||
class WashDataRecordStopButton(ButtonEntity):
|
||||
"""Button to stop manual recording."""
|
||||
|
||||
_attr_should_poll = False # state is pushed; nothing to poll (PERF-02)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "record_stop"
|
||||
_attr_icon = "mdi:stop-circle"
|
||||
@@ -259,6 +272,7 @@ class WashDataMarkUnloadedButton(ButtonEntity):
|
||||
and Home Assistant skips an unavailable entity in a service call, so an
|
||||
automation that presses it unconditionally is a harmless no-op.
|
||||
"""
|
||||
_attr_should_poll = False # state is pushed; nothing to poll (PERF-02)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "mark_unloaded"
|
||||
|
||||
@@ -23,8 +23,8 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import (
|
||||
@@ -164,7 +164,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # pylint: disable=a
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None # pylint: disable=unused-argument
|
||||
) -> FlowResult:
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is None:
|
||||
@@ -189,7 +189,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # pylint: disable=a
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow to change sensor, device type, or name."""
|
||||
entry = self._get_reconfigure_entry()
|
||||
errors: dict[str, str] = {}
|
||||
@@ -207,10 +207,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # pylint: disable=a
|
||||
# NB: intentionally do NOT write entry.data here — post-3.6 the
|
||||
# structural fields live in options and the display name is carried
|
||||
# by the entry title (see test_reconfigure_saves_and_aborts_on_valid_input).
|
||||
# Update only (audit PLATFORM-15): the entry's update listener
|
||||
# already reloads in place (sensor swap included, item 61);
|
||||
# async_update_reload_and_abort ALSO scheduled a full unload/setup,
|
||||
# which interrupted a running cycle and raced the in-place reload.
|
||||
if entry.state is config_entries.ConfigEntryState.LOADED:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry, title=user_input[CONF_NAME], options=new_options
|
||||
)
|
||||
return self.async_abort(reason="reconfigure_successful")
|
||||
# Not loaded (a failed setup, often what the reconfigure is fixing):
|
||||
# the update listener is registered only by a successful setup, so
|
||||
# nothing would apply the options until a restart. Reload instead.
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
title=user_input[CONF_NAME],
|
||||
options=new_options,
|
||||
entry, title=user_input[CONF_NAME], options=new_options,
|
||||
reason="reconfigure_successful",
|
||||
)
|
||||
|
||||
schema = _structural_schema(entry)
|
||||
@@ -244,7 +255,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the minimal options form."""
|
||||
entry = self._config_entry
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Constants for the WashData integration."""
|
||||
|
||||
import math
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
DOMAIN = "ha_washdata"
|
||||
|
||||
@@ -59,7 +61,7 @@ CONF_NOTIFY_LIVE_SERVICES = "notify_live_services"
|
||||
CONF_NOTIFY_CYCLE_TIMERS = "notify_cycle_timers"
|
||||
CONF_NO_UPDATE_ACTIVE_TIMEOUT = "no_update_active_timeout"
|
||||
CONF_LOW_POWER_NO_UPDATE_TIMEOUT = "low_power_no_update_timeout"
|
||||
CONF_SMOOTHING_WINDOW = "smoothing_window"
|
||||
CONF_SMOOTHING_WINDOW = "smoothing_window" # Removed in 0.5.8: never read; key kept for old migrations
|
||||
CONF_SAMPLING_INTERVAL = "sampling_interval"
|
||||
CONF_START_DURATION_THRESHOLD = (
|
||||
"start_duration_threshold" # Debounce for start detection
|
||||
@@ -77,15 +79,12 @@ CONF_AUTO_MAINTENANCE = "auto_maintenance"
|
||||
CONF_PROFILE_MATCH_INTERVAL = "profile_match_interval"
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO = "profile_match_min_duration_ratio"
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO = "profile_match_max_duration_ratio"
|
||||
CONF_MAX_PAST_CYCLES = "max_past_cycles"
|
||||
CONF_MAX_FULL_TRACES_PER_PROFILE = "max_full_traces_per_profile"
|
||||
CONF_MAX_FULL_TRACES_UNLABELED = "max_full_traces_unlabeled"
|
||||
CONF_WATCHDOG_INTERVAL = "watchdog_interval" # Derived from sampling_interval
|
||||
CONF_MATCH_PERSISTENCE = "match_persistence"
|
||||
CONF_COMPLETION_MIN_SECONDS = "completion_min_seconds"
|
||||
CONF_NOTIFY_BEFORE_END_MINUTES = "notify_before_end_minutes"
|
||||
CONF_RUNNING_DEAD_ZONE = "running_dead_zone" # REMOVED in 0.5.3 — was never wired to detection
|
||||
CONF_END_REPEAT_COUNT = "end_repeat_count" # Number of times end condition must be met
|
||||
CONF_END_REPEAT_COUNT = "end_repeat_count" # Removed in 0.5.8: the detector never read it; stored values are ignored
|
||||
CONF_MIN_OFF_GAP = "min_off_gap" # Minimum gap to separate cycles (seconds)
|
||||
CONF_START_ENERGY_THRESHOLD = "start_energy_threshold" # Wh required to confirm start
|
||||
CONF_END_ENERGY_THRESHOLD = "end_energy_threshold" # Wh allowed during end candidates
|
||||
@@ -100,30 +99,6 @@ CONF_POWER_OFF_DELAY = (
|
||||
"power_off_delay" # Seconds below the power-off threshold before Finished/Clean -> Off
|
||||
)
|
||||
CONF_EXPOSE_DEBUG_ENTITIES = "expose_debug_entities" # Expose detailed debug sensors
|
||||
# Per-device opt-in: blend the phase-resolved (per-role budget) ETA into the
|
||||
# time-remaining estimate for phase-matching-supported device types (washing
|
||||
# machine, washer-dryer). Default off. Validated by the Phase-0 ETA gate; see
|
||||
# docs/superpowers/specs/2026-07-17-phase-segmented-matching-design.md.
|
||||
CONF_ENABLE_PHASE_MATCHING = "enable_phase_matching"
|
||||
# Phase-structure consistency advisory (Profiles tab, never a notification).
|
||||
# A single-program/temperature profile should have a fairly consistent heating
|
||||
# block; wildly varying heating time or heating present in only some cycles
|
||||
# usually means different programs/temperatures were labelled under one profile
|
||||
# (the "mixed labels" data-hygiene problem). Pure statistics from the cached
|
||||
# phase profile - no relabeling (phase matching does not label better than the
|
||||
# whole-cycle matcher; see the Phase-0 gate).
|
||||
# Minimum member cycles before a profile's cached phase profile is trusted to
|
||||
# drive the live phase-resolved ETA (mirrors the envelope's cycle_count>=2 gate);
|
||||
# below this the priors are too noisy (single-cycle -> zero variance) and the
|
||||
# estimate falls back to the classic one. Design §6 cold-start floor.
|
||||
PHASE_PROFILE_MIN_CYCLES = 2
|
||||
PHASE_CONSISTENCY_MIN_CYCLES = 4
|
||||
# Heating-time std/mean above this -> likely mixed temperatures under one label.
|
||||
# A clean single-temperature profile sits ~0.2 (load variation only); a profile
|
||||
# mixing 30/40/90C sits ~0.45-0.6, so 0.45 catches genuine mixing with margin.
|
||||
PHASE_HEAT_CV_WARN = 0.45
|
||||
PHASE_HEAT_OCC_MIXED_LO = 0.25 # heating present in only 25%-75% of cycles ->
|
||||
PHASE_HEAT_OCC_MIXED_HI = 0.75 # mixed with a non-heating program
|
||||
CONF_SAVE_DEBUG_TRACES = (
|
||||
"save_debug_traces" # Improve historical cycle data with rich debug info
|
||||
)
|
||||
@@ -134,7 +109,7 @@ CONF_EXTERNAL_END_TRIGGER_INVERTED = "external_end_trigger_inverted" # Invert e
|
||||
CONF_ANTI_WRINKLE_ENABLED = "anti_wrinkle_enabled" # Dryer anti-wrinkle shielding
|
||||
CONF_ANTI_WRINKLE_MAX_POWER = "anti_wrinkle_max_power" # W threshold for anti-wrinkle spikes
|
||||
CONF_ANTI_WRINKLE_MAX_DURATION = "anti_wrinkle_max_duration" # Seconds to treat as anti-wrinkle
|
||||
CONF_ANTI_WRINKLE_EXIT_POWER = "anti_wrinkle_exit_power" # W threshold for true-off exit
|
||||
CONF_ANTI_WRINKLE_EXIT_POWER = "anti_wrinkle_exit_power" # W: quiet level in anti-wrinkle, floored at stop_threshold_w
|
||||
CONF_ANTI_WRINKLE_IDLE_TIMEOUT = "anti_wrinkle_idle_timeout" # Seconds below exit power before anti-wrinkle ends
|
||||
CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE = "dishwasher_end_spike_quiet_release" # Dishwasher: sustained-quiet seconds after expected duration that release the end-of-cycle drain wait early (#379)
|
||||
CONF_SMART_TERMINATION_DURATION_RATIO = "smart_termination_duration_ratio" # Fraction of the matched profile's expected (mean) duration that Smart Termination requires before it may fire (#393)
|
||||
@@ -291,7 +266,9 @@ CONF_LINKED_DEVICE = "linked_device"
|
||||
|
||||
DEFAULT_NOTIFY_TITLE = "WashData: {device}"
|
||||
DEFAULT_NOTIFY_START_MESSAGE = "{device} started."
|
||||
DEFAULT_NOTIFY_FINISH_MESSAGE = "{device} finished. Duration: {duration}m."
|
||||
# "{duration} min", not "{duration}m": a voice assistant read "m" as metres (#93, #117).
|
||||
# A template the user saved keeps its own text.
|
||||
DEFAULT_NOTIFY_FINISH_MESSAGE = "{device} finished. Duration: {duration} min."
|
||||
DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE = "{device}: Less than {minutes} minutes remaining."
|
||||
DEFAULT_NOTIFY_REMINDER_MESSAGE = "{device}: about {minutes} minutes left."
|
||||
DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE = "{device}: No profile matched yet."
|
||||
@@ -310,7 +287,7 @@ DEFAULT_NOTIFY_TIMEOUT_SECONDS = 0 # 0 = notifications never auto-dismiss
|
||||
DEFAULT_NOTIFY_CHANNEL = "" # Empty = omit channel (companion app default)
|
||||
DEFAULT_NOTIFY_FINISH_CHANNEL = "" # Empty = reuse status channel
|
||||
DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES = 60 # 1 hour before "still waiting" nag notification
|
||||
DEFAULT_NOTIFY_UNLOAD_MESSAGE = "{device} finished {duration}m ago - laundry is still inside."
|
||||
DEFAULT_NOTIFY_UNLOAD_MESSAGE = "{device} finished {duration} min ago - laundry is still inside."
|
||||
DEFAULT_NOTIFY_UNLOAD_REPEAT = False # opt-in: re-send the unload reminder until dismissed (#374)
|
||||
# Safety bound on repeat mode (#374). The reminder is meant to run "until the door
|
||||
# opens", and the in-notification "Stop reminding" button is mobile_app-only - so a
|
||||
@@ -322,10 +299,6 @@ DEFAULT_NOTIFY_UNLOAD_REPEAT = False # opt-in: re-send the unload reminder unti
|
||||
NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS = 48
|
||||
DEFAULT_PEAK_RATE_MESSAGE = "Running at peak rate ({price}/kWh)."
|
||||
|
||||
# Quiet hours default: feature off (both hours unset). See CONF_NOTIFY_QUIET_*.
|
||||
DEFAULT_NOTIFY_QUIET_START_HOUR = None
|
||||
DEFAULT_NOTIFY_QUIET_END_HOUR = None
|
||||
|
||||
# Milestone notification defaults.
|
||||
DEFAULT_NOTIFY_MILESTONES = [50, 100, 500, 1000]
|
||||
DEFAULT_NOTIFY_MILESTONE_MESSAGE = "{device} has completed {cycle_count} cycles!"
|
||||
@@ -380,13 +353,18 @@ DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO = 0.10 # Allow match after 10% of expe
|
||||
# 2.5 measures slightly higher (+0.83pp) but regresses one device; 1.8 is the
|
||||
# point at which nothing gets worse.
|
||||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO = 1.8
|
||||
DEFAULT_MAX_PAST_CYCLES = 200
|
||||
DEFAULT_MAX_FULL_TRACES_PER_PROFILE = 20
|
||||
DEFAULT_MAX_FULL_TRACES_UNLABELED = 20
|
||||
# A cycle needs at least this many trace points before its ML health is scored
|
||||
# (#459). It is the same floor `quality_features` uses before it falls back to a
|
||||
# `has_trace = 0` row, which no model was trained on.
|
||||
ML_HEALTH_MIN_TRACE_POINTS = 4
|
||||
DEFAULT_WATCHDOG_INTERVAL = 30 # Floor; effective default is resolved per device
|
||||
# A watchdog keepalive closing more than this many ticks was injected late (host
|
||||
# suspend, loop stall, restart): the interval it closes is unobserved (item 391).
|
||||
# On time it closes at most two (the first after a real reading), then one.
|
||||
WATCHDOG_LATE_TICK_FACTOR = 2.5
|
||||
# as max(this, 2*sampling_interval + 1) - see resolve_watchdog_interval_default (#396).
|
||||
DEFAULT_MATCH_PERSISTENCE = 3
|
||||
DEFAULT_END_REPEAT_COUNT = 1 # 1 = current behavior (no repeat required)
|
||||
DEFAULT_END_REPEAT_COUNT = 1 # Removed setting (see CONF_END_REPEAT_COUNT)
|
||||
|
||||
# Share of the SHORTEST known profile that the match-interval suggestion is
|
||||
# allowed to spend before a program can first be committed (#431). The
|
||||
@@ -444,22 +422,15 @@ PREROLL_CHAIN_BREAK_SECONDS = 90.0
|
||||
# Matching & Termination Stability
|
||||
DEFAULT_MATCH_REVERT_RATIO = 0.4 # Drop from peak score to revert to detecting
|
||||
DEFAULT_DEFER_FINISH_CONFIDENCE = 0.55 # Minimum confidence to defer cycle finish
|
||||
|
||||
# ML live-match commit gate: P(top-1 is correct) threshold to commit a match
|
||||
# before the persistence counter is satisfied. Set high to avoid false-early
|
||||
# commits; the model's owner-holdout precision is ~0.87 at this score.
|
||||
ML_MATCH_COMMIT_THRESHOLD = 0.85
|
||||
|
||||
# ML quality gate: P(cycle is a problem) threshold above which even a high-
|
||||
# confidence auto-label is downgraded to a feedback request. Tuned for a
|
||||
# specificity of ~0.84 (few false positives) so users are not flooded.
|
||||
ML_QUALITY_SUSPICIOUS_THRESHOLD = 0.65
|
||||
|
||||
# Match ranking history: maximum number of per-cycle snapshots retained on-device.
|
||||
# Each snapshot stores pre-computed live_match feature scalars (not traces) so
|
||||
# footprint is small; 500 snapshots cover ~6–12 months of typical usage and are
|
||||
# enough to build a per-device live_match training dataset.
|
||||
MATCH_RANKING_HISTORY_MAX = 500
|
||||
# Fraction of the matched programme's expected duration below which a confident
|
||||
# match holds a fallback end (`CycleDetector._should_defer_finish`). Its OWN
|
||||
# constant, not a user option: since Feb 2026 the detector was fed the matcher's
|
||||
# Stage-1 `profile_match_min_duration_ratio` (0.10, or 0.05 once the suggestion was
|
||||
# applied) through a field of the same name, so a confident match only held an end
|
||||
# below 5-10% of expected - never in practice. Restoring 0.8 is the live fix for
|
||||
# item 390's split: splits 1.37 -> 1.03% over 295 replayed cycles, early ends
|
||||
# unchanged, dishwashers byte-identical (audit DETECT-02).
|
||||
DEFAULT_DEFER_FINISH_RATIO = 0.8
|
||||
|
||||
# Runtime overrun anomaly: a *soft, visible* signal (attribute + cycle metadata,
|
||||
# never a notification) flagged once a running cycle exceeds its matched
|
||||
@@ -532,8 +503,10 @@ ENERGY_ANOMALY_Z_THRESHOLD = 2.5 # |z-score| above this = energy anomaly
|
||||
# Profile warm-up mode: a newly-created profile with fewer than this many
|
||||
# labeled cycles skips auto-labeling and always requests manual confirmation.
|
||||
# Prevents the system from confidently mis-labeling cycles before it has seen
|
||||
# enough examples of the program.
|
||||
CONF_PROFILE_MIN_WARMUP_CYCLES = 5 # labeled cycles before auto-matching is enabled
|
||||
# enough examples of the program. 5 until 0.5.8 (audit UI-26): eight programs
|
||||
# meant up to 40 confirmations before the first auto-label; the label gates
|
||||
# (margin, ambiguity, label_confidence) now carry what the extra prompts guarded.
|
||||
CONF_PROFILE_MIN_WARMUP_CYCLES = 2 # labeled cycles before auto-labelling is enabled
|
||||
|
||||
# Shape drift detection: compares the average power-curve envelope of the
|
||||
# earliest third of a profile's cycles against the most recent third.
|
||||
@@ -549,8 +522,9 @@ SHAPE_DRIFT_RESAMPLE_N = 50 # points for envelope comparison
|
||||
CLUSTER_SHAPE_SIMILARITY_THRESHOLD = 0.75 # min correlation for shape-similar cluster
|
||||
CLUSTER_RESAMPLE_N = 50 # points for pairwise comparison
|
||||
|
||||
# Terminal-drop fast finalize (opt-in; gated on CONF_ENABLE_ML_MODELS via the
|
||||
# manager provider). A hard cliff-to-~0 at an elapsed offset EARLIER than this
|
||||
# Terminal-drop fast finalize (on for TERMINAL_DROP_DEFAULT_ON_DEVICE_TYPES, else
|
||||
# gated on CONF_ENABLE_ML_MODELS; detector_config.terminal_drop_enabled decides
|
||||
# for the manager and the Playground). A hard cliff-to-~0 at an elapsed offset EARLIER than this
|
||||
# device has ever legitimately gone quiet (learned from its own completed
|
||||
# cycles) is an anomaly - almost certainly a real stop (plug pulled / cancelled)
|
||||
# rather than a soak pause - so the cycle is finalized quickly instead of waiting
|
||||
@@ -570,6 +544,13 @@ TERMINAL_DROP_MIN_PEAK_RATIO = 5.0 # cycle must have been clearly ON (peak
|
||||
# treated as potentially a NEW program and DEFERRED to the proven slow path
|
||||
# rather than assumed to be a stop.
|
||||
TERMINAL_DROP_PEAK_FAMILIAR_TOL = 0.4
|
||||
# Device types that get the terminal-drop finalize whatever the "Apply smart
|
||||
# models" toggle says (audit ML-08). It is pure statistics, not a model, and only
|
||||
# dishwashers gain from it: a plug pulled mid-wash closes in 3-4.5 min instead of
|
||||
# waiting out 70-121 min (and being stored as completed); washers: 0 fires.
|
||||
# Without the toggle it fires only on a committed, unambiguous match
|
||||
# (detector_config.terminal_drop_may_fire).
|
||||
TERMINAL_DROP_DEFAULT_ON_DEVICE_TYPES = frozenset({"dishwasher"})
|
||||
|
||||
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD = 3 # Ghost cycles before threshold adjustment
|
||||
|
||||
@@ -601,6 +582,11 @@ DEFAULT_DELAY_TIMEOUT_HOURS = 8.0 # h - give up waiting after this long
|
||||
CONF_PUMP_STUCK_DURATION = "pump_stuck_duration" # Seconds before a running pump is flagged as stuck
|
||||
DEFAULT_PUMP_STUCK_DURATION = 1800 # 30 min - typical sump pump runs <60 s; 30 min implies motor is jammed
|
||||
EVENT_PUMP_STUCK = "ha_washdata_pump_stuck" # Fired when stuck-pump threshold is exceeded
|
||||
# Discussion #452: a cycle halted on a flat standby-level plateau (an unbalanced
|
||||
# load, a door warning). Once per stall, display/automation only, never a
|
||||
# notification (CycleDetector.stalled).
|
||||
EVENT_CYCLE_STALLED = "ha_washdata_cycle_stalled"
|
||||
CYCLE_ANOMALY_STALLED = "stalled" # the state sensor's cycle_anomaly while stalled
|
||||
|
||||
# Profile Matching Thresholds
|
||||
CONF_PROFILE_MATCH_THRESHOLD = "profile_match_threshold"
|
||||
@@ -645,19 +631,23 @@ MATCH_MIN_RESAMPLED_POINTS = 12
|
||||
# dtw_score = DIST_SCALE / (DIST_SCALE + scaled_dtw_distance).
|
||||
MATCH_DTW_BLEND = 0.5
|
||||
MATCH_DTW_DIST_SCALE = 50.0
|
||||
MATCH_DTW_REFINE_TOP_N = 5 # DTW is applied to this many top candidates
|
||||
# (5 tuned via dtw_ab_eval: rescues correct
|
||||
# profiles Stage-2 ranked 4th-5th; +1.8pp)
|
||||
MATCH_DTW_REFINE_TOP_N = 5 # DTW is applied to this many top candidates.
|
||||
# On the shipped path 3 ties 5 and 8 is
|
||||
# identical to 5; 1 costs -0.92pp mid-cycle
|
||||
# (audit MR-05).
|
||||
# Stage-3 DTW modes (config key "dtw_mode"):
|
||||
# "legacy" - original: raw sequences, distance / len(current), fixed 50 W scale.
|
||||
# "scaled" - both sequences resampled to MATCH_DTW_RESAMPLE_N and the distance
|
||||
# expressed relative to the current peak (behaviour-neutral at
|
||||
# MATCH_MAE_REF_PEAK), matching the Stage-2 MAE treatment. Default.
|
||||
# MATCH_MAE_REF_PEAK), matching the Stage-2 MAE treatment.
|
||||
# "ddtw" - like "scaled" but warps on the first derivative (slope) of the
|
||||
# curves, so alignment is driven by shape rather than absolute level.
|
||||
# "ensemble" - blend of "scaled" and "ddtw": ENSEMBLE_W*L1 + (1-W)*DDTW.
|
||||
# Defaults tuned via devtools/dtw_ab_eval.py on cycle_data/ (leave-one-out top-1):
|
||||
# off 62.4%, legacy 66.4%, scaled 69.9%, ddtw 69.0%, ensemble(w=0.7,dd=30) 70.7%.
|
||||
# "ensemble" - blend of "scaled" and "ddtw": ENSEMBLE_W*L1 + (1-W)*DDTW. Default.
|
||||
# Measured on the shipped path (devtools/eval.py, audit MR-05), vs ensemble: DTW off
|
||||
# -2.16pp mid-cycle top-1 (-3.78 at 25%) but +0.16 at cycle end (n.s.); scaled
|
||||
# alone -0.32, ddtw alone -0.16. Stage 3 earns its keep mid-cycle only. (The older
|
||||
# dtw_ab_eval table, off 62.4% ... ensemble 70.7%, predates item 303 and did not
|
||||
# run the shipped matcher.)
|
||||
DEFAULT_DTW_MODE = "ensemble"
|
||||
MATCH_DTW_RESAMPLE_N = 200 # common grid length for "scaled"/"ddtw" DTW
|
||||
MATCH_DDTW_DIST_SCALE = 30.0 # half-saturation for derivative-DTW distance
|
||||
@@ -715,13 +705,43 @@ MATCH_LABEL_MIN_MARGIN = 0.08
|
||||
# so this is the conservative end of an accuracy/stability trade. Do not retune it
|
||||
# in isolation: any Stage-2 scoring change rescales the margin along with it.
|
||||
MATCH_DECISIVE_MARGIN = 0.12
|
||||
# Smart Termination landscape guard: when a non-winning candidate is at least this
|
||||
# The first commit of a live programme (match_rules.decide_switch, Case 1). A winner
|
||||
# that is AMBIGUOUS on the tick (inside MATCH_AMBIGUITY_MARGIN of the runner-up, or
|
||||
# flagged by a Stage-5 safeguard) commits only once it has led for this many times
|
||||
# match_persistence consecutive matches; a clear winner still commits at
|
||||
# match_persistence. Until 0.5.8 an ambiguous winner committed at match_persistence
|
||||
# too. Measured alone (devtools/decisive_margin_eval.py --switching --loo, 291
|
||||
# labelled cycles): first programme shown right on washers 29.8 -> 34.2%,
|
||||
# dishwashers 90.8 -> 93.1%, washer switches per cycle 1.32 -> 1.12, for a median
|
||||
# first commit 17.8 -> 19.5 min on washers (dishwashers unchanged at 11.1 min).
|
||||
# Not "never": a stable winner of an always-close pair must still get a programme
|
||||
# and an ETA (offline, waiting for a clear tick left 4 washer cycles uncommitted).
|
||||
MATCH_AMBIGUOUS_COMMIT_FACTOR = 2
|
||||
# DISPLAY ONLY - never a gate (audit MATCH-DECIDE-15/18). The Status card's
|
||||
# "Uncertain: X or Y, ~N% sure" figure while the live match is undecided:
|
||||
# P(the leading guess is the right programme) as a monotone piecewise-linear map
|
||||
# of the live top1-top2 margin, clamped at both ends. Being monotone, gating on it
|
||||
# equals gating on the margin, so it adds nothing as a gate and must not become one.
|
||||
# Fitted by devtools/margin_display_fit.py on devtools/eval.py --mode full, cuts
|
||||
# 0.1-0.9 (2324 live prefix folds from 48 exports, matcher-produced labels
|
||||
# excluded): 26% right at margin ~0 rising to 89% past 0.37; leave-one-source-out
|
||||
# ECE 0.059 (Brier 0.191 vs base rate 0.229). A lone candidate has no runner-up
|
||||
# (its margin is a 1.0 sentinel) and is right far less often than a real 1.0
|
||||
# margin, so it gets its own figure.
|
||||
MATCH_SURE_KNOTS: tuple[tuple[float, float], ...] = (
|
||||
(0.007, 0.26), (0.036, 0.41), (0.064, 0.50), (0.099, 0.59),
|
||||
(0.159, 0.73), (0.252, 0.84), (0.373, 0.89),
|
||||
)
|
||||
MATCH_SURE_SINGLE_CANDIDATE = 0.59
|
||||
# Prefix-landscape guard (#288): when a non-winning candidate is at least this
|
||||
# much longer than the matched profile AND has a decent shape score (before Stage-4
|
||||
# duration penalty), the current trace may be a *prefix* of that longer program
|
||||
# rather than a completed short one. Smart Termination is blocked; the power-based
|
||||
# fallback timeout decides instead. Ratio chosen so that programmes within ~50% of
|
||||
# rather than a completed short one. Ratio chosen so that programmes within ~50% of
|
||||
# each other (e.g. Quick 46 min vs Eco 60 min, ratio 1.30) do not trigger the guard
|
||||
# but genuine prefix pairs like Quick 46 vs Normal 88 min (ratio 1.91) always do.
|
||||
# Since audit LIVE-18 it guards only the dryer anti-crease finalize
|
||||
# (`MatchResult.is_prefix_ambiguous_full_shape`): at the ENDING gates it was every
|
||||
# false block at a genuine end.
|
||||
SMART_TERM_LANDSCAPE_RATIO = 1.5 # candidate must be >= 1.5× the matched duration
|
||||
SMART_TERM_LANDSCAPE_MIN_SHAPE = 0.40 # minimum shape score (pre-Stage-4) to qualify
|
||||
|
||||
@@ -735,27 +755,28 @@ SMART_TERM_LANDSCAPE_MIN_SHAPE = 0.40 # minimum shape score (pre-Stage-4) to qu
|
||||
# (3) the 1.5 ratio is knife-edge - on a real 13-programme washer the observed
|
||||
# neighbour ratios are 1.12-1.48, so the guard never fires at all.
|
||||
#
|
||||
# Two independent additions, both shorten-only (they can only ever BLOCK an early
|
||||
# finish, never end a cycle sooner).
|
||||
# (a) Prefix scoring (for 2 + 3) was REMOVED in 0.5.8. It re-scored a longer
|
||||
# candidate against its own curve truncated to the elapsed time and blocked the
|
||||
# ENDING gates when that beat the winner's shape score by 0.15 (floor 0.40, ratio
|
||||
# > 1.10, at most 3 scorings per match). #400 took its premise away: Stages 2/3
|
||||
# now score a running cycle against every candidate's truncated curve, so a longer
|
||||
# programme whose start explains the trace better mostly wins the match itself.
|
||||
# Measured on the shipped matcher (devtools/prefix_guard_eval.py --quiet-cuts
|
||||
# --sweep, leave-one-out, 71 devices): 0 of 713 genuine ends and 0 of the 7 quiet
|
||||
# split positives (ENDING quiet inside a pause power later resumed from, the only
|
||||
# moment Smart Termination can split a cycle) at every point of a margin 0-0.15 x
|
||||
# floor 0-0.60 x ratio 1.0-1.5 grid - their best prefix margin was -0.024. What it
|
||||
# caught were mid-activity cuts that never reach ENDING, which (b) blocks.
|
||||
#
|
||||
# (a) Prefix scoring (fixes 2 + 3). A longer candidate is re-scored against its own
|
||||
# curve TRUNCATED to the elapsed duration, which is an apples-to-apples comparison
|
||||
# and lands on the same 0-1 scale as `shape_score` (same find_best_alignment, same
|
||||
# DTW blend). Because it compares equal-length series over the whole overlap it
|
||||
# reads systematically higher than the full-envelope score, so it gets its OWN
|
||||
# threshold rather than reusing SMART_TERM_LANDSCAPE_MIN_SHAPE. The load-bearing
|
||||
# term is the MARGIN over the winner ("the longer programme explains this trace at
|
||||
# least this much better than the short one does"), which is scale-free; the floor
|
||||
# only rejects candidates that fit nothing. Measured on 20 real cycles + 7
|
||||
# envelopes (37 prefix-cut positives vs 17 genuine-cycle negatives): margin 0.15
|
||||
# catches 26/37 splits for 1/17 false blocks, while simply lowering the ratio to
|
||||
# 1.35/1.15 costs 2/17 and 4/17 false blocks for no measured gain.
|
||||
SMART_TERM_PREFIX_MARGIN = 0.15 # prefix score must beat the winner by this
|
||||
SMART_TERM_PREFIX_MIN_SHAPE = 0.40 # absolute floor on the prefix score
|
||||
SMART_TERM_PREFIX_MIN_RATIO = 1.10 # noise guard: ignore near-equal durations
|
||||
SMART_TERM_PREFIX_MAX_CANDIDATES = 3 # cap prefix scorings per match (cost control)
|
||||
SMART_TERM_PREFIX_MIN_POINTS = 12 # mirrors the matcher's >=12-sample floor
|
||||
SMART_TERM_PREFIX_MIN_COVERAGE = 0.90 # template span must cover >=90% of its duration
|
||||
# (c) Pause evidence (#424), on the #288 term. It asks whether the trace LOOKS like
|
||||
# the start of a longer programme, not whether that programme could be quiet right
|
||||
# now. A longer candidate can only explain a below-`stop_threshold_w` moment if it
|
||||
# is a programme that pauses below that threshold mid-cycle, so a candidate whose
|
||||
# stored cycles never once did no longer counts. Any stored pause of this length
|
||||
# keeps the guard; a programme with no traced evidence keeps it too. (Measured when
|
||||
# it also fed the ENDING gates: genuine-end fires 186/689 -> 142/689 on the old
|
||||
# harness, which OR-ed both terms.)
|
||||
SMART_TERM_PREFIX_MIN_PAUSE_S = 60.0
|
||||
|
||||
# (b) Power plausibility (fixes 1, the untrained case, which no candidate-pool guard
|
||||
# can reach). Both Smart-Termination paths key on `elapsed >= 0.98 * expected` and
|
||||
@@ -783,6 +804,8 @@ SMART_TERM_PREFIX_MIN_COVERAGE = 0.90 # template span must cover >=90% of its d
|
||||
# at 4-8x so they stay caught. A false block only costs a later finish (the
|
||||
# power-based fallback timeout still ends the cycle); a miss costs a split cycle.
|
||||
# ~1 in 5 of the remaining false blocks had a wrong top-1 anyway, where blocking is right.
|
||||
# Re-measured 2026-10-04 on the shipped matcher (item 483, `--quiet-cuts`): at 3.5x
|
||||
# it catches 156 of 508 split positives with 0 of 675 false blocks.
|
||||
SMART_TERM_TAIL_MAX_RATIO = 3.5 # block while trailing mean > this x profile tail
|
||||
SMART_TERM_TAIL_WINDOW_S = 300.0 # upper clamp on the trailing window
|
||||
SMART_TERM_TAIL_WINDOW_MIN_S = 60.0 # lower clamp (short programmes)
|
||||
@@ -806,11 +829,54 @@ REFERENCE_PROFILE_CURVE_POINTS = 50
|
||||
# actually DROPPING (62.7%->59.9%). Raising weight alone at the old loose scale
|
||||
# inflated both recall and FP (net-negative), so both knobs move together.
|
||||
MATCH_DURATION_WEIGHT = 0.22
|
||||
# Despite the name, "energy" here means mean power (W), not Wh — the Stage-4
|
||||
# agreement term compares cur_energy=mean(curr_arr) vs profile_mean_power.
|
||||
# Stage-4 duration weight while the cycle is still RUNNING (live match). Mid-cycle
|
||||
# the duration term compares elapsed time with each candidate's FULL duration, a
|
||||
# systematic pull toward shorter programmes (80% of 50%-elapsed errors picked a
|
||||
# shorter one), so it carries less weight there. Measured leave-one-out on the
|
||||
# shipped path: +0.97pp mid-cycle top-1 [+0.11, +1.86], completed cycles and the
|
||||
# ambiguity rate unchanged (audit MR-03). The completed-cycle weight stays.
|
||||
MATCH_DURATION_WEIGHT_IN_PROGRESS = 0.15
|
||||
# The Stage-1 LOWER duration-ratio gate is skipped for a live match during the first
|
||||
# this-many seconds of a cycle. Matching starts as soon as the cycle runs, and at
|
||||
# 5-10 min elapsed/avg_duration is below the 0.10 floor for every programme longer
|
||||
# than 50-100 min, so only short programmes could compete: the gate removed the
|
||||
# true programme on 69/114 user folds at 5 min and 35/243 at 10 min. Composed from
|
||||
# the measured records: 5 min +83/-2, 10 min +63/-6, 15 min +17/-7, 25% of the cycle
|
||||
# +0/-1 (audit MATCH-CORE-02). From 15 min on the gate stays, where it stops a much
|
||||
# longer programme stealing the match.
|
||||
MATCH_MIN_RATIO_GRACE_S = 900.0
|
||||
|
||||
# Hazard end gate (audit DETECT-16). Past an unambiguous match the ENDING fallback
|
||||
# waits MARGIN x the longest below-stop pause the matched profile's traced
|
||||
# evidence ever resumed from at or after this quiet's position (less SLACK of the
|
||||
# run), never less than off_delay and never longer than before. Needs MIN_CYCLES
|
||||
# traced cycles: a catalogue of one or two runs has not seen the programme's soaks.
|
||||
END_GATE_HAZARD_MARGIN = 1.25
|
||||
END_GATE_HAZARD_MIN_CYCLES = 3
|
||||
END_GATE_HAZARD_POSITION_SLACK = 0.05
|
||||
# Register item 498: a revoked match (divergence revert, or every candidate
|
||||
# rejected) leaves an envelope-verified pause with no expected duration, which only
|
||||
# high power cleared, so a finished cycle sat until the force stop. Released once the
|
||||
# gap-free quiet reaches END_GATE_HAZARD_MARGIN x the longest below-stop pause the
|
||||
# revoked programme's traced cycles ever resumed from, never before max(off_delay,
|
||||
# min_off_gap, ENDING_HARD_FINALIZE_MIN_QUIET_S) (what the unmatched fallback waits
|
||||
# anyway) and, above that floor, never after this cap: past the corpus's longest
|
||||
# resumed pause x margin (a 6838 s dishwasher drying phase before its pump-out ->
|
||||
# 8548 s) and 1.5 h inside the watchdog's 4.5 h silence limit under a verified pause.
|
||||
ORPHANED_PAUSE_MAX_WAIT_S = 10800.0
|
||||
# Stage-4 "energy" agreement. By default it compares mean power (W), not Wh:
|
||||
# cur_energy=mean(curr_arr) vs profile_mean_power. Washing machines and
|
||||
# washer-dryers compare integrated energy instead (analysis.stage4_energy_mode).
|
||||
# While elapsed < the template span both modes reduce to the mean ratio, so the
|
||||
# device choice only acts at cycle end or after an overrun (audit MR-09).
|
||||
MATCH_ENERGY_WEIGHT = 0.22
|
||||
MATCH_DURATION_SCALE = 0.175 # ~ln ratio at which duration agreement halves
|
||||
MATCH_ENERGY_SCALE = 0.25 # ~ln ratio at which energy agreement halves
|
||||
# At cycle end Stage 4 takes a washer's expected energy from the median of the
|
||||
# profile's own cycles (analysis.member_energy_reference) once it has this many;
|
||||
# below it, and mid-cycle, the template's mean power x duration as before. 3 was
|
||||
# measured slightly worse than 2 (eval.py full, cut 1.0: +3/-4 folds).
|
||||
MATCH_ENERGY_REF_MIN_CYCLES = 2
|
||||
# Issue #400: once a RUNNING cycle has outlasted a candidate, that is hard
|
||||
# evidence against it, and the penalty uses this sharper scale instead of
|
||||
# MATCH_DURATION_SCALE. Only reached when the caller opts in via
|
||||
@@ -818,13 +884,15 @@ MATCH_ENERGY_SCALE = 0.25 # ~ln ratio at which energy agreement halves
|
||||
# where elapsed IS the cycle's true duration.
|
||||
#
|
||||
# Below a candidate's duration the term is deliberately UNCHANGED. Suppressing the
|
||||
# penalty there ("we simply have not got there yet") was measured and rejected: it
|
||||
# adds only +0.7pp mid-cycle top-1 over prefix energy alone, costs 3.7pp at the 90%
|
||||
# checkpoint, and - because it hands a longer sibling full duration agreement near
|
||||
# the short one's end - it puts a dishwasher's 50 deg and 65 deg programmes inside
|
||||
# MATCH_AMBIGUITY_MARGIN of each other at the end of the 50 deg, which reads as
|
||||
# ambiguous and blocks Smart Termination (measured on four real exports; #393 is
|
||||
# about finishing on time, so that is not a trade worth 0.7pp).
|
||||
# penalty there ("we simply have not got there yet") was measured and rejected. On
|
||||
# the shipped path (audit MR-02) it gains +4.65pp top-1 at 50% elapsed but costs
|
||||
# -6.58pp at 98%, which is where Smart Termination reads the live match; it also
|
||||
# raises the share of correct matches flagged ambiguous (11.2% -> 13.3%) and costs
|
||||
# washer-dryers 18.2pp. Near the short programme's end it hands a longer sibling
|
||||
# full duration agreement, so a dishwasher's 50 deg and 65 deg programmes land
|
||||
# inside MATCH_AMBIGUITY_MARGIN of each other and Smart Termination is blocked
|
||||
# (#393 is about finishing on time). The earlier "+0.7pp / -3.7pp at 90%" figures
|
||||
# came from dtw_ab_eval, which does not run the shipped matcher.
|
||||
MATCH_DURATION_SCALE_OVERRUN = 0.05
|
||||
# Issue #400, shape half: while a cycle is running, Stages 2 and 3 score it against
|
||||
# each candidate TRUNCATED to the elapsed time (reusing the #364 prefix machinery),
|
||||
@@ -835,6 +903,11 @@ MATCH_DURATION_SCALE_OVERRUN = 0.05
|
||||
# 71.0% at 0.7; at 0.8 the 90% checkpoint drops and a real dishwasher export loses
|
||||
# Smart Termination, the same cliff the rejected duration credit fell off.
|
||||
MATCH_PREFIX_SHAPE_MAX_RATIO = 0.7
|
||||
# The fewest template samples a truncated prefix may keep and still be correlated
|
||||
# and warped (analysis._prefix_point_count / prefix_shape_arrays), mirroring the
|
||||
# matcher's >= 12-sample floor. Named SMART_TERM_PREFIX_MIN_POINTS while the
|
||||
# removed #364 prefix guard shared it.
|
||||
MATCH_PREFIX_MIN_POINTS = 12
|
||||
|
||||
|
||||
# States
|
||||
@@ -850,10 +923,26 @@ STATE_FINISHED = "finished"
|
||||
STATE_ANTI_WRINKLE = "anti_wrinkle"
|
||||
STATE_INTERRUPTED = "interrupted"
|
||||
STATE_FORCE_STOPPED = "force_stopped"
|
||||
STATE_RINSE = "rinse"
|
||||
|
||||
# States in which a cycle is in progress: what `binary_sensor.*_running` reports.
|
||||
# Only `running` used to count, so the sensor turned off during every soak, pause
|
||||
# and the end wait, and automations that treat "off" as "done" fired mid-cycle
|
||||
# (audit PLATFORM-06). STARTING is excluded (not yet a confirmed cycle), as is
|
||||
# ANTI_WRINKLE (the cycle has finished; the drum only tumbles the load).
|
||||
CYCLE_IN_PROGRESS_STATES = frozenset(
|
||||
{STATE_RUNNING, STATE_PAUSED, STATE_USER_PAUSED, STATE_ENDING}
|
||||
)
|
||||
|
||||
STATE_UNKNOWN = "unknown"
|
||||
STATE_CLEAN = "clean" # Cycle ended but door not yet opened (laundry still inside)
|
||||
|
||||
# A cycle start from one of these owns no update intervals yet, so the manager drops
|
||||
# the cadence intervals earlier false starts left pending (#458, items 504 and 515).
|
||||
CADENCE_RESET_FROM_STATES = frozenset({
|
||||
STATE_OFF, STATE_UNKNOWN, STATE_DELAY_WAIT,
|
||||
STATE_FINISHED, STATE_INTERRUPTED, STATE_FORCE_STOPPED,
|
||||
})
|
||||
|
||||
# Authoritative state -> display color map. Single source of truth for the
|
||||
# full-screen panel (and any other frontend), surfaced over the WebSocket
|
||||
# get_constants command so colors are defined in exactly one place. Values are
|
||||
@@ -872,7 +961,6 @@ STATE_COLORS = {
|
||||
STATE_ANTI_WRINKLE: "var(--info-color, #2196f3)",
|
||||
STATE_INTERRUPTED: "var(--error-color, #f44336)",
|
||||
STATE_FORCE_STOPPED: "var(--error-color, #f44336)",
|
||||
STATE_RINSE: "var(--info-color, #2196f3)",
|
||||
STATE_CLEAN: "var(--teal-color, #009688)",
|
||||
STATE_UNKNOWN: "var(--disabled-color, #bdbdbd)",
|
||||
"recording": "var(--error-color, #f44336)",
|
||||
@@ -954,14 +1042,31 @@ STANDBY_BAND_FINALIZE_DEVICE_TYPES = (
|
||||
# safe - past expected AND >=10 min flat AND <=10% of the cycle's own peak is
|
||||
# an appliance that has finished, not one still working.
|
||||
STANDBY_BAND_MIN_RATIO = 1.0 # only past the expected duration
|
||||
# ...but only for a plateau that IS the #445 shape: sitting at or just above the
|
||||
# stop threshold, within max(STANDBY_BAND_NEAR_STOP_FACTOR x stop,
|
||||
# stop + STANDBY_BAND_NEAR_STOP_W). 0.5.7 dropped the ratio for EVERY plateau the
|
||||
# loose test below accepts - flat and under 10% of the heater peak, i.e. anything
|
||||
# from a 0 W soak to a 70 W rinse on a 2 kW machine - so a run matched to a shorter
|
||||
# programme was finalised mid-wash. Replaying the local corpus at 0.5.7 it fired on
|
||||
# 8 washer cycles (0 at 0.5.6): 2 split, 6 lost 6-31 min of real activity, and none
|
||||
# of the 8 plateaus sat within a few watts of the stop threshold. The #445 Miele
|
||||
# (3.2-3.5 W idle on a 2.56 W stop), #458 (2.2 W on 1.76 W) and #427's AEG (0.7 W on
|
||||
# 0.6 W) all do.
|
||||
STANDBY_BAND_NEAR_STOP_FACTOR = 2.0
|
||||
STANDBY_BAND_NEAR_STOP_W = 3.0
|
||||
# Any other flat low plateau keeps the 0.5.6 gate: twice the expected duration.
|
||||
STANDBY_BAND_LOOSE_MIN_RATIO = 2.0
|
||||
|
||||
# Ceiling on the measured post-activity quiet span a stored cycle may bank
|
||||
# (register item 297). `profile_terminal_quiet_seconds` is a median over that profile's own
|
||||
# cycles, so it is already self-limiting; this is the guard against a corrupted
|
||||
# or hand-edited value licensing an unbounded tail - the one thing the field
|
||||
# exists to prevent. 30 min comfortably covers a dishwasher's passive drying
|
||||
# phase, measured at a median 11% of the cycle and reaching 43%.
|
||||
TERMINAL_QUIET_CAP_S = 1800.0
|
||||
# exists to prevent. Not a measurement of drying: 30 min did not cover it
|
||||
# (register item 469). 01KGM619's Eco dries 4840-4860 s before its pump-out, so
|
||||
# a run closed without one stored last activity + 1800 s (~8.0k s of an ~11.1k s
|
||||
# programme). At 2 h: 12 such replays store 10.0-11.3k s, end lag unchanged on
|
||||
# dishwashers but one cycle (+3 min) and no early end or split moved.
|
||||
TERMINAL_QUIET_CAP_S = 7200.0
|
||||
# A measured quiet span is only trusted as a tail allowance when the profile has
|
||||
# actually shown it repeatedly (register item 297). Measured over 20 real profiles: the two
|
||||
# dishwashers, which genuinely end in a passive drying phase, scored 20/20 and
|
||||
@@ -980,6 +1085,11 @@ BANKED_TAIL_REPAIR_KEY = "_banked_tail_repair_pending"
|
||||
# span is worth correcting. Measured median banking was 12.6 min, so this only
|
||||
# skips noise.
|
||||
BANKED_TAIL_REPAIR_MIN_S = 60.0
|
||||
# A dishwasher's stored end never falls before this fraction of the shortest
|
||||
# length the user has vouched for in its profile (`manual_duration`, a recorder
|
||||
# capture, a golden cycle). Shared by the banked-tail repair and the live
|
||||
# `_keep_tail_cap` (register item 384), so the two store the same duration.
|
||||
TRUSTED_LENGTH_FLOOR_FRAC = 0.9
|
||||
STANDBY_BAND_WINDOW_S = 600.0 # require a >=10 min flat plateau
|
||||
STANDBY_BAND_MAX_FRACTION = 0.10 # plateau level <= 10% of the cycle's peak
|
||||
STANDBY_BAND_FLATNESS_FRACTION = 0.03 # window (max-min) <= 3% of the cycle's peak
|
||||
@@ -1052,10 +1162,6 @@ DEFAULT_ANTI_CREASE_FINALIZE_RATIO = 0.98 # elapsed must reach 98% of expected
|
||||
# remove the discriminator this gate rests on for washing machines (see above).
|
||||
ANTI_CREASE_FINALIZE_RATIO_MIN = 0.5
|
||||
ANTI_CREASE_FINALIZE_RATIO_MAX = 1.0
|
||||
# Backwards-compatible alias: the pre-#429 module-level constant. Kept so older
|
||||
# imports (and anything pinned in the lab) still resolve; the detector reads the
|
||||
# per-device config field, never this.
|
||||
ANTI_CREASE_FINALIZE_RATIO = DEFAULT_ANTI_CREASE_FINALIZE_RATIO
|
||||
ANTI_CREASE_CONFIRM_WINDOW_S = 180.0 # recent window that must hold no reading > max_power
|
||||
|
||||
# Issue #399: both conditions above look BACKWARDS, so a wash whose final spin
|
||||
@@ -1080,6 +1186,14 @@ ANTI_CREASE_TERMINAL_MATCH_FRAC = 0.5 # live high-power seconds after that
|
||||
# profile's own block, that count as
|
||||
# "this run has had its spin"
|
||||
ANTI_CREASE_SPIN_WAIT_MAX_RATIO = 1.25 # never block past this x expected
|
||||
# Register item 480: the envelope's max band arms the guard when ANY member's last
|
||||
# block above the level is terminal, and washer spins straddle 400 W (held runs
|
||||
# peak at 331-394 W, the members that "spin" at 404-437 W). Once the band arms,
|
||||
# the guard stays armed only when at least this share of the profile's completed
|
||||
# traced members end with their own terminal block; with fewer members than the
|
||||
# floor the band (or sample) decides alone, as before.
|
||||
ANTI_CREASE_SPIN_ARM_MIN_SHARE = 0.5
|
||||
ANTI_CREASE_SPIN_ARM_MIN_MEMBERS = 2
|
||||
|
||||
# Device Type Defaults
|
||||
# Device Type Defaults (Maps)
|
||||
@@ -1145,6 +1259,15 @@ DISHWASHER_MATCH_FREEZE_QUIET_SECONDS = 300.0
|
||||
# caught by the end-spike arm first. Smaller than the 30-min window but large enough
|
||||
# to confirm a terminal tail rather than an inter-phase gap.
|
||||
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS = 600.0
|
||||
# ...but never shorter than this multiple of the matched profile's MEASURED quiet
|
||||
# before its terminal event (`profile_terminal_quiet_seconds`, match element 11):
|
||||
# a release after 600 s of quiet is premature on a programme measured to wait
|
||||
# 934-1810 s before its pump-out, and ended the corpus's "65° full" 12 min early
|
||||
# (register item 392). Lengthen-only, and bounded by the 30 min spike wait.
|
||||
# The same margin applies to the longest below-stop pause the profile's traced
|
||||
# cycles ever resumed from (match element 14, register item 465): element 11 is a
|
||||
# median that a cycle closed before its pump-out drags down.
|
||||
DISHWASHER_QUIET_RELEASE_TERMINAL_MARGIN = 1.1
|
||||
|
||||
# Confirmation window a dishwasher must spend in ENDING before Smart Termination
|
||||
# fires. This is deliberately a FIXED constant and NOT derived from off_delay:
|
||||
@@ -1330,9 +1453,18 @@ def resolve_min_off_gap_default(device_type: str) -> int:
|
||||
return int(DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(device_type, DEFAULT_MIN_OFF_GAP))
|
||||
|
||||
|
||||
def resolve_off_delay_default(device_type: str) -> int:
|
||||
"""Device-resolved off delay (#445), published for the same reason."""
|
||||
return int(DEFAULT_OFF_DELAY_BY_DEVICE.get(device_type, DEFAULT_OFF_DELAY))
|
||||
def resolve_off_delay_default(device_type: str) -> int: # noqa: ARG001
|
||||
"""The off delay a device runs on when it has none set (#445).
|
||||
|
||||
``DEFAULT_OFF_DELAY`` for every type. ``DEFAULT_OFF_DELAY_BY_DEVICE`` is the
|
||||
suggestion engine's FLOOR for a proposed value, not a runtime default: no
|
||||
device has ever run on it, because the config flow does not store an off
|
||||
delay and the manager falls back to the scalar. Publishing the table here
|
||||
made the panel show a dishwasher's unset Off Delay as 1800 s while the
|
||||
detector used 180 s - and 180 is the floor of the late ENDING shortening, so
|
||||
the difference is not cosmetic. The parameter is kept for the call sites.
|
||||
"""
|
||||
return int(DEFAULT_OFF_DELAY)
|
||||
|
||||
|
||||
def resolve_start_duration_default(device_type: str) -> float:
|
||||
@@ -1347,11 +1479,6 @@ def resolve_start_duration_default(device_type: str) -> float:
|
||||
return max(DEFAULT_START_DURATION_THRESHOLD, sampling)
|
||||
|
||||
|
||||
# Default profile match min duration ratio by device type
|
||||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE = {
|
||||
DEVICE_TYPE_DISHWASHER: 0.10,
|
||||
}
|
||||
|
||||
# Default Smart-Termination duration ratio by device type (#393). Dishwashers run
|
||||
# fixed programs (measured spread +4%/+17% around the mean), so the conservative
|
||||
# 0.99 gate is defensible there; every other type keeps the scalar
|
||||
@@ -1408,14 +1535,14 @@ def resolve_smart_termination_duration_ratio_default(device_type: str) -> float:
|
||||
device_type, DEFAULT_SMART_TERMINATION_DURATION_RATIO
|
||||
)
|
||||
|
||||
# Profile groups (Stage 5): the matcher only collapses a group into one
|
||||
# aggregate candidate when its members' minimum pairwise shape similarity is at
|
||||
# least this. Similarity is DTW/Sakoe-Chiba on peak-normalised envelopes, so it
|
||||
# tolerates the duration (longer heating/draining) and amplitude (temp/spin)
|
||||
# variation between real members. Looser groups stay individual (a blurry generic
|
||||
# aggregate could out-match unrelated profiles) and are flagged in the UI.
|
||||
# Calibrated on real profiles: genuine temp/spin variants score ~0.86-0.95,
|
||||
# distinct programs <~0.6; 0.80 leaves margin below the 0.85 suggestion bar.
|
||||
# Profile groups (Stage 5): a group is mapped to its members (and so collapsed into
|
||||
# one family after every member is scored on its own curve) only when the members'
|
||||
# minimum pairwise shape similarity is at least this. There is no aggregate
|
||||
# candidate any more (#400). Similarity is DTW/Sakoe-Chiba on peak-normalised
|
||||
# envelopes, so it tolerates the duration (longer heating/draining) and amplitude
|
||||
# (temp/spin) variation between real members. Looser groups stay individual and are
|
||||
# flagged in the UI. Calibrated on real profiles: genuine temp/spin variants score
|
||||
# ~0.86-0.95, distinct programs <~0.6.
|
||||
GROUP_MIN_COHESION = 0.80
|
||||
|
||||
# Per-profile terminal signature (`profile_store.compute_profile_terminal_signature`).
|
||||
@@ -1429,6 +1556,13 @@ GROUP_MIN_COHESION = 0.80
|
||||
# gap inside the wash; 120 s is well under the measured p10 of 624 s.
|
||||
TERMINAL_EVENT_PEAK_FRAC = 0.004
|
||||
TERMINAL_QUIET_MIN_S = 120.0
|
||||
# ...but a gap of 120 s also sits between a dishwasher's own heating blocks
|
||||
# (120-160 s on 01KGM619), so a cycle closed before its pump-out offered its LAST
|
||||
# HEATING BLOCK as the terminal event (register item 469). A terminal event is a
|
||||
# low-power one: over every corpus dishwasher the candidates peak at <= 9.1% of
|
||||
# their cycle's peak (median 1.3%) or at >= 90% (main activity), nothing between.
|
||||
# Above this fraction the last run is main activity and the trace has no event.
|
||||
TERMINAL_EVENT_MAX_PEAK_FRAC = 0.25
|
||||
# Below this many evidence cycles the medians describe noise, not the programme.
|
||||
TERMINAL_SIGNATURE_MIN_CYCLES = 3
|
||||
# Cycles a profile needs before one of them can be called a duration outlier
|
||||
@@ -1448,18 +1582,52 @@ SELF_UNMATCHABLE_MIN_CYCLES = 3
|
||||
# v9: pre-initialize additive top-level keys (lifetime_energy_wh,
|
||||
# settings_changelog, maintenance_log) so they are present from first load
|
||||
# rather than only appearing lazily on first use.
|
||||
# v11 is a marker-only bump: per-phase profiles (envelope["phase_profile"]) are
|
||||
# derived cache populated by async_rebuild_envelope, so no data migration is
|
||||
# needed - they self-populate on the next envelope rebuild.
|
||||
# v11 is a marker-only bump. It introduced a per-phase envelope cache for the
|
||||
# phase-resolved ETA, removed with that stack in 0.5.8 (register item 411); the
|
||||
# version stays because a store version can never go back down.
|
||||
# v12: initialize `backfill_cycles`, the third cycle list (issue #344). Cycles
|
||||
# recovered from raw power history predating the integration are auto-detected and
|
||||
# unverified, so they belong in neither `past_cycles` (which feeds lifetime stats, ML
|
||||
# training labels and the feedback queue, and is retention-evicted oldest-first) nor
|
||||
# `reference_cycles` (curated community-store templates, golden by construction).
|
||||
# Additive `setdefault`, so it is idempotent and loses nothing.
|
||||
STORAGE_VERSION = 13
|
||||
# v13: marker-only, arms the one-time banked-tail repair (register item 297).
|
||||
# v14: marker-only, re-arms it (#424): the v13 pass judged the drying phase at the
|
||||
# wrong threshold and skipped dishwasher timeout finishes.
|
||||
# v15: label provenance repair (audit MANAGER-01): cycles the user confirmed or
|
||||
# corrected in the review queue are stamped `label_source="manual"`, and an answer
|
||||
# the panel's Auto-label had replaced is put back. Pure data, idempotent.
|
||||
# v16: review-queue cleanup (register item 433): pending requests the new rule
|
||||
# would not raise are dropped, without recording an answer. Idempotent.
|
||||
# v17: drops the state of the ML parts removed in 0.5.8 (the early match commit,
|
||||
# the quality gate, the matcher weight tuner, and on-device training of every head
|
||||
# but total_energy): `match_ranking_history`, `matching_config`, and the
|
||||
# `live_match` / `quality` / `end` / `remaining_time` records in
|
||||
# `ml_model_versions` / `ml_training_history`. No cycle or label is touched.
|
||||
# Idempotent.
|
||||
STORAGE_VERSION = 17
|
||||
STORAGE_KEY = "ha_washdata"
|
||||
|
||||
# Restore point for "Undo last import" (register item 195): the store as it was before
|
||||
# the last replace import, in its own file `ha_washdata.<entry_id>.pre_import` so the
|
||||
# main store's per-save rewrite never carries a second copy. One per device: the next
|
||||
# replace import overwrites it, an undo consumes it, deleting the device removes it
|
||||
# (`async_remove_entry` + the orphan sweep in `__init__.py`). The record carries the
|
||||
# `STORAGE_VERSION` it was taken at, and a restore migrates it forward.
|
||||
PRE_IMPORT_STORE_SUFFIX = "pre_import"
|
||||
PRE_IMPORT_STORE_VERSION = 1
|
||||
|
||||
# Notifications held by quiet hours / presence when Home Assistant stops or the entry
|
||||
# unloads, in `ha_washdata.<entry_id>.notify_queue` (audit MANAGER-16). Written at the
|
||||
# stop, read and deleted once HA has started again; removed with the device.
|
||||
NOTIFY_QUEUE_STORE_SUFFIX = "notify_queue"
|
||||
|
||||
# The last active-cycle snapshot that failed to restore, in
|
||||
# `ha_washdata.<entry_id>.failed_restore` (register item 266 follow-up): kept for the
|
||||
# diagnostics download instead of being deleted with the cycle it held. Written only
|
||||
# on a failure, one per device (the next failure overwrites it), removed with the device.
|
||||
FAILED_RESTORE_STORE_SUFFIX = "failed_restore"
|
||||
|
||||
# ─── Config-entry schema version (NOT the storage version above) ───────────────
|
||||
# Single source for the config-entry schema: `ConfigFlow.VERSION`/`MINOR_VERSION`, every
|
||||
# stepwise block in `async_migrate_entry`, and the `minor_version=` the one-pass legacy
|
||||
@@ -1488,8 +1656,6 @@ SERVICE_SUBMIT_FEEDBACK = (
|
||||
# no panel sections render and no background work runs.
|
||||
#
|
||||
# SHOW_ML_LAB ML Lab comparison tab in the WashData panel.
|
||||
# ENABLE_ML_SUGGESTIONS ML-model-driven setting suggestions (Stage 3), shown
|
||||
# side-by-side with the classic statistical suggestions.
|
||||
# ENABLE_ML_TRAINING On-device model training loop (Stage 4): scheduled
|
||||
# retraining on the user's own labeled cycles.
|
||||
#
|
||||
@@ -1497,8 +1663,16 @@ SERVICE_SUBMIT_FEEDBACK = (
|
||||
# are always on - they only improve the existing suggestion engine and add no
|
||||
# new surfaces, so they need no flag.
|
||||
SHOW_ML_LAB = True
|
||||
ENABLE_ML_SUGGESTIONS = True
|
||||
ENABLE_ML_TRAINING = True
|
||||
# Frozen off even when a device enables ML models (audit ML-05): replayed on 292
|
||||
# real cycles the end-guard prevented no premature stop and raised the washer
|
||||
# median end lag 12.2 -> 17.5 min, every deferral the full 30 min cap.
|
||||
ENABLE_ML_END_GUARD = False
|
||||
# Removed in 0.5.8, after being frozen here: the remaining-time regressor (C4,
|
||||
# audit ML-07: worse than the naive estimate on 7 of 8 installs), the early match
|
||||
# commit (C2, audit ML-02: 31% of its early commits wrong vs 8.4% for
|
||||
# persistence) and the quality gate (C3, audit ML-06: fired on 0 of the 12
|
||||
# auto-label-eligible real cycles), with the matcher weight tuner.
|
||||
|
||||
# ─── Community store (online features) ────────────────────────────────────────
|
||||
# Opt-in browsing/importing/sharing of reference cycles via the WashData Store.
|
||||
@@ -1519,28 +1693,71 @@ DEFAULT_ENABLE_ONLINE_FEATURES = False
|
||||
SHAREABLE_SETTING_KEYS: tuple[str, ...] = (
|
||||
# Detection / recognition
|
||||
CONF_MIN_POWER,
|
||||
CONF_OFF_DELAY,
|
||||
CONF_START_THRESHOLD_W,
|
||||
CONF_STOP_THRESHOLD_W,
|
||||
CONF_START_DURATION_THRESHOLD,
|
||||
CONF_START_ENERGY_THRESHOLD,
|
||||
CONF_COMPLETION_MIN_SECONDS,
|
||||
CONF_MIN_OFF_GAP,
|
||||
CONF_END_ENERGY_THRESHOLD,
|
||||
CONF_POWER_OFF_THRESHOLD_W,
|
||||
CONF_POWER_OFF_DELAY,
|
||||
# (off_delay, min_off_gap, power_off_*, profile_match_interval dropped: they
|
||||
# are functions of the SHARER'S plug cadence, not the model - a 94 s plug's
|
||||
# 1800 s off_delay is a 30 min end lag on a 1 s plug. Audit STORE-06.)
|
||||
# Matching
|
||||
CONF_PROFILE_MATCH_THRESHOLD,
|
||||
CONF_PROFILE_UNMATCH_THRESHOLD,
|
||||
CONF_PROFILE_MATCH_INTERVAL,
|
||||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
CONF_PROFILE_DURATION_TOLERANCE,
|
||||
# (profile_duration_tolerance dropped: nothing reads it - audit DOCS-01.)
|
||||
CONF_DURATION_TOLERANCE,
|
||||
CONF_AUTO_LABEL_CONFIDENCE,
|
||||
CONF_LEARNING_CONFIDENCE,
|
||||
)
|
||||
|
||||
|
||||
# Shared settings the panel bounds to 0-1 (scores and a fraction). Every shareable
|
||||
# setting is also >= 0 there.
|
||||
_SHARED_UNIT_INTERVAL_KEYS = frozenset({
|
||||
CONF_PROFILE_MATCH_THRESHOLD,
|
||||
CONF_PROFILE_UNMATCH_THRESHOLD,
|
||||
CONF_DURATION_TOLERANCE,
|
||||
CONF_AUTO_LABEL_CONFIDENCE,
|
||||
CONF_LEARNING_CONFIDENCE,
|
||||
})
|
||||
|
||||
|
||||
def sanitize_shared_settings(settings: Any) -> dict[str, float]:
|
||||
"""The allow-listed, finite, numeric subset of a shared settings map.
|
||||
|
||||
Every share/adopt/export site goes through this. A value outside the panel's
|
||||
own range is dropped (a match threshold of 5 can never be met by a 0-1 score).
|
||||
The duration ratios are also held to the shipped bounds (audit STORE-06):
|
||||
25/25 store bundles carried a max ratio below 1.8 (12 at the 1.5 measured to
|
||||
delete the true candidate on 2.3% of folds, register item 311), and min ratios
|
||||
up to 0.81 forbid any match before 81% of a programme.
|
||||
"""
|
||||
if not isinstance(settings, dict):
|
||||
return {}
|
||||
out: dict[str, float] = {}
|
||||
for key, value in settings.items():
|
||||
if key not in SHAREABLE_SETTING_KEYS or isinstance(value, bool):
|
||||
continue
|
||||
if not isinstance(value, (int, float)):
|
||||
continue
|
||||
try:
|
||||
number = float(value) # an oversized JSON integer raises here
|
||||
except OverflowError:
|
||||
continue
|
||||
if not math.isfinite(number):
|
||||
continue
|
||||
if number < 0 or (key in _SHARED_UNIT_INTERVAL_KEYS and number > 1):
|
||||
continue
|
||||
if key == CONF_PROFILE_MATCH_MAX_DURATION_RATIO:
|
||||
value = max(value, DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO)
|
||||
elif key == CONF_PROFILE_MATCH_MIN_DURATION_RATIO:
|
||||
value = min(value, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO)
|
||||
out[str(key)] = value
|
||||
return out
|
||||
|
||||
# Public Firebase web config for the community store (NOT secret - identifies the
|
||||
# project; access is enforced by the store's Firestore rules).
|
||||
STORE_PROJECT_ID = "washdata-store"
|
||||
@@ -1568,16 +1785,10 @@ DEFAULT_ML_TRAINING_HOUR = 2 # 02:00 local - quiet hour
|
||||
DEFAULT_ML_TRAINING_MIN_CYCLES = 30 # need a meaningful corpus first
|
||||
DEFAULT_ML_TRAINING_INTERVAL_DAYS = 7 # retrain at most weekly
|
||||
|
||||
# A newly trained model is only promoted over the shipped baseline when its
|
||||
# held-out AUC is at least (baseline AUC - this margin). Small negative slack is
|
||||
# allowed so personalisation can win even at a tiny AUC cost.
|
||||
ML_TRAINING_AUC_MARGIN = 0.02
|
||||
# Separate tolerance for the calibration gate: a retrained classifier must not
|
||||
# degrade balanced accuracy AT the live operating cutoff by more than this. Kept
|
||||
# distinct from ML_TRAINING_AUC_MARGIN because it bounds a different metric (decision
|
||||
# quality at a fixed threshold, not overall rank quality); same 0.02 default today.
|
||||
ML_TRAINING_BACC_MARGIN = 0.02
|
||||
ML_TRAINING_MIN_POSITIVES = 20 # need at least this many positive examples to trust a fit
|
||||
# (The classifier promotion gate - AUC margin, balanced-accuracy margin, minimum
|
||||
# positives - was removed in 0.5.8 with on-device classifier training: audit ML-11
|
||||
# measured it promoting worse models on 4-7 held-out positives. Only the
|
||||
# total_energy regressor is trained on-device now.)
|
||||
|
||||
# Per-capability held-out-score history kept across training runs, so the panel
|
||||
# can show whether a model's fit is improving, steady, or declining over time
|
||||
@@ -1585,18 +1796,19 @@ ML_TRAINING_MIN_POSITIVES = 20 # need at least this many positive examples to t
|
||||
# are retained.
|
||||
ML_TRAINING_HISTORY_MAX = 30
|
||||
|
||||
# Remaining-time regressor (standardized_linear). Unlike the classifier heads it
|
||||
# has no shipped baseline; it is only promoted when its held-out mean-absolute
|
||||
# error on the completion-fraction target beats the naive elapsed/expected
|
||||
# estimate by at least this relative margin (5% lower MAE). Trained from prefixes
|
||||
# of the device's own clean cycles.
|
||||
# Total-energy regressor (standardized_linear), the one head trained on-device.
|
||||
# It has no shipped baseline; it is only promoted when its held-out mean-absolute
|
||||
# error on the energy-fraction target beats the naive elapsed/expected
|
||||
# estimate by at least this relative margin (5% lower MAE) AND beats the model
|
||||
# already in use, scored on the same held-out cycles (audit ML-12). Trained from
|
||||
# prefixes of the device's own clean cycles.
|
||||
ML_TRAINING_REGRESSION_MARGIN = 0.05
|
||||
ML_TRAINING_MIN_REGRESSION_ROWS = 30 # synthesized prefix rows needed to fit
|
||||
# How strongly a promoted remaining-time regressor influences the live progress
|
||||
# estimate. The ML completion-fraction is blended with the phase-aware estimate
|
||||
# at this weight before the existing EMA smoothing/monotonicity guards run, so a
|
||||
# bad model can never wholly override the proven phase estimator.
|
||||
ML_PROGRESS_BLEND_WEIGHT = 0.5
|
||||
# Held-out cycles a regressor must be scored on before it can be promoted. The
|
||||
# 20% holdout rested on ONE cycle for the only real promotion on record (audit
|
||||
# PROGRESS-16); the split now holds out at least this many when the device has
|
||||
# twice as many usable cycles, and never promotes on fewer.
|
||||
ML_TRAINING_MIN_HOLDOUT_CYCLES = 5
|
||||
|
||||
# Service + event names for the training loop.
|
||||
SERVICE_TRIGGER_ML_TRAINING = "trigger_ml_training"
|
||||
@@ -1627,33 +1839,68 @@ DEFAULT_MAINTENANCE_REMINDER_CYCLES = {
|
||||
"filter_clean": 50,
|
||||
"drum_clean": 100,
|
||||
}
|
||||
# Recognised maintenance event types. bearing_service / other default off (absent
|
||||
# from the default reminder dict) and are opt-in.
|
||||
# Recognised built-in maintenance event types. bearing_service / other default off
|
||||
# (absent from the default reminder dict) and are opt-in. The last four are the
|
||||
# device-type presets of discussion #461 (see MAINTENANCE_PRESETS_BY_DEVICE_TYPE).
|
||||
MAINTENANCE_EVENT_TYPES = (
|
||||
"descale",
|
||||
"filter_clean",
|
||||
"drum_clean",
|
||||
"bearing_service",
|
||||
"other",
|
||||
"salt",
|
||||
"rinse_aid",
|
||||
"lint_filter",
|
||||
"condenser_clean",
|
||||
)
|
||||
# Preset defaults per device type (cycles), replacing DEFAULT_MAINTENANCE_REMINDER_CYCLES
|
||||
# for that type while its reminder config was never saved. None of these can be
|
||||
# measured from power: they are manufacturer ballparks set on the early side, so the
|
||||
# reminder comes with headroom. Every type not listed keeps the washer default.
|
||||
MAINTENANCE_PRESETS_BY_DEVICE_TYPE: dict[str, dict[str, int]] = {
|
||||
DEVICE_TYPE_DISHWASHER: {
|
||||
# A 1-2 kg softener reservoir lasts ~30-60 cycles at medium-hard water.
|
||||
"salt": 30,
|
||||
# A ~110-150 ml rinse-aid reservoir at ~3 ml per cycle lasts ~40-50 cycles.
|
||||
"rinse_aid": 40,
|
||||
# Same 50 as the washer default, so an existing dishwasher's reminder stays put.
|
||||
"filter_clean": 50,
|
||||
},
|
||||
DEVICE_TYPE_DRYER: {
|
||||
# Manufacturers say every load; 10 is a backstop for a forgotten filter.
|
||||
"lint_filter": 10,
|
||||
# Condenser / heat-pump filters: manufacturers suggest every ~20-50 loads.
|
||||
"condenser_clean": 30,
|
||||
},
|
||||
}
|
||||
# Built-in types the reminder editor offers per device type (in this order). Types
|
||||
# not listed get the original five. A type with a saved positive threshold is shown
|
||||
# whatever its device type, so no saved reminder ever disappears from the editor.
|
||||
MAINTENANCE_TYPES_BY_DEVICE_TYPE: dict[str, tuple[str, ...]] = {
|
||||
DEVICE_TYPE_DISHWASHER: ("salt", "rinse_aid", "filter_clean", "descale", "other"),
|
||||
DEVICE_TYPE_DRYER: ("lint_filter", "condenser_clean", "other"),
|
||||
}
|
||||
# The preset types count "since last done" from the moment their reminder is first
|
||||
# active (a baseline stamped in the store), not from odometer 0: an upgraded
|
||||
# dishwasher with 300 cycles must not open with "salt due" (#461). The original five
|
||||
# keep counting from the whole odometer when never logged, exactly as before.
|
||||
MAINTENANCE_COUNT_FROM_ENABLE_TYPES = frozenset(
|
||||
{"salt", "rinse_aid", "lint_filter", "condenser_clean"}
|
||||
)
|
||||
# User-defined maintenance tasks (#461), stored per device in the profile store
|
||||
# ("maintenance_tasks") next to the log entries that reference them. Each has a
|
||||
# free-text name and an interval in cycles and/or days (0 = off for that axis);
|
||||
# it is due when either is reached. Ids carry this prefix so they can never collide
|
||||
# with a built-in type.
|
||||
MAINTENANCE_CUSTOM_TASK_PREFIX = "custom_"
|
||||
MAINTENANCE_CUSTOM_TASK_MAX = 20
|
||||
MAINTENANCE_TASK_NAME_MAX = 60
|
||||
MAINTENANCE_INTERVAL_CYCLES_MAX = 100000
|
||||
MAINTENANCE_INTERVAL_DAYS_MAX = 3650
|
||||
# A logged maintenance event of a matching type within this many days suppresses
|
||||
# the "needs maintenance" nag advisory (duration-trend / shape-drift).
|
||||
MAINTENANCE_RECENT_SUPPRESS_DAYS = 30
|
||||
|
||||
# ─── Playground stress-tail constants (never used by the live integration) ─────
|
||||
# These govern the synthetic idle continuation in the "Test idle termination"
|
||||
# Playground toggle. All times are in seconds.
|
||||
PLAYGROUND_STRESS_TRAILING_WINDOW_S: float = 60.0 # window for idle-floor derivation
|
||||
PLAYGROUND_STRESS_FLOOR_PERCENTILE: float = 0.07 # p7 of window readings = standby floor
|
||||
PLAYGROUND_STRESS_FLUCT_FALLBACK_FRAC: float = 0.12 # ±12% fallback when window is flat
|
||||
PLAYGROUND_STRESS_DENSE_STEP_S: float = 30.0 # dense pre-fill cadence
|
||||
PLAYGROUND_STRESS_DENSE_DURATION_S: float = 1200.0 # dense pre-fill length (20 min)
|
||||
PLAYGROUND_STRESS_SPARSE_STEP_S: float = 1800.0 # sparse main step (30 min)
|
||||
PLAYGROUND_STRESS_MAX_SPARSE_STEPS: int = 15 # max sparse steps → max 7.5 h extra
|
||||
PLAYGROUND_STRESS_MAX_IDLE_W: float = 100000.0 # upper bound for a manual idle override
|
||||
# (far beyond any appliance; guards against
|
||||
# inf/absurd values corrupting synthesis)
|
||||
|
||||
# ─── Playground setting presets (sandbox snapshots, per device) ────────────────
|
||||
# Named snapshots of the Playground control panel's values, stored under the
|
||||
# "playground_presets" store key. They never touch the live config: publishing a
|
||||
@@ -1677,9 +1924,8 @@ CONF_PROFILE_EVIDENCE_SOURCES = "profile_evidence_sources"
|
||||
EVIDENCE_REAL_CYCLES = "real_cycles"
|
||||
EVIDENCE_REFERENCE_CYCLES = "reference_cycles"
|
||||
EVIDENCE_BACKFILL_CYCLES = "backfill_cycles"
|
||||
# `real_cycles`/`reference_cycles` match the export taxonomy (`_EXPORT_CATEGORIES`); the
|
||||
# evidence view adds `backfill_cycles`, which the selective-export wizard does not yet
|
||||
# enumerate (whole-store export still round-trips it).
|
||||
# All three match the export taxonomy (`_EXPORT_CATEGORIES`), which the selective
|
||||
# export/import wizard enumerates per category (register item 129e).
|
||||
PROFILE_EVIDENCE_SOURCES = (
|
||||
EVIDENCE_REAL_CYCLES,
|
||||
EVIDENCE_REFERENCE_CYCLES,
|
||||
@@ -1696,7 +1942,9 @@ DEFAULT_PROFILE_EVIDENCE_SOURCES = list(PROFILE_EVIDENCE_SOURCES)
|
||||
HISTORY_IMPORT_MAX_BYTES: int = 32 * 1024 * 1024 # staged upload cap (~32 MiB of CSV text)
|
||||
HISTORY_IMPORT_MAX_ROWS: int = 500_000 # parsed-row cap (≈ a month at 5 s)
|
||||
HISTORY_IMPORT_CHUNK_BYTES: int = 512 * 1024 # per-WS-message upload chunk (frame cap is 4 MiB)
|
||||
HISTORY_IMPORT_CHUNK_SAMPLES: int = 4000 # samples replayed per executor job
|
||||
# Samples replayed per executor job. 4000 was ~1.0 s of GIL per job on a desktop,
|
||||
# i.e. the #311 freeze pattern on a Pi (audit PLAYGROUND-11); 1000 is ~0.25 s.
|
||||
HISTORY_IMPORT_CHUNK_SAMPLES: int = 1000
|
||||
HISTORY_IMPORT_MIN_BLOCK_SAMPLES: int = 20 # floor for the per-block sample gate
|
||||
HISTORY_IMPORT_MAX_MEDIAN_INTERVAL_S: float = 120.0 # floor for the per-block cadence gate; the
|
||||
# effective gate is
|
||||
@@ -1707,6 +1955,9 @@ HISTORY_IMPORT_EDGE_GAP_S: float = 60.0 # leading samples this far
|
||||
# are hourly-average debris and are trimmed
|
||||
# (leading edge ONLY - trimming the trailing
|
||||
# edge eats a real cycle's low-power tail)
|
||||
HISTORY_IMPORT_MAX_BRIDGE_S: float = 300.0 # an `unavailable` hole up to this long inside
|
||||
# a block is bridged as a plain gap (Wi-Fi
|
||||
# blip, HA restart), not a cut (PLAYGROUND-06)
|
||||
HISTORY_IMPORT_MAX_BLOCK_SPAN_S: float = 12 * 3600.0 # a block longer than this can only produce the
|
||||
# detector's 8 h `force_stopped` blob, so it is
|
||||
# reported rather than replayed
|
||||
@@ -1737,3 +1988,59 @@ HISTORY_IMPORT_RECORDER_EMPTY_DAY_STOP: int = 30 # consecutive empty days th
|
||||
# without this, a 10-year request would issue
|
||||
# thousands of pointless queries.
|
||||
HISTORY_IMPORT_SOURCE: str = "history_import" # `meta.source` marker on imported cycles
|
||||
|
||||
|
||||
def numeric_option_keys() -> dict[str, type]:
|
||||
"""Option keys whose compiled default is a number -> that default's type.
|
||||
|
||||
Derived from the ``CONF_X`` / ``DEFAULT_X`` naming pair, so a new numeric
|
||||
setting is covered without a list to maintain (audit PLATFORM-13).
|
||||
"""
|
||||
g = globals()
|
||||
out: dict[str, type] = {}
|
||||
for name, key in g.items():
|
||||
if not name.startswith("CONF_") or not isinstance(key, str):
|
||||
continue
|
||||
default = g.get("DEFAULT_" + name[5:])
|
||||
if isinstance(default, (int, float)) and not isinstance(default, bool):
|
||||
out[key] = type(default)
|
||||
return out
|
||||
|
||||
|
||||
def coerce_numeric_option(value: Any, kind: type) -> float | int | None:
|
||||
"""``value`` as a finite number of ``kind``'s type, or None if it is not one."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not math.isfinite(number):
|
||||
return None
|
||||
if kind is int:
|
||||
return int(number) if number.is_integer() else number
|
||||
return number
|
||||
|
||||
|
||||
def drop_invalid_numeric_options(options: Any) -> tuple[dict[str, Any], list[str]]:
|
||||
"""``(options without non-numeric numeric settings, the keys dropped)``.
|
||||
|
||||
A non-numeric value for a numeric setting is dropped so its default applies;
|
||||
stored, it raised in the manager's constructor and the entry never set up
|
||||
again (audit PLATFORM-13, register item 279).
|
||||
"""
|
||||
if not isinstance(options, dict):
|
||||
return {}, []
|
||||
kinds = numeric_option_keys()
|
||||
clean = dict(options)
|
||||
dropped: list[str] = []
|
||||
for key, kind in kinds.items():
|
||||
if key in clean and clean[key] is not None:
|
||||
number = coerce_numeric_option(clean[key], kind)
|
||||
if number is None:
|
||||
clean.pop(key)
|
||||
dropped.append(key)
|
||||
else:
|
||||
clean[key] = number
|
||||
return clean, dropped
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,13 +56,26 @@ _SENSITIVE_KEYS = {
|
||||
"switch_entity",
|
||||
"energy_price_entity",
|
||||
"energy_sensor",
|
||||
# The active-cycle snapshot's meter entity (kept in `failed_restore`).
|
||||
"energy_meter_source",
|
||||
}
|
||||
|
||||
|
||||
# A record that names a setting in a field instead of using it as the dict key -
|
||||
# a settings-changelog row is `{"key": "power_sensor", "old": ..., "new": ...}` -
|
||||
# carries the sensitive value under these generic names, which slipped past the
|
||||
# key-based redaction: entity ids, person.* and notify targets (audit PLATFORM-08).
|
||||
_VALUE_FIELDS = ("old", "new", "value")
|
||||
|
||||
|
||||
def _redact(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
named = obj.get("key")
|
||||
names_sensitive = isinstance(named, str) and named in _SENSITIVE_KEYS
|
||||
return {
|
||||
k: "**REDACTED**" if k in _SENSITIVE_KEYS else _redact(v)
|
||||
k: "**REDACTED**"
|
||||
if k in _SENSITIVE_KEYS or (names_sensitive and k in _VALUE_FIELDS)
|
||||
else _redact(v)
|
||||
for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
@@ -70,11 +83,23 @@ def _redact(obj: Any) -> Any:
|
||||
return obj
|
||||
|
||||
|
||||
async def _failed_restore(manager: WashDataManager) -> Any:
|
||||
"""The kept failed-restore record, redacted; None on any problem reading it."""
|
||||
try:
|
||||
return _redact(await manager.profile_store.async_get_failed_restore())
|
||||
except Exception: # noqa: BLE001 - the download must never fail on this
|
||||
return None
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
manager: WashDataManager = hass.data[DOMAIN][entry.entry_id]
|
||||
manager: WashDataManager | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
||||
if manager is None:
|
||||
# Setup failed or the entry is unloaded: the download must still work - it
|
||||
# is how such a failure gets reported (audit PLATFORM-14).
|
||||
return {"entry": _redact(entry.as_dict()), "manager_state": None}
|
||||
|
||||
# Full store export - same payload as the export_config service, but the
|
||||
# entry_data / entry_options pass through the redactor to strip personal keys.
|
||||
@@ -138,4 +163,8 @@ async def async_get_config_entry_diagnostics(
|
||||
# state_history: [{ts, from, to, program}, ...] - detector state changes
|
||||
# logs: [{ts, lvl}, ...] - log timestamps and levels (msg removed)
|
||||
"live_diagnostics": manager.diag_buffer.redacted_snapshot(),
|
||||
# The last active-cycle snapshot that failed to restore, with the error and
|
||||
# its age (register item 266 follow-up); None when none ever has. A download,
|
||||
# not a bus event, so the 32 KB event-data limit does not apply.
|
||||
"failed_restore": await _failed_restore(manager),
|
||||
}
|
||||
|
||||
@@ -220,6 +220,47 @@ def _ensure_gzip(path: Path) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _gzip_is_current(path: Path) -> bool:
|
||||
"""True when ``<path>.gz`` exists and decompresses to exactly ``path``'s bytes.
|
||||
|
||||
A content check, not an mtime check (see :func:`_ensure_gzip` for why mtimes
|
||||
cannot be trusted here). Decompressing is ~20x cheaper than recompressing.
|
||||
"""
|
||||
import gzip
|
||||
import zlib
|
||||
|
||||
gz = path.with_suffix(path.suffix + ".gz")
|
||||
try:
|
||||
return gzip.decompress(gz.read_bytes()) == path.read_bytes()
|
||||
except (OSError, EOFError, zlib.error): # missing, truncated or corrupt
|
||||
return False
|
||||
|
||||
|
||||
def _prepare_translations(directory: Path) -> bool:
|
||||
"""Pre-compress every panel translation file; True if the directory exists.
|
||||
|
||||
Audit UI-18: the panel fetches its English and user-language dictionaries
|
||||
(130-250 KB each) on every cold load, and aiohttp serves a ``.gz`` sibling only
|
||||
if one exists. Thirty-five files take ~0.6 s to compress at level 9, so unlike
|
||||
the two JS assets an unchanged ``.gz`` is kept (verified by content, ~30 ms for
|
||||
the whole directory) and only a missing or stale one is rebuilt. A ``.gz`` whose
|
||||
source was removed is deleted: aiohttp would otherwise keep serving it.
|
||||
"""
|
||||
if not directory.is_dir():
|
||||
return False
|
||||
try:
|
||||
sources = sorted(directory.glob("*.json"))
|
||||
for src in sources:
|
||||
if src.is_file() and not _gzip_is_current(src):
|
||||
_ensure_gzip(src)
|
||||
for gz in directory.glob("*.json.gz"):
|
||||
if not gz.with_suffix("").is_file():
|
||||
gz.unlink(missing_ok=True)
|
||||
except OSError as exc: # read-only install: serve uncompressed
|
||||
_LOGGER.debug("Could not pre-compress panel translations (%s)", exc)
|
||||
return True
|
||||
|
||||
|
||||
def _prepare_asset(source_name: str, www: Path | None = None) -> Path:
|
||||
"""Resolve the best variant of an asset and make sure its .gz is current.
|
||||
|
||||
@@ -289,8 +330,11 @@ def get_cache_buster(filename: str = CARD_NAME) -> str:
|
||||
src_mtime = os.stat(base / "www" / filename).st_mtime_ns
|
||||
try:
|
||||
panel_dir = base / "translations" / "panel"
|
||||
# Sources only: the .gz siblings are rewritten at startup, and folding
|
||||
# their mtime in would change the URL (and refetch the panel) on every
|
||||
# restart.
|
||||
trans_mtime = max(
|
||||
(os.stat(f).st_mtime_ns for f in panel_dir.iterdir() if f.is_file()),
|
||||
(os.stat(f).st_mtime_ns for f in panel_dir.glob("*.json") if f.is_file()),
|
||||
default=0,
|
||||
)
|
||||
except OSError:
|
||||
@@ -622,9 +666,9 @@ async def _do_register_panel(hass: HomeAssistant, src: Path) -> bool:
|
||||
served = await hass.async_add_executor_job(_prepare_asset, PANEL_JS_NAME)
|
||||
await _async_register_path(hass, PANEL_JS_URL, str(served))
|
||||
|
||||
# Per-language translation files.
|
||||
# Per-language translation files, pre-compressed like the JS (audit UI-18).
|
||||
trans_src = Path(__file__).parent / "translations" / PANEL_TRANSLATIONS_DIRNAME
|
||||
if await hass.async_add_executor_job(trans_src.is_dir):
|
||||
if await hass.async_add_executor_job(_prepare_translations, trans_src):
|
||||
await _async_register_path(hass, PANEL_TRANSLATIONS_URL, str(trans_src))
|
||||
|
||||
# Brand icon (panel header). Track registration so ws_get_constants
|
||||
|
||||
@@ -29,7 +29,7 @@ The single intent registered here is :data:`INTENT_STATUS`
|
||||
Wiring trigger sentences
|
||||
------------------------
|
||||
Registering the :class:`intent.IntentHandler` (via :func:`async_setup_intents`)
|
||||
makes the intent handleable — it can be fired immediately from automations, the
|
||||
makes the intent handleable: it can be fired immediately from automations, the
|
||||
``intent_script`` integration, developer tools, or the Assist pipeline **once a
|
||||
sentence maps text to it**. Home Assistant has no public runtime API for a
|
||||
*custom* integration to inject sentences into the built-in conversation agent,
|
||||
@@ -60,7 +60,12 @@ declare the same intent via the ``intent_script`` integration.
|
||||
|
||||
This module has no import-time side effects: it only defines constants, helpers
|
||||
and the handler class. Registration happens when :func:`async_setup_intents` is
|
||||
called from ``async_setup_entry`` (guarded to run once per HA instance).
|
||||
called from ``async_setup_entry`` (guarded to run once per HA instance). It
|
||||
needs neither the ``conversation`` nor the ``intent`` integration loaded:
|
||||
``intent.async_register`` stores the handler in ``hass.data`` and every consumer
|
||||
(the Assist agent, ``intent_script``, the LLM API) looks it up per request, so
|
||||
``conversation`` is only an after-dependency in the manifest (item 487). A hard
|
||||
dependency made a broken or absent Assist stack block the whole integration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -85,7 +90,6 @@ from .const import (
|
||||
STATE_ENDING,
|
||||
STATE_FINISHED,
|
||||
STATE_PAUSED,
|
||||
STATE_RINSE,
|
||||
STATE_RUNNING,
|
||||
STATE_STARTING,
|
||||
STATE_USER_PAUSED,
|
||||
@@ -104,7 +108,6 @@ _ACTIVE_STATES = frozenset(
|
||||
STATE_ENDING,
|
||||
STATE_PAUSED,
|
||||
STATE_USER_PAUSED,
|
||||
STATE_RINSE,
|
||||
STATE_ANTI_WRINKLE,
|
||||
}
|
||||
)
|
||||
@@ -136,7 +139,7 @@ def _minutes_from_seconds(seconds: Any) -> int | None:
|
||||
"""Return whole minutes (>=1) from a seconds value, or None when unusable."""
|
||||
try:
|
||||
value = float(seconds)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
@@ -149,7 +152,7 @@ def _minutes_since(end: Any, now: datetime) -> int | None:
|
||||
return None
|
||||
try:
|
||||
delta = (now - end).total_seconds()
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if delta < 0:
|
||||
return 0
|
||||
@@ -306,7 +309,7 @@ async def _localized_templates(
|
||||
continue
|
||||
try:
|
||||
loaded = await hass.async_add_executor_job(_load_intent_file, lg)
|
||||
except (AttributeError, TypeError):
|
||||
except (AttributeError, TypeError, OverflowError):
|
||||
# Only a hass without a usable executor (the minimal test stand-in)
|
||||
# reads on the loop. A broad except here would also catch a real
|
||||
# executor failure - e.g. "cannot schedule new futures after
|
||||
|
||||
@@ -20,8 +20,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
@@ -39,10 +40,12 @@ from .const import (
|
||||
DEFAULT_LEARNING_CONFIDENCE,
|
||||
MIN_SUGGESTION_COOLDOWN_CYCLES,
|
||||
MIN_SUGGESTION_REL_DELTA,
|
||||
ML_QUALITY_SUSPICIOUS_THRESHOLD,
|
||||
TerminationReason,
|
||||
)
|
||||
from .suggestion_engine import SuggestionEngine
|
||||
from .log_utils import DeviceLoggerAdapter
|
||||
from .profile_store import _AUTO_LABEL_SOURCES
|
||||
from .detector_config import effective_option_values
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .profile_store import ProfileStore
|
||||
@@ -70,6 +73,14 @@ def _suggestion_min_abs_delta(key: str) -> float:
|
||||
return 0.05
|
||||
|
||||
|
||||
def _ended_on_its_own(cycle: dict[str, Any]) -> bool:
|
||||
"""Completed, and not cut short by the user (#458; cf. ``select_clean_cycles``)."""
|
||||
return (
|
||||
cycle.get("status") == "completed"
|
||||
and cycle.get("termination_reason") != TerminationReason.USER
|
||||
)
|
||||
|
||||
|
||||
class StatisticalModel:
|
||||
"""Helper to track running stats for a metric."""
|
||||
|
||||
@@ -125,23 +136,45 @@ class LearningManager:
|
||||
profile_store: "ProfileStore",
|
||||
device_type: str | None = None,
|
||||
device_name: str = "",
|
||||
spawn: Callable[[Coroutine[Any, Any, Any]], Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the learning manager."""
|
||||
"""Initialize the learning manager.
|
||||
|
||||
``spawn`` starts every background task this class creates. The manager
|
||||
passes its ``_spawn_tracked`` (audit MANAGER-13): these tasks save the
|
||||
ProfileStore, and an untracked one survives an entry reload and can write
|
||||
the OLD store over the new one under the same Store key. Without it (unit
|
||||
tests) a plain ``hass.async_create_task`` is used.
|
||||
"""
|
||||
self._logger = DeviceLoggerAdapter(_LOGGER, device_name)
|
||||
self.hass = hass
|
||||
self._spawn_fn = spawn
|
||||
self.entry_id = entry_id
|
||||
self.profile_store = profile_store
|
||||
self.device_type = device_type
|
||||
self.suggestion_engine = SuggestionEngine(
|
||||
hass, entry_id, profile_store, device_type
|
||||
hass, entry_id, profile_store, device_type, spawn=spawn
|
||||
)
|
||||
|
||||
# Operational Stats
|
||||
self._sample_interval_model = StatisticalModel(max_samples=200)
|
||||
# Update intervals seen during the CURRENT cycle, held back until it ends
|
||||
# (#458). A cycle that never ends on its own - force-stopped after hours of
|
||||
# a plug reporting standby at its idle cadence - would otherwise fill the
|
||||
# whole 200-sample window with that idle cadence, and the watchdog and
|
||||
# no-update timeouts would be re-suggested from it every five minutes.
|
||||
# Bounded like the model itself: only its last 200 survive a commit anyway.
|
||||
self._pending_intervals: deque[tuple[float, datetime]] = deque(maxlen=200)
|
||||
self._last_suggestion_update: datetime | None = None
|
||||
self._last_batch_simulation_count: int = 0 # track when to re-run batch
|
||||
self._last_suggestions_labeled_count: int = 0 # gate model/detection passes
|
||||
|
||||
def _spawn(self, coro: Coroutine[Any, Any, Any]) -> Any:
|
||||
"""Start a background task through the manager's tracked spawner."""
|
||||
if self._spawn_fn is not None:
|
||||
return self._spawn_fn(coro)
|
||||
return self.hass.async_create_task(coro)
|
||||
|
||||
def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None:
|
||||
"""Apply suggestions that pass quality gates."""
|
||||
if not suggestions:
|
||||
@@ -154,12 +187,18 @@ class LearningManager:
|
||||
current_options = {**entry.data, **entry.options}
|
||||
|
||||
# Cooldown: how many cycles have elapsed since the user last applied suggestions?
|
||||
past_cycles = self.profile_store.get_past_cycles()
|
||||
# Counted on the odometer, not len(past_cycles): at the retention cap the
|
||||
# list length stops growing, so one Apply-all silenced every non-corrective
|
||||
# suggestion forever (audit SUGGEST-05).
|
||||
cycles_now = self.profile_store.get_lifetime_cycle_count()
|
||||
last_apply_count = self.profile_store.get_suggestion_apply_cycle_count()
|
||||
cooldown_active = (
|
||||
last_apply_count > 0
|
||||
and (len(past_cycles) - last_apply_count) < MIN_SUGGESTION_COOLDOWN_CYCLES
|
||||
and (cycles_now - last_apply_count) < MIN_SUGGESTION_COOLDOWN_CYCLES
|
||||
)
|
||||
# Compare against what each key RUNS with when it is unset (audit
|
||||
# SUGGEST-10), not against None, which skipped every gate below.
|
||||
effective = effective_option_values(current_options, self.device_type)
|
||||
|
||||
# Locked keys (#343): the user has told the auto-tuner to stop proposing
|
||||
# these (e.g. thresholds that break an anti-crease-tuned device). Drop them
|
||||
@@ -180,6 +219,8 @@ class LearningManager:
|
||||
continue
|
||||
if isinstance(data, dict) and "value" in data:
|
||||
current_val = current_options.get(key)
|
||||
if current_val is None:
|
||||
current_val = effective.get(key)
|
||||
suggested_val = data["value"]
|
||||
if current_val is not None and suggested_val is not None:
|
||||
try:
|
||||
@@ -204,16 +245,16 @@ class LearningManager:
|
||||
# After the user applies suggestions, wait for a few more
|
||||
# cycles before surfacing new ones (avoids immediately
|
||||
# re-suggesting a slightly-different value on the next cycle).
|
||||
if cooldown_active:
|
||||
if cooldown_active and not data.get("corrective"):
|
||||
continue
|
||||
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
filtered_suggestions[key] = data
|
||||
|
||||
if not filtered_suggestions:
|
||||
if any_deleted:
|
||||
self.hass.async_create_task(self.profile_store.async_save())
|
||||
self._spawn(self.profile_store.async_save())
|
||||
return
|
||||
|
||||
self.suggestion_engine.apply_suggestions(filtered_suggestions)
|
||||
@@ -226,7 +267,8 @@ class LearningManager:
|
||||
delta = (now - last_reading_time).total_seconds()
|
||||
# Ignore ultra-small jitter (<0.1s) and massive gaps (>1800s - likely downtime)
|
||||
if 0.1 < delta < 1800:
|
||||
self._sample_interval_model.add_sample(delta, now)
|
||||
# Held until the cycle's outcome is known (#458): see close_cycle_cadence.
|
||||
self._pending_intervals.append((delta, now))
|
||||
|
||||
# Periodically update suggestions based on operational stats
|
||||
if (
|
||||
@@ -235,6 +277,39 @@ class LearningManager:
|
||||
):
|
||||
self._update_operational_suggestions(now)
|
||||
|
||||
def discard_cycle_cadence(self) -> None:
|
||||
"""Drop held intervals without committing them (a new start from idle)."""
|
||||
self._pending_intervals.clear()
|
||||
|
||||
def close_cycle_cadence(self, cycle_data: dict[str, Any] | None) -> bool:
|
||||
"""Commit or drop the update intervals held for the cycle that just ended.
|
||||
|
||||
Committed only when the cycle ended on its own (``status == "completed"``
|
||||
and not user-stopped): the same line ``select_clean_cycles`` draws, and for
|
||||
the same reason (#458). A force-stopped or user-stopped cycle's intervals
|
||||
describe however long the plug sat reporting standby before something gave
|
||||
up, not how the appliance reports while it works. Called for EVERY cycle
|
||||
end, persisted or not, so one cycle's intervals can never leak into the
|
||||
next. Returns whether anything was committed.
|
||||
"""
|
||||
pending = list(self._pending_intervals)
|
||||
self._pending_intervals.clear()
|
||||
if not pending or not isinstance(cycle_data, dict):
|
||||
return False
|
||||
if cycle_data.get("status") != "completed":
|
||||
return False
|
||||
if cycle_data.get("termination_reason") in (
|
||||
TerminationReason.USER,
|
||||
TerminationReason.FORCE_STOPPED,
|
||||
):
|
||||
return False
|
||||
for delta, ts in pending:
|
||||
self._sample_interval_model.add_sample(delta, ts)
|
||||
# The periodic refresh only runs while a cycle is active, so surface what
|
||||
# this cycle taught now rather than at the start of the next one.
|
||||
self._update_operational_suggestions(pending[-1][1])
|
||||
return True
|
||||
|
||||
def process_cycle_end(
|
||||
self,
|
||||
cycle_data: dict[str, Any],
|
||||
@@ -242,8 +317,14 @@ class LearningManager:
|
||||
confidence: float = 0.0,
|
||||
predicted_duration: float | None = None,
|
||||
match_result: Any | None = None,
|
||||
label_allowed: bool = True,
|
||||
) -> None:
|
||||
"""Analyze completed cycle for learning.
|
||||
|
||||
``label_allowed`` is the manager's cycle-end label gate verdict (margin,
|
||||
ambiguity and margin owner). When it refused, this pass must not label the
|
||||
cycle anyway on confidence alone (audit MANAGER-02 / MATCH-DECIDE-01); it
|
||||
asks the user instead.
|
||||
|
||||
Args:
|
||||
cycle_data: Completed cycle data
|
||||
@@ -252,33 +333,35 @@ class LearningManager:
|
||||
predicted_duration: Expected duration in seconds
|
||||
match_result: MatchResult from profile_store.async_match_profile() (optional)
|
||||
"""
|
||||
# 1. Trigger single-cycle simulation — only for cleanly-completed, labeled,
|
||||
# non-noise cycles. Skipping force_stopped/unlabeled/noise avoids deriving
|
||||
# start/stop thresholds from mis-detected or truncated cycles.
|
||||
_profile = detected_profile or cycle_data.get("profile_name")
|
||||
_is_clean = (
|
||||
cycle_data.get("power_data")
|
||||
and _profile
|
||||
and _profile != "noise"
|
||||
and cycle_data.get("status") == "completed"
|
||||
# (No per-cycle stop/start simulation: re-deriving both from the last cycle
|
||||
# alone made them a random walk - audit SUGGEST-06. The batch pass below
|
||||
# derives them across cycles.)
|
||||
|
||||
# 1b. Standby above the stop threshold (#458). Every cycle end, whatever its
|
||||
# status: a force-stopped cycle is the evidence this pass exists for.
|
||||
self._dispatch_scan_and_apply(
|
||||
self.suggestion_engine.for_job().generate_standby_floor_suggestions,
|
||||
"Standby floor",
|
||||
)
|
||||
if _is_clean:
|
||||
self.hass.async_create_task(self._async_run_simulation(cycle_data))
|
||||
|
||||
# 2. Check if we should request feedback
|
||||
self._maybe_request_feedback(
|
||||
cycle_data, detected_profile, confidence, predicted_duration, match_result
|
||||
cycle_data, detected_profile, confidence, predicted_duration, match_result,
|
||||
label_allowed=label_allowed,
|
||||
)
|
||||
|
||||
# 3+3b. Heavy per-profile suggestion passes — only run when the labeled
|
||||
# cycle count has grown since the last update (skips passes for unlabeled /
|
||||
# noise / duplicate ends with no new data).
|
||||
# New EVIDENCE, not new rows (#458): a force-stopped or user-stopped cycle
|
||||
# is dropped by `select_clean_cycles` inside every pass this gates, so
|
||||
# counting it re-ran them on unchanged data.
|
||||
labeled_count = sum(
|
||||
1 for c in self.profile_store.get_past_cycles()
|
||||
if isinstance(c, dict)
|
||||
and c.get("profile_name")
|
||||
and c.get("profile_name") != "noise"
|
||||
and c.get("status") in ("completed", "force_stopped")
|
||||
and _ended_on_its_own(c)
|
||||
)
|
||||
if labeled_count > self._last_suggestions_labeled_count:
|
||||
self._last_suggestions_labeled_count = labeled_count
|
||||
@@ -301,7 +384,10 @@ class LearningManager:
|
||||
and c.get("power_data")
|
||||
and c.get("status") in ("completed", "force_stopped")
|
||||
]
|
||||
current_count = len(labeled_cycles)
|
||||
# The list still carries force-stopped cycles - the min_off_gap merge
|
||||
# ceiling needs the user's real turnaround - but only cycles that ended
|
||||
# on their own are new evidence for the re-run cadence (#458).
|
||||
current_count = sum(1 for c in labeled_cycles if _ended_on_its_own(c))
|
||||
|
||||
if current_count < _BATCH_MIN:
|
||||
return
|
||||
@@ -309,7 +395,7 @@ class LearningManager:
|
||||
return
|
||||
|
||||
self._last_batch_simulation_count = current_count
|
||||
self.hass.async_create_task(self._async_run_batch_simulation(labeled_cycles))
|
||||
self._spawn(self._async_run_batch_simulation(labeled_cycles))
|
||||
|
||||
async def _async_run_batch_simulation(self, cycles: list[dict[str, Any]]) -> None:
|
||||
"""Run multi-cycle batch simulation asynchronously."""
|
||||
@@ -328,21 +414,6 @@ class LearningManager:
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
self._logger.error("Batch simulation failed: %s", e)
|
||||
|
||||
async def _async_run_simulation(self, cycle_data: dict[str, Any]) -> None:
|
||||
"""Run simulation asynchronously."""
|
||||
try:
|
||||
# Simulation runner derives optimal thresholds
|
||||
# Offload to executor since simulation can be heavy (CPU bound)
|
||||
engine = self.suggestion_engine.for_job()
|
||||
new_suggestions = await self.hass.async_add_executor_job(
|
||||
engine.run_simulation, cycle_data
|
||||
)
|
||||
if new_suggestions:
|
||||
self._apply_suggestions_and_notify(new_suggestions)
|
||||
self._logger.debug("Post-cycle simulation completed with suggestions: %s", new_suggestions.keys())
|
||||
except Exception as e:
|
||||
self._logger.error("Background simulation failed: %s", e)
|
||||
|
||||
def _update_operational_suggestions(self, now: datetime) -> None:
|
||||
"""Generate suggestions for operational parameters (intervals, timeouts).
|
||||
|
||||
@@ -407,7 +478,7 @@ class LearningManager:
|
||||
if suggestions:
|
||||
self._apply_suggestions_and_notify(suggestions)
|
||||
return
|
||||
self.hass.async_create_task(self._async_scan_and_apply(generate, label))
|
||||
self._spawn(self._async_scan_and_apply(generate, label))
|
||||
|
||||
async def _async_scan_and_apply(
|
||||
self, generate: Callable[[], dict[str, Any]], label: str
|
||||
@@ -426,7 +497,7 @@ class LearningManager:
|
||||
Offloaded to an executor because it scans power traces across up to 200
|
||||
cycles for the clean-cycle health checks.
|
||||
"""
|
||||
self.hass.async_create_task(self._async_run_detection_suggestions())
|
||||
self._spawn(self._async_run_detection_suggestions())
|
||||
|
||||
async def _async_run_detection_suggestions(self) -> None:
|
||||
"""Run the detection-suggestion pass off the event loop."""
|
||||
@@ -487,6 +558,7 @@ class LearningManager:
|
||||
confidence: float,
|
||||
predicted_duration: float | None,
|
||||
match_result: Any | None = None,
|
||||
label_allowed: bool = True,
|
||||
) -> None:
|
||||
"""Check if feedback should be requested for this completed cycle."""
|
||||
if (
|
||||
@@ -503,6 +575,15 @@ class LearningManager:
|
||||
self._logger.warning("Cycle data missing ID, cannot request feedback")
|
||||
return
|
||||
|
||||
# The user already chose this cycle's programme. Asking them to confirm the
|
||||
# matcher's guess on top of it (warm-up, or a gate-refused match) is the
|
||||
# request the v16 cleanup drops (_dismiss_unneeded_feedback); never raise it.
|
||||
if cycle_data.get("label_source") == "manual":
|
||||
self._logger.debug(
|
||||
"Cycle %s was labelled by hand; no confirmation needed", cycle_id
|
||||
)
|
||||
return
|
||||
|
||||
# Get Configured Thresholds
|
||||
entry = self.hass.config_entries.async_get_entry(self.entry_id)
|
||||
if not entry:
|
||||
@@ -527,7 +608,9 @@ class LearningManager:
|
||||
# remains the real match score that gets displayed and persisted, so warmup
|
||||
# clamping never fabricates the value shown to the user.
|
||||
route_conf = confidence
|
||||
if confidence >= auto_label_conf:
|
||||
# Warm-up applies wherever the cycle would otherwise go unasked: the
|
||||
# auto-label band, and (since 0.5.8) a cycle the cycle-end gate labelled.
|
||||
if confidence >= auto_label_conf or (label_allowed and confidence >= learning_conf):
|
||||
_wm_count = self.profile_store.get_profile_labeled_count(detected_profile)
|
||||
# Imported reference profiles are trusted downloaded templates: the user
|
||||
# expects to match immediately, so they skip the local warm-up gate.
|
||||
@@ -555,46 +638,48 @@ class LearningManager:
|
||||
if learning_conf + 0.001 < auto_label_conf:
|
||||
route_conf = max(route_conf, learning_conf + 0.001)
|
||||
|
||||
# Auto-label if very high confidence — but skip auto-labeling when the ML
|
||||
# quality model flagged this cycle as suspicious (P(problem) >= threshold),
|
||||
# even if the matcher was confident. Downgrade to a feedback request so
|
||||
# the user can verify the match; this catches confident but wrong labels.
|
||||
ml_quality = cycle_data.get("ml_quality_score")
|
||||
# Use float() so numpy scalars (float32/float64) returned by resolve_scorer
|
||||
# are accepted — isinstance(numpy_float, float) is False in NumPy ≥ 2.0.
|
||||
# Wrap in try/except so non-numeric sentinel values are silently ignored.
|
||||
try:
|
||||
ml_suspicious = (
|
||||
ml_quality is not None
|
||||
and float(ml_quality) >= ML_QUALITY_SUSPICIOUS_THRESHOLD
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
ml_suspicious = False
|
||||
# Also downgrade when the cycle's power trace is mostly outside the
|
||||
# profile envelope band (low conformance = the shape matched but the
|
||||
# actual power levels are inconsistent with the profile).
|
||||
# Auto-label if very high confidence - but not when the cycle's power trace
|
||||
# is mostly outside the profile envelope band (low conformance = the shape
|
||||
# matched but the actual power levels are inconsistent with the profile).
|
||||
# (The ML quality gate that also downgraded here, C3, was removed in 0.5.8:
|
||||
# it fired on 0 of the auto-label-eligible real cycles, audit ML-06. A
|
||||
# legacy `ml_quality_score` on an older cycle is ignored.)
|
||||
_conformance = cycle_data.get("envelope_conformance")
|
||||
try:
|
||||
envelope_suspicious = (
|
||||
_conformance is not None
|
||||
and float(_conformance) < 0.40
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
envelope_suspicious = False
|
||||
# The cycle-end pass already labelled this cycle with a DIFFERENT programme
|
||||
# (the post-cycle match on the complete trace): re-labelling here would
|
||||
# overwrite that label with this pass's guess. (A hand-picked label returned
|
||||
# at the top.)
|
||||
_existing_label = cycle_data.get("profile_name")
|
||||
if route_conf >= auto_label_conf and _existing_label and _existing_label != detected_profile:
|
||||
self._logger.debug(
|
||||
"Cycle %s already labelled '%s' at cycle end; not auto-labelling it",
|
||||
cycle_id, _existing_label,
|
||||
)
|
||||
return
|
||||
if route_conf >= auto_label_conf:
|
||||
if ml_suspicious or envelope_suspicious:
|
||||
if ml_suspicious:
|
||||
self._logger.info(
|
||||
"ML quality model flagged cycle %s as suspicious (score=%.3f >= %.2f); "
|
||||
"downgrading auto-label to feedback request.",
|
||||
cycle_id, ml_quality, ML_QUALITY_SUSPICIOUS_THRESHOLD,
|
||||
)
|
||||
if envelope_suspicious:
|
||||
self._logger.info(
|
||||
"Envelope conformance for cycle %s is low (%.2f < 0.40); "
|
||||
"downgrading auto-label to feedback request.",
|
||||
cycle_id, _conformance,
|
||||
)
|
||||
if not label_allowed:
|
||||
self._logger.info(
|
||||
"Cycle %s: the label gate refused '%s' (too close to the runner-up "
|
||||
"or flagged ambiguous); requesting confirmation instead of "
|
||||
"auto-labelling it.",
|
||||
cycle_id, detected_profile,
|
||||
)
|
||||
# Fall through to the feedback-request path below.
|
||||
elif envelope_suspicious:
|
||||
# Not a review request on its own (register item 433): a cycle
|
||||
# the cycle-end gate already labelled returns below unasked.
|
||||
self._logger.info(
|
||||
"Envelope conformance for cycle %s is low (%.2f < 0.40); "
|
||||
"not auto-labelling it here.",
|
||||
cycle_id, _conformance,
|
||||
)
|
||||
# Fall through to feedback-request path below.
|
||||
else:
|
||||
labeled = self.auto_label_high_confidence(
|
||||
@@ -605,12 +690,34 @@ class LearningManager:
|
||||
)
|
||||
if labeled:
|
||||
# Rebuild envelope first, then persist (issue #131)
|
||||
self.hass.async_create_task(
|
||||
self._spawn(
|
||||
self._async_rebuild_and_save_profile(detected_profile)
|
||||
)
|
||||
self._logger.debug("Auto-labeled high-confidence cycle %s", cycle_id)
|
||||
return
|
||||
|
||||
# A cycle the cycle-end gate already labelled with this programme (a clear
|
||||
# margin over the runner-up, not ambiguous, above the learning floor) needs no
|
||||
# confirmation. Measured leave-one-out over 604 cycle ends, those labels are
|
||||
# 91.5% right; asking about every 0.6-0.9 match instead put 81% of all
|
||||
# cycles in the review queue, most of them already labelled correctly. What
|
||||
# predicts a wrong label is a small margin, not a modest confidence: at
|
||||
# 0.7-0.9 a clear margin is 93-96% right and a refused gate 33-54%
|
||||
# (register item 433). Warm-up still asks. Low envelope conformance does
|
||||
# NOT: labelled cycles under 0.40 are still 85.6% right, so asking about them
|
||||
# costs 7 questions per wrong label (and on a device with loose envelopes it
|
||||
# re-queued nearly every cycle).
|
||||
if (
|
||||
label_allowed
|
||||
and not warmup_request
|
||||
and cycle_data.get("profile_name") == detected_profile
|
||||
):
|
||||
self._logger.debug(
|
||||
"Cycle %s labelled '%s' at cycle end with a clear margin; no confirmation needed",
|
||||
cycle_id, detected_profile,
|
||||
)
|
||||
return
|
||||
|
||||
# Skip low-confidence matches below learning threshold — but a warmup cycle
|
||||
# always requests confirmation, even if the thresholds are misconfigured.
|
||||
if route_conf < learning_conf and not warmup_request:
|
||||
@@ -637,7 +744,7 @@ class LearningManager:
|
||||
# Persist pending feedback request so it survives restart.
|
||||
# The pending review is surfaced in the panel's Cycles review queue;
|
||||
# WashData intentionally does not raise a persistent notification here.
|
||||
self.hass.async_create_task(self.profile_store.async_save())
|
||||
self._spawn(self.profile_store.async_save())
|
||||
|
||||
def request_cycle_verification(
|
||||
self,
|
||||
@@ -669,7 +776,7 @@ class LearningManager:
|
||||
"metrics": cand.get("metrics", {}),
|
||||
"profile_duration": float(cand.get("profile_duration", 0.0)),
|
||||
})
|
||||
except (TypeError, ValueError, KeyError, AttributeError):
|
||||
except (TypeError, ValueError, KeyError, AttributeError, OverflowError):
|
||||
continue
|
||||
|
||||
feedback_req: dict[str, Any] = {
|
||||
@@ -740,7 +847,7 @@ class LearningManager:
|
||||
if corrected_duration is not None:
|
||||
try:
|
||||
duration_sec = float(corrected_duration)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
self._logger.warning(
|
||||
"Invalid corrected_duration %r for cycle %s, ignoring",
|
||||
corrected_duration,
|
||||
@@ -767,7 +874,11 @@ class LearningManager:
|
||||
elif user_confirmed:
|
||||
profile_name = pending.get("detected_profile")
|
||||
if isinstance(profile_name, str) and profile_name:
|
||||
self._auto_label_cycle(cycle_id, profile_name, duration_sec)
|
||||
left = self._auto_label_cycle(
|
||||
cycle_id, profile_name, duration_sec, source="manual"
|
||||
)
|
||||
if left:
|
||||
profiles_to_rebuild.add(left)
|
||||
if duration_sec is not None:
|
||||
cycles = self.profile_store.get_past_cycles()
|
||||
confirmed_cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
|
||||
@@ -781,10 +892,12 @@ class LearningManager:
|
||||
detected_profile_name = pending.get("detected_profile")
|
||||
|
||||
if isinstance(target_profile, str) and target_profile:
|
||||
self._apply_correction_learning(
|
||||
left = self._apply_correction_learning(
|
||||
cycle_id, target_profile, duration_sec
|
||||
)
|
||||
profiles_to_rebuild.add(target_profile)
|
||||
if left:
|
||||
profiles_to_rebuild.add(left)
|
||||
if (
|
||||
isinstance(detected_profile_name, str)
|
||||
and detected_profile_name
|
||||
@@ -946,34 +1059,74 @@ class LearningManager:
|
||||
)
|
||||
return True
|
||||
|
||||
def _auto_label_cycle(self, cycle_id: str, profile_name: str, manual_duration: float | None = None) -> None:
|
||||
def _auto_label_cycle(
|
||||
self,
|
||||
cycle_id: str,
|
||||
profile_name: str,
|
||||
manual_duration: float | None = None,
|
||||
*,
|
||||
source: str = "auto_match",
|
||||
) -> str | None:
|
||||
"""Write a label and its provenance; returns the profile the cycle left.
|
||||
|
||||
``source`` is ``"manual"`` when the user answered a review request (confirm
|
||||
or correct) and ``"auto_match"`` when the matcher's guess is recorded.
|
||||
These paths used to set only ``profile_name``, so a user's correction still
|
||||
read as a matcher guess and the panel's Auto-label reverted it - storing the
|
||||
user's answer as ``original_auto_label`` (audit MANAGER-01).
|
||||
|
||||
The returned name (None when the label did not move) is the profile whose
|
||||
envelope lost a member: it is the cycle's real old label, which need not
|
||||
be the profile the review request detected (register item 493).
|
||||
"""
|
||||
cycles = self.profile_store.get_past_cycles()
|
||||
cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
|
||||
left: str | None = None
|
||||
if cycle:
|
||||
old = cycle.get("profile_name")
|
||||
if (
|
||||
source == "manual"
|
||||
and old
|
||||
and old != profile_name
|
||||
and not cycle.get("original_auto_label")
|
||||
and cycle.get("label_source") in _AUTO_LABEL_SOURCES
|
||||
):
|
||||
cycle["original_auto_label"] = old
|
||||
cycle["profile_name"] = profile_name
|
||||
cycle["auto_labeled"] = True
|
||||
cycle["label_source"] = source
|
||||
if manual_duration:
|
||||
cycle["manual_duration"] = manual_duration
|
||||
if old and old != profile_name:
|
||||
# The profile the cycle left must not keep it as its sample.
|
||||
self.profile_store.heal_profile_sample(old)
|
||||
if isinstance(old, str):
|
||||
left = old
|
||||
return left
|
||||
|
||||
def _apply_correction_learning(
|
||||
self,
|
||||
cycle_id: str,
|
||||
corrected_profile: str,
|
||||
corrected_duration: Optional[float] = None,
|
||||
) -> None:
|
||||
) -> str | None:
|
||||
"""Apply user correction to a cycle (fix for issue #131).
|
||||
|
||||
Note: We do not update avg_duration here with EMA. Instead, the envelope
|
||||
rebuild in async_submit_cycle_feedback() will recalculate all statistics
|
||||
(min/max/avg) from labeled cycles, ensuring accuracy.
|
||||
(min/max/avg) from labeled cycles, ensuring accuracy. Returns the profile
|
||||
the cycle left (see :meth:`_auto_label_cycle`).
|
||||
"""
|
||||
self._auto_label_cycle(cycle_id, corrected_profile, corrected_duration)
|
||||
left = self._auto_label_cycle(
|
||||
cycle_id, corrected_profile, corrected_duration, source="manual"
|
||||
)
|
||||
if corrected_duration is not None:
|
||||
cycles = self.profile_store.get_past_cycles()
|
||||
cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
|
||||
if cycle:
|
||||
cycle["duration"] = corrected_duration
|
||||
# Profile stats will be recalculated when envelope is rebuilt
|
||||
return left
|
||||
|
||||
async def _async_rebuild_profile_envelope(self, profile_name: str) -> None:
|
||||
"""Async helper to rebuild a profile's envelope (issue #131 fix).
|
||||
|
||||
+1921
-2004
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,9 @@
|
||||
"domain": "ha_washdata",
|
||||
"name": "WashData",
|
||||
"after_dependencies": [
|
||||
"lovelace",
|
||||
"http",
|
||||
"conversation",
|
||||
"frontend",
|
||||
"websocket_api",
|
||||
"lovelace",
|
||||
"recorder"
|
||||
],
|
||||
"codeowners": [
|
||||
@@ -13,13 +12,14 @@
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"conversation"
|
||||
"http",
|
||||
"websocket_api"
|
||||
],
|
||||
"documentation": "https://github.com/3dg1luk43/ha_washdata",
|
||||
"iot_class": "local_polling",
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/3dg1luk43/ha_washdata/issues",
|
||||
"requirements": [
|
||||
"numpy"
|
||||
],
|
||||
"version": "0.5.7"
|
||||
"version": "0.5.8"
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ This package holds compact, NumPy-only models trained offline in the
|
||||
``promoted_manifest.json`` for provenance). The integration runtime stays
|
||||
NumPy-only; no sklearn/torch/scipy are imported.
|
||||
|
||||
The single runtime entry point is :func:`resolve_scorer`, which returns a scoring
|
||||
callable for a capability, preferring an on-device trained spec over the shipped
|
||||
embedded baseline. All live ML consumers go through it (the panel's ``ml_health``
|
||||
shadow comparison in ``ws_api`` and :class:`MLSuggestionEngine`), and any new
|
||||
runtime consumer should too — feature extraction lives in ``feature_extraction``
|
||||
The runtime entry points are :func:`resolve_scorer`, which returns the shipped
|
||||
embedded baseline's scoring callable for a classifier capability, and
|
||||
:func:`resolve_regressor`, which returns an on-device trained regressor. All live
|
||||
ML consumers go through them (the panel's ``ml_health`` shadow comparison in
|
||||
``ws_api``), and any new runtime consumer should too - feature extraction lives in ``feature_extraction``
|
||||
and gating in :func:`ml_models_enabled`, so there is no separate engine object.
|
||||
|
||||
Each model consumes a feature mapping whose keys are the model's
|
||||
@@ -48,7 +48,6 @@ CONF_ENABLE_ML_MODELS = "enable_ml_models"
|
||||
# Logical capability -> generated model module name (without the _model suffix).
|
||||
_MODEL_MODULES = {
|
||||
"quality": "hybrid_curve_quality_model",
|
||||
"live_match": "live_match_commit_model",
|
||||
"end": "cycle_end_detector_model",
|
||||
}
|
||||
|
||||
@@ -56,8 +55,8 @@ _MODEL_MODULES = {
|
||||
_SIBLING_MODULES = ("trainer", "feature_extraction")
|
||||
|
||||
# Imported baseline model modules, keyed by module name. Importing a module is a
|
||||
# blocking call Home Assistant forbids inside the event loop, and every
|
||||
# resolve_scorer() consumer (live matching, end detection, quality gating) runs
|
||||
# blocking call Home Assistant forbids inside the event loop, and
|
||||
# resolve_scorer() consumers (end detection, the panel's cycle health) run
|
||||
# there - so the modules are imported once from an import executor at setup
|
||||
# (:func:`preload_models`) and every later resolution is a dict lookup. A failed
|
||||
# import is cached as ``None`` so a broken install warns once instead of retrying
|
||||
@@ -127,123 +126,49 @@ def ml_models_enabled(options: Mapping[str, object] | None) -> bool:
|
||||
return bool(options.get(CONF_ENABLE_ML_MODELS, False))
|
||||
|
||||
|
||||
def resolve_scorer(capability: str, store: object | None):
|
||||
"""Return ``(score_fn, source)`` for a capability, preferring an on-device
|
||||
trained spec over the shipped embedded baseline.
|
||||
def resolve_scorer(capability: str):
|
||||
"""Return ``(score_fn, source)`` for a classifier capability's shipped baseline.
|
||||
|
||||
``score_fn`` maps a feature mapping -> float in [0,1]; ``source`` is
|
||||
``"on_device"`` or ``"baseline"``. Returns ``(None, None)`` when neither is
|
||||
available. This is the single bridge that lets trained models (Stage 4)
|
||||
actually reach inference (ML Lab shadow comparison + MLSuggestionEngine)
|
||||
while transparently falling back to the baseline.
|
||||
``"baseline"``. Returns ``(None, None)`` when the capability has no embedded
|
||||
model or it failed to import. The lookup hits the module cache warmed by
|
||||
:func:`preload_models`, so no import happens in the event loop. (The
|
||||
on-device classifier specs this used to prefer can no longer exist: their
|
||||
training was removed in 0.5.8 and storage v17 drops their records.)
|
||||
"""
|
||||
def _baseline():
|
||||
"""Resolve the shipped embedded baseline scorer for this capability.
|
||||
module_name = _MODEL_MODULES.get(capability)
|
||||
if module_name is None:
|
||||
return (None, None)
|
||||
module = _load_model_module(module_name)
|
||||
if module is None:
|
||||
return (None, None)
|
||||
|
||||
Kept as a lazily-invoked helper so the baseline module is only looked up
|
||||
when the on-device spec is absent *or* fails at call time. The lookup hits
|
||||
the module cache warmed by :func:`preload_models`, so no import happens in
|
||||
the event loop.
|
||||
"""
|
||||
module_name = _MODEL_MODULES.get(capability)
|
||||
if module_name is None:
|
||||
return (None, None)
|
||||
module = _load_model_module(module_name)
|
||||
if module is None:
|
||||
return (None, None)
|
||||
|
||||
def _baseline_score(feats, _m=module):
|
||||
# The embedded baseline must never raise into live inference either
|
||||
# (mirrors _on_device_score's call-time guard): on any scoring error
|
||||
# log and return a neutral 0.0 so a gate treats the signal as absent
|
||||
# rather than letting the exception reach live detection/matching.
|
||||
try:
|
||||
return float(_m.score(feats))
|
||||
except Exception as exc: # noqa: BLE001 - never raise into live inference
|
||||
_LOGGER.warning(
|
||||
"Embedded baseline scorer for capability %r failed at call "
|
||||
"time, returning neutral 0.0: %s", capability, exc,
|
||||
)
|
||||
return 0.0
|
||||
|
||||
return (_baseline_score, "baseline")
|
||||
|
||||
# 1) On-device trained spec from the store.
|
||||
if store is not None:
|
||||
def _baseline_score(feats, _m=module):
|
||||
# The embedded baseline must never raise into live inference: on any
|
||||
# scoring error log and return a neutral 0.0 so a gate treats the signal
|
||||
# as absent rather than letting the exception reach detection/matching.
|
||||
try:
|
||||
versions = store.get_ml_model_versions() or {} # type: ignore[attr-defined]
|
||||
record = versions.get(capability)
|
||||
spec = record.get("spec") if isinstance(record, dict) else None
|
||||
# Only treat a spec as a classifier here. A regression spec
|
||||
# (standardized_linear) must never be sigmoid-squashed by score_spec;
|
||||
# classifier and regression capability keys are disjoint today, but this
|
||||
# guard keeps it safe if a key were ever reused.
|
||||
if isinstance(spec, dict) and spec.get("kind") != "standardized_linear":
|
||||
# Feature-column schema guard: a spec promoted under an older
|
||||
# FEATURE_COLUMNS must be dropped rather than silently scoring on a
|
||||
# stale/neutral-filled schema. The call-time guard already catches
|
||||
# shape mismatches, but this catches them at load time and logs
|
||||
# clearly, so users see a single warm-up warning instead of a
|
||||
# per-inference warning storm.
|
||||
module_name = _MODEL_MODULES.get(capability)
|
||||
if module_name is not None:
|
||||
try:
|
||||
_bm = _load_model_module(module_name)
|
||||
_expected = list(getattr(_bm, "FEATURE_COLUMNS", []))
|
||||
_stored = list(spec.get("feature_columns") or [])
|
||||
if _expected and _stored and _stored != _expected:
|
||||
_LOGGER.warning(
|
||||
"Promoted spec for %r has stale feature schema "
|
||||
"(%d cols vs current %d); reverting to baseline.",
|
||||
capability, len(_stored), len(_expected),
|
||||
)
|
||||
return _baseline()
|
||||
except Exception: # noqa: BLE001 - schema check must not break inference
|
||||
pass
|
||||
|
||||
score_spec = _sibling_attr("trainer", "score_spec")
|
||||
if score_spec is None:
|
||||
return _baseline()
|
||||
|
||||
def _on_device_score(feats, _s=spec):
|
||||
# A malformed / dimensionally-incompatible promoted spec must
|
||||
# never raise into live detection/matching: on any call-time
|
||||
# error fall back to the embedded baseline (or a neutral 0.0).
|
||||
try:
|
||||
return float(score_spec(_s, feats))
|
||||
except Exception as exc: # noqa: BLE001 - never raise into live inference
|
||||
_LOGGER.warning(
|
||||
"Trained scorer for capability %r failed at call time, "
|
||||
"falling back to baseline: %s", capability, exc,
|
||||
)
|
||||
fn, _src = _baseline()
|
||||
if fn is not None:
|
||||
try:
|
||||
return fn(feats)
|
||||
except Exception: # noqa: BLE001 - baseline must not raise either
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
return (_on_device_score, "on_device")
|
||||
except Exception as exc: # noqa: BLE001 - never let a bad store break inference
|
||||
return float(_m.score(feats))
|
||||
except Exception as exc: # noqa: BLE001 - never raise into live inference
|
||||
_LOGGER.warning(
|
||||
"Failed to load trained spec for capability %r, falling back to baseline: %s",
|
||||
capability, exc,
|
||||
"Embedded baseline scorer for capability %r failed at call "
|
||||
"time, returning neutral 0.0: %s", capability, exc,
|
||||
)
|
||||
# 2) Shipped embedded baseline module.
|
||||
return _baseline()
|
||||
return 0.0
|
||||
|
||||
return (_baseline_score, "baseline")
|
||||
|
||||
|
||||
def resolve_regressor(capability: str, store: object | None):
|
||||
"""Return ``(predict_fn, source)`` for a regression capability.
|
||||
|
||||
Regression models (``"remaining_time"`` and ``"total_energy"``) have **no**
|
||||
Regression models (since 0.5.8 only ``"total_energy"``) have **no**
|
||||
shipped embedded baseline - they are trained purely on-device (Stage 4) and
|
||||
stored as ``standardized_linear`` specs. This returns ``(None, None)`` until
|
||||
on-device training promotes one, so live behaviour is unchanged until then.
|
||||
|
||||
``predict_fn`` maps a feature mapping -> float in the model's target units
|
||||
(a completion fraction in ~[0, 1] for both regression capabilities).
|
||||
(for ``total_energy``, the energy fraction so far in ~[0, 1]).
|
||||
"""
|
||||
if store is None:
|
||||
return (None, None)
|
||||
|
||||
@@ -24,12 +24,13 @@ Three feature extractors are implemented:
|
||||
|
||||
- **Cycle-end detector** (``END_FEATURE_COLUMNS``): self-contained from a live
|
||||
power series + profile expectation; call ``latest_end_event_features``.
|
||||
- **Live-match commit confidence** (``LIVE_MATCH_FEATURE_COLUMNS``): requires the
|
||||
match ranking from ``ProfileStore`` plus the observed prefix; call
|
||||
``live_match_features``.
|
||||
- **Remaining-time / energy regressors** (``PROGRESS_FEATURE_COLUMNS``): a
|
||||
running-cycle prefix + profile expectation; call ``progress_features``.
|
||||
- **Hybrid cycle quality** (``QUALITY_FEATURE_COLUMNS``): requires the complete
|
||||
cycle power trace plus profile/match context; call ``quality_features``.
|
||||
|
||||
(The live-match commit features went with the early match commit in 0.5.8.)
|
||||
|
||||
All inputs are plain Python/NumPy (offset-seconds, watts), so this module has no
|
||||
Home Assistant dependency and is unit-tested directly. It is only invoked when
|
||||
the user opts into experimental ML models (see engine.py).
|
||||
@@ -108,38 +109,6 @@ def profile_expectation(cycles_points: Sequence[Sequence[Point]]) -> dict[str, f
|
||||
}
|
||||
|
||||
|
||||
def profile_expectations(cycles: list[dict]) -> dict[str, dict[str, float]]:
|
||||
"""Median duration/energy/peak per profile from stored cycle dicts.
|
||||
|
||||
The dict-based counterpart of :func:`profile_expectation` (which works from
|
||||
decompressed traces): reads the ``duration``/``energy_wh``/``max_power``
|
||||
scalar fields already stored on each cycle. Shared by on-device training
|
||||
(``training_task``) and the ML suggestion engine so the "profile expectation"
|
||||
definition lives in one place. Profiles with no usable duration are skipped;
|
||||
missing energy/peak default to 500.
|
||||
"""
|
||||
stats: dict[str, dict[str, list[float]]] = {}
|
||||
for c in cycles:
|
||||
name = c.get("profile_name")
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
s = stats.setdefault(name, {"d": [], "e": [], "p": []})
|
||||
for key, field in (("d", "duration"), ("e", "energy_wh"), ("p", "max_power")):
|
||||
v = c.get(field)
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||
s[key].append(float(v))
|
||||
out: dict[str, dict[str, float]] = {}
|
||||
for name, s in stats.items():
|
||||
if not s["d"]:
|
||||
continue
|
||||
out[name] = {
|
||||
"duration": float(np.median(s["d"])),
|
||||
"energy": float(np.median(s["e"])) if s["e"] else 500.0,
|
||||
"peak": float(np.median(s["p"])) if s["p"] else 500.0,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def latest_end_event_features(
|
||||
points: Sequence[Point],
|
||||
expectation: dict[str, float],
|
||||
@@ -195,83 +164,11 @@ def latest_end_event_features(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live-match commit confidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mirrors ml_washdata/wash_ml/live_matching.py COMMIT_FEATURE_COLUMNS.
|
||||
# The test suite asserts this equals the embedded model's FEATURE_COLUMNS.
|
||||
LIVE_MATCH_FEATURE_COLUMNS = [
|
||||
"match_progress_top1",
|
||||
"top1_distance",
|
||||
"margin",
|
||||
"distance_ratio",
|
||||
"candidate_count_log",
|
||||
"prefix_active_fraction",
|
||||
"duration_ratio_top1",
|
||||
"elapsed_log",
|
||||
]
|
||||
|
||||
|
||||
def live_match_features(
|
||||
points: Sequence[Point],
|
||||
elapsed_s: float,
|
||||
top1_distance: float,
|
||||
top2_distance: float | None,
|
||||
top1_median_duration_s: float,
|
||||
candidate_count: int,
|
||||
) -> dict[str, float]:
|
||||
"""Features for the live-match commit-confidence model.
|
||||
|
||||
Args:
|
||||
points: Observed power readings (offset_s, watts) for the current prefix.
|
||||
elapsed_s: Seconds elapsed since cycle start.
|
||||
top1_distance: Blended RMSE+DTW shape distance to the top-1 candidate
|
||||
prefix (as returned by the profile matcher).
|
||||
top2_distance: Distance to the top-2 candidate; pass ``None`` or ``0.0``
|
||||
when only one candidate is available (margin defaults to 1.0).
|
||||
top1_median_duration_s: Expected (median) duration of the top-1 candidate
|
||||
profile in seconds.
|
||||
candidate_count: Number of candidate profiles on this device.
|
||||
|
||||
Returns a dict with exactly ``LIVE_MATCH_FEATURE_COLUMNS`` keys.
|
||||
"""
|
||||
elapsed = max(0.0, float(elapsed_s))
|
||||
top1 = max(0.0, float(top1_distance))
|
||||
top2_raw = float(top2_distance) if top2_distance is not None else 0.0
|
||||
top2 = top2_raw if top2_raw > 1e-9 else top1 + 1.0
|
||||
margin = max(0.0, top2 - top1)
|
||||
dur = float(top1_median_duration_s)
|
||||
progress = (elapsed / dur) if dur > 0 else 1.0
|
||||
|
||||
# prefix_active_fraction: fraction of prefix readings clearly above idle.
|
||||
# Lab uses > 0.05 on a peak-normalised trace; equivalent here is > 5% of
|
||||
# peak, with a 1 W floor so a cold trace never divides by near-zero.
|
||||
if points:
|
||||
powers = np.asarray([max(0.0, float(p)) for _, p in points], dtype=float)
|
||||
peak = float(np.max(powers)) if powers.size else 0.0
|
||||
active_thr = max(1.0, 0.05 * peak)
|
||||
prefix_active_fraction = float(np.mean(powers > active_thr)) if powers.size else 0.0
|
||||
else:
|
||||
prefix_active_fraction = 0.0
|
||||
|
||||
return {
|
||||
"match_progress_top1": float(min(progress, 2.0)),
|
||||
"top1_distance": float(top1),
|
||||
"margin": float(margin),
|
||||
"distance_ratio": float(top1 / top2) if top2 > 1e-9 else 1.0,
|
||||
"candidate_count_log": float(math.log1p(max(0, int(candidate_count)))),
|
||||
"prefix_active_fraction": float(prefix_active_fraction),
|
||||
"duration_ratio_top1": float(min(progress, 2.0)),
|
||||
"elapsed_log": float(math.log1p(elapsed)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remaining-time / progress regressor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Feature columns for the on-device remaining-time regressor. Unlike the three
|
||||
# Feature columns for the on-device remaining-time regressor. Unlike the
|
||||
# classifier heads this model is a ``standardized_linear`` regressor whose target
|
||||
# is the cycle completion fraction (elapsed / total_actual). There is no shipped
|
||||
# baseline: the model exists only once on-device training promotes one over the
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
[
|
||||
{
|
||||
"feature": "match_progress_top1",
|
||||
"group": "live_match",
|
||||
"runtime_source": "elapsed_seconds / top-1 candidate expected duration"
|
||||
},
|
||||
{
|
||||
"feature": "top1_distance",
|
||||
"group": "live_match",
|
||||
"runtime_source": "blended RMSE+DTW shape distance to the top-1 candidate prefix"
|
||||
},
|
||||
{
|
||||
"feature": "margin",
|
||||
"group": "live_match",
|
||||
"runtime_source": "top-2 distance minus top-1 distance (decision confidence)"
|
||||
},
|
||||
{
|
||||
"feature": "distance_ratio",
|
||||
"group": "live_match",
|
||||
"runtime_source": "top-1 distance / top-2 distance"
|
||||
},
|
||||
{
|
||||
"feature": "candidate_count_log",
|
||||
"group": "live_match",
|
||||
"runtime_source": "log1p(number of candidate profiles on the device)"
|
||||
},
|
||||
{
|
||||
"feature": "prefix_active_fraction",
|
||||
"group": "live_match",
|
||||
"runtime_source": "fraction of the observed prefix above the active threshold"
|
||||
},
|
||||
{
|
||||
"feature": "duration_ratio_top1",
|
||||
"group": "live_match",
|
||||
"runtime_source": "elapsed / top-1 expected duration (clipped)"
|
||||
},
|
||||
{
|
||||
"feature": "elapsed_log",
|
||||
"group": "live_match",
|
||||
"runtime_source": "log1p(elapsed seconds since cycle start)"
|
||||
}
|
||||
]
|
||||
@@ -1,129 +0,0 @@
|
||||
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
||||
# Copyright (C) 2026 Lukas Bandura
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Auto-generated by ml_washdata/wash_ml/promotion.py. Do not edit by hand.
|
||||
|
||||
Embedded WashData model: 'live_match_commit' (target: 'match_top1_correct').
|
||||
Kind: standardized logistic regression. Runtime dependency: NumPy only.
|
||||
|
||||
Regenerate with ``./ml.sh experiment`` in the ml_washdata lab and copy the new
|
||||
file. Determinism check at generation time: max_abs_score_diff=1.6467e-08
|
||||
over 2392 rows.
|
||||
|
||||
Usage in the integration::
|
||||
|
||||
from .live_match_commit_model import score, predict, FEATURE_COLUMNS
|
||||
features = build_runtime_features(...) # must populate FEATURE_COLUMNS
|
||||
is_positive = predict(features)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
from typing import Mapping
|
||||
|
||||
import numpy as np
|
||||
|
||||
MODEL_NAME = 'live_match_commit'
|
||||
MODEL_TARGET = 'match_top1_correct'
|
||||
MODEL_KIND = 'standardized_logistic'
|
||||
TARGET_UNITS = ''
|
||||
# Training artifact for select_threshold only — NOT used at live inference.
|
||||
# The live early-commit gate reads ML_MATCH_COMMIT_THRESHOLD from const.py.
|
||||
THRESHOLD = 0.371786
|
||||
|
||||
FEATURE_COLUMNS = [
|
||||
'match_progress_top1',
|
||||
'top1_distance',
|
||||
'margin',
|
||||
'distance_ratio',
|
||||
'candidate_count_log',
|
||||
'prefix_active_fraction',
|
||||
'duration_ratio_top1',
|
||||
'elapsed_log',
|
||||
]
|
||||
|
||||
# Provenance (metrics at training time):
|
||||
MODEL_METRICS = json.loads("""{
|
||||
"owner_holdout": {
|
||||
"accuracy": 0.771789,
|
||||
"balanced_accuracy": 0.741539,
|
||||
"f1": 0.836751,
|
||||
"fn": 120,
|
||||
"fp": 79,
|
||||
"positive_rate": 0.675459,
|
||||
"precision": 0.865874,
|
||||
"problem_recall": 0.809524,
|
||||
"rows": 872,
|
||||
"specificity": 0.673554,
|
||||
"tn": 163,
|
||||
"tp": 510
|
||||
}
|
||||
}""")
|
||||
|
||||
_MODEL_BLOB = (
|
||||
'H4sIAAAAAAACA1VU246kNhD9FYunRKGJDb7BKpGi0UaKtJONktmn1Qq5wXRbAxgZdzqb1fx7jmG6NXmiqMupOnXxt+zozJo1tNCM'
|
||||
'aVELnWednaMNWfOZFoJyJcqS57Rglaoha4gVo5UQsoQoGOe6ZiJnRc2kkoolX64oE7pi+RsEVWheUa7FF2TwdtjwS0UlFzo5Ms2o'
|
||||
'oila8lJQIWV+YEXJeF1zKfIDUJlAjUwmWai6qsSW7I6R1JwBRaqUI1gTbd+amDVZSUt5oPJQ1k8lbXjdUPEDpQ2lWZ4N8LsE23Z+'
|
||||
'vEwzevE5m0zszu0S/CnYdW2jXxgc06ft3RrN3Fn8Tyac3AzhpmuDic5D0Zm5dz3SA/Qyx3b0J2iXYAf3T2u66P627RCS4Lf4yxY4'
|
||||
'7/G3dHY0ywoCKRh8Ti4CbZpc4iOpMFqW8Hp2cw9FKqA3oXf/7hGoyHWpRhuD60DqW+avsw3t2Y+9v8SkMF2HxN3XNH2lmNJ1nh3N'
|
||||
'mJigbW+N6HwF48C2RamkEgx/c9awkkJYskbBvPjVbdRAwyZP+HFRb8Q7tyauKVwKrXhS+uNopxY2M46bhdYCi5IFf0XBWpV5ti6I'
|
||||
'HFzn4tcdEHsHj5hSywoCUgtGX17ybDYTkmZjKmCf32u3YPLRbnP9AONhM5LdeOj8PLjegnJD/vgOnT8wcp8ecSuJZwvfgCoj2RbC'
|
||||
'TN8XwHzY4kn0xJCU82YkO/z1bGeyItCSn38CCPYodf4d8QAMV7da8mztsnu7+USQk6xXd481pHfDYANu8U1BR4u67UpuZcdUylMw'
|
||||
'brY98TO5WvNMzCX6w2iOdlzfkYD9c5Ml02WNifRyAczDx8fH357aX9//8vTpz/ftw8cPnx5//4sMwU8b4Y3QTiSYGSt2KtIKYm8Q'
|
||||
'3t7eB1rQu27FENF+llSvcjpvXWnNucCVUq1Lzfj2JJSqqvFspJeE0ppj99KbwSvFZbVdNB4UhcD8fwh4B7A5FfuSMpztZDDtq1nP'
|
||||
'aIwp0P3Jp3OffG/HH7dzxXnadCz7NmzX+zrJu7W9zC5iNbKkuQ0pMavSPciX/wDk5KXIHwUAAA=='
|
||||
)
|
||||
|
||||
_MODEL_CACHE: dict | None = None
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
global _MODEL_CACHE
|
||||
if _MODEL_CACHE is None:
|
||||
payload = gzip.decompress(base64.b64decode(_MODEL_BLOB.encode("ascii")))
|
||||
spec = json.loads(payload.decode("utf-8"))
|
||||
_MODEL_CACHE = {
|
||||
"center": np.asarray(spec["center"], dtype=float),
|
||||
"scale": np.asarray(spec["scale"], dtype=float),
|
||||
"coef": np.asarray(spec["coef"], dtype=float),
|
||||
"bias": float(spec["bias"]),
|
||||
"threshold": float(spec["threshold"]),
|
||||
"output_center": float(spec.get("output_center") or 0.0),
|
||||
"output_scale": float(spec.get("output_scale") if spec.get("output_scale") is not None else 1.0),
|
||||
"feature_columns": list(spec["feature_columns"]),
|
||||
}
|
||||
return _MODEL_CACHE
|
||||
|
||||
|
||||
def score(features: Mapping[str, float]) -> float:
|
||||
"""Return the model probability in [0, 1] for one feature mapping."""
|
||||
model = _load()
|
||||
vector = np.array(
|
||||
[float(features.get(column) or 0.0) for column in model["feature_columns"]],
|
||||
dtype=float,
|
||||
)
|
||||
scaled = (vector - model["center"]) / model["scale"]
|
||||
logit = float(scaled @ model["coef"] + model["bias"])
|
||||
logit = max(-60.0, min(60.0, logit))
|
||||
return 1.0 / (1.0 + np.exp(-logit))
|
||||
|
||||
|
||||
def predict(features: Mapping[str, float]) -> bool:
|
||||
"""True when the example crosses the embedded decision threshold."""
|
||||
return score(features) >= _load()["threshold"]
|
||||
@@ -1,110 +0,0 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"expected_score": 0.0409139,
|
||||
"features": {
|
||||
"candidate_count_log": 2.30258509,
|
||||
"distance_ratio": 0.99324714,
|
||||
"duration_ratio_top1": 0.0713484,
|
||||
"elapsed_log": 6.43615037,
|
||||
"margin": 0.0003374,
|
||||
"match_progress_top1": 0.0713484,
|
||||
"prefix_active_fraction": 1.0,
|
||||
"top1_distance": 0.04962666
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.23638102,
|
||||
"features": {
|
||||
"candidate_count_log": 2.30258509,
|
||||
"distance_ratio": 0.9767654,
|
||||
"duration_ratio_top1": 0.77657169,
|
||||
"elapsed_log": 9.10509096,
|
||||
"margin": 0.00796904,
|
||||
"match_progress_top1": 0.77657169,
|
||||
"prefix_active_fraction": 0.28125,
|
||||
"top1_distance": 0.33501266
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.36610898,
|
||||
"features": {
|
||||
"candidate_count_log": 1.79175947,
|
||||
"distance_ratio": 0.98383705,
|
||||
"duration_ratio_top1": 0.67719326,
|
||||
"elapsed_log": 8.00670085,
|
||||
"margin": 0.00441228,
|
||||
"match_progress_top1": 0.67719326,
|
||||
"prefix_active_fraction": 0.328125,
|
||||
"top1_distance": 0.26857515
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.50799834,
|
||||
"features": {
|
||||
"candidate_count_log": 2.30258509,
|
||||
"distance_ratio": 0.56963091,
|
||||
"duration_ratio_top1": 0.56899238,
|
||||
"elapsed_log": 8.35501068,
|
||||
"margin": 0.12556275,
|
||||
"match_progress_top1": 0.56899238,
|
||||
"prefix_active_fraction": 0.40625,
|
||||
"top1_distance": 0.16619322
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.67345544,
|
||||
"features": {
|
||||
"candidate_count_log": 2.30258509,
|
||||
"distance_ratio": 0.61349302,
|
||||
"duration_ratio_top1": 0.99052133,
|
||||
"elapsed_log": 8.74369111,
|
||||
"margin": 0.12364826,
|
||||
"match_progress_top1": 0.99052133,
|
||||
"prefix_active_fraction": 0.296875,
|
||||
"top1_distance": 0.19626385
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.88134857,
|
||||
"features": {
|
||||
"candidate_count_log": 1.94591015,
|
||||
"distance_ratio": 0.37757139,
|
||||
"duration_ratio_top1": 1.01608929,
|
||||
"elapsed_log": 6.5539334,
|
||||
"margin": 0.1729941,
|
||||
"match_progress_top1": 1.01608929,
|
||||
"prefix_active_fraction": 0.859375,
|
||||
"top1_distance": 0.10493994
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.95653616,
|
||||
"features": {
|
||||
"candidate_count_log": 1.09861229,
|
||||
"distance_ratio": 0.21720322,
|
||||
"duration_ratio_top1": 0.30021136,
|
||||
"elapsed_log": 7.89561849,
|
||||
"margin": 0.51761286,
|
||||
"match_progress_top1": 0.30021136,
|
||||
"prefix_active_fraction": 0.234375,
|
||||
"top1_distance": 0.14362244
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.99762013,
|
||||
"features": {
|
||||
"candidate_count_log": 1.09861229,
|
||||
"distance_ratio": 0.02465625,
|
||||
"duration_ratio_top1": 1.00014576,
|
||||
"elapsed_log": 9.5269014,
|
||||
"margin": 1.86460354,
|
||||
"match_progress_top1": 1.00014576,
|
||||
"prefix_active_fraction": 0.09375,
|
||||
"top1_distance": 0.04713633
|
||||
}
|
||||
}
|
||||
],
|
||||
"kind": "standardized_logistic",
|
||||
"model": "live_match_commit"
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
||||
# Copyright (C) 2026 Lukas Bandura
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""On-device tuning of the matcher's scoring weights (Stage 4/5, opt-in).
|
||||
|
||||
Mirrors the offline ``devtools/dtw_ab_eval.py`` methodology but as a shippable,
|
||||
NumPy-only, executor-safe pure function: it does leave-one-out matching over the
|
||||
device's own labelled cycles, sweeps a small grid of the highest-impact scoring
|
||||
weights (corr/MAE split, duration agreement weight, energy agreement weight, and
|
||||
DTW ensemble weight independently), and - only if a candidate beats the shipped
|
||||
defaults on a HELD-OUT split by a margin - returns a per-device config override. The caller persists it; the matcher reads it live
|
||||
and falls back to the const defaults otherwise.
|
||||
|
||||
Discipline (same as model promotion): tune on a train split, gate on a held-out
|
||||
split, require a margin, cap the grid to bounded scoring weights (never
|
||||
structural behaviour). This guards against over-fitting the small, partly
|
||||
manually-labelled per-user cycle set.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .. import analysis
|
||||
from ..const import (
|
||||
DEFAULT_DTW_BANDWIDTH,
|
||||
DEFAULT_DTW_MODE,
|
||||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
)
|
||||
from ..signal_processing import resample_adaptive, resample_uniform
|
||||
|
||||
#: Matches the production matcher (``profile_store.async_match_profile``).
|
||||
_MIN_DT = 5.0
|
||||
_GAP_S = 21600.0
|
||||
|
||||
|
||||
def _series(cycle: dict[str, Any]) -> tuple[np.ndarray, np.ndarray] | None:
|
||||
"""``(offsets_s, watts)`` from a stored cycle, or None if unusable.
|
||||
|
||||
The offsets matter: ``analysis.find_best_alignment`` compares the two curves
|
||||
**index by index** (its ``dt`` argument is explicitly unused), so both sides
|
||||
must be on the same seconds-per-sample grid or the MAE compares different
|
||||
moments of the cycle. Dropping the offsets - as this module did until register
|
||||
item 303 - makes that impossible to honour.
|
||||
"""
|
||||
pd = cycle.get("power_data") or []
|
||||
ts: list[float] = []
|
||||
pw: list[float] = []
|
||||
for p in pd:
|
||||
# Both values are converted BEFORE either is appended: a row whose offset
|
||||
# parses and whose power does not would otherwise leave `ts` one element
|
||||
# longer than `pw`, and `resample_uniform` raises inside `np.interp` on
|
||||
# unequal arrays - a tuning run lost to one malformed sample.
|
||||
# OverflowError too: `json` keeps an oversized integer literal as an
|
||||
# unbounded int, and `float()` on one raises rather than returning inf.
|
||||
try:
|
||||
offset, watts = float(p[0]), float(p[1])
|
||||
except (TypeError, ValueError, IndexError, OverflowError):
|
||||
continue
|
||||
ts.append(offset)
|
||||
pw.append(watts)
|
||||
if len(pw) < 4:
|
||||
return None
|
||||
return np.asarray(ts, dtype=float), np.asarray(pw, dtype=float)
|
||||
|
||||
|
||||
def _longest(segments: list[Any]) -> Any | None:
|
||||
return max(segments, key=lambda s: len(s.power)) if segments else None
|
||||
|
||||
|
||||
def _prep(cycles: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Group labelled cycles by profile, keeping the raw time series."""
|
||||
by_profile: dict[str, list[dict[str, Any]]] = {}
|
||||
for c in cycles:
|
||||
name = c.get("profile_name")
|
||||
series = _series(c)
|
||||
if not name or series is None:
|
||||
continue
|
||||
try:
|
||||
dur = float(c.get("duration"))
|
||||
except (TypeError, ValueError):
|
||||
dur = 0.0
|
||||
if dur <= 0:
|
||||
# No reliable wall-clock duration: skip rather than fabricate one from the
|
||||
# sample count (len(pw)), which distorts duration scoring on devices that
|
||||
# sample every 30-60 s. Real cycles always carry a 'duration', so this only
|
||||
# drops degenerate entries.
|
||||
continue
|
||||
ts, pw = series
|
||||
review = c.get("ml_review")
|
||||
golden = bool(review.get("golden")) if isinstance(review, dict) else False
|
||||
by_profile.setdefault(name, []).append(
|
||||
{"ts": ts, "pw": pw, "dur": dur, "golden": golden}
|
||||
)
|
||||
return by_profile
|
||||
|
||||
|
||||
def _regrid(item: dict[str, Any], dt: float, cache: dict) -> list[float] | None:
|
||||
"""One cycle's curve on a ``dt``-second grid, cached per (cycle, dt)."""
|
||||
key = (id(item), round(float(dt), 2))
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
seg = _longest(resample_uniform(item["ts"], item["pw"], dt_s=dt, gap_s=_GAP_S))
|
||||
out = seg.power.tolist() if seg is not None and len(seg.power) >= 2 else None
|
||||
cache[key] = out
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def _envelope_avg(
|
||||
name: str, pool: list[dict[str, Any]], excluded: int | None, dt: float, cache: dict
|
||||
) -> list[float] | None:
|
||||
"""The pool's DTW-warped envelope average, re-gridded to the query's ``dt``.
|
||||
|
||||
This is what `ProfileStore.async_match_profile` scores against once a profile
|
||||
has >= 2 confirmed cycles and no pinned golden cycle, i.e. the common case
|
||||
(register item 347(e)).
|
||||
|
||||
Cheap enough to do honestly, which the deferral used to deny (item 354): an
|
||||
envelope is built from traces, so it does not depend on the config being
|
||||
tuned and survives the whole grid search, and leave-one-out changes only the
|
||||
target's OWN profile - so the number of distinct envelopes is
|
||||
``profiles + targets``, not ``folds x profiles``. Both caches below are keyed
|
||||
accordingly.
|
||||
"""
|
||||
ekey = ("env", name, excluded)
|
||||
built = cache.get(ekey)
|
||||
if built is None:
|
||||
raw = [
|
||||
(it["ts"].tolist(), it["pw"].tolist(), float(it["dur"]))
|
||||
for it in pool
|
||||
]
|
||||
try:
|
||||
built = analysis.compute_envelope_worker(raw, _BASE_CFG["dtw_bandwidth"])
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
built = False # cache the failure; do not retry per target
|
||||
cache[ekey] = built
|
||||
if not built:
|
||||
return None
|
||||
grid, _lo, _hi, avg, _std, _target = built
|
||||
if len(grid) < 2 or len(avg) != len(grid):
|
||||
return None
|
||||
gkey = ("envgrid", name, excluded, round(float(dt), 2))
|
||||
if gkey in cache:
|
||||
return cache[gkey]
|
||||
seg = _longest(
|
||||
resample_uniform(
|
||||
np.asarray(grid, dtype=float), np.asarray(avg, dtype=float),
|
||||
dt_s=dt, gap_s=_GAP_S,
|
||||
)
|
||||
)
|
||||
out = seg.power.tolist() if seg is not None and len(seg.power) >= 2 else None
|
||||
cache[gkey] = out
|
||||
return out
|
||||
|
||||
def _snaps(
|
||||
by_profile: dict[str, list[dict]],
|
||||
exclude: tuple[str, int] | None,
|
||||
dt: float,
|
||||
cache: dict,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""One snapshot per profile, re-gridded to the QUERY's ``dt``.
|
||||
|
||||
Mirrors production: ``profile_store`` resamples the current cycle with
|
||||
``resample_adaptive`` and then re-grids every candidate to that same
|
||||
``used_dt`` via ``_get_cached_sample_segment``, so index *i* is the same
|
||||
elapsed time on both sides.
|
||||
|
||||
**The template follows production's three-way rule, in production's order**
|
||||
(`ProfileStore.async_match_profile`), which is register item 356 closing item
|
||||
347(e): a pinned golden cycle's own sharp trace, else the DTW-warped ENVELOPE
|
||||
AVERAGE once the pool holds >= 2 cycles, else the single representative
|
||||
sample - which is also the safety net when an envelope will not build. The
|
||||
middle branch is the common case, and it is the one that used to be missing:
|
||||
scoring one representative cycle where live matching scores the envelope
|
||||
average can favour weights that win on a trace nothing is matched against.
|
||||
|
||||
**Cost, and the trap inside it.** An envelope is built from traces, so it does
|
||||
not depend on the weights being tuned and survives the whole grid search plus
|
||||
every holdout call; and leave-one-out excludes exactly ONE cycle, so only the
|
||||
target's own profile gets a different template while every other profile
|
||||
keeps the full-pool one, shared by every target. Distinct templates are
|
||||
therefore `profiles + targets`, not `folds x profiles` - measured on the worst
|
||||
real export in `cycle_data/` (12 profiles with >= 2 cycles, 63 targets), 75
|
||||
templates at 79 ms a DTW warp, ~5.9 s one-off. **That figure only holds if the
|
||||
cache outlives one `_top1` call.** It did not at first: `_top1` built a fresh
|
||||
cache and is called ~59 times (1 base + 48 grid + 10 holdout), which measured
|
||||
16.1 s -> 360.6 s on a real export. Hence the `cache` argument threaded
|
||||
through the whole run - nothing in it depends on `cfg`. Do not re-scope it.
|
||||
|
||||
The cheap approximation (averaging the pool's regridded curves without the
|
||||
DTW warp) is measured WORSE and is not what `_envelope_avg` does.
|
||||
|
||||
The exposure is bounded regardless: `tune_matching_config` can
|
||||
only move the bounded scoring weights, never structural matching behaviour,
|
||||
`revert_matching_config` undoes it, and promotion is gated on **held-out
|
||||
top-1 accuracy**: `tune_matching_config` promotes only when the tuned config
|
||||
beats the baseline by `margin` on at least `min_wins` of `n_splits` held-out
|
||||
subsamples (4 of 5) AND the mean held-out top-1 gain is itself >= `margin`.
|
||||
Not AUC - no AUC is computed anywhere in this module. The AUC gate in
|
||||
CLAUDE.md is `ML_TRAINING_AUC_MARGIN`, which governs the ml/ CLASSIFIERS in
|
||||
`training_task.py` and has nothing to do with the matcher's scoring weights.
|
||||
"""
|
||||
snaps = []
|
||||
for name, items in by_profile.items():
|
||||
excluded = exclude[1] if (exclude is not None and exclude[0] == name) else None
|
||||
pool = [
|
||||
it for idx, it in enumerate(items)
|
||||
if exclude is None or (name, idx) != exclude
|
||||
]
|
||||
if not pool:
|
||||
continue
|
||||
durs = [it["dur"] for it in pool]
|
||||
avg = float(np.mean(durs))
|
||||
# Production's three-way template rule, in production's order
|
||||
# (`ProfileStore.async_match_profile`). Getting this wrong is register
|
||||
# item 347(e): scoring one representative cycle where live matching
|
||||
# scores the envelope average can favour weights that lose on the curve
|
||||
# that actually does the matching.
|
||||
curve = None
|
||||
golden = [it for it in pool if it.get("golden")]
|
||||
if golden:
|
||||
# 1. a pinned golden cycle keeps its own sharp trace; the average
|
||||
# smears the wash-phase peaks, which is why production prefers it.
|
||||
curve = _regrid(golden[0], dt, cache)
|
||||
elif len(pool) >= 2:
|
||||
# 2. the common case: the DTW-warped envelope average.
|
||||
curve = _envelope_avg(name, pool, excluded, dt, cache)
|
||||
if not curve:
|
||||
# 3. the single-sample fallback, and the safety net for a profile
|
||||
# whose envelope will not build (too few usable points, a trace
|
||||
# that resamples to nothing). Representative rather than
|
||||
# arbitrary: closest to the pool's mean duration.
|
||||
rep = min(pool, key=lambda it: abs(it["dur"] - avg))
|
||||
curve = _regrid(rep, dt, cache)
|
||||
if not curve:
|
||||
continue
|
||||
snaps.append({
|
||||
"name": name,
|
||||
"avg_duration": avg,
|
||||
"sample_power": curve,
|
||||
})
|
||||
return snaps
|
||||
|
||||
|
||||
def _top1(
|
||||
by_profile: dict[str, list[dict]],
|
||||
targets: list[tuple[str, int]],
|
||||
cfg: dict[str, Any],
|
||||
cache: dict | None = None,
|
||||
) -> float:
|
||||
"""Fraction of the given (profile, idx) targets whose true profile ranks #1
|
||||
under leave-one-out matching with the given config.
|
||||
|
||||
``cache`` is shared ACROSS calls on purpose. Nothing in it depends on
|
||||
``cfg``: the re-gridded curves are keyed by (cycle, dt) and the envelopes by
|
||||
(profile, excluded index), while ``cfg`` only changes the scoring weights.
|
||||
Left per-call - as it was - the grid search rebuilds every envelope for each
|
||||
of its ~59 configs, which measured 16 s -> 361 s on one real export. This is
|
||||
what makes the `profiles + targets` cost in register item 354 real rather
|
||||
than theoretical."""
|
||||
if not targets:
|
||||
return 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
if cache is None:
|
||||
cache = {}
|
||||
for name, idx in targets:
|
||||
it = by_profile[name][idx]
|
||||
# The query defines the grid, exactly as in production.
|
||||
segments, used_dt = resample_adaptive(
|
||||
it["ts"], it["pw"], min_dt=_MIN_DT, gap_s=_GAP_S
|
||||
)
|
||||
seg = _longest(segments)
|
||||
if seg is None or len(seg.power) < 4:
|
||||
continue
|
||||
snaps = _snaps(by_profile, (name, idx), used_dt, cache)
|
||||
if len(snaps) < 2:
|
||||
continue
|
||||
cands = analysis.compute_matches_worker(
|
||||
seg.power.tolist(), it["dur"], snaps, cfg
|
||||
)
|
||||
total += 1
|
||||
if cands and cands[0]["name"] == name:
|
||||
correct += 1
|
||||
return correct / total if total else 0.0
|
||||
|
||||
|
||||
# The full production matcher config. A PARTIAL config does not inherit the
|
||||
# production defaults - `compute_matches_worker` has its own fallbacks - so
|
||||
# omitting a key here would tune the weights against a pipeline that never
|
||||
# ships. `energy_mode` is added per device type at the call site.
|
||||
_BASE_CFG = {
|
||||
"min_duration_ratio": DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
"max_duration_ratio": DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
"dtw_bandwidth": DEFAULT_DTW_BANDWIDTH,
|
||||
"dtw_mode": DEFAULT_DTW_MODE,
|
||||
}
|
||||
|
||||
#: Bounded scoring weights the tuner may promote. All live in [0, 1], so a tuned
|
||||
#: config can only shift emphasis (shape vs level vs energy, and how much the DTW
|
||||
#: ensemble leans on the derivative/DDTW component) - never structural behaviour.
|
||||
OVERRIDE_KEYS = ("corr_weight", "duration_weight", "energy_weight", "dtw_ensemble_w")
|
||||
|
||||
|
||||
def _grid() -> list[dict[str, Any]]:
|
||||
"""Small, high-impact grid over four bounded scoring weights.
|
||||
|
||||
Axes: corr/MAE split × duration agreement weight × energy agreement weight
|
||||
× DTW ensemble weight. The duration and energy axes are now independent so
|
||||
the tuner can find asymmetric configurations (e.g. a device with highly
|
||||
variable energy but stable duration benefits from a low energy_weight and a
|
||||
high duration_weight). All values are bounded scoring weights (see
|
||||
OVERRIDE_KEYS) so a promoted config can never change structural behaviour.
|
||||
Grid size: 4 × 2 × 2 × 3 = 48 configurations (was 4 × 2 × 3 = 24).
|
||||
"""
|
||||
out = []
|
||||
for cw in (0.40, 0.45, 0.50, 0.60):
|
||||
for dur_w in (0.15, 0.22):
|
||||
for en_w in (0.15, 0.22):
|
||||
for ew in (0.55, 0.70, 0.85):
|
||||
out.append({
|
||||
"corr_weight": cw,
|
||||
"duration_weight": dur_w,
|
||||
"energy_weight": en_w,
|
||||
"dtw_ensemble_w": ew,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def tune_matching_config(
|
||||
cycles: list[dict[str, Any]],
|
||||
device_type: str | None = None,
|
||||
*,
|
||||
min_cycles: int = 25,
|
||||
# Kept intentionally low so per-device tuning becomes useful early; the noise
|
||||
# a small sample would introduce is controlled by the multi-split majority gate
|
||||
# below (a lucky single split can't promote), not by a large ``min_targets``.
|
||||
min_targets: int = 12,
|
||||
margin: float = 0.03,
|
||||
seed: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Leave-one-out per-device tuning of matcher scoring weights.
|
||||
|
||||
Methodology (no target leakage between selection and gating):
|
||||
1. Partition the device's labelled cycles ONCE into a *search* pool and an
|
||||
untouched *holdout* pool; no target is ever used for both.
|
||||
2. **Select** the candidate config as the grid entry with the best
|
||||
leave-one-out top-1 on the SEARCH pool only. (Reference snapshots are
|
||||
built from all cycles — as in production, where a query is matched
|
||||
against aggregates of the full profile library; only the *query* targets
|
||||
are partitioned.)
|
||||
3. **Gate** the fixed candidate on the HOLDOUT pool: it must beat the
|
||||
shipped defaults by at least ``margin`` on a MAJORITY of reshuffled
|
||||
holdout subsamples (a variance check that rejects a lucky single split)
|
||||
AND on the holdout mean. ``min_targets`` is kept intentionally low so
|
||||
per-device tuning becomes useful early; the majority gate — not a large
|
||||
sample — controls the noise.
|
||||
|
||||
Returns a status dict; ``promoted`` is True only when both holdout gates pass.
|
||||
When promoted, ``config`` holds the override to persist (bounded scoring
|
||||
weights only — never structural matching behaviour). Never raises for data
|
||||
reasons; returns {"promoted": False, "reason": ...}.
|
||||
"""
|
||||
by_profile = _prep(cycles)
|
||||
multi = {n: items for n, items in by_profile.items() if len(items) >= 2}
|
||||
n_cycles = sum(len(v) for v in by_profile.values())
|
||||
if len(multi) < 2 or n_cycles < min_cycles:
|
||||
return {"promoted": False, "reason": "insufficient data", "n_cycles": n_cycles, "n_profiles": len(by_profile)}
|
||||
|
||||
# Partition targets ONCE, up front, into a search pool (used to pick the
|
||||
# candidate config) and an untouched holdout pool (used only to gate it). No
|
||||
# target is ever used for both selection and gating -> no target leakage.
|
||||
rng = np.random.default_rng(seed)
|
||||
targets = [(n, i) for n, items in multi.items() for i in range(len(items))]
|
||||
rng.shuffle(targets)
|
||||
if len(targets) < min_targets:
|
||||
return {"promoted": False, "reason": "too few targets", "n_targets": len(targets)}
|
||||
cut = max(1, len(targets) // 2)
|
||||
search_pool, holdout_pool = targets[:cut], targets[cut:]
|
||||
if not holdout_pool:
|
||||
return {"promoted": False, "reason": "too few targets", "n_targets": len(targets)}
|
||||
|
||||
# Tune under the same Stage-4 energy mode production uses for this device type,
|
||||
# so promoted weights are consistent with the live matcher.
|
||||
base = {**_BASE_CFG, "energy_mode": analysis.stage4_energy_mode(device_type)}
|
||||
# Candidate: the grid config with the best top-1 on the SEARCH pool only.
|
||||
# One cache for the entire run: the grid search and both holdout arms all
|
||||
# reuse the same envelopes and re-gridded curves (see `_top1`).
|
||||
shared: dict = {}
|
||||
best_search = _top1(by_profile, search_pool, base, shared)
|
||||
best_cfg = base
|
||||
for extra in _grid():
|
||||
acc = _top1(by_profile, search_pool, {**base, **extra}, shared)
|
||||
if acc > best_search:
|
||||
best_search, best_cfg = acc, {**base, **extra}
|
||||
override = {k: best_cfg[k] for k in OVERRIDE_KEYS if k in best_cfg}
|
||||
|
||||
# Gate the FIXED candidate on the held-out pool: require it to beat the defaults
|
||||
# by ``margin`` on a MAJORITY of reshuffled subsamples of the holdout (variance
|
||||
# check), rejecting a lucky single split while keeping min_targets low.
|
||||
n_splits, min_wins = 5, 4
|
||||
base_tests: list[float] = []
|
||||
tuned_tests: list[float] = []
|
||||
wins = 0
|
||||
for k in range(n_splits):
|
||||
r = np.random.default_rng(seed + 1 + k)
|
||||
pool = list(holdout_pool)
|
||||
r.shuffle(pool)
|
||||
held = pool[: max(1, len(pool) // 2)]
|
||||
bt = _top1(by_profile, held, base, shared)
|
||||
tt = _top1(by_profile, held, best_cfg, shared)
|
||||
base_tests.append(bt)
|
||||
tuned_tests.append(tt)
|
||||
if tt - bt >= margin:
|
||||
wins += 1
|
||||
mean_base = float(np.mean(base_tests)) if base_tests else 0.0
|
||||
mean_tuned = float(np.mean(tuned_tests)) if tuned_tests else 0.0
|
||||
has_override = bool(override)
|
||||
enough_wins = wins >= min_wins
|
||||
enough_margin = (mean_tuned - mean_base) >= margin
|
||||
promoted = has_override and enough_wins and enough_margin
|
||||
if promoted:
|
||||
reason = f"beat baseline on {wins}/{n_splits} held-out subsamples"
|
||||
elif not has_override:
|
||||
# AUDITED and correct (register item 356), because it fires on every real
|
||||
# export in `cycle_data/` and that looks like a stuck mechanism. It is
|
||||
# not. `_BASE_CFG` deliberately omits the four OVERRIDE_KEYS, so
|
||||
# `compute_matches_worker` falls back to `MATCH_CORR_WEIGHT` /
|
||||
# `MATCH_DURATION_WEIGHT` / `MATCH_ENERGY_WEIGHT` /
|
||||
# `MATCH_DTW_ENSEMBLE_W` - the tuner's baseline IS production's default,
|
||||
# not a partial config with different fallbacks. `override` is therefore
|
||||
# empty exactly when no grid entry beat that baseline, which is what this
|
||||
# string says. The weights do reach the scorer: on the least saturated
|
||||
# real device (12 profiles, 63 targets, base top-1 0.635) the grid
|
||||
# produces three distinct scores, all <= base. The grid also contains the
|
||||
# exact default combination (0.45 / 0.22 / 0.22 / 0.70), so the defaults
|
||||
# are evaluated on equal terms, and `acc > best_search` is strict so a tie
|
||||
# keeps them. Consistent with the corpus result that matcher top-1 is
|
||||
# near-saturated: there is nothing here for per-device tuning to win.
|
||||
reason = "defaults already optimal (no override)"
|
||||
elif not enough_wins:
|
||||
reason = f"only {wins}/{n_splits} held-out subsamples beat baseline by margin"
|
||||
else:
|
||||
reason = f"mean held-out gain {mean_tuned - mean_base:+.3f} below margin {margin}"
|
||||
return {
|
||||
"promoted": promoted,
|
||||
"config": override if promoted else None,
|
||||
"baseline_test_top1": round(mean_base, 3),
|
||||
"tuned_test_top1": round(mean_tuned, 3),
|
||||
"train_top1": round(best_search, 3),
|
||||
"holdout_wins": wins,
|
||||
"holdout_splits": n_splits,
|
||||
"n_targets": len(targets),
|
||||
"reason": reason,
|
||||
}
|
||||
@@ -65,32 +65,7 @@
|
||||
"name": "hybrid_curve_quality",
|
||||
"target": "problem_cycle",
|
||||
"target_units": ""
|
||||
},
|
||||
{
|
||||
"created_at": "2026-06-29T20:49:05+00:00",
|
||||
"git_commit": "605a862",
|
||||
"kind": "standardized_logistic",
|
||||
"metrics": {
|
||||
"owner_holdout": {
|
||||
"accuracy": 0.771789,
|
||||
"balanced_accuracy": 0.741539,
|
||||
"f1": 0.836751,
|
||||
"fn": 120,
|
||||
"fp": 79,
|
||||
"positive_rate": 0.675459,
|
||||
"precision": 0.865874,
|
||||
"problem_recall": 0.809524,
|
||||
"rows": 872,
|
||||
"specificity": 0.673554,
|
||||
"tn": 163,
|
||||
"tp": 510
|
||||
}
|
||||
},
|
||||
"module": "live_match_commit_model.py",
|
||||
"name": "live_match_commit",
|
||||
"target": "match_top1_correct",
|
||||
"target_units": ""
|
||||
}
|
||||
],
|
||||
"source": "ml_washdata/output/promoted"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,11 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""On-device, NumPy-only model training for WashData (Stage 4).
|
||||
|
||||
This is the runtime counterpart of the offline lab's promotion pipeline. All
|
||||
three embedded models are ``standardized_logistic`` heads - a mean/std scaler
|
||||
plus a weight vector, bias, and decision threshold - which the lab fits with a
|
||||
short pure-NumPy gradient descent (``wash_ml/end_detection.py::_fit_logistic``).
|
||||
This module reproduces that fit and the exact scoring math the embedded
|
||||
``*_model.py`` modules use, so a model trained here on the user's own cycles is
|
||||
byte-compatible with the shipped baseline and can be scored identically.
|
||||
Since 0.5.8 only the ``total_energy`` regressor is trained on-device
|
||||
(:func:`fit_ridge` + :func:`build_regression_spec`, scored by
|
||||
:func:`predict_value_spec`). The classifier training and the logistic spec
|
||||
scoring that used to live here went with the on-device classifier heads
|
||||
(register items 419, 518); the classifiers run their shipped baselines only.
|
||||
|
||||
No new dependencies: NumPy only. Nothing here runs unless the caller (behind the
|
||||
``ENABLE_ML_TRAINING`` flag) invokes it.
|
||||
@@ -38,261 +36,14 @@ import numpy as np
|
||||
PROMOTION_SCHEMA = "washdata.promoted_model/1"
|
||||
|
||||
|
||||
def _sigmoid(values: np.ndarray) -> np.ndarray:
|
||||
clipped = np.clip(values, -60.0, 60.0)
|
||||
return 1.0 / (1.0 + np.exp(-clipped))
|
||||
|
||||
|
||||
def fit_logistic(
|
||||
matrix: np.ndarray,
|
||||
labels: np.ndarray,
|
||||
*,
|
||||
l2: float = 0.01,
|
||||
learning_rate: float = 0.2,
|
||||
iterations: int = 4000,
|
||||
) -> dict[str, np.ndarray | float]:
|
||||
"""Fit a class-balanced, L2-regularised logistic head with NumPy GD.
|
||||
|
||||
Identical in shape to the lab's ``_fit_logistic``: mean/std standardisation,
|
||||
inverse-frequency class weights, and a fixed-step gradient descent. Returns
|
||||
``{center, scale, coef, bias}``.
|
||||
"""
|
||||
matrix = np.asarray(matrix, dtype=float)
|
||||
labels = np.asarray(labels, dtype=float)
|
||||
if matrix.ndim != 2 or matrix.shape[0] == 0:
|
||||
raise ValueError("matrix must be a non-empty 2D array")
|
||||
if labels.shape[0] != matrix.shape[0]:
|
||||
raise ValueError("labels/matrix row mismatch")
|
||||
if len(np.unique(labels)) < 2:
|
||||
raise ValueError(
|
||||
f"fit_logistic requires both positive and negative examples; "
|
||||
f"got labels: {np.unique(labels)}"
|
||||
)
|
||||
|
||||
center = np.mean(matrix, axis=0)
|
||||
scale = np.std(matrix, axis=0)
|
||||
scale = np.where(scale <= 1e-8, 1.0, scale)
|
||||
scaled = (matrix - center) / scale
|
||||
|
||||
weight = np.ones(labels.size, dtype=float)
|
||||
for label_value in (0.0, 1.0):
|
||||
mask = labels == label_value
|
||||
count = float(np.sum(mask))
|
||||
if count > 0:
|
||||
weight[mask] = labels.size / (2.0 * count)
|
||||
normalized = weight / (float(np.sum(weight)) or 1.0)
|
||||
|
||||
coef = np.zeros(matrix.shape[1], dtype=float)
|
||||
bias = 0.0
|
||||
for _ in range(iterations):
|
||||
predictions = _sigmoid(scaled @ coef + bias)
|
||||
residual = (predictions - labels) * normalized
|
||||
coef -= learning_rate * (scaled.T @ residual + l2 * coef)
|
||||
bias -= learning_rate * float(np.sum(residual))
|
||||
return {"center": center, "scale": scale, "coef": coef, "bias": float(bias)}
|
||||
|
||||
|
||||
def _safe_ratio(numerator: float, denominator: float) -> float:
|
||||
return float(numerator) / float(denominator) if denominator else 0.0
|
||||
|
||||
|
||||
def binary_metrics(labels: np.ndarray, scores: np.ndarray, threshold: float) -> dict[str, Any]:
|
||||
"""Confusion-matrix metrics at a threshold (pure NumPy)."""
|
||||
labels = np.asarray(labels, dtype=float)
|
||||
scores = np.asarray(scores, dtype=float)
|
||||
if labels.size == 0:
|
||||
return {}
|
||||
predictions = (scores >= threshold).astype(int)
|
||||
tp = int(np.sum((labels == 1) & (predictions == 1)))
|
||||
fp = int(np.sum((labels == 0) & (predictions == 1)))
|
||||
tn = int(np.sum((labels == 0) & (predictions == 0)))
|
||||
fn = int(np.sum((labels == 1) & (predictions == 0)))
|
||||
precision = _safe_ratio(tp, tp + fp)
|
||||
recall = _safe_ratio(tp, tp + fn)
|
||||
specificity = _safe_ratio(tn, tn + fp)
|
||||
f1 = _safe_ratio(2.0 * precision * recall, precision + recall)
|
||||
accuracy = _safe_ratio(tp + tn, labels.size)
|
||||
positive_rate = _safe_ratio(int(np.sum(labels == 1)), labels.size)
|
||||
# Key names mirror the shipped MODEL_METRICS schema (see *_model.py):
|
||||
# ``problem_recall`` (recall of the positive/"problem" class) and
|
||||
# ``positive_rate`` (base rate of positives), so on-device-trained metrics
|
||||
# are schema-identical to the embedded baselines they are compared against.
|
||||
return {
|
||||
"rows": int(labels.size),
|
||||
"tp": tp, "fp": fp, "tn": tn, "fn": fn,
|
||||
"precision": round(precision, 6),
|
||||
"problem_recall": round(recall, 6),
|
||||
"positive_rate": round(positive_rate, 6),
|
||||
"specificity": round(specificity, 6),
|
||||
"balanced_accuracy": round((recall + specificity) / 2.0, 6),
|
||||
"f1": round(f1, 6),
|
||||
"accuracy": round(accuracy, 6),
|
||||
}
|
||||
|
||||
|
||||
def auc(labels: np.ndarray, scores: np.ndarray) -> float:
|
||||
"""Rank-based ROC AUC (Mann-Whitney U). 0.5 when one class is absent."""
|
||||
labels = np.asarray(labels, dtype=float)
|
||||
scores = np.asarray(scores, dtype=float)
|
||||
finite_mask = np.isfinite(scores)
|
||||
scores = scores[finite_mask]
|
||||
labels = labels[finite_mask]
|
||||
if len(scores) == 0:
|
||||
return 0.5
|
||||
pos = scores[labels == 1]
|
||||
neg = scores[labels == 0]
|
||||
if pos.size == 0 or neg.size == 0:
|
||||
return 0.5
|
||||
order = np.argsort(scores, kind="mergesort")
|
||||
ranks = np.empty(scores.size, dtype=float)
|
||||
ranks[order] = np.arange(1, scores.size + 1, dtype=float)
|
||||
# Average ranks over ties so AUC is exact for discrete scores.
|
||||
_assign_tie_ranks(scores, ranks, order)
|
||||
rank_sum_pos = float(np.sum(ranks[labels == 1]))
|
||||
n_pos = float(pos.size)
|
||||
n_neg = float(neg.size)
|
||||
u = rank_sum_pos - n_pos * (n_pos + 1.0) / 2.0
|
||||
return float(u / (n_pos * n_neg))
|
||||
|
||||
|
||||
def _assign_tie_ranks(scores: np.ndarray, ranks: np.ndarray, order: np.ndarray) -> None:
|
||||
sorted_scores = scores[order]
|
||||
i = 0
|
||||
n = scores.size
|
||||
while i < n:
|
||||
j = i
|
||||
while j + 1 < n and sorted_scores[j + 1] == sorted_scores[i]:
|
||||
j += 1
|
||||
if j > i:
|
||||
avg = (ranks[order[i]] + ranks[order[j]]) / 2.0
|
||||
for k in range(i, j + 1):
|
||||
ranks[order[k]] = avg
|
||||
i = j + 1
|
||||
|
||||
|
||||
def select_threshold(
|
||||
labels: np.ndarray,
|
||||
scores: np.ndarray,
|
||||
*,
|
||||
default: float = 0.5,
|
||||
) -> float:
|
||||
"""Pick the threshold maximising balanced accuracy (model-agnostic).
|
||||
|
||||
Ties break toward the ``default`` so the operating point stays stable when
|
||||
the data does not clearly prefer one cut.
|
||||
"""
|
||||
labels = np.asarray(labels, dtype=float)
|
||||
scores = np.asarray(scores, dtype=float)
|
||||
if scores.size == 0:
|
||||
return default
|
||||
candidates = np.unique(
|
||||
np.concatenate([
|
||||
np.quantile(scores, np.linspace(0.05, 0.95, 37)),
|
||||
np.linspace(0.1, 0.95, 86),
|
||||
])
|
||||
)
|
||||
candidates = candidates[(candidates >= 0.05) & (candidates <= 0.999)]
|
||||
if candidates.size == 0:
|
||||
pos_scores = scores[labels == 1]
|
||||
return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
|
||||
best_key: tuple[float, float] | None = None
|
||||
best_threshold = default
|
||||
best_ba = 0.5
|
||||
for threshold in candidates:
|
||||
m = binary_metrics(labels, scores, float(threshold))
|
||||
bal = float(m.get("balanced_accuracy") or 0.0)
|
||||
key = (bal, -abs(float(threshold) - default))
|
||||
if best_key is None or key > best_key:
|
||||
best_key = key
|
||||
best_threshold = float(threshold)
|
||||
best_ba = bal
|
||||
if best_ba == 0.5:
|
||||
pos_scores = scores[labels == 1]
|
||||
return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
|
||||
return round(best_threshold, 6)
|
||||
|
||||
|
||||
def build_spec(
|
||||
*,
|
||||
name: str,
|
||||
target: str,
|
||||
feature_columns: Sequence[str],
|
||||
fit: Mapping[str, Any],
|
||||
threshold: float,
|
||||
metrics: Mapping[str, Any] | None = None,
|
||||
trained_at: str = "",
|
||||
cycle_count: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble a runtime model spec (same schema as the shipped bundles).
|
||||
|
||||
The returned dict is JSON-serialisable and can be scored by
|
||||
:func:`score_spec` with math identical to the embedded ``*_model.py``.
|
||||
"""
|
||||
return {
|
||||
"schema": PROMOTION_SCHEMA,
|
||||
"name": name,
|
||||
"kind": "standardized_logistic",
|
||||
"target": target,
|
||||
"target_units": "",
|
||||
"feature_columns": list(feature_columns),
|
||||
"center": [round(float(v), 8) for v in np.asarray(fit["center"], dtype=float)],
|
||||
"scale": [round(float(v), 8) for v in np.asarray(fit["scale"], dtype=float)],
|
||||
"coef": [round(float(v), 8) for v in np.asarray(fit["coef"], dtype=float)],
|
||||
"bias": round(float(fit["bias"]), 8),
|
||||
"threshold": round(float(threshold), 8),
|
||||
"output_center": 0.0,
|
||||
"output_scale": 1.0,
|
||||
"metrics": dict(metrics or {}),
|
||||
"notes": ["Trained on-device from the user's own labelled cycles."],
|
||||
"created_at": trained_at,
|
||||
"cycle_count": int(cycle_count),
|
||||
"source": "on_device",
|
||||
}
|
||||
|
||||
|
||||
def score_matrix_spec(spec: Mapping[str, Any], matrix: np.ndarray) -> np.ndarray:
|
||||
"""Pure-NumPy probabilities for a (rows, features) matrix from a spec."""
|
||||
matrix = np.asarray(matrix, dtype=float)
|
||||
if matrix.size == 0:
|
||||
return np.empty(0, dtype=float)
|
||||
center = np.asarray(spec["center"], dtype=float)
|
||||
scale = np.asarray(spec["scale"], dtype=float)
|
||||
coef = np.asarray(spec["coef"], dtype=float)
|
||||
raw = ((matrix - center) / scale) @ coef + float(spec["bias"])
|
||||
return _sigmoid(raw)
|
||||
|
||||
|
||||
def score_spec(spec: Mapping[str, Any], features: Mapping[str, float]) -> float:
|
||||
"""Pure-NumPy probability for one feature mapping.
|
||||
|
||||
Byte-identical to the embedded ``score()`` in ``*_model.py`` for *complete*
|
||||
feature mappings (the normal case: the extractors in ``feature_extraction``
|
||||
always populate every ``FEATURE_COLUMNS`` key). The two intentionally differ
|
||||
only on the defensive missing-key fallback: this fills a missing feature with
|
||||
the training center (standardises to 0.0 = neutral, avoiding 8+ SD corruption
|
||||
of inference), whereas the embedded ``score()`` fills raw 0.0. That path is
|
||||
not exercised by the parity fixtures and is not reachable in practice.
|
||||
"""
|
||||
columns = spec["feature_columns"]
|
||||
center = np.asarray(spec["center"], dtype=float)
|
||||
row = []
|
||||
for i, col in enumerate(columns):
|
||||
val = features.get(col)
|
||||
row.append(float(center[i]) if val is None else float(val))
|
||||
vector = np.array(row, dtype=float)
|
||||
return float(score_matrix_spec(spec, vector.reshape(1, -1))[0])
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression head (standardized_linear) - remaining-time / progress regressor.
|
||||
# Regression head (standardized_linear) - the total_energy regressor.
|
||||
#
|
||||
# The three classifier heads above are logistic. The remaining-time model is a
|
||||
# ridge-regularised linear regressor over standardised features with a
|
||||
# A ridge-regularised linear regressor over standardised features with a
|
||||
# standardised target; prediction un-standardises back to target units using the
|
||||
# spec's ``output_center``/``output_scale``. Same NumPy-only, JSON-serialisable
|
||||
# spec schema as :func:`build_spec` so it is stored/loaded identically, but it is
|
||||
# scored with :func:`predict_matrix_spec` (no sigmoid) rather than ``score_spec``.
|
||||
# spec's ``output_center``/``output_scale``. Same JSON-serialisable spec schema as
|
||||
# the shipped logistic bundles, scored with :func:`predict_matrix_spec` (no
|
||||
# sigmoid).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,25 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from .const import CONF_NOTIFY_QUIET_END_HOUR, CONF_NOTIFY_QUIET_START_HOUR
|
||||
from .const import (
|
||||
CONF_NOTIFY_QUIET_END_HOUR,
|
||||
CONF_NOTIFY_QUIET_START_HOUR,
|
||||
STATE_INTERRUPTED,
|
||||
)
|
||||
|
||||
|
||||
def cycle_end_is_finish(status: Any) -> bool:
|
||||
"""Whether a cycle that ended with ``status`` counts as a finished run.
|
||||
|
||||
Decides both the "finished" notification and entry into the Clean state (and
|
||||
so the unload reminder) - audit MANAGER-10. An ``interrupted`` cycle is a
|
||||
false start, a cancelled programme or a plug pulled early (the terminal-drop
|
||||
finalize files those as interrupted too): nothing finished, nothing to unload.
|
||||
A ``force_stopped`` cycle (watchdog or the user's Force Stop) ran a programme
|
||||
the user may still want to hear about, so it keeps both; the finish template's
|
||||
``{status}`` tells it apart. A missing/unknown status keeps today's behaviour.
|
||||
"""
|
||||
return status != STATE_INTERRUPTED
|
||||
|
||||
|
||||
def quiet_hours_bounds(options: Any) -> tuple[int, int] | None:
|
||||
@@ -54,7 +72,7 @@ def quiet_hours_bounds(options: Any) -> tuple[int, int] | None:
|
||||
end = int(raw_end)
|
||||
if start != raw_start or end != raw_end:
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not (0 <= start <= 23) or not (0 <= end <= 23):
|
||||
return None
|
||||
@@ -99,7 +117,11 @@ def seconds_until_quiet_end(
|
||||
if target <= when:
|
||||
# End hour is earlier today (wrap-around window) -> it lands tomorrow.
|
||||
target = target + timedelta(days=1)
|
||||
return max(0.0, (target - when).total_seconds())
|
||||
# The target is a local wall-clock time, but the wait is real seconds:
|
||||
# subtracting two datetimes that share one ZoneInfo uses their wall-clock
|
||||
# fields, so on a DST night the hold ended an hour early (autumn) or late
|
||||
# (spring). `timestamp()` honours each side's own UTC offset.
|
||||
return max(0.0, target.timestamp() - when.timestamp())
|
||||
|
||||
|
||||
def milestone_crossed(prev_count: int, cur_count: int, milestones: Any) -> int | None:
|
||||
@@ -124,7 +146,7 @@ def milestone_crossed(prev_count: int, cur_count: int, milestones: Any) -> int |
|
||||
continue
|
||||
try:
|
||||
m = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
if m != raw or m <= 0:
|
||||
continue
|
||||
|
||||
@@ -23,8 +23,11 @@ from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from .const import (
|
||||
DEVICE_TYPE_AIR_FRYER,
|
||||
DEVICE_TYPE_BREAD_MAKER,
|
||||
DEVICE_TYPE_DISHWASHER,
|
||||
DEVICE_TYPE_DRYER,
|
||||
DEVICE_TYPE_PUMP,
|
||||
DEVICE_TYPE_WASHER_DRYER,
|
||||
DEVICE_TYPE_WASHING_MACHINE,
|
||||
)
|
||||
@@ -202,6 +205,75 @@ DEFAULT_PHASES_BY_DEVICE: dict[str, list[PhaseItem]] = {
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
# #190: these two lists went with the removed device types in 0.5.0 (4fde004)
|
||||
# although both types stayed, so a bread maker was offered Pre-Wash and Spin.
|
||||
DEVICE_TYPE_BREAD_MAKER: [
|
||||
{
|
||||
"name": "Kneading",
|
||||
"description": "Motor-driven dough mixing and development. High power draw.",
|
||||
"translation_key": "phase_desc.kneading",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Resting",
|
||||
"description": "Short low-power pause between kneading stages for gluten relaxation.",
|
||||
"translation_key": "phase_desc.resting",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Proving",
|
||||
"description": "Low-heat rising period to allow yeast fermentation and dough expansion.",
|
||||
"translation_key": "phase_desc.proving",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Baking",
|
||||
"description": "High-temperature heating element active for crust and crumb formation.",
|
||||
"translation_key": "phase_desc.baking",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Keep Warm",
|
||||
"description": "Low-heat holding stage to keep the loaf warm after baking.",
|
||||
"translation_key": "phase_desc.keep_warm_bm",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
DEVICE_TYPE_AIR_FRYER: [
|
||||
{
|
||||
"name": "Pre-Heat",
|
||||
"description": "Initial chamber heating before full cooking.",
|
||||
"translation_key": "phase_desc.pre_heat",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Cooking",
|
||||
"description": "Main cooking phase with active heater and fan.",
|
||||
"translation_key": "phase_desc.cooking",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Pause",
|
||||
"description": "Short pause for shaking or inspection.",
|
||||
"translation_key": "phase_desc.pause_af",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Cool Down",
|
||||
"description": "Fan-only cool-down stage after heating.",
|
||||
"translation_key": "phase_desc.cool_down_af",
|
||||
"is_default": True,
|
||||
},
|
||||
{
|
||||
"name": "Keep Warm",
|
||||
"description": "Low-heat holding stage to keep food warm.",
|
||||
"translation_key": "phase_desc.keep_warm",
|
||||
"is_default": True,
|
||||
},
|
||||
],
|
||||
# A pump run is one phase; without an entry it fell back to the union of every
|
||||
# list above (washing phases included).
|
||||
DEVICE_TYPE_PUMP: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -326,6 +398,9 @@ def merge_phase_catalog(device_type: str, custom_phases: list[PhaseItem] | None)
|
||||
"name": normalized_name,
|
||||
"description": str(item.get("description", "")).strip(),
|
||||
"is_default": False,
|
||||
# An edited built-in: deleting it restores the built-in, so the
|
||||
# panel offers "Reset" rather than "Delete" on it.
|
||||
"is_override": True,
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -342,6 +417,9 @@ def merge_phase_catalog(device_type: str, custom_phases: list[PhaseItem] | None)
|
||||
# New phase: guard against universal overrides leaking into unrelated catalogs.
|
||||
# For legacy items with no device_type, first try matching against the active
|
||||
# catalog device_type before discarding, so legacy overrides are preserved.
|
||||
# Only an override is guarded (no id, or a built-in's id): a phase the user
|
||||
# created has its own id and stays, even when a built-in list added later
|
||||
# shares its name (the air fryer's "Pause", 0.5.8).
|
||||
if not item_device_type and normalized_name.casefold() in all_builtin_names:
|
||||
active_dt_key = (str(device_type or "").strip().casefold(), normalized_name.casefold())
|
||||
if active_dt_key in builtin_by_name:
|
||||
@@ -350,7 +428,9 @@ def merge_phase_catalog(device_type: str, custom_phases: list[PhaseItem] | None)
|
||||
if new_desc:
|
||||
merged[idx]["description"] = new_desc
|
||||
merged[idx]["is_default"] = False
|
||||
continue
|
||||
continue
|
||||
if not phase_id or get_builtin_phase_by_id(phase_id) is not None:
|
||||
continue
|
||||
|
||||
# Deduplicate before appending.
|
||||
if phase_id and phase_id in seen_ids:
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
||||
# Copyright (C) 2026 Lukas Bandura
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Phase-aware profile matching and phase-resolved ETA (Phase 0 prototype).
|
||||
|
||||
Given a cycle segmented into phases (:mod:`phase_segmenter`), this module:
|
||||
|
||||
* builds a per-profile :class:`PhaseProfile` (per-role duration/energy priors)
|
||||
from the profile's member cycles;
|
||||
* scores candidate profiles by **per-role** duration + energy agreement, so two
|
||||
profiles that differ mainly in heating length (i.e. temperature) are separated
|
||||
by a large per-phase signal instead of a small whole-cycle-correlation delta;
|
||||
* projects **time-remaining** as a per-role budget (Σ expected_role_total −
|
||||
consumed_role), which personalises the ETA to the matched variant.
|
||||
|
||||
The matcher handles **partial** (observed-so-far) cycles for progressive
|
||||
narrowing: completed roles are compared fully; the open current role is scored
|
||||
one-sided (a candidate is only penalised if the observed duration already
|
||||
*exceeds* its expected total), so the correct larger-heating variant is not
|
||||
prematurely ruled out while heating is still in progress.
|
||||
|
||||
Constraint: NumPy only, no Home Assistant imports. Pure and executor-safe;
|
||||
consumed live (behind the default-off ``enable_phase_matching`` per-device gate)
|
||||
by ``ProfileStore.phase_remaining`` and the progress blend. See
|
||||
`docs/superpowers/specs/2026-07-17-phase-segmented-matching-design.md`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from .phase_segmenter import (
|
||||
ROLE_HEATING,
|
||||
ROLE_IDLE,
|
||||
ROLE_SPIN,
|
||||
ROLE_WASH,
|
||||
PhaseSegment,
|
||||
)
|
||||
|
||||
# Per-role weight in the match score. Heating dominates because it is the
|
||||
# temperature discriminator; the wash middle is load-variable; spin/idle are
|
||||
# weak/noisy signals. Overridable via the match config.
|
||||
_ROLE_WEIGHTS: dict[str, float] = {
|
||||
ROLE_HEATING: 0.50,
|
||||
ROLE_WASH: 0.25,
|
||||
ROLE_SPIN: 0.15,
|
||||
ROLE_IDLE: 0.10,
|
||||
}
|
||||
_DUR_SCALE = 0.35 # log-ratio agreement scale for per-role duration
|
||||
_EN_SCALE = 0.40 # ... and per-role energy
|
||||
# Agreement score credited when a role is present on one side but absent on the
|
||||
# other (structural mismatch, e.g. a heating vs no-heating cycle). 0.0 = full
|
||||
# penalty (structural difference is a strong, wanted discriminator); raise toward
|
||||
# 1.0 to soften. Config-overridable via ``occ_penalty``.
|
||||
_OCC_PENALTY = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleStat:
|
||||
"""Aggregated per-role prior across a profile's member cycles."""
|
||||
|
||||
dur_mean: float
|
||||
dur_std: float
|
||||
dur_p50: float
|
||||
en_mean: float
|
||||
occurrence: float # fraction of member cycles that contain this role
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseProfile:
|
||||
"""Per-profile phase model: per-role priors + total-duration prior."""
|
||||
|
||||
name: str
|
||||
roles: dict[str, RoleStat]
|
||||
total_dur_mean: float
|
||||
total_dur_std: float
|
||||
n_cycles: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseMatchResult:
|
||||
name: str
|
||||
score: float
|
||||
|
||||
|
||||
def phase_profile_to_dict(profile: PhaseProfile) -> dict:
|
||||
"""Serialize a :class:`PhaseProfile` for storage (JSON-safe)."""
|
||||
return {
|
||||
"name": profile.name,
|
||||
"n_cycles": profile.n_cycles,
|
||||
"total_dur_mean": profile.total_dur_mean,
|
||||
"total_dur_std": profile.total_dur_std,
|
||||
"roles": {role: asdict(stat) for role, stat in profile.roles.items()},
|
||||
}
|
||||
|
||||
|
||||
def phase_profile_from_dict(data: dict | None) -> PhaseProfile | None:
|
||||
"""Rebuild a :class:`PhaseProfile` from stored form. Never raises."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
try:
|
||||
roles = {
|
||||
str(role): RoleStat(
|
||||
dur_mean=float(s.get("dur_mean", 0.0)),
|
||||
dur_std=float(s.get("dur_std", 0.0)),
|
||||
dur_p50=float(s.get("dur_p50", 0.0)),
|
||||
en_mean=float(s.get("en_mean", 0.0)),
|
||||
occurrence=float(s.get("occurrence", 0.0)),
|
||||
)
|
||||
for role, s in (data.get("roles") or {}).items()
|
||||
if isinstance(s, dict)
|
||||
}
|
||||
if not roles:
|
||||
return None
|
||||
return PhaseProfile(
|
||||
name=str(data.get("name", "")),
|
||||
roles=roles,
|
||||
total_dur_mean=float(data.get("total_dur_mean", 0.0)),
|
||||
total_dur_std=float(data.get("total_dur_std", 0.0)),
|
||||
n_cycles=int(data.get("n_cycles", 0)),
|
||||
)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _agree(observed: float, expected: float, scale: float) -> float:
|
||||
"""Log-ratio agreement in (0, 1]; 1.0 when equal, sharper for small scale."""
|
||||
if observed <= 0.0 or expected <= 0.0:
|
||||
# Both ~zero → perfect agreement; one zero → no agreement.
|
||||
return 1.0 if observed <= 0.0 and expected <= 0.0 else 0.0
|
||||
scale = scale if scale > 1e-9 else 1e-9 # avoid div-by-zero on a 0 config scale
|
||||
return 1.0 / (1.0 + abs(math.log(observed / expected)) / scale)
|
||||
|
||||
|
||||
def _role_totals(segments: list[PhaseSegment]) -> dict[str, dict[str, float]]:
|
||||
"""Sum duration + energy per role across a cycle's segments."""
|
||||
totals: dict[str, dict[str, float]] = {}
|
||||
for seg in segments:
|
||||
acc = totals.setdefault(seg.role, {"dur": 0.0, "en": 0.0})
|
||||
acc["dur"] += max(0.0, seg.duration_s)
|
||||
acc["en"] += max(0.0, seg.energy_wh)
|
||||
return totals
|
||||
|
||||
|
||||
def build_phase_profile(name: str, segmented_cycles: list[list[PhaseSegment]]) -> PhaseProfile | None:
|
||||
"""Aggregate a profile's member cycles into per-role priors. Never raises.
|
||||
|
||||
Returns ``None`` when no usable cycles are supplied.
|
||||
"""
|
||||
cycles = [c for c in segmented_cycles if c]
|
||||
if not cycles:
|
||||
return None
|
||||
n = len(cycles)
|
||||
per_role_durs: dict[str, list[float]] = {}
|
||||
per_role_ens: dict[str, list[float]] = {}
|
||||
role_count: dict[str, int] = {}
|
||||
totals_dur: list[float] = []
|
||||
for segs in cycles:
|
||||
totals = _role_totals(segs)
|
||||
totals_dur.append(sum(v["dur"] for v in totals.values()))
|
||||
for role, v in totals.items():
|
||||
per_role_durs.setdefault(role, []).append(v["dur"])
|
||||
per_role_ens.setdefault(role, []).append(v["en"])
|
||||
role_count[role] = role_count.get(role, 0) + 1
|
||||
|
||||
def _mean(xs: list[float]) -> float:
|
||||
return float(sum(xs) / len(xs)) if xs else 0.0
|
||||
|
||||
def _std(xs: list[float], m: float) -> float:
|
||||
return float((sum((x - m) ** 2 for x in xs) / len(xs)) ** 0.5) if xs else 0.0
|
||||
|
||||
def _p50(xs: list[float]) -> float:
|
||||
if not xs:
|
||||
return 0.0
|
||||
s = sorted(xs)
|
||||
mid = len(s) // 2
|
||||
return float(s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2.0)
|
||||
|
||||
roles: dict[str, RoleStat] = {}
|
||||
for role, durs in per_role_durs.items():
|
||||
m = _mean(durs)
|
||||
roles[role] = RoleStat(
|
||||
dur_mean=m, dur_std=_std(durs, m), dur_p50=_p50(durs),
|
||||
en_mean=_mean(per_role_ens[role]),
|
||||
occurrence=role_count[role] / n,
|
||||
)
|
||||
tm = _mean(totals_dur)
|
||||
return PhaseProfile(
|
||||
name=name, roles=roles,
|
||||
total_dur_mean=tm, total_dur_std=_std(totals_dur, tm), n_cycles=n,
|
||||
)
|
||||
|
||||
|
||||
def match_phase_profiles(
|
||||
observed: list[PhaseSegment],
|
||||
candidates: list[PhaseProfile],
|
||||
config: dict | None = None,
|
||||
) -> list[PhaseMatchResult]:
|
||||
"""Rank ``candidates`` for the ``observed`` (full or partial) cycle. Never raises.
|
||||
|
||||
Score = weighted mean over roles of ``sqrt(dur_agree * energy_agree)``, with a
|
||||
structural (occurrence-mismatch) penalty. The observed cycle's *open* role
|
||||
(partial cycle) is scored one-sided so a larger-heating candidate is not
|
||||
ruled out mid-heating.
|
||||
"""
|
||||
if not observed or not candidates:
|
||||
return []
|
||||
cfg = config or {}
|
||||
weights = {**_ROLE_WEIGHTS, **(cfg.get("role_weights") or {})}
|
||||
dur_scale = float(cfg.get("dur_scale", _DUR_SCALE))
|
||||
en_scale = float(cfg.get("en_scale", _EN_SCALE))
|
||||
occ_pen = float(cfg.get("occ_penalty", _OCC_PENALTY))
|
||||
|
||||
totals = _role_totals(observed)
|
||||
open_role = next((s.role for s in observed if s.open), None)
|
||||
is_partial = open_role is not None
|
||||
|
||||
results: list[PhaseMatchResult] = []
|
||||
for cand in candidates:
|
||||
all_roles = set(totals) | set(cand.roles)
|
||||
num = 0.0
|
||||
den = 0.0
|
||||
for role in all_roles:
|
||||
w = weights.get(role, 0.1)
|
||||
if w <= 0:
|
||||
continue
|
||||
obs = totals.get(role, {"dur": 0.0, "en": 0.0})
|
||||
stat = cand.roles.get(role)
|
||||
if stat is None:
|
||||
# Observed a role the candidate never exhibits: structural miss.
|
||||
if obs["dur"] > 0:
|
||||
num += w * occ_pen
|
||||
den += w
|
||||
continue
|
||||
if role not in totals:
|
||||
# Candidate expects a role not yet observed. Future phase on a
|
||||
# partial cycle → neutral (skip). On a completed cycle →
|
||||
# structural miss (candidate does a phase this cycle never had).
|
||||
if not is_partial:
|
||||
num += w * occ_pen
|
||||
den += w
|
||||
continue
|
||||
if role == open_role:
|
||||
# One-sided: only penalise if the in-progress duration/energy
|
||||
# already EXCEEDS what this candidate expects for the whole role
|
||||
# (a larger-budget candidate must not be ruled out mid-phase).
|
||||
# Energy is the cleanest temperature discriminator, so score it
|
||||
# one-sided too rather than dropping it (weaker narrowing).
|
||||
da = 1.0 if obs["dur"] <= stat.dur_mean else _agree(obs["dur"], stat.dur_mean, dur_scale)
|
||||
ea = 1.0 if obs["en"] <= stat.en_mean else _agree(obs["en"], stat.en_mean, en_scale)
|
||||
agree = math.sqrt(da * ea)
|
||||
else:
|
||||
da = _agree(obs["dur"], stat.dur_mean, dur_scale)
|
||||
ea = _agree(obs["en"], stat.en_mean, en_scale)
|
||||
agree = math.sqrt(da * ea)
|
||||
num += w * agree
|
||||
den += w
|
||||
score = (num / den) if den > 0 else 0.0
|
||||
results.append(PhaseMatchResult(name=cand.name, score=float(score)))
|
||||
|
||||
results.sort(key=lambda r: r.score, reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def phase_eta(
|
||||
observed: list[PhaseSegment],
|
||||
profile: PhaseProfile,
|
||||
) -> float | None:
|
||||
"""Project remaining seconds as a per-role budget. Never raises.
|
||||
|
||||
Classifies each profile role against the observed-so-far segments (design §8):
|
||||
|
||||
* **current** (the open, in-progress role): contribute
|
||||
``max(0, dur_mean − consumed)`` using the CONDITIONAL mean (the role is
|
||||
known present, so no occurrence discount);
|
||||
* **completed** (a role already observed but not the open one): contribute
|
||||
**0** — it is done, even if it ran shorter than the profile mean (fixing
|
||||
the "phantom remaining" over-count);
|
||||
* **future** (a profile role not yet observed): contribute the
|
||||
occurrence-weighted prior ``dur_mean × occurrence`` (we do not yet know
|
||||
whether it will occur).
|
||||
|
||||
Returns ``None`` when no phase priors are available (caller falls back to the
|
||||
current estimator).
|
||||
"""
|
||||
if profile is None or not profile.roles:
|
||||
return None
|
||||
consumed = _role_totals(observed) if observed else {}
|
||||
# The open (in-progress) role. If the caller did not mark one (e.g. a
|
||||
# completed trace), treat the last observed role as current so completed
|
||||
# roles still contribute 0 rather than a spurious prior.
|
||||
open_role = next((s.role for s in (observed or []) if s.open), None)
|
||||
if open_role is None and observed:
|
||||
open_role = observed[-1].role
|
||||
remaining = 0.0
|
||||
for role, stat in profile.roles.items():
|
||||
done = consumed.get(role, {}).get("dur", 0.0)
|
||||
if role == open_role:
|
||||
# current: conditional mean (occurrence -> 1, role is present)
|
||||
remaining += max(0.0, stat.dur_mean - done)
|
||||
elif role in consumed:
|
||||
# completed: nothing more to spend on it
|
||||
continue
|
||||
else:
|
||||
# future: unconditional prior (may or may not occur)
|
||||
remaining += stat.dur_mean * max(0.0, min(1.0, stat.occurrence))
|
||||
return float(max(0.0, remaining))
|
||||
@@ -1,317 +0,0 @@
|
||||
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
||||
# Copyright (C) 2026 Lukas Bandura
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Unsupervised power-trace phase segmentation (Phase 0 prototype).
|
||||
|
||||
Turns a cycle's power trace into an ordered list of :class:`PhaseSegment`
|
||||
(role, start/end offset, duration, energy, mean/peak power) via a hysteresis
|
||||
regime classifier (idle / active / high) + minimum-run merge + role assignment
|
||||
driven by a per-device-type :class:`PhaseModel`.
|
||||
|
||||
Constraint: NumPy only, no Home Assistant imports. Pure and executor-safe;
|
||||
``segment_cycle`` never raises - it returns ``[]`` on malformed input.
|
||||
|
||||
Consumed live (behind the default-off ``enable_phase_matching`` per-device gate)
|
||||
by `profile_store.ProfileStore` (phase-profile cache + `phase_remaining`) and,
|
||||
offline, by the side-by-side harness (`devtools/eta_phase_eval.py`). See
|
||||
`docs/superpowers/specs/2026-07-17-phase-segmented-matching-design.md`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Mapping, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .const import CONF_ENABLE_PHASE_MATCHING
|
||||
from .signal_processing import energy_gap_threshold_s, integrate_wh
|
||||
|
||||
# Regime tokens (internal, not user-facing).
|
||||
_IDLE = 0
|
||||
_ACTIVE = 1
|
||||
_HIGH = 2
|
||||
|
||||
# Role tokens. Physically detectable roles; mapping to display phase names
|
||||
# (phase_catalog.DEFAULT_PHASES_BY_DEVICE) is a later (Phase 6) concern.
|
||||
ROLE_HEATING = "heating"
|
||||
ROLE_WASH = "wash"
|
||||
ROLE_SPIN = "spin"
|
||||
ROLE_IDLE = "idle"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseSegment:
|
||||
"""One contiguous phase of a cycle."""
|
||||
|
||||
role: str
|
||||
t_start: float # seconds from cycle start
|
||||
t_end: float
|
||||
duration_s: float
|
||||
energy_wh: float
|
||||
mean_w: float
|
||||
peak_w: float
|
||||
open: bool = False # True when this is the final segment of a partial cycle
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseModel:
|
||||
"""Per-device-type segmentation parameters and role vocabulary.
|
||||
|
||||
``high`` threshold = ``max(high_w_floor, high_frac * robust_peak)`` where the
|
||||
robust peak is the 95th percentile of the trace, so a heating element is
|
||||
detected relative to the device's own scale without a single spike inflating
|
||||
it. ``active_w`` separates motor/fill activity from idle. A terminal
|
||||
``active`` block whose mean power >= ``spin_min_w`` and which begins in the
|
||||
last ``spin_tail_frac`` of the (completed) cycle is labelled ``spin``.
|
||||
"""
|
||||
|
||||
device_type: str
|
||||
high_w_floor: float
|
||||
high_frac: float
|
||||
active_w: float
|
||||
spin_min_w: float
|
||||
spin_tail_frac: float
|
||||
min_run_s: float
|
||||
roles: tuple[str, ...] = field(default=(ROLE_HEATING, ROLE_WASH, ROLE_SPIN, ROLE_IDLE))
|
||||
|
||||
|
||||
# Only washing_machine is intended for live rollout first; dishwasher and
|
||||
# washer-dryer models exist for OFFLINE evaluation in the Phase-0 harness.
|
||||
_MODELS: dict[str, PhaseModel] = {
|
||||
"washing_machine": PhaseModel(
|
||||
device_type="washing_machine",
|
||||
high_w_floor=800.0, high_frac=0.5,
|
||||
active_w=30.0, spin_min_w=200.0, spin_tail_frac=0.30,
|
||||
min_run_s=90.0,
|
||||
),
|
||||
"washer_dryer": PhaseModel(
|
||||
device_type="washer_dryer",
|
||||
high_w_floor=800.0, high_frac=0.5,
|
||||
active_w=30.0, spin_min_w=200.0, spin_tail_frac=0.30,
|
||||
min_run_s=90.0,
|
||||
),
|
||||
# Dishwasher: heating element is the dominant HIGH regime; the long passive
|
||||
# dry tail lands in IDLE. Deferred for LIVE rollout (delicate end-of-cycle
|
||||
# logic) - present here only for offline measurement.
|
||||
"dishwasher": PhaseModel(
|
||||
device_type="dishwasher",
|
||||
high_w_floor=800.0, high_frac=0.5,
|
||||
active_w=25.0, spin_min_w=250.0, spin_tail_frac=0.25,
|
||||
min_run_s=120.0,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Device types phase matching is rolled out for LIVE (validated by the Phase-0
|
||||
# ETA gate). Dishwasher has a model for OFFLINE evaluation only (its ETA gate was
|
||||
# a no-go and its end-of-cycle logic is delicate), so it is deliberately excluded
|
||||
# here even though ``phase_model_for("dishwasher")`` returns a model.
|
||||
LIVE_PHASE_DEVICE_TYPES: tuple[str, ...] = ("washing_machine", "washer_dryer")
|
||||
|
||||
|
||||
def phase_model_for(device_type: str | None) -> PhaseModel | None:
|
||||
"""Return the :class:`PhaseModel` for a device type, or ``None`` to fall back.
|
||||
|
||||
``None`` means the device type has no validated phase model and must use the
|
||||
existing whole-cycle pipeline (zero-regression fallback). Returns a model for
|
||||
every type with one defined (incl. dishwasher, for the offline harness).
|
||||
"""
|
||||
if not device_type:
|
||||
return None
|
||||
return _MODELS.get(str(device_type))
|
||||
|
||||
|
||||
def phase_matching_live_supported(device_type: str | None) -> bool:
|
||||
"""True when phase matching is enabled for LIVE use on this device type.
|
||||
|
||||
Stricter than ``phase_model_for`` - only the Phase-0-validated rollout types
|
||||
(washing machine, washer-dryer). Used to gate phase-profile caching and the
|
||||
live ETA path; the offline harness uses ``phase_model_for`` directly.
|
||||
"""
|
||||
return (
|
||||
device_type in LIVE_PHASE_DEVICE_TYPES
|
||||
and phase_model_for(device_type) is not None
|
||||
)
|
||||
|
||||
|
||||
def phase_matching_enabled(options: "Mapping[str, object] | None", device_type: str | None) -> bool:
|
||||
"""True when the user opted in AND the device type is live-supported.
|
||||
|
||||
The opt-in gate for the live phase-resolved ETA blend, mirroring
|
||||
``ml.engine.ml_models_enabled``. Both conditions are required: the per-device
|
||||
``enable_phase_matching`` option and a validated live phase model.
|
||||
"""
|
||||
if not options:
|
||||
return False
|
||||
if not bool(options.get(CONF_ENABLE_PHASE_MATCHING, False)):
|
||||
return False
|
||||
return phase_matching_live_supported(device_type)
|
||||
|
||||
|
||||
def _classify(power: np.ndarray, model: PhaseModel) -> tuple[np.ndarray, float]:
|
||||
"""Per-sample regime tokens + the robust peak used for the HIGH threshold."""
|
||||
robust_peak = float(np.percentile(power, 95)) if power.size else 0.0
|
||||
high_thr = max(model.high_w_floor, model.high_frac * robust_peak)
|
||||
reg = np.full(power.shape, _IDLE, dtype=int)
|
||||
reg[power >= model.active_w] = _ACTIVE
|
||||
reg[power >= high_thr] = _HIGH
|
||||
return reg, robust_peak
|
||||
|
||||
|
||||
def _runs(reg: np.ndarray) -> list[list[int]]:
|
||||
"""Contiguous same-regime runs as ``[regime, start_idx, end_idx]`` (inclusive).
|
||||
|
||||
Replaces the nested while-loop (O(n) Python iterations) with np.diff +
|
||||
np.flatnonzero (C-level), then a short list comprehension over only the
|
||||
run-boundary indices (~10-30 for a real cycle vs 1 400+ element visits).
|
||||
"""
|
||||
if len(reg) == 0:
|
||||
return []
|
||||
changes = np.flatnonzero(np.diff(reg)) # indices where value changes
|
||||
starts = np.empty(len(changes) + 1, dtype=np.intp)
|
||||
ends = np.empty(len(changes) + 1, dtype=np.intp)
|
||||
starts[0] = 0
|
||||
starts[1:] = changes + 1
|
||||
ends[:-1] = changes
|
||||
ends[-1] = len(reg) - 1
|
||||
return [[int(reg[s]), int(s), int(e)] for s, e in zip(starts, ends)]
|
||||
|
||||
|
||||
def _merge_short(runs: list[list[int]], t: np.ndarray, min_run_s: float) -> list[list[int]]:
|
||||
"""Absorb runs shorter than ``min_run_s`` into their neighbour.
|
||||
|
||||
A short leading run merges forward; every other short run merges into the
|
||||
preceding (already-accepted) run. This prevents brief motor spikes during
|
||||
tumbling from fragmenting a wash phase, and brief dips from splitting a
|
||||
heating block, without erasing genuine phases.
|
||||
"""
|
||||
if not runs:
|
||||
return runs
|
||||
merged: list[list[int]] = []
|
||||
for reg, a, b in runs:
|
||||
dur = float(t[b] - t[a])
|
||||
if merged and dur < min_run_s:
|
||||
merged[-1][2] = b # extend previous run to absorb this short one
|
||||
else:
|
||||
merged.append([reg, a, b])
|
||||
# A short LEADING run has no preceding run to absorb it, so it would survive
|
||||
# as its own segment (e.g. a startup/inrush HIGH spike becoming a phantom
|
||||
# heating block that pollutes the temperature prior). Fold it FORWARD into
|
||||
# the following run, adopting that run's regime.
|
||||
while len(merged) > 1 and float(t[merged[0][2]] - t[merged[0][1]]) < min_run_s:
|
||||
merged[1][1] = merged[0][1] # extend 2nd run's start back over the lead
|
||||
merged.pop(0)
|
||||
return merged
|
||||
|
||||
|
||||
def segment_cycle(
|
||||
timestamps: "np.ndarray | list[float]",
|
||||
power: "np.ndarray | list[float]",
|
||||
model: PhaseModel,
|
||||
*,
|
||||
partial: bool = False,
|
||||
) -> list[PhaseSegment]:
|
||||
"""Segment a power trace into ordered phases. Never raises.
|
||||
|
||||
Args:
|
||||
timestamps: seconds from cycle start (ascending).
|
||||
power: watts, same length as ``timestamps``.
|
||||
model: the device-type :class:`PhaseModel`.
|
||||
partial: when True the trace is an observed-so-far prefix of a running
|
||||
cycle; the final segment is marked ``open=True``.
|
||||
|
||||
Returns:
|
||||
List of :class:`PhaseSegment` in time order, or ``[]`` when the input is
|
||||
too short/degenerate to segment. Any internal error also yields ``[]``.
|
||||
"""
|
||||
try:
|
||||
return _segment_impl(timestamps, power, model, partial=partial)
|
||||
except Exception: # noqa: BLE001 - segmentation must never break a live cycle
|
||||
return []
|
||||
|
||||
|
||||
def _segment_impl(
|
||||
timestamps: "np.ndarray | list[float]",
|
||||
power: "np.ndarray | list[float]",
|
||||
model: PhaseModel,
|
||||
*,
|
||||
partial: bool = False,
|
||||
) -> list[PhaseSegment]:
|
||||
t = np.asarray(timestamps, dtype=float)
|
||||
w = np.asarray(power, dtype=float)
|
||||
if t.size < 4 or w.size != t.size:
|
||||
return []
|
||||
if not (np.all(np.isfinite(t)) and np.all(np.isfinite(w))):
|
||||
finite = np.isfinite(t) & np.isfinite(w)
|
||||
t, w = t[finite], w[finite]
|
||||
if t.size < 4:
|
||||
return []
|
||||
# Enforce ascending time.
|
||||
order = np.argsort(t, kind="stable")
|
||||
t, w = t[order], w[order]
|
||||
|
||||
reg, _peak = _classify(w, model)
|
||||
runs = _merge_short(_runs(reg), t, model.min_run_s)
|
||||
if not runs:
|
||||
return []
|
||||
|
||||
gap_s = energy_gap_threshold_s(t)
|
||||
total = float(t[-1] - t[0])
|
||||
spin_zone_start = t[0] + (1.0 - model.spin_tail_frac) * total if total > 0 else t[-1]
|
||||
|
||||
# First pass: build raw segments with stats.
|
||||
raw: list[dict] = []
|
||||
for reg_tok, a, b in runs:
|
||||
seg_t = t[a:b + 1]
|
||||
seg_w = w[a:b + 1]
|
||||
dur = float(seg_t[-1] - seg_t[0]) if seg_t.size > 1 else 0.0
|
||||
energy = integrate_wh(seg_t, seg_w, max_gap_s=gap_s) if seg_t.size > 1 else 0.0
|
||||
raw.append({
|
||||
"reg": reg_tok, "a": a, "b": b,
|
||||
"t0": float(seg_t[0]), "t1": float(seg_t[-1]),
|
||||
"dur": dur, "energy": float(energy),
|
||||
"mean": float(np.mean(seg_w)), "peak": float(np.max(seg_w)),
|
||||
})
|
||||
|
||||
# Determine which ACTIVE run (if any) is the spin: the last non-idle run,
|
||||
# elevated and starting in the terminal zone. Only on a completed cycle -
|
||||
# a partial cycle can't know its terminal segment yet.
|
||||
spin_idx: Optional[int] = None
|
||||
if not partial:
|
||||
for idx in range(len(raw) - 1, -1, -1):
|
||||
seg = raw[idx]
|
||||
if seg["reg"] == _IDLE:
|
||||
continue
|
||||
if (seg["reg"] == _ACTIVE and seg["mean"] >= model.spin_min_w
|
||||
and seg["t0"] >= spin_zone_start):
|
||||
spin_idx = idx
|
||||
break # only inspect the last non-idle run
|
||||
|
||||
out: list[PhaseSegment] = []
|
||||
for idx, seg in enumerate(raw):
|
||||
if seg["reg"] == _HIGH:
|
||||
role = ROLE_HEATING
|
||||
elif seg["reg"] == _IDLE:
|
||||
role = ROLE_IDLE
|
||||
else: # _ACTIVE
|
||||
role = ROLE_SPIN if idx == spin_idx else ROLE_WASH
|
||||
out.append(PhaseSegment(
|
||||
role=role, t_start=seg["t0"], t_end=seg["t1"],
|
||||
duration_s=seg["dur"], energy_wh=seg["energy"],
|
||||
mean_w=seg["mean"], peak_w=seg["peak"],
|
||||
open=(partial and idx == len(raw) - 1),
|
||||
))
|
||||
return out
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@
|
||||
|
||||
Single source of truth for the cycle-progress math. Both the live integration
|
||||
(``manager.WashDataManager`` - thin wrappers over these functions) and the
|
||||
Playground's headless simulation (``playground.SimRunner``) call the SAME
|
||||
Playground's headless replay (``playground.py``) call the SAME
|
||||
functions here, so the panel's what-if replay is byte-for-byte what the running
|
||||
integration computes. Nothing here touches Home Assistant; every function is
|
||||
pure given a ``ProfileStore`` (read-only), the entry options mapping, and a
|
||||
@@ -34,6 +34,7 @@ import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from operator import le as _le
|
||||
from typing import Any, cast
|
||||
|
||||
import numpy as np
|
||||
@@ -41,19 +42,23 @@ import numpy as np
|
||||
from .const import (
|
||||
CYCLE_OVERRUN_ANOMALY_RATIO,
|
||||
DEVICE_SMOOTHING_THRESHOLDS,
|
||||
ML_PROGRESS_BLEND_WEIGHT,
|
||||
STATE_ENDING,
|
||||
STATE_PAUSED,
|
||||
STATE_RUNNING,
|
||||
)
|
||||
from .profile_store import decompress_power_data
|
||||
from .profile_store import _envelope_y, decompress_power_data
|
||||
from .time_utils import power_data_to_offsets
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Minimum progress before an energy projection is trusted (mirrors the manager
|
||||
# class constant of the same purpose).
|
||||
PROJECTION_MIN_PROGRESS = 3.0
|
||||
# Minimum progress before an energy projection is shown. 10, not 3 (audit
|
||||
# PROGRESS-04): at 3% both divisors were off by 77-101% MAPE over 670 LOO folds
|
||||
# (devtools/energy_projection_eval.py), at 10% the energy-share divisor is 50%.
|
||||
PROJECTION_MIN_PROGRESS = 10.0
|
||||
|
||||
# Floor on the matched profile's cumulative-energy share used as the projection
|
||||
# divisor: below it the curve's start is noise and the division explodes.
|
||||
PROJECTION_MIN_ENERGY_FRACTION = 0.05
|
||||
|
||||
# The progress EMA weights below are per *estimate*, and were chosen against the
|
||||
# manager's 5 s estimate throttle. See :func:`_dt_scaled_alpha`.
|
||||
@@ -62,6 +67,9 @@ SMOOTHING_NOMINAL_DT_S = 5.0
|
||||
# Cache type for profile_end_expectation: (profile_name, base_expectation_dict).
|
||||
EndExpCache = tuple[str, dict[str, float]] | None
|
||||
|
||||
# How many of a profile's most recent traces its end expectation is taken from.
|
||||
_END_EXPECTATION_CYCLES = 20
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgressResult:
|
||||
@@ -93,14 +101,24 @@ def profile_end_expectation(
|
||||
else:
|
||||
from .ml.feature_extraction import profile_expectation
|
||||
|
||||
# The 20 most recent non-empty traces, oldest first - walked newest-first
|
||||
# and stopped there, so a long history is not decompressed just to be
|
||||
# thrown away (49-248 ms on the largest corpus profiles, on the event
|
||||
# loop, once per cycle start).
|
||||
cycles = store.get_past_cycles() or []
|
||||
if not isinstance(cycles, (list, tuple)):
|
||||
cycles = list(cycles)
|
||||
points_list: list[list[tuple[float, float]]] = []
|
||||
for cycle in store.get_past_cycles():
|
||||
for cycle in reversed(cycles):
|
||||
if cycle.get("profile_name") != profile_name:
|
||||
continue
|
||||
pts = decompress_power_data(cycle)
|
||||
if pts:
|
||||
points_list.append(pts)
|
||||
base = profile_expectation(points_list[-20:])
|
||||
if len(points_list) >= _END_EXPECTATION_CYCLES:
|
||||
break
|
||||
points_list.reverse()
|
||||
base = profile_expectation(points_list)
|
||||
if base is None:
|
||||
return None, cache
|
||||
cache = (profile_name, dict(base))
|
||||
@@ -113,61 +131,6 @@ def profile_end_expectation(
|
||||
EndExpFn = Any # Callable[[str, float], dict[str, float] | None]
|
||||
|
||||
|
||||
def ml_progress_percent(
|
||||
store: Any,
|
||||
options: Any,
|
||||
matched_duration: float,
|
||||
trace: list[tuple[datetime, float]],
|
||||
profile_name: str,
|
||||
end_expectation_fn: EndExpFn,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> float | None:
|
||||
"""ML completion-fraction estimate (0-100) for the running cycle, or None.
|
||||
|
||||
Uses the on-device ``remaining_time`` regressor; gated on the ML opt-in and
|
||||
inert until training promotes a regressor. ``end_expectation_fn(name, dur)``
|
||||
supplies the profile expectation (the manager passes its cached
|
||||
``_profile_end_expectation``; the Playground wraps :func:`profile_end_expectation`)
|
||||
so history is only decompressed after the cheap gates pass. Never raises.
|
||||
"""
|
||||
logger = logger or _LOGGER
|
||||
try:
|
||||
from .ml.engine import ml_models_enabled, resolve_regressor
|
||||
|
||||
if not ml_models_enabled(options):
|
||||
return None
|
||||
if (
|
||||
not profile_name
|
||||
or profile_name in ("off", "detecting...", "restored...")
|
||||
or profile_name not in store.get_profiles()
|
||||
):
|
||||
return None
|
||||
predict_fn, _src = resolve_regressor("remaining_time", store)
|
||||
if predict_fn is None:
|
||||
return None
|
||||
if not trace or len(trace) < 4:
|
||||
return None
|
||||
expectation = end_expectation_fn(
|
||||
profile_name, float(matched_duration or 0.0)
|
||||
)
|
||||
if expectation is None:
|
||||
return None
|
||||
t0 = trace[0][0]
|
||||
pts = [(float((t - t0).total_seconds()), float(p)) for t, p in trace]
|
||||
from .ml.feature_extraction import progress_features
|
||||
|
||||
feat = progress_features(pts, expectation)
|
||||
if feat is None:
|
||||
return None
|
||||
frac = float(predict_fn(feat))
|
||||
if not math.isfinite(frac):
|
||||
return None
|
||||
return float(min(max(frac, 0.0), 0.99)) * 100.0
|
||||
except Exception as err: # noqa: BLE001 - ML must never break estimates
|
||||
logger.debug("ML progress estimate skipped: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
def ml_energy_total(
|
||||
store: Any,
|
||||
options: Any,
|
||||
@@ -178,8 +141,12 @@ def ml_energy_total(
|
||||
logger: logging.Logger | None = None,
|
||||
) -> float | None:
|
||||
"""Predicted total cycle energy (Wh) from the on-device ``total_energy``
|
||||
regressor, or None. ``end_expectation_fn`` as in :func:`ml_progress_percent`.
|
||||
Never raises.
|
||||
regressor, or None. Never raises.
|
||||
|
||||
``end_expectation_fn(name, dur)`` supplies the profile expectation (the
|
||||
manager passes its cached ``_profile_end_expectation``; the Playground wraps
|
||||
:func:`profile_end_expectation`) so history is only decompressed after the
|
||||
cheap gates pass.
|
||||
"""
|
||||
logger = logger or _LOGGER
|
||||
try:
|
||||
@@ -225,37 +192,13 @@ def ml_energy_total(
|
||||
return None
|
||||
|
||||
|
||||
def estimate_phase_progress(
|
||||
store: Any,
|
||||
current_power_data: list[tuple[datetime, float]] | list[tuple[str, float]],
|
||||
current_duration: float,
|
||||
profile_name: str,
|
||||
logger: logging.Logger | None = None,
|
||||
quiet_threshold_w: float = 0.0,
|
||||
) -> tuple[float, float] | None:
|
||||
"""Estimate cycle progress by analyzing which phase we're in.
|
||||
_PHASE_ENVELOPE_CACHE: dict[tuple[Any, int, Any], tuple[Any, tuple[dict[str, Any], Any, float]]] = {}
|
||||
|
||||
Uses cached statistical envelope built from ALL cycles labeled with this
|
||||
profile, normalized by TIME to account for different sampling rates. Returns
|
||||
``(progress_pct, variance_watts)`` or ``None`` if estimation fails.
|
||||
|
||||
``quiet_threshold_w`` is the detector's own off-noise floor
|
||||
(``CycleDetectorConfig.stop_threshold_w``, itself derived from the configured
|
||||
minimum power). A window that never rises above it is *not* the appliance
|
||||
doing something, so it carries no phase information and the scan declines
|
||||
rather than guessing (#386); a dead-flat window declines for the same reason
|
||||
at any power level. The default 0.0 leaves only the flatness rule for callers
|
||||
that do not know the floor.
|
||||
"""
|
||||
logger = logger or _LOGGER
|
||||
# Get cached envelope (fast - already computed and stored)
|
||||
envelope = store.get_envelope(profile_name)
|
||||
|
||||
if envelope is None:
|
||||
logger.debug("No envelope cached for profile %s", profile_name)
|
||||
return None
|
||||
|
||||
# Convert cached lists back to numpy arrays
|
||||
def _parse_phase_envelope(
|
||||
envelope: dict[str, Any], profile_name: str, logger: logging.Logger
|
||||
) -> tuple[dict[str, Any], Any, float] | None:
|
||||
"""``(arrays, time_grid, target_duration)`` of a stored envelope, read-only."""
|
||||
try:
|
||||
env_min = envelope.get("min", [])
|
||||
env_max = envelope.get("max", [])
|
||||
@@ -287,9 +230,124 @@ def estimate_phase_progress(
|
||||
envelope.get("time_grid", []), dtype=float
|
||||
)
|
||||
target_duration = float(envelope.get("target_duration", 0.0) or 0.0)
|
||||
except (KeyError, ValueError, TypeError, IndexError) as e:
|
||||
except (KeyError, ValueError, TypeError, IndexError, OverflowError) as e:
|
||||
logger.warning("Invalid envelope format for %s: %s", profile_name, e)
|
||||
return None
|
||||
for _arr in (*envelope_arrays.values(), time_grid):
|
||||
_arr.setflags(write=False)
|
||||
return envelope_arrays, time_grid, target_duration
|
||||
|
||||
|
||||
def _window_values(
|
||||
power_data: Any, window_s: float
|
||||
) -> np.ndarray[Any, np.dtype[np.float64]] | None:
|
||||
"""Powers of the trailing ``window_s`` of a ``(datetime, power)`` trace.
|
||||
|
||||
Exactly what ``power_data_to_offsets`` + the ``offsets >= last - window``
|
||||
mask in :func:`estimate_phase_progress` select (same anchor, same 0.1 s
|
||||
rounding, same skipped rows), without converting the whole trace. Only the
|
||||
``datetime`` format the detector hands out takes this path, and only when
|
||||
its timestamps never go backwards: then everything before the first row
|
||||
that falls out of the window is out of it too. Anything else returns None
|
||||
and the caller converts the whole trace as before.
|
||||
"""
|
||||
try:
|
||||
if not isinstance(power_data, (list, tuple)) or not power_data:
|
||||
return None
|
||||
first = power_data[0]
|
||||
if not (
|
||||
isinstance(first, (list, tuple))
|
||||
and len(first) >= 2
|
||||
and isinstance(first[0], datetime)
|
||||
):
|
||||
return None
|
||||
stamps = [row[0] for row in power_data]
|
||||
if not all(map(_le, stamps, stamps[1:])):
|
||||
return None
|
||||
|
||||
def _row(row: Any) -> tuple[datetime, float] | None:
|
||||
# The same rows `power_data_to_offsets` keeps (and the same order of
|
||||
# checks, so the same one anchors the offsets).
|
||||
try:
|
||||
ts = row[0]
|
||||
if not isinstance(ts, datetime):
|
||||
return None
|
||||
return ts, float(row[1])
|
||||
except (TypeError, ValueError, AttributeError, IndexError, OverflowError):
|
||||
return None
|
||||
|
||||
anchor: float | None = None
|
||||
for row in power_data:
|
||||
kept = _row(row)
|
||||
if kept is not None:
|
||||
anchor = kept[0].timestamp()
|
||||
break
|
||||
if anchor is None:
|
||||
return np.array([], dtype=float)
|
||||
window_start: float | None = None
|
||||
tail: list[float] = []
|
||||
for row in reversed(power_data):
|
||||
kept = _row(row)
|
||||
if kept is None:
|
||||
continue
|
||||
offset = round(kept[0].timestamp() - anchor, 1)
|
||||
if window_start is None:
|
||||
window_start = max(0, offset - window_s)
|
||||
if offset < window_start:
|
||||
break
|
||||
tail.append(kept[1])
|
||||
tail.reverse()
|
||||
return np.array(tail)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
return None
|
||||
|
||||
|
||||
def estimate_phase_progress(
|
||||
store: Any,
|
||||
current_power_data: list[tuple[datetime, float]] | list[tuple[str, float]],
|
||||
current_duration: float,
|
||||
profile_name: str,
|
||||
logger: logging.Logger | None = None,
|
||||
quiet_threshold_w: float = 0.0,
|
||||
) -> tuple[float, float] | None:
|
||||
"""Estimate cycle progress by analyzing which phase we're in.
|
||||
|
||||
Uses cached statistical envelope built from ALL cycles labeled with this
|
||||
profile, normalized by TIME to account for different sampling rates. Returns
|
||||
``(progress_pct, variance_watts)`` or ``None`` if estimation fails.
|
||||
|
||||
``quiet_threshold_w`` is the detector's own off-noise floor
|
||||
(``CycleDetectorConfig.stop_threshold_w``, itself derived from the configured
|
||||
minimum power). A window that never rises above it is *not* the appliance
|
||||
doing something, so it carries no phase information and the scan declines
|
||||
rather than guessing (#386); a dead-flat window declines for the same reason
|
||||
at any power level. The default 0.0 leaves only the flatness rule for callers
|
||||
that do not know the floor.
|
||||
"""
|
||||
logger = logger or _LOGGER
|
||||
# Get cached envelope (fast - already computed and stored)
|
||||
envelope = store.get_envelope(profile_name)
|
||||
|
||||
if envelope is None:
|
||||
logger.debug("No envelope cached for profile %s", profile_name)
|
||||
return None
|
||||
|
||||
# Parse the stored lists into arrays once per envelope build, not on every
|
||||
# 5 s estimate (audit PERF-06: ~20% of a 17 ms call, on the event loop).
|
||||
# Keyed on the envelope object and its `updated` stamp; arrays are read-only.
|
||||
_key = (profile_name, id(envelope), envelope.get("updated"))
|
||||
_hit = _PHASE_ENVELOPE_CACHE.get(_key)
|
||||
if _hit is not None and _hit[0] is envelope:
|
||||
_parsed = _hit[1]
|
||||
else:
|
||||
_parsed = _parse_phase_envelope(envelope, profile_name, logger)
|
||||
if _parsed is None:
|
||||
return None
|
||||
if len(_PHASE_ENVELOPE_CACHE) > 32:
|
||||
_PHASE_ENVELOPE_CACHE.clear()
|
||||
# The envelope itself is held, so its id cannot be recycled while cached.
|
||||
_PHASE_ENVELOPE_CACHE[_key] = (envelope, _parsed)
|
||||
envelope_arrays, time_grid, target_duration = _parsed
|
||||
|
||||
if len(time_grid) == 0 or target_duration <= 0:
|
||||
if target_duration > 0 and len(envelope_arrays["avg"]) > 0:
|
||||
@@ -305,23 +363,33 @@ def estimate_phase_progress(
|
||||
logger.debug("Envelope missing time grid/duration, cannot estimate phase")
|
||||
return None
|
||||
|
||||
# Extract power offsets from current cycle (any format -> [offset, power])
|
||||
current_offsets_list = power_data_to_offsets(
|
||||
cast(list[list[Any] | tuple[Any, ...]], current_power_data)
|
||||
)
|
||||
current_offsets = np.array([o for o, _ in current_offsets_list])
|
||||
current_values = np.array([p for _, p in current_offsets_list])
|
||||
if current_offsets.size == 0:
|
||||
logger.debug("No valid current power offsets, cannot estimate phase")
|
||||
return None
|
||||
|
||||
# Use sliding window on TIME, not sample count
|
||||
window_duration = min(60.0, target_duration * 0.25)
|
||||
current_time = current_offsets[-1]
|
||||
window_start_time = max(0, current_time - window_duration)
|
||||
# Only the last `window_duration` seconds of the trace are read, so convert
|
||||
# only those (audit PROGRESS-17: the whole trace was converted on every 5 s
|
||||
# estimate, on the event loop). `_window_values` returns exactly what the
|
||||
# full conversion + time mask selects, or None to take that full path.
|
||||
_windowed = _window_values(current_power_data, window_duration)
|
||||
if _windowed is None:
|
||||
# Extract power offsets from current cycle (any format -> [offset, power])
|
||||
current_offsets_list = power_data_to_offsets(
|
||||
cast(list[list[Any] | tuple[Any, ...]], current_power_data)
|
||||
)
|
||||
current_offsets = np.array([o for o, _ in current_offsets_list])
|
||||
current_values = np.array([p for _, p in current_offsets_list])
|
||||
if current_offsets.size == 0:
|
||||
logger.debug("No valid current power offsets, cannot estimate phase")
|
||||
return None
|
||||
current_time = current_offsets[-1]
|
||||
window_start_time = max(0, current_time - window_duration)
|
||||
|
||||
window_mask = current_offsets >= window_start_time
|
||||
current_window_values = current_values[window_mask]
|
||||
window_mask = current_offsets >= window_start_time
|
||||
current_window_values = current_values[window_mask]
|
||||
elif _windowed.size == 0:
|
||||
logger.debug("No valid current power offsets, cannot estimate phase")
|
||||
return None
|
||||
else:
|
||||
current_window_values = _windowed
|
||||
|
||||
if len(current_window_values) < 3:
|
||||
logger.debug("Insufficient data in current window for phase estimation")
|
||||
@@ -605,7 +673,7 @@ def _dt_scaled_alpha(alpha: float, dt_s: float | None) -> float:
|
||||
alpha_dt = 1 - (1 - alpha) ** (dt / SMOOTHING_NOMINAL_DT_S)
|
||||
|
||||
``dt_s`` of ``None`` (or <= 0) keeps the nominal weight, so every caller that
|
||||
does not track its own cadence - and the golden snapshot - is unchanged.
|
||||
does not track its own cadence (and the Playground replay) is unchanged.
|
||||
"""
|
||||
if dt_s is None or not math.isfinite(dt_s) or dt_s <= 0.0:
|
||||
return alpha
|
||||
@@ -615,23 +683,36 @@ def _dt_scaled_alpha(alpha: float, dt_s: float | None) -> float:
|
||||
return 1.0 - (1.0 - alpha) ** steps
|
||||
|
||||
|
||||
def ema_seed(
|
||||
prev_smoothed: float, prev_program: str | None, program: str | None
|
||||
) -> float:
|
||||
"""The EMA state an estimate for ``program`` continues from (audit PROGRESS-09).
|
||||
|
||||
A programme switch or a pin re-seeds to 0.0 (a cold start, i.e. the raw
|
||||
estimate for the new programme). Carrying the old percent onto the new
|
||||
duration read 62-67 min against a 90 min truth and took up to 12 min to
|
||||
settle; an honest backwards jump at a switch is the correct information.
|
||||
"""
|
||||
if prev_program is not None and program != prev_program:
|
||||
return 0.0
|
||||
return prev_smoothed
|
||||
|
||||
|
||||
def _compute_progress_base(
|
||||
device_type: str,
|
||||
matched_duration: float,
|
||||
duration_so_far: float,
|
||||
prev_smoothed: float,
|
||||
phase_result: tuple[float, float] | None,
|
||||
ml_pct: float | None,
|
||||
logger: logging.Logger | None = None,
|
||||
dt_seconds: float | None = None,
|
||||
) -> ProgressResult | None:
|
||||
"""The blend + EMA + monotonicity + back-calculation body of the estimate loop.
|
||||
"""The EMA + monotonicity + back-calculation body of the estimate loop.
|
||||
|
||||
Pure arithmetic: the caller supplies ``phase_result`` (from
|
||||
:func:`estimate_phase_progress`, or ``None`` to force the linear fallback) and
|
||||
``ml_pct`` (from :func:`ml_progress_percent`, or ``None``); both the live
|
||||
manager and the Playground compute those via the same functions, so this is
|
||||
the single implementation of the smoothing/back-calc. Returns ``None`` when no
|
||||
:func:`estimate_phase_progress`, or ``None`` to force the linear fallback);
|
||||
the live manager and the Playground compute it via the same function, so this
|
||||
is the single implementation of the smoothing/back-calc. Returns ``None`` when no
|
||||
profile duration is known (caller clears the estimate). Behavior-identical to
|
||||
the matched-duration branch of ``manager._update_remaining_only``.
|
||||
"""
|
||||
@@ -643,10 +724,6 @@ def _compute_progress_base(
|
||||
if phase_result is not None:
|
||||
phase_progress, phase_variance = phase_result
|
||||
|
||||
if ml_pct is not None:
|
||||
w = ML_PROGRESS_BLEND_WEIGHT
|
||||
phase_progress = (1.0 - w) * phase_progress + w * ml_pct
|
||||
|
||||
if prev_smoothed == 0.0:
|
||||
smoothed = phase_progress
|
||||
else:
|
||||
@@ -665,8 +742,12 @@ def _compute_progress_base(
|
||||
smoothing_threshold = DEVICE_SMOOTHING_THRESHOLDS.get(device_type, 5.0)
|
||||
if phase_progress < current_smoothed - smoothing_threshold:
|
||||
# Backward step: damping here exists to resist regression, not to
|
||||
# track, so it stays per-estimate (unscaled) on purpose.
|
||||
smoothed = (current_smoothed * 0.95) + (phase_progress * 0.05)
|
||||
# track. It is still a time constant, not a step count (audit
|
||||
# PROGRESS-13): per estimate, the Playground's 30 s steps (and a
|
||||
# plug reporting every 30 s live) gave way to a real drop 6x
|
||||
# slower than a 5 s plug. dt=None keeps the plain 95/5 step.
|
||||
beta = _dt_scaled_alpha(0.05, dt_seconds)
|
||||
smoothed = (current_smoothed * (1.0 - beta)) + (phase_progress * beta)
|
||||
logger.debug(
|
||||
"Progress drop detected (%.1f%% < %.1f%% - %.1f%%), "
|
||||
"applying heavy damping for %s",
|
||||
@@ -680,10 +761,22 @@ def _compute_progress_base(
|
||||
smoothed = (prev_smoothed * (1.0 - alpha)) + (phase_progress * alpha)
|
||||
|
||||
smoothed = min(99.0, smoothed)
|
||||
if duration_so_far >= matched_duration and prev_smoothed > smoothed:
|
||||
# Past the expected end the cycle is finishing, not going backwards.
|
||||
# In an overrun tail the phase scan declines on quiet windows, so the
|
||||
# branches alternate: the linear one reaches 100%, then the next phase
|
||||
# estimate's backward step (and its 99% cap) pulled the shown progress
|
||||
# back to ~97% (audit PROGRESS-13 follow-up). Hold what was shown.
|
||||
smoothed = prev_smoothed
|
||||
progress = smoothed
|
||||
|
||||
remaining = matched_duration * (1.0 - (progress / 100.0))
|
||||
remaining = max(0.0, remaining)
|
||||
if duration_so_far >= matched_duration:
|
||||
# Overrun: the 99% cap would pin remaining at 1% of the profile for as
|
||||
# long as the run lasts, re-arming the live chronometer "now + 36 s"
|
||||
# every tick (audit PROGRESS-06). The linear branch already says 0.
|
||||
remaining = 0.0
|
||||
total = duration_so_far + remaining
|
||||
|
||||
logger.debug(
|
||||
@@ -699,18 +792,16 @@ def _compute_progress_base(
|
||||
remaining = max(matched_dur - duration_so_far, 0.0)
|
||||
progress = (duration_so_far / matched_dur) * 100.0
|
||||
|
||||
if ml_pct is not None:
|
||||
w = ML_PROGRESS_BLEND_WEIGHT
|
||||
progress = (1.0 - w) * progress + w * ml_pct
|
||||
remaining = max(matched_dur * (1.0 - progress / 100.0), 0.0)
|
||||
|
||||
if prev_smoothed > 0:
|
||||
lin_alpha = _dt_scaled_alpha(0.1, dt_seconds)
|
||||
smoothed = (prev_smoothed * (1.0 - lin_alpha)) + (progress * lin_alpha)
|
||||
else:
|
||||
smoothed = progress
|
||||
|
||||
progress = max(0.0, min(smoothed, 100.0))
|
||||
# Clamped in the carried state too: unclamped, a run past a short mis-match
|
||||
# carried 146% into the correct longer programme (audit PROGRESS-09).
|
||||
smoothed = max(0.0, min(smoothed, 100.0))
|
||||
progress = smoothed
|
||||
remaining = max(matched_dur * (1.0 - progress / 100.0), 0.0)
|
||||
total = duration_so_far + remaining
|
||||
logger.debug(
|
||||
@@ -727,83 +818,74 @@ def compute_progress(
|
||||
duration_so_far: float,
|
||||
prev_smoothed: float,
|
||||
phase_result: tuple[float, float] | None,
|
||||
ml_pct: float | None,
|
||||
logger: logging.Logger | None = None,
|
||||
phase_remaining_s: float | None = None,
|
||||
dt_seconds: float | None = None,
|
||||
) -> ProgressResult | None:
|
||||
"""Progress/remaining estimate, optionally blended with a phase-resolved ETA.
|
||||
|
||||
When ``phase_remaining_s`` is provided (opt-in phase matching for a supported
|
||||
device type), the phase-budget remaining is converted to a completion PERCENT
|
||||
and blended into the phase-progress signal **before** delegating to
|
||||
:func:`_compute_progress_base` - so the blend rides the proven, golden-locked
|
||||
EMA + monotonicity + back-calculation guards (design §8, "one smoothing
|
||||
implementation"), rather than re-deriving a raw, unsmoothed progress. The
|
||||
blend leans on the phase budget early (low base progress) and on the proven
|
||||
phase estimate late::
|
||||
|
||||
phase_pct = duration_so_far / (duration_so_far + phase_remaining_s) * 100
|
||||
f = base_phase_progress / 100
|
||||
blended = (1 - f) * phase_pct + f * base_phase_progress
|
||||
|
||||
Because this feeds the percent-domain smoothing, the displayed progress stays
|
||||
monotone/smoothed (no tick-to-tick jitter or collapse-to-99%), and remaining
|
||||
is re-derived by the base from ``matched_duration``.
|
||||
|
||||
Behaviour is BYTE-IDENTICAL to before when ``phase_remaining_s is None`` (the
|
||||
default) - the golden progress snapshot and every existing caller are
|
||||
unaffected. This is the single implementation of the blend; the manager and
|
||||
the Playground SimRunner both go through it.
|
||||
"""
|
||||
blended = False
|
||||
if phase_remaining_s is not None and matched_duration and matched_duration > 0:
|
||||
try:
|
||||
pr = float(phase_remaining_s)
|
||||
except (TypeError, ValueError):
|
||||
pr = float("nan")
|
||||
if math.isfinite(pr) and pr >= 0.0:
|
||||
denom = duration_so_far + pr
|
||||
phase_pct = (duration_so_far / denom * 100.0) if denom > 0 else 0.0
|
||||
phase_pct = max(0.0, min(100.0, phase_pct))
|
||||
if phase_result is not None:
|
||||
base_pp, variance = phase_result
|
||||
f = max(0.0, min(1.0, float(base_pp) / 100.0))
|
||||
phase_result = ((1.0 - f) * phase_pct + f * float(base_pp), variance)
|
||||
else:
|
||||
# No envelope phase-progress: blend the phase budget's implied
|
||||
# percent with the linear (elapsed/matched) percent, still leaning
|
||||
# on the phase budget early and the linear estimate late.
|
||||
lin_pct = max(0.0, min(100.0, duration_so_far / matched_duration * 100.0))
|
||||
f = lin_pct / 100.0
|
||||
phase_result = ((1.0 - f) * phase_pct + f * lin_pct, 0.0)
|
||||
blended = True
|
||||
|
||||
base = _compute_progress_base(
|
||||
"""Progress/remaining estimate: the one entry point for the manager and the
|
||||
Playground replay (the phase-resolved ETA blend that used to sit here was
|
||||
removed, audit PROGRESS-01/02: it never ran in production, and revived it was
|
||||
10% worse at 25% on washers)."""
|
||||
return _compute_progress_base(
|
||||
device_type, matched_duration, duration_so_far, prev_smoothed,
|
||||
phase_result, ml_pct, logger, dt_seconds,
|
||||
)
|
||||
if base is None or not blended:
|
||||
return base
|
||||
# Relabel the source for diagnostics; values already reflect the blend.
|
||||
return ProgressResult(
|
||||
base.progress, base.smoothed, base.remaining, base.total,
|
||||
base.phase_progress, "phase_blend",
|
||||
phase_result, logger, dt_seconds,
|
||||
)
|
||||
|
||||
|
||||
def phase_timeline_span(
|
||||
ranges: list[dict[str, Any]], expected_duration: float | None
|
||||
) -> float:
|
||||
"""Seconds the progress fraction maps onto: ``max(last range end, expected)``.
|
||||
|
||||
Phase ranges are minutes into the programme, so a profile that marks only
|
||||
Wash 0-30 / Rinse 30-60 on a 100 min programme reads Rinse at minute 45 and
|
||||
no phase at minute 80 (audit PROGRESS-10). Stretching the ranges over the
|
||||
whole cycle (the old scale, the last range end) named Wash at 45%. Ranges
|
||||
that run past the expected duration keep their own end. 0.0 when unusable.
|
||||
"""
|
||||
span = max((float(r.get("end") or 0.0) for r in ranges), default=0.0)
|
||||
try:
|
||||
expected = float(expected_duration or 0.0)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
expected = 0.0
|
||||
if math.isfinite(expected) and expected > span:
|
||||
span = expected
|
||||
return span if math.isfinite(span) and span > 0.0 else 0.0
|
||||
|
||||
|
||||
def phase_at(
|
||||
ranges: list[dict[str, Any]], position_s: float, span_s: float
|
||||
) -> str | None:
|
||||
"""The range containing ``position_s``: ``[start, end)``, the timeline's own
|
||||
end included. None in a gap or past every range - no nearest-phase guess.
|
||||
The panel's Status timeline applies the same rule."""
|
||||
at_end = position_s >= span_s
|
||||
for r in sorted(ranges, key=lambda x: float(x.get("start") or 0.0)):
|
||||
start = float(r.get("start") or 0.0)
|
||||
end = float(r.get("end") or 0.0)
|
||||
if end <= start:
|
||||
continue
|
||||
if start <= position_s < end or (at_end and end >= span_s and start <= position_s):
|
||||
name = str(r.get("name") or "").strip()
|
||||
return name or None
|
||||
return None
|
||||
|
||||
|
||||
def current_phase(
|
||||
store: Any,
|
||||
state: str,
|
||||
current_program: str | None,
|
||||
cycle_progress: float,
|
||||
expected_duration: float | None = None,
|
||||
) -> str | None:
|
||||
"""Live phase from the profile's configured ranges + ML-blended progress.
|
||||
"""Live phase from the profile's configured ranges + the smoothed progress.
|
||||
|
||||
Indexed by the smoothed progress fraction rather than raw elapsed seconds, so
|
||||
overrun/underrun cycles still name the phase correctly. Returns ``None`` when
|
||||
not running, no profile is matched, or the profile has no configured phase
|
||||
ranges. Never raises.
|
||||
overrun/underrun cycles still name the phase correctly; the fraction maps onto
|
||||
:func:`phase_timeline_span` (the matched profile's ``expected_duration`` unless
|
||||
the ranges run longer), so ranges are read at their real minutes. Returns
|
||||
``None`` when not running, no profile is matched, the profile has no phase
|
||||
ranges, or no range covers this point (audit PROGRESS-11: no guessed phase).
|
||||
Never raises.
|
||||
"""
|
||||
try:
|
||||
if state not in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
|
||||
@@ -814,15 +896,63 @@ def current_phase(
|
||||
ranges = store.get_profile_phase_ranges(profile)
|
||||
if not ranges:
|
||||
return None
|
||||
nominal = max((float(r.get("end") or 0.0) for r in ranges), default=0.0)
|
||||
if nominal <= 0.0:
|
||||
span = phase_timeline_span(ranges, expected_duration)
|
||||
if span <= 0.0:
|
||||
return None
|
||||
frac = max(0.0, min(1.0, float(cycle_progress) / 100.0))
|
||||
return store.check_phase_match(profile, frac * nominal)
|
||||
return phase_at(ranges, frac * span, span)
|
||||
except Exception: # noqa: BLE001 - phase readout must never break
|
||||
return None
|
||||
|
||||
|
||||
_ENERGY_CURVES: dict[tuple[str, int, Any], tuple[Any, tuple[np.ndarray, np.ndarray] | None]] = {}
|
||||
|
||||
|
||||
def envelope_energy_fraction(
|
||||
store: Any, program: str | None, progress_pct: float
|
||||
) -> float | None:
|
||||
"""Share of the matched profile's energy used by ``progress_pct`` (audit PROGRESS-04).
|
||||
|
||||
The cumulative integral of the envelope's ``avg`` curve, read at the same
|
||||
fraction of its time grid. Energy does not accrue linearly in time - heaters
|
||||
front-load it - so ``energy / time_fraction`` projected washers 1.89x too high
|
||||
at 25%. None without a usable envelope (the caller falls back to that).
|
||||
"""
|
||||
if not program or store is None:
|
||||
return None
|
||||
try:
|
||||
env = store.get_envelope(program)
|
||||
except Exception: # noqa: BLE001 - a projection input, never fatal
|
||||
return None
|
||||
if not isinstance(env, dict):
|
||||
return None
|
||||
key = (program, id(env), env.get("updated"))
|
||||
hit = _ENERGY_CURVES.get(key)
|
||||
if hit is None or hit[0] is not env:
|
||||
if len(_ENERGY_CURVES) > 64:
|
||||
_ENERGY_CURVES.clear()
|
||||
curve = None
|
||||
try:
|
||||
tg = np.asarray(env.get("time_grid") or [], dtype=float)
|
||||
avg = _envelope_y(env.get("avg"))
|
||||
if tg.size >= 2 and avg.size == tg.size and np.all(np.isfinite(avg)):
|
||||
cum = np.concatenate(
|
||||
([0.0], np.cumsum(np.diff(tg) * (avg[1:] + avg[:-1]) / 2.0))
|
||||
)
|
||||
if cum[-1] > 0 and tg[-1] > tg[0]:
|
||||
curve = (tg, cum / cum[-1])
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
curve = None
|
||||
# The envelope itself is held, so its id cannot be recycled while cached.
|
||||
_ENERGY_CURVES[key] = (env, curve)
|
||||
curve = _ENERGY_CURVES[key][1]
|
||||
if curve is None:
|
||||
return None
|
||||
tg, frac = curve
|
||||
x = tg[0] + (tg[-1] - tg[0]) * min(max(float(progress_pct) / 100.0, 0.0), 1.0)
|
||||
return max(float(np.interp(x, tg, frac)), PROJECTION_MIN_ENERGY_FRACTION)
|
||||
|
||||
|
||||
def projected_energy(
|
||||
store: Any,
|
||||
options: Any,
|
||||
@@ -839,8 +969,10 @@ def projected_energy(
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""Project total energy (Wh) and cost for the running cycle.
|
||||
|
||||
Prefers the on-device ``total_energy`` regressor; otherwise falls back to
|
||||
``energy_so_far / progress_fraction``. Returns ``(wh, cost)``; both values are
|
||||
Prefers the on-device ``total_energy`` regressor; otherwise divides
|
||||
``energy_so_far`` by the matched profile's cumulative-energy share at this
|
||||
progress (:func:`envelope_energy_fraction`), and by the time fraction only
|
||||
when the profile has no usable envelope. Returns ``(wh, cost)``; both values are
|
||||
``None`` when progress is too low or there is no energy yet. Never raises.
|
||||
|
||||
``cost_so_far`` is the dynamic-tariff cost already incurred (#426): the energy
|
||||
@@ -868,13 +1000,16 @@ def projected_energy(
|
||||
end_expectation_fn, logger,
|
||||
)
|
||||
if projected_wh is None:
|
||||
projected_wh = energy_so_far / (progress / 100.0)
|
||||
fraction = envelope_energy_fraction(store, current_program, progress)
|
||||
projected_wh = energy_so_far / (
|
||||
fraction if fraction is not None else progress / 100.0
|
||||
)
|
||||
projected_wh = max(projected_wh, energy_so_far)
|
||||
# A valid price of 0 (free/zero tariff) must yield cost 0.0, not None; only an
|
||||
# absent or non-numeric price is "unknown".
|
||||
try:
|
||||
price_val = float(price)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
price_val = None
|
||||
if price_val is None:
|
||||
cost = None
|
||||
@@ -885,7 +1020,7 @@ def projected_energy(
|
||||
if cost_so_far_wh is not None:
|
||||
try:
|
||||
charged_wh = float(cost_so_far_wh)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
charged_wh = energy_so_far
|
||||
remaining_wh = max(0.0, projected_wh - charged_wh)
|
||||
cost = float(cost_so_far) + (remaining_wh / 1000.0) * price_val
|
||||
|
||||
@@ -31,12 +31,17 @@ from .const import (
|
||||
STORAGE_KEY,
|
||||
)
|
||||
from .log_utils import DeviceLoggerAdapter
|
||||
from .time_utils import utc_now
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_KEY_RECORDER = f"{STORAGE_KEY}.recorder"
|
||||
|
||||
|
||||
# Seconds between buffer saves while recording (audit PLATFORM-18).
|
||||
_SAVE_INTERVAL_S = 300.0
|
||||
|
||||
|
||||
class RecorderStore(Store[dict[str, Any]]):
|
||||
"""Store for recorder data with migration support."""
|
||||
|
||||
@@ -87,7 +92,7 @@ class CycleRecorder:
|
||||
def current_duration(self) -> float:
|
||||
"""Return current recording duration in seconds."""
|
||||
if self._start_time:
|
||||
return (dt_util.now() - self._start_time).total_seconds()
|
||||
return (utc_now() - self._start_time).total_seconds()
|
||||
return 0.0
|
||||
|
||||
async def async_load(self) -> None:
|
||||
@@ -188,7 +193,7 @@ class CycleRecorder:
|
||||
"last_run": self._last_run,
|
||||
}
|
||||
await self._store.async_save(data)
|
||||
self._last_save = dt_util.now()
|
||||
self._last_save = utc_now()
|
||||
|
||||
async def start_recording(self) -> None:
|
||||
"""Start a new recording."""
|
||||
@@ -200,7 +205,7 @@ class CycleRecorder:
|
||||
# Previous recordings are kept until explicitly cleared or overwritten
|
||||
|
||||
self._is_recording = True
|
||||
self._start_time = dt_util.now()
|
||||
self._start_time = utc_now()
|
||||
self._buffer = []
|
||||
await self._async_save()
|
||||
|
||||
@@ -209,14 +214,15 @@ class CycleRecorder:
|
||||
if not self._is_recording:
|
||||
return
|
||||
|
||||
now = dt_util.now()
|
||||
now = utc_now()
|
||||
# Append to buffer
|
||||
self._buffer.append((now.isoformat(), float(power)))
|
||||
|
||||
# Periodic save every 60s to ensure data persistence
|
||||
# Better safe than sorry: save if last save was > 1 minute ago
|
||||
if self._last_save and (now - self._last_save).total_seconds() > 60:
|
||||
self.hass.add_job(self._async_save)
|
||||
elif not self._last_save:
|
||||
# Periodic save every 5 min (audit PLATFORM-18): each save rewrites the
|
||||
# whole buffer and the previous recording, ~200 MB to the SD card over a
|
||||
# 4 h recording at 60 s. Stamped when SCHEDULED, not when the write
|
||||
# finishes, so the readings that arrive meanwhile cannot each schedule one.
|
||||
if not self._last_save or (now - self._last_save).total_seconds() > _SAVE_INTERVAL_S:
|
||||
self._last_save = now
|
||||
self.hass.add_job(self._async_save)
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ async def async_setup_entry(
|
||||
class WashDataProgramSelect(SelectEntity):
|
||||
"""Select entity to manually choose the running program."""
|
||||
|
||||
_attr_should_poll = False # pushed by the manager's update signal (PERF-02)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
_attr_translation_key = "program_select"
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
from asyncio import Task
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -33,10 +34,14 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.const import EntityCategory, UnitOfEnergy
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .time_utils import utc_now
|
||||
from .const import (
|
||||
CONF_AUTO_LABEL_CONFIDENCE,
|
||||
CONF_DURATION_TOLERANCE,
|
||||
@@ -71,7 +76,6 @@ from .const import (
|
||||
STATE_DELAY_WAIT,
|
||||
STATE_INTERRUPTED,
|
||||
STATE_FORCE_STOPPED,
|
||||
STATE_RINSE,
|
||||
STATE_UNKNOWN,
|
||||
STATE_CLEAN,
|
||||
)
|
||||
@@ -79,6 +83,12 @@ from .manager import WashDataManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The per-profile count sensors' own signal (register item 456). They read only
|
||||
# the profile summaries and envelopes, which change at a cycle end or a profile
|
||||
# edit, so rewriting all of them on every live refresh was pure cost (audit
|
||||
# PERF-01). Sent by WasherProfileSensorManager when the summaries change.
|
||||
SIGNAL_WASHER_PROFILES_UPDATE = "ha_washdata_profiles_update_{}"
|
||||
|
||||
|
||||
_STATIC_DIAGNOSTIC_SUFFIXES = {
|
||||
"debug_info",
|
||||
@@ -200,6 +210,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
manager.sensor_add_entities = async_add_entities
|
||||
|
||||
# Reconcile diagnostics at startup so stale unavailable entries are auto-removed.
|
||||
cleanup_orphaned_diagnostic_entities(hass, manager, entry)
|
||||
@@ -210,10 +221,33 @@ async def async_setup_entry(
|
||||
entry.async_on_unload(profile_sensor_manager.unsubscribe)
|
||||
|
||||
|
||||
@callback
|
||||
def async_reconcile_device_type_sensors(
|
||||
hass: HomeAssistant, manager: WashDataManager, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Add or drop the pump-only sensor after an in-place device type change.
|
||||
|
||||
Options apply through the update listener without re-running platform setup
|
||||
(audit PLATFORM-15), so a washer turned pump never got PumpRunsTodaySensor
|
||||
and a pump turned washer kept it until a restart. Removing the registry entry
|
||||
also removes the live entity.
|
||||
"""
|
||||
if manager.device_type == DEVICE_TYPE_PUMP and manager.sensor_add_entities is not None:
|
||||
entity_id = entity_registry.async_get(hass).async_get_entity_id(
|
||||
"sensor", DOMAIN, f"{entry.entry_id}_pump_runs_today"
|
||||
)
|
||||
if entity_id is None or hass.states.get(entity_id) is None:
|
||||
manager.sensor_add_entities([PumpRunsTodaySensor(manager, entry)])
|
||||
cleanup_orphaned_diagnostic_entities(hass, manager, entry)
|
||||
|
||||
|
||||
class WasherBaseSensor(SensorEntity):
|
||||
"""Base sensor for ha_washdata."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
# Pushed by the manager's update signal. Polling re-wrote every entity every
|
||||
# 30 s, idle included (audit PERF-02); only clock-driven values poll.
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
@@ -264,7 +298,6 @@ class WasherStateSensor(WasherBaseSensor):
|
||||
STATE_DELAY_WAIT,
|
||||
STATE_INTERRUPTED,
|
||||
STATE_FORCE_STOPPED,
|
||||
STATE_RINSE,
|
||||
STATE_UNKNOWN,
|
||||
STATE_CLEAN,
|
||||
],
|
||||
@@ -291,8 +324,10 @@ class WasherStateSensor(WasherBaseSensor):
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self): # type: ignore[override]
|
||||
# No per-reading counter here (audit PLATFORM-07): `samples_recorded`
|
||||
# changed on every power reading, so each reading wrote a new state row
|
||||
# and a new attribute row to the recorder. The debug sensor keeps it.
|
||||
attrs: dict[str, Any] = {
|
||||
"samples_recorded": self._manager.samples_recorded,
|
||||
"current_program_guess": self._manager.current_program,
|
||||
"sub_state": self._manager.sub_state,
|
||||
}
|
||||
@@ -435,7 +470,9 @@ class WasherTimeRemainingSensor(WasherBaseSensor):
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
if self._manager.check_state() in (STATE_OFF, STATE_ANTI_WRINKLE, STATE_DELAY_WAIT):
|
||||
if self._manager.check_state() in (
|
||||
STATE_OFF, STATE_IDLE, STATE_ANTI_WRINKLE, STATE_DELAY_WAIT
|
||||
):
|
||||
return None
|
||||
if self._manager.time_remaining is not None:
|
||||
return int(self._manager.time_remaining / 60)
|
||||
@@ -461,22 +498,39 @@ class WasherTotalDurationSensor(WasherBaseSensor):
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
if self._manager.check_state() == STATE_OFF:
|
||||
if self._manager.check_state() in (STATE_OFF, STATE_IDLE): # idle: #452
|
||||
return None
|
||||
if self._manager.total_duration:
|
||||
return int(self._manager.total_duration / 60)
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self): # type: ignore[override]
|
||||
"""Return extra state attributes."""
|
||||
return {
|
||||
"last_updated": self._manager.last_total_duration_update,
|
||||
}
|
||||
# No `last_updated` attribute any more (audit PERF-13): it was stamped on every
|
||||
# estimate, so the entity wrote a state_changed event, i.e. one recorder row,
|
||||
# every 5 s even while the whole-minute value stood still. An unrecorded
|
||||
# attribute would not help: the recorder writes a row for every state_changed.
|
||||
# The entity's own `last_changed` says when the total last moved.
|
||||
|
||||
|
||||
def _whole_percent(value: Any) -> int | None:
|
||||
"""Progress rounded half-up to a whole percent (the card's Math.round)."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
pct = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not math.isfinite(pct):
|
||||
return None
|
||||
return int(math.floor(pct + 0.5))
|
||||
|
||||
|
||||
class WasherProgressSensor(WasherBaseSensor):
|
||||
"""Sensor for cycle progress percentage."""
|
||||
"""Sensor for cycle progress percentage.
|
||||
|
||||
The state is a whole percent (audit PERF-13): as a raw float it changed on
|
||||
every estimate, and every state_changed event is a recorder row. The panel
|
||||
reads the unrounded figure over the WS API, not from this entity.
|
||||
"""
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the progress sensor."""
|
||||
@@ -484,14 +538,17 @@ class WasherProgressSensor(WasherBaseSensor):
|
||||
key="cycle_progress",
|
||||
translation_key="cycle_progress",
|
||||
native_unit_of_measurement="%",
|
||||
suggested_display_precision=1,
|
||||
suggested_display_precision=0,
|
||||
icon="mdi:progress-clock",
|
||||
)
|
||||
super().__init__(manager, entry)
|
||||
# The projection attributes, refreshed when the shown percent moves.
|
||||
self._attrs_key: tuple[Any, ...] | None = None
|
||||
self._attrs: dict[str, float] | None = None
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
return self._manager.cycle_progress
|
||||
return _whole_percent(self._manager.cycle_progress)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self): # type: ignore[override]
|
||||
@@ -500,15 +557,23 @@ class WasherProgressSensor(WasherBaseSensor):
|
||||
Derived from accumulated energy and the (ML-blended) progress estimate.
|
||||
Keys are present only while a projection is available, so the attributes
|
||||
stay clean when idle or early in a cycle.
|
||||
|
||||
Refreshed when the shown percent changes or a projection appears or goes,
|
||||
not on every estimate: the projection moves with each reading, so it would
|
||||
otherwise write the state_changed row the whole-percent state saves.
|
||||
"""
|
||||
attrs: dict[str, float] = {}
|
||||
projected_wh = self._manager.projected_energy_wh
|
||||
if projected_wh is not None:
|
||||
attrs["projected_energy_kwh"] = round(float(projected_wh) / 1000.0, 3)
|
||||
projected_cost = self._manager.projected_cost
|
||||
if projected_cost is not None:
|
||||
attrs["projected_cost"] = round(float(projected_cost), 2)
|
||||
return attrs or None
|
||||
key = (self.native_value, projected_wh is None, projected_cost is None)
|
||||
if key != self._attrs_key:
|
||||
attrs: dict[str, float] = {}
|
||||
if projected_wh is not None:
|
||||
attrs["projected_energy_kwh"] = round(float(projected_wh) / 1000.0, 3)
|
||||
if projected_cost is not None:
|
||||
attrs["projected_cost"] = round(float(projected_cost), 2)
|
||||
self._attrs_key = key
|
||||
self._attrs = attrs or None
|
||||
return self._attrs
|
||||
|
||||
|
||||
class WasherPowerSensor(WasherBaseSensor):
|
||||
@@ -533,6 +598,9 @@ class WasherPowerSensor(WasherBaseSensor):
|
||||
class WasherElapsedTimeSensor(WasherBaseSensor):
|
||||
"""Sensor for elapsed cycle time."""
|
||||
|
||||
# Elapsed time advances with the clock even when a quiet plug sends nothing.
|
||||
_attr_should_poll = True
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
"""Initialize the elapsed time sensor."""
|
||||
self.entity_description = SensorEntityDescription(
|
||||
@@ -540,6 +608,12 @@ class WasherElapsedTimeSensor(WasherBaseSensor):
|
||||
translation_key="elapsed_time",
|
||||
native_unit_of_measurement="s",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
# Shown in minutes like time remaining / total duration (#232). The
|
||||
# native unit stays seconds: HA applies a suggested unit only when the
|
||||
# entity is first registered, so an existing entity keeps the unit its
|
||||
# history and automations use (no state_class, so no statistics) until
|
||||
# its owner picks another one in the entity settings, which HA converts.
|
||||
suggested_unit_of_measurement="min",
|
||||
suggested_display_precision=0,
|
||||
icon="mdi:timer-outline",
|
||||
)
|
||||
@@ -547,12 +621,15 @@ class WasherElapsedTimeSensor(WasherBaseSensor):
|
||||
|
||||
@property
|
||||
def native_value(self): # type: ignore[override]
|
||||
if self._manager.check_state() == STATE_OFF:
|
||||
if self._manager.check_state() in (STATE_OFF, STATE_IDLE): # idle: #452
|
||||
return 0
|
||||
start = self._manager.cycle_start_time
|
||||
if start:
|
||||
delta = dt_util.now() - start
|
||||
return int(delta.total_seconds())
|
||||
# Whole minutes (audit PLATFORM-07): to the second it changed on every
|
||||
# state write, one recorder row per power reading. UTC, like every
|
||||
# interval (DST).
|
||||
delta = utc_now() - dt_util.as_utc(start)
|
||||
return int(delta.total_seconds()) // 60 * 60
|
||||
return 0
|
||||
|
||||
|
||||
@@ -580,9 +657,20 @@ class WasherDebugSensor(WasherBaseSensor):
|
||||
detector = self._manager.detector
|
||||
stats = self._manager.sample_interval_stats
|
||||
# pylint: disable=protected-access
|
||||
# The confidence comes from the same result as top_candidates and
|
||||
# last_match_details (audit MATCH-DECIDE-14), not the committed program's
|
||||
# `_last_match_confidence`, which moves only when the switching rules commit
|
||||
# or re-confirm the program.
|
||||
# That one is still the Match Confidence sensor's state; it is the fallback
|
||||
# here only while there is no result to describe.
|
||||
last = getattr(self._manager, "_last_match_result", None)
|
||||
attrs: dict[str, Any] = {
|
||||
"sub_state": detector.sub_state,
|
||||
"match_confidence": getattr(self._manager, "_last_match_confidence", 0.0),
|
||||
"match_confidence": (
|
||||
float(getattr(last, "confidence", 0.0) or 0.0)
|
||||
if last is not None
|
||||
else getattr(self._manager, "_last_match_confidence", 0.0)
|
||||
),
|
||||
"cycle_id": getattr(detector, "_current_cycle_start", None),
|
||||
"samples": detector.samples_recorded,
|
||||
"energy_accum": getattr(detector, "_energy_since_idle_wh", 0.0),
|
||||
@@ -713,11 +801,43 @@ class WasherProfileCountSensor(WasherBaseSensor):
|
||||
# Override unique ID to be profile specific
|
||||
self._attr_unique_id = f"{entry.entry_id}_profile_count_{self._profile_token}"
|
||||
|
||||
# One state write reads `available`, `native_value` and
|
||||
# `extra_state_attributes`; take one profile snapshot for all three instead of
|
||||
# three lookups per write (audit PERF-01).
|
||||
_write_snapshot: dict[str, Any] | None = None
|
||||
_write_snapshot_valid: bool = False
|
||||
|
||||
def _profile(self) -> dict[str, Any] | None:
|
||||
if self._write_snapshot_valid:
|
||||
return self._write_snapshot
|
||||
return self._manager.profile_store.get_profile(self._profile_name)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Listen to the profiles signal, not the live one (register item 456)."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_WASHER_PROFILES_UPDATE.format(self._entry.entry_id),
|
||||
self._update_callback,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
"""Write state from a single profile snapshot."""
|
||||
self._write_snapshot = self._manager.profile_store.get_profile(self._profile_name)
|
||||
self._write_snapshot_valid = True
|
||||
try:
|
||||
self.async_write_ha_state()
|
||||
finally:
|
||||
self._write_snapshot = None
|
||||
self._write_snapshot_valid = False
|
||||
|
||||
@property
|
||||
def native_value(self) -> int: # type: ignore[override]
|
||||
"""Return the cycle count."""
|
||||
# Fetch fresh count from store if available
|
||||
profile = self._manager.profile_store.get_profile(self._profile_name)
|
||||
profile = self._profile()
|
||||
if profile:
|
||||
return profile.get("cycle_count", 0)
|
||||
return 0
|
||||
@@ -725,12 +845,12 @@ class WasherProfileCountSensor(WasherBaseSensor):
|
||||
@property
|
||||
def available(self) -> bool: # type: ignore[override]
|
||||
"""Return True if profile still exists."""
|
||||
return self._manager.profile_store.get_profile(self._profile_name) is not None
|
||||
return self._profile() is not None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None: # type: ignore[override]
|
||||
"""Return profile statistics."""
|
||||
profile = self._manager.profile_store.get_profile(self._profile_name)
|
||||
profile = self._profile()
|
||||
if not profile:
|
||||
return None
|
||||
|
||||
@@ -796,17 +916,22 @@ class WasherProfileSensorManager:
|
||||
self._entry = entry
|
||||
self._async_add_entities = async_add_entities
|
||||
self._sensors: dict[str, WasherProfileCountSensor] = {}
|
||||
self._diagnostics_cleanup_done: bool = False
|
||||
|
||||
# Determine the signal string. It must match SIGNAL_WASHER_UPDATE from const.py
|
||||
# which is "washdata_update_{}"
|
||||
self._signal = SIGNAL_WASHER_UPDATE.format(entry.entry_id)
|
||||
self._update_task: Task[None] | None = None
|
||||
self._pending_update: bool = False
|
||||
# The summaries revision last acted on; held, so `is` stays meaningful.
|
||||
# Seeded with today's: the sensors about to be created are written from it.
|
||||
self._seen_revision: object | None = None
|
||||
try:
|
||||
self._seen_revision = manager.profile_store.profile_summaries_revision()
|
||||
except Exception: # noqa: BLE001 - None just means "refresh on first notify"
|
||||
self._seen_revision = None
|
||||
|
||||
# Register callback for ALL updates (simplest hook we have)
|
||||
# Ideally we'd have a specific profile update signal, but general update is fine
|
||||
# as long as we debounce or check efficiently.
|
||||
# Every live refresh comes through here; it is turned into the profiles
|
||||
# signal only when the profile summaries actually changed.
|
||||
self._unsub_dispatcher = async_dispatcher_connect(
|
||||
manager.hass,
|
||||
self._signal,
|
||||
@@ -818,7 +943,6 @@ class WasherProfileSensorManager:
|
||||
cleanup_orphaned_diagnostic_entities(
|
||||
self._manager.hass, self._manager, self._entry
|
||||
)
|
||||
self._diagnostics_cleanup_done = True
|
||||
|
||||
def unsubscribe(self) -> None:
|
||||
"""Remove the dispatcher subscription."""
|
||||
@@ -834,7 +958,18 @@ class WasherProfileSensorManager:
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
"""Handle updates."""
|
||||
"""Handle updates: act only when the profile summaries changed."""
|
||||
try:
|
||||
revision = self._manager.profile_store.profile_summaries_revision()
|
||||
except Exception: # noqa: BLE001 - a failed check must not freeze the sensors
|
||||
revision = object()
|
||||
if revision is self._seen_revision:
|
||||
return
|
||||
self._seen_revision = revision
|
||||
async_dispatcher_send(
|
||||
self._manager.hass,
|
||||
SIGNAL_WASHER_PROFILES_UPDATE.format(self._entry.entry_id),
|
||||
)
|
||||
if self._update_task and not self._update_task.done():
|
||||
self._pending_update = True
|
||||
return
|
||||
@@ -968,6 +1103,8 @@ class PumpRunsTodaySensor(WasherBaseSensor):
|
||||
|
||||
Only created when device type is ``pump``.
|
||||
"""
|
||||
# A rolling 24 h count: runs age out with the clock, not with an update.
|
||||
_attr_should_poll = True
|
||||
|
||||
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
|
||||
self.entity_description = SensorEntityDescription(
|
||||
@@ -987,8 +1124,8 @@ class WasherCycleCountSensor(WasherBaseSensor):
|
||||
"""Odometer: how many cycles this appliance has run, ever.
|
||||
|
||||
Reports the monotonic lifetime counter, not ``len(stored history)`` (#414). The
|
||||
stored-history number is capped at ``max_past_cycles`` and shrinks when the user
|
||||
deletes a record, so as a state it was unusable for the thing people build on it:
|
||||
stored-history number shrinks when the user deletes a record (and was capped at
|
||||
200 until 0.5.8), so as a state it was unusable for the thing people build on it:
|
||||
an "every N cycles" maintenance schedule, whether WashData's own reminders or an
|
||||
external integration's. The old number is still available as the
|
||||
``stored_cycles`` attribute.
|
||||
|
||||
@@ -17,7 +17,7 @@ label_cycle:
|
||||
text:
|
||||
profile_name:
|
||||
name: Profile Name
|
||||
description: The name of an existing profile (create profiles in Manage Profiles menu). Leave blank to remove label.
|
||||
description: An existing profile name (create profiles in the WashData panel). Leave blank to remove the label.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
@@ -84,9 +84,8 @@ auto_label_cycles:
|
||||
integration: ha_washdata
|
||||
confidence_threshold:
|
||||
name: Confidence Threshold
|
||||
description: Minimum match confidence (0.50-0.95) to apply labels.
|
||||
description: Minimum match confidence (0.50-0.95) to apply labels. Leave empty to use the device's Auto-Label Confidence setting.
|
||||
required: false
|
||||
default: 0.75
|
||||
selector:
|
||||
number:
|
||||
min: 0.50
|
||||
@@ -95,7 +94,7 @@ auto_label_cycles:
|
||||
|
||||
export_config:
|
||||
name: Export Config
|
||||
description: Export this washer's profiles and cycles to a JSON file (per device).
|
||||
description: Export this device's profiles, cycles and settings to a JSON file.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
@@ -132,8 +131,8 @@ import_config:
|
||||
submit_cycle_feedback:
|
||||
name: Submit Cycle Feedback
|
||||
description: >
|
||||
Confirm or correct an auto-detected program after a completed cycle.
|
||||
Provide either `entry_id` (advanced) or `device_id` (recommended).
|
||||
Confirm or correct the detected program of a finished cycle.
|
||||
Give `device_id` (recommended) or `entry_id`.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
@@ -274,7 +273,7 @@ resume_cycle:
|
||||
|
||||
mark_unloaded:
|
||||
name: Mark Unloaded
|
||||
description: Confirm the finished load has been taken out, clearing the Clean state and any unload reminder. Does nothing when no load is waiting.
|
||||
description: Confirm the finished load was taken out. Clears the Clean state and any unload reminder.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
|
||||
@@ -29,30 +29,57 @@ def compute_setup_phase(
|
||||
ref_profile_names: set[str],
|
||||
coverage_gap: dict | None,
|
||||
suggestions: list[dict],
|
||||
profile_groups: list[dict],
|
||||
skipped_steps: dict[str, str | None],
|
||||
now: datetime,
|
||||
backfill_cycles: list[dict] | None = None,
|
||||
) -> SetupPhaseResult:
|
||||
"""Compute the current adoption phase for a device.
|
||||
|
||||
Every cycle argument is the **evidence** view (``iter_evidence_cycles``), not the
|
||||
stored one: each check here asks whether a profile can be matched, and a profile
|
||||
whose only cycles the user excluded cannot. That keeps phase0 equal to
|
||||
``ProfileStore.has_real_profiles`` being False, which is when the manager skips
|
||||
matching and leaves this card to explain why (register item 129d).
|
||||
|
||||
Args:
|
||||
device_type: HA device type string (washing_machine, dishwasher, ...).
|
||||
profile_names: All profile names stored for this device.
|
||||
past_cycles: All past cycles (each may have profile_name and meta.source).
|
||||
ref_profile_names: Profile names that have reference cycles (store-adopted).
|
||||
past_cycles: Live cycles that count as evidence (each may have profile_name
|
||||
and meta.source).
|
||||
ref_profile_names: Profile names backed by evidence reference cycles
|
||||
(store-adopted).
|
||||
coverage_gap: Result of profile_store.suggest_coverage_gaps(), or None.
|
||||
suggestions: Actionable suggestions from SuggestionEngine (empty list = none).
|
||||
profile_groups: Profile groups list from store (empty list = none pending).
|
||||
skipped_steps: Dict of step_key -> "never" | ISO timestamp | None.
|
||||
now: Current aware datetime for snooze comparisons.
|
||||
backfill_cycles: Evidence cycles recovered from this machine's raw power
|
||||
history (#344). They are its own history, so a profile they back is a
|
||||
self-built profile, never a community one; they are never recordings.
|
||||
"""
|
||||
real = _real_profile_names(profile_names, past_cycles)
|
||||
backfill = list(backfill_cycles or [])
|
||||
own_cycles = [*past_cycles, *backfill]
|
||||
real = _real_profile_names(profile_names, own_cycles)
|
||||
has_real = bool(real)
|
||||
# Only the recorder writes meta.source == "recorder", and it writes past_cycles.
|
||||
has_recorded = _has_recorded_cycles(past_cycles, real)
|
||||
has_store = bool(ref_profile_names)
|
||||
has_self_cycles = bool(real) # any cycle assigned to a real profile
|
||||
has_self_cycles = bool(real) # any own cycle assigned to a real profile
|
||||
|
||||
# ── Phase 0 ──────────────────────────────────────────────────────────────
|
||||
if not has_real and not has_store and any(not c.get("profile_name") for c in backfill):
|
||||
# Imported history is waiting to be named: labelling it is the next step,
|
||||
# recording is the alternative. "Start recording" first told a user who had
|
||||
# just imported weeks of cycles that the device had nothing (item 129d).
|
||||
return SetupPhaseResult(
|
||||
phase="phase0",
|
||||
message_key="setup.phase0.generic",
|
||||
cta_label_key="setup.cta.label_detected_cycle",
|
||||
cta_action="open_cycles_unlabeled",
|
||||
secondary_label_key="setup.cta.start_recording",
|
||||
secondary_action="open_recorder",
|
||||
skippable=False,
|
||||
dismissible=False,
|
||||
)
|
||||
if not has_real and not has_store:
|
||||
msg_key = {
|
||||
"washing_machine": "setup.phase0.washer",
|
||||
@@ -114,7 +141,7 @@ def compute_setup_phase(
|
||||
)
|
||||
|
||||
# ── Phase 3 — tuning items ────────────────────────────────────────────────
|
||||
item = _phase3_pending_item(suggestions, profile_groups, skipped_steps, now)
|
||||
item = _phase3_pending_item(suggestions, skipped_steps, now)
|
||||
if item:
|
||||
return item
|
||||
|
||||
@@ -131,7 +158,7 @@ def compute_setup_phase(
|
||||
# ≥5 cycles assigned to real profiles. At that point the "record your first
|
||||
# cycle" nudge is stale and misleading regardless of whether the user ever
|
||||
# clicked Skip.
|
||||
_established = len(real) >= 2 or _real_cycle_count(past_cycles, real) >= 5
|
||||
_established = len(real) >= 2 or _real_cycle_count(own_cycles, real) >= 5
|
||||
_coverage_gap_actionable = bool(coverage_gap and coverage_gap.get("suggest_create"))
|
||||
_phase1_suppressed = _is_step_suppressed("setup_skip_phase1", skipped_steps, now)
|
||||
if has_real and not _established and not _coverage_gap_actionable and not _phase1_suppressed:
|
||||
@@ -209,7 +236,7 @@ def _is_step_suppressed(step_key: str, skipped_steps: dict, now: datetime) -> bo
|
||||
if until.tzinfo is None:
|
||||
until = until.replace(tzinfo=timezone.utc)
|
||||
return now < until
|
||||
except (ValueError, TypeError):
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return False
|
||||
|
||||
|
||||
@@ -221,7 +248,6 @@ def _phase2_active(coverage_gap: dict | None, skipped_steps: dict, now: datetime
|
||||
|
||||
def _phase3_pending_item(
|
||||
suggestions: list[dict],
|
||||
profile_groups: list[dict],
|
||||
skipped_steps: dict,
|
||||
now: datetime,
|
||||
) -> SetupPhaseResult | None:
|
||||
@@ -235,14 +261,4 @@ def _phase3_pending_item(
|
||||
dismissible=True,
|
||||
step_key="setup_skip_phase3_suggestions",
|
||||
)
|
||||
if profile_groups and not _is_step_suppressed("setup_skip_phase3_groups", skipped_steps, now):
|
||||
return SetupPhaseResult(
|
||||
phase="phase3",
|
||||
message_key="setup.phase3.groups",
|
||||
cta_label_key="setup.cta.organise_profiles",
|
||||
cta_action="open_profiles_groups",
|
||||
skippable=True,
|
||||
dismissible=True,
|
||||
step_key="setup_skip_phase3_groups",
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -23,6 +23,8 @@ Constraint: Resampling must be segment-based (no interpolation across gaps).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Sequence
|
||||
from typing import List, Tuple
|
||||
@@ -53,7 +55,7 @@ def energy_gap_threshold_s(timestamps: np.ndarray) -> float:
|
||||
Ten times the median sample interval, clamped to ``[60, 3600]``. Segments
|
||||
longer than this are treated as sensor outages and excluded from the energy
|
||||
sum, without masking valid slow-sampling configurations. Single source for
|
||||
both persistence paths (``manager._on_cycle_end`` / ``ProfileStore.add_cycle``).
|
||||
both persistence paths (``manager._on_cycle_end`` / ``ProfileStore.async_add_cycle``).
|
||||
"""
|
||||
ts = np.asarray(timestamps, dtype=float)
|
||||
if ts.size < 2:
|
||||
@@ -475,3 +477,179 @@ def quiet_run_before(
|
||||
if prev_active is None:
|
||||
return 0.0
|
||||
return max(0.0, run_start - prev_active)
|
||||
|
||||
|
||||
def terminal_quiet_seen(
|
||||
points: list[tuple[float, float]],
|
||||
last_active: float,
|
||||
stop_threshold_w: float,
|
||||
quiet_s: float,
|
||||
peak_frac: float,
|
||||
) -> bool:
|
||||
"""Has this trace ALREADY been through the quiet phase its profile ends with?
|
||||
|
||||
``quiet_s`` is ``compute_profile_terminal_signature``'s ``quiet_before_s``: the
|
||||
quiet measured before the LAST run above ``peak * peak_frac`` of each cycle.
|
||||
A kept tail may add up to that much past the last activity, on the reasoning
|
||||
that a run which has not yet dried is still drying - so the allowance must be
|
||||
withheld when this run already did.
|
||||
|
||||
Asked two ways, and either one answers yes (shorten-only):
|
||||
|
||||
* at ``stop_threshold_w``, from ``last_active`` - the original test (register
|
||||
item 347);
|
||||
* at the SIGNATURE'S OWN threshold, from the last sample above it (#424). The
|
||||
statistic and the test of whether it has happened must use the same level.
|
||||
On #424's Beko the quiet is measured at 7.9 W (0.4% of a 1.97 kW peak) while
|
||||
its stop threshold is 0.96 W; its run ends 15-20 W drain -> 1.3 W -> 0.3 W,
|
||||
so at the stop threshold the last activity is the 1.3 W wind-down sample,
|
||||
preceded by the drain, and the test found no quiet at all. Every cycle then
|
||||
banked the full 611 s allowance as cycle time. At 7.9 W the same traces show
|
||||
605-630 s of quiet, i.e. the phase had happened.
|
||||
|
||||
Shared by ``CycleDetector._keep_tail_cap`` and the banked-tail repair so a
|
||||
cycle is judged the same way live and in history. Pure; never raises.
|
||||
"""
|
||||
try:
|
||||
if quiet_s <= 0 or not points:
|
||||
return False
|
||||
need = 0.5 * float(quiet_s)
|
||||
if quiet_run_before(points, last_active, stop_threshold_w) >= need:
|
||||
return True
|
||||
peak = max(p for _o, p in points)
|
||||
if peak <= 0:
|
||||
return False
|
||||
thr = peak * peak_frac
|
||||
last_event: float | None = None
|
||||
for offset, power in reversed(points):
|
||||
if power > thr:
|
||||
last_event = offset
|
||||
break
|
||||
if last_event is None:
|
||||
return False
|
||||
return quiet_run_before(points, last_event, thr) >= need
|
||||
except Exception: # noqa: BLE001 - a statistic must never break a finish
|
||||
return False
|
||||
|
||||
|
||||
def terminal_event_end(
|
||||
points: Sequence[tuple[float, float]], last_active: float, peak_frac: float
|
||||
) -> float:
|
||||
"""Where a run that has been through its terminal quiet actually ends.
|
||||
|
||||
``last_active`` (the last sample above the stop threshold), or the last sample
|
||||
above ``peak * peak_frac`` when that is later: a stop threshold above the
|
||||
signature's level leaves a quiet pump-out BELOW it, and when only
|
||||
:func:`terminal_quiet_seen`'s peak-fraction test fires, ending at
|
||||
``last_active`` cut that pump-out off (register item 384). Shared by the live
|
||||
cap and the banked-tail repair, like ``terminal_quiet_seen``. Never raises.
|
||||
"""
|
||||
try:
|
||||
peak = max((p for _o, p in points), default=0.0)
|
||||
if peak <= 0:
|
||||
return last_active
|
||||
thr = peak * peak_frac
|
||||
for offset, power in reversed(points):
|
||||
if power > thr:
|
||||
return max(float(last_active), float(offset))
|
||||
return last_active
|
||||
except Exception: # noqa: BLE001
|
||||
return last_active
|
||||
|
||||
|
||||
def has_resumed_pause(
|
||||
points: Sequence[tuple[float, float]], threshold_w: float, min_pause_s: float
|
||||
) -> bool:
|
||||
"""Does this trace hold a pause below ``threshold_w`` of ``min_pause_s`` or more
|
||||
that power later came back from? (#424)
|
||||
|
||||
Timed on the wall clock from the first below-threshold sample to the next one at
|
||||
or above it, so a change-only plug that reports one 0 W row and then nothing
|
||||
still counts its silence. A run still open at the end of the trace never
|
||||
resumed: that is the cycle's own end, not a pause. A run that starts on the
|
||||
first sample is the standby before the cycle (a curve pre-roll), not a pause
|
||||
either. Pure; never raises.
|
||||
"""
|
||||
return longest_resumed_pause_s(points, threshold_w, skip_leading=True) >= min_pause_s
|
||||
|
||||
|
||||
def longest_resumed_pause_s(
|
||||
points: Sequence[tuple[float, float]], threshold_w: float, *, skip_leading: bool = True
|
||||
) -> float:
|
||||
"""Longest pause below ``threshold_w`` that power later came back from (#458).
|
||||
|
||||
Timed like :func:`has_resumed_pause`. With ``skip_leading`` a run that starts on
|
||||
the trace's first sample is ignored: that is standby before the cycle began (a
|
||||
curve pre-roll), which the end gates never see. A run still open at the end of
|
||||
the trace is the cycle's own end and is ignored too. Pure; never raises.
|
||||
"""
|
||||
return max(
|
||||
(d for _f, d in resumed_pauses(points, threshold_w, skip_leading=skip_leading)),
|
||||
default=0.0,
|
||||
)
|
||||
|
||||
|
||||
def resumed_pauses(
|
||||
points: Sequence[tuple[float, float]], threshold_w: float, *, skip_leading: bool = True
|
||||
) -> list[tuple[float, float]]:
|
||||
"""``(start_fraction, seconds)`` of every pause below ``threshold_w`` that power
|
||||
later came back from, timed like :func:`has_resumed_pause`.
|
||||
|
||||
``start_fraction`` is where the pause began as a share of the trace's span: the
|
||||
hazard end gate's per-profile pause catalogue (audit DETECT-16). Pure; never
|
||||
raises.
|
||||
"""
|
||||
try:
|
||||
out: list[tuple[float, float]] = []
|
||||
if not points:
|
||||
return out
|
||||
t0 = float(points[0][0])
|
||||
span = float(points[-1][0]) - t0
|
||||
first_below: float | None = None
|
||||
leading = True
|
||||
for offset, power in points:
|
||||
if power < threshold_w:
|
||||
if first_below is None:
|
||||
first_below = offset
|
||||
else:
|
||||
if first_below is not None and not (skip_leading and leading):
|
||||
out.append((
|
||||
(first_below - t0) / span if span > 0 else 0.0,
|
||||
offset - first_below,
|
||||
))
|
||||
first_below = None
|
||||
leading = False
|
||||
return out
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
def percentile_linear(values: Sequence[float], q: float) -> float:
|
||||
"""``np.percentile(values, q)`` (linear method) without NumPy, bit for bit.
|
||||
|
||||
For the detector's 20-interval cadence window, where NumPy's per-call
|
||||
overhead (40-185 us) dwarfed the arithmetic (audit PERF-07). Same virtual
|
||||
index ``(n - 1) * q`` and the same two-sided lerp NumPy uses. ``values``
|
||||
must be non-empty.
|
||||
"""
|
||||
a = sorted(float(v) for v in values)
|
||||
n = len(a)
|
||||
virtual = (n - 1) * (q / 100.0)
|
||||
lo = math.floor(virtual)
|
||||
hi = min(lo + 1, n - 1)
|
||||
lo = min(max(lo, 0), n - 1)
|
||||
t = virtual - lo
|
||||
x, y = a[lo], a[hi]
|
||||
diff = y - x
|
||||
return y - diff * (1.0 - t) if t >= 0.5 else x + diff * t
|
||||
|
||||
|
||||
def median_fast(values: Sequence[float]) -> float:
|
||||
"""``np.median(values)`` without NumPy, bit for bit; ``values`` non-empty."""
|
||||
a = sorted(float(v) for v in values)
|
||||
n = len(a)
|
||||
mid = n // 2
|
||||
if n % 2:
|
||||
return a[mid]
|
||||
return (a[mid - 1] + a[mid]) / 2.0
|
||||
|
||||
|
||||
@@ -24,13 +24,15 @@ brand/model stay per-device. Nothing here runs unless online features are enable
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import store_account
|
||||
from .const import QC_EDITED, QC_MANUAL, QC_RECORDING
|
||||
from .store_client import device_id, get_client, profile_id, trace_hash
|
||||
from .store_client import STORE_UNREACHABLE, device_id, get_client, profile_id, trace_hash
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -122,6 +124,34 @@ def _downsample(points: list[list[float]], max_n: int = 10000) -> list[list[floa
|
||||
return sampled
|
||||
|
||||
|
||||
# Points kept per browse-row trace: the panel's sparkline is 120 px wide.
|
||||
_BROWSE_TRACE_POINTS = 200
|
||||
|
||||
|
||||
def _browse_rows(cycles: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Browse copies of store cycle rows: no ``importable``, trace downsampled to
|
||||
``_BROWSE_TRACE_POINTS`` (see ``StoreBridge.get_cycles``). Executor-safe; never
|
||||
raises (a non-numeric point is dropped, store data is untrusted)."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for cyc in cycles:
|
||||
if not isinstance(cyc, dict):
|
||||
continue
|
||||
row = {k: v for k, v in cyc.items() if k != "importable"}
|
||||
trace = cyc.get("trace")
|
||||
if isinstance(trace, dict) and isinstance(trace.get("points"), list):
|
||||
pts: list[list[float]] = []
|
||||
for p in trace["points"]:
|
||||
try:
|
||||
o, w = float(p[0]), float(p[1])
|
||||
except (TypeError, ValueError, IndexError, KeyError, OverflowError):
|
||||
continue
|
||||
if math.isfinite(o) and math.isfinite(w):
|
||||
pts.append([o, w])
|
||||
row["trace"] = {**trace, "points": _downsample(pts, _BROWSE_TRACE_POINTS)}
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _cycle_upload_stats(cyc: dict[str, Any], pts: list[list[float]]) -> dict[str, Any]:
|
||||
"""Build the community-upload stats for a cycle from its stored metadata + trace.
|
||||
|
||||
@@ -140,7 +170,7 @@ def _cycle_upload_stats(cyc: dict[str, Any], pts: list[list[float]]) -> dict[str
|
||||
}
|
||||
try:
|
||||
energy = float(cyc.get("energy_wh"))
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
energy = 0.0
|
||||
if energy > 0:
|
||||
stats["energy_wh"] = energy
|
||||
@@ -184,17 +214,36 @@ class StoreBridge:
|
||||
return {"enabled": store_account.online_enabled(self._hass), **store_account.get_identity(self._hass)}
|
||||
|
||||
async def connect(self, refresh_token: str, uid: str, name: str | None) -> dict[str, Any]:
|
||||
# Validate the refresh token by exchanging it once before persisting.
|
||||
"""Validate the refresh token by exchanging it once, then persist the account.
|
||||
|
||||
The stored uid is the one the token endpoint returned for that exchange, never
|
||||
the caller's ``uid`` (audit STORE-18): a mismatch made every later write 403
|
||||
under the store rules' ``uid == request.auth.uid`` checks. ``uid`` is kept in
|
||||
the signature for the WS contract; it is only compared, for a log line.
|
||||
"""
|
||||
# ensure_id_token writes the client-wide _last_error slot on failure, so it
|
||||
# takes the same lock the upload paths use: otherwise a sign-in failing here
|
||||
# could overwrite the reason a concurrent share is about to report.
|
||||
async with self._client.write_lock:
|
||||
token = await self._client.ensure_id_token(refresh_token)
|
||||
if not token:
|
||||
verified = self._client.verified_uid(refresh_token) if token else None
|
||||
if not token or not verified:
|
||||
return {"error": "token_invalid"}
|
||||
await store_account.async_set_account(self._hass, {"refresh_token": refresh_token, "uid": uid, "name": name})
|
||||
if uid and uid != verified:
|
||||
_LOGGER.warning("Store connect: ignoring a uid that does not match the token's account")
|
||||
await store_account.async_set_account(
|
||||
self._hass, {"refresh_token": refresh_token, "uid": verified, "name": name},
|
||||
)
|
||||
return store_account.get_identity(self._hass)
|
||||
|
||||
def _uploader_name(self, acct: dict[str, Any]) -> str | None:
|
||||
"""The name published on a shared cycle: the account's display name only when
|
||||
the user opted in (``share_name``), else None (audit STORE-14)."""
|
||||
if not store_account.get_pref(self._hass, "share_name"):
|
||||
return None
|
||||
name = acct.get("name")
|
||||
return name if isinstance(name, str) and name.strip() else None
|
||||
|
||||
async def disconnect(self) -> dict[str, Any]:
|
||||
await store_account.async_clear_account(self._hass)
|
||||
return {"connected": False}
|
||||
@@ -228,10 +277,13 @@ class StoreBridge:
|
||||
self._client.refresh_catalog()
|
||||
return {"ok": True}
|
||||
|
||||
async def get_profiles(self, device_id: str, *, include_pending: bool = True) -> list[dict[str, Any]]:
|
||||
async def get_profiles(
|
||||
self, device_id: str, *, include_pending: bool = True,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Shared programs for a catalog appliance. Pending-inclusive, like the device
|
||||
list this is opened from and like get_cycles below; passed explicitly so the
|
||||
browse cannot silently drift back to approved-only (which showed nothing)."""
|
||||
browse cannot silently drift back to approved-only (which showed nothing).
|
||||
``None`` = the store could not be reached, not "no programs" (audit STORE-09)."""
|
||||
return await self._client.get_profiles(device_id, include_pending=include_pending)
|
||||
|
||||
async def device_profiles(self, brand: str, model: str, appliance_type: str) -> dict[str, Any]:
|
||||
@@ -239,11 +291,20 @@ class StoreBridge:
|
||||
dialog's profile picker). Maps the HA device type to the catalog type first."""
|
||||
return await self._client.device_profiles(brand, model, store_appliance_type(appliance_type))
|
||||
|
||||
async def get_cycles(self, profile_id: str) -> list[dict[str, Any]]:
|
||||
return await self._client.get_cycles(profile_id)
|
||||
async def get_cycles(self, profile_id: str) -> list[dict[str, Any]] | None:
|
||||
"""A program's shared cycles for the browse list; ``None`` = store unreachable.
|
||||
|
||||
Slimmed for the wire (audit STORE-20): the panel draws each trace as a 120 px
|
||||
sparkline, yet every row carried the full trace twice (``trace.points`` and
|
||||
``importable``, up to 7k points each, 50 rows). Import re-fetches the cycle by
|
||||
id, so the browse rows drop ``importable`` and carry a peak-preserving
|
||||
downsample. The download path reads the client directly and keeps full traces.
|
||||
"""
|
||||
cycles = await self._client.get_cycles(profile_id)
|
||||
if not cycles:
|
||||
return cycles
|
||||
return await self._hass.async_add_executor_job(_browse_rows, cycles)
|
||||
|
||||
async def get_device_quality(self, device_id: str) -> dict[str, Any]:
|
||||
return await self._client.get_device_quality(device_id)
|
||||
|
||||
# ── community actions (authed writes) ────────────────────────────────────────
|
||||
|
||||
@@ -280,11 +341,18 @@ class StoreBridge:
|
||||
profile = raw_profile.strip() if isinstance(raw_profile, str) else ""
|
||||
if not profile:
|
||||
return {"error": "profile_name_required"}
|
||||
local_id = await self._ps.add_reference_cycle(profile, pts, {
|
||||
meta = {
|
||||
"store_cycle_id": cyc.get("id"),
|
||||
"store_uploaded_at": cyc.get("createdAt"),
|
||||
"sampling_interval": (cyc.get("trace") or {}).get("sampleIntervalSec"),
|
||||
})
|
||||
"community": True,
|
||||
}
|
||||
# Too short / gappy / implausible, or a copy of a trace already stored
|
||||
# under any name (audit STORE-03/05): refused before it shapes a profile.
|
||||
verdict = self._ps.reference_import_verdict(pts, meta)
|
||||
if verdict != "ok":
|
||||
return {"error": "invalid_trace" if verdict == "invalid" else verdict}
|
||||
local_id = await self._ps.add_reference_cycle(profile, pts, meta)
|
||||
if not local_id: # trace failed validation in add_reference_cycle
|
||||
return {"error": "invalid_trace"}
|
||||
# Credit the download on the source store cycle + record one community-wide
|
||||
@@ -326,7 +394,7 @@ class StoreBridge:
|
||||
# write and the read that interprets it have to be one critical section.
|
||||
async with self._client.write_lock:
|
||||
new_id = await self._client.upload_reference_cycle(
|
||||
acct["refresh_token"], acct.get("uid", ""), acct.get("name"),
|
||||
acct["refresh_token"], acct.get("uid", ""), self._uploader_name(acct),
|
||||
meta, downsampled, stats, derive_qc(cyc),
|
||||
)
|
||||
if not new_id:
|
||||
@@ -400,7 +468,7 @@ class StoreBridge:
|
||||
device_meta["settings"] = dict(settings)
|
||||
async with self._client.write_lock: # see share_cycle
|
||||
res = await self._client.upload_device_bundle(
|
||||
acct["refresh_token"], acct.get("uid", ""), acct.get("name"), device_meta, bundle_items,
|
||||
acct["refresh_token"], acct.get("uid", ""), self._uploader_name(acct), device_meta, bundle_items,
|
||||
)
|
||||
# Return the raw bundle result ({ok, cycle_ids, errors}) so the caller can
|
||||
# tell a partial upload (some cycle_ids present) from a total failure.
|
||||
@@ -409,7 +477,14 @@ class StoreBridge:
|
||||
res = {**res, "detail": self._client.last_error()}
|
||||
return res
|
||||
|
||||
async def download_device(self, device_id_: str, device_type: str = "") -> dict[str, Any]:
|
||||
async def download_device(
|
||||
self,
|
||||
device_id_: str,
|
||||
device_type: str = "",
|
||||
*,
|
||||
progress: Callable[[int, int], None] | None = None,
|
||||
should_cancel: Callable[[], bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Adopt a whole-device bundle: for each downloaded profile, import its
|
||||
reference cycles into ``reference_cycles`` (merge/upsert; real past_cycles are
|
||||
never touched) and, when the profile carries a phase map, replace the local
|
||||
@@ -421,38 +496,72 @@ class StoreBridge:
|
||||
Idempotent: a store cycle already imported locally (``meta.source ==
|
||||
"store:<id>"``) is skipped, so re-downloading the same device does not
|
||||
accumulate duplicate reference cycles.
|
||||
|
||||
A store that could not be read is reported as ``{"error":
|
||||
"store_unreachable"}``, never as an empty adopt (audit STORE-09: offline used
|
||||
to read "Nothing new - already on your device"). When only some programs'
|
||||
cycles could not be fetched, the result carries ``partial`` +
|
||||
``failed_profiles``; if nothing new was imported at all it is that error.
|
||||
"""
|
||||
bundle = await self._client.get_device_bundle(device_id_)
|
||||
if bundle.get("error"):
|
||||
return {"error": str(bundle["error"])}
|
||||
already = {
|
||||
str((c.get("meta") or {}).get("source") or "")
|
||||
for c in self._ps.get_reference_cycles()
|
||||
}
|
||||
# Hashed once for the whole bundle; each import adds its own, so two
|
||||
# programs carrying the same recording keep only the first (STORE-05).
|
||||
known_hashes = self._ps.stored_trace_hashes()
|
||||
profiles_adopted = 0
|
||||
cycles_imported = 0
|
||||
cycles_skipped = 0
|
||||
phases_applied = 0
|
||||
imported_store_ids: list[str] = []
|
||||
for prof in bundle.get("profiles", []) or []:
|
||||
# Batched (audit STORE-10): every cycle used to rebuild its envelope and
|
||||
# rewrite the whole store - 40 saves for a 40-cycle bundle, 7 s vs 1.5 s.
|
||||
# Now one rebuild per touched program and one save at the end.
|
||||
touched: list[str] = []
|
||||
profiles_list = [p for p in (bundle.get("profiles", []) or []) if isinstance(p, dict)]
|
||||
failed_profiles = sum(1 for p in profiles_list if p.get("cycles_unavailable"))
|
||||
total = sum(len(p.get("cycles") or []) for p in profiles_list)
|
||||
seen = 0
|
||||
cancelled = False
|
||||
for prof in profiles_list:
|
||||
if cancelled:
|
||||
break
|
||||
program = str(prof.get("program") or prof.get("program_lc") or "").strip()
|
||||
if not program:
|
||||
continue
|
||||
adopted_any = False
|
||||
for cyc in prof.get("cycles", []) or []:
|
||||
if should_cancel is not None and should_cancel():
|
||||
cancelled = True
|
||||
break
|
||||
if progress is not None:
|
||||
progress(seen, total)
|
||||
seen += 1
|
||||
pts = cyc.get("importable")
|
||||
if not pts:
|
||||
continue
|
||||
store_cid = cyc.get("id")
|
||||
if store_cid and f"store:{store_cid}" in already:
|
||||
continue # already imported on a previous download
|
||||
local_id = await self._ps.add_reference_cycle(program, pts, {
|
||||
local_id = self._ps._add_reference_cycle_nosave(program, pts, { # noqa: SLF001
|
||||
"store_cycle_id": store_cid,
|
||||
"store_uploaded_at": cyc.get("createdAt"),
|
||||
"sampling_interval": (cyc.get("trace") or {}).get("sampleIntervalSec"),
|
||||
})
|
||||
"community": True,
|
||||
}, known_hashes=known_hashes)
|
||||
if local_id:
|
||||
if program not in touched:
|
||||
touched.append(program)
|
||||
cycles_imported += 1
|
||||
adopted_any = True
|
||||
if store_cid:
|
||||
imported_store_ids.append(store_cid)
|
||||
else:
|
||||
cycles_skipped += 1
|
||||
if adopted_any:
|
||||
profiles_adopted += 1
|
||||
# Stage 2: apply the bundled phase map (replace) + reconcile labels. Never
|
||||
@@ -467,12 +576,27 @@ class StoreBridge:
|
||||
# community-wide "download" (adoption) for the store's usage dashboard -- the real
|
||||
# metric of how many people actually pulled this into their integration. Fired in
|
||||
# the background so store latency never delays the adopt-bundle response.
|
||||
for program in touched:
|
||||
await self._ps.async_rebuild_envelope(program)
|
||||
if touched:
|
||||
await self._ps.async_save()
|
||||
if imported_store_ids:
|
||||
self._fire_download_telemetry(imported_store_ids)
|
||||
if failed_profiles and not cycles_imported and not cancelled:
|
||||
# Nothing new arrived and part of the setup could not be read: a failed
|
||||
# download, not "already on your device" (audit STORE-09).
|
||||
return {
|
||||
"error": STORE_UNREACHABLE, "failed_profiles": failed_profiles,
|
||||
"cycles_skipped": cycles_skipped, "phases_applied": phases_applied,
|
||||
}
|
||||
settings = bundle.get("settings") if isinstance(bundle.get("settings"), dict) else {}
|
||||
return {
|
||||
"profiles_adopted": profiles_adopted,
|
||||
"cycles_imported": cycles_imported,
|
||||
# Refused by the quality bar or as a duplicate (audit STORE-03/05).
|
||||
"cycles_skipped": cycles_skipped,
|
||||
**({"cancelled": True} if cancelled else {}),
|
||||
**({"partial": True, "failed_profiles": failed_profiles} if failed_profiles else {}),
|
||||
"phases_applied": phases_applied,
|
||||
"settings": settings,
|
||||
}
|
||||
@@ -492,7 +616,7 @@ class StoreBridge:
|
||||
name = str(p.get("name", "")).strip()
|
||||
try:
|
||||
start, end = float(p.get("start", 0)), float(p.get("end", 0))
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
if name and end > start:
|
||||
ranges.append({"name": name, "start": start, "end": end})
|
||||
|
||||
@@ -46,6 +46,10 @@ _LOAD_LOCK_KEY = f"{DOMAIN}_online_load_lock"
|
||||
# plumbing carries it end-to-end with no further wiring.
|
||||
_DEFAULT_PREFS: dict[str, Any] = {
|
||||
"show_contributor": True, # show "by <contributor>" attribution in the pickers
|
||||
# Publish the connected GitHub display name (often a real name) as the uploader of
|
||||
# cycles this install shares. Opt-in, like the website's showName consent; off
|
||||
# means shared cycles carry no name (audit STORE-14).
|
||||
"share_name": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ Never raises into the event loop - failures return ``None``/empty and are logged
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
@@ -42,9 +44,10 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SHAREABLE_SETTING_KEYS,
|
||||
sanitize_shared_settings,
|
||||
STORE_API_KEY,
|
||||
STORE_PROJECT_ID,
|
||||
STORE_WEB_ORIGIN,
|
||||
SUPPORTED_CYCLE_SCHEMA_VERSIONS,
|
||||
)
|
||||
|
||||
@@ -52,8 +55,11 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_APPLIANCE_TYPES = {"washer", "dryer", "dishwasher", "washer_dryer"}
|
||||
|
||||
# Max concurrent per-cycle rating aggregations when listing a profile's cycles.
|
||||
_RATING_FANOUT_LIMIT = 8
|
||||
# Error marker for a read the store could not answer (network failure, timeout, 5xx,
|
||||
# rate limit). Distinct from an empty result on purpose: an offline store used to read
|
||||
# as "no shared programs" and a failed download as "already on your device" (audit
|
||||
# STORE-09).
|
||||
STORE_UNREACHABLE = "store_unreachable"
|
||||
|
||||
# Max profiles hydrated concurrently when downloading a whole-device bundle. One query
|
||||
# each (the bundle skips the per-cycle rating fan-out), kept small to stay well under the
|
||||
@@ -183,6 +189,42 @@ def _decode(v: dict[str, Any]) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _rating_from_doc(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""``{"avg", "count"}`` from a doc's denormalized ``ratingSum`` / ``ratingCount``.
|
||||
|
||||
Never raises; missing or malformed fields read as no ratings."""
|
||||
try:
|
||||
count = int(doc.get("ratingCount") or 0)
|
||||
total = float(doc.get("ratingSum") or 0)
|
||||
avg = total / count if count > 0 else None
|
||||
except (TypeError, ValueError, AttributeError, OverflowError):
|
||||
# OverflowError: an integerValue decodes to an unbounded int.
|
||||
return {"avg": None, "count": 0}
|
||||
if avg is None or not math.isfinite(avg):
|
||||
return {"avg": None, "count": 0}
|
||||
return {"avg": avg, "count": count}
|
||||
|
||||
|
||||
def _token_uid(body: dict[str, Any]) -> str | None:
|
||||
"""The Firebase uid the token endpoint vouches for, or None.
|
||||
|
||||
``user_id`` of the refresh-token exchange response, else the ``user_id`` / ``sub``
|
||||
claim of the ID token that same response carried. Both come from Google over TLS
|
||||
in reply to our own request, so neither needs a signature check here; what must
|
||||
NOT be trusted is the uid a caller hands to ``connect`` (audit STORE-18).
|
||||
"""
|
||||
uid = body.get("user_id")
|
||||
if isinstance(uid, str) and uid:
|
||||
return uid
|
||||
try:
|
||||
payload = str(body.get("id_token") or "").split(".")[1]
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
||||
uid = claims.get("user_id") or claims.get("sub")
|
||||
except Exception: # noqa: BLE001 - malformed token -> no verified uid
|
||||
return None
|
||||
return uid if isinstance(uid, str) and uid else None
|
||||
|
||||
|
||||
def _decode_doc(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
out = {k: _decode(x) for k, x in doc.get("fields", {}).items()}
|
||||
name = doc.get("name", "")
|
||||
@@ -190,6 +232,53 @@ def _decode_doc(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def _favorite_count(value: Any) -> int | float:
|
||||
"""A row's ``favoriteCount`` as a number the browse can sort by: 0 when it is
|
||||
missing, a bool, a string or NaN (index rows and delta rows are both remote)."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
|
||||
return 0
|
||||
return value
|
||||
|
||||
|
||||
def _shape_index(raw: Any) -> dict[str, Any] | None:
|
||||
"""The published ``search-index.json`` (schema 1) as dict rows, or None.
|
||||
|
||||
Rows are positional arrays under ``fields``; a brand's id IS its ``brand_lc``,
|
||||
a device's ``brand_lc`` / ``model_lc`` are its lowercased names, as stored.
|
||||
"""
|
||||
if not isinstance(raw, dict) or raw.get("schema") != 1 or not raw.get("generatedAt"):
|
||||
return None
|
||||
fields = raw.get("fields")
|
||||
# Remote data, validated here so a bad index falls back to the direct queries
|
||||
# instead of raising in a consumer: every one keys rows by ``id``.
|
||||
if not isinstance(fields, dict) or not all(
|
||||
isinstance(fields.get(k), list) and "id" in fields[k] for k in ("brands", "devices")
|
||||
):
|
||||
return None
|
||||
|
||||
def _rows(name: str) -> list[dict[str, Any]]:
|
||||
names = fields[name]
|
||||
out = []
|
||||
for row in raw.get(name) or []:
|
||||
if isinstance(row, list) and len(row) == len(names):
|
||||
item = dict(zip(names, row))
|
||||
if isinstance(item.get("id"), str) and item["id"]:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
brands = _rows("brands")
|
||||
for b in brands:
|
||||
b["brand_lc"] = str(b.get("id") or "").lower()
|
||||
devices = _rows("devices")
|
||||
for d in devices:
|
||||
d["favoriteCount"] = _favorite_count(d.get("favoriteCount")) # the browse sorts by it
|
||||
# As the device documents store it (lowercased display name), NOT the id's
|
||||
# normalised token ("aeg lavamat" vs "aeg-lavamat"): brand filters compare it.
|
||||
d["brand_lc"] = str(d.get("brand") or "").lower()
|
||||
d["model_lc"] = str(d.get("model") or "").lower()
|
||||
return {"generatedAt": str(raw["generatedAt"]), "brands": brands, "devices": devices}
|
||||
|
||||
|
||||
# Firestore forbids directly-nested arrays, so a trace can't be stored as
|
||||
# [[offset, watts], ...]. On the wire we store an array of {o, w} maps and convert
|
||||
# to/from [[offset, watts], ...] pairs at the boundary (matches lib/trace.js).
|
||||
@@ -267,10 +356,14 @@ class StoreClient:
|
||||
self._id_token: str | None = None
|
||||
self._id_token_exp: float = 0.0
|
||||
self._id_token_rt: str | None = None # refresh token that produced the cached id_token
|
||||
self._id_token_uid: str | None = None # uid the token endpoint returned with it
|
||||
self._last_error: str | None = None # short reason for the last failed write, for the UI
|
||||
self._base = f"{self._FS}/projects/{project_id}/databases/(default)/documents"
|
||||
# key -> (expiry_epoch, value). Read-only catalog/config responses; see class docstring.
|
||||
self._read_cache: dict[str, tuple[float, Any]] = {}
|
||||
# Status changes this client made (device id -> (status, UTC stamp)): the daily
|
||||
# search index only learns of them at its next build. See _devices_from_index.
|
||||
self._status_overrides: dict[str, tuple[str, str]] = {}
|
||||
# Bumped on every catalog invalidation; a read captures it before its query and only
|
||||
# caches the result if it is unchanged afterwards, so an in-flight read that spans an
|
||||
# invalidation cannot re-cache a pre-write snapshot.
|
||||
@@ -336,15 +429,16 @@ class StoreClient:
|
||||
"""Drop cached brand/device catalog reads (call after a create/upload/promote write so
|
||||
a just-contributed or newly-approved entry appears immediately, not after the TTL).
|
||||
|
||||
Covers both the list queries (``brands:``/``devices:``) and the single-document
|
||||
Covers the list queries (``brands:``/``devices:``), the single-document
|
||||
lookups behind the identity badges (``brand:``/``device:``) -- a create writes the
|
||||
very document those resolve, so a stale hit there would show "not in the catalog"
|
||||
for an entry the user just added.
|
||||
for an entry the user just added -- and the search index's ``delta:`` query,
|
||||
which is where a just-created entry appears while the index predates it.
|
||||
"""
|
||||
self._cache_gen += 1
|
||||
for key in [
|
||||
k for k in self._read_cache
|
||||
if k.startswith(("brands:", "devices:", "brand:", "device:"))
|
||||
if k.startswith(("brands:", "devices:", "brand:", "device:", "delta:"))
|
||||
]:
|
||||
self._read_cache.pop(key, None)
|
||||
|
||||
@@ -385,12 +479,29 @@ class StoreClient:
|
||||
return None
|
||||
self._id_token = body.get("id_token")
|
||||
self._id_token_rt = refresh_token
|
||||
self._id_token_uid = _token_uid(body)
|
||||
try:
|
||||
self._id_token_exp = now + float(body.get("expires_in", 3600))
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
self._id_token_exp = now + 3600
|
||||
return self._id_token
|
||||
|
||||
def verified_uid(self, refresh_token: str) -> str | None:
|
||||
"""The uid the token endpoint returned for ``refresh_token``'s last exchange.
|
||||
|
||||
Only meaningful right after ``ensure_id_token(refresh_token)`` succeeded (same
|
||||
cache key, so another account's uid is never returned). Every authed write
|
||||
stamps this, not the uid stored at connect: the store rules require
|
||||
``uploaderUid`` / ``createdByUid`` / the confirmation doc id to equal
|
||||
``request.auth.uid``, so a mismatched stored uid made every write 403
|
||||
(audit STORE-18).
|
||||
"""
|
||||
return self._id_token_uid if self._id_token_rt == refresh_token else None
|
||||
|
||||
def _write_uid(self, refresh_token: str, stored_uid: str) -> str:
|
||||
"""The uid an authed write must carry (see ``verified_uid``)."""
|
||||
return self.verified_uid(refresh_token) or stored_uid
|
||||
|
||||
# ── reads (public, no token) ────────────────────────────────────────────────
|
||||
|
||||
async def _run_query(self, sq: dict[str, Any], parent: str = "") -> list[dict[str, Any]] | None:
|
||||
@@ -519,6 +630,86 @@ class StoreClient:
|
||||
return cached if include_pending else self._approved_only(cached)
|
||||
return None
|
||||
|
||||
# ── published catalog index (audit STORE-07) ─────────────────────────────────
|
||||
#
|
||||
# The store's deploy builds `search-index.json` daily (brands, devices,
|
||||
# profiles, as row arrays under a `fields` header). Type-wide device lists and the
|
||||
# brand list come from it, plus the entries created since it was built (one
|
||||
# small `createdAt` query, the same delta the website runs), so a Store search
|
||||
# costs ~0 Firestore reads instead of every device of the type (473 per install
|
||||
# per cache hour, silently truncated at 500). Any failure falls back to the
|
||||
# direct queries below.
|
||||
|
||||
_INDEX_TTL_S = 3600.0
|
||||
_INDEX_MISS_TTL_S = 300.0
|
||||
_INDEX_DELTA_LIMIT = 50
|
||||
|
||||
async def _catalog_index(self) -> dict[str, Any] | None:
|
||||
hit = self._cache_get("catalog_index")
|
||||
if hit is not None:
|
||||
return hit or None
|
||||
gen = self._cache_gen
|
||||
index: dict[str, Any] | None = None
|
||||
try:
|
||||
async with self._sess().get(f"{STORE_WEB_ORIGIN}/search-index.json", timeout=15) as resp:
|
||||
if resp.status == 200:
|
||||
index = _shape_index(await resp.json(content_type=None))
|
||||
except Exception as exc: # noqa: BLE001 - fall back to the direct queries
|
||||
_LOGGER.debug("Store search index unavailable: %s", exc)
|
||||
if gen == self._cache_gen:
|
||||
self._cache_put(
|
||||
"catalog_index", index or {},
|
||||
self._INDEX_TTL_S if index else self._INDEX_MISS_TTL_S,
|
||||
)
|
||||
return index
|
||||
|
||||
async def _index_delta(
|
||||
self, collection: str, generated_at: str, fields: tuple[str, ...]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Entries of ``collection`` created after the index was built (any status
|
||||
the browse shows); filtered in memory by the caller."""
|
||||
key = f"delta:{collection}:{generated_at}"
|
||||
return await self._cached_catalog_query(key, lambda: {
|
||||
"from": [{"collectionId": collection}],
|
||||
"select": {"fields": [{"fieldPath": f} for f in (*fields, "createdAt")]},
|
||||
"where": self._where([
|
||||
self._status_filter(True),
|
||||
{"fieldFilter": {"field": {"fieldPath": "createdAt"}, "op": "GREATER_THAN",
|
||||
"value": {"timestampValue": generated_at}}},
|
||||
]),
|
||||
"orderBy": [{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"}],
|
||||
"limit": self._INDEX_DELTA_LIMIT,
|
||||
})
|
||||
|
||||
async def _devices_from_index(
|
||||
self, brand: str | None, appliance_type: str | None, include_pending: bool,
|
||||
page_size: int,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
index = await self._catalog_index()
|
||||
if index is None:
|
||||
return None
|
||||
rows = {r["id"]: r for r in index["devices"]}
|
||||
for r in await self._index_delta("devices", index["generatedAt"], _DEVICE_LIST_FIELDS):
|
||||
r.setdefault("brand_lc", str(r.get("brand") or "").lower())
|
||||
r.setdefault("model_lc", str(r.get("model") or "").lower())
|
||||
rows[r["id"]] = r
|
||||
# The delta only carries entries CREATED after the build, so a device this
|
||||
# client just promoted (confirm_device) still reads "pending" from the index.
|
||||
for did, (status, at) in self._status_overrides.items():
|
||||
if did in rows and at > index["generatedAt"]:
|
||||
rows[did] = {**rows[did], "status": status}
|
||||
bl = (brand or "").lower()
|
||||
out = [
|
||||
r for r in rows.values()
|
||||
if (include_pending or r.get("status") == "approved")
|
||||
and r.get("status") in ("approved", "pending")
|
||||
and (not appliance_type or r.get("applianceType") == appliance_type)
|
||||
and (not bl or r.get("brand_lc") == bl)
|
||||
]
|
||||
# Delta rows come straight from Firestore, uncleaned: sort through the same guard.
|
||||
out.sort(key=lambda r: (-_favorite_count(r.get("favoriteCount")), str(r.get("id"))))
|
||||
return out[:page_size]
|
||||
|
||||
async def search_devices(
|
||||
self, brand: str | None = None, appliance_type: str | None = None,
|
||||
model_query: str | None = None, *, include_pending: bool = False, page_size: int = 500,
|
||||
@@ -538,6 +729,10 @@ class StoreClient:
|
||||
rows = [r for r in rows if str(r.get("model_lc", "")).startswith(p)]
|
||||
return rows
|
||||
|
||||
from_index = await self._devices_from_index(brand, appliance_type, include_pending, page_size)
|
||||
if from_index is not None:
|
||||
return _finish(from_index)
|
||||
|
||||
shared = self._serve_from_superset(f"{base}:1:{page_size}", include_pending, page_size)
|
||||
if shared is not None:
|
||||
return _finish(shared)
|
||||
@@ -608,6 +803,20 @@ class StoreClient:
|
||||
dropdown" case, and truncating it would make brands past the cap unfindable.
|
||||
"""
|
||||
prefix = (q or "").strip().lower()
|
||||
index = await self._catalog_index()
|
||||
if index is not None:
|
||||
rows = {r["id"]: r for r in index["brands"]}
|
||||
for r in await self._index_delta("brands", index["generatedAt"], _BRAND_LIST_FIELDS):
|
||||
r.setdefault("brand_lc", str(r.get("id") or "").lower())
|
||||
rows[r["id"]] = r
|
||||
out = [
|
||||
r for r in rows.values()
|
||||
if (include_pending or r.get("status") == "approved")
|
||||
and r.get("status") in ("approved", "pending")
|
||||
and str(r.get("brand_lc", "")).startswith(prefix)
|
||||
]
|
||||
out.sort(key=lambda r: str(r.get("brand_lc", "")))
|
||||
return out[:page_size]
|
||||
select = {"fields": [{"fieldPath": f} for f in _BRAND_LIST_FIELDS]}
|
||||
order = [{"field": {"fieldPath": "brand_lc"}, "direction": "ASCENDING"}]
|
||||
|
||||
@@ -651,10 +860,21 @@ class StoreClient:
|
||||
successful lookups are cached (a miss may simply mean "not contributed yet",
|
||||
which flips as soon as the user contributes it).
|
||||
"""
|
||||
return (await self._fetch_doc(path, cache_key=cache_key))[0]
|
||||
|
||||
async def _fetch_doc(
|
||||
self, path: str, *, cache_key: str | None = None,
|
||||
) -> tuple[dict[str, Any] | None, bool]:
|
||||
"""``(doc, reached)``: ``_get_doc`` plus whether the store answered at all.
|
||||
|
||||
``reached`` is False only when the store could not answer (network error,
|
||||
timeout, 5xx, rate limit), so a caller can tell "no such document" (403/404,
|
||||
``(None, True)``) from "offline" (``(None, False)``).
|
||||
"""
|
||||
if cache_key is not None:
|
||||
cached = self._cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
return cached, True
|
||||
# Capture the generation BEFORE the request: if an invalidation lands while this
|
||||
# read is in flight, caching its result would re-pin a pre-write document for the
|
||||
# full TTL. Same discipline as _fetch_and_cache.
|
||||
@@ -662,17 +882,18 @@ class StoreClient:
|
||||
try:
|
||||
async with self._sess().get(f"{self._base}/{path}", timeout=15) as resp:
|
||||
if resp.status != 200:
|
||||
if resp.status not in (403, 404):
|
||||
_LOGGER.debug("Store get %s HTTP %s", path, resp.status)
|
||||
return None
|
||||
if resp.status in (403, 404):
|
||||
return None, True
|
||||
_LOGGER.debug("Store get %s HTTP %s", path, resp.status)
|
||||
return None, False
|
||||
doc = await resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_LOGGER.debug("Store get %s error: %s", path, exc)
|
||||
return None
|
||||
return None, False
|
||||
out = _decode_doc(doc)
|
||||
if cache_key is not None and gen == self._cache_gen:
|
||||
self._cache_put(cache_key, out, self._CATALOG_CACHE_TTL_S)
|
||||
return out
|
||||
return out, True
|
||||
|
||||
async def get_device(self, device_id: str) -> dict[str, Any] | None:
|
||||
return await self._get_doc(f"devices/{_seg(device_id)}", cache_key=f"device:{device_id}")
|
||||
@@ -726,50 +947,14 @@ class StoreClient:
|
||||
self._cache_put("config:site", cfg, self._CONFIG_CACHE_TTL_S)
|
||||
return cfg
|
||||
|
||||
async def _rating_agg(self, parent_path: str) -> dict[str, Any]:
|
||||
"""count + average over the `ratings` subcollection under ``parent_path``.
|
||||
|
||||
Public (unauthenticated) aggregation -- ratings are world-readable. Returns
|
||||
``{"avg": float|None, "count": int}`` and never raises.
|
||||
"""
|
||||
body = {"structuredAggregationQuery": {
|
||||
"structuredQuery": {"from": [{"collectionId": "ratings"}]},
|
||||
"aggregations": [
|
||||
{"alias": "cnt", "count": {}},
|
||||
# NB: the Firestore aggregation operator is `avg`, NOT `average` --
|
||||
# the wrong name 400s the whole query and silently zeroes ratings.
|
||||
{"alias": "avg", "avg": {"field": {"fieldPath": "rating"}}},
|
||||
],
|
||||
}}
|
||||
try:
|
||||
async with self._sess().post(
|
||||
f"{self._base}/{parent_path}:runAggregationQuery",
|
||||
json=body, timeout=15,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return {"avg": None, "count": 0}
|
||||
rows = await resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_LOGGER.debug("Store rating aggregation error (%s): %s", parent_path, exc)
|
||||
return {"avg": None, "count": 0}
|
||||
agg = next((r["result"]["aggregateFields"] for r in rows if isinstance(r, dict) and "result" in r), None)
|
||||
if not agg:
|
||||
return {"avg": None, "count": 0}
|
||||
cnt = _decode(agg["cnt"]) if "cnt" in agg else 0
|
||||
avg = _decode(agg["avg"]) if ("avg" in agg and "nullValue" not in agg["avg"]) else None
|
||||
return {"avg": avg if (cnt and avg is not None) else None, "count": cnt or 0}
|
||||
|
||||
async def get_device_quality(self, device_id: str) -> dict[str, Any]:
|
||||
"""count + average of the device's 5-star quality ratings (info only)."""
|
||||
return await self._rating_agg(f"devices/{_seg(device_id)}")
|
||||
|
||||
async def cycle_rating(self, cycle_id: str) -> dict[str, Any]:
|
||||
"""count + average of a reference cycle's 5-star ratings (info only)."""
|
||||
return await self._rating_agg(f"cycles/{_seg(cycle_id)}")
|
||||
|
||||
async def get_profiles(self, dev_id: str, include_pending: bool = True, page_size: int = 100) -> list[dict[str, Any]]:
|
||||
async def get_profiles(
|
||||
self, dev_id: str, include_pending: bool = True, page_size: int = 100,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Shared programs for one catalog appliance, most-recent-first.
|
||||
|
||||
``None`` when the store could not be reached, ``[]`` when the appliance really
|
||||
has no shared programs: the two used to be the same ``[]`` (audit STORE-09).
|
||||
|
||||
``include_pending`` defaults to **True**, matching ``get_cycles`` and the
|
||||
device browse. It used to default to False, which made the Store tab list a
|
||||
device with a "Programs: N" chip and then report "No shared programs for this
|
||||
@@ -788,13 +973,16 @@ class StoreClient:
|
||||
"orderBy": [{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"}],
|
||||
"limit": page_size,
|
||||
}
|
||||
return await self._run_query(sq) or []
|
||||
return await self._run_query(sq)
|
||||
|
||||
async def device_profiles(self, brand: str, model: str, appliance_type: str) -> dict[str, Any]:
|
||||
"""Resolve the store deviceId from brand/model/type and return its profiles
|
||||
(approved + the caller's own pending), for the Share dialog's profile picker."""
|
||||
(approved + the caller's own pending), for the Share dialog's profile picker.
|
||||
Carries ``error`` when the store could not be reached (audit STORE-09)."""
|
||||
dev_id = device_id(appliance_type, brand, model)
|
||||
items = await self.get_profiles(dev_id, include_pending=True)
|
||||
if items is None:
|
||||
return {"device_id": dev_id, "items": [], "error": STORE_UNREACHABLE}
|
||||
return {"device_id": dev_id, "items": items}
|
||||
|
||||
async def get_device_bundle(self, dev_id: str, include_pending: bool = True) -> dict[str, Any]:
|
||||
@@ -803,45 +991,61 @@ class StoreClient:
|
||||
``cycles`` (hydrated by get_cycles). One device GET + one profiles query + one
|
||||
cycles query per profile. Never raises.
|
||||
|
||||
Star ratings are deliberately skipped here. They are browse-only decoration and
|
||||
the adopt path (``StoreBridge.download_device``) never reads them, but they cost
|
||||
one aggregation request *per cycle*: a 15-profile device turned a ~17-request
|
||||
download into ~60, all on the user's critical path.
|
||||
Failures are reported, never flattened into an empty bundle (audit STORE-09):
|
||||
``error`` when the device doc or the profile list could not be read (nothing is
|
||||
known about the setup), and per profile ``cycles_unavailable`` plus a top-level
|
||||
``failed_profiles`` count when that program's cycle query failed.
|
||||
"""
|
||||
device = await self.get_device(dev_id) or {}
|
||||
settings = device.get("settings") if isinstance(device.get("settings"), dict) else {}
|
||||
unreachable = {"device_id": dev_id, "settings": {}, "profiles": [], "error": STORE_UNREACHABLE}
|
||||
device, reached = await self._fetch_doc(
|
||||
f"devices/{_seg(dev_id)}", cache_key=f"device:{dev_id}",
|
||||
)
|
||||
if not reached:
|
||||
return unreachable
|
||||
profiles = await self.get_profiles(dev_id, include_pending=include_pending)
|
||||
if profiles is None:
|
||||
return unreachable
|
||||
device = device or {}
|
||||
settings = device.get("settings") if isinstance(device.get("settings"), dict) else {}
|
||||
|
||||
# Bound the per-profile fan-out: an unbounded gather over a device with many
|
||||
# profiles could burst hundreds of concurrent requests and trip the store's rate
|
||||
# limiter. A shared semaphore caps how many profiles hydrate at once.
|
||||
sem = asyncio.Semaphore(_BUNDLE_HYDRATE_LIMIT)
|
||||
|
||||
async def _cycles_for(p: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
async def _cycles_for(p: dict[str, Any]) -> list[dict[str, Any]] | None:
|
||||
pid = p.get("id")
|
||||
if not pid:
|
||||
return []
|
||||
async with sem:
|
||||
return await self.get_cycles(pid, include_pending=include_pending, include_ratings=False)
|
||||
return await self.get_cycles(pid, include_pending=include_pending)
|
||||
|
||||
# Fetch profiles' cycles concurrently (bounded) rather than one at a time.
|
||||
cycle_lists = await asyncio.gather(*(_cycles_for(p) for p in profiles))
|
||||
failed = 0
|
||||
for p, cycles in zip(profiles, cycle_lists):
|
||||
p["cycles"] = cycles
|
||||
return {"device_id": dev_id, "settings": settings, "profiles": profiles}
|
||||
if cycles is None:
|
||||
failed += 1
|
||||
p["cycles_unavailable"] = True
|
||||
p["cycles"] = cycles or []
|
||||
bundle: dict[str, Any] = {"device_id": dev_id, "settings": settings, "profiles": profiles}
|
||||
if failed:
|
||||
bundle["failed_profiles"] = failed
|
||||
return bundle
|
||||
|
||||
async def get_cycles(
|
||||
self, prof_id: str, include_pending: bool = True, page_size: int = 50,
|
||||
*, include_ratings: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Reference cycles for a profile, most-recent-first.
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Reference cycles for a profile, most-recent-first; ``None`` when the store
|
||||
could not be reached (``[]`` = genuinely none shared; audit STORE-09).
|
||||
|
||||
``include_pending`` (default True) also returns still-awaiting-approval
|
||||
recordings so they can be browsed/imported before the community votes them
|
||||
in (they are publicly readable, shown with an "awaiting approval" tag).
|
||||
Each cycle gets a ``rating`` = ``{"avg", "count"}`` summary attached, unless
|
||||
``include_ratings`` is False -- one extra aggregation request per cycle that
|
||||
only the browse UI displays (see get_device_bundle).
|
||||
Each cycle carries a ``rating`` = ``{"avg", "count"}`` summary read from the
|
||||
denormalized ``ratingSum`` / ``ratingCount`` the store keeps on the cycle doc,
|
||||
so it costs no extra request. Until 0.5.8 it was one aggregation query per
|
||||
listed cycle (audit STORE-15); a cycle without the fields has no ratings.
|
||||
"""
|
||||
sq = {
|
||||
"from": [{"collectionId": "cycles"}],
|
||||
@@ -852,23 +1056,12 @@ class StoreClient:
|
||||
"orderBy": [{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"}],
|
||||
"limit": page_size,
|
||||
}
|
||||
cycles = [self._with_decoded_trace(c) for c in (await self._run_query(sq) or [])]
|
||||
if not include_ratings:
|
||||
return cycles
|
||||
# Attach each cycle's 5-star rating summary (info-only; the aggregation lives
|
||||
# in a subcollection so it can't ride the list query). Bound concurrency with
|
||||
# a semaphore so a large page can't fan out into dozens of simultaneous
|
||||
# aggregation requests.
|
||||
sem = asyncio.Semaphore(_RATING_FANOUT_LIMIT)
|
||||
async def _rate(cyc: dict[str, Any]) -> dict[str, Any]:
|
||||
cid = cyc.get("id")
|
||||
if not cid:
|
||||
return {"avg": None, "count": 0}
|
||||
async with sem:
|
||||
return await self.cycle_rating(cid)
|
||||
summaries = await asyncio.gather(*(_rate(c) for c in cycles), return_exceptions=True)
|
||||
for cyc, summary in zip(cycles, summaries):
|
||||
cyc["rating"] = summary if isinstance(summary, dict) else {"avg": None, "count": 0}
|
||||
rows = await self._run_query(sq)
|
||||
if rows is None:
|
||||
return None
|
||||
cycles = [self._with_decoded_trace(c) for c in rows]
|
||||
for cyc in cycles:
|
||||
cyc["rating"] = _rating_from_doc(cyc)
|
||||
return cycles
|
||||
|
||||
async def get_cycle(self, cycle_id: str) -> dict[str, Any] | None:
|
||||
@@ -967,6 +1160,7 @@ class StoreClient:
|
||||
token = await self.ensure_id_token(refresh_token)
|
||||
if not token:
|
||||
return _out(None, False)
|
||||
uid = self._write_uid(refresh_token, uid)
|
||||
|
||||
# Preserve the documented never-raise contract: malformed metadata/points must
|
||||
# return a failure marker (with _last_error set), not propagate an exception to
|
||||
@@ -989,7 +1183,7 @@ class StoreClient:
|
||||
program = required["program"]
|
||||
try:
|
||||
interval = float(meta.get("sampleIntervalSec") or 0)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
interval = 0.0
|
||||
if appliance not in _APPLIANCE_TYPES:
|
||||
_LOGGER.warning("Store upload: invalid applianceType %r", appliance)
|
||||
@@ -1002,7 +1196,7 @@ class StoreClient:
|
||||
qc_code = qc if qc in (1, 2, 3) else 3
|
||||
try:
|
||||
pts = [[float(p[0]), float(p[1])] for p in (points or [])[:10000] if len(p) >= 2]
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
self._last_error = "malformed trace points"
|
||||
return _out(None, False)
|
||||
if len(pts) < 2:
|
||||
@@ -1028,11 +1222,7 @@ class StoreClient:
|
||||
# Defense in depth at the store boundary: keep only allow-listed, numeric
|
||||
# settings (never trust the caller to have filtered) so nothing arbitrary is
|
||||
# ever written to the shared device doc.
|
||||
filtered = {
|
||||
str(k): v for k, v in settings.items()
|
||||
if k in SHAREABLE_SETTING_KEYS
|
||||
and isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||
}
|
||||
filtered = sanitize_shared_settings(settings)
|
||||
if filtered:
|
||||
device_fields["settings"] = filtered
|
||||
ok = ok and await self._commit_create(token, f"devices/{d_id}", device_fields)
|
||||
@@ -1052,7 +1242,7 @@ class StoreClient:
|
||||
# 0.0 (which would ship a bogus zero-length phase to the catalog).
|
||||
try:
|
||||
return {"name": str(p.get("name", "")), "start": float(p["start"]), "end": float(p["end"])}
|
||||
except (KeyError, TypeError, ValueError):
|
||||
except (KeyError, TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
valid_phases = [
|
||||
vp for vp in (_valid_phase(p) for p in phases if isinstance(p, dict)) if vp is not None
|
||||
@@ -1083,9 +1273,10 @@ class StoreClient:
|
||||
cyc_ok, created = await self._commit_create_ex(token, f"cycles/{cyc_id}", cycle_fields)
|
||||
if not cyc_ok:
|
||||
return _out(None, False)
|
||||
# NB: cycle/profile counts are CALCULATED on the store (COUNT aggregation over
|
||||
# approved+pending), not maintained as a running total here -- a best-effort
|
||||
# increment that a rule denied is what left the browse counters stuck at 0.
|
||||
# NB: the integration never maintains cycle/profile counts: the website
|
||||
# increments them in the same batched write that creates the item (the store
|
||||
# rules only allow +1 alongside a new child). A best-effort increment from
|
||||
# here that a rule denied is what once left the browse counters stuck at 0.
|
||||
return _out(cyc_id, created)
|
||||
|
||||
async def upload_device_bundle(
|
||||
@@ -1167,6 +1358,7 @@ class StoreClient:
|
||||
token = await self.ensure_id_token(refresh_token)
|
||||
if not token:
|
||||
return None
|
||||
uid = self._write_uid(refresh_token, uid)
|
||||
dev_path = self._doc_path(f"devices/{device_id}")
|
||||
conf_path = self._doc_path(f"devices/{device_id}/confirmations/{uid}")
|
||||
writes = [
|
||||
@@ -1198,7 +1390,7 @@ class StoreClient:
|
||||
status = dev.get("status")
|
||||
try:
|
||||
threshold = int((await self.get_config()).get("confirmThreshold") or 5)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
threshold = 5
|
||||
if status == "pending" and count >= threshold:
|
||||
promote = [{
|
||||
@@ -1208,26 +1400,52 @@ class StoreClient:
|
||||
}]
|
||||
if (await self._commit(token, promote))[0]:
|
||||
status = "approved"
|
||||
self._status_overrides[device_id] = (
|
||||
status, time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()) + "Z"
|
||||
)
|
||||
# Promotion changes catalog visibility (pending -> approved); drop cached
|
||||
# listings so the newly-approved device shows in approved-only searches now.
|
||||
self._invalidate_catalog_cache()
|
||||
return {"confirmed": True, "confirmCount": count, "status": status}
|
||||
|
||||
async def rate_device(self, refresh_token: str, uid: str, device_id: str, rating: int) -> bool:
|
||||
"""Set this user's 5-star quality rating for a device (info only)."""
|
||||
"""Set this user's 5-star quality rating for a device (info only).
|
||||
|
||||
The device's denormalized ``ratingSum`` / ``ratingCount`` move in the SAME
|
||||
batch, as the website's ``rateDevice`` does (the store rules tie the two with
|
||||
``existsAfter`` / ``getAfter``). Writing only the rating doc, as this did until
|
||||
0.5.8, left every integration rating out of the totals the store shows (audit
|
||||
STORE-15). The prior rating is read uncached so an edit shifts the sum by the
|
||||
difference instead of counting a second rating."""
|
||||
if rating not in (1, 2, 3, 4, 5):
|
||||
return False
|
||||
token = await self.ensure_id_token(refresh_token)
|
||||
if not token:
|
||||
return False
|
||||
uid = self._write_uid(refresh_token, uid)
|
||||
prev_doc = await self._get_doc(f"devices/{_seg(device_id)}/ratings/{_seg(uid)}")
|
||||
prev = prev_doc.get("rating") if isinstance(prev_doc, dict) else None
|
||||
path = self._doc_path(f"devices/{device_id}/ratings/{uid}")
|
||||
writes = [{
|
||||
dev_path = self._doc_path(f"devices/{device_id}")
|
||||
writes: list[dict[str, Any]] = [{
|
||||
"update": {"name": path, "fields": {"uid": _encode(uid), "rating": _encode(rating)}},
|
||||
"updateTransforms": [{"fieldPath": "updatedAt", "setToServerValue": "REQUEST_TIME"}],
|
||||
}]
|
||||
if prev not in (1, 2, 3, 4, 5):
|
||||
writes.append({"transform": {"document": dev_path, "fieldTransforms": [
|
||||
{"fieldPath": "ratingCount", "increment": _encode(1)},
|
||||
{"fieldPath": "ratingSum", "increment": _encode(rating)},
|
||||
]}})
|
||||
elif prev != rating:
|
||||
writes.append({"transform": {"document": dev_path, "fieldTransforms": [
|
||||
{"fieldPath": "ratingSum", "increment": _encode(rating - prev)},
|
||||
]}})
|
||||
ok, body = await self._commit(token, writes)
|
||||
if not ok:
|
||||
_LOGGER.warning("Store rate_device failed: %s", body[:200])
|
||||
else:
|
||||
# The device doc's totals just changed; drop the cached point read.
|
||||
self._invalidate_catalog_cache()
|
||||
return ok
|
||||
|
||||
async def bump_downloads(self, cycle_ids: list[str]) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData Setup",
|
||||
"description": "Configure your washing machine or other appliance.\n\nPower sensor is required.\n\nAfter setup, open the WashData panel from the sidebar (or go to [/ha-washdata](/ha-washdata)) to view cycles, tune detection and manage profiles.",
|
||||
"description": "Set up a washing machine or other appliance. A power sensor is required.\n\nAfter setup, open the WashData panel from the sidebar ([/ha-washdata](/ha-washdata)) to see cycles, profiles and all settings.",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "A friendly name for this device (e.g., 'Washing Machine', 'Dishwasher').",
|
||||
"device_type": "What type of appliance is this? Helps tailor detection and labeling.",
|
||||
"power_sensor": "The sensor entity that reports real-time power consumption (in watts) from your smart plug.",
|
||||
"min_power": "Power readings above this threshold (in watts) indicate the appliance is running. Start with 2W for most devices."
|
||||
"device_type": "Sets detection defaults for this kind of appliance.",
|
||||
"power_sensor": "The smart plug sensor that reports live power in watts.",
|
||||
"min_power": "Readings above this (in watts) mean the appliance is running. 2 W suits most devices."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Update the device name, appliance type, or power sensor.\n\nThe WashData panel (sidebar, or [/ha-washdata](/ha-washdata)) is where detection, cycles, profiles and notifications are managed.",
|
||||
"description": "Change the device name, appliance type or power sensor.\n\nEverything else is in the WashData panel (sidebar, or [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Rename the device or change its core hardware settings. All detection, matching and notification settings are in the [WashData panel](/ha-washdata) (also reachable from the sidebar).",
|
||||
"description": "Rename the device or change its core settings. Everything else is in the [WashData panel](/ha-washdata) in the sidebar.",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profile Name",
|
||||
"description": "The name of an existing profile (create profiles in Manage Profiles menu). Leave blank to remove label."
|
||||
"description": "An existing profile name (create profiles in the WashData panel). Leave blank to remove the label."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Confidence Threshold",
|
||||
"description": "Minimum match confidence (0.50-0.95) to apply labels."
|
||||
"description": "Minimum match confidence (0.50-0.95) to apply labels. Leave empty to use the device's Auto-Label Confidence setting."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Export Config",
|
||||
"description": "Export this device's profiles, cycles, and settings to a JSON file (per device).",
|
||||
"description": "Export this device's profiles, cycles and settings to a JSON file.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Submit Cycle Feedback",
|
||||
"description": "Confirm or correct an auto-detected program after a completed cycle. Provide either `entry_id` (advanced) or `device_id` (recommended).",
|
||||
"description": "Confirm or correct the detected program of a finished cycle. Give `device_id` (recommended) or `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Mark Unloaded",
|
||||
"description": "Confirm the finished load has been taken out of a WashData device, clearing the Clean state and any unload reminder.",
|
||||
"description": "Confirm the finished load was taken out. Clears the Clean state and any unload reminder.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Match Ambiguity"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Maintenance due",
|
||||
"state": {
|
||||
"on": "Due",
|
||||
"off": "Not due"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Due task IDs"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Due tasks",
|
||||
"state": {
|
||||
"descale": "Descale",
|
||||
"filter_clean": "Clean filter",
|
||||
"drum_clean": "Clean drum",
|
||||
"bearing_service": "Bearing service",
|
||||
"other": "Other",
|
||||
"salt": "Refill salt",
|
||||
"rinse_aid": "Refill rinse aid",
|
||||
"lint_filter": "Clean lint filter",
|
||||
"condenser_clean": "Clean condenser"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Anti-Wrinkle",
|
||||
"interrupted": "Interrupted",
|
||||
"force_stopped": "Force Stopped",
|
||||
"rinse": "Rinse",
|
||||
"unknown": "Unknown",
|
||||
"clean": "Clean",
|
||||
"delay_wait": "Delay Start"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Cycle anomaly",
|
||||
"state": {
|
||||
"overrun": "Running long",
|
||||
"stalled": "Stalled"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,7 +58,7 @@ class Task:
|
||||
|
||||
id: str
|
||||
entry_id: str
|
||||
kind: str # 'reprocess' | 'ml_training' | 'pg_history' | 'pg_sweep'
|
||||
kind: str # reprocess, rebuild, ml_training, auto_label, trim, split, merge, store_download, history_import, history_import_apply, pg_history, pg_sweep, pg_detail (ws_api's reg.create calls)
|
||||
label: str # English fallback shown only if no label_key resolves
|
||||
# Panel-localizable label: the pill renders _t(label_key, label_params, label)
|
||||
# so per-step progress text is translated. When label_key is None the pill
|
||||
|
||||
@@ -38,6 +38,21 @@ import homeassistant.util.dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return the current time as an aware UTC datetime, for interval arithmetic.
|
||||
|
||||
Two aware datetimes that share one tzinfo instance are subtracted on their
|
||||
wall-clock fields, and every ``dt_util.now()`` stamp shares HA's ZoneInfo, so
|
||||
``now() - earlier_now()`` is an hour wrong across a DST change (audit
|
||||
DETECT-01). Use this for anything that is subtracted, compared or stored to
|
||||
be subtracted later; keep ``dt_util.now()`` only where the LOCAL clock is the
|
||||
point (quiet hours, "finished at 14:05"). It reads ``dt_util.now()`` rather
|
||||
than ``dt_util.utcnow()`` so tests that freeze ``dt_util.now`` keep
|
||||
controlling the clock.
|
||||
"""
|
||||
return dt_util.as_utc(dt_util.now())
|
||||
|
||||
# Type aliases
|
||||
PowerPoint = list[Any] | tuple[Any, ...]
|
||||
PowerData = list[PowerPoint]
|
||||
@@ -103,7 +118,7 @@ def power_data_to_offsets(
|
||||
parsed_start = dt_util.parse_datetime(start_time_iso)
|
||||
if parsed_start is not None:
|
||||
base_ts = parsed_start.timestamp()
|
||||
except (ValueError, OSError) as e:
|
||||
except (ValueError, OSError, OverflowError) as e:
|
||||
_LOGGER.debug("Failed to parse start_time_iso %s: %s", start_time_iso, e)
|
||||
result: list[list[float]] = []
|
||||
for item in power_data:
|
||||
@@ -114,7 +129,7 @@ def power_data_to_offsets(
|
||||
base_ts = ts_abs # use first reading as anchor
|
||||
offset = round(ts_abs - base_ts, 1)
|
||||
result.append([max(0.0, offset), p])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
except (TypeError, ValueError, IndexError, OverflowError):
|
||||
continue
|
||||
return result
|
||||
|
||||
@@ -124,7 +139,7 @@ def power_data_to_offsets(
|
||||
for item in power_data:
|
||||
try:
|
||||
result.append([float(item[0]), float(item[1])])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
except (TypeError, ValueError, IndexError, OverflowError):
|
||||
continue
|
||||
return result
|
||||
|
||||
@@ -135,7 +150,7 @@ def power_data_to_offsets(
|
||||
parsed_start = dt_util.parse_datetime(start_time_iso)
|
||||
if parsed_start is not None:
|
||||
start_ts = parsed_start.timestamp()
|
||||
except (ValueError, OSError) as e:
|
||||
except (ValueError, OSError, OverflowError) as e:
|
||||
_LOGGER.debug("Failed to parse datetime %s: %s", start_time_iso, e)
|
||||
result: list[list[float]] = []
|
||||
for item in power_data:
|
||||
@@ -148,7 +163,7 @@ def power_data_to_offsets(
|
||||
if start_ts is None:
|
||||
start_ts = ts.timestamp()
|
||||
result.append([round(ts.timestamp() - start_ts, 1), p])
|
||||
except (TypeError, ValueError, AttributeError, IndexError):
|
||||
except (TypeError, ValueError, AttributeError, IndexError, OverflowError):
|
||||
continue
|
||||
return result
|
||||
|
||||
@@ -161,7 +176,7 @@ def power_data_to_offsets(
|
||||
if parsed is None:
|
||||
return []
|
||||
base_ts = parsed.timestamp()
|
||||
except (ValueError, OSError) as e:
|
||||
except (ValueError, OSError, OverflowError) as e:
|
||||
_LOGGER.debug("Failed to parse datetime %s: %s", start_time_iso, e)
|
||||
return []
|
||||
|
||||
@@ -198,7 +213,7 @@ def power_data_to_offsets(
|
||||
offset, p, len(result),
|
||||
)
|
||||
result.append([max(0.0, offset), p])
|
||||
except (TypeError, ValueError, AttributeError, IndexError):
|
||||
except (TypeError, ValueError, AttributeError, IndexError, OverflowError):
|
||||
continue
|
||||
return result
|
||||
|
||||
@@ -206,39 +221,6 @@ def power_data_to_offsets(
|
||||
return []
|
||||
|
||||
|
||||
def power_data_offsets_to_datetimes(
|
||||
power_data: PowerData,
|
||||
start_time_iso: str,
|
||||
) -> list[tuple[datetime, float]]:
|
||||
"""Convert stored ``[[offset_sec, power], ...]`` to ``[(datetime, power), ...]``.
|
||||
|
||||
Args:
|
||||
power_data: Offset-format power data.
|
||||
start_time_iso: ISO-8601 cycle start time.
|
||||
|
||||
Returns:
|
||||
List of ``(datetime, power)`` tuples. Empty list on failure.
|
||||
"""
|
||||
try:
|
||||
start_dt = dt_util.parse_datetime(start_time_iso)
|
||||
if start_dt is None:
|
||||
return []
|
||||
start_ts = start_dt.timestamp()
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
return []
|
||||
|
||||
result: list[tuple[datetime, float]] = []
|
||||
for item in power_data:
|
||||
try:
|
||||
offset = float(item[0])
|
||||
p = float(item[1])
|
||||
ts = datetime.fromtimestamp(start_ts + offset, tz=start_dt.tzinfo)
|
||||
result.append((ts, p))
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
def migrate_power_data_to_offsets(cycle: dict[str, Any]) -> bool:
|
||||
"""Migrate a single cycle's power_data to offset format in-place.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Настройка на WashData",
|
||||
"description": "Конфигурирайте вашата пералня или друг уред.\n\nНеобходим е сензор за мощност.\n\nСлед настройката отворете панела на WashData от страничната лента (или отидете на [/ha-washdata](/ha-washdata)), за да преглеждате цикли, да настроите откриването и да управлявате профили.",
|
||||
"description": "Настройте пералня или друг уред. Нужен е сензор за мощност.\n\nСлед настройката отворете панела на WashData от страничната лента ([/ha-washdata](/ha-washdata)): там са циклите, профилите и всички настройки.",
|
||||
"data": {
|
||||
"name": "Име на устройството",
|
||||
"device_type": "Тип устройство",
|
||||
@@ -12,43 +12,41 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Удобно име за това устройство (напр. „Перална машина“, „Миялна машина“).",
|
||||
"device_type": "Какъв тип уред е това? Помага за персонализиране на откриването и етикетирането.",
|
||||
"power_sensor": "Сензорният обект, който отчита консумацията на енергия в реално време (във ватове) от вашия интелигентен щепсел.",
|
||||
"min_power": "Отчитанията на мощност над този праг (във ватове) показват, че уредът работи. Започнете с 2W за повечето устройства."
|
||||
"device_type": "Задава стойностите по подразбиране за откриване за този вид уред.",
|
||||
"power_sensor": "Сензорът на смарт контакта, който отчита текущата мощност във ватове.",
|
||||
"min_power": "Стойности над тази (във ватове) означават, че уредът работи. 2 W е подходящо за повечето устройства."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Обновете името на устройството, типа на уреда или сензора за мощност.\n\nПанелът на WashData (странична лента или [/ha-washdata](/ha-washdata)) е мястото, където се управляват откривате, циклите, профилите и известията.",
|
||||
"title": "Преконфигуриране на WashData",
|
||||
"description": "Променете името на устройството, типа на уреда или сензора за мощност.\n\nВсичко останало е в панела на WashData (странична лента или [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Име на устройството",
|
||||
"device_type": "Тип устройство",
|
||||
"power_sensor": "Сензор за мощност",
|
||||
"min_power": "Минимален праг на мощност (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Неуспешно свързване",
|
||||
"invalid_auth": "Невалидно удостоверяване",
|
||||
"unknown": "Неочаквана грешка",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Прагът на мощност трябва да е по-голям от 0"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Устройството вече е конфигурирано",
|
||||
"reconfigure_successful": "Reconfiguration was successful"
|
||||
"reconfigure_successful": "Преконфигурирането беше успешно"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Преименувайте устройството или променете основните му хардуерни настройки. Всички настройки за откриване, съвпадение и известия са в [панела на WashData](/ha-washdata) (достъпен и от страничната лента).",
|
||||
"title": "Настройки на WashData",
|
||||
"description": "Преименувайте устройството или променете основните му настройки. Всичко останало е в [панела на WashData](/ha-washdata) в страничната лента.",
|
||||
"data": {
|
||||
"name": "Име на устройството",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Тип устройство",
|
||||
"power_sensor": "Сензор за мощност",
|
||||
"min_power": "Минимален праг на мощност (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,7 +58,7 @@
|
||||
"notify_live_waiting_message": "{device}: Все още няма съвпадащ профил.",
|
||||
"vs_typical_longer": "{pct}% по-дълго от обичайното",
|
||||
"vs_typical_shorter": "{pct}% по-кратко от обичайното",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Прагът на мощност трябва да е по-голям от 0"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -84,7 +82,7 @@
|
||||
"description": "Присвоете съществуващ профил към минал цикъл или премахнете етикета.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "устройство",
|
||||
"name": "Устройство",
|
||||
"description": "Устройството на пералната машина за етикетиране."
|
||||
},
|
||||
"cycle_id": {
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Име на профил",
|
||||
"description": "Името на съществуващ профил (създайте профили в менюто Управление на профили). Оставете празно, за да премахнете етикета."
|
||||
"description": "Име на съществуващ профил (профилите се създават в панела на WashData). Оставете празно, за да премахнете етикета."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Праг на доверие",
|
||||
"description": "Минимална степен на доверие (0,50-0,95) за прилагане на етикети."
|
||||
"description": "Минимална степен на доверие за съвпадение (0,50-0,95) за прилагане на етикети. Оставете празно, за да се използва настройката „Автоматично маркиране доверие“ на устройството."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Експортиране на конфигурация",
|
||||
"description": "Експортирайте профилите, циклите и настройките на това устройство в JSON файл (на устройство).",
|
||||
"description": "Експортиране на профилите, циклите и настройките на това устройство в JSON файл.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "устройство",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Изпратете обратна връзка за цикъл",
|
||||
"description": "Потвърдете или коригирайте автоматично открита програма след завършен цикъл. Въведете или `entry_id` (разширено) или `device_id` (препоръчително).",
|
||||
"description": "Потвърдете или коригирайте откритата програма на завършен цикъл. Въведете `device_id` (препоръчително) или `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "устройство",
|
||||
@@ -272,18 +270,18 @@
|
||||
}
|
||||
},
|
||||
"trigger_ml_training": {
|
||||
"name": "Trigger ML Training",
|
||||
"name": "Стартиране на ML обучение",
|
||||
"description": "Ръчно преквалификация на моделите ML на устройство от собствените етикети на това устройство (експериментални).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
"description": "The WashData device to retrain models for."
|
||||
"name": "Устройство",
|
||||
"description": "Устройството WashData, чиито модели да се преобучат."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Маркирай като разтоварено",
|
||||
"description": "Потвърдете, че готовото съдържание е извадено от устройство WashData, с което се изчиства състоянието \"Чисто\" и всяко напомняне за разтоварване.",
|
||||
"description": "Потвърдете, че готовото пране е извадено. Изчиства състоянието \"Чисто\" и напомнянето за разтоварване.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Устройство",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Неяснота на съвпадението"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Предстои поддръжка",
|
||||
"state": {
|
||||
"on": "Предстои",
|
||||
"off": "Не предстои"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID на предстоящи задачи"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Предстоящи задачи",
|
||||
"state": {
|
||||
"descale": "Премахване на котлен камък",
|
||||
"filter_clean": "Почистване на филтъра",
|
||||
"drum_clean": "Почистване на барабана",
|
||||
"bearing_service": "Сервиз на лагерите",
|
||||
"other": "Друго",
|
||||
"salt": "Допълване на сол",
|
||||
"rinse_aid": "Допълване на препарат за изплакване",
|
||||
"lint_filter": "Почистване на филтъра за мъх",
|
||||
"condenser_clean": "Почистване на кондензатора"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,14 +340,22 @@
|
||||
"anti_wrinkle": "Против бръчки",
|
||||
"interrupted": "Прекъснат",
|
||||
"force_stopped": "Принудително спряно",
|
||||
"rinse": "Изплакване",
|
||||
"unknown": "Неизвестен",
|
||||
"clean": "Чисто",
|
||||
"delay_wait": "Изчакване за стартиране"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Аномалия на цикъла",
|
||||
"state": {
|
||||
"overrun": "Работи дълго",
|
||||
"stalled": "Заседнал"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
"name": "програма"
|
||||
"name": "Програма"
|
||||
},
|
||||
"time_remaining": {
|
||||
"name": "Оставащо време"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData Setup",
|
||||
"description": "Konfigurišite svoju mašinu za pranje veša ili drugi uređaj.\n\nSenzor snage je obavezan.\n\nNakon postavljanja otvorite WashData panel s bočne trake (ili idite na [/ha-washdata](/ha-washdata)) za pregled ciklusa, podešavanje otkrivanja i upravljanje profilima.",
|
||||
"title": "Postavljanje WashData",
|
||||
"description": "Postavite mašinu za pranje veša ili drugi uređaj. Potreban je senzor snage.\n\nNakon postavljanja otvorite WashData panel s bočne trake ([/ha-washdata](/ha-washdata)) za cikluse, profile i sve postavke.",
|
||||
"data": {
|
||||
"name": "Naziv uređaja",
|
||||
"device_type": "Tip uređaja",
|
||||
@@ -12,25 +12,23 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Prijateljski naziv za ovaj uređaj (npr. 'Mašina za pranje veša', 'Mašina za pranje sudova').",
|
||||
"device_type": "Koja je ovo vrsta aparata? Pomaže u otkrivanju i označavanju po mjeri.",
|
||||
"power_sensor": "Senzorski entitet koji izvještava o potrošnji energije u realnom vremenu (u vatima) iz vašeg pametnog priključka.",
|
||||
"min_power": "Očitavanja snage iznad ovog praga (u vatima) pokazuju da uređaj radi. Počnite sa 2W za većinu uređaja."
|
||||
"device_type": "Postavlja zadane vrijednosti detekcije za ovu vrstu uređaja.",
|
||||
"power_sensor": "Senzor pametnog utikača koji javlja trenutnu snagu u vatima.",
|
||||
"min_power": "Očitanja iznad ove vrijednosti (u vatima) znače da uređaj radi. 2 W odgovara većini uređaja."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Ažurirajte naziv uređaja, tip uređaja ili senzor snage.\n\nWashData panel (bočna traka ili [/ha-washdata](/ha-washdata)) služi za upravljanje otkrivanjem, ciklusima, profilima i obavještenjima.",
|
||||
"title": "Rekonfiguracija WashData",
|
||||
"description": "Promijenite naziv uređaja, tip uređaja ili senzor snage.\n\nSve ostalo je u WashData panelu (bočna traka ili [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Naziv uređaja",
|
||||
"device_type": "Tip uređaja",
|
||||
"power_sensor": "Senzor snage",
|
||||
"min_power": "Minimalni prag snage (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Povezivanje nije uspjelo",
|
||||
"invalid_auth": "Nevažeća autentifikacija",
|
||||
"unknown": "Neočekivana greška",
|
||||
"invalid_power": "Prag snage mora biti veći od 0"
|
||||
},
|
||||
@@ -42,13 +40,13 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Preimenujte uređaj ili promijenite njegove osnovne postavke hardvera. Sve postavke otkrivanja, podudaranja i obavještavanja nalaze se na [WashData panelu](/ha-washdata) (dostupan i s bočne trake).",
|
||||
"title": "Postavke WashData",
|
||||
"description": "Preimenujte uređaj ili promijenite njegove osnovne postavke. Sve ostalo je u [WashData panelu](/ha-washdata) na bočnoj traci.",
|
||||
"data": {
|
||||
"name": "Naziv uređaja",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Tip uređaja",
|
||||
"power_sensor": "Senzor snage",
|
||||
"min_power": "Minimalni prag snage (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -74,7 +72,7 @@
|
||||
"bread_maker": "Pekač hljeba",
|
||||
"pump": "Pumpa / Drenažna pumpa",
|
||||
"generic": "Ostalo (napredno)",
|
||||
"other": "Threshold Device"
|
||||
"other": "Uređaj s pragom"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Ime profila",
|
||||
"description": "Naziv postojećeg profila (kreirajte profile u meniju Upravljanje profilima). Ostavite prazno da uklonite naljepnicu."
|
||||
"description": "Naziv postojećeg profila (profile kreirajte u WashData panelu). Ostavite prazno da uklonite oznaku."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Prag povjerenja",
|
||||
"description": "Minimalna pouzdanost podudaranja (0,50-0,95) za primjenu oznaka."
|
||||
"description": "Minimalna pouzdanost podudaranja (0,50-0,95) za primjenu oznaka. Ostavite prazno da se koristi postavka uređaja „Pouzdanost automatskog označavanja“."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Izvoz konfig",
|
||||
"description": "Izvezite profile, cikluse i postavke ovog uređaja u JSON datoteku (po uređaju).",
|
||||
"description": "Izvezite profile, cikluse i postavke ovog uređaja u JSON datoteku.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Uređaj",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Pošaljite povratne informacije o ciklusu",
|
||||
"description": "Potvrdite ili ispravite automatski otkriveni program nakon završenog ciklusa. Navedite ili `entry_id` (napredno) ili `device_id` (preporučeno).",
|
||||
"description": "Potvrdite ili ispravite otkriveni program završenog ciklusa. Navedite `device_id` (preporučeno) ili `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Uređaj",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Označi kao istovareno",
|
||||
"description": "Potvrdite da je gotov sadržaj izvađen iz WashData uređaja, čime se briše stanje \"Čisto\" i svaki podsjetnik za istovar.",
|
||||
"description": "Potvrdite da je gotov sadržaj izvađen. Briše stanje \"Čisto\" i podsjetnik za istovar.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Uređaj",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Dvosmislenost podudaranja"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Dospjelo održavanje",
|
||||
"state": {
|
||||
"on": "Dospjelo",
|
||||
"off": "Nije dospjelo"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID-ovi dospjelih zadataka"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Dospjeli zadaci",
|
||||
"state": {
|
||||
"descale": "Uklanjanje kamenca",
|
||||
"filter_clean": "Očisti filter",
|
||||
"drum_clean": "Očisti bubanj",
|
||||
"bearing_service": "Servis ležajeva",
|
||||
"other": "Ostalo",
|
||||
"salt": "Dopuni so",
|
||||
"rinse_aid": "Dopuni sredstvo za ispiranje",
|
||||
"lint_filter": "Očisti filter za dlačice",
|
||||
"condenser_clean": "Očisti kondenzator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Anti-gužvanje",
|
||||
"interrupted": "Prekinut",
|
||||
"force_stopped": "Prisilno zaustavljeno",
|
||||
"rinse": "Isperite",
|
||||
"unknown": "Nepoznato",
|
||||
"clean": "Čisto",
|
||||
"delay_wait": "Čeka se početak"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Anomalija ciklusa",
|
||||
"state": {
|
||||
"overrun": "Traje predugo",
|
||||
"stalled": "Zaglavljeno"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Nastavení WashData",
|
||||
"description": "Nakonfigurujte si pračku nebo jiný spotřebič.\n\nSenzor výkonu je vyžadován.\n\nPo nastavení otevřete panel WashData z postranního panelu (nebo přejděte na [/ha-washdata](/ha-washdata)) pro zobrazení cyklů, ladění detekce a správu profilů.",
|
||||
"description": "Nastavte pračku nebo jiný spotřebič. Je potřeba senzor výkonu.\n\nPo nastavení otevřete panel WashData z postranního panelu ([/ha-washdata](/ha-washdata)), kde najdete cykly, profily a všechna nastavení.",
|
||||
"data": {
|
||||
"name": "Název zařízení",
|
||||
"device_type": "Typ zařízení",
|
||||
@@ -12,25 +12,23 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Popisný název pro toto zařízení (např. 'Pračka', 'Myčka nádobí').",
|
||||
"device_type": "O jaký typ spotřebiče se jedná? Pomáhá přizpůsobit detekci a označování.",
|
||||
"power_sensor": "Entita senzoru, která hlásí spotřebu energie v reálném čase (ve wattech) z vaší chytré zástrčky.",
|
||||
"min_power": "Údaje o výkonu nad touto prahovou hodnotou (ve wattech) indikují, že spotřebič běží. U většiny zařízení začněte s 2W."
|
||||
"device_type": "Nastaví výchozí hodnoty detekce pro tento druh spotřebiče.",
|
||||
"power_sensor": "Senzor chytré zásuvky, který hlásí aktuální výkon ve wattech.",
|
||||
"min_power": "Hodnoty nad touto mezí (ve wattech) znamenají, že spotřebič běží. Pro většinu zařízení stačí 2 W."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Aktualizujte název zařízení, typ spotřebiče nebo senzor výkonu.\n\nPanel WashData (postranní panel nebo [/ha-washdata](/ha-washdata)) slouží ke správě detekce, cyklů, profilů a oznámení.",
|
||||
"title": "Rekonfigurace WashData",
|
||||
"description": "Změňte název zařízení, typ spotřebiče nebo senzor výkonu.\n\nVše ostatní je v panelu WashData (postranní panel nebo [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Název zařízení",
|
||||
"device_type": "Typ zařízení",
|
||||
"power_sensor": "Výkonový senzor",
|
||||
"min_power": "Minimální práh výkonu (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Připojení se nezdařilo",
|
||||
"invalid_auth": "Neplatné ověření",
|
||||
"unknown": "Neočekávaná chyba",
|
||||
"invalid_power": "Práh výkonu musí být větší než 0"
|
||||
},
|
||||
@@ -42,13 +40,13 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Přejmenujte zařízení nebo změňte jeho základní hardwarová nastavení. Veškerá nastavení detekce, porovnávání a oznámení jsou na [panelu WashData](/ha-washdata) (dostupný také z postranního panelu).",
|
||||
"title": "Nastavení WashData",
|
||||
"description": "Přejmenujte zařízení nebo změňte jeho základní nastavení. Vše ostatní je v [panelu WashData](/ha-washdata) v postranním panelu.",
|
||||
"data": {
|
||||
"name": "Název zařízení",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Typ zařízení",
|
||||
"power_sensor": "Výkonový senzor",
|
||||
"min_power": "Minimální práh výkonu (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Název profilu",
|
||||
"description": "Název existujícího profilu (profily vytvořte v nabídce Správa profilů). Chcete-li štítek odstranit, ponechte prázdné."
|
||||
"description": "Název existujícího profilu (profily vytvoříte v panelu WashData). Ponechte prázdné pro odebrání štítku."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Práh důvěry",
|
||||
"description": "Minimální spolehlivost shody (0,50–0,95) pro použití štítků."
|
||||
"description": "Minimální spolehlivost shody (0,50–0,95) pro použití štítků. Ponechte prázdné, chcete-li použít nastavení zařízení „Důvěra auto-označení“."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Export konfigurace",
|
||||
"description": "Exportujte profily, cykly a nastavení tohoto zařízení do souboru JSON (na zařízení).",
|
||||
"description": "Exportuje profily, cykly a nastavení tohoto zařízení do souboru JSON.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Zařízení",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Odeslat zpětnou vazbu k cyklu",
|
||||
"description": "Po dokončení cyklu potvrďte nebo opravte automaticky detekovaný program. Zadejte buď `entry_id` (pokročilé) nebo `device_id` (doporučeno).",
|
||||
"description": "Potvrďte nebo opravte detekovaný program dokončeného cyklu. Zadejte `device_id` (doporučeno) nebo `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Zařízení",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Označit jako vyložené",
|
||||
"description": "Potvrďte, že dokončená náplň byla vyjmuta ze zařízení WashData, čímž se zruší stav „Čistý“ i případná připomínka vyložení.",
|
||||
"description": "Potvrďte, že dokončená náplň byla vyjmuta. Zruší stav „Čistý“ i připomínku vyložení.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Zařízení",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Nejednoznačnost shody"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Údržba na řadě",
|
||||
"state": {
|
||||
"on": "Na řadě",
|
||||
"off": "Není na řadě"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID úkolů na řadě"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Úkoly na řadě",
|
||||
"state": {
|
||||
"descale": "Odvápnění",
|
||||
"filter_clean": "Vyčistit filtr",
|
||||
"drum_clean": "Vyčistit buben",
|
||||
"bearing_service": "Servis ložisek",
|
||||
"other": "Jiné",
|
||||
"salt": "Doplnit sůl",
|
||||
"rinse_aid": "Doplnit leštidlo",
|
||||
"lint_filter": "Vyčistit filtr na vlákna",
|
||||
"condenser_clean": "Vyčistit kondenzátor"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Proti pomačkání",
|
||||
"interrupted": "Přerušeno",
|
||||
"force_stopped": "Nuceně zastaveno",
|
||||
"rinse": "Máchání",
|
||||
"unknown": "Neznámý",
|
||||
"clean": "Čistý",
|
||||
"delay_wait": "Čekání na spuštění"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Anomálie cyklu",
|
||||
"state": {
|
||||
"overrun": "Běží dlouho",
|
||||
"stalled": "Zaseknuto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData opsætning",
|
||||
"description": "Konfigurer din vaskemaskine eller andet apparat.\n\nStrømsensor er påkrævet.\n\nEfter opsætning skal du åbne WashData-panelet fra sidebjælken (eller gå til [/ha-washdata](/ha-washdata)) for at se cyklusser, justere registrering og administrere profiler.",
|
||||
"description": "Opsæt en vaskemaskine eller et andet apparat. En effektsensor er påkrævet.\n\nÅbn efter opsætningen WashData-panelet fra sidebjælken ([/ha-washdata](/ha-washdata)) for at se cyklusser, profiler og alle indstillinger.",
|
||||
"data": {
|
||||
"name": "Enhedens navn",
|
||||
"device_type": "Enhedstype",
|
||||
@@ -12,55 +12,53 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Et venligt navn for denne enhed (f.eks. \"Vaskemaskine\", \"Opvaskemaskine\").",
|
||||
"device_type": "Hvilken type apparat er dette? Hjælper med at skræddersy detektering og mærkning.",
|
||||
"power_sensor": "Sensorenheden, der rapporterer strømforbrug i realtid (i watt) fra dit smartstik.",
|
||||
"min_power": "Effektaflæsninger over denne tærskel (i watt) indikerer, at apparatet kører. Start med 2W for de fleste enheder."
|
||||
"device_type": "Indstiller registreringsstandarder for denne type apparat.",
|
||||
"power_sensor": "Smartstikkets sensor, der rapporterer aktuel effekt i watt.",
|
||||
"min_power": "Målinger over denne værdi (i watt) betyder, at apparatet kører. 2 W passer til de fleste enheder."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Opdater enhedsnavnet, apparattypen eller strømsensoren.\n\nWashData-panelet (sidebjælken eller [/ha-washdata](/ha-washdata)) er, hvor registrering, cyklusser, profiler og notifikationer administreres.",
|
||||
"title": "Genkonfigurer WashData",
|
||||
"description": "Skift enhedsnavn, apparattype eller effektsensor.\n\nAlt andet findes i WashData-panelet (sidebjælken eller [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Enhedens navn",
|
||||
"device_type": "Enhedstype",
|
||||
"power_sensor": "Strømsensor",
|
||||
"min_power": "Minimum effekttærskel (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kunne ikke oprette forbindelse",
|
||||
"invalid_auth": "Ugyldig godkendelse",
|
||||
"unknown": "Uventet fejl",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Effekttærsklen skal være større end 0"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Enheden er allerede konfigureret",
|
||||
"reconfigure_successful": "Reconfiguration was successful"
|
||||
"reconfigure_successful": "Genkonfigurationen lykkedes"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Omdøb enheden eller skift dens grundlæggende hardwareindstillinger. Alle indstillinger for registrering, matching og notifikationer findes i [WashData-panelet](/ha-washdata) (også tilgængeligt fra sidebjælken).",
|
||||
"title": "WashData-indstillinger",
|
||||
"description": "Omdøb enheden eller skift dens grundindstillinger. Alt andet findes i [WashData-panelet](/ha-washdata) i sidebjælken.",
|
||||
"data": {
|
||||
"name": "Enhedsnavn",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Enhedstype",
|
||||
"power_sensor": "Strømsensor",
|
||||
"min_power": "Minimum effekttærskel (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"timer_default_message": "{device}: {minutes} min timer",
|
||||
"timer_default_message": "{device}: timer på {minutes} min",
|
||||
"timer_pause_action_title": "Genoptag cyklus",
|
||||
"timer_pause_body_suffix": "Cyklussen er sat på pause. Åbn WashData-panelet for at genoptage.",
|
||||
"unload_dismiss_action_title": "Stop påmindelser",
|
||||
"notify_live_waiting_message": "{device}: Ingen profil genkendt endnu.",
|
||||
"vs_typical_longer": "{pct}% længere end normalt",
|
||||
"vs_typical_shorter": "{pct}% kortere end normalt",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Effekttærsklen skal være større end 0"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -70,7 +68,7 @@
|
||||
"dryer": "Tørretumbler",
|
||||
"washer_dryer": "Vaske-tørretumbler kombi",
|
||||
"dishwasher": "Opvaskemaskine",
|
||||
"air_fryer": "Air Fryer",
|
||||
"air_fryer": "Airfryer",
|
||||
"bread_maker": "Brødmaskine",
|
||||
"pump": "Pumpe / Sump Pumpe",
|
||||
"generic": "Andet (avanceret)",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profilnavn",
|
||||
"description": "Navnet på en eksisterende profil (opret profiler i menuen Administrer profiler). Lad være tomt for at fjerne etiketten."
|
||||
"description": "Navnet på en eksisterende profil (opret profiler i WashData-panelet). Lad være tomt for at fjerne mærkningen."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Tillidsgrænse",
|
||||
"description": "Mindste matchkonfidens (0,50-0,95) for at anvende etiketter."
|
||||
"description": "Mindste matchkonfidens (0,50-0,95) for at anvende etiketter. Lad feltet stå tomt for at bruge enhedens indstilling \"Auto-mærkning konfidens\"."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Eksporter Konfig",
|
||||
"description": "Eksporter denne enheds profiler, cyklusser og indstillinger til en JSON-fil (pr. enhed).",
|
||||
"description": "Eksporter denne enheds profiler, cyklusser og indstillinger til en JSON-fil.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Enhed",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Indsend cyklusfeedback",
|
||||
"description": "Bekræft eller ret et auto-detekteret program efter en afsluttet cyklus. Angiv enten \"entry_id\" (avanceret) eller \"device_id\" (anbefales).",
|
||||
"description": "Bekræft eller ret det registrerede program for en afsluttet cyklus. Angiv `device_id` (anbefales) eller `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Enhed",
|
||||
@@ -272,18 +270,18 @@
|
||||
}
|
||||
},
|
||||
"trigger_ml_training": {
|
||||
"name": "Trigger ML Training",
|
||||
"description": "Manually retrain the on-device ML models from this device's own labelled cycles (experimental).",
|
||||
"name": "Start ML-træning",
|
||||
"description": "Gentræn ML-modellerne på enheden manuelt ud fra enhedens egne mærkede cyklusser (eksperimentel).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
"description": "The WashData device to retrain models for."
|
||||
"name": "Enhed",
|
||||
"description": "WashData-enheden, hvis modeller skal gentrænes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Markér som aflæsset",
|
||||
"description": "Bekræft, at den færdige vask er taget ud af en WashData-enhed, hvilket rydder tilstanden \"Ren\" og en eventuel aflæsningspåmindelse.",
|
||||
"description": "Bekræft, at den færdige vask er taget ud. Rydder tilstanden \"Ren\" og en eventuel aflæsningspåmindelse.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Enhed",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Matchtvetydighed"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Vedligeholdelse forfalder",
|
||||
"state": {
|
||||
"on": "Forfalden",
|
||||
"off": "Ikke forfalden"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Id'er for forfaldne opgaver"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Forfaldne opgaver",
|
||||
"state": {
|
||||
"descale": "Afkalk",
|
||||
"filter_clean": "Rengør filter",
|
||||
"drum_clean": "Rengør tromle",
|
||||
"bearing_service": "Lejeservice",
|
||||
"other": "Andet",
|
||||
"salt": "Påfyld salt",
|
||||
"rinse_aid": "Påfyld afspændingsmiddel",
|
||||
"lint_filter": "Rengør fnugfilter",
|
||||
"condenser_clean": "Rengør kondensator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Anti-Rynke",
|
||||
"interrupted": "Afbrudt",
|
||||
"force_stopped": "Tving stoppet",
|
||||
"rinse": "Skylle",
|
||||
"unknown": "Ukendt",
|
||||
"clean": "Ren",
|
||||
"delay_wait": "Venter på at starte"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Cyklusanomali",
|
||||
"state": {
|
||||
"overrun": "Kører længere",
|
||||
"stalled": "Gået i stå"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData-Setup",
|
||||
"description": "Konfigurieren Sie Ihre Waschmaschine oder ein anderes Gerät.\n\nLeistungssensor ist erforderlich.\n\nNach der Einrichtung öffnen Sie das WashData-Panel über die Seitenleiste (oder gehen Sie zu [/ha-washdata](/ha-washdata)), um Zyklen anzuzeigen, die Erkennung anzupassen und Profile zu verwalten.",
|
||||
"description": "Richten Sie eine Waschmaschine oder ein anderes Gerät ein. Ein Leistungssensor ist erforderlich.\n\nÖffnen Sie nach der Einrichtung das WashData-Panel über die Seitenleiste ([/ha-washdata](/ha-washdata)), um Zyklen, Profile und alle Einstellungen zu sehen.",
|
||||
"data": {
|
||||
"name": "Gerätename",
|
||||
"device_type": "Gerätetyp",
|
||||
@@ -12,43 +12,41 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Ein benutzerfreundlicher Name für dieses Gerät (z. B. „Waschmaschine“, „Geschirrspüler“).",
|
||||
"device_type": "Um welche Art von Gerät handelt es sich? Dies hilft bei der individuellen Erkennung und Zuordnung.",
|
||||
"power_sensor": "Die Sensoreinheit, die den Stromverbrauch (in Watt) Ihres Smart Plugs in Echtzeit meldet.",
|
||||
"min_power": "Leistungswerte über diesem Schwellwert (in Watt) zeigen an, dass das Gerät läuft. Für die meisten Geräte ist 2W ein guter Anfang."
|
||||
"device_type": "Legt die Erkennungsstandards für diese Geräteart fest.",
|
||||
"power_sensor": "Der Smart-Plug-Sensor, der die aktuelle Leistung in Watt meldet.",
|
||||
"min_power": "Werte über diesem Wert (in Watt) bedeuten, dass das Gerät läuft. 2 W passen für die meisten Geräte."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Aktualisieren Sie den Gerätenamen, den Gerätetyp oder den Leistungssensor.\n\nDas WashData-Panel (Seitenleiste oder [/ha-washdata](/ha-washdata)) ist der Ort, an dem Erkennung, Zyklen, Profile und Benachrichtigungen verwaltet werden.",
|
||||
"title": "WashData neu konfigurieren",
|
||||
"description": "Ändern Sie den Gerätenamen, den Gerätetyp oder den Leistungssensor.\n\nAlles andere finden Sie im WashData-Panel (Seitenleiste oder [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Gerätename",
|
||||
"device_type": "Gerätetyp",
|
||||
"power_sensor": "Leistungssensor",
|
||||
"min_power": "Mindestleistungsschwelle (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Verbindung konnte nicht hergestellt werden",
|
||||
"invalid_auth": "Ungültige Authentifizierung",
|
||||
"unknown": "Unerwarteter Fehler",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Die Leistungsschwelle muss größer als 0 sein"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Gerät ist bereits konfiguriert",
|
||||
"reconfigure_successful": "Reconfiguration was successful"
|
||||
"reconfigure_successful": "Die Neukonfiguration war erfolgreich"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Benennen Sie das Gerät um oder ändern Sie seine grundlegenden Hardware-Einstellungen. Alle Erkennungs-, Abgleich- und Benachrichtigungseinstellungen befinden sich im [WashData-Panel](/ha-washdata) (auch über die Seitenleiste erreichbar).",
|
||||
"title": "WashData-Einstellungen",
|
||||
"description": "Benennen Sie das Gerät um oder ändern Sie seine Grundeinstellungen. Alles andere finden Sie im [WashData-Panel](/ha-washdata) in der Seitenleiste.",
|
||||
"data": {
|
||||
"name": "Name des Geräts",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Gerätetyp",
|
||||
"power_sensor": "Leistungssensor",
|
||||
"min_power": "Mindestleistungsschwelle (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,7 +58,7 @@
|
||||
"notify_live_waiting_message": "{device}: Noch kein Profil erkannt.",
|
||||
"vs_typical_longer": "{pct}% länger als üblich",
|
||||
"vs_typical_shorter": "{pct}% kürzer als üblich",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Die Leistungsschwelle muss größer als 0 sein"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profilname",
|
||||
"description": "Der Name eines vorhandenen Profils (Profile erstellen im Menü „Profile verwalten“). Feld leer lassen, um die Zuordnung zu entfernen."
|
||||
"description": "Name eines vorhandenen Profils (Profile erstellen Sie im WashData-Panel). Leer lassen, um die Beschriftung zu entfernen."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Vertrauensschwellwert",
|
||||
"description": "Mindestübereinstimmungskonfidenz (0,50–0,95) zum Anwenden von Zuordnungen."
|
||||
"description": "Mindestübereinstimmungskonfidenz (0,50–0,95) zum Anwenden von Zuordnungen. Lassen Sie das Feld leer, um die Geräteeinstellung „Automatische Beschriftungskonfidenz“ zu verwenden."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Konfiguration exportieren",
|
||||
"description": "Profile, Zyklen und Einstellungen dieses Geräts in eine JSON-Datei exportieren (pro Gerät).",
|
||||
"description": "Profile, Zyklen und Einstellungen dieses Geräts in eine JSON-Datei exportieren.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Gerät",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Zyklus-Feedback bestätigen",
|
||||
"description": "Ein automatisch erkanntes Programm nach einem abgeschlossenen Zyklus bestätigen oder korrigieren. Geben Sie entweder „entry_id“ (erweitert) oder „device_id“ (empfohlen) an.",
|
||||
"description": "Das erkannte Programm eines abgeschlossenen Zyklus bestätigen oder korrigieren. Geben Sie `device_id` (empfohlen) oder `entry_id` an.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Gerät",
|
||||
@@ -272,18 +270,18 @@
|
||||
}
|
||||
},
|
||||
"trigger_ml_training": {
|
||||
"name": "Trigger ML Training",
|
||||
"description": "Manually retrain the on-device ML models from this device's own labelled cycles (experimental).",
|
||||
"name": "ML-Training starten",
|
||||
"description": "Die ML-Modelle auf dem Gerät manuell aus den eigenen zugeordneten Zyklen dieses Geräts neu trainieren (experimentell).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
"description": "The WashData device to retrain models for."
|
||||
"name": "Gerät",
|
||||
"description": "Das WashData-Gerät, dessen Modelle neu trainiert werden sollen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Als entladen markieren",
|
||||
"description": "Bestätigen Sie, dass die fertige Ladung aus einem WashData-Gerät entnommen wurde. Damit werden der Zustand \"Sauber\" und eine eventuelle Entladeerinnerung zurückgesetzt.",
|
||||
"description": "Bestätigen, dass die fertige Ladung entnommen wurde. Setzt den Zustand „Sauber“ und eine eventuelle Entladeerinnerung zurück.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Gerät",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Übereinstimmungsmehrdeutigkeit"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Wartung fällig",
|
||||
"state": {
|
||||
"on": "Fällig",
|
||||
"off": "Nicht fällig"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "IDs fälliger Aufgaben"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Fällige Aufgaben",
|
||||
"state": {
|
||||
"descale": "Entkalken",
|
||||
"filter_clean": "Filter reinigen",
|
||||
"drum_clean": "Trommel reinigen",
|
||||
"bearing_service": "Lagerwartung",
|
||||
"other": "Sonstiges",
|
||||
"salt": "Salz nachfüllen",
|
||||
"rinse_aid": "Klarspüler nachfüllen",
|
||||
"lint_filter": "Flusensieb reinigen",
|
||||
"condenser_clean": "Kondensator reinigen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Knitterschutz",
|
||||
"interrupted": "Unterbrochen",
|
||||
"force_stopped": "Erzwungen beendet",
|
||||
"rinse": "Spülen",
|
||||
"unknown": "Unbekannt",
|
||||
"clean": "Sauber",
|
||||
"delay_wait": "Warten auf den Start"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Zyklusanomalie",
|
||||
"state": {
|
||||
"overrun": "Läuft länger",
|
||||
"stalled": "Stillstand"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Ρύθμιση WashData",
|
||||
"description": "Ρυθμίστε το πλυντήριο ρούχων ή άλλη συσκευή σας.\n\nΑπαιτείται αισθητήρας ισχύος.\n\nΜετά τη ρύθμιση, ανοίξτε τον πίνακα WashData από την πλευρική γραμμή (ή μεταβείτε στο [/ha-washdata](/ha-washdata)) για να δείτε κύκλους, να ρυθμίσετε την ανίχνευση και να διαχειριστείτε προφίλ.",
|
||||
"description": "Ρυθμίστε ένα πλυντήριο ρούχων ή άλλη συσκευή. Απαιτείται αισθητήρας ισχύος.\n\nΜετά τη ρύθμιση, ανοίξτε τον πίνακα WashData από την πλευρική γραμμή ([/ha-washdata](/ha-washdata)) για να δείτε κύκλους, προφίλ και όλες τις ρυθμίσεις.",
|
||||
"data": {
|
||||
"name": "Όνομα συσκευής",
|
||||
"device_type": "Τύπος συσκευής",
|
||||
@@ -12,43 +12,41 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Ένα φιλικό όνομα για αυτή τη συσκευή (π.χ. «Πλυντήριο ρούχων», «Πλυντήριο πιάτων»).",
|
||||
"device_type": "Τι είδους συσκευή είναι αυτή; Βοηθά στην προσαρμογή της ανίχνευσης και της επισήμανσης.",
|
||||
"power_sensor": "Η οντότητα αισθητήρα που αναφέρει σε πραγματικό χρόνο την κατανάλωση ισχύος (σε watt) από το έξυπνο βύσμα σας.",
|
||||
"min_power": "Μετρήσεις ισχύος πάνω από αυτό το όριο (σε watt) υποδεικνύουν ότι η συσκευή λειτουργεί. Ξεκινήστε με 2 W για τις περισσότερες συσκευές."
|
||||
"device_type": "Ορίζει τις προεπιλογές ανίχνευσης για αυτό το είδος συσκευής.",
|
||||
"power_sensor": "Ο αισθητήρας του έξυπνου βύσματος που αναφέρει τη ζωντανή ισχύ σε watt.",
|
||||
"min_power": "Μετρήσεις πάνω από αυτό (σε watt) σημαίνουν ότι η συσκευή λειτουργεί. Τα 2 W ταιριάζουν στις περισσότερες συσκευές."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Ενημερώστε το όνομα της συσκευής, τον τύπο συσκευής ή τον αισθητήρα ισχύος.\n\nΟ πίνακας WashData (πλευρική γραμμή ή [/ha-washdata](/ha-washdata)) είναι το μέρος όπου διαχειρίζεται η ανίχνευση, οι κύκλοι, τα προφίλ και οι ειδοποιήσεις.",
|
||||
"title": "Επαναδιαμόρφωση WashData",
|
||||
"description": "Αλλάξτε το όνομα, τον τύπο συσκευής ή τον αισθητήρα ισχύος.\n\nΌλα τα υπόλοιπα βρίσκονται στον πίνακα WashData (πλευρική γραμμή ή [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Όνομα συσκευής",
|
||||
"device_type": "Τύπος συσκευής",
|
||||
"power_sensor": "Αισθητήρας ισχύος",
|
||||
"min_power": "Ελάχιστο όριο ισχύος (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Αποτυχία σύνδεσης",
|
||||
"invalid_auth": "Μη έγκυρη αυθεντικοποίηση",
|
||||
"unknown": "Απρόσμενο σφάλμα",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Το όριο ισχύος πρέπει να είναι μεγαλύτερο από 0"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Η συσκευή έχει ήδη διαμορφωθεί",
|
||||
"reconfigure_successful": "Reconfiguration was successful"
|
||||
"reconfigure_successful": "Η εκ νέου διαμόρφωση ήταν επιτυχής"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Μετονομάστε τη συσκευή ή αλλάξτε τις βασικές ρυθμίσεις υλικού. Όλες οι ρυθμίσεις ανίχνευσης, αντιστοίχισης και ειδοποιήσεων βρίσκονται στον [πίνακα WashData](/ha-washdata) (επίσης προσβάσιμος από την πλευρική γραμμή).",
|
||||
"title": "Ρυθμίσεις WashData",
|
||||
"description": "Μετονομάστε τη συσκευή ή αλλάξτε τις βασικές ρυθμίσεις της. Όλα τα υπόλοιπα βρίσκονται στον [πίνακα WashData](/ha-washdata) στην πλευρική γραμμή.",
|
||||
"data": {
|
||||
"name": "Όνομα συσκευής",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Τύπος συσκευής",
|
||||
"power_sensor": "Αισθητήρας ισχύος",
|
||||
"min_power": "Ελάχιστο όριο ισχύος (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,7 +58,7 @@
|
||||
"notify_live_waiting_message": "{device}: Δεν έχει αναγνωριστεί ακόμη κανένα προφίλ.",
|
||||
"vs_typical_longer": "{pct}% περισσότερο από το συνηθισμένο",
|
||||
"vs_typical_shorter": "{pct}% λιγότερο από το συνηθισμένο",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Το όριο ισχύος πρέπει να είναι μεγαλύτερο από 0"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Όνομα προφίλ",
|
||||
"description": "Το όνομα ενός υπάρχοντος προφίλ (δημιουργήστε προφίλ από το μενού Διαχείριση προφίλ). Αφήστε κενό για αφαίρεση ετικέτας."
|
||||
"description": "Όνομα υπάρχοντος προφίλ (τα προφίλ δημιουργούνται στον πίνακα WashData). Αφήστε κενό για να αφαιρέσετε την ετικέτα."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Κατώφλι εμπιστοσύνης",
|
||||
"description": "Ελάχιστη εμπιστοσύνη αντιστοίχισης (0,50-0,95) για εφαρμογή ετικετών."
|
||||
"description": "Ελάχιστη εμπιστοσύνη αντιστοίχισης (0,50-0,95) για εφαρμογή ετικετών. Αφήστε το κενό για να χρησιμοποιηθεί η ρύθμιση Εμπιστοσύνη Αυτόματης Επισήμανσης της συσκευής."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Εξαγωγή διαμόρφωσης",
|
||||
"description": "Εξαγωγή προφίλ, κύκλων και ρυθμίσεων αυτής της συσκευής σε αρχείο JSON (ανά συσκευή).",
|
||||
"description": "Εξαγωγή των προφίλ, των κύκλων και των ρυθμίσεων αυτής της συσκευής σε αρχείο JSON.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Συσκευή",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Υποβολή ανατροφοδότησης κύκλου",
|
||||
"description": "Επιβεβαιώστε ή διορθώστε ένα αυτόματα εντοπισμένο πρόγραμμα μετά από ολοκληρωμένο κύκλο. Δώστε είτε `entry_id` (σύνθετο) ή `device_id` (συνιστάται).",
|
||||
"description": "Επιβεβαιώστε ή διορθώστε το πρόγραμμα που εντοπίστηκε σε έναν ολοκληρωμένο κύκλο. Δώστε `device_id` (συνιστάται) ή `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Συσκευή",
|
||||
@@ -272,18 +270,18 @@
|
||||
}
|
||||
},
|
||||
"trigger_ml_training": {
|
||||
"name": "Trigger ML Training",
|
||||
"name": "Έναρξη εκπαίδευσης ML",
|
||||
"description": "Χειροκίνητα επανεκπαιδεύστε τα μοντέλα on-device ML από τους χαρακτηρισμένους κύκλους αυτής της συσκευής (πειραματικό).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
"description": "The WashData device to retrain models for."
|
||||
"name": "Συσκευή",
|
||||
"description": "Η συσκευή WashData της οποίας τα μοντέλα θα επανεκπαιδευτούν."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Σήμανση Αποφόρτωσης",
|
||||
"description": "Επιβεβαιώνει ότι το ολοκληρωμένο φορτίο έχει αφαιρεθεί από μια συσκευή WashData, ακυρώνοντας την κατάσταση Καθαρός και κάθε υπενθύμιση αποφόρτωσης.",
|
||||
"description": "Επιβεβαιώνει ότι το ολοκληρωμένο φορτίο αφαιρέθηκε. Ακυρώνει την κατάσταση Καθαρός και κάθε υπενθύμιση αποφόρτωσης.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Συσκευή",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Αμφισημία αντιστοίχισης"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Οφειλόμενη συντήρηση",
|
||||
"state": {
|
||||
"on": "Οφείλεται",
|
||||
"off": "Δεν οφείλεται"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Αναγνωριστικά οφειλόμενων εργασιών"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Οφειλόμενες εργασίες",
|
||||
"state": {
|
||||
"descale": "Αφαλάτωση",
|
||||
"filter_clean": "Καθαρισμός φίλτρου",
|
||||
"drum_clean": "Καθαρισμός κάδου",
|
||||
"bearing_service": "Σέρβις ρουλεμάν",
|
||||
"other": "Άλλο",
|
||||
"salt": "Αναπλήρωση αλατιού",
|
||||
"rinse_aid": "Αναπλήρωση λαμπρυντικού",
|
||||
"lint_filter": "Καθαρισμός φίλτρου χνουδιού",
|
||||
"condenser_clean": "Καθαρισμός συμπυκνωτή"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Κατά ρυτίδων",
|
||||
"interrupted": "Διακόπηκε",
|
||||
"force_stopped": "Αναγκαστική διακοπή",
|
||||
"rinse": "Ξέπλυμα",
|
||||
"unknown": "Άγνωστο",
|
||||
"clean": "Καθαρός",
|
||||
"delay_wait": "Αναμονή για την έναρξη"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Ανωμαλία κύκλου",
|
||||
"state": {
|
||||
"overrun": "Καθυστερεί",
|
||||
"stalled": "Κόλλησε"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData Setup",
|
||||
"description": "Configure your washing machine or other appliance.\n\nPower sensor is required.\n\nAfter setup, open the WashData panel from the sidebar (or go to [/ha-washdata](/ha-washdata)) to view cycles, tune detection and manage profiles.",
|
||||
"description": "Set up a washing machine or other appliance. A power sensor is required.\n\nAfter setup, open the WashData panel from the sidebar ([/ha-washdata](/ha-washdata)) to see cycles, profiles and all settings.",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "A friendly name for this device (e.g., 'Washing Machine', 'Dishwasher').",
|
||||
"device_type": "What type of appliance is this? Helps tailor detection and labeling.",
|
||||
"power_sensor": "The sensor entity that reports real-time power consumption (in watts) from your smart plug.",
|
||||
"min_power": "Power readings above this threshold (in watts) indicate the appliance is running. Start with 2W for most devices."
|
||||
"device_type": "Sets detection defaults for this kind of appliance.",
|
||||
"power_sensor": "The smart plug sensor that reports live power in watts.",
|
||||
"min_power": "Readings above this (in watts) mean the appliance is running. 2 W suits most devices."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Update the device name, appliance type, or power sensor.\n\nThe WashData panel (sidebar, or [/ha-washdata](/ha-washdata)) is where detection, cycles, profiles and notifications are managed.",
|
||||
"description": "Change the device name, appliance type or power sensor.\n\nEverything else is in the WashData panel (sidebar, or [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Rename the device or change its core hardware settings. All detection, matching and notification settings are in the [WashData panel](/ha-washdata) (also reachable from the sidebar).",
|
||||
"description": "Rename the device or change its core settings. Everything else is in the [WashData panel](/ha-washdata) in the sidebar.",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profile Name",
|
||||
"description": "The name of an existing profile (create profiles in Manage Profiles menu). Leave blank to remove label."
|
||||
"description": "An existing profile name (create profiles in the WashData panel). Leave blank to remove the label."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Confidence Threshold",
|
||||
"description": "Minimum match confidence (0.50-0.95) to apply labels."
|
||||
"description": "Minimum match confidence (0.50-0.95) to apply labels. Leave empty to use the device's Auto-Label Confidence setting."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Export Config",
|
||||
"description": "Export this device's profiles, cycles, and settings to a JSON file (per device).",
|
||||
"description": "Export this device's profiles, cycles and settings to a JSON file.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Submit Cycle Feedback",
|
||||
"description": "Confirm or correct an auto-detected program after a completed cycle. Provide either `entry_id` (advanced) or `device_id` (recommended).",
|
||||
"description": "Confirm or correct the detected program of a finished cycle. Give `device_id` (recommended) or `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Mark Unloaded",
|
||||
"description": "Confirm the finished load has been taken out of a WashData device, clearing the Clean state and any unload reminder.",
|
||||
"description": "Confirm the finished load was taken out. Clears the Clean state and any unload reminder.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Match Ambiguity"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Maintenance due",
|
||||
"state": {
|
||||
"on": "Due",
|
||||
"off": "Not due"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Due task IDs"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Due tasks",
|
||||
"state": {
|
||||
"descale": "Descale",
|
||||
"filter_clean": "Clean filter",
|
||||
"drum_clean": "Clean drum",
|
||||
"bearing_service": "Bearing service",
|
||||
"other": "Other",
|
||||
"salt": "Refill salt",
|
||||
"rinse_aid": "Refill rinse aid",
|
||||
"lint_filter": "Clean lint filter",
|
||||
"condenser_clean": "Clean condenser"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Anti-Wrinkle",
|
||||
"interrupted": "Interrupted",
|
||||
"force_stopped": "Force Stopped",
|
||||
"rinse": "Rinse",
|
||||
"unknown": "Unknown",
|
||||
"clean": "Clean",
|
||||
"delay_wait": "Delay Start"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Cycle anomaly",
|
||||
"state": {
|
||||
"overrun": "Running long",
|
||||
"stalled": "Stalled"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configuración de WashData",
|
||||
"description": "Configura tu lavadora u otro electrodoméstico.\n\nSe requiere un sensor de potencia.\n\nTras la configuración, abre el panel de WashData desde la barra lateral (o ve a [/ha-washdata](/ha-washdata)) para ver los ciclos, ajustar la detección y gestionar los perfiles.",
|
||||
"description": "Configura una lavadora u otro electrodoméstico. Se requiere un sensor de potencia.\n\nTras la configuración, abre el panel de WashData desde la barra lateral ([/ha-washdata](/ha-washdata)) para ver ciclos, perfiles y todos los ajustes.",
|
||||
"data": {
|
||||
"name": "Nombre del dispositivo",
|
||||
"device_type": "Tipo de dispositivo",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Un nombre descriptivo para este dispositivo (p. ej., «Lavadora», «Lavavajillas»).",
|
||||
"device_type": "¿Qué tipo de electrodoméstico es este? Ayuda a personalizar la detección y el etiquetado.",
|
||||
"power_sensor": "La entidad de sensor que informa el consumo de potencia en tiempo real (en vatios) desde tu enchufe inteligente.",
|
||||
"min_power": "Las lecturas de potencia por encima de este umbral (en vatios) indican que el electrodoméstico está en funcionamiento. Empieza con 2 W para la mayoría de los dispositivos."
|
||||
"device_type": "Define los valores de detección predeterminados para este tipo de electrodoméstico.",
|
||||
"power_sensor": "El sensor del enchufe inteligente que informa la potencia actual en vatios.",
|
||||
"min_power": "Las lecturas por encima de este valor (en vatios) indican que el electrodoméstico está en marcha. 2 W sirve para la mayoría de los dispositivos."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigurar WashData",
|
||||
"description": "Actualiza el nombre del dispositivo, el tipo de electrodoméstico o el sensor de potencia.\n\nEl panel de WashData (barra lateral, o [/ha-washdata](/ha-washdata)) es donde se gestionan la detección, los ciclos, los perfiles y las notificaciones.",
|
||||
"description": "Cambia el nombre del dispositivo, el tipo de electrodoméstico o el sensor de potencia.\n\nTodo lo demás está en el panel de WashData (barra lateral, o [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Nombre del dispositivo",
|
||||
"device_type": "Tipo de dispositivo",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "No se pudo conectar",
|
||||
"invalid_auth": "Autenticación no válida",
|
||||
"unknown": "Error inesperado",
|
||||
"invalid_power": "El umbral de potencia debe ser mayor que 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Ajustes de WashData",
|
||||
"description": "Cambia el nombre del dispositivo o su configuración de hardware principal. Todos los ajustes de detección, coincidencia y notificaciones están en el [panel de WashData](/ha-washdata) (también accesible desde la barra lateral).",
|
||||
"description": "Cambia el nombre del dispositivo o sus ajustes principales. Todo lo demás está en el [panel de WashData](/ha-washdata) de la barra lateral.",
|
||||
"data": {
|
||||
"name": "Nombre del dispositivo",
|
||||
"device_type": "Tipo de dispositivo",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Nombre del perfil",
|
||||
"description": "El nombre de un perfil existente (crea perfiles en el menú Gestionar perfiles). Deja vacío para quitar la etiqueta."
|
||||
"description": "Nombre de un perfil existente (crea perfiles en el panel de WashData). Déjalo vacío para quitar la etiqueta."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Umbral de confianza",
|
||||
"description": "Confianza mínima de coincidencia (0,50-0,95) para aplicar etiquetas."
|
||||
"description": "Confianza mínima de coincidencia (0,50-0,95) para aplicar etiquetas. Déjelo vacío para usar el ajuste Confianza de etiquetado automático del dispositivo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Exportar configuración",
|
||||
"description": "Exporta los perfiles, ciclos y ajustes de este dispositivo a un archivo JSON (por dispositivo).",
|
||||
"description": "Exporta los perfiles, ciclos y ajustes de este dispositivo a un archivo JSON.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Dispositivo",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Enviar retroalimentación del ciclo",
|
||||
"description": "Confirma o corrige un programa detectado automáticamente tras un ciclo completado. Proporciona `entry_id` (avanzado) o `device_id` (recomendado).",
|
||||
"description": "Confirma o corrige el programa detectado de un ciclo terminado. Indica `device_id` (recomendado) o `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Dispositivo",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Marcar como descargado",
|
||||
"description": "Confirmar que la carga terminada se ha sacado de un dispositivo WashData, borrando el estado Limpio y cualquier recordatorio de descarga.",
|
||||
"description": "Confirma que se ha sacado la carga terminada. Borra el estado Limpio y cualquier recordatorio de descarga.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Dispositivo",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Ambigüedad de coincidencia"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Mantenimiento pendiente",
|
||||
"state": {
|
||||
"on": "Pendiente",
|
||||
"off": "No pendiente"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID de tareas pendientes"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Tareas pendientes",
|
||||
"state": {
|
||||
"descale": "Descalcificar",
|
||||
"filter_clean": "Limpiar filtro",
|
||||
"drum_clean": "Limpiar tambor",
|
||||
"bearing_service": "Servicio de rodamientos",
|
||||
"other": "Otro",
|
||||
"salt": "Rellenar sal",
|
||||
"rinse_aid": "Rellenar abrillantador",
|
||||
"lint_filter": "Limpiar filtro de pelusa",
|
||||
"condenser_clean": "Limpiar condensador"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Antiarrugas",
|
||||
"interrupted": "Interrumpido",
|
||||
"force_stopped": "Parada forzada",
|
||||
"rinse": "Aclarado",
|
||||
"unknown": "Desconocido",
|
||||
"clean": "Limpio",
|
||||
"delay_wait": "Esperando para comenzar"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Anomalía del ciclo",
|
||||
"state": {
|
||||
"overrun": "Con retraso",
|
||||
"stalled": "Atascado"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData häälestus",
|
||||
"description": "Seadistage oma pesumasin või muu seade.\n\nVajalik on võimsusandur.\n\nPärast seadistamist avage WashData paneel külgribalt (või minge [/ha-washdata](/ha-washdata)), et vaadata tsükleid, häälestada tuvastust ja hallata profiile.",
|
||||
"description": "Seadistage pesumasin või muu seade. Vajalik on võimsusandur.\n\nPärast seadistamist avage külgribalt WashData paneel ([/ha-washdata](/ha-washdata)), et näha tsükleid, profiile ja kõiki seadeid.",
|
||||
"data": {
|
||||
"name": "Seadme nimi",
|
||||
"device_type": "Seadme tüüp",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Selle seadme sõbralik nimi (nt 'pesumasin', 'nõudepesumasin').",
|
||||
"device_type": "Mis tüüpi seade see on? Aitab kohandada tuvastamist ja märgistamist.",
|
||||
"power_sensor": "Anduri olem, mis teatab teie nutika pistiku reaalajas energiatarbimisest (vattides).",
|
||||
"min_power": "Sellest läviväärtusest kõrgemad võimsusnäidud (vattides) näitavad, et seade töötab. Enamiku seadmete puhul alustage 2W-st."
|
||||
"device_type": "Määrab seda tüüpi seadme tuvastamise vaikesätted.",
|
||||
"power_sensor": "Nutipistiku andur, mis teatab reaalajas võimsust vattides.",
|
||||
"min_power": "Sellest suuremad näidud (vattides) tähendavad, et seade töötab. Enamikule seadmetele sobib 2 W."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "WashData uuesti seadistamine",
|
||||
"description": "Uuendage seadme nime, seadmetüüpi või võimsusandurit.\n\nWashData paneelis (külgriba või [/ha-washdata](/ha-washdata)) hallatakse tuvastust, tsükleid, profiile ja teavitusi.",
|
||||
"description": "Muutke seadme nime, seadmetüüpi või võimsusandurit.\n\nKõik muu on WashData paneelis (külgriba või [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Seadme nimi",
|
||||
"device_type": "Seadme tüüp",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Ühenduse loomine ebaõnnestus",
|
||||
"invalid_auth": "Kehtetu autentimine",
|
||||
"unknown": "Ootamatu viga",
|
||||
"invalid_power": "Võimsuslävi peab olema suurem kui 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData seadistused",
|
||||
"description": "Nimetage seade ümber või muutke selle põhilisi riistvara seadeid. Kõik tuvastus-, sobitamis- ja teavitusseaded asuvad [WashData paneelil](/ha-washdata) (ligipääsetav ka külgribalt).",
|
||||
"description": "Nimetage seade ümber või muutke selle põhiseadeid. Kõik muu on külgribal olevas [WashData paneelis](/ha-washdata).",
|
||||
"data": {
|
||||
"name": "Seadme nimi",
|
||||
"device_type": "Seadme tüüp",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profiili nimi",
|
||||
"description": "Olemasoleva profiili nimi (profiilide loomine menüüs Profiilide haldamine). Sildi eemaldamiseks jätke tühjaks."
|
||||
"description": "Olemasoleva profiili nimi (profiile saab luua WashData paneelis). Sildi eemaldamiseks jätke tühjaks."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Usalduse lävi",
|
||||
"description": "Minimaalne vaste usaldusväärsus (0,50–0,95) siltide rakendamiseks."
|
||||
"description": "Minimaalne vaste usaldusväärsus (0,50-0,95) siltide rakendamiseks. Jätke tühjaks, et kasutada seadme sätet Automaatse Sildi Usaldus."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Ekspordi konfiguratsioon",
|
||||
"description": "Eksportige selle seadme profiilid, tsüklid ja seaded JSON-faili (seadme kohta).",
|
||||
"description": "Eksportige selle seadme profiilid, tsüklid ja seaded JSON-faili.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Seade",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Esitage tsükli tagasiside",
|
||||
"description": "Pärast tsükli lõppemist kinnitage või parandage automaatselt tuvastatud programm. Sisestage kas „entry_id” (täpsem) või „device_id” (soovitatav).",
|
||||
"description": "Kinnitage või parandage lõppenud tsükli tuvastatud programm. Sisestage `device_id` (soovitatav) või `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Seade",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Märgi mahalaadituks",
|
||||
"description": "Kinnitage, et lõpetatud tsükli sisu on WashData seadmest välja võetud: olek „Puhas“ ja mahalaadimise meeldetuletus lähtestatakse.",
|
||||
"description": "Kinnitage, et lõppenud tsükli sisu on välja võetud. Lähtestab oleku „Puhas“ ja kõik mahalaadimise meeldetuletused.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Seade",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Vaste ebamäärasus"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Hooldus vajalik",
|
||||
"state": {
|
||||
"on": "Vajalik",
|
||||
"off": "Pole vajalik"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Vajalike ülesannete ID-d"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Vajalikud ülesanded",
|
||||
"state": {
|
||||
"descale": "Katlakivi eemaldamine",
|
||||
"filter_clean": "Puhasta filter",
|
||||
"drum_clean": "Puhasta trummel",
|
||||
"bearing_service": "Laagrite hooldus",
|
||||
"other": "Muu",
|
||||
"salt": "Lisa soola",
|
||||
"rinse_aid": "Lisa loputusvahendit",
|
||||
"lint_filter": "Puhasta ebemefilter",
|
||||
"condenser_clean": "Puhasta kondensaator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Kortsudevastane",
|
||||
"interrupted": "Katkestatud",
|
||||
"force_stopped": "Sundpeatatud",
|
||||
"rinse": "Loputamine",
|
||||
"unknown": "Tundmatu",
|
||||
"clean": "Puhas",
|
||||
"delay_wait": "Ootab algust"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Tsükli anomaalia",
|
||||
"state": {
|
||||
"overrun": "Töötab kaua",
|
||||
"stalled": "Seiskunud"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData-asetukset",
|
||||
"description": "Määritä pesukoneesi tai muu laite.\n\nTehoanturi tarvitaan.\n\nAsetuksen jälkeen avaa WashData-paneeli sivupalkista (tai siirry osoitteeseen [/ha-washdata](/ha-washdata)) nähdäksesi syklit, säätääksesi tunnistuksen ja hallitaksesi profiileja.",
|
||||
"description": "Määritä pesukone tai muu laite. Tehoanturi tarvitaan.\n\nAsetuksen jälkeen avaa WashData-paneeli sivupalkista ([/ha-washdata](/ha-washdata)) nähdäksesi syklit, profiilit ja kaikki asetukset.",
|
||||
"data": {
|
||||
"name": "Laitteen nimi",
|
||||
"device_type": "Laitteen tyyppi",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Ystävällinen nimi tälle laitteelle (esim. 'pesukone', 'astianpesukone').",
|
||||
"device_type": "Minkä tyyppinen laite tämä on? Auttaa räätälöimään havaitsemista ja merkitsemistä.",
|
||||
"power_sensor": "Anturientiteetti, joka raportoi reaaliaikaisen virrankulutuksen (watteina) älypistokkeesta.",
|
||||
"min_power": "Tämän kynnyksen ylittävät teholukemat (watteina) osoittavat, että laite on käynnissä. Aloita 2 watilla useimmissa laitteissa."
|
||||
"device_type": "Asettaa tunnistuksen oletukset tämän tyyppiselle laitteelle.",
|
||||
"power_sensor": "Älypistokkeen anturi, joka raportoi reaaliaikaisen tehon watteina.",
|
||||
"min_power": "Tämän ylittävät lukemat (watteina) tarkoittavat, että laite on käynnissä. 2 W sopii useimmille laitteille."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Konfiguroi WashData uudelleen",
|
||||
"description": "Päivitä laitteen nimi, laitteen tyyppi tai tehoanturi.\n\nWashData-paneelissa (sivupalkki tai [/ha-washdata](/ha-washdata)) hallitaan tunnistusta, syklejä, profiileja ja ilmoituksia.",
|
||||
"description": "Muuta laitteen nimeä, laitetyyppiä tai tehoanturia.\n\nKaikki muu on WashData-paneelissa (sivupalkki tai [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Laitteen nimi",
|
||||
"device_type": "Laitteen tyyppi",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Yhteyden muodostaminen epäonnistui",
|
||||
"invalid_auth": "Virheellinen todennus",
|
||||
"unknown": "Odottamaton virhe",
|
||||
"invalid_power": "Tehokynnyksen on oltava suurempi kuin 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData-asetukset",
|
||||
"description": "Nimeä laite uudelleen tai muuta sen ydinlaitteiston asetuksia. Kaikki tunnistus-, täsmäytys- ja ilmoitusasetukset ovat [WashData-paneelissa](/ha-washdata) (saavutettavissa myös sivupalkista).",
|
||||
"description": "Nimeä laite uudelleen tai muuta sen perusasetuksia. Kaikki muu on sivupalkin [WashData-paneelissa](/ha-washdata).",
|
||||
"data": {
|
||||
"name": "Laitteen nimi",
|
||||
"device_type": "Laitteen tyyppi",
|
||||
@@ -70,7 +68,7 @@
|
||||
"dryer": "Kuivausrumpu",
|
||||
"washer_dryer": "Pesukone-kuivausrumpu yhdistelmä",
|
||||
"dishwasher": "Astianpesukone",
|
||||
"air_fryer": "Air Fryer",
|
||||
"air_fryer": "Airfryer",
|
||||
"bread_maker": "Leipäkone",
|
||||
"pump": "Pumppu / sumpupumppu",
|
||||
"generic": "Muu (edistynyt)",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profiilin nimi",
|
||||
"description": "Olemassa olevan profiilin nimi (luo profiilit Profiilien hallinta -valikossa). Jätä tyhjäksi poistaaksesi tarran."
|
||||
"description": "Olemassa olevan profiilin nimi (luo profiilit WashData-paneelissa). Jätä tyhjäksi poistaaksesi merkinnän."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Luottamuskynnys",
|
||||
"description": "Vähimmäisosumavarmuus (0,50–0,95) tunnisteiden lisäämistä varten."
|
||||
"description": "Vähimmäisosumavarmuus (0,50-0,95) tunnisteiden lisäämistä varten. Jätä tyhjäksi käyttääksesi laitteen Automaattimerkinnän varmuus -asetusta."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Vie kokoonpano",
|
||||
"description": "Vie tämän laitteen profiilit, syklit ja asetukset JSON-tiedostoon (laitetta kohti).",
|
||||
"description": "Vie tämän laitteen profiilit, syklit ja asetukset JSON-tiedostoon.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Laite",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Lähetä syklipalaute",
|
||||
"description": "Vahvista tai korjaa automaattisesti tunnistettu ohjelma suoritetun jakson jälkeen. Anna joko \"entry_id\" (lisäasetukset) tai \"device_id\" (suositus).",
|
||||
"description": "Vahvista tai korjaa valmiin syklin tunnistettu ohjelma. Anna `device_id` (suositus) tai `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Laite",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Merkitse puretuksi",
|
||||
"description": "Vahvista, että valmiin syklin sisältö on otettu pois WashData-laitteesta: Puhdas-tila ja purkamismuistutus nollataan.",
|
||||
"description": "Vahvista, että valmis kuorma on otettu pois. Poistaa Puhdas-tilan ja mahdollisen purkamismuistutuksen.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Laite",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Vastaavuuden epäselvyys"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Huolto tarpeen",
|
||||
"state": {
|
||||
"on": "Tarpeen",
|
||||
"off": "Ei tarpeen"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Ajankohtaisten tehtävien tunnukset"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Ajankohtaiset tehtävät",
|
||||
"state": {
|
||||
"descale": "Kalkinpoisto",
|
||||
"filter_clean": "Puhdista suodatin",
|
||||
"drum_clean": "Puhdista rumpu",
|
||||
"bearing_service": "Laakerihuolto",
|
||||
"other": "Muu",
|
||||
"salt": "Lisää suolaa",
|
||||
"rinse_aid": "Lisää huuhtelukirkastetta",
|
||||
"lint_filter": "Puhdista nukkasihti",
|
||||
"condenser_clean": "Puhdista lauhdutin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Ryppyjenesto",
|
||||
"interrupted": "Keskeytynyt",
|
||||
"force_stopped": "Pakotettu pysäytys",
|
||||
"rinse": "Huuhtelu",
|
||||
"unknown": "Tuntematon",
|
||||
"clean": "Puhdas",
|
||||
"delay_wait": "Odotetaan aloitusta"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Syklin poikkeama",
|
||||
"state": {
|
||||
"overrun": "Käy pitkään",
|
||||
"stalled": "Jumissa"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configuration des données de lavage",
|
||||
"description": "Configurez votre lave-linge ou autre appareil.\n\nUn capteur de puissance est requis.\n\nUne fois la configuration terminée, ouvrez le panneau WashData depuis la barre latérale (ou accédez à [/ha-washdata](/ha-washdata)) pour consulter les cycles, affiner la détection et gérer les profils.",
|
||||
"description": "Configurez un lave-linge ou un autre appareil. Un capteur de puissance est requis.\n\nAprès la configuration, ouvrez le panneau WashData depuis la barre latérale ([/ha-washdata](/ha-washdata)) pour voir les cycles, les profils et tous les réglages.",
|
||||
"data": {
|
||||
"name": "Nom de l'appareil",
|
||||
"device_type": "Type d'appareil",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Un nom convivial pour cet appareil (par exemple, « Machine à laver », « Lave-vaisselle »).",
|
||||
"device_type": "De quel type d'appareil s'agit-il ? Aide à adapter la détection et l’étiquetage.",
|
||||
"power_sensor": "L'entité de capteur qui signale la consommation d'énergie en temps réel (en watts) de votre prise intelligente.",
|
||||
"min_power": "Les lectures de puissance supérieures à ce seuil (en watts) indiquent que l'appareil est en marche. Commencez avec 2 W pour la plupart des appareils."
|
||||
"device_type": "Définit les valeurs de détection par défaut pour ce type d'appareil.",
|
||||
"power_sensor": "Le capteur de la prise connectée qui indique la puissance actuelle en watts.",
|
||||
"min_power": "Au-dessus de cette valeur (en watts), l'appareil est considéré en marche. 2 W convient à la plupart des appareils."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigurer WashData",
|
||||
"description": "Mettez à jour le nom de l'appareil, le type d'appareil ou le capteur de puissance.\n\nLe panneau WashData (barre latérale ou [/ha-washdata](/ha-washdata)) permet de gérer la détection, les cycles, les profils et les notifications.",
|
||||
"description": "Modifiez le nom de l'appareil, son type ou le capteur de puissance.\n\nTout le reste se trouve dans le panneau WashData (barre latérale ou [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Nom de l'appareil",
|
||||
"device_type": "Type d'appareil",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Échec de la connexion",
|
||||
"invalid_auth": "Authentification invalide",
|
||||
"unknown": "Erreur inattendue",
|
||||
"invalid_power": "Le seuil de puissance doit être supérieur à 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Paramètres WashData",
|
||||
"description": "Renommez l'appareil ou modifiez ses paramètres matériels de base. Tous les paramètres de détection, de correspondance et de notification se trouvent dans le [panneau WashData](/ha-washdata) (également accessible depuis la barre latérale).",
|
||||
"description": "Renommez l'appareil ou modifiez ses réglages de base. Tout le reste se trouve dans le [panneau WashData](/ha-washdata) de la barre latérale.",
|
||||
"data": {
|
||||
"name": "Nom du périphérique",
|
||||
"device_type": "Type d'appareil",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Nom du profil",
|
||||
"description": "Le nom d'un profil existant (créer des profils dans le menu Gérer les profils). Laissez vide pour supprimer l’étiquette."
|
||||
"description": "Nom d'un profil existant (créez les profils dans le panneau WashData). Laissez vide pour retirer l'étiquette."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Seuil de confiance",
|
||||
"description": "Confiance de correspondance minimale (0,50-0,95) pour appliquer les étiquettes."
|
||||
"description": "Confiance de correspondance minimale (0,50-0,95) pour appliquer les étiquettes. Laissez vide pour utiliser le réglage Confiance d'étiquetage automatique de l'appareil."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Exporter la configuration",
|
||||
"description": "Exportez les profils, cycles et paramètres de cet appareil vers un fichier JSON (par appareil).",
|
||||
"description": "Exportez les profils, cycles et réglages de cet appareil vers un fichier JSON.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Appareil",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Soumettre des commentaires sur le cycle",
|
||||
"description": "Confirmez ou corrigez un programme détecté automatiquement après un cycle terminé. Fournissez soit `entry_id` (avancé) ou `device_id` (recommandé).",
|
||||
"description": "Confirmez ou corrigez le programme détecté d'un cycle terminé. Indiquez `device_id` (recommandé) ou `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Appareil",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Marquer comme déchargé",
|
||||
"description": "Confirmer que la charge terminée a été sortie d'un appareil WashData, ce qui efface l'état Propre et tout rappel de déchargement.",
|
||||
"description": "Confirmez que la charge terminée a été sortie. Efface l'état Propre et tout rappel de déchargement.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Appareil",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Ambiguïté des correspondances"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Entretien à effectuer",
|
||||
"state": {
|
||||
"on": "À effectuer",
|
||||
"off": "À jour"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID des tâches à effectuer"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Tâches à effectuer",
|
||||
"state": {
|
||||
"descale": "Détartrer",
|
||||
"filter_clean": "Nettoyer le filtre",
|
||||
"drum_clean": "Nettoyer le tambour",
|
||||
"bearing_service": "Entretien des roulements",
|
||||
"other": "Autre",
|
||||
"salt": "Recharger le sel",
|
||||
"rinse_aid": "Recharger le liquide de rinçage",
|
||||
"lint_filter": "Nettoyer le filtre à peluches",
|
||||
"condenser_clean": "Nettoyer le condenseur"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Anti-faux plis",
|
||||
"interrupted": "Interrompu",
|
||||
"force_stopped": "Arrêt forcé",
|
||||
"rinse": "Rinçage",
|
||||
"unknown": "Inconnu",
|
||||
"clean": "Propre",
|
||||
"delay_wait": "En attendant de commencer"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Anomalie du cycle",
|
||||
"state": {
|
||||
"overrun": "Dépassement",
|
||||
"stalled": "Bloqué"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Postavljanje WashData",
|
||||
"description": "Konfigurirajte svoju perilicu rublja ili neki drugi uređaj.\n\nSenzor snage je obavezan.\n\nNakon postavljanja otvorite WashData panel s bočne trake (ili idite na [/ha-washdata](/ha-washdata)) za pregled ciklusa, podešavanje otkrivanja i upravljanje profilima.",
|
||||
"description": "Postavite perilicu rublja ili drugi uređaj. Potreban je senzor snage.\n\nNakon postavljanja otvorite WashData panel s bočne trake ([/ha-washdata](/ha-washdata)) za cikluse, profile i sve postavke.",
|
||||
"data": {
|
||||
"name": "Naziv uređaja",
|
||||
"device_type": "Vrsta uređaja",
|
||||
@@ -12,43 +12,41 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Prijateljski naziv za ovaj uređaj (npr. \"Perilica rublja\", \"Perilica posuđa\").",
|
||||
"device_type": "Koja je ovo vrsta uređaja? Pomaže u prilagođavanju otkrivanja i označavanja.",
|
||||
"power_sensor": "Entitet senzora koji prijavljuje potrošnju energije u stvarnom vremenu (u vatima) iz vašeg pametnog utikača.",
|
||||
"min_power": "Očitavanje snage iznad ovog praga (u vatima) znači da uređaj radi. Počnite s 2 W za većinu uređaja."
|
||||
"device_type": "Postavlja zadane vrijednosti otkrivanja za ovu vrstu uređaja.",
|
||||
"power_sensor": "Senzor pametnog utikača koji javlja trenutnu snagu u vatima.",
|
||||
"min_power": "Očitanja iznad ove vrijednosti (u vatima) znače da uređaj radi. 2 W odgovara većini uređaja."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Ažurirajte naziv uređaja, vrstu uređaja ili senzor snage.\n\nWashData panel (bočna traka ili [/ha-washdata](/ha-washdata)) služi za upravljanje otkrivanjem, ciklusima, profilima i obavijestima.",
|
||||
"title": "Ponovna konfiguracija WashData",
|
||||
"description": "Promijenite naziv uređaja, vrstu uređaja ili senzor snage.\n\nSve ostalo je u WashData panelu (bočna traka ili [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Naziv uređaja",
|
||||
"device_type": "Vrsta uređaja",
|
||||
"power_sensor": "Senzor snage",
|
||||
"min_power": "Minimalni prag snage (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Povezivanje nije uspjelo",
|
||||
"invalid_auth": "Nevažeća provjera autentičnosti",
|
||||
"unknown": "Neočekivana pogreška",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Prag snage mora biti veći od 0"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Uređaj je već konfiguriran",
|
||||
"reconfigure_successful": "Reconfiguration was successful"
|
||||
"reconfigure_successful": "Ponovna konfiguracija je uspjela"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Preimenujte uređaj ili promijenite njegove osnovne postavke hardvera. Sve postavke otkrivanja, podudaranja i obavijesti nalaze se na [WashData panelu](/ha-washdata) (dostupan i s bočne trake).",
|
||||
"title": "Postavke WashData",
|
||||
"description": "Preimenujte uređaj ili promijenite njegove osnovne postavke. Sve ostalo je u [WashData panelu](/ha-washdata) na bočnoj traci.",
|
||||
"data": {
|
||||
"name": "Naziv uređaja",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Vrsta uređaja",
|
||||
"power_sensor": "Senzor snage",
|
||||
"min_power": "Minimalni prag snage (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,7 +58,7 @@
|
||||
"notify_live_waiting_message": "{device}: Nijedan profil još nije podudaran.",
|
||||
"vs_typical_longer": "{pct}% dulje nego obično",
|
||||
"vs_typical_shorter": "{pct}% kraće nego obično",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Prag snage mora biti veći od 0"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Ime profila",
|
||||
"description": "Naziv postojećeg profila (izradite profile u izborniku Upravljanje profilima). Ostavite prazno za uklanjanje oznake."
|
||||
"description": "Naziv postojećeg profila (profile izradite u WashData panelu). Ostavite prazno za uklanjanje oznake."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Prag povjerenja",
|
||||
"description": "Minimalna pouzdanost podudaranja (0,50-0,95) za primjenu oznaka."
|
||||
"description": "Minimalna pouzdanost podudaranja (0,50-0,95) za primjenu oznaka. Ostavite prazno za korištenje postavke uređaja „Pouzdanost za automatsko označavanje”."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Izvoz konfiguracije",
|
||||
"description": "Izvezite profile, cikluse i postavke ovog uređaja u JSON datoteku (po uređaju).",
|
||||
"description": "Izvezite profile, cikluse i postavke ovog uređaja u JSON datoteku.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Uređaj",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Pošaljite povratne informacije o ciklusu",
|
||||
"description": "Potvrdite ili ispravite automatski otkriveni program nakon završenog ciklusa. Navedite `entry_id` (napredno) ili `device_id` (preporučeno).",
|
||||
"description": "Potvrdite ili ispravite otkriveni program završenog ciklusa. Navedite `device_id` (preporučeno) ili `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Uređaj",
|
||||
@@ -272,18 +270,18 @@
|
||||
}
|
||||
},
|
||||
"trigger_ml_training": {
|
||||
"name": "Trigger ML Training",
|
||||
"description": "Manually retrain the on-device ML models from this device's own labelled cycles (experimental).",
|
||||
"name": "Pokreni ML treniranje",
|
||||
"description": "Ručno ponovno istrenirajte ML modele na uređaju pomoću vlastitih označenih ciklusa ovog uređaja (eksperimentalno).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
"description": "The WashData device to retrain models for."
|
||||
"name": "Uređaj",
|
||||
"description": "WashData uređaj čije modele treba ponovno istrenirati."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Označi kao istovareno",
|
||||
"description": "Potvrdite da je gotov sadržaj izvađen iz WashData uređaja, čime se briše stanje \"Čist\" i svaki podsjetnik za istovar.",
|
||||
"description": "Potvrdite da je gotov sadržaj izvađen. Briše stanje \"Čist\" i podsjetnik za istovar.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Uređaj",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Dvosmislenost podudaranja"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Održavanje na redu",
|
||||
"state": {
|
||||
"on": "Na redu",
|
||||
"off": "Nije na redu"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID-ovi zadataka na redu"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Zadaci na redu",
|
||||
"state": {
|
||||
"descale": "Uklanjanje kamenca",
|
||||
"filter_clean": "Očisti filtar",
|
||||
"drum_clean": "Očisti bubanj",
|
||||
"bearing_service": "Servis ležajeva",
|
||||
"other": "Ostalo",
|
||||
"salt": "Dopuni sol",
|
||||
"rinse_aid": "Dopuni sredstvo za ispiranje",
|
||||
"lint_filter": "Očisti filtar za dlačice",
|
||||
"condenser_clean": "Očisti kondenzator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Protiv bora",
|
||||
"interrupted": "Prekinut",
|
||||
"force_stopped": "Prisilno zaustavljeno",
|
||||
"rinse": "Ispiranje",
|
||||
"unknown": "Nepoznato",
|
||||
"clean": "Čist",
|
||||
"delay_wait": "Čekanje na početak"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Anomalija ciklusa",
|
||||
"state": {
|
||||
"overrun": "Traje predugo",
|
||||
"stalled": "Zaglavljeno"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData beállítása",
|
||||
"description": "Állítsa be a mosógépet vagy más készüléket.\n\nTeljesítményérzékelő szükséges.\n\nA beállítás után nyissa meg a WashData panelt az oldalsávból (vagy lépjen ide: [/ha-washdata](/ha-washdata)), ahol megtekintheti a ciklusokat, finomhangolhatja a felismerést és kezelheti a profilokat.",
|
||||
"description": "Állítson be egy mosógépet vagy más készüléket. Teljesítményérzékelő szükséges.\n\nA beállítás után nyissa meg a WashData panelt az oldalsávból ([/ha-washdata](/ha-washdata)) a ciklusok, profilok és az összes beállítás megtekintéséhez.",
|
||||
"data": {
|
||||
"name": "Eszköz neve",
|
||||
"device_type": "Eszköz típusa",
|
||||
@@ -12,43 +12,41 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Barátságos név ennek az eszköznek (pl. „Mosógép”, „Mosogatógép”).",
|
||||
"device_type": "Milyen típusú készülék ez? Segít személyre szabni az észlelést és a címkézést.",
|
||||
"power_sensor": "Az érzékelő entitás, amely valós idejű energiafogyasztást jelent (wattban) az okosdugóról.",
|
||||
"min_power": "Az e küszöbérték feletti teljesítményértékek (wattban) azt jelzik, hogy a készülék működik. Kezdje 2 W-tal a legtöbb eszköz esetében."
|
||||
"device_type": "Beállítja az észlelési alapértékeket ehhez a készüléktípushoz.",
|
||||
"power_sensor": "Az okoskonnektor érzékelője, amely az élő teljesítményt jelenti wattban.",
|
||||
"min_power": "Az e feletti leolvasások (wattban) azt jelentik, hogy a készülék működik. A legtöbb eszközhöz 2 W megfelelő."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Frissítse az eszköz nevét, a készülék típusát vagy a teljesítményérzékelőt.\n\nA WashData panel (oldalsáv vagy [/ha-washdata](/ha-washdata)) a felismerés, ciklusok, profilok és értesítések kezelésére szolgál.",
|
||||
"title": "WashData újrakonfigurálása",
|
||||
"description": "Módosítsa az eszköz nevét, a készülék típusát vagy a teljesítményérzékelőt.\n\nMinden más a WashData panelen található (oldalsáv vagy [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Eszköz neve",
|
||||
"device_type": "Eszköz típusa",
|
||||
"power_sensor": "Teljesítmény érzékelő",
|
||||
"min_power": "Minimális teljesítmény küszöb (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nem sikerült csatlakozni",
|
||||
"invalid_auth": "Érvénytelen hitelesítés",
|
||||
"unknown": "Váratlan hiba",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "A teljesítményküszöbnek 0-nál nagyobbnak kell lennie"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Az eszköz már konfigurálva van",
|
||||
"reconfigure_successful": "Reconfiguration was successful"
|
||||
"reconfigure_successful": "Az újrakonfigurálás sikeres volt"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Nevezze át az eszközt, vagy módosítsa az alap hardverbeállításait. Az összes felismerési, egyeztetési és értesítési beállítás a [WashData panelen](/ha-washdata) található (az oldalsávból is elérhető).",
|
||||
"title": "WashData beállításai",
|
||||
"description": "Nevezze át az eszközt, vagy módosítsa az alapbeállításait. Minden más az oldalsáv [WashData paneljén](/ha-washdata) található.",
|
||||
"data": {
|
||||
"name": "Eszköz neve",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Eszköz típusa",
|
||||
"power_sensor": "Teljesítmény érzékelő",
|
||||
"min_power": "Minimális teljesítmény küszöb (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,7 +58,7 @@
|
||||
"notify_live_waiting_message": "{device}: Még nincs illeszkedő profil.",
|
||||
"vs_typical_longer": "{pct}%-kal hosszabb a szokásosnál",
|
||||
"vs_typical_shorter": "{pct}%-kal rövidebb a szokásosnál",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "A teljesítményküszöbnek 0-nál nagyobbnak kell lennie"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -70,7 +68,7 @@
|
||||
"dryer": "Szárítógép",
|
||||
"washer_dryer": "Mosó-szárító kombó",
|
||||
"dishwasher": "Mosogatógép",
|
||||
"air_fryer": "Air Fryer",
|
||||
"air_fryer": "Forrólevegős sütő",
|
||||
"bread_maker": "Kenyérsütő",
|
||||
"pump": "Szivattyú / búvárszivattyú",
|
||||
"generic": "Egyéb (haladó)",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profil neve",
|
||||
"description": "Egy meglévő profil neve (profilok létrehozása a Profilok kezelése menüben). Hagyja üresen a címke eltávolításához."
|
||||
"description": "Egy meglévő profil neve (profilokat a WashData panelen hozhat létre). Hagyja üresen a címke eltávolításához."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Magabiztossági küszöb",
|
||||
"description": "Minimális egyezési megbízhatóság (0,50–0,95) a címkék alkalmazásához."
|
||||
"description": "Minimális egyezési megbízhatóság (0,50-0,95) a címkék alkalmazásához. Hagyja üresen az eszköz Automatikus jelölési megbízhatóság beállításának használatához."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Konfiguráció exportálása",
|
||||
"description": "Exportálja az eszköz profiljait, ciklusait és beállításait JSON-fájlba (eszközönként).",
|
||||
"description": "Az eszköz profiljainak, ciklusainak és beállításainak exportálása JSON-fájlba.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Eszköz",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Ciklus visszajelzés küldése",
|
||||
"description": "Erősítse meg vagy javítsa ki az automatikusan észlelt programot a ciklus befejezése után. Adja meg az „entry_id” (speciális) vagy az „device_id” (ajánlott) értéket.",
|
||||
"description": "Egy befejezett ciklus észlelt programjának megerősítése vagy javítása. Adja meg a `device_id` (ajánlott) vagy az `entry_id` értéket.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Eszköz",
|
||||
@@ -272,18 +270,18 @@
|
||||
}
|
||||
},
|
||||
"trigger_ml_training": {
|
||||
"name": "Trigger ML Training",
|
||||
"description": "Manually retrain the on-device ML models from this device's own labelled cycles (experimental).",
|
||||
"name": "ML-tanítás indítása",
|
||||
"description": "Tanítsa újra kézzel az eszközön futó ML-modelleket az eszköz saját címkézett ciklusaiból (kísérleti).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
"description": "The WashData device to retrain models for."
|
||||
"name": "Eszköz",
|
||||
"description": "A WashData eszköz, amelynek modelljeit újra szeretné tanítani."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Megjelölés kirakodottként",
|
||||
"description": "Annak megerősítése, hogy a kész adagot kivették a WashData eszközből, ami törli a „Tiszta” állapotot és a kirakodási emlékeztetőt.",
|
||||
"description": "Megerősíti, hogy a kész adagot kivették. Törli a „Tiszta” állapotot és a kirakodási emlékeztetőt.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Eszköz",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Találat kétértelműsége"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Esedékes karbantartás",
|
||||
"state": {
|
||||
"on": "Esedékes",
|
||||
"off": "Nem esedékes"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Esedékes feladatok azonosítói"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Esedékes feladatok",
|
||||
"state": {
|
||||
"descale": "Vízkőmentesítés",
|
||||
"filter_clean": "Szűrő tisztítása",
|
||||
"drum_clean": "Dob tisztítása",
|
||||
"bearing_service": "Csapágy szerviz",
|
||||
"other": "Egyéb",
|
||||
"salt": "Só utántöltése",
|
||||
"rinse_aid": "Öblítőszer utántöltése",
|
||||
"lint_filter": "Szöszszűrő tisztítása",
|
||||
"condenser_clean": "Kondenzátor tisztítása"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Ránctalanító",
|
||||
"interrupted": "Megszakított",
|
||||
"force_stopped": "Kényszer leállított",
|
||||
"rinse": "Öblítés",
|
||||
"unknown": "Ismeretlen",
|
||||
"clean": "Tiszta",
|
||||
"delay_wait": "Várakozás a kezdésre"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Ciklusrendellenesség",
|
||||
"state": {
|
||||
"overrun": "Sokáig fut",
|
||||
"stalled": "Elakadt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData uppsetning",
|
||||
"description": "Stilltu þvottavélina þína eða annað tæki.\n\nKraftskynjari er nauðsynlegur.\n\nEftir uppsetningu skaltu opna WashData-spjaldið frá hliðarstikunni (eða fara á [/ha-washdata](/ha-washdata)) til að skoða hringrás, stilla skynjun og stjórna sniðum.",
|
||||
"description": "Settu upp þvottavél eða annað tæki. Aflskynjari er nauðsynlegur.\n\nEftir uppsetningu skaltu opna WashData-spjaldið frá hliðarstikunni ([/ha-washdata](/ha-washdata)) til að sjá lotur, snið og allar stillingar.",
|
||||
"data": {
|
||||
"name": "Nafn tækis",
|
||||
"device_type": "Tegund tækis",
|
||||
@@ -12,55 +12,53 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Vingjarnlegt nafn fyrir þetta tæki (t.d. „Þvottavél“, „Uppþvottavél“).",
|
||||
"device_type": "Hvers konar tæki er þetta? Hjálpar til við að sérsníða greiningu og merkingu.",
|
||||
"power_sensor": "Skynjareiningin sem tilkynnir um orkunotkun í rauntíma (í vöttum) frá snjalltenginu þínu.",
|
||||
"min_power": "Aflmælingar yfir þessum viðmiðunarmörkum (í vöttum) gefa til kynna að heimilistækið sé í gangi. Byrjaðu með 2W fyrir flest tæki."
|
||||
"device_type": "Stillir sjálfgefin greiningargildi fyrir þessa tegund tækis.",
|
||||
"power_sensor": "Skynjari snjalltengilsins sem gefur upp núverandi afl í vöttum.",
|
||||
"min_power": "Mælingar yfir þessu (í vöttum) þýða að tækið er í gangi. 2 W hentar flestum tækjum."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Uppfærðu heiti tækisins, tegund tækis eða kraftskynjara.\n\nWashData-spjaldið (hliðarstikan eða [/ha-washdata](/ha-washdata)) er þar sem skynjun, hringrás, sniðmát og tilkynningar eru stjórnað.",
|
||||
"title": "Endurstilla WashData",
|
||||
"description": "Breyttu heiti tækisins, tegund tækis eða aflskynjara.\n\nAllt annað er í WashData-spjaldinu (hliðarstikan eða [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Nafn tækis",
|
||||
"device_type": "Tegund tækis",
|
||||
"power_sensor": "Aflskynjari",
|
||||
"min_power": "Lágmarksaflsþröskuldur (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Tókst ekki að tengjast",
|
||||
"invalid_auth": "Ógild auðkenning",
|
||||
"unknown": "Óvænt villa",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Aflsþröskuldur verður að vera stærri en 0"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Tæki er þegar stillt",
|
||||
"reconfigure_successful": "Reconfiguration was successful"
|
||||
"reconfigure_successful": "Endurstilling heppnaðist"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Endurnefndu tækið eða breyttu grunnstillingum vélbúnaðar. Allar stillingar fyrir skynjun, samsvörun og tilkynningar eru í [WashData-spjaldinu](/ha-washdata) (einnig aðgengilegt frá hliðarstikunni).",
|
||||
"title": "Stillingar WashData",
|
||||
"description": "Endurnefndu tækið eða breyttu grunnstillingum þess. Allt annað er í [WashData-spjaldinu](/ha-washdata) á hliðarstikunni.",
|
||||
"data": {
|
||||
"name": "Nafn tækis",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Tegund tækis",
|
||||
"power_sensor": "Aflskynjari",
|
||||
"min_power": "Lágmarksaflsþröskuldur (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"timer_default_message": "{device}: {minutes} mín tímataki",
|
||||
"timer_pause_action_title": "Halda áfram hringrás",
|
||||
"timer_pause_body_suffix": "Hringrásin er í bið. Opnaðu WashData-gluggann til að halda áfram.",
|
||||
"timer_pause_action_title": "Halda lotu áfram",
|
||||
"timer_pause_body_suffix": "Lotan er í bið. Opnaðu WashData-spjaldið til að halda henni áfram.",
|
||||
"unload_dismiss_action_title": "Hætta að minna",
|
||||
"notify_live_waiting_message": "{device}: Ekkert snið hefur enn verið greint.",
|
||||
"vs_typical_longer": "{pct}% lengri en venjulega",
|
||||
"vs_typical_shorter": "{pct}% styttri en venjulega",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "Aflsþröskuldur verður að vera stærri en 0"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -70,7 +68,7 @@
|
||||
"dryer": "Þurrkari",
|
||||
"washer_dryer": "Þvottavél-þurrkari samsettur",
|
||||
"dishwasher": "Uppþvottavél",
|
||||
"air_fryer": "Air Fryer",
|
||||
"air_fryer": "Loftsteikingarpottur",
|
||||
"bread_maker": "Brauðgerðarmaður",
|
||||
"pump": "Dæla / Sump Pump",
|
||||
"generic": "Annað (háþróað)",
|
||||
@@ -80,7 +78,7 @@
|
||||
},
|
||||
"services": {
|
||||
"label_cycle": {
|
||||
"name": "Merkja hringrás",
|
||||
"name": "Merkja lotu",
|
||||
"description": "Úthlutaðu núverandi prófíl við fyrri lotu eða fjarlægðu merkið.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
@@ -88,12 +86,12 @@
|
||||
"description": "Þvottavél tæki til að merkja."
|
||||
},
|
||||
"cycle_id": {
|
||||
"name": "Auðkenni hringrásar",
|
||||
"name": "Auðkenni lotu",
|
||||
"description": "Auðkenni lotunnar sem á að merkja."
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Nafn prófíls",
|
||||
"description": "Heiti sniðs sem fyrir er (búið til snið í valmyndinni Stjórna sniðum). Skildu eftir autt til að fjarlægja merkimiðann."
|
||||
"description": "Heiti sniðs sem er til (búðu til snið í WashData-spjaldinu). Skildu eftir autt til að fjarlægja merkinguna."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -117,7 +115,7 @@
|
||||
},
|
||||
"delete_profile": {
|
||||
"name": "Eyða prófíl",
|
||||
"description": "Eyddu sniði og afmerktu hringrás með því að nota það.",
|
||||
"description": "Eyddu sniði og fjarlægðu ef vill merkinguna af lotum sem nota það.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Tæki",
|
||||
@@ -128,13 +126,13 @@
|
||||
"description": "Prófíllinn til að eyða."
|
||||
},
|
||||
"unlabel_cycles": {
|
||||
"name": "Afmerkja hringrásir",
|
||||
"name": "Afmerkja lotur",
|
||||
"description": "Fjarlægðu prófílmerki úr lotum með því að nota þetta prófíl."
|
||||
}
|
||||
}
|
||||
},
|
||||
"auto_label_cycles": {
|
||||
"name": "Sjálfvirk merking á gömlum hringrásum",
|
||||
"name": "Sjálfvirk merking gamalla lota",
|
||||
"description": "Merktu afturvirkt ómerktar lotur með því að nota prófílsamsvörun.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Sjálfstraustsþröskuldur",
|
||||
"description": "Lágmarksöryggi (0,50-0,95) til að setja á merki."
|
||||
"description": "Lágmarksöryggi samsvörunar (0,50-0,95) til að setja á merki. Skildu reitinn eftir auðan til að nota stillingu tækisins „Sjálfvirk merkingaöryggi“."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Flytja út stillingar",
|
||||
"description": "Flyttu út prófíla, lotur og stillingar þessa tækis í JSON skrá (á hvert tæki).",
|
||||
"description": "Flyttu snið, lotur og stillingar þessa tækis út í JSON-skrá.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Tæki",
|
||||
@@ -176,8 +174,8 @@
|
||||
}
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Senda endurgjöf um hringrás",
|
||||
"description": "Staðfestu eða leiðréttu sjálfvirkt forrit sem greint hefur verið frá eftir að lotunni er lokið. Gefðu upp annað hvort 'entry_id' (háþróað) eða 'device_id' (ráðlagt).",
|
||||
"name": "Senda endurgjöf um lotu",
|
||||
"description": "Staðfestu eða leiðréttu greint kerfi lotu sem er lokið. Gefðu upp `device_id` (ráðlagt) eða `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Tæki",
|
||||
@@ -188,16 +186,16 @@
|
||||
"description": "Auðkenni stillingarfærslu fyrir tækið (valkostur við device_id)."
|
||||
},
|
||||
"cycle_id": {
|
||||
"name": "Auðkenni hringrásar",
|
||||
"description": "Auðkenni hringrásarinnar sem sýnt er í endurgjöfstilkynningunni / logs."
|
||||
"name": "Auðkenni lotu",
|
||||
"description": "Auðkenni lotunnar sem sýnt er í endurgjafartilkynningunni / annálunum."
|
||||
},
|
||||
"user_confirmed": {
|
||||
"name": "Staðfestu uppgötvað forrit",
|
||||
"description": "Stilltu satt ef forritið sem fannst er rétt."
|
||||
"name": "Staðfesta greint kerfi",
|
||||
"description": "Stilltu á satt ef greinda kerfið er rétt."
|
||||
},
|
||||
"corrected_profile": {
|
||||
"name": "Leiðrétt prófíl",
|
||||
"description": "Ef það er ekki staðfest skaltu gefa upp rétt snið/nafn forrits."
|
||||
"description": "Ef það er ekki staðfest skaltu gefa upp rétt heiti sniðs/kerfis."
|
||||
},
|
||||
"corrected_duration": {
|
||||
"name": "Leiðrétt lengd (sekúndur)",
|
||||
@@ -210,7 +208,7 @@
|
||||
}
|
||||
},
|
||||
"record_start": {
|
||||
"name": "Hefja upptöku hringrásar",
|
||||
"name": "Hefja upptöku lotu",
|
||||
"description": "Byrjaðu handvirkt að taka upp hreina lotu (framhjá öllum samsvarandi rökfræði).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
@@ -230,7 +228,7 @@
|
||||
}
|
||||
},
|
||||
"trim_cycle": {
|
||||
"name": "Klipptu hringrás",
|
||||
"name": "Klippa lotu til",
|
||||
"description": "Klipptu aflgögn fyrri lotu í ákveðinn tímaglugga.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
@@ -238,8 +236,8 @@
|
||||
"description": "WashData tækið."
|
||||
},
|
||||
"cycle_id": {
|
||||
"name": "Auðkenni hringrásar",
|
||||
"description": "Auðkenni hringrásarinnar sem á að klippa."
|
||||
"name": "Auðkenni lotu",
|
||||
"description": "Auðkenni lotunnar sem á að klippa til."
|
||||
},
|
||||
"trim_start_s": {
|
||||
"name": "Klippiupphaf (sekúndur)",
|
||||
@@ -252,8 +250,8 @@
|
||||
}
|
||||
},
|
||||
"pause_cycle": {
|
||||
"name": "Gera hlé á hringrás",
|
||||
"description": "Gerðu hlé á virku hringrásinni fyrir WashData tæki.",
|
||||
"name": "Gera hlé á lotu",
|
||||
"description": "Gerðu hlé á virku lotunni í WashData-tæki.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Tæki",
|
||||
@@ -262,7 +260,7 @@
|
||||
}
|
||||
},
|
||||
"resume_cycle": {
|
||||
"name": "Halda áfram hringrás",
|
||||
"name": "Halda lotu áfram",
|
||||
"description": "Haltu áfram að gera hlé á lotu fyrir WashData tæki.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
@@ -273,7 +271,7 @@
|
||||
},
|
||||
"trigger_ml_training": {
|
||||
"name": "Kveikja á ML þjálfun",
|
||||
"description": "Endurþjálfaðu ML líkönin á tækinu handvirkt með þjálfuðum hringrásum þessa tækis (tilraunakenndar).",
|
||||
"description": "Endurþjálfaðu ML-líkönin á tækinu handvirkt út frá merktum lotum þessa tækis (tilraunaeiginleiki).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Tæki",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Merkja sem losað",
|
||||
"description": "Staðfestu að búið sé að taka fullunna þvottinn úr WashData tæki, sem hreinsar stöðuna \"Hreint\" og hvers kyns losunaráminningu.",
|
||||
"description": "Staðfestu að þvotturinn hafi verið tekinn út. Hreinsar stöðuna \"Hreint\" og hvers kyns losunaráminningu.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Tæki",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Samsvörun tvíræðni"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Viðhald tímabært",
|
||||
"state": {
|
||||
"on": "Tímabært",
|
||||
"off": "Ekki tímabært"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Auðkenni tímabærra verka"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Tímabær verk",
|
||||
"state": {
|
||||
"descale": "Afkalka",
|
||||
"filter_clean": "Hreinsa síu",
|
||||
"drum_clean": "Hreinsa tromlu",
|
||||
"bearing_service": "Þjónusta á legum",
|
||||
"other": "Annað",
|
||||
"salt": "Fylla á salt",
|
||||
"rinse_aid": "Fylla á gljáa",
|
||||
"lint_filter": "Hreinsa lósíu",
|
||||
"condenser_clean": "Hreinsa eimsvala"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,14 +340,22 @@
|
||||
"anti_wrinkle": "Anti-hrukku",
|
||||
"interrupted": "Truflað",
|
||||
"force_stopped": "Þvingun stöðvuð",
|
||||
"rinse": "Skolaðu",
|
||||
"unknown": "Óþekkt",
|
||||
"clean": "Hreint",
|
||||
"delay_wait": "Bíður eftir að byrja"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Frávik í lotu",
|
||||
"state": {
|
||||
"overrun": "Keyrir lengur",
|
||||
"stalled": "Stöðvuð"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
"name": "Forrit"
|
||||
"name": "Kerfi"
|
||||
},
|
||||
"time_remaining": {
|
||||
"name": "Tími sem eftir er"
|
||||
@@ -360,7 +392,7 @@
|
||||
},
|
||||
"profile_cycle_count": {
|
||||
"name": "Prófíl {profile_name} Talning",
|
||||
"unit_of_measurement": "hringrásir"
|
||||
"unit_of_measurement": "lotur"
|
||||
},
|
||||
"suggestions": {
|
||||
"name": "Tillögur að stillingum"
|
||||
@@ -369,7 +401,7 @@
|
||||
"name": "Dæla gengur (síðasti 24 klst.)"
|
||||
},
|
||||
"cycle_count": {
|
||||
"name": "Talning hringrásar"
|
||||
"name": "Fjöldi lota"
|
||||
},
|
||||
"energy_total": {
|
||||
"name": "Heildarorka"
|
||||
@@ -377,7 +409,7 @@
|
||||
},
|
||||
"select": {
|
||||
"program_select": {
|
||||
"name": "Hringrásarforrit",
|
||||
"name": "Kerfi lotu",
|
||||
"state": {
|
||||
"auto_detect": "Sjálfvirk uppgötvun"
|
||||
}
|
||||
@@ -388,16 +420,16 @@
|
||||
"name": "Þvingaðu lokahring"
|
||||
},
|
||||
"pause_cycle": {
|
||||
"name": "Gera hlé á hringrás"
|
||||
"name": "Gera hlé á lotu"
|
||||
},
|
||||
"resume_cycle": {
|
||||
"name": "Halda áfram hringrás"
|
||||
"name": "Halda lotu áfram"
|
||||
},
|
||||
"record_start": {
|
||||
"name": "Hefja upptöku hringrásar"
|
||||
"name": "Hefja upptöku lotu"
|
||||
},
|
||||
"record_stop": {
|
||||
"name": "Stöðva upptöku hringrásar"
|
||||
"name": "Stöðva upptöku lotu"
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Merkja sem losað"
|
||||
@@ -424,10 +456,10 @@
|
||||
"message": "Samþætting ekki hlaðin fyrir þetta tæki."
|
||||
},
|
||||
"cycle_not_found_or_no_power": {
|
||||
"message": "Hringrás fannst ekki eða hefur engin orkugögn."
|
||||
"message": "Lota fannst ekki eða hefur engin orkugögn."
|
||||
},
|
||||
"trim_failed_empty_window": {
|
||||
"message": "Trim mistókst - hringrás fannst ekki, engin orkugögn eða gluggi sem myndast er tómur."
|
||||
"message": "Klipping mistókst - lota fannst ekki, engin orkugögn eru til eða glugginn sem eftir stendur er tómur."
|
||||
},
|
||||
"trim_invalid_range": {
|
||||
"message": "trim_end_s verða að vera stærri en trim_start_s."
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configurazione WashData",
|
||||
"description": "Configura la tua lavatrice o altro elettrodomestico.\n\nÈ richiesto il sensore di potenza.\n\nDopo la configurazione, apri il pannello WashData dalla barra laterale (o vai su [/ha-washdata](/ha-washdata)) per visualizzare i cicli, regolare il rilevamento e gestire i profili.",
|
||||
"description": "Configura una lavatrice o un altro elettrodomestico. È richiesto un sensore di potenza.\n\nDopo la configurazione, apri il pannello WashData dalla barra laterale ([/ha-washdata](/ha-washdata)) per vedere cicli, profili e tutte le impostazioni.",
|
||||
"data": {
|
||||
"name": "Nome dispositivo",
|
||||
"device_type": "Tipo di dispositivo",
|
||||
@@ -12,43 +12,41 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Un nome descrittivo per questo dispositivo (ad esempio \"Lavatrice\", \"Lavastoviglie\").",
|
||||
"device_type": "Che tipo di elettrodomestico è questo? Aiuta a personalizzare il rilevamento e l'etichettatura.",
|
||||
"power_sensor": "L'entità sensore che segnala il consumo energetico in tempo reale (in watt) dalla tua presa intelligente.",
|
||||
"min_power": "Le letture di potenza superiori a questa soglia (in watt) indicano che l'elettrodomestico è in funzione. Inizia con 2 W per la maggior parte dei dispositivi."
|
||||
"device_type": "Imposta i valori di rilevamento predefiniti per questo tipo di elettrodomestico.",
|
||||
"power_sensor": "Il sensore della presa intelligente che riporta la potenza attuale in watt.",
|
||||
"min_power": "Letture sopra questo valore (in watt) indicano che l'elettrodomestico è in funzione. 2 W va bene per la maggior parte dei dispositivi."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "Aggiorna il nome del dispositivo, il tipo di elettrodomestico o il sensore di potenza.\n\nIl pannello WashData (barra laterale, o [/ha-washdata](/ha-washdata)) è dove vengono gestiti rilevamento, cicli, profili e notifiche.",
|
||||
"title": "Riconfigura WashData",
|
||||
"description": "Cambia il nome del dispositivo, il tipo di elettrodomestico o il sensore di potenza.\n\nTutto il resto è nel pannello WashData (barra laterale, o [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "Nome dispositivo",
|
||||
"device_type": "Tipo di dispositivo",
|
||||
"power_sensor": "Sensore di potenza",
|
||||
"min_power": "Soglia di potenza minima (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Connessione non riuscita",
|
||||
"invalid_auth": "Autenticazione non valida",
|
||||
"unknown": "Errore imprevisto",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "La soglia di potenza deve essere maggiore di 0"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Il dispositivo è già configurato",
|
||||
"reconfigure_successful": "Reconfiguration was successful"
|
||||
"reconfigure_successful": "La riconfigurazione è avvenuta con successo"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Rinomina il dispositivo o modifica le sue impostazioni hardware principali. Tutte le impostazioni di rilevamento, corrispondenza e notifica si trovano nel [pannello WashData](/ha-washdata) (accessibile anche dalla barra laterale).",
|
||||
"title": "Impostazioni WashData",
|
||||
"description": "Rinomina il dispositivo o modifica le sue impostazioni principali. Tutto il resto è nel [pannello WashData](/ha-washdata) nella barra laterale.",
|
||||
"data": {
|
||||
"name": "Nome del dispositivo",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "Tipo di dispositivo",
|
||||
"power_sensor": "Sensore di potenza",
|
||||
"min_power": "Soglia di potenza minima (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,7 +58,7 @@
|
||||
"notify_live_waiting_message": "{device}: nessun profilo ancora riconosciuto.",
|
||||
"vs_typical_longer": "{pct}% più lungo del solito",
|
||||
"vs_typical_shorter": "{pct}% più corto del solito",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "La soglia di potenza deve essere maggiore di 0"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Nome profilo",
|
||||
"description": "Il nome di un profilo esistente (crea profili nel menu Gestisci profili). Lascia vuoto per rimuovere l'etichetta."
|
||||
"description": "Nome di un profilo esistente (crea i profili nel pannello WashData). Lascia vuoto per rimuovere l'etichetta."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Soglia di confidenza",
|
||||
"description": "Confidenza di corrispondenza minima (0,50-0,95) per applicare le etichette."
|
||||
"description": "Confidenza di corrispondenza minima (0,50-0,95) per applicare le etichette. Lascia vuoto per usare l'impostazione Confidenza etichettatura automatica del dispositivo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Esporta configurazione",
|
||||
"description": "Esporta profili, cicli e impostazioni di questo dispositivo in un file JSON (per dispositivo).",
|
||||
"description": "Esporta profili, cicli e impostazioni di questo dispositivo in un file JSON.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Dispositivo",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Invia feedback sul ciclo",
|
||||
"description": "Conferma o correggi un programma rilevato automaticamente dopo un ciclo completato. Fornisci `entry_id` (avanzato) o `device_id` (consigliato).",
|
||||
"description": "Conferma o correggi il programma rilevato di un ciclo terminato. Indica `device_id` (consigliato) o `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Dispositivo",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Segna come scaricato",
|
||||
"description": "Confermare che il carico terminato è stato tolto da un dispositivo WashData, azzerando lo stato Pulito e ogni promemoria di scarico.",
|
||||
"description": "Conferma che il carico terminato è stato tolto. Azzera lo stato Pulito e ogni promemoria di scarico.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Dispositivo",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Ambiguità corrispondenza"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Manutenzione in scadenza",
|
||||
"state": {
|
||||
"on": "In scadenza",
|
||||
"off": "Non in scadenza"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID attività in scadenza"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Attività in scadenza",
|
||||
"state": {
|
||||
"descale": "Decalcifica",
|
||||
"filter_clean": "Pulisci filtro",
|
||||
"drum_clean": "Pulisci cestello",
|
||||
"bearing_service": "Manutenzione cuscinetti",
|
||||
"other": "Altro",
|
||||
"salt": "Ricarica sale",
|
||||
"rinse_aid": "Ricarica brillantante",
|
||||
"lint_filter": "Pulisci filtro lanugine",
|
||||
"condenser_clean": "Pulisci condensatore"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Antirughe",
|
||||
"interrupted": "Interrotto",
|
||||
"force_stopped": "Fermato forzatamente",
|
||||
"rinse": "Risciacquo",
|
||||
"unknown": "Sconosciuto",
|
||||
"clean": "Pulito",
|
||||
"delay_wait": "In attesa di iniziare"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Anomalia del ciclo",
|
||||
"state": {
|
||||
"overrun": "In ritardo",
|
||||
"stalled": "Bloccato"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData セットアップ",
|
||||
"description": "洗濯機またはその他の家電製品を設定します。\n\n電力センサーが必要です。\n\nセットアップ後、サイドバーの WashData パネルを開くか(または [/ha-washdata](/ha-washdata) にアクセスして)、サイクルの確認、検出の調整、プロファイルの管理を行ってください。",
|
||||
"description": "洗濯機などの家電を設定します。電力センサーが必要です。\n\nセットアップ後、サイドバーから WashData パネル([/ha-washdata](/ha-washdata))を開くと、サイクル、プロファイル、すべての設定を確認できます。",
|
||||
"data": {
|
||||
"name": "デバイス名",
|
||||
"device_type": "デバイスの種類",
|
||||
@@ -12,43 +12,41 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "このデバイスのわかりやすい名前(例:「洗濯機」、「食器洗い機」)。",
|
||||
"device_type": "家電製品の種類を選択します。検出とラベル付けの精度向上に役立ちます。",
|
||||
"power_sensor": "スマートプラグからリアルタイムの消費電力(ワット)を報告するセンサーエンティティ。",
|
||||
"min_power": "このしきい値(ワット)を超える電力値は、家電製品が動作中であることを示します。ほとんどのデバイスでは 2W から始めてください。"
|
||||
"device_type": "この種類の家電向けの検出のデフォルトを設定します。",
|
||||
"power_sensor": "リアルタイムの電力をワット単位で報告するスマートプラグのセンサー。",
|
||||
"min_power": "この値(ワット)を超える測定値は、家電が動作中であることを示します。ほとんどのデバイスには 2 W が適しています。"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "デバイス名、家電の種類、または電力センサーを更新します。\n\nWashData パネル(サイドバー、または [/ha-washdata](/ha-washdata))で、検出、サイクル、プロファイル、通知を管理できます。",
|
||||
"title": "WashData の再設定",
|
||||
"description": "デバイス名、家電の種類、電力センサーを変更します。\n\nその他の設定はすべて WashData パネル(サイドバー、または [/ha-washdata](/ha-washdata))にあります。",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "デバイス名",
|
||||
"device_type": "デバイスの種類",
|
||||
"power_sensor": "電力センサー",
|
||||
"min_power": "最小電力しきい値 (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "接続に失敗しました",
|
||||
"invalid_auth": "認証に失敗しました",
|
||||
"unknown": "予期しないエラーが発生しました",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "電力しきい値は 0 より大きくする必要があります"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "このデバイスはすでに設定されています",
|
||||
"reconfigure_successful": "Reconfiguration was successful"
|
||||
"reconfigure_successful": "再設定が成功しました"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "デバイス名を変更するか、主要なハードウェア設定を変更します。すべての検出・照合・通知の設定は [WashData パネル](/ha-washdata) で確認できます(サイドバーからもアクセス可能)。",
|
||||
"title": "WashData 設定",
|
||||
"description": "デバイス名や主要な設定を変更します。その他の設定はすべて、サイドバーの [WashData パネル](/ha-washdata) にあります。",
|
||||
"data": {
|
||||
"name": "デバイス名",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "デバイスの種類",
|
||||
"power_sensor": "電力センサー",
|
||||
"min_power": "最小電力しきい値 (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,7 +58,7 @@
|
||||
"notify_live_waiting_message": "{device}: まだ一致するプロファイルがありません。",
|
||||
"vs_typical_longer": "通常より {pct}% 長い",
|
||||
"vs_typical_shorter": "通常より {pct}% 短い",
|
||||
"invalid_power": "Power threshold must be greater than 0"
|
||||
"invalid_power": "電力しきい値は 0 より大きくする必要があります"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "プロファイル名",
|
||||
"description": "既存のプロファイル名(プロファイル管理メニューでプロファイルを作成)。ラベルを削除するには空白にします。"
|
||||
"description": "既存のプロファイル名(プロファイルは WashData パネルで作成)。空欄にするとラベルを削除します。"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "信頼度しきい値",
|
||||
"description": "ラベルを適用する最小照合信頼度(0.50-0.95)。"
|
||||
"description": "ラベルを適用する最小照合信頼度(0.50-0.95)。空欄にすると、デバイスの「自動ラベル信頼度」設定が使用されます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "設定をエクスポート",
|
||||
"description": "このデバイスのプロファイル、サイクル、設定を JSON ファイルにエクスポートします(デバイスごと)。",
|
||||
"description": "このデバイスのプロファイル、サイクル、設定を JSON ファイルにエクスポートします。",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "デバイス",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "サイクルフィードバックを送信",
|
||||
"description": "完了したサイクルの自動検出されたプログラムを確認または修正します。`entry_id`(上級者向け)または `device_id`(推奨)のいずれかを指定します。",
|
||||
"description": "完了したサイクルで検出されたプログラムを確認または修正します。`device_id`(推奨)または `entry_id` を指定します。",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "デバイス",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "取り出し済みとしてマーク",
|
||||
"description": "WashData デバイスから終了した中身を取り出したことを確認し、クリーン状態と取り出しリマインダーを解除します。",
|
||||
"description": "終了した中身を取り出したことを確認します。クリーン状態と取り出しリマインダーを解除します。",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "デバイス",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "照合の曖昧さ"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "メンテナンス時期",
|
||||
"state": {
|
||||
"on": "要対応",
|
||||
"off": "対応不要"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "要対応タスク ID"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "要対応タスク",
|
||||
"state": {
|
||||
"descale": "スケール除去",
|
||||
"filter_clean": "フィルターの清掃",
|
||||
"drum_clean": "ドラムの清掃",
|
||||
"bearing_service": "ベアリングの整備",
|
||||
"other": "その他",
|
||||
"salt": "塩の補充",
|
||||
"rinse_aid": "リンス剤の補充",
|
||||
"lint_filter": "糸くずフィルターの清掃",
|
||||
"condenser_clean": "コンデンサーの清掃"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "しわ防止",
|
||||
"interrupted": "中断",
|
||||
"force_stopped": "強制停止",
|
||||
"rinse": "すすぎ",
|
||||
"unknown": "不明",
|
||||
"clean": "クリーン",
|
||||
"delay_wait": "開始を待っています"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "サイクル異常",
|
||||
"state": {
|
||||
"overrun": "超過中",
|
||||
"stalled": "停滞中"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData 설정",
|
||||
"description": "세탁기 또는 기타 가전제품을 구성하세요.\n\n전력 센서가 필요합니다.\n\n설정 후, 사이드바에서 WashData 패널을 열거나 [/ha-washdata](/ha-washdata)로 이동하여 사이클 확인, 검출 조정 및 프로파일 관리를 하세요.",
|
||||
"description": "세탁기 또는 기타 가전제품을 설정합니다. 전력 센서가 필요합니다.\n\n설정 후 사이드바에서 WashData 패널([/ha-washdata](/ha-washdata))을 열면 사이클, 프로필, 모든 설정을 볼 수 있습니다.",
|
||||
"data": {
|
||||
"name": "장치 이름",
|
||||
"device_type": "장치 유형",
|
||||
@@ -12,25 +12,23 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "이 장치의 이름입니다(예: '세탁기', '식기세척기').",
|
||||
"device_type": "이 가전제품의 종류는 무엇입니까? 감지 및 라벨링 맞춤에 도움이 됩니다.",
|
||||
"power_sensor": "스마트 플러그에서 실시간 전력 소비(와트)를 보고하는 센서 엔터티입니다.",
|
||||
"min_power": "이 임계값(와트)보다 높은 전력 수치는 가전제품이 작동 중임을 나타냅니다. 대부분의 장치에서는 2W로 시작하세요."
|
||||
"device_type": "이 종류의 가전제품에 맞는 감지 기본값을 설정합니다.",
|
||||
"power_sensor": "실시간 전력을 와트 단위로 보고하는 스마트 플러그 센서입니다.",
|
||||
"min_power": "이 값(와트)보다 높은 측정값은 가전제품이 작동 중임을 뜻합니다. 대부분의 장치에는 2 W가 적합합니다."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure WashData",
|
||||
"description": "장치 이름, 가전 유형 또는 전력 센서를 업데이트합니다.\n\nWashData 패널(사이드바 또는 [/ha-washdata](/ha-washdata))에서 검출, 사이클, 프로파일 및 알림을 관리합니다.",
|
||||
"title": "WashData 재구성",
|
||||
"description": "장치 이름, 가전 유형 또는 전력 센서를 변경합니다.\n\n그 밖의 모든 설정은 WashData 패널(사이드바 또는 [/ha-washdata](/ha-washdata))에 있습니다.",
|
||||
"data": {
|
||||
"name": "Device Name",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"name": "장치 이름",
|
||||
"device_type": "장치 유형",
|
||||
"power_sensor": "전력 센서",
|
||||
"min_power": "최소 전력 임계값 (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "연결하지 못했습니다",
|
||||
"invalid_auth": "인증 실패",
|
||||
"unknown": "예기치 않은 오류",
|
||||
"invalid_power": "전력 임계값은 0보다 커야 합니다"
|
||||
},
|
||||
@@ -42,13 +40,13 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "장치 이름을 변경하거나 핵심 하드웨어 설정을 변경합니다. 모든 검출, 매칭 및 알림 설정은 [WashData 패널](/ha-washdata)에서 확인할 수 있습니다(사이드바에서도 접근 가능).",
|
||||
"title": "WashData 설정",
|
||||
"description": "장치 이름이나 핵심 설정을 변경합니다. 그 밖의 모든 설정은 사이드바의 [WashData 패널](/ha-washdata)에 있습니다.",
|
||||
"data": {
|
||||
"name": "장치 이름",
|
||||
"device_type": "Device Type",
|
||||
"power_sensor": "Power Sensor",
|
||||
"min_power": "Minimum Power Threshold (W)"
|
||||
"device_type": "장치 유형",
|
||||
"power_sensor": "전력 센서",
|
||||
"min_power": "최소 전력 임계값 (W)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "프로필 이름",
|
||||
"description": "기존 프로필의 이름입니다(프로필 관리 메뉴에서 프로필 생성). 라벨을 제거하려면 비워두세요."
|
||||
"description": "기존 프로필 이름입니다(프로필은 WashData 패널에서 생성). 비워 두면 라벨이 제거됩니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "신뢰도 임계값",
|
||||
"description": "라벨을 적용하기 위한 최소 일치 신뢰도(0.50-0.95)입니다."
|
||||
"description": "라벨을 적용하기 위한 최소 일치 신뢰도(0.50-0.95)입니다. 비워 두면 기기의 자동 라벨 신뢰도 설정이 사용됩니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "구성 내보내기",
|
||||
"description": "이 장치의 프로필, 사이클 및 설정을 JSON 파일로 내보냅니다(장치별).",
|
||||
"description": "이 장치의 프로필, 사이클 및 설정을 JSON 파일로 내보냅니다.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "장치",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "사이클 피드백 제출",
|
||||
"description": "사이클이 완료된 후 자동 감지된 프로그램을 확인하거나 수정합니다. `entry_id`(고급) 또는 `device_id`(권장)를 제공하세요.",
|
||||
"description": "완료된 사이클에서 감지된 프로그램을 확인하거나 수정합니다. `device_id`(권장) 또는 `entry_id`를 지정하세요.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "장치",
|
||||
@@ -272,18 +270,18 @@
|
||||
}
|
||||
},
|
||||
"trigger_ml_training": {
|
||||
"name": "Trigger ML Training",
|
||||
"name": "ML 훈련 시작",
|
||||
"description": "이 기기의 자체 라벨이 지정된 주기에서 기기 내 ML 모델을 수동으로 재교육합니다(실험용).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device",
|
||||
"description": "The WashData device to retrain models for."
|
||||
"name": "장치",
|
||||
"description": "모델을 재훈련할 WashData 장치입니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "꺼냄으로 표시",
|
||||
"description": "WashData 장치에서 완료된 내용물을 꺼냈음을 확인하여 깨끗한 상태와 모든 꺼내기 알림을 해제합니다.",
|
||||
"description": "완료된 내용물을 꺼냈음을 확인합니다. 깨끗한 상태와 모든 꺼내기 알림을 해제합니다.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "장치",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "일치 모호도"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "유지보수 예정",
|
||||
"state": {
|
||||
"on": "예정",
|
||||
"off": "예정 없음"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "예정 작업 ID"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "예정 작업",
|
||||
"state": {
|
||||
"descale": "석회질 제거",
|
||||
"filter_clean": "필터 청소",
|
||||
"drum_clean": "드럼 청소",
|
||||
"bearing_service": "베어링 정비",
|
||||
"other": "기타",
|
||||
"salt": "소금 보충",
|
||||
"rinse_aid": "린스 보충",
|
||||
"lint_filter": "보풀 필터 청소",
|
||||
"condenser_clean": "콘덴서 청소"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "주름 방지",
|
||||
"interrupted": "중단됨",
|
||||
"force_stopped": "강제 정지됨",
|
||||
"rinse": "헹굼",
|
||||
"unknown": "알 수 없음",
|
||||
"clean": "깨끗한",
|
||||
"delay_wait": "시작을 기다리는 중"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "사이클 이상",
|
||||
"state": {
|
||||
"overrun": "지연 중",
|
||||
"stalled": "멈춤"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData sąranka",
|
||||
"description": "Sukonfigūruokite skalbimo mašiną ar kitą prietaisą.\n\nReikalingas galios jutiklis.\n\nBaigę sąranką, atidarykite WashData skydelį iš šoninės juostos (arba eikite į [/ha-washdata](/ha-washdata)), kad peržiūrėtumėte ciklus, sureguliuotumėte aptikimą ir tvarkytumėte profilius.",
|
||||
"description": "Sukonfigūruokite skalbimo mašiną ar kitą prietaisą. Reikalingas galios jutiklis.\n\nBaigę sąranką, atidarykite WashData skydelį šoninėje juostoje ([/ha-washdata](/ha-washdata)), kad matytumėte ciklus, profilius ir visus nustatymus.",
|
||||
"data": {
|
||||
"name": "Įrenginio pavadinimas",
|
||||
"device_type": "Įrenginio tipas",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Patogus šio įrenginio pavadinimas (pvz., 'Skalbimo mašina', 'Indaplovė').",
|
||||
"device_type": "Kokio tipo tai prietaisas? Padeda pritaikyti aptikimą ir ženklinimą.",
|
||||
"power_sensor": "Jutiklio objektas, kuris realiuoju laiku praneša energijos suvartojimą (vatais) iš jūsų išmaniojo kištuko.",
|
||||
"min_power": "Galios rodmenys, viršijantys šią ribą (vatais), rodo, kad prietaisas veikia. Pradėkite nuo 2 W daugeliui įrenginių."
|
||||
"device_type": "Nustato šio prietaiso tipo numatytuosius aptikimo nustatymus.",
|
||||
"power_sensor": "Išmaniojo kištuko jutiklis, pranešantis esamą galią vatais.",
|
||||
"min_power": "Rodmenys virš šios ribos (vatais) reiškia, kad prietaisas veikia. Daugeliui įrenginių tinka 2 W."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "WashData rekonfigūracija",
|
||||
"description": "Atnaujinkite įrenginio pavadinimą, prietaiso tipą arba galios jutiklį.\n\nWashData skydelyje (šoninė juosta arba [/ha-washdata](/ha-washdata)) tvarkomas aptikimas, ciklai, profiliai ir pranešimai.",
|
||||
"description": "Pakeiskite įrenginio pavadinimą, prietaiso tipą arba galios jutiklį.\n\nVisa kita yra WashData skydelyje (šoninė juosta arba [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Įrenginio pavadinimas",
|
||||
"device_type": "Įrenginio tipas",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nepavyko prisijungti",
|
||||
"invalid_auth": "Neteisingas autentifikavimas",
|
||||
"unknown": "Netikėta klaida",
|
||||
"invalid_power": "Galios slenkstis turi būti didesnis nei 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData nustatymai",
|
||||
"description": "Pervadinkite įrenginį arba pakeiskite pagrindinius aparatinės įrangos nustatymus. Visi aptikimo, atitikimo ir pranešimų nustatymai yra [WashData skydelyje](/ha-washdata) (taip pat pasiekiami iš šoninės juostos).",
|
||||
"description": "Pervadinkite įrenginį arba pakeiskite pagrindinius nustatymus. Visa kita yra [WashData skydelyje](/ha-washdata) šoninėje juostoje.",
|
||||
"data": {
|
||||
"name": "Įrenginio pavadinimas",
|
||||
"device_type": "Įrenginio tipas",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profilio pavadinimas",
|
||||
"description": "Esamo profilio pavadinimas (sukurkite profilius meniu Tvarkyti profilius). Palikite tuščią, kad pašalintumėte etiketę."
|
||||
"description": "Esamo profilio pavadinimas (profiliai kuriami WashData skydelyje). Palikite tuščią, kad pašalintumėte žymę."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Patikimumo slenkstis",
|
||||
"description": "Minimalus atitikties patikimumas (0,50–0,95) etiketėms taikyti."
|
||||
"description": "Minimalus atitikties patikimumas (0,50-0,95) etiketėms taikyti. Palikite tuščią, kad būtų naudojamas įrenginio nustatymas „Automatinio žymėjimo pasitikėjimas“."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Eksportuoti konfigūraciją",
|
||||
"description": "Eksportuoti šio įrenginio profilius, ciklus ir nustatymus į JSON failą (kiekvienam įrenginiui).",
|
||||
"description": "Eksportuoti šio įrenginio profilius, ciklus ir nustatymus į JSON failą.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Įrenginys",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Pateikti ciklo atsiliepimą",
|
||||
"description": "Patvirtinti arba pataisyti automatiškai aptiktą programą po baigto ciklo. Pateikite `entry_id` (išplėstinis) arba `device_id` (rekomenduojama).",
|
||||
"description": "Patvirtinti arba pataisyti baigto ciklo aptiktą programą. Nurodykite `device_id` (rekomenduojama) arba `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Įrenginys",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Pažymėti kaip iškrautą",
|
||||
"description": "Patvirtinkite, kad baigto ciklo turinys išimtas iš „WashData“ įrenginio: būsena „Švarus“ ir iškrovimo priminimas bus panaikinti.",
|
||||
"description": "Patvirtinti, kad baigto ciklo turinys išimtas. Išvalo būseną „Švarus“ ir iškrovimo priminimą.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Įrenginys",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Atitikties dviprasmiškumas"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Reikia priežiūros",
|
||||
"state": {
|
||||
"on": "Reikia",
|
||||
"off": "Nereikia"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Reikalingų užduočių ID"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Reikalingos užduotys",
|
||||
"state": {
|
||||
"descale": "Kalkių šalinimas",
|
||||
"filter_clean": "Valyti filtrą",
|
||||
"drum_clean": "Valyti būgną",
|
||||
"bearing_service": "Guolių aptarnavimas",
|
||||
"other": "Kita",
|
||||
"salt": "Papildyti druskos",
|
||||
"rinse_aid": "Papildyti skalavimo priemonės",
|
||||
"lint_filter": "Valyti pūkų filtrą",
|
||||
"condenser_clean": "Valyti kondensatorių"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Apsauga nuo raukšlių",
|
||||
"interrupted": "Nutraukta",
|
||||
"force_stopped": "Priverstinai sustabdyta",
|
||||
"rinse": "Skalavimas",
|
||||
"unknown": "Nežinoma",
|
||||
"clean": "Švarus",
|
||||
"delay_wait": "Laukiama pradžios"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Ciklo anomalija",
|
||||
"state": {
|
||||
"overrun": "Veikia ilgai",
|
||||
"stalled": "Užstrigo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData iestatīšana",
|
||||
"description": "Konfigurējiet veļas mašīnu vai citu ierīci.\n\nNepieciešams jaudas sensors.\n\nPēc iestatīšanas atveriet WashData paneli no sānjoslas (vai dodieties uz [/ha-washdata](/ha-washdata)), lai skatītu ciklus, pielāgotu noteikšanu un pārvaldītu profilus.",
|
||||
"description": "Iestatiet veļas mašīnu vai citu ierīci. Nepieciešams jaudas sensors.\n\nPēc iestatīšanas atveriet WashData paneli no sānjoslas ([/ha-washdata](/ha-washdata)), lai redzētu ciklus, profilus un visus iestatījumus.",
|
||||
"data": {
|
||||
"name": "Ierīces nosaukums",
|
||||
"device_type": "Ierīces veids",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Šīs ierīces draudzīgs nosaukums (piemēram, “Veļas mašīna”, “Trauku mazgājamā mašīna”).",
|
||||
"device_type": "Kāda veida ierīce šī ir? Palīdz pielāgot noteikšanu un marķēšanu.",
|
||||
"power_sensor": "Sensora entītija, kas ziņo reāllaika enerģijas patēriņu (vatos) no jūsu viedā spraudņa.",
|
||||
"min_power": "Jaudas rādījumi, kas pārsniedz šo slieksni (vatos), norāda, ka ierīce darbojas. Lielākajai daļai ierīču sāciet ar 2 W."
|
||||
"device_type": "Iestata šāda veida ierīcei piemērotus noteikšanas noklusējumus.",
|
||||
"power_sensor": "Viedā spraudņa sensors, kas ziņo pašreizējo jaudu vatos.",
|
||||
"min_power": "Rādījumi virs šīs vērtības (vatos) nozīmē, ka ierīce darbojas. Lielākajai daļai ierīču der 2 W."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "WashData pārkonfigurēšana",
|
||||
"description": "Atjauniniet ierīces nosaukumu, ierīces veidu vai jaudas sensoru.\n\nWashData panelī (sānjosla vai [/ha-washdata](/ha-washdata)) tiek pārvaldīta noteikšana, cikli, profili un paziņojumi.",
|
||||
"description": "Mainiet ierīces nosaukumu, ierīces veidu vai jaudas sensoru.\n\nViss pārējais ir WashData panelī (sānjosla vai [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Ierīces nosaukums",
|
||||
"device_type": "Ierīces veids",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Neizdevās izveidot savienojumu",
|
||||
"invalid_auth": "Nederīga autentifikācija",
|
||||
"unknown": "Negaidīta kļūda",
|
||||
"invalid_power": "Jaudas slieksnim jābūt lielākam par 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData iestatījumi",
|
||||
"description": "Pārdēvējiet ierīci vai mainiet tās galvenos aparatūras iestatījumus. Visi noteikšanas, saskaņošanas un paziņojumu iestatījumi atrodas [WashData panelī](/ha-washdata) (pieejams arī no sānjoslas).",
|
||||
"description": "Pārdēvējiet ierīci vai mainiet tās galvenos iestatījumus. Viss pārējais ir [WashData panelī](/ha-washdata) sānjoslā.",
|
||||
"data": {
|
||||
"name": "Ierīces nosaukums",
|
||||
"device_type": "Ierīces veids",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profila nosaukums",
|
||||
"description": "Esoša profila nosaukums (izveidojiet profilus izvēlnē Pārvaldīt profilus). Atstājiet tukšu, lai noņemtu etiķeti."
|
||||
"description": "Esoša profila nosaukums (profilus izveido WashData panelī). Atstājiet tukšu, lai noņemtu etiķeti."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Pārliecības slieksnis",
|
||||
"description": "Minimālā atbilstības ticamība (0,50–0,95), lai lietotu etiķetes."
|
||||
"description": "Minimālā atbilstības ticamība (0,50-0,95), lai lietotu etiķetes. Atstājiet tukšu, lai izmantotu ierīces iestatījumu Automātiskās marķēšanas pārliecība."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Eksportēt konfigurāciju",
|
||||
"description": "Eksportējiet šīs ierīces profilus, ciklus un iestatījumus JSON failā (katrai ierīcei).",
|
||||
"description": "Eksportējiet šīs ierīces profilus, ciklus un iestatījumus JSON failā.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Ierīce",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Iesniedziet cikla atsauksmes",
|
||||
"description": "Pēc cikla pabeigšanas apstipriniet vai labojiet automātiski noteikto programmu. Norādiet vai nu “entry_id” (papildu) vai “device_id” (ieteicams).",
|
||||
"description": "Apstipriniet vai labojiet pabeigta cikla noteikto programmu. Norādiet `device_id` (ieteicams) vai `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Ierīce",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Atzīmēt kā izkrautu",
|
||||
"description": "Apstipriniet, ka pabeigtā cikla saturs ir izņemts no WashData ierīces: stāvoklis „Tīrs“ un izkraušanas atgādinājums tiks notīrīti.",
|
||||
"description": "Apstipriniet, ka pabeigtā cikla saturs ir izņemts. Notīra stāvokli „Tīrs“ un izkraušanas atgādinājumu.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Ierīce",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Atbilstības neskaidrība"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Jāveic apkope",
|
||||
"state": {
|
||||
"on": "Jāveic",
|
||||
"off": "Nav jāveic"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "Jāveicamo uzdevumu ID"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Jāveicamie uzdevumi",
|
||||
"state": {
|
||||
"descale": "Atkaļķošana",
|
||||
"filter_clean": "Tīrīt filtru",
|
||||
"drum_clean": "Tīrīt tromeli",
|
||||
"bearing_service": "Gultņu apkope",
|
||||
"other": "Cits",
|
||||
"salt": "Papildināt sāli",
|
||||
"rinse_aid": "Papildināt skalošanas līdzekli",
|
||||
"lint_filter": "Tīrīt plūksnu filtru",
|
||||
"condenser_clean": "Tīrīt kondensatoru"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Pretgrumbu",
|
||||
"interrupted": "Pārtrauca",
|
||||
"force_stopped": "Piespiedu kārtā apturēta",
|
||||
"rinse": "Noskalo",
|
||||
"unknown": "Nezināms",
|
||||
"clean": "Tīrs",
|
||||
"delay_wait": "Gaida sākumu"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Cikla anomālija",
|
||||
"state": {
|
||||
"overrun": "Darbojas ilgi",
|
||||
"stalled": "Iestrēdzis"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Поставување податоци за перење",
|
||||
"description": "Конфигурирајте ја вашата машина за перење или друг уред.\n\nПотребен е сензор за напојување.\n\nПо поставувањето, отворете го панелот на WashData од страничната лента (или одете на [/ha-washdata](/ha-washdata)) за преглед на циклусите, подесување на откривањето и управување со профили.",
|
||||
"description": "Поставете машина за перење или друг апарат. Потребен е сензор за напојување.\n\nПо поставувањето отворете го панелот WashData од страничната лента ([/ha-washdata](/ha-washdata)): таму се циклусите, профилите и сите поставки.",
|
||||
"data": {
|
||||
"name": "Име на уред",
|
||||
"device_type": "Тип на уред",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Пријателско име за овој уред (на пр., „Машина за перење“, „Миење садови“).",
|
||||
"device_type": "Каков тип на апарат е ова? Помага при прилагодување на откривањето и етикетирањето.",
|
||||
"power_sensor": "Сензорскиот ентитет кој известува за потрошувачката на енергија во реално време (во вати) од вашиот паметен приклучок.",
|
||||
"min_power": "Читањата на моќноста над овој праг (во вати) покажуваат дека апаратот работи. Започнете со 2W за повеќето уреди."
|
||||
"device_type": "Ги поставува стандардните вредности за откривање за овој вид апарат.",
|
||||
"power_sensor": "Сензорот на паметниот приклучок што ја јавува тековната моќност во вати.",
|
||||
"min_power": "Читања над оваа вредност (во вати) значат дека апаратот работи. 2 W одговара за повеќето уреди."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Реконфигурирај WashData",
|
||||
"description": "Ажурирајте го името на уредот, типот на апаратот или сензорот за напојување.\n\nПанелот на WashData (странична лента или [/ha-washdata](/ha-washdata)) е местото каде се управуваат откривањето, циклусите, профилите и известувањата.",
|
||||
"description": "Сменете го името на уредот, типот на апаратот или сензорот за напојување.\n\nСè друго е во панелот WashData (странична лента или [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Име на уред",
|
||||
"device_type": "Тип на уред",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Не успеа да се поврзе",
|
||||
"invalid_auth": "Неважечка автентикација",
|
||||
"unknown": "Неочекувана грешка",
|
||||
"invalid_power": "Прагот на моќност мора да биде поголем од 0"
|
||||
},
|
||||
@@ -42,8 +40,8 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData Settings",
|
||||
"description": "Преименувајте го уредот или сменете ги неговите основни хардверски поставки. Сите поставки за откривање, совпаѓање и известување се во [панелот на WashData](/ha-washdata) (достапен и од страничната лента).",
|
||||
"title": "Поставки на WashData",
|
||||
"description": "Преименувајте го уредот или сменете ги неговите основни поставки. Сè друго е во [панелот WashData](/ha-washdata) во страничната лента.",
|
||||
"data": {
|
||||
"name": "Име на уред",
|
||||
"device_type": "Тип на уред",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Име на профилот",
|
||||
"description": "Името на постоечки профил (создајте профили во менито Управување со профили). Оставете празно за да ја отстраните етикетата."
|
||||
"description": "Име на постоечки профил (профилите се создаваат во панелот WashData). Оставете празно за да ја отстраните етикетата."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Праг на доверба",
|
||||
"description": "Минимална сигурност за совпаѓање (0,50-0,95) за примена на етикети."
|
||||
"description": "Минимална сигурност за совпаѓање (0,50-0,95) за примена на етикети. Оставете празно за да се користи поставката „Доверба за автоматско означување“ на уредот."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Извези конфигурација",
|
||||
"description": "Извезете ги профилите, циклусите и поставките на овој уред во датотека JSON (по уред).",
|
||||
"description": "Извезете ги профилите, циклусите и поставките на овој уред во JSON датотека.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Уред",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Испратете повратни информации за циклусот",
|
||||
"description": "Потврдете или поправете програма што е автоматски откриена по завршен циклус. Наведете „idry_id“ (напредно) или „device_id“ (препорачано).",
|
||||
"description": "Потврдете ја или поправете ја откриената програма на завршен циклус. Наведете `device_id` (препорачано) или `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Уред",
|
||||
@@ -272,7 +270,7 @@
|
||||
}
|
||||
},
|
||||
"trigger_ml_training": {
|
||||
"name": "Trigger ML Training",
|
||||
"name": "Покрени ML обука",
|
||||
"description": "Рачно обучете ги моделите ML на уредот од означените циклуси на овој уред (експериментални).",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Означи како истоварено",
|
||||
"description": "Потврдете дека завршената содржина е извадена од уредот WashData, со што се брише состојбата \"Чиста\" и секој потсетник за истовар.",
|
||||
"description": "Потврдете дека готовата содржина е извадена. Ја брише состојбата \"Чиста\" и потсетникот за истовар.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Уред",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Нејасност на совпаѓање"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Треба одржување",
|
||||
"state": {
|
||||
"on": "Треба",
|
||||
"off": "Не треба"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID на задачи за извршување"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Задачи за извршување",
|
||||
"state": {
|
||||
"descale": "Отстранување на бигор",
|
||||
"filter_clean": "Исчисти го филтерот",
|
||||
"drum_clean": "Исчисти го барабанот",
|
||||
"bearing_service": "Сервис на лежишта",
|
||||
"other": "Друго",
|
||||
"salt": "Дополни сол",
|
||||
"rinse_aid": "Дополни средство за плакнење",
|
||||
"lint_filter": "Исчисти го филтерот за мовчиња",
|
||||
"condenser_clean": "Исчисти го кондензаторот"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Против брчки",
|
||||
"interrupted": "Прекинато",
|
||||
"force_stopped": "Сила запре",
|
||||
"rinse": "Исплакнете",
|
||||
"unknown": "Непознат",
|
||||
"clean": "Чиста",
|
||||
"delay_wait": "Се чека да започне"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Аномалија на циклусот",
|
||||
"state": {
|
||||
"overrun": "Трае предолго",
|
||||
"stalled": "Заглавено"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData-oppsett",
|
||||
"description": "Konfigurer vaskemaskinen din eller et annet apparat.\n\nStrømsensor er nødvendig.\n\nEtter oppsett åpner du WashData-panelet fra sidefeltet (eller gå til [/ha-washdata](/ha-washdata)) for å se sykluser, justere registrering og administrere profiler.",
|
||||
"description": "Sett opp en vaskemaskin eller et annet apparat. En strømsensor er påkrevd.\n\nÅpne etter oppsettet WashData-panelet fra sidefeltet ([/ha-washdata](/ha-washdata)) for å se sykluser, profiler og alle innstillinger.",
|
||||
"data": {
|
||||
"name": "Enhetsnavn",
|
||||
"device_type": "Enhetstype",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Et vennlig navn for denne enheten (f.eks. \"Vaskemaskin\", \"Oppvaskmaskin\").",
|
||||
"device_type": "Hva slags apparat er dette? Hjelper med å skreddersy deteksjon og merking.",
|
||||
"power_sensor": "Sensorenheten som rapporterer strømforbruk i sanntid (i watt) fra smartpluggen din.",
|
||||
"min_power": "Effektavlesninger over denne terskelen (i watt) indikerer at apparatet er i gang. Start med 2W for de fleste enheter."
|
||||
"device_type": "Angir deteksjonsstandarder for denne typen apparat.",
|
||||
"power_sensor": "Smartpluggens sensor som rapporterer gjeldende effekt i watt.",
|
||||
"min_power": "Målinger over denne verdien (i watt) betyr at apparatet går. 2 W passer for de fleste enheter."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Rekonfigurer WashData",
|
||||
"description": "Oppdater enhetsnavnet, apparattypen eller strømsensoren.\n\nWashData-panelet (sidefeltet eller [/ha-washdata](/ha-washdata)) er der registrering, sykluser, profiler og varsler administreres.",
|
||||
"description": "Endre enhetsnavn, apparattype eller strømsensor.\n\nAlt annet finnes i WashData-panelet (sidefeltet eller [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Enhetsnavn",
|
||||
"device_type": "Enhetstype",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kunne ikke koble til",
|
||||
"invalid_auth": "Ugyldig autentisering",
|
||||
"unknown": "Uventet feil",
|
||||
"invalid_power": "Effektterskelen må være større enn 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData-innstillinger",
|
||||
"description": "Gi enheten nytt navn eller endre de grunnleggende maskinvareinnstillingene. Alle innstillinger for registrering, matching og varsler finnes i [WashData-panelet](/ha-washdata) (også tilgjengelig fra sidefeltet).",
|
||||
"description": "Gi enheten nytt navn eller endre grunninnstillingene. Alt annet finnes i [WashData-panelet](/ha-washdata) i sidefeltet.",
|
||||
"data": {
|
||||
"name": "Enhetsnavn",
|
||||
"device_type": "Enhetstype",
|
||||
@@ -70,7 +68,7 @@
|
||||
"dryer": "Tørketrommel",
|
||||
"washer_dryer": "Vaske-tørketrommel kombi",
|
||||
"dishwasher": "Oppvaskmaskin",
|
||||
"air_fryer": "Air Fryer",
|
||||
"air_fryer": "Airfryer",
|
||||
"bread_maker": "Brødbaker",
|
||||
"pump": "Pumpe / Sump Pump",
|
||||
"generic": "Annet (avansert)",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profilnavn",
|
||||
"description": "Navnet på en eksisterende profil (opprett profiler i Administrer profiler-menyen). La stå tomt for å fjerne etiketten."
|
||||
"description": "Navnet på en eksisterende profil (opprett profiler i WashData-panelet). La stå tomt for å fjerne merkingen."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Konfidensgrense",
|
||||
"description": "Minste matchkonfidens (0,50–0,95) for å bruke etiketter."
|
||||
"description": "Minste matchkonfidens (0,50–0,95) for å bruke etiketter. La feltet stå tomt for å bruke enhetens innstilling \"Auto-merking konfidensgrad\"."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Eksporter konfig",
|
||||
"description": "Eksporter denne enhetens profiler, sykluser og innstillinger til en JSON-fil (per enhet).",
|
||||
"description": "Eksporter denne enhetens profiler, sykluser og innstillinger til en JSON-fil.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Enhet",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Send tilbakemelding om syklus",
|
||||
"description": "Bekreft eller korriger et automatisk oppdaget program etter en fullført syklus. Oppgi enten «entry_id» (avansert) eller «device_id» (anbefalt).",
|
||||
"description": "Bekreft eller korriger det oppdagede programmet for en fullført syklus. Oppgi `device_id` (anbefalt) eller `entry_id`.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Enhet",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Merk som utlastet",
|
||||
"description": "Bekreft at den ferdige vasken er tatt ut av en WashData-enhet, noe som fjerner tilstanden \"Rengjøre\" og en eventuell utlastingspåminnelse.",
|
||||
"description": "Bekreft at den ferdige vasken er tatt ut. Fjerner tilstanden \"Ren\" og en eventuell utlastingspåminnelse.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Enhet",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Match Tvetydighet"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Vedlikehold forfaller",
|
||||
"state": {
|
||||
"on": "Forfalt",
|
||||
"off": "Ikke forfalt"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID-er for forfalte oppgaver"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Forfalte oppgaver",
|
||||
"state": {
|
||||
"descale": "Avkalking",
|
||||
"filter_clean": "Rengjør filter",
|
||||
"drum_clean": "Rengjør trommel",
|
||||
"bearing_service": "Lagerservice",
|
||||
"other": "Annet",
|
||||
"salt": "Fyll på salt",
|
||||
"rinse_aid": "Fyll på glansemiddel",
|
||||
"lint_filter": "Rengjør lofilter",
|
||||
"condenser_clean": "Rengjør kondensator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Anti-rynke",
|
||||
"interrupted": "Avbrutt",
|
||||
"force_stopped": "Tving stoppet",
|
||||
"rinse": "Skylle",
|
||||
"unknown": "Ukjent",
|
||||
"clean": "Rengjøre",
|
||||
"clean": "Ren",
|
||||
"delay_wait": "Venter på å starte"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Syklusavvik",
|
||||
"state": {
|
||||
"overrun": "Kjører lenger",
|
||||
"stalled": "Stoppet opp"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "WashData-instellingen",
|
||||
"description": "Configureer uw wasmachine of ander apparaat.\n\nVermogenssensor is vereist.\n\nNa de installatie opent u het WashData-paneel via de zijbalk (of ga naar [/ha-washdata](/ha-washdata)) om cycli te bekijken, detectie af te stellen en profielen te beheren.",
|
||||
"description": "Stel een wasmachine of ander apparaat in. Een vermogenssensor is vereist.\n\nOpen na de installatie het WashData-paneel via de zijbalk ([/ha-washdata](/ha-washdata)) voor cycli, profielen en alle instellingen.",
|
||||
"data": {
|
||||
"name": "Apparaatnaam",
|
||||
"device_type": "Apparaattype",
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Een beschrijvende naam voor dit apparaat (bijvoorbeeld 'Wasmachine', 'Vaatwasser').",
|
||||
"device_type": "Welk type apparaat is dit? Helpt bij het op maat maken van detectie en labeling.",
|
||||
"power_sensor": "De sensorentiteit die het realtime energieverbruik (in watt) van uw slimme stekker rapporteert.",
|
||||
"min_power": "Vermogensmetingen boven deze drempel (in watt) geven aan dat het apparaat werkt. Begin met 2W voor de meeste apparaten."
|
||||
"device_type": "Stelt de detectiestandaarden in voor dit soort apparaat.",
|
||||
"power_sensor": "De sensor van de slimme stekker die het actuele vermogen in watt meldt.",
|
||||
"min_power": "Metingen boven deze waarde (in watt) betekenen dat het apparaat draait. 2 W past bij de meeste apparaten."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "WashData opnieuw configureren",
|
||||
"description": "Werk de apparaatnaam, het type apparaat of de vermogenssensor bij.\n\nHet WashData-paneel (zijbalk of [/ha-washdata](/ha-washdata)) is waar detectie, cycli, profielen en meldingen worden beheerd.",
|
||||
"description": "Wijzig de apparaatnaam, het apparaattype of de vermogenssensor.\n\nAl het andere staat in het WashData-paneel (zijbalk of [/ha-washdata](/ha-washdata)).",
|
||||
"data": {
|
||||
"name": "Apparaatnaam",
|
||||
"device_type": "Apparaattype",
|
||||
@@ -29,8 +29,6 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kan geen verbinding maken",
|
||||
"invalid_auth": "Ongeldige authenticatie",
|
||||
"unknown": "Onverwachte fout",
|
||||
"invalid_power": "Vermogensdrempel moet groter zijn dan 0"
|
||||
},
|
||||
@@ -43,7 +41,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "WashData-instellingen",
|
||||
"description": "Geef het apparaat een nieuwe naam of wijzig de basishardware-instellingen. Alle instellingen voor detectie, matching en meldingen bevinden zich in het [WashData-paneel](/ha-washdata) (ook bereikbaar via de zijbalk).",
|
||||
"description": "Geef het apparaat een nieuwe naam of wijzig de basisinstellingen. Al het andere staat in het [WashData-paneel](/ha-washdata) in de zijbalk.",
|
||||
"data": {
|
||||
"name": "Apparaatnaam",
|
||||
"device_type": "Apparaattype",
|
||||
@@ -93,7 +91,7 @@
|
||||
},
|
||||
"profile_name": {
|
||||
"name": "Profielnaam",
|
||||
"description": "De naam van een bestaand profiel (maak profielen aan in het menu Profielen beheren). Laat leeg om het label te verwijderen."
|
||||
"description": "Naam van een bestaand profiel (maak profielen aan in het WashData-paneel). Laat leeg om het label te verwijderen."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -143,13 +141,13 @@
|
||||
},
|
||||
"confidence_threshold": {
|
||||
"name": "Betrouwbaarheidsdrempel",
|
||||
"description": "Minimale matchbetrouwbaarheid (0,50-0,95) om labels toe te passen."
|
||||
"description": "Minimale matchbetrouwbaarheid (0,50-0,95) om labels toe te passen. Laat leeg om de instelling \"Auto-label betrouwbaarheid\" van het apparaat te gebruiken."
|
||||
}
|
||||
}
|
||||
},
|
||||
"export_config": {
|
||||
"name": "Configuratie exporteren",
|
||||
"description": "Exporteer de profielen, cycli en instellingen van dit apparaat naar een JSON-bestand (per apparaat).",
|
||||
"description": "Exporteer de profielen, cycli en instellingen van dit apparaat naar een JSON-bestand.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Apparaat",
|
||||
@@ -177,7 +175,7 @@
|
||||
},
|
||||
"submit_cycle_feedback": {
|
||||
"name": "Cyclusfeedback indienen",
|
||||
"description": "Bevestig of corrigeer een automatisch gedetecteerd programma na een voltooide cyclus. Geef `entry_id` (geavanceerd) of `device_id` (aanbevolen) op.",
|
||||
"description": "Bevestig of corrigeer het gedetecteerde programma van een voltooide cyclus. Geef `device_id` (aanbevolen) of `entry_id` op.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Apparaat",
|
||||
@@ -283,7 +281,7 @@
|
||||
},
|
||||
"mark_unloaded": {
|
||||
"name": "Markeren als uitgeladen",
|
||||
"description": "Bevestig dat de voltooide lading uit een WashData-apparaat is gehaald, waarmee de staat \"Schoon\" en een eventuele uitlaadherinnering worden gewist.",
|
||||
"description": "Bevestig dat de voltooide lading is uitgehaald. Wist de staat \"Schoon\" en een eventuele uitlaadherinnering.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Apparaat",
|
||||
@@ -299,6 +297,32 @@
|
||||
},
|
||||
"match_ambiguity": {
|
||||
"name": "Match-dubbelzinnigheid"
|
||||
},
|
||||
"maintenance_due": {
|
||||
"name": "Onderhoud nodig",
|
||||
"state": {
|
||||
"on": "Nodig",
|
||||
"off": "Niet nodig"
|
||||
},
|
||||
"state_attributes": {
|
||||
"due_task_ids": {
|
||||
"name": "ID's van openstaande taken"
|
||||
},
|
||||
"due_tasks": {
|
||||
"name": "Openstaande taken",
|
||||
"state": {
|
||||
"descale": "Ontkalken",
|
||||
"filter_clean": "Filter reinigen",
|
||||
"drum_clean": "Trommel reinigen",
|
||||
"bearing_service": "Lageronderhoud",
|
||||
"other": "Overig",
|
||||
"salt": "Zout bijvullen",
|
||||
"rinse_aid": "Glansmiddel bijvullen",
|
||||
"lint_filter": "Pluizenfilter reinigen",
|
||||
"condenser_clean": "Condensor reinigen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -316,10 +340,18 @@
|
||||
"anti_wrinkle": "Anti-kreuk",
|
||||
"interrupted": "Onderbroken",
|
||||
"force_stopped": "Geforceerd gestopt",
|
||||
"rinse": "Spoelen",
|
||||
"unknown": "Onbekend",
|
||||
"clean": "Schoon",
|
||||
"delay_wait": "Wachten om te beginnen"
|
||||
},
|
||||
"state_attributes": {
|
||||
"cycle_anomaly": {
|
||||
"name": "Cyclusafwijking",
|
||||
"state": {
|
||||
"overrun": "Loopt langer",
|
||||
"stalled": "Vastgelopen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"washer_program": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user