diff --git a/custom_components/ha_washdata/__init__.py b/custom_components/ha_washdata/__init__.py index 2a872d70..57f6e07f 100644 --- a/custom_components/ha_washdata/__init__.py +++ b/custom_components/ha_washdata/__init__.py @@ -33,6 +33,8 @@ from homeassistant.helpers import device_registry as dr from .const import ( DOMAIN, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, SERVICE_SUBMIT_FEEDBACK, CONF_LINKED_DEVICE, CONF_MIN_POWER, @@ -103,9 +105,12 @@ from .const import ( DEVICE_TYPE_OTHER, DEFAULT_START_DURATION_THRESHOLD, CONF_START_DURATION_THRESHOLD, + resolve_watchdog_interval_default, + resolve_start_duration_default, CONF_RUNNING_DEAD_ZONE, ) from .log_utils import DeviceLoggerAdapter +from .options_utils import strip_null_options _LOGGER = logging.getLogger(__name__) @@ -132,13 +137,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: version = entry.version or 1 minor_version = entry.minor_version or 1 - if version > 3: + if version > CONFIG_ENTRY_VERSION: _log.error( "Refusing to migrate unsupported future schema %s.%s", version, minor_version ) return False - if version == 3 and minor_version >= 8: + if version == CONFIG_ENTRY_VERSION and minor_version >= CONFIG_ENTRY_MINOR_VERSION: return True # 3.6 → 3.7: remove initial_profile stub key from entry.data. @@ -160,7 +165,81 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: minor_version = 8 _log.debug("Migrated WashData entry from 3.7 to 3.8 (removed running_dead_zone)") - if version == 3 and minor_version >= 8: + # 3.8 → 3.9: drop options persisted as null. A never-saved setting has no + # entry in options, so the per-setting Revert used to send the changelog's + # `old` (null) and ws_set_options stored it verbatim - and a stored None + # survives options.get(key, DEFAULT), so the numeric casts that build + # CycleDetectorConfig raised TypeError and the entry could never be set up + # again without hand-editing .storage (#389). ws_set_options and the import + # path now strip on write; this heals entries already carrying one. Healing + # here rather than on a setup failure keeps it deterministic, covers the + # crash classes a null produces outside a numeric cast (a null power_sensor + # raises AttributeError inside hass.states.get(None)), and never rewrites the + # entry mid-setup. + if version == 3 and minor_version == 8: + new_opts = strip_null_options(entry.options) + dropped = sorted(set(entry.options) - set(new_opts)) + hass.config_entries.async_update_entry( + entry, options=new_opts, minor_version=9 + ) + minor_version = 9 + if dropped: + _log.warning( + "Migrated WashData entry from 3.8 to 3.9: dropped option(s) %s " + "stored as null so the compiled defaults apply again", + dropped, + ) + else: + _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. + 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 = [] + 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 + # future bump to 11, jump a 3.9 entry straight to 11 and skip the new 3.10->3.11 + # step entirely. Only the one-pass legacy write at the end means "land on + # current" and uses the constant. + hass.config_entries.async_update_entry( + entry, options=new_opts, minor_version=10 + ) + minor_version = 10 + _log.debug( + "Migrated WashData entry from 3.9 to 3.10 (healed seeded cadence " + "defaults: %s)", + _healed or "none", + ) + + if version == CONFIG_ENTRY_VERSION and minor_version >= CONFIG_ENTRY_MINOR_VERSION: return True data: dict[str, Any] = dict(entry.data) @@ -207,7 +286,17 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options.setdefault( CONF_DEVICE_TYPE, data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE) ) - options.setdefault(CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD) + # setdefault does not replace a present-but-null value; a null device type would + # then miss the per-type maps and fall through to the coarse scalar defaults (30/61) + # instead of the washing-machine defaults DEFAULT_DEVICE_TYPE stands for (5/30). + # Coerce it here (an explicit type is truthy and preserved) so both resolvers below + # see a real device type. + if not options.get(CONF_DEVICE_TYPE): + options[CONF_DEVICE_TYPE] = DEFAULT_DEVICE_TYPE + options.setdefault( + CONF_START_DURATION_THRESHOLD, + resolve_start_duration_default(options[CONF_DEVICE_TYPE]), + ) options.setdefault(CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL) options.setdefault( @@ -223,7 +312,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options.setdefault( CONF_MAX_FULL_TRACES_UNLABELED, DEFAULT_MAX_FULL_TRACES_UNLABELED ) - options.setdefault(CONF_WATCHDOG_INTERVAL, DEFAULT_WATCHDOG_INTERVAL) + options.setdefault( + CONF_WATCHDOG_INTERVAL, + resolve_watchdog_interval_default(options[CONF_DEVICE_TYPE]), + ) options.setdefault( CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD, DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD ) @@ -294,15 +386,20 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # (covers entries that migrate straight from v1/v2/early-v3 in one pass). options.pop(CONF_RUNNING_DEAD_ZONE, None) + # Same for an option persisted as null (3.8 -> 3.9), so a one-pass legacy + # migration lands on the current schema rather than needing a second call. + options = strip_null_options(options) + hass.config_entries.async_update_entry( entry, data=data, options=options, - version=3, - minor_version=8, + version=CONFIG_ENTRY_VERSION, + minor_version=CONFIG_ENTRY_MINOR_VERSION, ) _log.info( - "Migrated WashData entry from version %s.%s to 3.8", version, minor_version + "Migrated WashData entry from version %s.%s to %s.%s", + version, minor_version, CONFIG_ENTRY_VERSION, CONFIG_ENTRY_MINOR_VERSION, ) return True @@ -355,6 +452,29 @@ async def _migrate_online_to_global(hass: HomeAssistant, entry: ConfigEntry, man pass +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 + 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 + means ML stays inert, so it must never block setup. + """ + + def _preload() -> None: + # pylint: disable=import-outside-toplevel + from .ml.engine import preload_models + + preload_models() + + try: + await hass.async_add_import_executor_job(_preload) + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.debug("ML module preload failed", exc_info=True) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WashData from a config entry.""" _log = DeviceLoggerAdapter(_LOGGER, entry.title) @@ -367,6 +487,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) + # Warm the ML module cache before anything can score in the event loop. + await _async_preload_ml_modules(hass) + # Migration: Remove old auto_maintenance switch entity (now in settings) # pylint: disable=import-outside-toplevel from homeassistant.helpers import entity_registry as er @@ -894,8 +1017,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if key in entry_data: new_data[key] = entry_data[key] - # Update all options from import + # 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, @@ -1094,9 +1223,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await asyncio.gather(*_cancelled_tasks, return_exceptions=True) # Release the per-entry write lock so it doesn't block the next setup. - from .ws_api import _WS_WRITE_LOCKS_KEY + from .ws_api import _WS_WRITE_LOCKS_KEY, async_clear_history_import hass.data.get(_WS_WRITE_LOCKS_KEY, {}).pop(entry.entry_id, None) + # Drop any staged history import (uploaded CSV text or a finished scan's + # traces). Nothing else owns that memory, so without this an abandoned upload + # would live for the lifetime of the process. + async_clear_history_import(hass, entry.entry_id) + # When the last WashData entry is removed, tear down the shared panel/sidebar # so no stale registration flags or sidebar entry linger. if not hass.data.get(DOMAIN): diff --git a/custom_components/ha_washdata/analysis.py b/custom_components/ha_washdata/analysis.py index 5f5540b7..7044eeaa 100644 --- a/custom_components/ha_washdata/analysis.py +++ b/custom_components/ha_washdata/analysis.py @@ -41,6 +41,11 @@ from .const import ( 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, ) @@ -322,6 +327,51 @@ def _dtw_component_score( return scale / (scale + scaled) +def _stage3_dtw_score( + curr_arr: np.ndarray, + sample_arr: np.ndarray, + current_peak: float, + *, + dtw_mode: str, + dtw_bandwidth: float, + l1_scale: float, + 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. + + 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. + """ + if dtw_mode == "legacy": + # Original behaviour: raw sequences, distance / len(current), + # fixed absolute-watt scale (not peak-relative). + 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 + 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 + # "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 + # MATCH_MAE_REF_PEAK), mirroring the Stage-2 MAE treatment. + use_deriv = dtw_mode == "ddtw" + 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 compute_matches_worker( current_power: list[float], current_duration: float, @@ -368,6 +418,10 @@ def compute_matches_worker( "profile_duration": profile_duration, "current": current_power, "sample": sample_power, + # 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. + "sample_span_s": float(item.get("sample_span_s") or profile_duration or 0.0), "offset": offset }) @@ -391,31 +445,17 @@ def compute_matches_worker( for cand in to_refine: sample_arr = np.array(cand["sample"]) - if dtw_mode == "legacy": - # Original behaviour: raw sequences, distance / len(current), - # fixed absolute-watt scale (not peak-relative). - 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 - dtw_score = 1.0 / (1.0 + norm_dist / MATCH_DTW_DIST_SCALE) - elif 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) - dtw_score = ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd - norm_dist = 0.0 # composite; per-component distance not meaningful - else: - # "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 - # MATCH_MAE_REF_PEAK), mirroring the Stage-2 MAE treatment. - use_deriv = dtw_mode == "ddtw" - scale = ddtw_scale if use_deriv else l1_scale - dtw_score = _dtw_component_score( - curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale, curr_resampled=curr_resampled - ) - norm_dist = 0.0 + dtw_score, norm_dist = _stage3_dtw_score( + curr_arr, + sample_arr, + current_peak, + dtw_mode=dtw_mode, + dtw_bandwidth=dtw_bandwidth, + l1_scale=l1_scale, + ddtw_scale=ddtw_scale, + ensemble_w=ensemble_w, + curr_resampled=curr_resampled, + ) cand["original_score"] = float(cand["score"]) cand["score"] = float(blend * cand["score"] + (1.0 - blend) * dtw_score) @@ -461,8 +501,134 @@ def compute_matches_worker( ) candidates.sort(key=lambda x: x["score"], 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) + return candidates +def _prefix_point_count( + n_points: int, current_duration: float, sample_span_s: float +) -> int: + """Leading template samples that cover ``current_duration`` seconds. + + 0 when the span is unknown/non-positive, when the elapsed time already covers + 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). + """ + if n_points < SMART_TERM_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: + return 0 + return k + + +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. + """ + arr = np.asarray(sample, dtype=float) + k = _prefix_point_count(arr.size, current_duration, sample_span_s) + if k == 0: + return None + prefix = arr[:k] + # Put both series on one grid so index offset equals time offset regardless of + # the template's native cadence, and honour the #388 OOM cap. + grid = int(min(curr_arr.size, k, MAX_ALIGN_GRID_POINTS)) + if grid < SMART_TERM_PREFIX_MIN_POINTS: + return None + a = _resample_to(curr_arr, grid) + b = _resample_to(prefix, grid) + + corr_weight = float(config.get("corr_weight", MATCH_CORR_WEIGHT)) + score, _metrics, _offset = find_best_alignment(a, b, 1.0, corr_weight=corr_weight) + + dtw_bandwidth = float(config.get("dtw_bandwidth", 0.1)) + 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: @@ -537,6 +703,20 @@ 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 + # the caller falls back to linear interpolation (graceful degrade rather + # than OOM-killing Home Assistant — issue #388). + _DTW_CELL_BUDGET = 10_000_000 # 10 M cells x 8 B ≈ 80 MB + if (n + 1) * (m + 1) > _DTW_CELL_BUDGET: + _LOGGER.warning( + "DTW cost matrix %dx%d would need %.0f MB — skipping DTW refinement " + "(cap compute_envelope_worker inputs via MAX_ALIGN_GRID_POINTS to prevent this)", + n, m, (n + 1) * (m + 1) * 8 / 1e6, + ) + return [] + w = max(1, int(min(n, m) * band_width_ratio)) try: cost_matrix = _dtw_cost_matrix_vectorized(x, y, n, m, w) @@ -701,6 +881,9 @@ def compute_envelope_worker( align_dt = avg_sample_rate num_points = max(50, int(target_duration / align_dt)) + if num_points > MAX_ALIGN_GRID_POINTS: + num_points = MAX_ALIGN_GRID_POINTS + align_dt = target_duration / num_points # re-derive so per-cycle grids inherit the cap time_grid = np.linspace(0.0, target_duration, num_points) # Robust reference curve: the pointwise MEDIAN across all cycles resampled @@ -733,7 +916,11 @@ def compute_envelope_worker( for offsets, values, dur in normalized_curves: this_dur = dur - this_num_points = max(10, int(this_dur / align_dt)) + # Cap this grid too, not just the reference one: a cycle far longer than the + # median would otherwise size its own grid past the cap and push the cost + # matrix over compute_dtw_path's budget, which silently drops the outlier + # back to plain interpolation. Capping keeps DTW alignment available for it. + this_num_points = min(MAX_ALIGN_GRID_POINTS, max(10, int(this_dur / align_dt))) this_grid = np.linspace(0.0, this_dur, this_num_points) this_array = np.interp(this_grid, offsets, values) diff --git a/custom_components/ha_washdata/config_flow.py b/custom_components/ha_washdata/config_flow.py index 6caf9b03..70ffe024 100644 --- a/custom_components/ha_washdata/config_flow.py +++ b/custom_components/ha_washdata/config_flow.py @@ -29,6 +29,8 @@ from homeassistant.helpers import selector from .const import ( DOMAIN, + CONFIG_ENTRY_MINOR_VERSION, + CONFIG_ENTRY_VERSION, CONF_POWER_SENSOR, CONF_MIN_POWER, CONF_DEVICE_TYPE, @@ -146,8 +148,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema( class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # pylint: disable=abstract-method """Handle a config flow for WashData.""" - VERSION = 3 - MINOR_VERSION = 8 + # Single source in const.py; the migration blocks in __init__.py read the same two. + VERSION = CONFIG_ENTRY_VERSION + MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION def __init__(self) -> None: """Initialize the config flow.""" diff --git a/custom_components/ha_washdata/const.py b/custom_components/ha_washdata/const.py index 08fc84a0..0e8ec007 100644 --- a/custom_components/ha_washdata/const.py +++ b/custom_components/ha_washdata/const.py @@ -137,6 +137,7 @@ CONF_ANTI_WRINKLE_MAX_DURATION = "anti_wrinkle_max_duration" # Seconds to treat CONF_ANTI_WRINKLE_EXIT_POWER = "anti_wrinkle_exit_power" # W threshold for true-off exit 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) CONF_DELAY_START_DETECT_ENABLED = "delay_start_detect_enabled" # Enable delayed-start detection CONF_DELAY_CONFIRM_SECONDS = "delay_confirm_seconds" # Seconds power must stay in standby band before DELAY_WAIT engages CONF_DELAY_TIMEOUT_HOURS = "delay_timeout_hours" # Safety timeout (hours) while waiting to start @@ -297,7 +298,8 @@ DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO = 1.5 DEFAULT_MAX_PAST_CYCLES = 200 DEFAULT_MAX_FULL_TRACES_PER_PROFILE = 20 DEFAULT_MAX_FULL_TRACES_UNLABELED = 20 -DEFAULT_WATCHDOG_INTERVAL = 30 # Derived: 2 * sampling_interval + 1 +DEFAULT_WATCHDOG_INTERVAL = 30 # Floor; effective default is resolved per device +# 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) @@ -487,6 +489,13 @@ 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 MATCH_DTW_ENSEMBLE_W = 0.7 # weight on L1 vs DDTW in "ensemble" mode +# Envelope alignment grid cap: maximum number of time-grid points used by +# compute_envelope_worker. The DTW cost matrix is (n+1)x(m+1)x8 B float64; +# both n and m derive from this cap, so memory is bounded to roughly +# MAX_ALIGN_GRID_POINTS² x 8 B ≈ 32 MB at 2000 — regardless of cycle duration +# or recording density. Without this cap a 4 h cycle at 1 Hz asks for 1.81 GB +# in a single np.full, which OOM-kills Home Assistant (issue #388). +MAX_ALIGN_GRID_POINTS = 2000 # Ambiguity: top1-top2 score gap below this flags the match as ambiguous. MATCH_AMBIGUITY_MARGIN = 0.05 # Smart Termination landscape guard: when a non-winning candidate is at least this @@ -499,6 +508,70 @@ MATCH_AMBIGUITY_MARGIN = 0.05 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 +# Issue #364: the landscape guard above has three structural false negatives, all +# reproduced by field reports on a multi-programme Miele washer: +# (1) it needs a longer profile to EXIST in the candidate pool, so an untrained +# longer programme is uncatchable; +# (2) it qualifies the longer candidate on its shape score against its FULL +# envelope, but a trace that is only part-way through a longer programme +# scores poorly against that programme's whole curve; +# (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 (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 + +# (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 +# neither checks whether the appliance is still WORKING, so a mis-matched shorter +# profile finalises a cycle mid-wash. Compare the trailing mean power against what +# the matched profile itself draws at its own end: if we are drawing several times +# that, this is not the end of anything. +# +# The two windows must cover the same FRACTION of the run, or the comparison is not +# like-for-like. A fixed 300 s trailing window is 4% of a cotton wash but a third +# of a 15-minute "Spin & Drain", whose trailing mean is then the spin itself while +# its profile tail is the quiet moment after the pump stops - ratios of 45-310x on +# perfectly normal cycle ends. So the trailing window is +# `expected_duration * SMART_TERM_TAIL_WINDOW_FRAC`, clamped; that alone is strictly +# better at every threshold (e.g. at 4.0x: false blocks 5% -> 3%). +# +# Swept with devtools/prefix_guard_eval.py over the whole cycle_data corpus (19 +# devices, 225 labelled cycles, leave-one-out): 114 folds where a shorter profile +# is winning mid-cycle (the #364 split condition) vs 165 genuine cycle ends. +# ratio caught false-blocked +# 3.0 27% 8% +# 3.5 25% 4% <- shipped, the knee +# 4.0 20% 3% +# 3.0 -> 3.5 halves the false blocks for 2pp of catch, and the reported cases sit +# 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. +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) +SMART_TERM_TAIL_MIN_POINTS = 3 # too few samples -> no opinion, do not block +SMART_TERM_TAIL_WINDOW_FRAC = 0.05 # both windows = last 5% of the run + # Number of points in the compact reference-profile curve exposed on the # `_program` sensor (`profile_store.reference_curve`). Chosen so the resulting # `[[offset_s, watts], ...]` attribute stays comfortably under ~1 KB regardless @@ -773,6 +846,20 @@ STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS = 300.0 # a washer's longest legitimate mid-cycle soak trough while capping the pathology. WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS = 600.0 +# Fraction of the matched profile's expected (mean) duration that Smart Termination +# requires before it may fire (#393). self._expected_duration is the profile's +# outlier-filtered ARITHMETIC MEAN, so a fixed 0.98 gate against a mean is +# structurally unreachable for appliances whose runtime depends on load, fill level +# or inlet temperature - about half of those cycles are shorter than their own mean +# by construction and can never take the fast path. Exposed as the per-device +# CONF_SMART_TERMINATION_DURATION_RATIO option (range 0.50-1.00; empty = default). +# The default is device-type-resolved: dishwashers keep the conservative 0.99 +# (their programs are fixed, so the spread is small) while everything else keeps +# 0.98. The dishwasher pump-out relief (0.90 once the terminal pump-out spike is +# confirmed) is combined with the configured value via min(), so the option can +# only ever LOOSEN the gate, never tighten it. +DEFAULT_SMART_TERMINATION_DURATION_RATIO = 0.98 + DEFAULT_OFF_DELAY_BY_DEVICE = { DEVICE_TYPE_DISHWASHER: 1800, # 30 min (Drying) DEVICE_TYPE_BREAD_MAKER: 300, # 5 min (Keep-warm phase after baking) @@ -842,11 +929,71 @@ DEFAULT_SAMPLING_INTERVAL_BY_DEVICE = { DEVICE_TYPE_PUMP: 10.0, # 10s - pump cycles can be <30 s; 30s default would miss them } + +def resolve_sampling_interval_default(device_type: str) -> float: + """Device-resolved default sampling interval (#396). + + Single source of truth for the sampling default, so the manager, the panel + (via ws_get_options) and the config migration all agree. Wet appliances + sample fast (2 s) to capture the rapid 0<->150 W oscillation; everything else + keeps the coarse 30 s scalar. + """ + return DEFAULT_SAMPLING_INTERVAL_BY_DEVICE.get(device_type, DEFAULT_SAMPLING_INTERVAL) + + +def resolve_watchdog_interval_default(device_type: str) -> int: + """Device-resolved watchdog tick default (#396). + + The panel enforces watchdog_interval >= 2*sampling_interval (a publish-on-change + sensor can skip a sample, so the staleness tick must be coarser than the + sampling gap). Derived as max(DEFAULT_WATCHDOG_INTERVAL, 2*sampling+1): 30 for + the fast/pump types (30 already clears 2*2 / 2*10), 61 for the 30 s-sampling + types. Never smaller than the 30 s floor so a fast-sampling device does not get + an over-aggressive watchdog. + """ + sampling = resolve_sampling_interval_default(device_type) + return int(max(DEFAULT_WATCHDOG_INTERVAL, 2.0 * sampling + 1.0)) + + +def resolve_start_duration_default(device_type: str) -> float: + """Device-resolved start-debounce default (#396). + + The panel enforces start_duration_threshold >= sampling_interval (a debounce + shorter than one sample lets a single spike open a cycle). Derived as + max(DEFAULT_START_DURATION_THRESHOLD, sampling): 5 s for the fast types, the + sampling interval for the coarser ones. + """ + sampling = resolve_sampling_interval_default(device_type) + 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 +# DEFAULT_SMART_TERMINATION_DURATION_RATIO (0.98). Resolved in the config builder, +# never in the gate, so playground.effective_settings() always sees a real float. +DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE = { + DEVICE_TYPE_DISHWASHER: 0.99, +} + + +def resolve_smart_termination_duration_ratio_default(device_type: str) -> float: + """Device-resolved Smart-Termination duration ratio default (#393). + + Single source of truth shared by the manager (config build/reload), the + Playground fallback config and the panel (via ws_get_options), so the value + the panel pre-populates always matches the one the detector actually uses: + 0.99 for dishwashers (fixed programs), 0.98 for everything else. + """ + return DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE.get( + 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 @@ -872,9 +1019,24 @@ GROUP_MIN_COHESION = 0.80 # 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. -STORAGE_VERSION = 11 +# 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 = 12 STORAGE_KEY = "ha_washdata" +# ─── 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 +# migration writes all read from here. They must move together - a bump that misses one +# leaves an entry a version short, which then re-migrates on every start - and repeating +# the literals in three places is what made that easy to do. +CONFIG_ENTRY_VERSION = 3 +CONFIG_ENTRY_MINOR_VERSION = 10 + # Notification events EVENT_CYCLE_STARTED = "ha_washdata_cycle_started" EVENT_CYCLE_ENDED = "ha_washdata_cycle_ended" @@ -1066,3 +1228,80 @@ PLAYGROUND_STRESS_MAX_IDLE_W: float = 100000.0 # upper bound for a manual # value to entry.options is always an explicit, per-setting user action. PLAYGROUND_PRESET_MAX: int = 30 # per-device cap (keeps the store small) PLAYGROUND_PRESET_NAME_MAX: int = 60 # preset name length cap + +# ─── Which cycle categories count as evidence for a profile ──────────────────── +# A profile's envelope (its average curve + duration/energy spread) and the matching +# template are built from stored cycles. By default all three categories count. Untick a +# category and it stops shaping profiles - useful when you do not trust imported data - +# without deleting anything: the cycles remain stored, listed and deletable. +# +# This gates *evidence* only (`ProfileStore.iter_evidence_cycles`), never +# `iter_stored_cycles`/`find_stored_cycle`. Profile garbage collection and sample repair +# delete or re-point a profile whose sample cycle resolves to nothing, so they must keep +# seeing every stored cycle: a cycle excluded from evidence is still a stored cycle, and +# gating those lookups would destroy a backfill-only profile the moment someone unticked +# imported history. +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). +PROFILE_EVIDENCE_SOURCES = ( + EVIDENCE_REAL_CYCLES, + EVIDENCE_REFERENCE_CYCLES, + EVIDENCE_BACKFILL_CYCLES, +) +# All three: the pre-setting behaviour, so an upgrade changes nothing. +DEFAULT_PROFILE_EVIDENCE_SOURCES = list(PROFILE_EVIDENCE_SOURCES) + +# ─── Historical power-data import (issue #344) ───────────────────────────────── +# An HA history export (or a recorder read) is a *change-based* stream: a steady +# 0 W emits no rows at all, so it cannot be fed to the detector as-is (doing so +# produces multi-day `force_stopped` blobs). `history_import.py` pre-segments the +# stream into activity blocks first; these constants govern that pre-pass. +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 +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 + # max(this, 4 x sampling_interval) so a plug + # that legitimately reports every 60 s is not + # rejected +HISTORY_IMPORT_EDGE_GAP_S: float = 60.0 # leading samples this far from the block body + # 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_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 +HISTORY_IMPORT_DENSIFY_STEP_S: float = 30.0 # cadence of the synthetic samples inserted into + # a carried-forward *quiet* gap, so the + # detector's gap-free quiet tally can accrue + # exactly as it does live +HISTORY_IMPORT_TAIL_STEP_S: float = 30.0 # synthetic quiet-tail cadence used to close the + # last cycle of a block +HISTORY_IMPORT_MAX_SEGMENTS: int = 60 # candidates surfaced by one scan +HISTORY_IMPORT_MAX_TOTAL_CYCLES: int = 200 # total backfilled cycles kept per device + # (`backfill_cycles` has no retention pass and + # the whole store blob is rewritten on every + # throttled active-cycle save) +HISTORY_IMPORT_RECORDER_MAX_DAYS: int = 3700 # ~10 years. HA's default `purge_keep_days` + # is 10, but a recorder configured to keep + # full-resolution states for years is a real + # setup and must not be capped out of reach. + # Reaching past what the recorder holds simply + # returns fewer rows; the real guard is + # HISTORY_IMPORT_MAX_ROWS, which stops the + # day-by-day read as soon as enough accrues. +HISTORY_IMPORT_RECORDER_EMPTY_DAY_STOP: int = 30 # consecutive empty days that end the walk. + # Within the retention window a day always + # yields at least the carried start-time state, + # so a run of truly empty days means the + # recorder has been purged past this point - + # without this, a 10-year request would issue + # thousands of pointless queries. +HISTORY_IMPORT_SOURCE: str = "history_import" # `meta.source` marker on imported cycles diff --git a/custom_components/ha_washdata/cycle_detector.py b/custom_components/ha_washdata/cycle_detector.py index b8b3f033..6db05789 100644 --- a/custom_components/ha_washdata/cycle_detector.py +++ b/custom_components/ha_washdata/cycle_detector.py @@ -48,10 +48,16 @@ from .const import ( DEVICE_TYPE_WASHER_DRYER, DEFAULT_MAX_DEFERRAL_SECONDS, DEFAULT_DEFER_FINISH_CONFIDENCE, + DEFAULT_SMART_TERMINATION_DURATION_RATIO, DISHWASHER_END_SPIKE_MIN_PROGRESS, DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS, DISHWASHER_END_SPIKE_WAIT_SECONDS, DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS, + SMART_TERM_TAIL_MAX_RATIO, + SMART_TERM_TAIL_MIN_POINTS, + SMART_TERM_TAIL_WINDOW_FRAC, + SMART_TERM_TAIL_WINDOW_MIN_S, + SMART_TERM_TAIL_WINDOW_S, WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS, STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS, DISHWASHER_MATCH_FREEZE_QUIET_SECONDS, @@ -129,6 +135,13 @@ class CycleDetectorConfig: start_threshold_w: float = 2.0 stop_threshold_w: float = 2.0 min_duration_ratio: float = 0.8 # Default deferred finish ratio + # Minimum live-match confidence for a match to be trusted by Smart Termination + # and the anti-crease gate. Fed from the `profile_match_threshold` option, which + # up to 0.5.5 was stored and never read - so raising it (the workaround the #288 + # reporter documented) silently did nothing. Default matches the value that was + # hard-coded at those two sites, so behaviour is unchanged unless the user has + # deliberately tuned the option. + match_confidence_threshold: float = 0.4 # Power-based Off detection (issue #284). Carried on the config so the manager # (the single owner of the terminal -> Off transition) can read them live; the # detector itself does not act on them. 0 = disabled. @@ -146,6 +159,13 @@ class CycleDetectorConfig: # the shipped constant; per-device configurable so a machine with a long silent # passive-drying phase before its final drain can absorb profile drift. dishwasher_end_spike_quiet_release: float = DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS + # Fraction of the matched profile's expected (mean) duration Smart Termination + # requires before it may fire (#393). Device-type-resolved in the manager's + # config builder (0.99 dishwasher / 0.98 other), so this field always carries a + # real float - never None - which is what playground.effective_settings() relies + # on. The dishwasher pump-out relief is combined via min(), so a configured value + # can only loosen the gate. + smart_termination_duration_ratio: float = DEFAULT_SMART_TERMINATION_DURATION_RATIO delay_detect_enabled: bool = False # Sustained seconds power must stay in the standby band (between # stop_threshold_w and start_threshold_w) before DELAY_WAIT engages. @@ -311,6 +331,14 @@ class CycleDetector: self._end_spike_duration: float = 0.0 # cycle duration (s) when _end_spike_seen was last set self._match_ambiguous: bool = False # last live match was ambiguous (gates predictive end) self._match_prefix_ambiguous: bool = False # longer candidate with good shape exists (prefix guard) + # The narrower #288-only half of the flag above. #364 widened + # _match_prefix_ambiguous with prefix scoring, which is safe for the ENDING + # Smart-Termination gate but must NOT reach the anti-crease finalize (see + # _anticrease_gate_open) where blocking can re-hang a cycle (#296). + self._match_prefix_ambiguous_full_shape: bool = False + # Mean power the matched profile draws over the last few % of its own run + # (profile_store.profile_tail_power). None = no opinion, guard stays inert. + self._matched_tail_power: float | None = None self._last_smart_term_block_reason: str | None = None # #346 diagnostic throttle # Anti-wrinkle tracking (dryers only) @@ -494,6 +522,110 @@ class CycleDetector: return self._SANITIZE_INVALID_SENTINEL return value + @staticmethod + def _sanitize_tail_power(raw: Any) -> float | None: + """Coerce ``raw`` into a finite, positive float, else None (#364). + + None means "no opinion": ``_smart_term_power_plausible`` then leaves both + Smart-Termination paths exactly as they behaved before the guard existed. + """ + if raw is None: + return None + try: + value = float(raw) + except (TypeError, ValueError): + return None + if not math.isfinite(value) or value <= 0: + return None + return value + + def _trailing_mean_power(self, timestamp: datetime, window_s: float) -> float | None: + """Time-weighted mean power over the trailing ``window_s``, or None when + there are too few samples to judge. + + Time-weighted (not a plain sample mean) so an irregular reporting cadence - + a plug that only pushes on change, so quiet stretches are sparse - cannot + bias the result toward whichever regime happened to sample more often. + """ + window: list[tuple[datetime, float]] = [] + for ts, power in reversed(self._power_readings): + if (timestamp - ts).total_seconds() > window_s: + break + window.append((ts, float(power))) + window.reverse() + # Explicit gap handling (energy-integration rule): a reading held across an + # unobserved outage would dominate the trapezoid - e.g. a stale 2000 W sample + # 290 s before a 284 s dropout, then 5 W, integrates to ~1000 W though every + # observed recent reading is 5 W, wrongly blocking termination. So drop + # everything up to and including the most recent outage-sized gap and judge + # only the clean contiguous tail, the same ceiling the standby / anti-crease + # window scans reject a holed window with. + if len(window) >= 2: + # O(1) ceiling from the maintained p95 cadence (mirrors energy_gap_threshold_s + # = clip(10x cadence, 60, 3600)), NOT _outage_threshold_s() which rebuilds a + # NumPy array from every reading - this runs on the per-reading ENDING / + # anti-crease path, same reasoning as the gap-free tally at L1006. + max_gap = min(3600.0, max(60.0, 10.0 * self._p95_dt)) + cut = 0 + for i in range(1, len(window)): + if (window[i][0] - window[i - 1][0]).total_seconds() > max_gap: + cut = i + window = window[cut:] + if len(window) < SMART_TERM_TAIL_MIN_POINTS: + return None + span = (window[-1][0] - window[0][0]).total_seconds() + if span <= 0: + return None + energy = 0.0 + for (t0, p0), (t1, p1) in zip(window, window[1:]): + energy += (p0 + p1) / 2.0 * (t1 - t0).total_seconds() + return energy / span + + def _smart_term_power_plausible(self, timestamp: datetime) -> bool: + """Whether the appliance looks like it is actually FINISHING (#364). + + Both Smart-Termination paths fire at ``elapsed >= 0.98 * expected`` and + neither asks whether the machine is still working. When the matcher has + locked onto a shorter look-alike profile that anchor lands mid-wash, the + cycle is cut in half and the remainder is recorded as a second cycle. + + The test: compare the trailing mean power against what the matched profile + itself draws at its own end. Drawing several times that level is proof we + are not at the end of anything - whatever the clock says. Unlike the + prefix-landscape guard this needs no longer profile to exist in the pool, + so it also covers the reported case where the programme actually running + was never trained. + + Shorten-only and fail-open: any missing input returns True, leaving + behaviour identical to before the guard. A False can only ever *block* an + early finish - the power-based fallback timeout still ends the cycle. + """ + tail_power = self._matched_tail_power + if tail_power is None or tail_power <= 0: + return True + mean_power = self._trailing_mean_power(timestamp, self._tail_window_s()) + if mean_power is None: + return True + return mean_power <= tail_power * SMART_TERM_TAIL_MAX_RATIO + + def _tail_window_s(self) -> float: + """Trailing window that covers the same FRACTION of the run as the profile + tail it is compared against. + + A fixed window is not comparable across programme lengths: 300 s is 4% of a + cotton wash but a third of a 15-minute spin-and-drain, whose trailing mean + would then be the spin itself while its profile tail is the quiet moment + after the pump stops. Measured on the full corpus, making this proportional + is strictly better at every threshold. + """ + expected = self._expected_duration + if expected <= 0: + return SMART_TERM_TAIL_WINDOW_S + return min( + SMART_TERM_TAIL_WINDOW_S, + max(SMART_TERM_TAIL_WINDOW_MIN_S, expected * SMART_TERM_TAIL_WINDOW_FRAC), + ) + def update_match(self, result: tuple[Any, ...] | list[Any] | Any) -> None: # type: ignore[misc] """Process a match result (synchronously). @@ -586,6 +718,20 @@ class CycleDetector: self._last_match_confidence = confidence or 0.0 self._match_ambiguous = ambiguous self._match_prefix_ambiguous = bool(result_seq[6]) if len(result_seq) >= 7 else False + # Element 8 (#364): the narrower legacy verdict. A shorter tuple + # (Playground, older callers, most tests) falls back to the widened + # value, which reproduces pre-#364 behaviour exactly. + self._match_prefix_ambiguous_full_shape = ( + bool(result_seq[7]) if len(result_seq) >= 8 else self._match_prefix_ambiguous + ) + # Element 9 (#364): the matched profile's own tail power level. Absent + # or non-finite leaves the power-plausibility guard inert - so a shorter + # tuple (Playground, older callers, most tests) must CLEAR it, not keep + # the previous match's value: retaining it would let the guard compare + # the live tail against the wrong profile and block a valid termination. + self._matched_tail_power = ( + self._sanitize_tail_power(result_seq[8]) if len(result_seq) >= 9 else None + ) else: # Assume MatchResult object or similar (future proofing) # But for now wrapper returns tuple @@ -596,6 +742,8 @@ class CycleDetector: self._matched_profile = None self._match_ambiguous = False self._match_prefix_ambiguous = False + self._match_prefix_ambiguous_full_shape = False + self._matched_tail_power = None elif match_name: # If sanitization rejected the expected_duration, treat the match @@ -647,6 +795,8 @@ class CycleDetector: self._last_match_confidence = 0.0 self._match_ambiguous = False self._match_prefix_ambiguous = False + self._match_prefix_ambiguous_full_shape = False + self._matched_tail_power = None # Per-cycle diagnostic throttle (#346): the "Smart Termination not applied" # line only logs when the reason CHANGES. Carrying the previous cycle's # reason across a reset swallows the new cycle's very first diagnostic @@ -713,14 +863,15 @@ class CycleDetector: is_confident: bool, ambiguous: bool, prefix_ambiguous: bool, + power_plausible: bool = True, ) -> str | None: """Why the Smart-Termination fast end-path did NOT fire, for diagnostics. Returns None when the gate would pass, or when no expected duration is known - yet (nothing meaningful to report). Mirrors the gate's four conditions in - order so the first blocking reason is surfaced. Pure and side-effect-free; - the detector logs the result (throttled to reason changes) - no behaviour - change (#346). + yet (nothing meaningful to report). Mirrors the gate's conditions in order so + the first blocking reason is surfaced. Pure and side-effect-free; the + detector logs the result (throttled to reason changes) - no behaviour + change (#346, extended with "still_active" for #364). """ if expected <= 0: return None @@ -732,8 +883,48 @@ class CycleDetector: return "match_ambiguous" if prefix_ambiguous: return "prefix_ambiguous" + if not power_plausible: + return "still_active" return None + @staticmethod + def _resolve_smart_ratio( + device_type: str, + configured_ratio: float, + end_spike_seen: bool, + end_spike_duration: float, + expected_duration: float, + ) -> float: + """Resolve the Smart-Termination duration-ratio gate (#393). + + ``configured_ratio`` is the per-device option, already resolved in the + config builder to the device-type default (0.99 dishwasher / 0.98 other) + unless the user tuned it - so it is always a real float here. + + For a dishwasher whose most-recent in-ENDING spike landed at >=90% of the + expected duration, that spike is the terminal pump-out (not a mid-cycle + rinse drain): once it is confirmed the gate is loosened to the 0.90 + pump-out relief, because individual cycles can be a few % shorter than the + rolling average and still terminate cleanly. Keeping the configured gate + for spikes at <90% prevents premature closes during the passive Dry phase + that follows the pre-final-rinse drain. The relief is combined with the + configured value via ``min()`` so a configured ratio can only ever LOOSEN + the gate, never tighten it. Pure and side-effect-free (unit-testable). + """ + # Clamp to the documented [0.50, 1.00] range (the WS write path clamps, but a + # value persisted by an import or an older schema is read here unclamped): a + # 0.0 would drop the duration floor entirely and let Smart Termination fire the + # moment its other conditions pass. + configured_ratio = min(1.0, max(0.5, configured_ratio)) + if ( + device_type == "dishwasher" + and end_spike_seen + and expected_duration > 0 + and end_spike_duration >= expected_duration * 0.90 + ): + return min(configured_ratio, 0.90) + return configured_ratio + def process_reading(self, power: float, timestamp: datetime) -> None: """Process a new power reading using robust dt-aware logic.""" @@ -1373,30 +1564,27 @@ class CycleDetector: # 1. Require higher duration ratio for Smart path # 2. Require debounce to be measured FROM entry into ENDING state - if self._config.device_type == "dishwasher": - # If the most-recent in-ENDING spike occurred at ≥90% of - # expected, it is the terminal pump-out, not a mid-cycle - # rinse drain. Once that pump-out is confirmed, we don't - # need to wait for 99% of the rolling avg — individual - # cycles can be up to ~7% shorter than avg_duration and - # still terminate cleanly. Keeping the 0.99 gate for - # spikes at <90% prevents premature closes during the - # passive Dry phase that follows the pre-final-rinse drain. - _esp_dur = getattr(self, "_end_spike_duration", 0.0) - if ( - getattr(self, "_end_spike_seen", False) - and self._expected_duration > 0 - and _esp_dur >= self._expected_duration * 0.90 - ): - smart_ratio = 0.90 # pump-out confirmed near end - else: - smart_ratio = 0.99 # conservative: wait for expected duration - else: - smart_ratio = 0.98 + # Per-appliance configurable gate (#393): the ratio is resolved + # in the config builder to the device-type default (0.99 + # dishwasher / 0.98 other) unless the user tuned it, and the + # dishwasher pump-out relief is folded in via a pure helper so + # the gate logic stays unit-testable (see _resolve_smart_ratio). + smart_ratio = self._resolve_smart_ratio( + self._config.device_type, + self._config.smart_termination_duration_ratio, + getattr(self, "_end_spike_seen", False), + getattr(self, "_end_spike_duration", 0.0), + self._expected_duration, + ) is_confident_match = ( - getattr(self, "_last_match_confidence", 0.0) >= 0.4 + getattr(self, "_last_match_confidence", 0.0) + >= self._config.match_confidence_threshold ) + # Compute the #364 power-plausibility once per reading and reuse it + # for the diagnostic reason and the gate below - the helper walks the + # trailing window, so calling it two/three times per reading is waste. + _power_plausible = self._smart_term_power_plausible(timestamp) # Gate the predictive end on match certainty. # _match_ambiguous: top-1 vs top-2 score gap is too small to @@ -1421,19 +1609,22 @@ class CycleDetector: is_confident_match, self._match_ambiguous, self._match_prefix_ambiguous, + _power_plausible, ) if _block_reason != self._last_smart_term_block_reason: self._last_smart_term_block_reason = _block_reason if _block_reason is not None: self._logger.debug( "Smart Termination not applied (%s): dur=%.0fs/%.0fs conf=%.2f " - "ambiguous=%s prefix_ambiguous=%s", + "ambiguous=%s prefix_ambiguous=%s trailing_power=%s profile_tail=%s", _block_reason, current_duration, self._expected_duration * smart_ratio, getattr(self, "_last_match_confidence", 0.0), self._match_ambiguous, self._match_prefix_ambiguous, + self._trailing_mean_power(timestamp, self._tail_window_s()), + self._matched_tail_power, ) if ( @@ -1441,6 +1632,11 @@ class CycleDetector: and is_confident_match and not self._match_ambiguous and not self._match_prefix_ambiguous + # #364: the clock says "done", but if we are still drawing + # several times what this profile draws at its own end, the + # match is a shorter look-alike and we are mid-wash. Block; + # the power-based fallback timeout decides instead. + and _power_plausible ): # Dynamic confirmation window if self._config.device_type == "dishwasher": @@ -1950,18 +2146,31 @@ class CycleDetector: return False if not (self._matched_profile and self._expected_duration > 0): return False - if self._last_match_confidence < 0.4: + if self._last_match_confidence < self._config.match_confidence_threshold: return False - if self._match_ambiguous or self._match_prefix_ambiguous: + # Deliberately the NARROW #288-only verdict, not the #364-widened flag: a + # false block here disables the finalise AND the match freeze, and because + # the tumble bursts recur faster than off_delay neither the fallback timeout + # nor ENDING_HARD_FINALIZE can close the cycle - that is the #296 hang. + if self._match_ambiguous or self._match_prefix_ambiguous_full_shape: return False if self._cycle_max_power <= float(self._config.anti_wrinkle_max_power): return False # never a hot/energetic cycle - leave low-power programs alone + # Cheap clock test first, so the trailing-power scan below is skipped for the + # whole mid-wash phase (it only matters once we are past-expected). start = self._current_cycle_start if start is None: return False current_duration = (timestamp - start).total_seconds() if current_duration < self._expected_duration * ANTI_CREASE_FINALIZE_RATIO: return False + # #364: "past expected" only means "past the wash" when expected belongs to + # the RIGHT profile. A whole washer wash phase sits below + # anti_wrinkle_max_power, so with a mis-matched shorter profile this gate + # would open mid-wash. Requiring the trailing power to look like this + # profile's own tail restores the guarantee the ratio alone used to give. + if not self._smart_term_power_plausible(timestamp): + return False return True def _in_anticrease_freeze(self, timestamp: datetime) -> bool: @@ -2441,6 +2650,8 @@ class CycleDetector: "end_spike_duration": self._end_spike_duration, "match_ambiguous": self._match_ambiguous, "match_prefix_ambiguous": self._match_prefix_ambiguous, + "match_prefix_ambiguous_full_shape": self._match_prefix_ambiguous_full_shape, + "matched_tail_power": self._matched_tail_power, "ml_defer_start_duration": self._ml_defer_start_duration, } @@ -2503,6 +2714,14 @@ class CycleDetector: self._end_spike_duration = float(snapshot.get("end_spike_duration", 0.0)) self._match_ambiguous = snapshot.get("match_ambiguous", False) self._match_prefix_ambiguous = snapshot.get("match_prefix_ambiguous", False) + # A pre-#364 snapshot has no narrow flag: fall back to the widened + # value so a restart cannot loosen the anti-crease gate. + self._match_prefix_ambiguous_full_shape = snapshot.get( + "match_prefix_ambiguous_full_shape", self._match_prefix_ambiguous + ) + self._matched_tail_power = self._sanitize_tail_power( + snapshot.get("matched_tail_power") + ) self._ml_defer_start_duration = snapshot.get("ml_defer_start_duration") # Restore state enter time and recompute time_in_state from it diff --git a/custom_components/ha_washdata/diagnostics.py b/custom_components/ha_washdata/diagnostics.py index 62d18425..5cbc6ce6 100644 --- a/custom_components/ha_washdata/diagnostics.py +++ b/custom_components/ha_washdata/diagnostics.py @@ -82,8 +82,14 @@ async def async_get_config_entry_diagnostics( entry_options=dict(entry.options), ) + from .frontend import served_asset_report + return { "entry": _redact(entry.as_dict()), + # Which panel/card variant is actually being served. Both are served at the + # same URL by design, so without this a bug report cannot tell us whether the + # reporter was running the minified bundle or the readable fallback. + "frontend_assets": served_asset_report(), "manager_state": { "current_state": manager.check_state(), "current_program": manager.current_program, diff --git a/custom_components/ha_washdata/frontend.py b/custom_components/ha_washdata/frontend.py index d3cd429a..cb98d4d0 100644 --- a/custom_components/ha_washdata/frontend.py +++ b/custom_components/ha_washdata/frontend.py @@ -31,6 +31,18 @@ CARD_NAME = "ha-washdata-card.js" INTEGRATION_URL = f"/{LOCAL_SUBDIR}/{CARD_NAME}" CARD_REGISTERED = "registered" +# Minified build artifacts produced by devtools/build_panel.mjs, and the manifest +# recording which source each was built from. These are an optimisation only: +# every asset is ALWAYS served at its readable-source URL, and _resolve_asset() +# falls back to the readable source whenever the artifact is missing, stale, or +# hand-edited. So a forgotten rebuild degrades to a bigger download, never to +# wrong code -- which is why the URL must not encode which variant was chosen. +BUILD_MANIFEST_NAME = "build-manifest.json" + +# source name -> {"serving", "minified", "bytes"} for whatever was last registered. +# Module level (not per-entry): the frontend assets are registered once per HA start. +_SERVED_ASSETS: dict[str, dict] = {} + # Full-screen panel constants PANEL_JS_NAME = "ha-washdata-panel.js" PANEL_JS_URL = f"/{LOCAL_SUBDIR}/{PANEL_JS_NAME}" @@ -63,70 +75,235 @@ class LovelaceResourceItem(TypedDict, total=False): res_type: str -def get_cache_buster(filename: str = CARD_NAME) -> str: - """Generate a stable cache buster based on a www asset's mtime. +def _sha256_file(path: Path) -> str: + """SHA-256 of a file, streamed so a large asset never lands in memory twice.""" + import hashlib - Also considers the translations/panel/ directory mtime so that - translation-only releases (e.g. GitLocalize merges) still bust the - browser cache for both the panel JS and the per-language JSON files. + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 256), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _resolve_asset(source_name: str, www: Path | None = None) -> Path: + """Return the file to serve for ``source_name``: minified build, or source. + + The minified artifact is used only when the manifest proves it was built from + exactly the bytes currently on disk AND has not been modified since. Any doubt + -- no manifest, no artifact, changed source, tampered artifact, unreadable + anything -- resolves to the readable source. Blocking I/O; call in an executor. + + ``www`` overrides the asset directory (tests only); production always uses the + integration's own www/. """ + import json + + if www is None: + www = Path(__file__).parent / "www" + source = www / source_name + try: - base = Path(__file__).parent - src_mtime = os.path.getmtime(base / "www" / filename) + manifest = json.loads((www / BUILD_MANIFEST_NAME).read_text()) + entry = manifest["assets"][source_name] + artifact = www / entry["artifact"] + if not artifact.is_file(): + return source + if _sha256_file(source) != entry["source_sha256"]: + _LOGGER.debug( + "%s changed since the last panel build; serving readable source " + "(run devtools/build_panel.mjs to refresh the minified build)", + source_name, + ) + return source + if _sha256_file(artifact) != entry["artifact_sha256"]: + _LOGGER.warning( + "Minified asset %s does not match its build manifest; serving " + "readable source instead", + entry["artifact"], + ) + return source + return artifact + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "No usable minified build for %s (%s); serving readable source", + source_name, + exc, + ) + return source + + +def _ensure_gzip(path: Path) -> None: + """Keep a fresh ``.gz`` beside ``path`` so aiohttp can serve it. + + aiohttp's FileResponse transparently serves a pre-compressed sibling when the + client sends a matching Accept-Encoding, which cuts these assets by ~75% -- but + it only checks that the sibling EXISTS, never that it is current. A stale .gz + would therefore be served as if it were the real file (and cached for a month), + so the sibling is unconditionally rebuilt from the exact file being served. + + Rebuilding unconditionally rather than comparing mtimes is deliberate: the .gz + is not shipped, so it is written at install time, while an update can restore + an *older* source mtime from the release archive (the same mtime-preservation + ``get_cache_buster`` works around). "Newer .gz" therefore does not imply "current + .gz", and the cost of being sure is ~25 ms for the panel and ~1 ms for the card, + once per HA start, in an executor. Best-effort: a read-only install just serves + uncompressed. + + If the rebuild fails, any existing sibling is removed rather than left behind: + aiohttp would keep serving it, which is the stale-content case this function + exists to prevent. Losing compression is the safe half of that trade. + """ + import gzip + import shutil + import tempfile + + gz = path.with_suffix(path.suffix + ".gz") + try: + # Compress to a temp file in the same directory, then atomically replace, so + # a concurrent request can never observe a half-written .gz. + fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), suffix=".gz.tmp") + os.close(fd) + tmp = Path(tmp_name) + try: + with open(path, "rb") as src, gzip.open(str(tmp), "wb", compresslevel=9) as dst: + shutil.copyfileobj(src, dst) + os.replace(tmp, gz) + except BaseException: + tmp.unlink(missing_ok=True) + raise + _LOGGER.debug("Wrote compressed asset %s", gz.name) + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.debug("Could not pre-compress %s (%s); serving uncompressed", path, exc) + try: + gz.unlink(missing_ok=True) + except OSError: # read-only dir: nothing was ever written there either + pass + + +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. + + Also records the outcome so it can be reported without a browser: because both + variants are served at the SAME url, the only ways to tell from outside are the + response size and its hash, which is awkward to ask of a bug reporter. See + ``served_asset_report``. + """ + served = _resolve_asset(source_name, www) + _ensure_gzip(served) + # Stat once and reuse: a second, unguarded stat()/is_file() below could raise if + # the file vanished between the two calls and fail the whole registration, even + # though the asset was resolved and compressed fine. + try: + size = served.stat().st_size + except OSError: + size = None + if size is not None: + _SERVED_ASSETS[source_name] = { + "serving": served.name, + "minified": served.name != source_name, + "bytes": size, + } + _LOGGER.info( + "Serving %s as %s (%s, %.1f KB)", + source_name, + served.name, + "minified build" if served.name != source_name else "readable source", + (size / 1024) if size is not None else 0.0, + ) + return served + + +def served_asset_report() -> dict[str, dict]: + """Which variant of each frontend asset is actually being served. + + Populated by :func:`_prepare_asset` at registration time. Surfaced in + diagnostics so "is the minified panel live?" is answerable from a diagnostics + download instead of from browser devtools and a hash comparison. + """ + return {k: dict(v) for k, v in _SERVED_ASSETS.items()} + + +def get_cache_buster(filename: str = CARD_NAME) -> str: + """Generate a stable cache buster for a www asset. + + Folds in the manifest.json version string so that every release produces a + different URL even when the package manager (e.g. HACS) preserves original + file mtimes from the release archive. The mtime path is kept as a secondary + signal so translation-only GitLocalize merges still bust the cache. + + Timestamps are read in nanoseconds (``st_mtime_ns``): ``getmtime()`` returns + float seconds, and truncating that to whole seconds made two rebuilds inside + the same second - which is a normal development cycle - produce the same + token, leaving the browser on the immutably-cached previous artifact. + """ + import hashlib + import json + + base = Path(__file__).parent + try: + manifest_version = json.loads((base / "manifest.json").read_text())["version"] + except Exception: # pylint: disable=broad-exception-caught + manifest_version = "" + + try: + src_mtime = os.stat(base / "www" / filename).st_mtime_ns try: panel_dir = base / "translations" / "panel" trans_mtime = max( - (os.path.getmtime(f) for f in panel_dir.iterdir() if f.is_file()), - default=0.0, + (os.stat(f).st_mtime_ns for f in panel_dir.iterdir() if f.is_file()), + default=0, ) except OSError: - trans_mtime = 0.0 - return str(int(max(src_mtime, trans_mtime))) + trans_mtime = 0 + # A rebuild changes the minified artifact and the manifest but not the + # source, so fold both in: otherwise switching between the readable and + # minified variant would reuse a URL the browser has already cached. + try: + build_mtime = max( + os.stat(base / "www" / BUILD_MANIFEST_NAME).st_mtime_ns, + os.stat(_resolve_asset(filename)).st_mtime_ns, + ) + except OSError: + build_mtime = 0 + mtime_part = str(max(src_mtime, trans_mtime, build_mtime)) except OSError: - # Deterministic fallback when file is unavailable. - return "1" + mtime_part = "1" + + raw = f"{manifest_version}:{mtime_part}" + # Not a security primitive - just a short, stable URL token - so tell Ruff (S324). + return hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()[:10] -def _register_static_path(hass: HomeAssistant, url_path: str, path: str) -> None: - """Register a static path with the HA HTTP component, compatible with multiple HA versions.""" - try: - # pylint: disable=import-outside-toplevel - from homeassistant.components.http import StaticPathConfig +def _register_static_path(hass: HomeAssistant, url_path: str, path: str) -> bool: + """Register a static path through the legacy sync HA HTTP helper. - if hasattr(hass.http, "async_register_static_paths"): + Only reached from :func:`_async_register_path` when the modern + ``async_register_static_paths`` API is unavailable, which no supported Home + Assistant hits (hacs.json floors the requirement at 2026.5.0, and the sync + helper was removed upstream well before that). - async def _safe_register(): - try: - await hass.http.async_register_static_paths( - [StaticPathConfig(url_path, path, True)] - ) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Failed to async register static path %s -> %s: %s", - url_path, - path, - exc, - ) - - hass.async_create_task(_safe_register()) - return - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Async static path registration not available; falling back to " - "sync registration for %s -> %s (%s)", - url_path, - path, - exc, - ) - - # Fallback for older HA + Returns True only when a registration actually happened. Reporting the + outcome is the point: a route that was never registered but is treated as + success leaves the Lovelace resource pointing at a permanently 404ing URL, + which is issue #384 in its silent form. + """ try: http_obj = cast(Any, hass.http) register_static_path = getattr(http_obj, "register_static_path", None) - if callable(register_static_path): - register_static_path(url_path, path, cache_headers=True) - except Exception: # pylint: disable=broad-exception-caught - _LOGGER.debug("Failed to register static path %s -> %s", url_path, path) + if not callable(register_static_path): + _LOGGER.debug( + "No usable static-path API for %s -> %s (neither " + "async_register_static_paths nor register_static_path)", + url_path, + path, + ) + return False + register_static_path(url_path, path, cache_headers=True) + return True + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.debug("Failed to register static path %s -> %s (%s)", url_path, path, exc) + return False async def _init_resource(hass: HomeAssistant, url: str, ver: str) -> bool: @@ -207,7 +384,28 @@ class WashDataCardRegistration: _LOGGER.warning("Card file not found: %s", src) return CARD_FAILED - _register_static_path(self.hass, INTEGRATION_URL, str(src)) + # Serve the minified build when it is provably current. The URL stays + # INTEGRATION_URL either way, so the Lovelace resource never has to be + # migrated and dashboards cannot end up pointing at a variant that moved. + src = await self.hass.async_add_executor_job(_prepare_asset, CARD_NAME) + + # The static route MUST exist before the Lovelace resource is published: + # the resource is what makes every browser fetch this URL, and a fetch + # that lands before the route is registered 404s, so the module never + # runs its customElements.define() and the dashboard reports + # "Custom element not found: ha-washdata-card". Awaiting here (rather + # than the old fire-and-forget task) both orders the two steps and + # surfaces a genuine registration failure instead of swallowing it. + try: + await _async_register_path(self.hass, INTEGRATION_URL, str(src)) + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.warning( + "Failed to register card static path %s -> %s: %s", + INTEGRATION_URL, + src, + exc, + ) + return CARD_FAILED version = await self.hass.async_add_executor_job(get_cache_buster) @@ -281,16 +479,20 @@ async def _async_register_path(hass: HomeAssistant, url_path: str, path: str) -> back to the legacy sync helper only when that API is absent — not on a genuine registration failure. An already-registered path is treated as success (benign on integration reload); any other exception propagates so - the caller can decide whether to report failure. + the caller can decide whether to report failure. A legacy fallback that + could not register either propagates as well: silently returning would + publish a Lovelace resource for a URL that 404s (issue #384). """ try: from homeassistant.components.http import StaticPathConfig # pylint: disable=import-outside-toplevel except ImportError: - _register_static_path(hass, url_path, path) + if not _register_static_path(hass, url_path, path): + raise return if not hasattr(hass.http, "async_register_static_paths"): - _register_static_path(hass, url_path, path) + if not _register_static_path(hass, url_path, path): + raise RuntimeError(f"no usable static-path API to serve {url_path}") return try: @@ -313,7 +515,10 @@ async def _do_register_panel(hass: HomeAssistant, src: Path) -> bool: # ── Phase 1: static paths ──────────────────────────────────────────────── try: # Panel JS (primary asset — must be available before the sidebar fires). - await _async_register_path(hass, PANEL_JS_URL, str(src)) + # Served at PANEL_JS_URL regardless of which variant won, so the sidebar's + # module_url never has to change and a stale build cannot strand the panel. + 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. trans_src = Path(__file__).parent / "translations" / PANEL_TRANSLATIONS_DIRNAME diff --git a/custom_components/ha_washdata/intents.py b/custom_components/ha_washdata/intents.py index 4169610a..a4bed59c 100644 --- a/custom_components/ha_washdata/intents.py +++ b/custom_components/ha_washdata/intents.py @@ -294,8 +294,9 @@ async def _localized_templates( English base first, then the language's base subtag, then the full tag (so ``pt-BR`` overrides ``pt`` overrides ``en``). File reads are offloaded to the - executor when the hass supports it, with a synchronous fallback (minimal test - hass). Falls back to :data:`DEFAULT_TEMPLATES` on any failure. + executor when the hass supports it, with a synchronous fallback for a hass that + has no executor (the minimal test stand-in). Falls back to + :data:`DEFAULT_TEMPLATES` on any failure. """ templates = dict(DEFAULT_TEMPLATES) lang = language or "en" @@ -305,8 +306,15 @@ async def _localized_templates( continue try: loaded = await hass.async_add_executor_job(_load_intent_file, lg) - except Exception: # noqa: BLE001 - minimal test hass has no executor + except (AttributeError, TypeError): + # 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 + # shutdown" - and then do the blocking open() on the event loop, + # which is exactly what the offload exists to prevent. loaded = _load_intent_file(lg) + except Exception: # noqa: BLE001 - a real executor failure keeps English + loaded = {} for key, value in (loaded or {}).items(): if isinstance(value, str) and value: templates[key] = value diff --git a/custom_components/ha_washdata/manager.py b/custom_components/ha_washdata/manager.py index 2bfc46c6..11e5c3a3 100644 --- a/custom_components/ha_washdata/manager.py +++ b/custom_components/ha_washdata/manager.py @@ -54,6 +54,7 @@ from homeassistant.helpers import translation from .const import ( DOMAIN, CONF_POWER_SENSOR, + CONF_PROFILE_EVIDENCE_SOURCES, CONF_MIN_POWER, CONF_OFF_DELAY, CONF_NOTIFY_SERVICE, @@ -113,6 +114,9 @@ from .const import ( CONF_ANTI_WRINKLE_IDLE_TIMEOUT, CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE, DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS, + CONF_SMART_TERMINATION_DURATION_RATIO, + DEFAULT_SMART_TERMINATION_DURATION_RATIO, + DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE, CONF_DELAY_START_DETECT_ENABLED, CONF_DELAY_CONFIRM_SECONDS, CONF_DELAY_TIMEOUT_HOURS, @@ -142,6 +146,7 @@ from .const import ( DEFAULT_SAMPLING_INTERVAL, DEFAULT_PROGRESS_RESET_DELAY, DEFAULT_POWER_OFF_THRESHOLD_W, + DEFAULT_PROFILE_EVIDENCE_SOURCES, DEFAULT_POWER_OFF_DELAY, DEFAULT_LEARNING_CONFIDENCE, DEFAULT_DURATION_TOLERANCE, @@ -223,6 +228,10 @@ from .const import ( DEFAULT_MAX_FULL_TRACES_UNLABELED, DEFAULT_DTW_BANDWIDTH, DEFAULT_WATCHDOG_INTERVAL, + resolve_sampling_interval_default, + resolve_watchdog_interval_default, + resolve_start_duration_default, + resolve_smart_termination_duration_ratio_default, CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE, DEFAULT_MATCH_REVERT_RATIO, @@ -593,6 +602,11 @@ class WashDataManager: # Stage-4 energy discriminator: integrated energy for WM/washer-dryer, # mean power elsewhere (see analysis.stage4_energy_mode). self.profile_store.energy_mode = analysis.stage4_energy_mode(self.device_type) + # Which cycle categories may shape a profile. The store cannot read entry + # options, so the manager pushes this in (same as energy_mode above). + self.profile_store.evidence_sources = config_entry.options.get( + CONF_PROFILE_EVIDENCE_SOURCES, DEFAULT_PROFILE_EVIDENCE_SOURCES + ) self.learning_manager = LearningManager( hass, self.entry_id, self.profile_store, self.device_type, device_name=config_entry.title, @@ -707,7 +721,8 @@ class WashDataManager: start_duration_threshold = float( config_entry.options.get( - CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD + CONF_START_DURATION_THRESHOLD, + resolve_start_duration_default(self.device_type), ) ) end_repeat_count = int( @@ -779,6 +794,16 @@ class WashDataManager: CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL ) ), + # `profile_match_threshold` was stored on the ProfileStore and never read + # anywhere, so the #288 workaround (raise it so near-duplicate profiles + # stop being trusted mid-cycle) silently did nothing. Wire it to the gate + # it was always documented to control; the default is the value that used + # to be hard-coded there, so nothing changes unless it was tuned. + match_confidence_threshold=float( + config_entry.options.get( + CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD + ) + ), anti_wrinkle_enabled=bool( config_entry.options.get( CONF_ANTI_WRINKLE_ENABLED, DEFAULT_ANTI_WRINKLE_ENABLED @@ -810,6 +835,15 @@ class WashDataManager: DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS, ) ), + # #393: resolve the device-type default HERE (not in the gate) so the + # field always carries a real float - playground.effective_settings() + # skips a None-valued field, which would desync the sim from the detector. + smart_termination_duration_ratio=float( + config_entry.options.get( + CONF_SMART_TERMINATION_DURATION_RATIO, + resolve_smart_termination_duration_ratio_default(self.device_type), + ) + ), delay_detect_enabled=bool( config_entry.options.get( CONF_DELAY_START_DETECT_ENABLED, DEFAULT_DELAY_START_DETECT_ENABLED @@ -903,13 +937,19 @@ class WashDataManager: self._remove_external_trigger_listener = None # External cycle end trigger self._remove_watchdog = None self._watchdog_interval = int( - config_entry.options.get(CONF_WATCHDOG_INTERVAL, DEFAULT_WATCHDOG_INTERVAL) + config_entry.options.get( + CONF_WATCHDOG_INTERVAL, + resolve_watchdog_interval_default(self.device_type), + ) ) self._match_persistence = int( config_entry.options.get(CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE) ) self._sampling_interval = float( - config_entry.options.get(CONF_SAMPLING_INTERVAL, DEFAULT_SAMPLING_INTERVAL) + config_entry.options.get( + CONF_SAMPLING_INTERVAL, + resolve_sampling_interval_default(self.device_type), + ) ) self._noise_events_threshold = int( config_entry.options.get( @@ -1427,10 +1467,15 @@ class WashDataManager: # Push updates to detector self.detector.set_verified_pause(verified_pause) + # Element 8 is the narrow #288-only prefix verdict and element 9 the + # matched profile's own tail power level, both for the #364 guards. The + # detector tolerates shorter tuples, so other callers stay valid. self.detector.update_match( (profile_name, confidence, matched_duration, phase_name, result.is_confident_mismatch, result.is_ambiguous, - result.is_prefix_ambiguous) + result.is_prefix_ambiguous, + result.is_prefix_ambiguous_full_shape, + self.profile_store.profile_tail_power(profile_name) if profile_name else None) ) # --- LOGGING (Unified) --- @@ -2093,12 +2138,36 @@ class WashDataManager: CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL ) ) + # Keep the detector's copy in sync so a panel edit takes effect without a + # restart, exactly like min_duration_ratio below. + self.detector.config.match_confidence_threshold = float( + config_entry.options.get( + CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD + ) + ) self.profile_store.dtw_bandwidth = float( config_entry.options.get(CONF_DTW_BANDWIDTH, DEFAULT_DTW_BANDWIDTH) ) # Stage-4 energy discriminator: integrated energy for WM/washer-dryer, # mean power elsewhere (see analysis.stage4_energy_mode). self.profile_store.energy_mode = analysis.stage4_energy_mode(self.device_type) + # Which cycle categories may shape a profile. Changing it changes every + # profile's curve, so rebuild them all now: envelopes are otherwise only rebuilt + # on a cycle end or a label change, so the user would tick the box and see + # nothing happen for days. + _prev_evidence = self.profile_store.evidence_sources + self.profile_store.evidence_sources = config_entry.options.get( + CONF_PROFILE_EVIDENCE_SOURCES, DEFAULT_PROFILE_EVIDENCE_SOURCES + ) + if self.profile_store.evidence_sources != _prev_evidence: + self._logger.info( + "Profile evidence sources changed %s -> %s; rebuilding all envelopes", + list(_prev_evidence), list(self.profile_store.evidence_sources), + ) + # Tracked, not fire-and-forget: this writes to the ProfileStore, so a + # reload/unload mid-rebuild must be able to cancel it before the store is + # swapped out (see _spawn_tracked). + self._spawn_tracked(self.profile_store.async_rebuild_all_envelopes()) # Device default dev_def = DEVICE_COMPLETION_THRESHOLDS.get( @@ -2110,7 +2179,8 @@ class WashDataManager: new_start_threshold = float( config_entry.options.get( - CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD + CONF_START_DURATION_THRESHOLD, + resolve_start_duration_default(self.device_type), ) ) new_end_repeat_count = int( @@ -2180,6 +2250,12 @@ class WashDataManager: DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS, ) ) + new_smart_termination_duration_ratio = float( + config_entry.options.get( + CONF_SMART_TERMINATION_DURATION_RATIO, + resolve_smart_termination_duration_ratio_default(self.device_type), + ) + ) new_delay_detect_enabled = bool( config_entry.options.get( CONF_DELAY_START_DETECT_ENABLED, DEFAULT_DELAY_START_DETECT_ENABLED @@ -2219,6 +2295,7 @@ class WashDataManager: self.detector.config.anti_wrinkle_exit_power = new_anti_wrinkle_exit_power self.detector.config.anti_wrinkle_idle_timeout = new_anti_wrinkle_idle_timeout self.detector.config.dishwasher_end_spike_quiet_release = new_dishwasher_end_spike_quiet_release + self.detector.config.smart_termination_duration_ratio = new_smart_termination_duration_ratio self.detector.config.delay_detect_enabled = new_delay_detect_enabled self.detector.config.delay_confirm_seconds = new_delay_confirm_seconds self.detector.config.delay_timeout_seconds = new_delay_timeout_seconds @@ -2414,7 +2491,10 @@ class WashDataManager: # Update sampling interval old_sampling = self._sampling_interval new_sampling = float( - config_entry.options.get(CONF_SAMPLING_INTERVAL, DEFAULT_SAMPLING_INTERVAL) + config_entry.options.get( + CONF_SAMPLING_INTERVAL, + resolve_sampling_interval_default(self.device_type), + ) ) if old_sampling != new_sampling: self._sampling_interval = new_sampling @@ -2422,6 +2502,26 @@ class WashDataManager: "Updated sampling interval: %.1fs -> %.1fs", old_sampling, new_sampling ) + # Watchdog cadence: like sampling above, this was only read at construction, so a + # changed CONF_WATCHDOG_INTERVAL (or a device-type change selecting a new default) + # otherwise kept the old cadence until the manager was recreated. Re-arm an active + # watchdog so the new interval takes effect mid-cycle. + old_watchdog = self._watchdog_interval + new_watchdog = int( + config_entry.options.get( + CONF_WATCHDOG_INTERVAL, + resolve_watchdog_interval_default(self.device_type), + ) + ) + if old_watchdog != new_watchdog: + self._watchdog_interval = new_watchdog + self._logger.info( + "Updated watchdog interval: %ds -> %ds", old_watchdog, new_watchdog + ) + if self._remove_watchdog: # active cycle: cancel and re-register at the new cadence + self._stop_watchdog() + self._start_watchdog() + # RESTORE STATE (only if recent enough, otherwise treat as stale) await self._attempt_state_restoration() @@ -3218,8 +3318,24 @@ class WashDataManager: ): return - # Track observed power readings for learning - self.learning_manager.process_power_reading(power, now, self._last_reading_time) + # Track observed power readings for learning - only while a cycle is + # active (#394). An appliance is idle ~98% of the time; running the 5-min + # auto-tune pass and training the sample-interval cadence model on the + # standby heartbeat is constant background work (a store rewrite every few + # minutes) that buys nothing AND skews every operational suggestion, since + # the idle publish-on-change heartbeat is not the in-cycle sampling + # cadence those suggestions are sized from. The detector below still + # receives EVERY reading, so the next cycle's start is never missed - only + # the learning call is gated. + if self.detector.state in ( + STATE_STARTING, + STATE_RUNNING, + STATE_PAUSED, + STATE_ENDING, + ): + self.learning_manager.process_power_reading( + power, now, self._last_reading_time + ) self._last_reading_time = now self._last_real_reading_time = now # Track real update self._current_power = power @@ -3635,6 +3751,20 @@ class WashDataManager: if not self._last_reading_time: return + # Refresh the remaining-time / progress estimate on the watchdog cadence + # (sampling-derived: max(30, 2*sampling+1)s), independently of incoming power + # events. A publish-on-change plug emits nothing during a flat low-power tail + # (e.g. a dishwasher's ~30 min drying phase at 0 W), so the event-driven + # _update_remaining_only never runs and the displayed countdown freezes at + # whatever value it last showed. The estimate is wall-clock based + # (net_elapsed_seconds), so this tick advances it correctly with zero new + # readings; it no-ops until a profile is matched. Kept ahead of the + # keepalive/force-end branches below so even a verified-pause drying tail + # (which skips those branches) still ticks down. + if self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING): + self._update_remaining_only() + self._notify_update() + time_since_any_update = (now - self._last_reading_time).total_seconds() # Calculate time since REAL update (if available, else fallback to any update) @@ -6742,6 +6872,9 @@ class WashDataManager: current_duration, profile_name, self._logger, + quiet_threshold_w=float( + getattr(self.detector.config, "stop_threshold_w", 0.0) or 0.0 + ), ) def _notify_update(self) -> None: diff --git a/custom_components/ha_washdata/manifest.json b/custom_components/ha_washdata/manifest.json index fcb306de..b7119d15 100644 --- a/custom_components/ha_washdata/manifest.json +++ b/custom_components/ha_washdata/manifest.json @@ -21,5 +21,5 @@ "requirements": [ "numpy" ], - "version": "0.5.4" + "version": "0.5.5" } \ No newline at end of file diff --git a/custom_components/ha_washdata/ml/README.md b/custom_components/ha_washdata/ml/README.md index 6d19d694..d2344723 100644 --- a/custom_components/ha_washdata/ml/README.md +++ b/custom_components/ha_washdata/ml/README.md @@ -1,84 +1,3 @@ -# WashData ML subsystem (experimental, gated) +# WashData ML Subsystem -Compact, **NumPy-only** models plus the runtime that trains and consumes them. -No new dependencies (NumPy is already in `manifest.json`). Everything here is -gated by flags in `const.py` and is inert until enabled, so the proven -detection/matching/ETA code paths are unchanged by default. - -## Feature flags (const.py) - -- `SHOW_ML_LAB` - show the ML Lab panel tab (shadow-mode comparison + review). -- `ENABLE_ML_SUGGESTIONS` - surface ML-calibrated setting suggestions alongside - the classic ones (`MLSuggestionEngine`). -- `ENABLE_ML_TRAINING` - allow the scheduled/manual on-device training loop. -- `CONF_ENABLE_ML_MODELS` (per-device option) - opt-in gate (via - `ml_models_enabled(options)`) for feeding ML signals into runtime decisions; - default off so callers keep existing behavior. (No runtime consumer wires this - yet - the live ML paths below run under their own flags.) - -## What ships here - -- `promoted_manifest.json` + `_model.py` - the embedded **baseline** models - (the broad-corpus models trained offline in `/root/ml_washdata`). Each module - is self-contained and exposes `score()`, `predict()`, `FEATURE_COLUMNS`, - `THRESHOLD`, `MODEL_METRICS`. `_feature_contract.json` documents the live - data each feature comes from; `_parity.json` are golden feature→score - cases the tests assert against. -- `feature_extraction.py` - NumPy-only runtime feature extractors - (`latest_end_event_features`, `live_match_features`, `quality_features`, - `profile_expectation`, energy integration) matching the models' `FEATURE_COLUMNS`. -- `engine.py` - `resolve_scorer(capability, store)`, the single bridge that - returns a **classifier** scoring callable preferring an on-device trained spec - over the embedded baseline (`"on_device"` vs `"baseline"`); `resolve_regressor( - capability, store)` is its **regression** twin for `standardized_linear` heads - that have no shipped baseline (returns `(None, None)` until one is promoted), - plus `ml_models_enabled` (opt-in gate) and `available_models` (manifest provenance). -- `trainer.py` - NumPy-only training for two spec kinds: logistic classifiers - (`fit_logistic`, `select_threshold`, `binary_metrics`, `auc`, `build_spec`/ - `score_spec` - byte-compatible with the embedded `score()` math) and ridge - **regressors** (`fit_ridge`, `regression_metrics`, `build_regression_spec`/ - `predict_value_spec` - standardized features + standardized target). -- `training_task.py` - on-device orchestration: derives labels from the device's - own cycles (end events from trace geometry; quality from status + ML-Lab review - labels; live_match from match-ranking-history snapshots), synthesises - completion-fraction examples for the regression capabilities, trains, and - promotes a classifier only when its held-out AUC is within margin of the - baseline (a regressor only when its held-out MAE beats the naive elapsed/ - expected projection). -- `matching_tuner.py` - `tune_matching_config(cycles)`: NumPy-only, executor-safe - leave-one-out tuning of the matcher's bounded scoring weights (`corr_weight`, - `duration_weight`, `energy_weight`, `dtw_ensemble_w`) over the device's own - labelled cycles. Same promotion discipline as the models (gate on a held-out - split by a margin); it only ever changes the emphasis between shape/level/energy, - never structural matching behaviour. - -Models (all standardized-logistic; only models that beat their baseline are shipped): -- `hybrid_curve_quality_model` - P(finished cycle is a problem). -- `live_match_commit_model` - P(top-1 live program match is correct). -- `cycle_end_detector_model` - P(a low-power event is the true end vs a pause). - -(No regression baseline is shipped: the `remaining_time` and `total_energy` -completion-fraction regressors did not beat the `expected_duration - elapsed` -heuristic on the broad corpus, so they stay inert until on-device training -promotes a per-device spec that beats that naive projection.) - -## How trained models reach inference - -`resolve_scorer(capability, store)` is used by the ML Lab shadow comparison -(`ws_api._compute_ml_comparison`) and by `MLSuggestionEngine`. If the profile -store holds an on-device spec for that capability (trained by `training_task` and -persisted under `ml_model_versions`), it is used; otherwise the embedded baseline -module is used. The shipped baseline is a broad-corpus model - per-user accuracy -gains come from on-device training, not from replacing the baseline. - -## Regenerating the embedded baseline (offline lab only) - -```bash -cd /root/ml_washdata -./ml.sh experiment # retrain + verify the determinism gate -python promote_to_integration.py --target # reads output/promoted/ -``` - -`promote_to_integration.py` refuses to copy any model whose encode/decode round -trip is not deterministic. On-device training never touches these baseline files; -it writes trained specs into the profile store instead. +Documentation has moved to the [ML Subsystem wiki page](https://github.com/3dg1luk43/ha_washdata/wiki/ML-Subsystem). diff --git a/custom_components/ha_washdata/ml/__init__.py b/custom_components/ha_washdata/ml/__init__.py index 7561ebbc..fc87029c 100644 --- a/custom_components/ha_washdata/ml/__init__.py +++ b/custom_components/ha_washdata/ml/__init__.py @@ -24,6 +24,7 @@ from .engine import ( CONF_ENABLE_ML_MODELS, available_models, ml_models_enabled, + preload_models, resolve_regressor, resolve_scorer, ) @@ -32,6 +33,7 @@ __all__ = [ "CONF_ENABLE_ML_MODELS", "available_models", "ml_models_enabled", + "preload_models", "resolve_regressor", "resolve_scorer", ] diff --git a/custom_components/ha_washdata/ml/engine.py b/custom_components/ha_washdata/ml/engine.py index 748657cf..b2a9cb6e 100644 --- a/custom_components/ha_washdata/ml/engine.py +++ b/custom_components/ha_washdata/ml/engine.py @@ -39,7 +39,7 @@ import importlib import json import logging from pathlib import Path -from typing import Mapping +from typing import Any, Mapping _LOGGER = logging.getLogger(__name__) @@ -52,6 +52,73 @@ _MODEL_MODULES = { "end": "cycle_end_detector_model", } +# Modules the live ML paths import lazily besides the baselines themselves. +_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 +# 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 +# the import on every inference. +_MODULE_CACHE: dict[str, object | None] = {} + + +def _load_model_module(module_name: str) -> Any | None: + """Return the embedded model module, importing it at most once. + + Safe to call from the event loop *after* :func:`preload_models` has run (the + import is then already satisfied from ``sys.modules``); the first call should + happen in an executor thread. + """ + if module_name in _MODULE_CACHE: + return _MODULE_CACHE[module_name] + try: + module = importlib.import_module(f"{__package__}.{module_name}") + except Exception as exc: # noqa: BLE001 - a missing model must not break setup + _LOGGER.warning( + "Failed to load embedded model module %r: %s", module_name, exc + ) + module = None + _MODULE_CACHE[module_name] = module + return module + + +def _sibling_attr(module_name: str, attr: str) -> Any | None: + """Fetch ``attr`` from an embedded sibling module via the cache, or None. + + Resolution paths run in the event loop, so they must never re-import: this hits + the ``_MODULE_CACHE`` warmed by :func:`preload_models` (which stores ``None`` on a + failed import). A missing module or attribute returns None, and the caller falls + back to the baseline / inert path rather than triggering a blocking loop import. + """ + module = _load_model_module(module_name) + return getattr(module, attr, None) if module is not None else None + + +def preload_models() -> None: + """Import everything the live ML paths touch. Call from an executor thread. + + ``resolve_scorer`` / ``resolve_regressor`` are called from the event loop, so + the imports they need (the embedded baselines plus ``trainer`` / + ``feature_extraction``) must already be in ``sys.modules`` by then - Home + Assistant flags a blocking ``importlib.import_module`` in the loop (issue + #328). Also warms the manifest cache, which reads a file. Never raises; + idempotent, so calling it once per config entry is cheap. + """ + for module_name in _MODEL_MODULES.values(): + _load_model_module(module_name) + # Cache the siblings too (module-or-None), so a failed import is recorded once + # here and the event-loop resolvers read it from the cache instead of retrying + # a blocking import (issue #328). + for sibling in _SIBLING_MODULES: + _load_model_module(sibling) + # No guard needed: available_models() carries its own outer try/except and caches + # [] on every failure path, so it cannot raise here (and a try/except/pass around it + # would be unreachable code that Ruff flags as S110/SIM105). + available_models() + def ml_models_enabled(options: Mapping[str, object] | None) -> bool: """True when the user has opted into experimental ML models.""" @@ -73,20 +140,16 @@ def resolve_scorer(capability: str, store: object | None): def _baseline(): """Resolve the shipped embedded baseline scorer for this capability. - Kept as a lazily-invoked helper so the baseline module is only imported - when the on-device spec is absent *or* fails at call time - preserving the - original "baseline only loaded when needed" semantics. + 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) - try: - module = importlib.import_module(f"{__package__}.{module_name}") - except Exception as exc: # noqa: BLE001 - _LOGGER.warning( - "Failed to load embedded baseline for capability %r: %s", - capability, exc, - ) + module = _load_model_module(module_name) + if module is None: return (None, None) def _baseline_score(feats, _m=module): @@ -125,7 +188,7 @@ def resolve_scorer(capability: str, store: object | None): module_name = _MODEL_MODULES.get(capability) if module_name is not None: try: - _bm = importlib.import_module(f"{__package__}.{module_name}") + _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: @@ -138,7 +201,9 @@ def resolve_scorer(capability: str, store: object | None): except Exception: # noqa: BLE001 - schema check must not break inference pass - from .trainer import score_spec + 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 @@ -189,8 +254,8 @@ def resolve_regressor(capability: str, store: object | None): if isinstance(spec, dict) and spec.get("kind") == "standardized_linear": # Feature-column schema guard for regression specs. try: - from .feature_extraction import PROGRESS_FEATURE_COLUMNS - _expected_r = list(PROGRESS_FEATURE_COLUMNS) + _prog_cols = _sibling_attr("feature_extraction", "PROGRESS_FEATURE_COLUMNS") + _expected_r = list(_prog_cols or []) _stored_r = list(spec.get("feature_columns") or []) if _expected_r and _stored_r and _stored_r != _expected_r: _LOGGER.warning( @@ -202,7 +267,9 @@ def resolve_regressor(capability: str, store: object | None): except Exception: # noqa: BLE001 - schema check must not break inference pass - from .trainer import predict_value_spec + predict_value_spec = _sibling_attr("trainer", "predict_value_spec") + if predict_value_spec is None: + return (None, None) def _on_device_predict(feats, _s=spec): # A malformed / incompatible promoted regression spec must never @@ -240,14 +307,26 @@ def available_models() -> list[dict[str, object]]: global _MANIFEST_MODELS_CACHE if _MANIFEST_MODELS_CACHE is not None: return _MANIFEST_MODELS_CACHE - manifest = Path(__file__).resolve().parent / "promoted_manifest.json" - if not manifest.exists(): - return [] + # Outer guard so EVERY failure caches a result: an unhandled exception here (from + # warm-up in preload_models, whose caller swallows it) would leave the cache cold, + # and the next event-loop caller would retry Path.exists()/read_text() - re-creating + # the blocking-call warning preload exists to prevent (#328). try: - payload = json.loads(manifest.read_text(encoding="utf-8")) - except (OSError, ValueError): - return [] - models = payload.get("models") - result = models if isinstance(models, list) else [] + manifest = Path(__file__).resolve().parent / "promoted_manifest.json" + # A missing manifest is cached as [] too: this is a shipped file that cannot + # appear at runtime, and the read is a blocking open() some callers make on + # the event loop. + if not manifest.exists(): + result: list[dict[str, object]] = [] + else: + payload = json.loads(manifest.read_text(encoding="utf-8")) + # A manifest decoding to a list/scalar would make .get() raise; keep only + # dict model entries so the return honours its list[dict] contract even for + # a malformed manifest like {"models": [null]}. + models = payload.get("models") if isinstance(payload, dict) else None + result = [m for m in models if isinstance(m, dict)] if isinstance(models, list) else [] + except Exception as exc: # noqa: BLE001 - never raise / never leave the cache cold + _LOGGER.debug("Could not read the promoted model manifest (%s); caching empty", exc) + result = [] _MANIFEST_MODELS_CACHE = result return result diff --git a/custom_components/ha_washdata/ml/training_task.py b/custom_components/ha_washdata/ml/training_task.py index 906ab415..c95d1b3f 100644 --- a/custom_components/ha_washdata/ml/training_task.py +++ b/custom_components/ha_washdata/ml/training_task.py @@ -34,7 +34,6 @@ Label sources (no manual labelling required to start): """ from __future__ import annotations -import importlib import logging from typing import Any @@ -542,11 +541,14 @@ def _holdout_split( def _embedded_module(capability: str): + """Embedded baseline module for a capability, via the shared engine cache.""" module_name = _CAPABILITIES.get(capability, (None, None))[0] if module_name is None: return None try: - return importlib.import_module(f"{__package__}.{module_name}") + from .engine import _load_model_module + + return _load_model_module(module_name) except Exception: # pylint: disable=broad-exception-caught return None diff --git a/custom_components/ha_washdata/playground.py b/custom_components/ha_washdata/playground.py index b47dbddf..508f4898 100644 --- a/custom_components/ha_washdata/playground.py +++ b/custom_components/ha_washdata/playground.py @@ -54,6 +54,7 @@ from .const import ( CONF_ANTI_WRINKLE_EXIT_POWER, CONF_ANTI_WRINKLE_IDLE_TIMEOUT, CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE, + CONF_SMART_TERMINATION_DURATION_RATIO, CONF_ANTI_WRINKLE_MAX_DURATION, CONF_ANTI_WRINKLE_MAX_POWER, CONF_COMPLETION_MIN_SECONDS, @@ -115,7 +116,11 @@ from .const import ( TerminationReason, ) from .cycle_detector import CycleDetector, CycleDetectorConfig -from .profile_store import _ambiguity_from_candidates, decompress_power_data +from .profile_store import ( + _ambiguity_from_candidates, + _match_prefix_ambiguity, + decompress_power_data, +) _LOGGER = logging.getLogger(__name__) @@ -168,6 +173,7 @@ _OVERRIDE_FIELD_MAP: dict[str, tuple[str, Callable[[Any], Any]]] = { CONF_ANTI_WRINKLE_EXIT_POWER: ("anti_wrinkle_exit_power", float), CONF_ANTI_WRINKLE_IDLE_TIMEOUT: ("anti_wrinkle_idle_timeout", float), CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE: ("dishwasher_end_spike_quiet_release", float), + CONF_SMART_TERMINATION_DURATION_RATIO: ("smart_termination_duration_ratio", float), CONF_OFF_DELAY: ("off_delay", int), CONF_MIN_OFF_GAP: ("min_off_gap", int), CONF_COMPLETION_MIN_SECONDS: ("completion_min_seconds", int), @@ -411,13 +417,34 @@ def _build_match_snapshots( snapshots: list[dict[str, Any]] = [] try: data = getattr(store, "_data", {}) or {} - profiles = data.get("profiles", {}) or {} - # Include imported reference cycles: an import-only profile samples from - # reference_cycles, so without them it would be dropped as a candidate and - # the Playground auto-detect would never match a downloaded profile. - past = data.get("past_cycles", []) or [] - refs = data.get("reference_cycles", []) or [] - by_id = {c.get("id"): c for c in (list(past) + list(refs)) if isinstance(c, dict)} + # Snapshot the profiles dict before iterating: this runs in an executor thread + # (ws_api dispatches _build_match_snapshots via async_add_executor_job) while the + # event loop may add/remove a profile (cycle-end creation, GC, auto-label), and a + # live `.items()` walk would raise "dictionary changed size during iteration" - + # the same race get_export_inventory was moved on-loop to avoid. dict() is a cheap + # shallow copy of the top-level mapping (values are read-only here). iter_evidence_ + # cycles() below returns a fresh list, so its .extend() is already snapshot-safe. + profiles = dict(data.get("profiles", {}) or {}) + # Include every cycle the live matcher would consider, via the store's own + # evidence view: an import-only profile samples from reference_cycles or + # backfill_cycles, so a snapshot pool built from past_cycles alone would drop it + # as a candidate and the Playground's auto-detect would never match a downloaded + # or backfilled profile - silently reporting it as unmatched. Reading the same + # gated view the matcher reads also keeps the sandbox honest when the user has + # excluded a category from shaping profiles. + try: + pool = store.iter_evidence_cycles() + except Exception: # pylint: disable=broad-exception-caught + # Older store without the evidence view: fall back to the raw lists. Include + # backfill_cycles too (the evidence view does), else a profile whose sample + # lives only in imported history has no snapshot and the sim reports it + # unmatched though live matching can use it. + pool = ( + list(data.get("past_cycles", []) or []) + + list(data.get("reference_cycles", []) or []) + + list(data.get("backfill_cycles", []) or []) + ) + by_id = {c.get("id"): c for c in pool if isinstance(c, dict)} for name, profile in profiles.items(): if not isinstance(profile, dict): continue @@ -437,6 +464,11 @@ def _build_match_snapshots( "name": name, "avg_duration": float(avg_dur), "sample_power": [p for _, p in sample_p], + # The trace's own time span, which is NOT avg_duration (a trimmed + # mean across cycles). `analysis._prefix_point_count` converts + # elapsed time to an index with it, so omitting it made the sim + # truncate the prefix at a different point than production. + "sample_span_s": float(sample_p[-1][0] - sample_p[0][0]), } ) except Exception as exc: # pylint: disable=broad-exception-caught @@ -872,10 +904,17 @@ class _DetailSim: members = self.group_members.get(gkey, []) if members and self.store is not None: try: - member_name, _, _ = self.store._stage5_pick_member( # noqa: SLF001 + member_name, _, member_dur = self.store._stage5_pick_member( # noqa: SLF001 list(powers), duration, members, self.member_snaps or {} ) - candidates[0] = dict(candidates[0], name=member_name) + # Carry the member's duration as well, exactly as + # `async_match_profile` relabels the winner: leaving the group's + # aggregate duration here fed the wrong expected value to the + # detector AND to the #364 prefix guard below. + resolved = dict(candidates[0], name=member_name) + if member_dur: + resolved["profile_duration"] = float(member_dur) + candidates[0] = resolved except Exception: # pylint: disable=broad-exception-caught pass best = candidates[0] @@ -915,7 +954,31 @@ class _DetailSim: # The DETECTOR still receives the RAW top-1, so detection / smart-termination # behaviour is byte-identical to before this reporting change. - return (raw_name, raw_conf, raw_expected, None, False, bool(is_ambiguous)) + # Elements 7-9 (#364): without them the prefix-landscape and power-plausibility + # guards were never exercised in a simulation, so the exact failure the + # Playground exists to reproduce was invisible here. + full_shape_hit, prefix_fit_hit = _match_prefix_ambiguity(candidates, raw_expected) + # Guard the store call like iter_evidence_cycles above: on an older store or a + # partial test double without profile_tail_power the AttributeError would + # bubble through _try_profile_match, which drops the match at debug - so EVERY + # match in the sim would be silently reported as unmatched. + tail_power = None + if self.store is not None and raw_name: + try: + tail_power = self.store.profile_tail_power(raw_name) + except Exception: # pylint: disable=broad-exception-caught + tail_power = None + return ( + raw_name, + raw_conf, + raw_expected, + None, + False, + bool(is_ambiguous), + bool(full_shape_hit or prefix_fit_hit), + bool(full_shape_hit), + tail_power, + ) def _sample(self, ts: datetime) -> None: if not self.compute_series: @@ -947,7 +1010,10 @@ class _DetailSim: phase_result = None if len(trace) >= 10 and program != "detecting...": phase_result = progress_mod.estimate_phase_progress( - self.store, trace, offset, program + self.store, trace, offset, program, + quiet_threshold_w=float( + getattr(self.detector.config, "stop_threshold_w", 0.0) or 0.0 + ), ) ml_pct = progress_mod.ml_progress_percent( self.store, self.options, matched_dur, trace, program, self._end_exp_fn @@ -1356,6 +1422,7 @@ def _sim_config_summary(config: CycleDetectorConfig) -> dict[str, Any]: "anti_wrinkle_exit_power": getattr(config, "anti_wrinkle_exit_power", None), "anti_wrinkle_idle_timeout": getattr(config, "anti_wrinkle_idle_timeout", None), "dishwasher_end_spike_quiet_release": getattr(config, "dishwasher_end_spike_quiet_release", None), + "smart_termination_duration_ratio": getattr(config, "smart_termination_duration_ratio", None), } diff --git a/custom_components/ha_washdata/profile_store.py b/custom_components/ha_washdata/profile_store.py index 80c15b21..d2f84d80 100644 --- a/custom_components/ha_washdata/profile_store.py +++ b/custom_components/ha_washdata/profile_store.py @@ -58,11 +58,19 @@ from .const import ( SHAREABLE_SETTING_KEYS, SMART_TERM_LANDSCAPE_RATIO, SMART_TERM_LANDSCAPE_MIN_SHAPE, + SMART_TERM_PREFIX_MARGIN, + SMART_TERM_PREFIX_MIN_RATIO, + SMART_TERM_PREFIX_MIN_SHAPE, + SMART_TERM_TAIL_WINDOW_FRAC, STORAGE_KEY, STORAGE_VERSION, DEFAULT_MAX_PAST_CYCLES, DEFAULT_MAX_FULL_TRACES_PER_PROFILE, DEFAULT_MAX_FULL_TRACES_UNLABELED, + EVIDENCE_BACKFILL_CYCLES, + EVIDENCE_REAL_CYCLES, + EVIDENCE_REFERENCE_CYCLES, + PROFILE_EVIDENCE_SOURCES, DEFAULT_DTW_BANDWIDTH, ) from .features import compute_signature @@ -106,6 +114,15 @@ _TRIM_SNAP_TOLERANCE_S = 1.0 JSONDict: TypeAlias = dict[str, Any] CycleDict: TypeAlias = dict[str, Any] +# Label sources that mean "the matcher guessed this", as opposed to a user confirming it. +# Consulted before overwriting a label, so the original guess is preserved exactly once. +# `auto_label_backfill` is the #344 import's own marker: an auto-labelled backfilled cycle +# feeds its profile's envelope immediately, so a wrong guess shifts what later cycles match +# against and has to stay distinguishable from a confirmed label. +_AUTO_LABEL_SOURCES = ( + "auto_match", "auto_label_post", "auto_label_service", "auto_label_backfill", +) + def _is_recorded_cycle(cycle: dict[str, Any]) -> bool: """True when a cycle was produced by the manual recorder. @@ -346,6 +363,12 @@ class MatchResult: is_confident_mismatch: bool = False mismatch_reason: str | None = None is_prefix_ambiguous: bool = False + # The LEGACY (#288-only, full-envelope shape) half of the verdict above. + # `is_prefix_ambiguous` is widened by #364's prefix scoring, which is safe for + # the ENDING Smart-Termination gate (a false fire only delays the finish) but + # NOT for the anti-crease finalize, where blocking can re-hang a cycle the way + # #296 described. That consumer reads this narrower flag instead. + is_prefix_ambiguous_full_shape: bool = False def to_dict(self) -> dict[str, Any]: """Convert to dictionary with JSON-serializable types, excluding heavy arrays.""" @@ -836,6 +859,15 @@ class WashDataStore(Store[JSONDict]): _LOGGER.info("Migrating storage from v%s to v11 (phase-profile cache marker)", old_major_version) + if old_major_version < 12: + # Cycles recovered from raw power history that predates the integration + # (issue #344) get their own list. They are auto-detected and unverified, so + # they belong in neither past_cycles (lifetime stats, ML training labels, the + # feedback queue, retention eviction) nor reference_cycles (curated + # community-store templates, golden by construction). Additive + idempotent. + _LOGGER.info("Migrating storage from v%s to v12", old_major_version) + old_data.setdefault("backfill_cycles", []) + return old_data def _ambiguity_from_candidates(candidates: list[dict]) -> tuple[float, bool]: @@ -851,6 +883,67 @@ def _ambiguity_from_candidates(candidates: list[dict]) -> tuple[float, bool]: return margin, margin < MATCH_AMBIGUITY_MARGIN +def _match_prefix_ambiguity( + candidates: list[dict], best_duration: float +) -> tuple[bool, bool]: + """``(full_shape_hit, prefix_fit_hit)`` for the prefix-landscape guard. + + Both terms answer the same question - "might this trace be a *prefix* of a + longer programme rather than a complete short one?" - by two different routes, + and either one blocks Smart Termination: + + * ``full_shape_hit`` (#288, unchanged): a non-winning candidate is at least + ``SMART_TERM_LANDSCAPE_RATIO`` longer than the winner and still scored + ``SMART_TERM_LANDSCAPE_MIN_SHAPE`` against its **full** envelope. + * ``prefix_fit_hit`` (#364): a longer candidate's score against its curve + **truncated to the elapsed duration** (``prefix_score``, computed in + ``analysis.annotate_prefix_scores``) beats the winner's own score by + ``SMART_TERM_PREFIX_MARGIN``. The margin is the load-bearing term - it is + scale-free, and asks whether the longer programme explains the trace + materially better than the short one does. The absolute floor only rejects + candidates that fit nothing. + + The full-envelope term alone has three structural false negatives on real + devices (see the #364 block in const.py); the prefix term exists because a + trace part-way through a longer programme cannot score well against that + programme's whole curve. Returned separately because the widened verdict is + only safe for the ENDING gate - see ``MatchResult.is_prefix_ambiguous_full_shape``. + + Pure function, no I/O. ``prefix_score`` absent (Stage 4 skipped, a mocked + executor, an older snapshot) degrades to the legacy term alone, so this can + never fire *less* often than the #288 predicate did. + """ + if best_duration <= 0 or len(candidates) < 2: + return False, False + # Compare against the winner's SHAPE score, not its blended final score: prefix_score + # is a shape-scale value (Stage-2 + Stage-3, no Stage-4 duration/energy agreement), so + # measuring the margin against the blended score mixed scales and made 0.15 too strict. + # Fall back to the blended score only when shape_score is absent (older snapshot). + _best_shape = candidates[0].get("shape_score") + best_score = float( + (_best_shape if _best_shape is not None else candidates[0].get("score")) or 0.0 + ) + full_shape_hit = False + prefix_fit_hit = False + for cand in candidates[1:]: + prof_dur = float(cand.get("profile_duration") or 0) + if prof_dur > best_duration * SMART_TERM_LANDSCAPE_RATIO and float( + cand.get("shape_score", cand.get("score", 0)) + ) >= SMART_TERM_LANDSCAPE_MIN_SHAPE: + full_shape_hit = True + prefix_score = cand.get("prefix_score") + if ( + prefix_score is not None + and prof_dur > best_duration * SMART_TERM_PREFIX_MIN_RATIO + and float(prefix_score) >= SMART_TERM_PREFIX_MIN_SHAPE + and float(prefix_score) >= best_score + SMART_TERM_PREFIX_MARGIN + ): + prefix_fit_hit = True + if full_shape_hit and prefix_fit_hit: + break + return full_shape_hit, prefix_fit_hit + + # ── Selective export/import taxonomy ──────────────────────────────────────────── # One canonical description of the store's top-level data kinds, grouped into the # user-facing categories offered by the export/import wizard. A single walker @@ -1071,6 +1164,8 @@ def unwrap_import_payload(payload: Any) -> tuple[dict[str, Any], dict[str, Any]] data_dict["past_cycles"] = [] if not isinstance(data_dict.get("reference_cycles"), list): data_dict["reference_cycles"] = [] + if not isinstance(data_dict.get("backfill_cycles"), list): + data_dict["backfill_cycles"] = [] data_dict.setdefault("envelopes", {}) fingerprint = payload.get("device_fingerprint") @@ -1228,6 +1323,10 @@ class ProfileStore: # from the device type via analysis.stage4_energy_mode. Default "mean" # keeps behaviour byte-identical until wired. self.energy_mode: str = "mean" + # Which cycle categories may shape a profile (see CONF_PROFILE_EVIDENCE_SOURCES). + # Pushed in by the manager from entry options, the way energy_mode is; the store + # has no access to them itself. Defaults to all three, i.e. pre-setting behaviour. + self._evidence_sources: tuple[str, ...] = tuple(PROFILE_EVIDENCE_SOURCES) self._save_debug_traces = save_debug_traces # Cache for resampled sample segments: key=(cycle_id, dt) @@ -1258,6 +1357,8 @@ class ProfileStore: "profiles": {}, "past_cycles": [], "reference_cycles": [], # Imported store cycles: envelope/matcher only, never usage stats + "backfill_cycles": [], # Cycles recovered from raw history (#344): unverified, + # envelope/matcher only, never stats/ML/shareable "envelopes": {}, # Cached statistical envelopes per profile "auto_adjustments": [], # Log of automatic setting changes "suggestions": {}, # Suggested settings (do NOT change user options) @@ -1731,6 +1832,96 @@ class ProfileStore: return cast(list[CycleDict], raw) return [] + def get_backfill_cycles(self) -> list[CycleDict]: + """Return the cycles recovered from raw power history (issue #344). + + A third list, deliberately neither of the other two. Like ``reference_cycles`` + these shape the envelope and can serve as a matching template once labelled, and + never touch usage/energy/count stats. Unlike them they are **not** curated: they + were auto-detected by replaying a history export, nothing has verified them, so + they are never golden, never shareable, and never training data. + """ + raw = self._data.setdefault("backfill_cycles", []) + if isinstance(raw, list): + return cast(list[CycleDict], raw) + return [] + + def iter_stored_cycles(self) -> list[CycleDict]: + """Every stored cycle, from all three lists. + + The single source for "find a cycle by id" and "is this id still real?". Profile + garbage collection and sample repair delete a profile whose ``sample_cycle_id`` + resolves to nothing, so a read path that forgets one of the lists silently + destroys an import-only profile. Route those lookups through here rather than + open-coding the union. + """ + return [ + *self.get_past_cycles(), + *self.get_reference_cycles(), + *self.get_backfill_cycles(), + ] + + @property + def evidence_sources(self) -> tuple[str, ...]: + """Categories currently allowed to shape a profile.""" + return self._evidence_sources + + @evidence_sources.setter + def evidence_sources(self, value: Any) -> None: + """Accept a user selection, falling back to all categories. + + An empty or unrecognised selection is treated as "all": with nothing allowed + every envelope would be empty and every profile unmatchable, which is worse than + ignoring the setting. Unknown names are dropped rather than trusted. + """ + wanted = tuple( + name for name in PROFILE_EVIDENCE_SOURCES + if isinstance(value, (list, tuple, set, frozenset)) and name in value + ) + if not wanted: + if value: + self._logger.warning( + "Ignoring profile-evidence selection %s: no known category left, " + "falling back to all", value, + ) + wanted = tuple(PROFILE_EVIDENCE_SOURCES) + self._evidence_sources = wanted + + def iter_evidence_cycles(self) -> list[CycleDict]: + """Stored cycles the user allows to shape a profile. + + The gated twin of :meth:`iter_stored_cycles`. Use this for the envelope, the + matcher's snapshot pool and the matching template - anything that answers "what + does this profile look like". Never use it for a lookup by id or a validity check: + an excluded cycle still exists, and profile garbage collection reading this view + would delete a profile whose only cycles the user had merely stopped trusting. + """ + allowed = self._evidence_sources + out: list[CycleDict] = [] + if EVIDENCE_REAL_CYCLES in allowed: + out.extend(self.get_past_cycles()) + if EVIDENCE_REFERENCE_CYCLES in allowed: + out.extend(self.get_reference_cycles()) + if EVIDENCE_BACKFILL_CYCLES in allowed: + out.extend(self.get_backfill_cycles()) + return out + + def find_stored_cycle(self, cycle_id: str) -> tuple[CycleDict | None, str]: + """Locate a cycle by id, returning it with the name of the list holding it. + + The origin is what decides capability: ``past`` cycles are fully editable, + ``reference`` and ``backfill`` cycles can be labelled but not trimmed or split. + """ + for origin, cycles in ( + ("past", self.get_past_cycles()), + ("reference", self.get_reference_cycles()), + ("backfill", self.get_backfill_cycles()), + ): + match = next((c for c in cycles if c.get("id") == cycle_id), None) + if match is not None: + return match, origin + return None, "" + def get_shareable_cycles(self) -> list[dict[str, Any]]: """Recorded/golden reference cycles eligible to share to the community store. @@ -2017,6 +2208,7 @@ class ProfileStore: n = 200 agg_curves: dict[str, list[np.ndarray]] = {} agg_durs: dict[str, list[float]] = {} + agg_spans: dict[str, list[float]] = {} member_snaps: dict[str, dict[str, Any]] = {} out: list[dict[str, Any]] = [] for s in snapshots: @@ -2031,15 +2223,21 @@ class ProfileStore: agg_curves.setdefault(g, []).append( np.interp(np.linspace(0, 1, n), np.linspace(0, 1, arr.size), arr) ) - agg_durs.setdefault(g, []).append(float(s.get("avg_duration") or 0.0)) + _dur = float(s.get("avg_duration") or 0.0) + agg_durs.setdefault(g, []).append(_dur) + # Members are resampled onto a normalized 0..1 axis above, so the + # aggregate's own time span is the mean of its members' (#364). + agg_spans.setdefault(g, []).append(float(s.get("sample_span_s") or _dur)) group_members: dict[str, list[str]] = {} for g, curves in agg_curves.items(): key = f"__group__{g}" durs = [d for d in agg_durs[g] if d > 0] + spans = [v for v in agg_spans.get(g, []) if v > 0] out.append({ "name": key, "avg_duration": float(np.mean(durs)) if durs else 0.0, "sample_power": np.mean(np.array(curves), axis=0).tolist(), + "sample_span_s": float(np.mean(spans)) if spans else 0.0, }) group_members[key] = [m for m in member_to_group if member_to_group[m] == g and m in member_snaps] return out, group_members, member_snaps @@ -3453,16 +3651,15 @@ class ProfileStore: profiles: dict[str, dict[str, Any]] = self._data.get("profiles", {}) or {} cycles: list[dict[str, Any]] = self._data.get("past_cycles", []) or [] - ref_cycles: list[dict[str, Any]] = self._data.get("reference_cycles", []) or [] if not profiles or not cycles: return stats - # Sample validity must recognise imported reference cycles: an import-only - # profile legitimately points its sample at a reference cycle. Without this, - # such a sample looks "missing" and the repair below would steal an unrelated - # unlabeled real cycle into the imported profile. + # Sample validity must recognise imported and backfilled cycles: an import-only + # profile legitimately points its sample at one of those. Without this, such a + # sample looks "missing" and the repair below would steal an unrelated unlabeled + # real cycle into the imported profile. by_id: dict[str, dict[str, Any]] = { - c["id"]: c for c in list(cycles) + list(ref_cycles) if c.get("id") + c["id"]: c for c in self.iter_stored_cycles() if c.get("id") } def newest_unlabeled_with_power_data() -> dict[str, Any] | None: @@ -3850,13 +4047,10 @@ class ProfileStore: def cleanup_orphaned_profiles(self) -> int: """Remove profiles that reference non-existent cycles. Returns number of profiles removed.""" - # Imported reference cycles are valid sample targets too (an import-only - # profile points its sample there), so include them or such profiles would - # be wrongly deleted as orphans. - cycle_ids = {c["id"] for c in self._data.get("past_cycles", [])} - cycle_ids |= { - c["id"] for c in self._data.get("reference_cycles", []) if c.get("id") - } + # Imported reference cycles and backfilled history cycles are valid sample + # targets too (an import-only profile points its sample at one), so every list + # counts here or such profiles would be wrongly deleted as orphans. + cycle_ids = {c["id"] for c in self.iter_stored_cycles() if c.get("id")} orphaned: list[str] = [] for name, profile in self._data["profiles"].items(): ref = profile.get("sample_cycle_id") @@ -4228,7 +4422,7 @@ class ProfileStore: not chosen). Returns a cycle id, or None if there are no usable cycles. """ cands = [ - c for c in list(self._data.get("past_cycles", [])) + list(self._data.get("reference_cycles", [])) + c for c in self.iter_evidence_cycles() if c.get("profile_name") == profile_name and c.get("status") in ("completed", "force_stopped") and isinstance(c.get("power_data"), list) and len(c["power_data"]) >= 3 @@ -4351,11 +4545,23 @@ class ProfileStore: and c.get("duration", 0) > 60 ] - # Real cycles drive usage stats (energy/count). Imported reference cycles - # additionally shape the curves + matching duration, but never usage stats. + # Real cycles drive usage stats (energy/count). Imported reference cycles and + # backfilled history cycles additionally shape the curves + matching duration, + # but never usage stats. + # + # Which of the three may shape the curve is the user's choice + # (CONF_PROFILE_EVIDENCE_SOURCES); usage stats are not evidence and keep counting + # real cycles either way, so unticking "real cycles" removes them from the curve + # without making the profile claim it has never run. + allowed = self._evidence_sources real_cycles = _eligible(self._data["past_cycles"]) - ref_cycles = _eligible(self._data.get("reference_cycles", [])) - shape_cycles = real_cycles + ref_cycles + shape_real = real_cycles if EVIDENCE_REAL_CYCLES in allowed else [] + ref_cycles: list[CycleDict] = [] + if EVIDENCE_REFERENCE_CYCLES in allowed: + ref_cycles += _eligible(self._data.get("reference_cycles", [])) + if EVIDENCE_BACKFILL_CYCLES in allowed: + ref_cycles += _eligible(self._data.get("backfill_cycles", [])) + shape_cycles = shape_real + ref_cycles if not shape_cycles: if profile_name in self._data.get("envelopes", {}): @@ -4721,6 +4927,64 @@ class ProfileStore: return 0.0 return float(curves[0][-1]) + def profile_tail_power( + self, + profile_name: str, + window_frac: float = SMART_TERM_TAIL_WINDOW_FRAC, + ) -> float | None: + """Mean power (W) a profile draws over the last ``window_frac`` of its own + run, or None when the profile has no usable trace. Never raises. + + This is the reference level the Smart-Termination power-plausibility guard + compares the live trailing power against (#364). Both Smart-Termination + paths key on ``elapsed >= 0.98 * expected``; when the matcher has locked onto + a *shorter* look-alike profile that anchor lands mid-wash, and neither path + asks whether the appliance is still working. "Several times what this + programme draws at its own end" is the signal that it is. + + Prefers the profile's envelope average curve; falls back to the sample + cycle's raw trace, because a thinly-trained profile (one labelled cycle, so + no envelope) is still a match candidate and would otherwise get no guard at + all. Pure statistics, no ML - same never-raises contract as + ``compute_envelope_conformance``. + """ + try: + frac = min(max(float(window_frac), 0.01), 1.0) + + curves = self._envelope_time_power(self.get_envelope(profile_name)) + if curves and len(curves[0]) >= 2: + env_time, env_power = curves + cutoff = float(env_time[-1]) * (1.0 - frac) + tail = [p for t, p in zip(env_time, env_power) if t >= cutoff] + if tail: + return float(np.mean(tail)) + + profile = self.get_profiles().get(profile_name) + if not isinstance(profile, dict): + return None + sample_id = profile.get("sample_cycle_id") + if not sample_id: + return None + cycle, _ = self.find_stored_cycle(str(sample_id)) + if not cycle: + return None + # Through decompress_power_data, not raw power_data: a legacy cycle stores + # (iso_string, power) pairs, and float() on the ISO string would raise into + # the broad except below, silently leaving the #364 guard inert for that + # profile. The isinstance check does not catch it - [iso_str, power] is a list. + points = decompress_power_data(cycle) + if len(points) < 2: + return None + offsets = [float(pt[0]) for pt in points] + powers = [float(pt[1]) for pt in points] + cutoff = offsets[-1] * (1.0 - frac) + tail = [p for t, p in zip(offsets, powers) if t >= cutoff] + if not tail: + return None + return float(np.mean(tail)) + except Exception: # noqa: BLE001 + return None + def reference_curve( self, profile_name: str, n: int = REFERENCE_PROFILE_CURVE_POINTS ) -> JSONDict | None: @@ -5178,9 +5442,11 @@ class ProfileStore: current_power_list = current_seg.power.tolist() - # Prepare Snapshots. Imported reference cycles are eligible as matching - # templates alongside real cycles (so an import-only profile can match). - all_cycles = list(self._data["past_cycles"]) + list(self._data.get("reference_cycles", [])) + # Prepare Snapshots. Imported reference cycles and backfilled history + # cycles are eligible as matching templates alongside real cycles (so an + # import-only profile can match) - subject to the user's evidence choice, so + # the pool and the envelope always agree about what a profile looks like. + all_cycles = self.iter_evidence_cycles() # Precompute per-profile lookups ONCE so the loop below is O(profiles), # not O(profiles x cycles). Rescanning all_cycles with next()/any() for # every profile made matching quadratic and stalled low-power hosts on @@ -5259,6 +5525,12 @@ class ProfileStore: "name": name, "avg_duration": float(avg_duration), "sample_power": avg_y, + # True wall-clock span of `sample_power` (#364). NOT the same + # as avg_duration, which prefers target_duration / the + # profile's rolling mean - so index fraction only equals time + # fraction against this. Needed to truncate the curve to an + # elapsed duration for prefix scoring. + "sample_span_s": float(_env_ts_duration or avg_duration), }) continue @@ -5301,7 +5573,12 @@ class ProfileStore: "name": name, "avg_duration": float(avg_dur), "sample_power": sample_seg.power.tolist(), - "sample_dt": used_dt + "sample_dt": used_dt, + # True wall-clock span of `sample_power` (#364). _get_cached_sample_segment + # keeps only the LONGEST gap-free segment, so a cycle with an internal + # outage yields a curve covering less than avg_dur - truncating by a + # fraction of avg_dur would then cut the wrong place. + "sample_span_s": float(_seg_ts_duration or avg_dur), }) if skipped_profiles: @@ -5396,12 +5673,10 @@ class ProfileStore: # current trace may be a prefix of that longer program, not a complete # short cycle. Signal cycle_detector to block Smart Termination; the # power-based fallback timeout will decide instead. - best_dur = best_duration or 0.0 - is_prefix_ambiguous = best_dur > 0 and any( - float(c.get("profile_duration") or 0) > best_dur * SMART_TERM_LANDSCAPE_RATIO - and float(c.get("shape_score", c.get("score", 0))) >= SMART_TERM_LANDSCAPE_MIN_SHAPE - for c in candidates[1:] + full_shape_hit, prefix_fit_hit = _match_prefix_ambiguity( + candidates, best_duration or 0.0 ) + is_prefix_ambiguous = full_shape_hit or prefix_fit_hit return MatchResult( best_name, @@ -5413,6 +5688,7 @@ class ProfileStore: margin, ranking=candidates[:5], # populate ranking (consumed for training snapshots) is_prefix_ambiguous=is_prefix_ambiguous, + is_prefix_ambiguous_full_shape=full_shape_hit, ) async def async_verify_alignment( @@ -5532,10 +5808,19 @@ class ProfileStore: async def create_profile(self, name: str, source_cycle_id: str) -> None: - """Create a new profile from a past cycle.""" - cycle = next( - (c for c in self._data["past_cycles"] if c["id"] == source_cycle_id), None - ) + """Create a new profile from a cycle, real or imported. + + ``reference_cycles`` are searched too: an imported cycle (community store, or a + historical import per issue #344) is a legitimate source for a brand-new profile, + and for a history import it is the *primary* one - the whole point is to name the + programs found in months of past data. Looking only in ``past_cycles`` made + "Label -> Create new profile..." fail outright on those cycles. + + The envelope is rebuilt here rather than left for the next match or maintenance + pass, so the profile is usable the moment it is created (mirroring + :meth:`assign_profile_to_cycle`). + """ + cycle, _origin = self.find_stored_cycle(source_cycle_id) if not cycle: raise ValueError("Cycle not found") @@ -5546,36 +5831,29 @@ class ProfileStore: "sample_cycle_id": source_cycle_id, } + await self.async_rebuild_envelope(name) # Save to persist the label await self.async_save() @property def has_real_profiles(self) -> bool: - """True if at least one stored profile is backed by a real cycle. + """True if at least one stored profile is backed by a cycle that counts as evidence. - A profile counts as "real" when it has a labelled cycle in ``past_cycles`` - OR an imported ``reference_cycle`` (store-adopted templates that the matcher - treats as eligible snapshots — see the snapshot builder in async_match). An - import-only install has zero past_cycles but is fully matchable, so it must - pass this gate too, otherwise matching and the setup notifications are - skipped for it entirely. + A profile with no cycle behind it cannot be matched, so this gates matching and + the setup notifications. Imported reference cycles and backfilled history cycles + count: an import-only install has zero `past_cycles` and is still fully matchable. + Evidence-gated, so a profile whose only cycles the user has excluded reads as not + matchable - which is what excluding them means. """ profile_names = self._data.get("profiles", {}).keys() if not profile_names: return False assigned = { c.get("profile_name") - for c in self._data.get("past_cycles", []) + for c in self.iter_evidence_cycles() if c.get("profile_name") } - if assigned.intersection(profile_names): - return True - ref_assigned = { - c.get("profile_name") - for c in self._data.get("reference_cycles", []) - if c.get("profile_name") - } - return bool(ref_assigned.intersection(profile_names)) + return bool(assigned.intersection(profile_names)) def list_profiles(self) -> list[dict[str, Any]]: """List all profiles with metadata.""" @@ -5651,6 +5929,13 @@ class ProfileStore: "total_cost": total_cost, "signature_curve": sig_curve, "is_imported": self.profile_has_reference_cycles(name), + # Cycles recovered from imported history (#344). Reported separately + # from cycle_count, which counts only real observed cycles, so a + # profile built purely from backfilled history does not look empty. + "backfill_count": sum( + 1 for c in self.get_backfill_cycles() + if c.get("profile_name") == name + ), } ) return sorted(profiles, key=lambda p: profile_sort_key(p.get("name", ""))) @@ -5670,7 +5955,7 @@ class ProfileStore: profile_data: JSONDict = {} if reference_cycle_id: cycle = next( - (c for c in self._data["past_cycles"] if c["id"] == reference_cycle_id), + (c for c in self.iter_stored_cycles() if c.get("id") == reference_cycle_id), None, ) if cycle: @@ -5741,12 +6026,9 @@ class ProfileStore: # Update cycles and feedback if renamed count = 0 if renamed: - # 1. Update past + imported reference cycles (imports carry profile_name + # 1. Update every stored cycle (imports and backfills carry profile_name # too; leaving them under the old name orphans them from the matcher). - for cycle in ( - list(self._data.get("past_cycles", [])) - + list(self._data.get("reference_cycles", [])) - ): + for cycle in self.iter_stored_cycles(): if cycle.get("profile_name") == old_name: cycle["profile_name"] = new_name count += 1 @@ -5792,13 +6074,11 @@ class ProfileStore: # Delete profile del self._data["profiles"][name] - # Handle cycles (past + imported reference; both carry profile_name, so an - # imported cycle would otherwise keep a dangling label for a deleted profile). + # Handle cycles from every list: imported and backfilled cycles carry + # profile_name too, so they would otherwise keep a dangling label for a + # deleted profile. count = 0 - for cycle in ( - list(self._data.get("past_cycles", [])) - + list(self._data.get("reference_cycles", [])) - ): + for cycle in self.iter_stored_cycles(): if cycle.get("profile_name") == name: if unlabel_cycles: cycle["profile_name"] = None @@ -5813,6 +6093,7 @@ class ProfileStore: """Clear all profiles, cycle data, and derived state.""" self._data["past_cycles"] = [] self._data["reference_cycles"] = [] + self._data["backfill_cycles"] = [] self._data["profiles"] = {} self._data["envelopes"] = {} self._data["suggestions"] = {} @@ -5848,20 +6129,16 @@ class ProfileStore: ) -> None: """Assign an existing profile to a cycle. Rebuilds envelope.""" old_profile = None - cycle = next( - (c for c in self._data["past_cycles"] if c["id"] == cycle_id), None - ) - if not cycle: - # Imported reference recording (separate list, never in usage stats): - # relabelling just moves which profile's template it seeds. - ref = next( - (c for c in self.get_reference_cycles() if c.get("id") == cycle_id), - None, - ) - if ref is not None: - await self._assign_reference_cycle_profile(ref, profile_name) - return + found, origin = self.find_stored_cycle(cycle_id) + if found is None: raise ValueError(f"Cycle {cycle_id} not found") + if origin != "past": + # An imported store recording or a backfilled history cycle (separate lists, + # never in usage stats): relabelling just moves which profile's template it + # seeds. + await self._assign_reference_cycle_profile(found, profile_name) + return + cycle = found # Track old profile for envelope rebuild old_profile = cycle.get("profile_name") @@ -5872,7 +6149,7 @@ class ProfileStore: # Preserve original auto-assigned label before first manual relabeling if profile_name and old_profile and not cycle.get("original_auto_label"): orig_src = cycle.get("label_source", "") - if orig_src in ("auto_match", "auto_label_post", "auto_label_service"): + if orig_src in _AUTO_LABEL_SOURCES: cycle["original_auto_label"] = old_profile # Update cycle @@ -5899,40 +6176,73 @@ class ProfileStore: # Trigger smart processing to potentially merge now-labeled cycle await self.async_smart_process_history() - async def _assign_reference_cycle_profile( + def _relabel_non_real_cycle( self, ref: CycleDict, profile_name: str | None - ) -> None: - """Reassign an imported reference recording to a different profile. + ) -> set[str]: + """Move a non-real cycle onto a profile, returning the envelopes to rebuild. - The cycle stays in ``reference_cycles`` (out of usage stats); only which - profile envelope it seeds changes. Rebuilds the old and new envelopes. - ``profile_name=None`` clears the label (the recording then seeds nothing). + The bookkeeping half of :meth:`_assign_reference_cycle_profile`, split out so a + bulk caller (:meth:`auto_label_cycles`) can apply it to many cycles and then + rebuild and save **once** instead of per cycle - the same batching the real-cycle + path uses, and the difference between one store write and one per imported cycle. + + Synchronous and self-contained: it validates the target, moves the label, clears a + stale ``sample_cycle_id`` on the profile the cycle is leaving, and drops that + profile outright when nothing is left in it (mirrors `_delete_non_real_cycle`; + without it a sampleless profile would later be re-populated by sample repair + stealing an unrelated real cycle). Returns the names whose envelope the caller + must rebuild - a profile this dropped is deliberately absent from that set. """ if profile_name and profile_name not in self._data.get("profiles", {}): raise ValueError(f"Profile '{profile_name}' not found. Create it first.") old_profile = ref.get("profile_name") ref_id = ref.get("id") ref["profile_name"] = profile_name if profile_name else None + touched: set[str] = set() if old_profile and old_profile != profile_name: - # The moved cycle may have been the old profile's sample. Clear that - # stale pointer so the old profile can't resolve the moved trace (now - # another profile's) by id, and drop the old profile if it is now empty - # (mirrors _delete_reference_cycle; prevents repair adopting a real cycle). op = self._data.get("profiles", {}).get(old_profile) if op is not None and op.get("sample_cycle_id") == ref_id: op["sample_cycle_id"] = None old_has_cycles = any( c.get("profile_name") == old_profile - for c in list(self._data.get("past_cycles", [])) - + list(self._data.get("reference_cycles", [])) + for c in self.iter_stored_cycles() ) if old_has_cycles: - await self.async_rebuild_envelope(old_profile) + touched.add(old_profile) else: self._data.get("profiles", {}).pop(old_profile, None) self._data.get("envelopes", {}).pop(old_profile, None) if profile_name: - await self.async_rebuild_envelope(profile_name) + touched.add(profile_name) + return touched + + async def _assign_reference_cycle_profile( + self, ref: CycleDict, profile_name: str | None + ) -> None: + """Reassign a non-real cycle - an imported store recording or a backfilled + history cycle - to a different profile. + + The cycle stays in its own list (out of usage stats); only which profile envelope + it seeds changes. Rebuilds the old and new envelopes. ``profile_name=None`` clears + the label (the cycle then seeds nothing). + + This is the *manual* (user-driven) relabel path, so it stamps provenance exactly + like the real-cycle path in ``assign_profile_to_cycle``: preserve the original + auto-guess once, then mark the label ``manual``. Without the manual stamp a later + ``auto_label_cycles(overwrite=True)`` would still see an auto ``label_source`` on a + backfilled cycle and silently overwrite the user's correction. + """ + old_profile = ref.get("profile_name") + # Preserve the original auto-assigned label before the first manual relabel. + if profile_name and old_profile and not ref.get("original_auto_label"): + if ref.get("label_source", "") in _AUTO_LABEL_SOURCES: + ref["original_auto_label"] = old_profile + touched = self._relabel_non_real_cycle(ref, profile_name) + # _relabel_non_real_cycle only moves profile_name (it is shared with the auto + # bulk path, which sets its own source); stamp the manual provenance here. + ref["label_source"] = "manual" if profile_name else None + for name in touched: + await self.async_rebuild_envelope(name) await self.async_save() self._logger.info( "Reassigned imported reference cycle %s to profile '%s'", @@ -5949,20 +6259,52 @@ class ProfileStore: overwrite: If True, re-evaluates already labeled cycles. Returns stats: {labeled: int, relabeled: int, skipped: int, total: int} + + Covers **backfilled history cycles** (#344) as well as real ones. Nobody remembers + which wash was Eco 40 six months later, so an import that left dozens of unnamed + cycles to hand-label would be barely better than no import at all. The gate is the + same for both (``confidence >= threshold`` and not ambiguous), but a backfilled + cycle is written through :meth:`_relabel_non_real_cycle` rather than mutated like a + real one, because moving one off a profile has to clear a stale sample pointer and + may leave that profile empty. + + A label applied here is stamped ``auto_label_backfill``, distinct from the + real-cycle ``auto_label_service``. That distinction matters: an auto-labelled + backfill cycle immediately feeds its profile's envelope, so a wrong guess shifts + what later cycles match against, and the marker is what makes that recoverable - + by the user or by a later pass - rather than indistinguishable from a confirmed + label. + + NB on a fresh install with no profiles there is nothing to match against, so the + real sequence is "hand-label a couple, then auto-label the rest": each label + strengthens the envelope the next batch matches against. There is no + self-matching circularity - with ``overwrite=False`` only unlabelled cycles are + touched, and an unlabelled cycle is in no envelope yet. """ stats = {"labeled": 0, "relabeled": 0, "skipped": 0, "total": 0} - cycles = self._data.get("past_cycles", []) - - # Filter down if not overwriting + # (cycle, is_backfill) so the write path can differ while the gate does not. + candidates: list[tuple[CycleDict, bool]] = [ + *((c, False) for c in self._data.get("past_cycles", [])), + *((c, True) for c in self.get_backfill_cycles()), + ] if not overwrite: - target_cycles = [c for c in cycles if not c.get("profile_name")] - else: - target_cycles = cycles + candidates = [(c, b) for c, b in candidates if not c.get("profile_name")] - stats["total"] = len(target_cycles) + stats["total"] = len(candidates) + # Envelopes are rebuilt once at the end, not per cycle: this is a bulk pass and + # `_assign_reference_cycle_profile`'s per-call rebuild+save would mean one whole + # store write per imported cycle. + touched: set[str] = set() + + for cycle, is_backfill in candidates: + # Never overwrite a user's manual label, even with overwrite=True: a manual + # correction is exactly the ground truth this pass should defer to (real and + # non-real alike - the non-real manual relabel now stamps this too). + if cycle.get("label_source") == "manual": + stats["skipped"] += 1 + continue - for cycle in target_cycles: # Reconstruct power data for matching power_data = decompress_power_data(cycle) if not power_data or len(power_data) < 10: @@ -5985,19 +6327,46 @@ class ProfileStore: for c in (getattr(result, "ranking", []) or [])[:5] ] + label_source = "auto_label_backfill" if is_backfill else "auto_label_service" + + def _apply( + target: CycleDict = cycle, + backfill: bool = is_backfill, + match: MatchResult = result, + source: str = label_source, + ranking: list[dict[str, Any]] = ranking_top5, + ) -> None: + """Move the label, then stamp the provenance the mover does not. + + `_relabel_non_real_cycle` only moves `profile_name`, so without this + an auto-labelled imported cycle would carry no confidence for the + Cycles list to show and no marker saying the matcher guessed it. + + EVERY loop value it reads is bound as a default, not closed over: the + call happens in the same iteration today, but a later change that + defers it (collect the callables, run them after the loop) would + otherwise silently apply the LAST iteration's match to every cycle. + """ + if backfill: + touched.update( + self._relabel_non_real_cycle(target, match.best_profile) + ) + else: + target["profile_name"] = match.best_profile + target["match_confidence"] = float(match.confidence) + target["label_source"] = source + if ranking: + target["match_ranking_top5"] = ranking + # If overwriting, check if new match is different and better/valid if current_label: if current_label != result.best_profile: # Preserve original label before first auto-service relabeling if not cycle.get("original_auto_label"): orig_src = cycle.get("label_source", "") - if orig_src in ("auto_match", "auto_label_post", "auto_label_service"): + if orig_src in _AUTO_LABEL_SOURCES: cycle["original_auto_label"] = current_label - cycle["profile_name"] = result.best_profile - cycle["match_confidence"] = float(result.confidence) - cycle["label_source"] = "auto_label_service" - if ranking_top5: - cycle["match_ranking_top5"] = ranking_top5 + _apply() stats["relabeled"] += 1 self._logger.info( "Relabeled cycle %s: '%s' -> '%s' (confidence: %.2f)", @@ -6007,11 +6376,7 @@ class ProfileStore: result.confidence, ) else: - cycle["profile_name"] = result.best_profile - cycle["match_confidence"] = float(result.confidence) - cycle["label_source"] = "auto_label_service" - if ranking_top5: - cycle["match_ranking_top5"] = ranking_top5 + _apply() stats["labeled"] += 1 self._logger.info( "Auto-labeled cycle %s as '%s' (confidence: %.2f)", @@ -6023,6 +6388,11 @@ class ProfileStore: stats["skipped"] += 1 if stats["labeled"] > 0 or stats["relabeled"] > 0: + # A labelled backfill cycle exists only to shape its profile's envelope, so + # rebuild what changed before saving - otherwise the import accomplishes + # nothing until some unrelated later trigger. + for name in touched: + await self.async_rebuild_envelope(name) await self.async_save() # Trigger smart processing after bulk labeling await self.async_smart_process_history() @@ -6648,9 +7018,9 @@ class ProfileStore: initial_len = len(cycles) cycle_to_delete = next((c for c in cycles if c.get("id") == cycle_id), None) if not cycle_to_delete: - # Not a real cycle -- it may be an imported store recording, which - # lives in the separate reference_cycles list (never in usage stats). - return await self._delete_reference_cycle(cycle_id) + # Not a real cycle -- it may be an imported store recording or a backfilled + # history cycle, which live in their own lists (never in usage stats). + return await self._delete_non_real_cycle(cycle_id) profile_name = cycle_to_delete.get("profile_name") self._data["past_cycles"] = [c for c in cycles if c.get("id") != cycle_id] @@ -6673,19 +7043,23 @@ class ProfileStore: return True return False - async def _delete_reference_cycle(self, cycle_id: str) -> bool: - """Delete a single imported store recording from ``reference_cycles``. + async def _delete_non_real_cycle(self, cycle_id: str) -> bool: + """Delete a single imported store recording or backfilled history cycle. - Rebuilds the affected profile's envelope so removing a bad import - immediately stops influencing the matcher template. Returns False when - no reference cycle carries that id. + Rebuilds the affected profile's envelope so removing a bad import immediately + stops influencing the matcher template. Returns False when neither list carries + that id. """ - refs = cast(list[CycleDict], self._data.get("reference_cycles", [])) - cycle = next((c for c in refs if c.get("id") == cycle_id), None) + cycle: CycleDict | None = None + for key in ("reference_cycles", "backfill_cycles"): + items = cast(list[CycleDict], self._data.get(key, [])) + cycle = next((c for c in items if c.get("id") == cycle_id), None) + if cycle is not None: + self._data[key] = [c for c in items if c.get("id") != cycle_id] + break if cycle is None: return False profile_name = cycle.get("profile_name") - self._data["reference_cycles"] = [c for c in refs if c.get("id") != cycle_id] # Clear any profile that sampled this now-deleted reference cycle, mirroring # the real-cycle path in delete_cycle, so no sample id is left dangling. for _p_name, p_data in self.get_profiles().items(): @@ -6698,8 +7072,7 @@ class ProfileStore: # async_repair_profile_samples stealing an unlabeled real cycle into it. remaining = any( c.get("profile_name") == profile_name - for c in list(self._data.get("past_cycles", [])) - + list(self._data.get("reference_cycles", [])) + for c in self.iter_stored_cycles() ) if remaining: await self.async_rebuild_envelope(profile_name) @@ -6714,16 +7087,9 @@ class ProfileStore: Returns an empty list if the cycle is not found or has no power data. """ - cycle = next( - (c for c in self.get_past_cycles() if c.get("id") == cycle_id), None - ) - if cycle is None: - # Imported store recordings live in a separate list; the panel opens - # them from the same Cycles table, so look them up here too. - cycle = next( - (c for c in self.get_reference_cycles() if c.get("id") == cycle_id), - None, - ) + # Imported and backfilled cycles live in their own lists; the panel opens them + # from the same Cycles table, so all three are searched. + cycle, _origin = self.find_stored_cycle(cycle_id) if cycle is None: return [] return decompress_power_data(cycle) diff --git a/custom_components/ha_washdata/progress.py b/custom_components/ha_washdata/progress.py index c21726d2..1132438d 100644 --- a/custom_components/ha_washdata/progress.py +++ b/custom_components/ha_washdata/progress.py @@ -227,12 +227,21 @@ def estimate_phase_progress( 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) @@ -314,6 +323,56 @@ def estimate_phase_progress( logger.debug("Insufficient data in current window for phase estimation") return None + # Two window shapes carry no information the scan can align on, and both + # mislocate badly when it tries anyway (#386): the correlation term is dead or + # is noise on the plug's last reported digit, the MAE/bounds terms then score + # every similar stretch of the envelope alike, and the only term left that + # knows the clock is the time penalty - which is capped at 40%. + # * BELOW THE OFF FLOOR. The appliance is not drawing anything the detector + # would call active, so there is nothing to locate. Catches a quiet tail + # whatever jitter the plug puts on its last digit. + # * DEAD FLAT. No shape at any power level, e.g. a steady plateau reported + # by a plug that re-reports unchanged values. Replay says these mislocate + # too (a late offset wins on level alone), and the cost of declining is + # within noise, so a plateau defers to the clock as well. + # Declining hands the caller its linear (clock) estimate, which is what ran + # before phase-aware progress existed. + quiet_w = float(quiet_threshold_w or 0.0) + window_max = float(np.max(current_window_values)) + window_flat = float(np.std(current_window_values)) == 0.0 + if window_max <= quiet_w or window_flat: + logger.debug( + "Uninformative current window (max=%.2fW, off-floor=%.2fW, flat=%s), " + "skipping phase estimation", + window_max, + quiet_w, + window_flat, + ) + return None + + # The envelope's trailing all-zero stretch is an artefact of averaging cycles + # that ended at different times (real dishwasher envelopes carry 30+ min of + # it). It is a perfect fit for any quiet window of any length, while the true + # region scores 0 on bounds because the drain pump smears across cycles and + # keeps the envelope's own min above zero - so a near-zero reading is drawn to + # the pad and progress collapses to the 99% clamp (#386). Offsets inside the + # pad are not candidate alignments: the scan stops at the last offset where + # the envelope is still active. + active_offsets = np.flatnonzero(envelope_arrays["max"] > 0.0) + active_len = int(active_offsets[-1]) + 1 if active_offsets.size else 0 + # A malformed envelope can carry bands of differing length; never index past + # the shortest of the three the scan slices in lockstep. + active_len = min( + active_len, + len(envelope_arrays["avg"]), + len(envelope_arrays["min"]), + len(envelope_arrays["max"]), + ) + scan_n = min(len(time_grid) - 1, active_len) + if scan_n <= 0: + logger.debug("Envelope has no active offsets, cannot estimate phase") + return None + # Slide the current window across the whole envelope grid and keep the # best-scoring alignment. The scalar form below is the reference; the # vectorized form computes the identical per-offset score in bulk (the grid is @@ -325,12 +384,10 @@ def estimate_phase_progress( b_score = -1.0 b_in_bounds = False b_tws: float | None = None - for i in range(len(time_grid) - 1): + for i in range(scan_n): time_window_start = float(time_grid[i]) envelope_window_start = i - envelope_window_end = min( - i + len(current_window_values), len(envelope_arrays["avg"]) - ) + envelope_window_end = min(i + len(current_window_values), active_len) if envelope_window_end <= envelope_window_start: continue avg_window = envelope_arrays["avg"][envelope_window_start:envelope_window_end] @@ -383,8 +440,8 @@ def estimate_phase_progress( avg_arr = envelope_arrays["avg"] min_arr = envelope_arrays["min"] max_arr = envelope_arrays["max"] - length = len(avg_arr) - n = len(time_grid) - 1 + length = active_len + n = scan_n if n <= 0 or w == 0: return _scan_scalar() diff --git a/custom_components/ha_washdata/store.py b/custom_components/ha_washdata/store.py index 5e537eb3..3e6a4c0d 100644 --- a/custom_components/ha_washdata/store.py +++ b/custom_components/ha_washdata/store.py @@ -30,7 +30,7 @@ from homeassistant.core import HomeAssistant from . import store_account from .const import QC_EDITED, QC_MANUAL, QC_RECORDING -from .store_client import StoreClient, device_id, profile_id, trace_hash +from .store_client import device_id, get_client, profile_id, trace_hash _LOGGER = logging.getLogger(__name__) @@ -158,7 +158,10 @@ class StoreBridge: def __init__(self, hass: Any, profile_store: Any) -> None: self._hass = hass self._ps = profile_store - self._client = StoreClient(hass) + # One client for the whole install, not one per appliance: the catalog it reads is + # public and device-agnostic, so a per-bridge client made an N-appliance install + # issue N cold-cache copies of the same brand/device queries. See get_client. + self._client = get_client(hass) def _fire_download_telemetry(self, cycle_ids: list[str]) -> None: """Fire best-effort download/adoption telemetry as a detached background task. @@ -182,7 +185,12 @@ class StoreBridge: 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. - if not await self._client.ensure_id_token(refresh_token): + # 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: return {"error": "token_invalid"} await store_account.async_set_account(self._hass, {"refresh_token": refresh_token, "uid": uid, "name": name}) return store_account.get_identity(self._hass) @@ -204,6 +212,22 @@ class StoreBridge: brand, appliance_type, model_query=model_query, include_pending=include_pending, ) + async def catalog_entry(self, brand: str, model: str, appliance_type: str) -> dict[str, Any]: + """The catalog identity of one appliance -- its brand + device documents, resolved + by deterministic id, for the settings form's status badges. + + The badges used to be a by-product of the pickers' full brand/device *lists*, which + meant simply opening the Settings tab downloaded the catalog. Two point reads answer + the same question, so the lists are now only fetched when the user actually opens a + picker. Maps the HA device type to the catalog type first. + """ + return await self._client.catalog_entry(brand, model, store_appliance_type(appliance_type)) + + def refresh_catalog(self) -> dict[str, Any]: + """Drop the cached catalog so the next read is fresh (panel "refresh" action).""" + self._client.refresh_catalog() + return {"ok": True} + async def get_profiles(self, device_id: str) -> list[dict[str, Any]]: return await self._client.get_profiles(device_id) @@ -224,14 +248,16 @@ class StoreBridge: acct = store_account.get_account(self._hass) if not acct.get("refresh_token"): return {"error": "not_connected"} - res = await self._client.confirm_device(acct["refresh_token"], acct.get("uid", ""), device_id) + async with self._client.write_lock: # writes _last_error; see connect() + res = await self._client.confirm_device(acct["refresh_token"], acct.get("uid", ""), device_id) return res if res else {"error": "confirm_failed"} async def rate_device(self, device_id: str, rating: int) -> dict[str, Any]: acct = store_account.get_account(self._hass) if not acct.get("refresh_token"): return {"error": "not_connected"} - ok = await self._client.rate_device(acct["refresh_token"], acct.get("uid", ""), device_id, rating) + async with self._client.write_lock: # writes _last_error; see connect() + ok = await self._client.rate_device(acct["refresh_token"], acct.get("uid", ""), device_id, rating) return {"ok": True} if ok else {"error": "rate_failed"} # ── import / share (target this device's ProfileStore) ─────────────────────── @@ -293,12 +319,15 @@ class StoreBridge: downsampled = await self._hass.async_add_executor_job( _downsample, [[p[0], p[1]] for p in pts] ) - new_id = await self._client.upload_reference_cycle( - acct["refresh_token"], acct.get("uid", ""), acct.get("name"), - meta, downsampled, stats, derive_qc(cyc), - ) - if not new_id: - return {"error": "upload_failed", "detail": self._client.last_error()} + # The client is shared install-wide, and last_error() is a single slot, so the + # 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"), + meta, downsampled, stats, derive_qc(cyc), + ) + if not new_id: + return {"error": "upload_failed", "detail": self._client.last_error()} return {"store_cycle_id": new_id} async def share_device( @@ -366,14 +395,15 @@ class StoreBridge: # to the allow-list by the WS layer, which owns entry.options). if isinstance(settings, dict) and settings: device_meta["settings"] = dict(settings) - res = await self._client.upload_device_bundle( - acct["refresh_token"], acct.get("uid", ""), acct.get("name"), 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. - # Only a pre-flight gate short-circuits with an {"error": ...} marker above. - if not res.get("ok") and not res.get("cycle_ids"): - res = {**res, "detail": self._client.last_error()} + 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, + ) + # 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. + # Only a pre-flight gate short-circuits with an {"error": ...} marker above. + if not res.get("ok") and not res.get("cycle_ids"): + res = {**res, "detail": self._client.last_error()} return res async def download_device(self, device_id_: str, device_type: str = "") -> dict[str, Any]: diff --git a/custom_components/ha_washdata/store_client.py b/custom_components/ha_washdata/store_client.py index a440d412..23eeea21 100644 --- a/custom_components/ha_washdata/store_client.py +++ b/custom_components/ha_washdata/store_client.py @@ -34,12 +34,14 @@ import time import unicodedata from collections.abc import Callable from typing import Any +from urllib.parse import quote from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from .const import ( + DOMAIN, SHAREABLE_SETTING_KEYS, STORE_API_KEY, STORE_PROJECT_ID, @@ -53,12 +55,72 @@ _APPLIANCE_TYPES = {"washer", "dryer", "dishwasher", "washer_dryer"} # Max concurrent per-cycle rating aggregations when listing a profile's cycles. _RATING_FANOUT_LIMIT = 8 -# Max profiles hydrated concurrently when downloading a whole-device bundle. Each -# profile's get_cycles adds its own (rating) fan-out, so the effective ceiling is -# roughly this x (1 + _RATING_FANOUT_LIMIT); kept small to stay well under the +# 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 # store's rate limiter on devices that carry many profiles. _BUNDLE_HYDRATE_LIMIT = 4 +# Upper bound for a Firestore "starts with" range on a string field: U+F8FF sits above +# every character that realistically appears in a brand name, so [p, p + _PREFIX_MAX] +# selects exactly the values beginning with p. Written as an escape on purpose -- the +# literal glyph is invisible in an editor and trivially lost to a copy/paste. +_PREFIX_MAX = "\uf8ff" + +# Field projections for the catalog list queries (Firestore `select`). A projection does +# NOT reduce the billed document count -- Firestore bills per document read regardless -- +# but it does cut the wire payload substantially, which matters because these lists are +# relayed verbatim to the panel over the HA WebSocket. Measured on the live catalog: the +# brand list drops 64.7 KB -> 33 KB, and a single brand's device list 54.4 KB -> 17 KB +# (device docs carry a ~25-key `settings` map that no list view reads; the whole-device +# bundle fetches the full doc via get_device instead). +# +# These are ALLOW-lists: a field missing here is absent from the decoded row, so keep +# them in sync with what the panel + StoreBridge actually consume. `id` is derived from +# the document name and is always present. +_BRAND_LIST_FIELDS = ("brand", "brand_lc", "status") +_DEVICE_LIST_FIELDS = ( + "brand", "brand_lc", "model", "model_lc", "applianceType", "status", + "confirmCount", "favoriteCount", "manualUrl", "createdByName", + # Content markers for the Store browse list. Only ~30% of catalog entries carry any + # shared program (168 of 564 measured), so "does this entry actually have anything?" + # is the most useful thing a row can say. NB these are contributor-maintained + # counters and are known to under-report where a best-effort increment was denied, + # so the UI shows a chip when the count is positive and says nothing when it is + # zero/absent -- never "this is empty", which a stale zero would make a lie. + "profileCount", "cycleCount", +) + +# Domain-scoped key for the process-wide shared client (see get_client). +_CLIENT_KEY = f"{DOMAIN}_store_client" + + +def get_client(hass: HomeAssistant) -> "StoreClient": + """The one shared StoreClient for this HA install. + + The community catalog is public, device-agnostic data, but a StoreBridge is created + per config entry -- so a per-bridge client made an N-appliance install pay N times + over for the exact same brand/device lists, each with its own cold cache. Hanging a + single instance off ``hass.data`` collapses that to one, and (unlike a bridge) it is + deliberately NOT removed on unload so the read cache also survives an entry reload. + Mirrors the global-state pattern in ``store_account``. + """ + client = hass.data.get(_CLIENT_KEY) + if not isinstance(client, StoreClient): + client = StoreClient(hass) + hass.data[_CLIENT_KEY] = client + return client + + +def _seg(value: Any) -> str: + """Percent-encode one REST path segment. + + Store document ids are raw user text: ``brand_id`` is just ``brand.lower()`` with no + normalisation, so the live catalog really does contain ids like ``aeg lavamat``, + ``fisher & paykel`` and ``ok.``. Interpolating those into a URL unencoded produces a + malformed request rather than a 404, so every id-in-path must go through this. + """ + return quote(str(value if value is not None else ""), safe="") + # ── deterministic ids (must match the store's lib/ids.js exactly) ────────────── @@ -173,10 +235,17 @@ class StoreClient: # store's Firebase free tier (50k document reads/day) that brand-list query alone was # the single largest read source. Cache these reads in memory for a short TTL so a # burst of panel opens collapses to at most one Firestore query per key per window. - # The client is a long-lived singleton (one per manager) so the cache survives panel - # reloads. Writes that add brands/devices invalidate it (see _commit_create) so a - # freshly-contributed entry still appears immediately for the user who added it. - _CATALOG_CACHE_TTL_S = 900.0 # brands + device searches (15 min) + # The client is a process-wide singleton (see get_client) so the cache survives panel + # reloads AND config-entry reloads, and is shared by every appliance. Writes that add + # brands/devices invalidate it (see _commit_create) so a freshly-contributed entry + # still appears immediately for the user who added it. + # The catalog changes a few times a week (a contributor adds a brand, an admin + # approves one), so a 15-minute window was re-reading a near-static ~650-document + # catalog dozens of times a day. An hour keeps a browsing session on one query while + # staying fresh enough to notice someone else's contribution, and it is not the only + # freshness path: a local create invalidates immediately (_commit_create) and + # refresh_catalog() force-drops everything on demand. + _CATALOG_CACHE_TTL_S = 3600.0 # brands + device searches (1 h) _CONFIG_CACHE_TTL_S = 3600.0 # config/site (maintenance flag + confirm threshold) # Hard cap on distinct cached read keys. Device searches key on the brand term, so a # long-lived session that issues many distinct searches would otherwise accumulate an @@ -212,10 +281,30 @@ class StoreClient: # invalidation is not joined by a post-invalidation caller (which would receive a # pre-write snapshot); such a caller starts a fresh query instead. self._inflight: dict[str, "tuple[int, asyncio.Future[list[dict[str, Any]]]]"] = {} + # Guards the write-then-read-_last_error sequence; see the write_lock property. + self._write_lock = asyncio.Lock() def last_error(self) -> str | None: return self._last_error + @property + def write_lock(self) -> asyncio.Lock: + """Serialises a write with the ``last_error()`` read that interprets it. + + ``_last_error`` is a single slot cleared at the start of each upload, and this + client is shared by every appliance in the install (see get_client), so two + concurrent shares could otherwise interleave clear/set and make one device report + the other's failure reason. Holding this across the whole upload-then-read + sequence keeps that attribution correct; it also naturally rate-limits concurrent + uploads, which the store's free tier appreciates. + + Every caller that can reach a ``_last_error`` writer must hold it, not just the + ones that read it back: ``ensure_id_token`` also writes the slot, so connect / + confirm / rate take it too (see ``store.WashDataStore``). It is NOT reentrant -- + take it around a client call, never inside one. + """ + return self._write_lock + def _sess(self) -> Any: if self._session is None: self._session = async_get_clientsession(self._hass) @@ -245,11 +334,26 @@ class StoreClient: def _invalidate_catalog_cache(self) -> None: """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).""" + a just-contributed or newly-approved entry appears immediately, not after the TTL). + + Covers both the list queries (``brands:``/``devices:``) and 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. + """ self._cache_gen += 1 - for key in [k for k in self._read_cache if k.startswith(("brands:", "devices:"))]: + for key in [ + k for k in self._read_cache + if k.startswith(("brands:", "devices:", "brand:", "device:")) + ]: self._read_cache.pop(key, None) + def refresh_catalog(self) -> None: + """Force the next catalog read to hit Firestore (public wrapper for the panel's + "refresh catalog" action). With a 1-hour TTL a user who is told by a friend that a + brand was just approved needs a way to see it without waiting out the window.""" + self._invalidate_catalog_cache() + # ── auth ────────────────────────────────────────────────────────────────── async def ensure_id_token(self, refresh_token: str) -> str | None: @@ -368,6 +472,37 @@ class StoreClient: }} return self._field_filter("status", "EQUAL", "approved") + @staticmethod + def _approved_only(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Narrow a pending-inclusive result to the approved rows, in memory.""" + return [r for r in rows if str(r.get("status") or "") == "approved"] + + def _serve_from_superset( + self, superset_key: str, include_pending: bool, page_size: int + ) -> list[dict[str, Any]] | None: + """A warm pending-inclusive cache entry already contains every approved row, so an + approved-only caller can be served from it instead of issuing a second, narrower + query for a strict subset of rows we are already holding. Returns None when the + request wants pending rows anyway, or when the superset is not cached. + + This is why ``include_pending`` stays in the cache key rather than being collapsed + into one always-pending-inclusive fetch: upgrading an approved-only request to the + superset query would *raise* its read count (on the live catalog, approved devices + of one appliance type number ~6 against ~140 pending-inclusive). Sharing downwards + is free; sharing upwards is not. + """ + if include_pending: + return None + cached = self._cache_get(superset_key) + if cached is None: + return None + # A result capped at page_size may be missing approved rows past the cap, so it + # cannot answer a narrower question. Mirrors the same guard in + # _cached_brand_superset. + if len(cached) >= page_size: + return None + return self._approved_only(cached) + 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, @@ -378,7 +513,18 @@ class StoreClient: # was fetched, so caching a truncated list would hide models past the limit for the # whole TTL. A query reads only the docs that exist, so the high ceiling adds no reads # for today's catalog while staying complete as it grows. - key = f"devices:{(brand or '').lower()}:{appliance_type or ''}:{int(include_pending)}:{page_size}" + base = f"devices:{(brand or '').lower()}:{appliance_type or ''}" + key = f"{base}:{int(include_pending)}:{page_size}" + + def _finish(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + if model_query: + p = model_query.lower() + rows = [r for r in rows if str(r.get("model_lc", "")).startswith(p)] + return rows + + shared = self._serve_from_superset(f"{base}:1:{page_size}", include_pending, page_size) + if shared is not None: + return _finish(shared) def _build() -> dict[str, Any]: filters = [self._status_filter(include_pending)] @@ -388,49 +534,149 @@ class StoreClient: filters.append(self._field_filter("brand_lc", "EQUAL", brand.lower())) return { "from": [{"collectionId": "devices"}], + "select": {"fields": [{"fieldPath": f} for f in _DEVICE_LIST_FIELDS]}, "where": self._where(filters), "orderBy": [{"field": {"fieldPath": "favoriteCount"}, "direction": "DESCENDING"}], "limit": page_size, } - rows = await self._cached_catalog_query(key, _build) - if model_query: - p = model_query.lower() - rows = [r for r in rows if str(r.get("model_lc", "")).startswith(p)] - return rows + return _finish(await self._cached_catalog_query(key, _build)) + + def _cached_brand_superset(self, q: str, include_pending: bool, page_size: int) -> list[dict[str, Any]] | None: + """Find a cached brand result that provably contains every match for prefix ``q``. + + Checked in order of breadth: the pending-inclusive full list, the same-status full + list, then the longest cached prefix that is itself a prefix of ``q`` (a result set + for "bo" contains every brand starting with "bos"). A cached entry is only reusable + when it was not truncated at ``page_size`` -- a capped result may be missing rows + that a narrower query would have returned. + """ + candidates = [f"brands:1:{page_size}"] + if not include_pending: + candidates.append(f"brands:0:{page_size}") + # Longest usable prefix first: fewer rows to filter, same answer. + candidates += [ + f"brands:p:{int(include_pending)}:{q[:n]}:{page_size}" + for n in range(len(q), 0, -1) + ] + for cand in candidates: + cached = self._cache_get(cand) + if cached is not None and len(cached) < page_size: + return cached if include_pending else self._approved_only(cached) + return None + + async def list_brands( + self, q: str | None = None, *, include_pending: bool = True, page_size: int = 500, + ) -> list[dict[str, Any]]: + """Brands for the picker. With ``q``, resolved server-side as a prefix range query + unless a broad-enough cached result can answer it in memory. + + Downloading all ~84 brand documents to render a filtered dropdown was the single + largest read source in the store. A ``brand_lc`` range query rides the existing + (status, brand_lc) composite index and reads only the matching documents -- a + 2-character prefix costs single digits against 84 -- and because a prefix result is + cached, every subsequent keystroke narrowing that prefix is answered from memory. + The unfiltered list (no ``q``) still fetches everything: it is the "show me the + dropdown" case, and truncating it would make brands past the cap unfindable. + """ + prefix = (q or "").strip().lower() + select = {"fields": [{"fieldPath": f} for f in _BRAND_LIST_FIELDS]} + order = [{"field": {"fieldPath": "brand_lc"}, "direction": "ASCENDING"}] + + if prefix: + cached = self._cached_brand_superset(prefix, include_pending, page_size) + if cached is not None: + return [r for r in cached if str(r.get("brand_lc", "")).startswith(prefix)] + key = f"brands:p:{int(include_pending)}:{prefix}:{page_size}" + rows = await self._cached_catalog_query(key, lambda: { + "from": [{"collectionId": "brands"}], + "select": select, + "where": self._where([ + self._status_filter(include_pending), + self._field_filter("brand_lc", "GREATER_THAN_OR_EQUAL", prefix), + self._field_filter("brand_lc", "LESS_THAN_OR_EQUAL", prefix + _PREFIX_MAX), + ]), + "orderBy": order, + "limit": page_size, + }) + # The range is inclusive of exactly the prefix matches, so no further filter is + # needed -- but stay defensive in case a cached entry pre-dates this path. + return [r for r in rows if str(r.get("brand_lc", "")).startswith(prefix)] - async def list_brands(self, q: str | None = None, *, include_pending: bool = True, page_size: int = 500) -> list[dict[str, Any]]: - # Cache the unfiltered brand list (the q prefix filter is applied in memory below), - # so one Firestore query serves every search prefix for this key. The limit is a - # full-catalog ceiling (not a UI page size): the in-memory prefix filter can only - # match what was fetched, so a low cap would make brands past it unsearchable for - # the whole cache TTL. The brand collection is small with tiny docs, and a query - # only reads the docs that exist, so this ceiling does not add reads for today's - # catalog while staying correct as it grows. key = f"brands:{int(include_pending)}:{page_size}" - rows = await self._cached_catalog_query(key, lambda: { + shared = self._serve_from_superset(f"brands:1:{page_size}", include_pending, page_size) + if shared is not None: + return shared + return await self._cached_catalog_query(key, lambda: { "from": [{"collectionId": "brands"}], + "select": select, "where": self._where([self._status_filter(include_pending)]), - "orderBy": [{"field": {"fieldPath": "brand_lc"}, "direction": "ASCENDING"}], + "orderBy": order, "limit": page_size, }) - if q: - p = q.lower() - rows = [r for r in rows if str(r.get("brand_lc", "")).startswith(p)] - return rows - async def get_device(self, device_id: str) -> dict[str, Any] | None: + async def _get_doc(self, path: str, *, cache_key: str | None = None) -> dict[str, Any] | None: + """Fetch one document by path. ``None`` for missing/forbidden or on any failure. + + A point read costs exactly one document, so resolving a known id this way is + vastly cheaper than the list query that used to be used to find it. Only + successful lookups are cached (a miss may simply mean "not contributed yet", + which flips as soon as the user contributes it). + """ + if cache_key is not None: + cached = self._cache_get(cache_key) + if cached is not None: + return cached + # 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. + gen = self._cache_gen try: - async with self._sess().get(f"{self._base}/devices/{device_id}", timeout=15) as resp: - if resp.status in (403, 404): - return None + 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 doc = await resp.json() except Exception as exc: # noqa: BLE001 - _LOGGER.debug("Store get_device error: %s", exc) + _LOGGER.debug("Store get %s error: %s", path, exc) return None - return _decode_doc(doc) + 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 + + 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}") + + async def get_brand(self, brand: str) -> dict[str, Any] | None: + """The brand document for a brand name (doc id is the lowercased name).""" + b_id = brand_id(brand) + if not b_id: + return None + return await self._get_doc(f"brands/{_seg(b_id)}", cache_key=f"brand:{b_id}") + + async def catalog_entry( + self, brand: str, model: str, appliance_type: str, + ) -> dict[str, Any]: + """Resolve just the catalog *identity* of one appliance: its brand and device + documents, by deterministic id. + + This backs the brand/model status badges in the settings form, which previously + forced a download of the entire brand list plus that brand's whole device list + (measured: 128 documents, 119 KB) purely to locate two rows the caller could + already name. Two point reads answer it exactly, and they are issued + concurrently so the round-trip cost is one request deep, not two. + """ + async def _brand() -> dict[str, Any] | None: + return await self.get_brand(brand) if brand else None + + async def _device() -> dict[str, Any] | None: + return await self.get_device(dev_id) if dev_id else None + + dev_id = device_id(appliance_type, brand, model) if (brand and model and appliance_type) else "" + brand_doc, device_doc = await asyncio.gather(_brand(), _device()) + return {"device_id": dev_id, "brand": brand_doc, "device": device_doc} async def get_config(self) -> dict[str, Any]: """Public config/site (maintenance flag + confirmThreshold). {} on failure. @@ -487,11 +733,11 @@ class StoreClient: 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/{device_id}") + 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/{cycle_id}") + return await self._rating_agg(f"cycles/{_seg(cycle_id)}") async def get_profiles(self, dev_id: str, include_pending: bool = False, page_size: int = 100) -> list[dict[str, Any]]: sq = { @@ -515,17 +761,21 @@ class StoreClient: async def get_device_bundle(self, dev_id: str, include_pending: bool = True) -> dict[str, Any]: """Whole-device package for download: the device's shareable ``settings`` (from the device doc) + its profiles, each with its reference cycles nested under - ``cycles`` (hydrated + rating-summarised by get_cycles). One device GET + one - profiles query + one cycles query per profile. Never raises. + ``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. """ device = await self.get_device(dev_id) or {} settings = device.get("settings") if isinstance(device.get("settings"), dict) else {} profiles = await self.get_profiles(dev_id, include_pending=include_pending) - # Bound the per-profile fan-out: each get_cycles issues one query plus a - # rating fan-out, so 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. + # 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]]: @@ -533,7 +783,7 @@ class StoreClient: if not pid: return [] async with sem: - return await self.get_cycles(pid, include_pending=include_pending) + return await self.get_cycles(pid, include_pending=include_pending, include_ratings=False) # Fetch profiles' cycles concurrently (bounded) rather than one at a time. cycle_lists = await asyncio.gather(*(_cycles_for(p) for p in profiles)) @@ -542,14 +792,17 @@ class StoreClient: return {"device_id": dev_id, "settings": settings, "profiles": profiles} async def get_cycles( - self, prof_id: str, include_pending: bool = True, page_size: int = 50 + 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. ``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. + 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). """ sq = { "from": [{"collectionId": "cycles"}], @@ -561,6 +814,8 @@ class StoreClient: "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 @@ -578,18 +833,10 @@ class StoreClient: return cycles async def get_cycle(self, cycle_id: str) -> dict[str, Any] | None: - try: - async with self._sess().get(f"{self._base}/cycles/{cycle_id}", timeout=15) as resp: - if resp.status in (403, 404): - return None - if resp.status != 200: - _LOGGER.debug("Store get_cycle HTTP %s", resp.status) - return None - doc = await resp.json() - except Exception as exc: # noqa: BLE001 - _LOGGER.debug("Store get_cycle error: %s", exc) - return None - return self._with_decoded_trace(_decode_doc(doc)) + # Not cached: cycle documents carry the full trace, so they are the one read here + # worth fetching fresh rather than pinning in memory. + doc = await self._get_doc(f"cycles/{_seg(cycle_id)}") + return None if doc is None else self._with_decoded_trace(doc) @staticmethod def _with_decoded_trace(cycle: dict[str, Any]) -> dict[str, Any]: @@ -901,6 +1148,12 @@ class StoreClient: if not ok and "ALREADY_EXISTS" not in body and "FAILED_PRECONDITION" not in body: _LOGGER.warning("Store confirm_device failed: %s", body[:200]) return None + # This write just changed the very document the next line reads, and get_device + # is a CACHED point read that the settings badge has almost certainly already + # warmed. Without dropping it here the read-back returns the pre-increment doc, + # so the count shown is one behind and `count >= threshold` never becomes true -- + # community auto-promotion would silently never fire. + self._invalidate_catalog_cache() dev = await self.get_device(device_id) or {} count = int(dev.get("confirmCount") or 0) status = dev.get("status") diff --git a/custom_components/ha_washdata/suggestion_engine.py b/custom_components/ha_washdata/suggestion_engine.py index 14c8579c..97676e53 100644 --- a/custom_components/ha_washdata/suggestion_engine.py +++ b/custom_components/ha_washdata/suggestion_engine.py @@ -21,6 +21,7 @@ from __future__ import annotations import copy import logging import math +from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from datetime import datetime from typing import Any, TYPE_CHECKING, cast @@ -484,9 +485,23 @@ def reconcile_suggestions( """True if at least one key is already in the suggestion map (original or cascade).""" return any(isinstance(out.get(k), dict) for k in keys) - def adjust(key: str, new_value: float, why: str) -> None: - """Set a suggestion value; cascade-creates an entry when the key is absent.""" - rounded = round(new_value, 2) + def adjust(key: str, new_value: float, why: str, round_dir: str = "nearest") -> None: + """Set a suggestion value; cascade-creates an entry when the key is absent. + + ``round_dir`` controls the 2-dp rounding so a strict inequality survives it: + ``"up"`` (ceil) when *raising* a value to clear a lower bound, ``"down"`` + (floor) when *lowering* one to a ceiling. Nearest-rounding could otherwise land + back on the value that violated the constraint (e.g. raising auto to 0.901 would + round to 0.90 and stay below a match of 0.901). Default ``"nearest"`` keeps every + non-ladder rule byte-identical. + """ + if round_dir == "up": + # Decimal(str(x)) so binary FP can't nudge e.g. 0.07 to 0.0700001 and ceil to 0.08. + rounded = float(Decimal(str(new_value)).quantize(Decimal("0.01"), rounding=ROUND_CEILING)) + elif round_dir == "down": + rounded = float(Decimal(str(new_value)).quantize(Decimal("0.01"), rounding=ROUND_FLOOR)) + else: + rounded = round(new_value, 2) entry = out.get(key) if isinstance(entry, dict): if _num(entry.get("value")) == rounded: @@ -564,17 +579,43 @@ def reconcile_suggestions( if sampling is not None and start_dur is not None and start_dur < sampling and in_out(CONF_SAMPLING_INTERVAL, CONF_START_DURATION_THRESHOLD): adjust(CONF_START_DURATION_THRESHOLD, sampling, "the sampling interval") - # ── Rule 5: learning_confidence <= match_threshold <= auto_label ─────── - # Reconcile top-down (match<=auto first) so a lower fix cannot re-break - # the ordering already set above. + # ── Rule 5: match_threshold <= learning_confidence, match <= auto_label ─ + # The confidence ladder is unmatch < match < learning < auto_label (#396): + # the verify-band floor (learning) sits AT OR ABOVE the live match-trust + # gate (match), which itself sits at or below the auto-label ceiling. + # Reconcile match<=auto first so a later fix cannot re-break the ordering. match_thr = eff(CONF_PROFILE_MATCH_THRESHOLD) auto = eff(CONF_AUTO_LABEL_CONFIDENCE) if match_thr is not None and auto is not None and match_thr > auto and in_out(CONF_PROFILE_MATCH_THRESHOLD, CONF_AUTO_LABEL_CONFIDENCE): - adjust(CONF_PROFILE_MATCH_THRESHOLD, auto, "the auto-label confidence") - match_thr = eff(CONF_PROFILE_MATCH_THRESHOLD) + # Anchor on whichever the engine actually proposed, like every other + # two-sided rule. match_threshold now drives detection (it is + # CycleDetectorConfig.match_confidence_threshold), so when the engine + # deliberately RAISED it, lift the auto-label ceiling to keep it rather + # than silently undoing the raise; only cascade it downward when it was + # not the proposed key. + if is_original(CONF_PROFILE_MATCH_THRESHOLD): + # raise the ceiling to (>=) match: ceil so 2-dp rounding can't drop it back under + adjust(CONF_AUTO_LABEL_CONFIDENCE, match_thr, "the profile match threshold", "up") + auto = eff(CONF_AUTO_LABEL_CONFIDENCE) + else: + # lower match to (<=) auto: floor so it stays at/below the ceiling + adjust(CONF_PROFILE_MATCH_THRESHOLD, auto, "the auto-label confidence", "down") + match_thr = eff(CONF_PROFILE_MATCH_THRESHOLD) learn = eff(CONF_LEARNING_CONFIDENCE) - if learn is not None and match_thr is not None and learn > match_thr and in_out(CONF_LEARNING_CONFIDENCE, CONF_PROFILE_MATCH_THRESHOLD): - adjust(CONF_LEARNING_CONFIDENCE, match_thr, "the profile match threshold") + if learn is not None and match_thr is not None and learn < match_thr and in_out(CONF_LEARNING_CONFIDENCE, CONF_PROFILE_MATCH_THRESHOLD): + # raise learning to (>=) match: ceil + adjust(CONF_LEARNING_CONFIDENCE, match_thr, "the profile match threshold", "up") + # Top of the ladder: learning <= auto. `_add_confidence_suggestions` derives the + # two independently (learning from p05 of manual labels, auto from p15 of + # uncorrected auto-labels), so a device with few high-confidence manual labels can + # yield learning > auto. Cascade-RAISE the auto ceiling to the verify floor (the + # conservative direction — never lower the verify band); keeps the full declared + # ordering intact instead of enforcing only its middle two rungs. + learn = eff(CONF_LEARNING_CONFIDENCE) + auto = eff(CONF_AUTO_LABEL_CONFIDENCE) + if learn is not None and auto is not None and learn > auto and in_out(CONF_LEARNING_CONFIDENCE, CONF_AUTO_LABEL_CONFIDENCE): + # raise the auto ceiling to (>=) learning: ceil + adjust(CONF_AUTO_LABEL_CONFIDENCE, learn, "the learning confidence", "up") # ── Rule 6: profile_unmatch_threshold < profile_match_threshold ──────── unmatch = eff(CONF_PROFILE_UNMATCH_THRESHOLD) diff --git a/custom_components/ha_washdata/translations/bs.json b/custom_components/ha_washdata/translations/bs.json index 52c1a06f..9542f60e 100644 --- a/custom_components/ha_washdata/translations/bs.json +++ b/custom_components/ha_washdata/translations/bs.json @@ -231,7 +231,7 @@ }, "trim_cycle": { "name": "Podrezivanje ciklusa", - "description": "Skratite podatke o snazi ​​prošlog ciklusa na određeni vremenski prozor.", + "description": "Skratite podatke o snazi prošlog ciklusa na određeni vremenski prozor.", "fields": { "device_id": { "name": "Uređaj", diff --git a/custom_components/ha_washdata/translations/fi.json b/custom_components/ha_washdata/translations/fi.json index 5465215a..9b8bddd5 100644 --- a/custom_components/ha_washdata/translations/fi.json +++ b/custom_components/ha_washdata/translations/fi.json @@ -205,7 +205,7 @@ }, "notes": { "name": "Huomautuksia", - "description": "Valinnaisia ​​huomautuksia tästä syklistä." + "description": "Valinnaisia huomautuksia tästä syklistä." } } }, diff --git a/custom_components/ha_washdata/translations/hr.json b/custom_components/ha_washdata/translations/hr.json index 5234bed5..22602615 100644 --- a/custom_components/ha_washdata/translations/hr.json +++ b/custom_components/ha_washdata/translations/hr.json @@ -231,7 +231,7 @@ }, "trim_cycle": { "name": "Ciklus podrezivanja", - "description": "Skratite podatke o snazi ​​prošlog ciklusa na određeni vremenski prozor.", + "description": "Skratite podatke o snazi prošlog ciklusa na određeni vremenski prozor.", "fields": { "device_id": { "name": "Uređaj", @@ -414,7 +414,7 @@ "message": "Ciklus nije pronađen ili nema podataka o snazi." }, "trim_failed_empty_window": { - "message": "Podrezivanje nije uspjelo - ciklus nije pronađen, nema podataka o snazi ​​ili je rezultirajući prozor prazan." + "message": "Podrezivanje nije uspjelo - ciklus nije pronađen, nema podataka o snazi ili je rezultirajući prozor prazan." }, "trim_invalid_range": { "message": "trim_end_s mora biti veći od trim_start_s." diff --git a/custom_components/ha_washdata/translations/panel/bg.json b/custom_components/ha_washdata/translations/panel/bg.json index a055c255..03cb5109 100644 --- a/custom_components/ha_washdata/translations/panel/bg.json +++ b/custom_components/ha_washdata/translations/panel/bg.json @@ -78,9 +78,12 @@ "awaiting": "Изчаква одобрение", "imported_tip": "Импортирано от магазина на общността. Използва се само за съпоставяне, не се брои в статистиката.", "not_importable": "неприложимо", - "exists": "съществува" + "exists": "съществува", + "backfilled_tip": "Открито в импортирана история на мощността. Влияе само на съпоставянето на програми, не се брои в статистиката." }, "btn": { + "set_brand_model": "Задаване на марка и модел", + "refresh_catalog": "Опресняване на каталога", "add_device": "+ Добавяне на устройство", "add_device_tip": "Добавете друго устройство WashData", "add_maintenance": "Добавяне на събитие за поддръжка", @@ -90,7 +93,7 @@ "apply_label": "Прилагане на етикет", "apply_set_b": "Прилагане на набор B", "apply_split": "Приложете Разделяне", - "apply_trim": "Нанесете Trim", + "apply_trim": "Приложете изрязването", "auto_detect_split": "Автоматично откриване", "auto_label_cycles": "Цикли на автоматично етикетиране", "auto_label_cycles_tip": "Автоматично присвоява имена на профили на немаркирани цикли, чиято степен на достоверност на съвпадение надвишава прага", @@ -211,8 +214,8 @@ "stop": "Спри", "submit_correction": "Изпратете корекция", "train_now": "Тренирайте сега", - "trim": "Подстригване", - "trim_split": "Подстригване / Разделяне", + "trim": "Изрязване", + "trim_split": "Изрязване / Разделяне", "undo": "Отмяна", "use": "Използвайте", "wipe_all": "Изтриване на всички данни", @@ -234,14 +237,19 @@ "download_device": "Изтегли настройките на устройството", "share_device": "Сподели настройките на устройството", "share_device_tip": "Споделете програмите и настройките на това устройство с общността", - "share_n": "Сподели {n} програм{s}", + "share_n": "Споделяне на цикли ({n})", "export_selected": "Експорт (избор на данни)", "export_all": "Бърз експорт на всичко", "import_raw": "Разширено: замяна на всичко от JSON", "download_export": "Изтегляне на експорта", "analyze_import": "Анализ на файл", "import_selected": "Импортиране на избраното", - "back": "Назад" + "back": "Назад", + "import_power_history": "Импортиране на историята на мощността", + "hist_read_recorder": "Прочитане от Home Assistant", + "hist_scan": "Търсене на цикли", + "hist_import_n": "Импортиране на цикли ({n})", + "hist_goto_cycles": "Покажи циклите" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "Трябва да е под Макс. Мощност Против Бръчки ({max} Вт)" }, "attn_sub": "Поправете конфликтите преди запазване", - "attn_title": "{n} конфликт{s} в настройките", - "settings_banner": "{n} конфликт{s} в настройките – проверете маркираните раздели и поправете преди запазване.", + "attn_title": "Конфликти в настройките: {n}", + "settings_banner": "Конфликти в настройките: {n}. Проверете маркираните раздели и ги поправете преди запазване.", "settings_banner_btn": "Към първия", "confidence": { "auto": "Трябва да е на или над Праг на Съвпадение ({match})", - "learning": "Трябва да е на или под Праг на Съвпадение ({match})", + "learning": "Трябва да е на или над Праг на Съвпадение ({match})", "match_for_auto": "Трябва да е на или под Доверие в Авто-Маркирането ({alc})", - "match_for_learning": "Трябва да е на или над Доверие в Обучението ({lc})" + "match_for_learning": "Трябва да е на или под Доверие в Обучението ({lc})" }, "duration_ratio": { "max": "Трябва да е по-голям от Мин. Съотношение на Продължителност ({min})", @@ -298,7 +306,7 @@ "match": "Трябва да е над Праг на Несъвпадение ({un})", "unmatch": "Трябва да е под Праг на Съвпадение ({match}); иначе потвърденото съвпадение веднага се губи" }, - "cascade_toast": "Автоматично бяха коригирани и {n} параметър{s} за последователност.", + "cascade_toast": "Други настройки, коригирани за последователност: {n}", "suggestion_resolves": "Активирайте чакащото предложение ({val}) по-долу, за да поправите това", "use_fix": "Използвай {val}", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Резултат от симулацията", "pg_across_cycles": "За всички ваши цикли", "community_store": "Магазин на общността", - "online_account": "Магазин на общността и онлайн функции" + "online_account": "Магазин на общността и онлайн функции", + "import_power_history": "Импортиране на историята на мощността" }, "health": { "fair": "Приемливо качество на съвпадение", @@ -364,6 +373,7 @@ "poor": "⚠ Лошо качество на съвпадение" }, "lbl": { + "drag_to_resize": "Плъзнете за преоразмеряване", "actions": "Действия", "activity": "активност", "administrators": "Администратори", @@ -435,7 +445,7 @@ "from": "От", "gap_s": "Празнина (и)", "group_name": "Име на групата", - "head_trim": "Подстригване на главата", + "head_trim": "Изрязване в началото (с)", "health": "здраве", "hide_tabs": "Скриване на раздели за неадминистратори", "in_use": "В употреба", @@ -451,7 +461,7 @@ "metric": "Показател", "mode_existing_profile": "Добавяне към съществуващ профил", "mode_new_profile": "Създаване на нов профил", - "models_fine_tuned": "({count} модел{plural} доуточнени)", + "models_fine_tuned": "(фино настроени модели: {count})", "n_classic_suggestions": "{n} класически", "n_ml_suggestions": "{n} ML", "n_selected": "{n} избрани", @@ -533,7 +543,7 @@ "stage3": "Етап 3 – DTW", "stage4": "Етап 4 – съответствие", "status": "Статус", - "tail_trim": "Подстригване на опашката", + "tail_trim": "Изрязване в края (с)", "timer_auto_pause": "Автоматична пауза", "timer_min": "мин", "timer_msg_placeholder": "Съобщение (по избор, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_resolution": "Конфликти на имена", "conflict_import_copy": "Импортиране като копие", "conflict_keep_mine": "Запази моите", - "conflict_overwrite": "Презапис" + "conflict_overwrite": "Презапис", + "hist_csv_data": "CSV данни", + "hist_from_recorder": "Или го прочетете от Home Assistant", + "days": "дни", + "hist_keep": "Запази този цикъл", + "hist_looks_complete": "завършен", + "peak_power_short": "Пик", + "shape": "Форма", + "hist_skip_idle": "нищо не е работило", + "hist_skip_sparse": "показанията са прекалено разредени", + "hist_skip_short": "твърде малко показания", + "hist_skip_long": "няма достатъчно дълга пауза за разделяне", + "hist_reason_short": "по-кратък от най-краткия реален цикъл на този уред", + "hist_reason_no_end": "никога не завърши коректно", + "hist_since": "От", + "task_history_import": "Сканиране на историята на мощността", + "task_history_import_apply": "Импортиране на цикли", + "evidence_real_cycles": "Цикли, изпълнени от тази машина", + "evidence_reference_cycles": "Изтеглени от магазина на общността", + "evidence_backfill_cycles": "Намерени в импортирана история на мощността" }, "log": { "all_levels": "Всички нива", @@ -756,9 +785,15 @@ "store_share": "Споделяне в магазина на общността", "store_share_device": "Сподели настройките на устройството", "export_select": "Експорт - избор на данни", - "import_wizard": "Импорт - избор на данни" + "import_wizard": "Импорт - избор на данни", + "history_import": "Импортиране на историята на мощността" }, "msg": { + "tail_trim_hint": "Премахнете толкова секунди от края", + "store_sibling_hint": "Няма нищо споделено за точния ви модел? Близък модел от същата марка обикновено е добра отправна точка.", + "store_declare_appliance": "Кажете на WashData кой уред притежавате и този раздел ще показва конфигурациите, които други хора са споделили за него. Можете също да въведете марка по-горе, за да разгледате каталога.", + "refresh_catalog_hint": "Списъците с марки и уреди на общността се кешират, за да остане магазинът на общността в рамките на дневния си лимит. Опреснете, за да видите записите, добавени или одобрени от други.", + "head_trim_hint": "Премахнете толкова секунди от началото", "appliance_monitor": "Монитор на уреди", "artifact_dip_detail": "Падна под обичайния диапазон на мощността за ~{n} сек.", "artifact_footer": "Подчертано на графиката по-горе. Това са преходни артефакти (напр. вратата се отвори по средата на цикъла), а не непременно проблеми.", @@ -769,7 +804,7 @@ "automations_intro": "WashData задейства събития {start} / {end} и предоставя обекти, затова известията и действията най-добре се изграждат като обикновени автоматизации на Home Assistant. По-долу са показани автоматизациите, използващи това устройство.", "cleanup_intro": "Всеки етикетиран цикъл е насложен. Отбележете отклоненията и изтрийте, за да почистите профила.", "clear_debug_hint": "Премахнете съхранените данни за отстраняване на грешки, за да освободите място.", - "collecting_data": "Събиране на данни – нужни са още {need} цикъл{plural} преди да може да започне прецизната настройка ({current}/{min}).", + "collecting_data": "Събиране на данни. Цикли, необходими преди да започне фината настройка: {need} ({current}/{min}).", "compare_overlay_profiles": "Профили на наслагване (бледи)", "compare_profiles_tip": "Наслагнете други профилни пликове върху диаграмата по-горе, за да видите кой най-добре пасва на този цикъл.", "compare_selected_cycles": "Избрани цикли (плътни) – показване / скриване", @@ -779,7 +814,7 @@ "cycles_deleted": "Изтрити цикли: {count}", "enough_data": "Достатъчно данни за обучение ({current}/{min} цикъла).", "export_description": "Изберете точно кои профили, цикли, настройки и други да се експортират в JSON, или анализирайте файл и импортирайте само желаните части.", - "feedback_cycles_pending": "{n} цикъл{s} за преглед", + "feedback_cycles_pending": "За преглед: {n}", "feedback_prompt": "Потвърдете, че е правилно, коригирайте програмата или игнорирайте.", "feedback_relabel_hint": "Преетикетирането на този цикъл също го разрешава.", "filter_by_profile": "Филтриране по профил…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "тук започва симулацията на готовност", "pg_sweep_intro": "Какво ако {param} беше различен? Тествайте {steps} стойности върху последните ви {cycles} цикъла, за да намерите настройката, при която най-много цикли съвпадат правилно.", "pg_sweep_step": "Стъпка {done} / {total}", - "pg_undetected": "{n} неоткрит цикъл{s}", "pg_verdict_bad": "Изисква внимание: много цикли остават неоткрити.", "pg_verdict_good": "Добре настроено: повечето цикли се откриват и съвпадат правилно.", "pg_verdict_ok": "Приемливо: някои цикли са пропуснати. Опитайте да намалите прага за стартиране.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Маркирайте това като ръчно избран референтен цикъл за неговата програма - същата роля като ръчно записан цикъл. Референтните цикли винаги се запазват, зареждат съответстващия шаблон и никога не се изтриват от почистване. (Това е „златният“/записан флаг; и двете са едно и също нещо.)", "review_tags_tip": "Незадължителни флагове, описващи какво се е объркало с този цикъл, така че обучението и почистването могат да го обяснят.", "review_to_cycles": "Отворете опашката за преглед на Cycles", + "samples_decimated": "Показани {shown} от {total} проби (разредени за показване; пиковете са запазени). Широка празнина тук е разреждане, а не липсващи данни.", "saving_triggers_reload": "Запазването задейства презареждане на интеграцията. Обектите на HA може за кратко да се показват като неналични.", "search_placeholder": "Настройки за търсене...", "see_recorder": "Вижте джаджа за записващо устройство по-долу", @@ -991,12 +1026,12 @@ "share_consent": "Споделяте реални данни от вашия уред. Не споделяйте, ако моделите ви на използване са лични.", "share_device_none": "Все още няма настроени устройства. Първо добавете устройство.", "share_guideline_naming": "Използвайте ясни имена на програмите (напр. 'Cotton 40', 'Eco 60'), за да могат другите да ги разпознаят", - "share_guideline_quality": "Споделяйте само профили с ⭐ референтни цикли или поне {n} потвърдени стартирания", + "share_guideline_quality": "Споделяйте само цикли, завършили нормално -- без прекъсвания по време на цикъла, отваряне на вратата или трептения в захранването.", "share_guideline_review": "Прегледайте профилите си преди споделяне -- премахнете тези, които изглеждат грешни", "share_guidelines_title": "Преди споделяне", "store_download_device_intro": "Изтеглете настройките на устройство от общността и ги приложете към ново или съществуващо устройство", - "store_share_device_intro": "Споделете програмите на вашето устройство (профили + референтни цикли) с общността. Настройките са незадължителни.", - "share_profile_no_cycles": "Профилът '{p}' няма ⭐ референтни цикли -- ще бъде пропуснат, освен ако нямате {n}+ потвърдени стартирания", + "store_share_device_intro": "Качете {brand} {model} с референтните цикли, които изберете. Други със същия уред могат да приемат вашите програми. Публикациите се преглеждат, преди да станат публични.", + "share_profile_no_cycles": "Няма референтни цикли - маркирайте цикъл със ⭐ в раздела Цикли, за да включите този профил", "advisory_phase_inconsistent": "Изглежда, че '{name}' смесва различни програми или температури - циклите му загряват за много различно време. Разделянето му на отделни профили (напр. по температура) ще подобри съпоставянето и оценките на времето.", "advisory_phase_inconsistent_title": "⚠ Възможно смесени програми", "export_select_intro": "Отбележете какво точно да се включи. Изборът на профили без техните цикли пак експортира разпознаваема програма (научената ѝ форма се пренася заедно с нея).", @@ -1006,7 +1041,29 @@ "merge_hint": "Импортираните елементи се добавят; нищо локално не се губи. Конфликтите на имена се разрешават по-долу.", "replace_warn": "Всяка отбелязана категория се изтрива и заменя от файла. Неотбелязаните категории остават непроменени.", "dest_reference_hint": "Импортираните цикли само подобряват разпознаването на програми и никога не влияят на статистиката за използване/енергия.", - "dest_real_history_hint": "Импортираните цикли се броят като собствена история на това устройство и захранват статистиката за енергия/използване. Използвайте за преместване на един уред към нова инсталация." + "dest_real_history_hint": "Импортираните цикли се броят като собствена история на това устройство и захранват статистиката за енергия/използване. Използвайте за преместване на един уред към нова инсталация.", + "import_history_description": "Имали сте умен контакт още преди WashData? Качете експорт на историята на неговия сензор за мощност или я прочетете направо от Home Assistant, и обичайното откриване минава по нея, така че миналите цикли се появяват в списъка „Цикли“ готови за наименуване.", + "hist_input_hint": "Качете CSV файл, изтеглен от панела „История“ (обект, състояние, последна промяна), или оставете WashData да прочете историята на сензора директно. След това откриването минава по нея точно както в реално време, а вие избирате кои от намерените цикли да запазите.", + "hist_recorder_hint": "Чете данните от избраната дата до сега. По подразбиране Home Assistant пази подробна история 10 дни, а след това само средни стойности на час, които са прекалено груби за откриване на цикли - избирайте по-ранна дата само ако вашият recorder е настроен да пази повече.", + "hist_scanning": "Историята ви се възпроизвежда през детектора. Това се изпълнява във фонов режим - можете да затворите този диалог и да се върнете по-късно.", + "hist_imported_count": "Импортирани цикли: {n}.", + "hist_duplicates": "Вече импортирани и пропуснати: {n}.", + "hist_capped": "Достигнат е лимитът на импортирани цикли за устройството; останалите не бяха запазени.", + "hist_next_step": "Те са в списъка „Цикли“, отбелязани като импортирана история. Отворете един и използвайте „Етикет“, за да посочите програмата му.", + "hist_rows_read": "Прочетени показания: {n}", + "hist_breaks": "Пропуски, при които сензорът е бил недостъпен: {n}", + "hist_other_entity": "Пропуснати показания на други обекти: {n}", + "hist_entity_substituted": "Прочетено {used} (това устройство е настроено за {wanted})", + "hist_skipped_spans": "Пропуснати участъци", + "hist_settings_used": "Открито с текущите настройки на това устройство (Минимална мощност {w} Вт, Закъснение при изключване {s} с).", + "hist_none_found": "В тази история не можаха да бъдат открити цикли.", + "hist_found": "Намерени цикли: {n}. Махнете отметката от всичко, което не изглежда като истинско пускане - нищо не се запазва, докато не импортирате.", + "hist_scan_capped": "Показани са само първите кандидати (намерени: {n}).", + "hist_recorder_empty": "Home Assistant няма подробна история за този сензор в този период.", + "hist_scan_failed": "Сканирането е неуспешно.", + "hist_scan_expired": "Това сканиране вече не е налично. Моля, сканирайте отново.", + "hist_import_failed": "Импортирането е неуспешно.", + "imported_history_readonly": "Открито в импортирана история на мощността. Влияе на съпоставянето на програми, но не се брои във вашата статистика и не може да се изрязва или разделя. Използвайте „Етикет“, за да посочите програмата." }, "phase_desc": { "anti_crease": "Случайни кратки движения след завършване за намаляване на бръчките.", @@ -1471,6 +1528,10 @@ "show_contributor": { "doc": "Покажи името на сътрудника в профилите, изтеглени от магазина на общността" }, + "smart_termination_duration_ratio": { + "doc": "Колко навътре в очакваната продължителност на съпоставената програма трябва да е навлязъл цикълът, преди Интелигентното прекратяване да може да го завърши предсрочно при спадане на мощността. Очакваната продължителност е средната за програмата, затова при уреди със силно променливо време на работа - перални при студена зимна и топла лятна входяща вода, сушилни с датчик за влажност, програми, зависещи от натоварването - около половината от всички пускания завършват по-кратко от тази средна стойност и никога не получават бързото завършване, приключвайки само чрез резервния таймаут с минути закъснение. Намалете тази стойност (напр. 0.85) при такива машини, за да се задейства все пак ранното завършване; повишете я към 1.0 за по-предпазливо поведение. Оставете празно за стойността по подразбиране (0.98 или 0.99 за съдомиялни). Може само да завърши цикъл по-рано, никога по-късно, и никога не се задейства при нееднозначно или несигурно съпоставяне.", + "label": "Коефициент на интелигентно прекратяване" + }, "enable_phase_matching": { "label": "Оставащо време съобразено с фазите", "doc": "Разделя всеки активен цикъл на фази (нагряване, пране, центрофугиране) и разпределя оставащото време по фази, съчетано с класическата оценка - като разчита на разпределението по фази в началото на цикъла и на класическата оценка към края. Това персонализира обратното броене спрямо това колко всъщност загрява и работи вашата машина, което е най-осезаемо през първата половина на цикъла. Изключено = само класическата оценка. Засяга се само показването на оставащото време; съпоставянето на програми и откриването на цикли остават непроменени." @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Изчакване при отворена врата", "doc": "Колко дълго трябва вратата да остане отворена, преди WashData да приключи цикъла, когато \"Вратата се отваря автоматично в края\" е включено. Достатъчно дълго, за да игнорира бързото добавяне на съд (по подразбиране 60 с), достатъчно кратко, за да приключи бързо, когато машината отвори вратата." + }, + "profile_evidence_sources": { + "label": "Цикли, които оформят програмата", + "doc": "Кои цикли се използват за изграждане на кривата на мощността на всяка програма и за съпоставяне на завършен цикъл с нея. Ако махнете отметката на даден вид, той спира да оформя програмите ви, без нищо да се изтрива - циклите остават в списъка Цикли и все още могат да бъдат маркирани или премахнати. Полезно, ако не се доверявате на импортираните данни. Статистиката не се влияе: в нея винаги се броят само циклите, които тази машина наистина е изпълнила. Махането на всички отметки се игнорира, защото програма без нито един цикъл никога не би могла да съвпадне." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Основна конфигурация" + }, + "profile_evidence": { + "label": "Основа на профила" } }, "status": { @@ -1626,10 +1694,11 @@ "analyzing": "Анализиране…", "resetting": "Нулиране…", "reverting": "Връщане…", - "trimming": "Подрязване…", + "trimming": "Изрязване…", "splitting": "Разделяне…", "deleting": "Изтриване…", - "imported": "Импортирано" + "imported": "Импортирано", + "preparing": "Подготовка…" }, "tab": { "advanced": "Разширено", @@ -1659,6 +1728,7 @@ "wrong_profile": "Грешен профил" }, "toast": { + "catalog_refreshed": "Каталогът на общността е опреснен", "access_saved": "Контролът на достъпа е запазен", "all_wiped": "Всички данни са изтрити", "analysis_complete_none": "Анализът е завършен: няма нови предложения", @@ -1670,7 +1740,7 @@ "cycle_labelled": "Цикъл с етикет", "cycle_paused": "Цикълът на пауза", "cycle_resumed": "Цикълът е възобновен", - "cycle_trimmed": "Цикъл подрязан", + "cycle_trimmed": "Цикълът е изрязан", "cycles_merged": "Обединени цикли", "envelope_rebuilt": "Преустроен плик", "envelopes_rebuilt": "Пликове преустроени", @@ -1752,19 +1822,21 @@ "rating_saved": "Оценката за качество е запазена", "brand_added": "Марката е добавена, изчаква одобрение", "profile_added": "Профилът е добавен, изчаква одобрение", - "saved_except_conflicts": "Настройките са запазени -- {n} настройка{s} пропусната поради конфликти", + "saved_except_conflicts": "Запазено. Поправете маркираните конфликти, за да запазите останалото.", "share_device_none_sel": "Изберете поне една програма за споделяне", - "store_device_downloaded": "Настройките на устройството са изтеглени: {created} профил{c} създаден, {dup} вече съществуваше", - "store_device_downloaded_phases": "Настройките на устройството са изтеглени: {created} профил{c} създаден, {dup} вече съществуваше, картата на фазите е приложена", - "store_device_downloaded_settings": "Настройките на устройството са изтеглени: {created} профил{c} създаден, {dup} вече съществуваше, настройките са приложени", - "store_device_shared": "Настройките на устройството са споделени: {n} програм{s} качена", - "store_device_shared_all_dup": "Няма нищо ново за споделяне -- всички програми вече съществуват в магазина", - "store_device_shared_partial": "Частично споделяне: {n} програм{s} качена, {failed} пропуснати", - "store_device_shared_some_dup": "Настройките на устройството са споделени: {n} програм{s} качена ({dup} вече съществуваше)", + "store_device_downloaded": "Добавено: програми {p}, записи {c}", + "store_device_downloaded_phases": "Добавено: програми {p}, записи {c}, карти на фазите {ph}", + "store_device_downloaded_settings": "Добавено: програми {p}, записи {c}, карти на фазите {ph}, настройки {s}", + "store_device_shared": "Цикли, споделени в магазина на общността: {n}. Предстои преглед.", + "store_device_shared_all_dup": "Всички цикли ({n}) вече бяха в магазина на общността.", + "store_device_shared_partial": "Споделени цикли: {n}; неуспешно качени: {failed}.", + "store_device_shared_some_dup": "Споделени цикли: {created}; вече бяха в магазина: {dup}.", "store_download_failed": "Грешка при изтегляне: {error}", "store_download_nothing": "Няма какво да се изтегля -- всички профили вече съществуват на това устройство", "export_selective_done": "Експортът е изтеглен", - "import_selective_done": "Импортирани са {profiles} профил(а) и {cycles} цикъл(а)" + "import_selective_done": "Импортирани са {profiles} профил(а) и {cycles} цикъл(а)", + "hist_csv_required": "Първо заредете CSV файл или поставете съдържанието му", + "file_read_failed": "Файлът не можа да бъде прочетен" }, "suggestion": { "both_agree": "WashData препоръчва", @@ -1860,7 +1932,7 @@ "thr_batch": "Задържано точно над най-ниската работна мощност p05 за {cycles} цикъла ({p05}W), за да се улови стартът възможно най-рано и прагът на спиране да остане под най-ниската работна мощност на машината.", "tol_per_profile": "p75 на дисперсията на продължителността по профил за {profiles} профила ({cycles} цикъла); стриктните профили не се санкционират.", "tol_pooled": "Въз основа на обединената дисперсия на продължителността на {cycles} скорошни етикетирани цикъла (p95 отклонение={dev}).", - "watchdog": "Задържано възможно най-ниско при запазена безопасност (малко над интервала на обновяване p95 от {p95}s, мин. 30s), за да се улавят бързо блокиранията без фалшиви спирания." + "watchdog": "Задържано възможно най-ниско при запазена безопасност (малко над интервала на обновяване p95 от {p95}s и поне 2x интервала на вземане на проби от {median}s, мин. 30s), за да се улавят бързо блокиранията без фалшиви спирания." }, "exclusions": { "summary": "Изключени са {total} грешно открити цикъла: {parts}.", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Съдомиялна машина: секунди неактивност след очакваната продължителност, преди да се освободи изчакването за източване в края на цикъла", + "smart_termination_duration_ratio": "Дял от очакваната продължителност на съпоставената програма, който цикълът трябва да достигне, преди Интелигентното прекратяване да може да го завърши предсрочно; намалете го за машини, зависещи от натоварването или температурата", "completion_min_seconds": "Най-краткото изпълнение, което се счита за истински цикъл", "end_repeat_count": "Ниски отчитания поред преди приключване", "interrupted_min_seconds": "Кратки цикли се отбелязват като прекъснати", @@ -1955,6 +2028,10 @@ "finished": "Цикълът достигна крайно състояние и приключи." }, "store": { + "your_model_tip": "Това е уредът, който сте задали в настройките", + "your_model": "Ваш", + "search_brand_ph": "Търсене по марка…", + "programs_count": "Програми: {n}", "browse": "Разглеждане", "device": "Уред", "favorites": "Любими", diff --git a/custom_components/ha_washdata/translations/panel/bs.json b/custom_components/ha_washdata/translations/panel/bs.json index b9031891..910fc8f5 100644 --- a/custom_components/ha_washdata/translations/panel/bs.json +++ b/custom_components/ha_washdata/translations/panel/bs.json @@ -78,9 +78,12 @@ "awaiting": "Čeka odobrenje", "imported_tip": "Uvezeno iz zajedničke trgovine. Koristi se samo za podudaranje, ne broji se u statistiku.", "not_importable": "nije dostupno", - "exists": "postoji" + "exists": "postoji", + "backfilled_tip": "Otkriveno u uvezenoj historiji snage. Utiče samo na podudaranje programa, ne broji se u statistiku." }, "btn": { + "set_brand_model": "Postavi marku i model", + "refresh_catalog": "Osvježi katalog", "add_device": "+ Dodaj uređaj", "add_device_tip": "Dodajte još jedan WashData uređaj", "add_maintenance": "Dodaj događaj održavanja", @@ -234,14 +237,19 @@ "download_device": "Preuzmi postavke uređaja", "share_device": "Podijeli postavke uređaja", "share_device_tip": "Podijelite programe i postavke ovog uređaja sa zajednicom", - "share_n": "Podijeli {n} program{s}", + "share_n": "Podijeli cikluse ({n})", "export_selected": "Izvoz (odaberi podatke)", "export_all": "Brzi izvoz svega", "import_raw": "Napredno: zamijeni sve iz JSON datoteke", "download_export": "Preuzmi izvoz", "analyze_import": "Analiziraj datoteku", "import_selected": "Uvezi odabrano", - "back": "Nazad" + "back": "Nazad", + "import_power_history": "Uvezi historiju snage", + "hist_read_recorder": "Pročitaj iz Home Assistant", + "hist_scan": "Potraži cikluse", + "hist_import_n": "Uvezi cikluse: {n}", + "hist_goto_cycles": "Prikaži cikluse" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Mora biti ispod Maks. snage anti-gužvanja ({max} W)" }, "attn_sub": "Ispravite konflikte prije pohrane", - "attn_title": "{n} konflikt{s} postavki", + "attn_title": "Konflikti postavki: {n}", "confidence": { "auto": "Mora biti na razini ili iznad Praga podudaranja ({match})", - "learning": "Mora biti na razini ili ispod Praga podudaranja ({match})", + "learning": "Mora biti na razini ili iznad Praga podudaranja ({match})", "match_for_auto": "Mora biti na razini ili ispod Pouzdanosti automatskog označavanja ({alc})", - "match_for_learning": "Mora biti na razini ili iznad Pouzdanosti učenja ({lc})" + "match_for_learning": "Mora biti na razini ili ispod Pouzdanosti učenja ({lc})" }, "duration_ratio": { "max": "Mora biti veće od Min. omjera trajanja ({min})", @@ -296,14 +304,14 @@ "match": "Mora biti iznad Praga nepodudaranja ({un})", "unmatch": "Mora biti ispod Praga podudaranja ({match}); inače se potvrđeno podudaranje odmah poništava" }, - "cascade_toast": "Radi dosljednosti, automatski je podešeno još {n} postavki.", + "cascade_toast": "Druge postavke prilagođene radi dosljednosti: {n}", "suggestion_resolves": "Primijenite prijedlog na čekanju ({val}) ispod za rješavanje ovog sukoba", "use_fix": "Koristite {val}", "watchdog": { "interval": "Treba biti najmanje 2× Interval uzorkovanja ({si} s)", "sampling": "Interval uzorkovanja treba biti najviše polovina Intervala čuvara ({wi} s)" }, - "settings_banner": "{n} konflikt{s} postavki – provjerite istaknute sekcije i ispravite prije pohrane.", + "settings_banner": "Konflikti postavki: {n}. Provjerite istaknute sekcije i ispravite ih prije pohrane.", "settings_banner_btn": "Idi na prvi" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Ishod simulacije", "pg_across_cycles": "Kroz sve vaše cikluse", "community_store": "Zajednička trgovina", - "online_account": "Zajednička trgovina i online funkcije" + "online_account": "Zajednička trgovina i online funkcije", + "import_power_history": "Uvoz historije snage" }, "health": { "fair": "Prihvatljiva kvaliteta podudaranja", @@ -364,6 +373,7 @@ "poor": "⚠ Loša kvaliteta podudaranja" }, "lbl": { + "drag_to_resize": "Prevucite za promjenu veličine", "actions": "Akcije", "activity": "Aktivnost", "administrators": "Administratori", @@ -435,7 +445,7 @@ "from": "Od", "gap_s": "jaz (s)", "group_name": "Naziv grupe", - "head_trim": "Trim (s) glave", + "head_trim": "Skraćivanje početka (s)", "health": "Zdravlje", "hide_tabs": "Sakrij kartice za neadministratore", "in_use": "U upotrebi", @@ -451,7 +461,7 @@ "metric": "Metrika", "mode_existing_profile": "Dodaj postojećem profilu", "mode_new_profile": "Kreirajte novi profil", - "models_fine_tuned": "({count} modela fino podešenih)", + "models_fine_tuned": "(fino podešeni modeli: {count})", "n_classic_suggestions": "{n} klasičan", "n_ml_suggestions": "{n} ML", "n_selected": "{n} odabrano", @@ -533,7 +543,7 @@ "stage3": "Faza 3 – DTW", "stage4": "Faza 4 – slaganje", "status": "Status", - "tail_trim": "Obrub repa (s)", + "tail_trim": "Skraćivanje kraja (s)", "timer_auto_pause": "Automatska pauza", "timer_min": "min", "timer_msg_placeholder": "Poruka (opcionalno, {device}/{program}/{minutes})", @@ -651,7 +661,7 @@ "show_contributor": "Prikaži doprinositelja", "task_pg_detail": "Simuliraj ciklus", "task_split": "Dijeljenje ciklusa", - "task_trim": "Obrezivanje ciklusa", + "task_trim": "Skraćivanje ciklusa", "task_merge": "Spajanje ciklusa", "task_rebuild": "Ponovna izgradnja omotača", "cat_profiles": "Profili (programi)", @@ -677,7 +687,26 @@ "conflict_resolution": "Sukobi naziva", "conflict_import_copy": "Uvezi kao kopiju", "conflict_keep_mine": "Zadrži moje", - "conflict_overwrite": "Prepiši" + "conflict_overwrite": "Prepiši", + "hist_csv_data": "CSV podaci", + "hist_from_recorder": "Ili je pročitajte iz Home Assistant", + "days": "dana", + "hist_keep": "Zadrži ovaj ciklus", + "hist_looks_complete": "završen", + "peak_power_short": "Vrh", + "shape": "Oblik", + "hist_skip_idle": "uređaj nije radio", + "hist_skip_sparse": "očitanja predaleko jedno od drugog", + "hist_skip_short": "nedovoljno očitanja", + "hist_skip_long": "nema dovoljno duge pauze za razdvajanje", + "hist_reason_short": "kraći od najkraćeg stvarnog ciklusa ovog uređaja", + "hist_reason_no_end": "nikada se nije pravilno završio", + "hist_since": "Od", + "task_history_import": "Pretraživanje historije snage", + "task_history_import_apply": "Uvoz ciklusa", + "evidence_real_cycles": "Ciklusi koje je izvršila ova mašina", + "evidence_reference_cycles": "Preuzeti iz zajedničke trgovine", + "evidence_backfill_cycles": "Pronađeni u uvezenoj historiji snage" }, "log": { "all_levels": "Svi nivoi", @@ -756,9 +785,15 @@ "store_share": "Podijeli u zajedničku trgovinu", "store_share_device": "Podijeli postavke uređaja", "export_select": "Izvoz - odaberi podatke", - "import_wizard": "Uvoz - odaberi podatke" + "import_wizard": "Uvoz - odaberi podatke", + "history_import": "Uvoz historije snage" }, "msg": { + "tail_trim_hint": "Uklonite ovoliko sekundi sa kraja", + "store_sibling_hint": "Nema ničega podijeljenog za vaš tačan model? Blisko srodan model iste marke obično je dobra početna tačka.", + "store_declare_appliance": "Recite integraciji WashData koji uređaj posjedujete i ova kartica prikazat će konfiguracije koje su drugi podijelili za njega. Možete i upisati marku iznad kako biste pregledali katalog.", + "refresh_catalog_hint": "Liste marki i uređaja zajednice privremeno se čuvaju kako bi zajednička trgovina ostala u okviru svog dnevnog ograničenja. Osvježite da preuzmete unose koje su drugi dodali ili odobrili.", + "head_trim_hint": "Uklonite ovoliko sekundi sa početka", "appliance_monitor": "Monitor uređaja", "artifact_dip_detail": "Pao ispod uobičajenog pojasa snage za ~{n}s.", "artifact_footer": "Istaknuto na grafikonu iznad. To su prolazni artefakti (npr. vrata se otvaraju usred ciklusa), ne nužno problemi.", @@ -769,7 +804,7 @@ "automations_intro": "WashData pokreće događaje {start} / {end} i izlaže entitete, pa je obavijesti i radnje najbolje graditi kao normalne automatizacije Home Assistanta. Automatizacije koje koriste ovaj uređaj prikazane su ispod.", "cleanup_intro": "Svaki označeni ciklus se preklapa. Označite nedostatke i izbrišite da biste očistili profil.", "clear_debug_hint": "Uklonite pohranjene podatke za otklanjanje grešaka kako biste oslobodili prostor.", - "collecting_data": "Prikupljanje podataka: još {need} ciklusa do početka finog podešavanja ({current}/{min}).", + "collecting_data": "Prikupljanje podataka. Još potrebnih ciklusa prije početka finog podešavanja: {need} ({current}/{min}).", "compare_overlay_profiles": "Profili preklapanja (slabi)", "compare_profiles_tip": "Prekrijte druge koverte profila na gornjoj tabeli da vidite koja najbolje odgovara ovom ciklusu.", "compare_selected_cycles": "Odabrani ciklusi (puni) – prikaži / sakrij", @@ -779,7 +814,7 @@ "cycles_deleted": "Izbrisano ciklusa: {count}", "enough_data": "Dovoljno podataka za učenje ({current}/{min} ciklusa).", "export_description": "Odaberite tačno koje profile, cikluse, postavke i ostalo želite izvesti u JSON datoteku ili analizirajte datoteku i uvezite samo željene dijelove.", - "feedback_cycles_pending": "{n} ciklus{s} za pregled", + "feedback_cycles_pending": "Za pregled: {n}", "feedback_prompt": "Potvrdite da je ispravno, ispravite program ili zanemarite.", "feedback_relabel_hint": "Ponovno označavanje ovog ciklusa takođe ga rješava.", "filter_by_profile": "Filtriraj po profilu…", @@ -793,7 +828,7 @@ "loading_curve": "Učitavanje krivulje…", "loading_settings": "Učitavanje postavki…", "log_buffer_hint": "Najnovije prvo · baferuje poslednjih 500 ha_washdata zapisa od ponovnog pokretanja · prevucite donju ivicu da promenite veličinu.", - "maintenance_advisory": "Povećanje trajanja/energije može ukazivati ​​da je potrebno održavanje uređaja (npr. uklanjanje kamenca, čišćenje filtera).", + "maintenance_advisory": "Povećanje trajanja/energije može ukazivati da je potrebno održavanje uređaja (npr. uklanjanje kamenca, čišćenje filtera).", "maintenance_due": "Održavanje dospijeva: {items}", "maintenance_intro": "Zabilježite servis koji obavljate na ovom uređaju i budite podsjećeni kada svaki zadatak ponovo dospije.", "maintenance_load_error": "Nije moguće učitati podatke o održavanju: {error}", @@ -830,7 +865,7 @@ "no_split_points": "Još nema podijeljenih bodova.", "no_suggestions": "Nema aktivnih prijedloga.", "notify_services_hint": "Koristite ID-ove servisa {entity} (odvojene zarezima za više). Varijable predloška: {vars}.", - "old_actions_warning": "Konfigurisano sa starim uređivačem akcija (sada uklonjeno). I dalje se aktiviraju na događaje ciklusa, ali se više ne mogu uređivati ​​ovdje. Pretvorite ih u normalnu automatizaciju ili ih uklonite.", + "old_actions_warning": "Konfigurisano sa starim uređivačem akcija (sada uklonjeno). I dalje se aktiviraju na događaje ciklusa, ali se više ne mogu uređivati ovdje. Pretvorite ih u normalnu automatizaciju ili ih uklonite.", "onboarding_progress": "{n} / 3 ciklusa promatrano", "onboarding_watching": "Pustite uređaj da radi normalno – WashData prati. Nakon 3 ciklusa počinje podudaranje programa.", "pending_feedback": "Čeka se povratna informacija o detekciji", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "simulacija mirovanja počinje ovdje", "pg_sweep_intro": "Šta ako bi {param} bio drugačiji? Testirajte {steps} vrijednosti na vaših posljednjih {cycles} ciklusa da pronađete postavku pri kojoj se najviše ciklusa ispravno podudara.", "pg_sweep_step": "Korak {done} / {total}", - "pg_undetected": "{n} neotkrivenih ciklusa", "pg_verdict_bad": "Zahtijeva pažnju: mnogi ciklusi ostaju neotkriveni.", "pg_verdict_good": "Dobro podešeno: većina ciklusa se ispravno prepoznaje i podudara.", "pg_verdict_ok": "Prihvatljivo: neki ciklusi su propušteni. Pokušajte smanjiti prag pokretanja.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Označite ovo kao ručno odabran referentni ciklus za njegov program - ista uloga kao i ručno snimljeni ciklus. Referentni ciklusi se uvijek čuvaju, postavljaju podudarni predložak i nikada se ne ispuštaju čišćenjem. (Ovo je \"zlatna\"/snimljena zastava; obje su ista stvar.)", "review_tags_tip": "Opcione zastavice koje opisuju šta je pošlo po zlu s ovim ciklusom, tako da obuka i čišćenje mogu to objasniti.", "review_to_cycles": "Otvorite red za pregled ciklusa", + "samples_decimated": "Prikazano {shown} od {total} uzoraka (prorijeđeno za prikaz; vrhovi zadržani). Široki razmak ovdje znači prorjeđivanje, a ne podatke koji nedostaju.", "saving_triggers_reload": "Spremanje pokreće ponovno učitavanje integracije. HA entiteti se mogu nakratko prikazati kao nedostupni.", "search_placeholder": "Postavke pretraživanja…", "see_recorder": "Pogledajte widget snimača ispod", @@ -991,12 +1026,12 @@ "share_consent": "Dijelite stvarne podatke sa svog uređaja. Ne dijelite ako su vaši obrasci korištenja privatni.", "share_device_none": "Još nema postavljenih uređaja. Prvo dodajte uređaj.", "share_guideline_naming": "Koristite jasna imena programa (npr. 'Cotton 40', 'Eco 60') kako bi ih drugi mogli prepoznati", - "share_guideline_quality": "Dijelite samo profile sa ⭐ referentnim ciklusima ili najmanje {n} potvrđenih pokretanja", + "share_guideline_quality": "Dijelite samo cikluse koji su normalno završili -- bez prekida usred ciklusa, otvaranja vrata ili skokova napajanja.", "share_guideline_review": "Pregledajte profile prije dijeljenja -- uklonite one koji izgledaju pogrešno", "share_guidelines_title": "Prije dijeljenja", "store_download_device_intro": "Preuzmite postavke uređaja iz zajednice i primijenite ih na novi ili postojeći uređaj", - "store_share_device_intro": "Podijelite programe svog uređaja (profile + referentne cikluse) sa zajednicom. Postavke su opcione.", - "share_profile_no_cycles": "Profil '{p}' nema ⭐ referentnih ciklusa -- bit će preskočen osim ako nemate {n}+ potvrđenih pokretanja", + "store_share_device_intro": "Otpremite {brand} {model} s referentnim ciklusima koje odaberete. Drugi s istim uređajem mogu preuzeti vaše programe. Unosi se pregledaju prije nego što postanu javni.", + "share_profile_no_cycles": "Nema referentnih ciklusa - označite ciklus sa ⭐ u kartici Ciklusi da uključite ovaj profil", "advisory_phase_inconsistent": "Čini se da '{name}' miješa različite programe ili temperature - njegovi ciklusi se griju vrlo različito dugo. Podjela na zasebne profile (npr. po temperaturi) poboljšat će podudaranje i procjene vremena.", "advisory_phase_inconsistent_title": "⚠ Možda pomiješani programi", "export_select_intro": "Označite tačno šta treba uključiti. Odabir profila bez njihovih ciklusa i dalje izvozi prepoznatljiv program (njegov naučeni oblik prenosi se s njim).", @@ -1006,7 +1041,29 @@ "merge_hint": "Uvezene stavke se dodaju; ništa lokalno se ne gubi. Sukobi naziva rješavaju se u nastavku.", "replace_warn": "Svaka označena kategorija briše se i zamjenjuje podacima iz datoteke. Neoznačene kategorije ostaju nepromijenjene.", "dest_reference_hint": "Uvezeni ciklusi samo poboljšavaju prepoznavanje programa i nikada ne utiču na statistiku potrošnje/energije.", - "dest_real_history_hint": "Uvezeni ciklusi računaju se kao vlastita historija ovog uređaja i doprinose statistici energije/potrošnje. Koristite pri premještanju jednog uređaja na novu instalaciju." + "dest_real_history_hint": "Uvezeni ciklusi računaju se kao vlastita historija ovog uređaja i doprinose statistici energije/potrošnje. Koristite pri premještanju jednog uređaja na novu instalaciju.", + "import_history_description": "Imali ste pametni utikač i prije WashData? Učitajte izvoz historije njegovog senzora snage ili je pročitajte direktno iz Home Assistant, a obično otkrivanje proći će kroz te podatke, pa će se stari ciklusi pojaviti na vašoj listi Ciklusi, spremni za imenovanje.", + "hist_input_hint": "Učitajte CSV preuzet s panela Historija (entitet, stanje, posljednja promjena) ili pustite WashData da direktno pročita historiju senzora. Otkrivanje se onda izvodi isto kao i uživo, a vi birate koje od pronađenih ciklusa ćete zadržati.", + "hist_recorder_hint": "Čita podatke od izabranog datuma do sada. Home Assistant standardno čuva detaljnu historiju 10 dana, a nakon toga samo satne prosjeke, koji su previše grubi za otkrivanje ciklusa - raniji datum birajte samo ako je vaš recorder postavljen na duže čuvanje.", + "hist_scanning": "Vašu historiju ponovo provodimo kroz detektor. Ovo se izvodi u pozadini - možete zatvoriti ovaj dijalog i vratiti se kasnije.", + "hist_imported_count": "Uvezeni ciklusi: {n}.", + "hist_duplicates": "Već ranije uvezeno i preskočeno: {n}.", + "hist_capped": "Dostignuto je ograničenje uvezenih ciklusa po uređaju; ostatak nije sačuvan.", + "hist_next_step": "Nalaze se na vašoj listi Ciklusi, obilježeni kao uvezena historija. Otvorite jedan i tasterom Označi mu dodijelite naziv programa kojem pripada.", + "hist_rows_read": "Pročitana očitanja: {n}", + "hist_breaks": "Praznine u kojima senzor nije bio dostupan: {n}", + "hist_other_entity": "Zanemarena očitanja drugih entiteta: {n}", + "hist_entity_substituted": "Očitano {used} (ovaj uređaj je postavljen na {wanted})", + "hist_skipped_spans": "Preskočeni dijelovi", + "hist_settings_used": "Otkriveno s trenutnim postavkama ovog uređaja (minimalna snaga {w} W, kašnjenje isključivanja {s} s).", + "hist_none_found": "U toj historiji nije bilo moguće otkriti nijedan ciklus.", + "hist_found": "Pronađeni ciklusi: {n}. Odznačite sve što ne izgleda kao stvarni rad - ništa se ne čuva dok ne uvezete.", + "hist_scan_capped": "Prikazani su samo prvi kandidati (pronađeno je {n}).", + "hist_recorder_empty": "Home Assistant nema detaljnu historiju za ovaj senzor u tom periodu.", + "hist_scan_failed": "Pretraživanje nije uspjelo.", + "hist_scan_expired": "To pretraživanje više nije dostupno. Pokrenite ga ponovo.", + "hist_import_failed": "Uvoz nije uspio.", + "imported_history_readonly": "Otkriveno u uvezenoj historiji snage. Utiče na podudaranje programa, ali se ne broji u vašu statistiku i ne može se skratiti ni podijeliti. Označite ga da mu dodijelite program." }, "phase_desc": { "anti_crease": "Povremena kratka spuštanja nakon završetka za smanjenje bora.", @@ -1173,7 +1230,7 @@ "label": "Broj ponavljanja kraja" }, "energy_price_entity": { - "doc": "Senzor sa trenutnom cijenom električne energije po kWh (npr. dinamička tarifa). Ima prednost nad statičnom cijenom ispod. Svaki ciklus zamrzava cijenu koja je na snazi ​​kada se završi.", + "doc": "Senzor sa trenutnom cijenom električne energije po kWh (npr. dinamička tarifa). Ima prednost nad statičnom cijenom ispod. Svaki ciklus zamrzava cijenu koja je na snazi kada se završi.", "label": "Entitet cijene energije" }, "energy_price_static": { @@ -1392,6 +1449,10 @@ "doc": "Sačuvajte puni trag napajanja i odgovarajuće podatke za otklanjanje grešaka za svaki ciklus. Korisno za rješavanje problema, ali povećava veličinu skladišta.", "label": "Sačuvati tragove otklanjanja grešaka" }, + "smart_termination_duration_ratio": { + "doc": "Koliko daleko u očekivanom trajanju podudarajućeg programa ciklus mora biti prije nego što ga Pametni završetak može ranije završiti nakon pada snage. Očekivano trajanje je prosjek programa, pa kod uređaja čije vrijeme rada jako varira - mašine za pranje rublja s hladnom dovodnom vodom zimi u odnosu na toplu ljeti, sušilice sa senzorskim sušenjem, programi ovisni o količini rublja - otprilike polovina svih pokretanja završi kraće od tog prosjeka i nikada ne dobije brzo završavanje, već završi tek putem rezervnog vremenskog ograničenja, nekoliko minuta kasnije. Kod takvih mašina smanjite ovu vrijednost (npr. 0,85) kako bi se ranije završavanje ipak pokrenulo; povećajte je prema 1,0 za oprezniji pristup. Ostavite prazno za zadanu vrijednost (0,98, ili 0,99 za mašine za pranje posuđa). Ciklus može uvijek samo ranije završiti, nikada kasnije, i nikada se ne pokreće kod nejasnog podudaranja ili podudaranja niske pouzdanosti.", + "label": "Omjer pametnog završetka" + }, "smoothing_window": { "doc": "Koliko je signal sirove snage izglađen. Niska (2) je odzivna, ali bučna; high (5) izglađuje šiljke, ali dodaje zaostajanje.", "label": "Prozor izglađivanja" @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Zadržavanje otvorenih vrata za završetak", "doc": "Koliko dugo vrata moraju ostati otvorena prije nego WashData završi ciklus, kada je uključena postavka \"Vrata se automatski otvaraju na kraju\". Dovoljno dugo da se ignorira brzo dodavanje posuđa (zadano 60 s), dovoljno kratko za brz završetak kad mašina otvori vrata." + }, + "profile_evidence_sources": { + "label": "Ciklusi koji formiraju program", + "doc": "Koji se ciklusi koriste za izgradnju krivulje snage svakog programa i za podudaranje završenog ciklusa s njom. Kada odznačite neku vrstu, ona više ne utiče na vaše programe, ali se ništa ne briše - ciklusi ostaju na listi Ciklusi i još uvijek se mogu označiti ili izbrisati. Korisno ako ne vjerujete uvezenim podacima. Na statistiku to ne utiče: uvijek se računaju samo ciklusi koje je ova mašina zaista izvršila. Odznačavanje svega se ignoriše jer se program bez ikakvih ciklusa nikada ne bi mogao podudariti." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Osnovna konfiguracija" + }, + "profile_evidence": { + "label": "Podloga profila" } }, "status": { @@ -1626,10 +1694,11 @@ "analyzing": "Analiziranje…", "resetting": "Resetovanje…", "reverting": "Vraćanje…", - "trimming": "Obrezivanje…", + "trimming": "Skraćivanje…", "splitting": "Dijeljenje…", "deleting": "Brisanje…", - "imported": "Uvezeno" + "imported": "Uvezeno", + "preparing": "Priprema…" }, "suggestion": { "both_agree": "WashData preporučuje", @@ -1725,7 +1794,7 @@ "thr_batch": "Zadržano tik iznad najniže radne snage p05 kroz {cycles} ciklusa ({p05}W) kako bi se početak uhvatio što ranije, a prag zaustavljanja ostao ispod najniže radne snage mašine.", "tol_per_profile": "p75 varijanse trajanja po profilu kroz {profiles} profila ({cycles} ciklusa); dosljedni profili nisu kažnjeni.", "tol_pooled": "Na osnovu objedinjene varijanse trajanja {cycles} novijih označenih ciklusa (p95 odstupanje={dev}).", - "watchdog": "Zadržano što nižim uz sigurnost (tik iznad razmaka ažuriranja p95 od {p95}s, najmanje 30s) kako bi se zastoji brzo uhvatili bez lažnih zaustavljanja." + "watchdog": "Zadržano što nižim uz sigurnost (tik iznad razmaka ažuriranja p95 od {p95}s i najmanje 2× intervala uzorkovanja od {median}s, najmanje 30s) kako bi se zastoji brzo uhvatili bez lažnih zaustavljanja." }, "exclusions": { "summary": "Isključeno {total} pogrešno otkrivenih ciklusa: {parts}.", @@ -1772,6 +1841,7 @@ "wrong_profile": "Pogrešan profil" }, "toast": { + "catalog_refreshed": "Katalog zajednice je osvježen", "access_saved": "Kontrola pristupa je sačuvana", "all_wiped": "Svi podaci su izbrisani", "analysis_complete_none": "Analiza završena: nema novih prijedloga", @@ -1865,19 +1935,21 @@ "rating_saved": "Ocjena kvaliteta sačuvana", "brand_added": "Marka dodana, čeka odobrenje", "profile_added": "Profil dodan, čeka odobrenje", - "saved_except_conflicts": "Postavke sačuvane -- {n} postavka{s} preskočena zbog konflikata", + "saved_except_conflicts": "Sačuvano. Ispravite istaknute konflikte da sačuvate ostalo.", "share_device_none_sel": "Odaberite barem jedan program za dijeljenje", - "store_device_downloaded": "Postavke uređaja preuzete: {created} profil{c} kreiran, {dup} već postoji", - "store_device_downloaded_phases": "Postavke uređaja preuzete: {created} profil{c} kreiran, {dup} već postoji, mapa faza primijenjena", - "store_device_downloaded_settings": "Postavke uređaja preuzete: {created} profil{c} kreiran, {dup} već postoji, postavke primijenjene", - "store_device_shared": "Postavke uređaja podijeljene: {n} program{s} učitan", - "store_device_shared_all_dup": "Nema ničeg novog za dijeljenje -- svi programi već postoje u prodavnici", - "store_device_shared_partial": "Djelimično dijeljenje: {n} program{s} učitan, {failed} preskočeno", - "store_device_shared_some_dup": "Postavke uređaja podijeljene: {n} program{s} učitan ({dup} već postoji)", + "store_device_downloaded": "Dodano: programi {p}, snimci {c}", + "store_device_downloaded_phases": "Dodano: programi {p}, snimci {c}, mape faza {ph}", + "store_device_downloaded_settings": "Dodano: programi {p}, snimci {c}, mape faza {ph}, postavke {s}", + "store_device_shared": "Ciklusi podijeljeni u zajedničku trgovinu: {n}. Čeka se pregled.", + "store_device_shared_all_dup": "Svi ciklusi ({n}) su već bili u zajedničkoj trgovini.", + "store_device_shared_partial": "Podijeljeni ciklusi: {n}; nije otpremljeno: {failed}.", + "store_device_shared_some_dup": "Podijeljeni ciklusi: {created}; već u trgovini: {dup}.", "store_download_failed": "Preuzimanje nije uspjelo: {error}", "store_download_nothing": "Nema ništa za preuzimanje -- svi profili već postoje na ovom uređaju", "export_selective_done": "Izvoz preuzet", - "import_selective_done": "Uvezeno {profiles} profila i {cycles} ciklusa" + "import_selective_done": "Uvezeno {profiles} profila i {cycles} ciklusa", + "hist_csv_required": "Prvo učitajte CSV fajl ili zalijepite njegov sadržaj", + "file_read_failed": "Nije bilo moguće pročitati taj fajl" }, "trend": { "down": "Opadajući trend", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Mašina za pranje posuđa: sekunde tišine nakon očekivanog trajanja prije nego što se otpusti čekanje na završno ispumpavanje vode", + "smart_termination_duration_ratio": "Udio očekivanog trajanja podudarajućeg programa koji ciklus mora dostići prije nego što ga Pametni završetak može ranije završiti; smanjite ga za mašine ovisne o količini rublja ili temperaturi", "anti_wrinkle_enabled": "Upij impulse bubnja nakon glavne faze, a ne čitaj ih kao nove cikluse", "anti_wrinkle_exit_power": "Snaga mora pasti ispod ove vrijednosti između impulsa da bi anti-gužvanje ostalo aktivno", "anti_wrinkle_idle_timeout": "Dozvoljeno vrijeme mirovanja između dva impulsa bubnja prije nego što se anti-gužvanje završi", @@ -1955,6 +2028,10 @@ "finished": "Ciklus je dostigao završno stanje i završio." }, "store": { + "your_model_tip": "Ovo je uređaj koji ste naveli u Postavkama", + "your_model": "Vaš", + "search_brand_ph": "Pretraži po marki…", + "programs_count": "Programi: {n}", "browse": "Pregledaj", "device": "Uređaj", "favorites": "Favoriti", @@ -1993,6 +2070,7 @@ "add_profile": "Dodajte profil za ovaj uređaj na web-stranici zajednice" }, "task": { + "cancelling": "Otkazivanje...", "reprocess": { "matching": "Ponovna obrada: podudaranje ciklusa", "golden": "Ponovna obrada: popunjavanje referentnih", @@ -2010,7 +2088,7 @@ "apply": "Dijeljenje ciklusa" }, "trim": { - "apply": "Obrezivanje ciklusa" + "apply": "Skraćivanje ciklusa" }, "merge": { "apply": "Spajanje ciklusa" diff --git a/custom_components/ha_washdata/translations/panel/cs.json b/custom_components/ha_washdata/translations/panel/cs.json index 99434504..10691d73 100644 --- a/custom_components/ha_washdata/translations/panel/cs.json +++ b/custom_components/ha_washdata/translations/panel/cs.json @@ -78,9 +78,12 @@ "awaiting": "Čeká na schválení", "imported_tip": "Importováno z komunitního obchodu. Používá se pouze pro porovnávání, nezapočítává se do statistik.", "not_importable": "nedostupné", - "exists": "existuje" + "exists": "existuje", + "backfilled_tip": "Detekováno v importované historii výkonu. Ovlivňuje pouze přiřazování programů, nezapočítává se do statistik." }, "btn": { + "set_brand_model": "Nastavit značku a model", + "refresh_catalog": "Obnovit katalog", "add_device": "+ Přidat zařízení", "add_device_tip": "Přidejte další zařízení WashData", "add_maintenance": "Přidat údržbovou událost", @@ -234,14 +237,19 @@ "download_device": "Stáhnout nastavení zařízení", "share_device": "Sdílet nastavení zařízení", "share_device_tip": "Sdílet programy a nastavení tohoto zařízení s komunitou", - "share_n": "Sdílet {n} program{s}", + "share_n": "Sdílet cykly: {n}", "export_selected": "Export (vybrat data)", "export_all": "Rychlý export všeho", "import_raw": "Pokročilé: nahradit vše ze souboru JSON", "download_export": "Stáhnout export", "analyze_import": "Analyzovat soubor", "import_selected": "Importovat vybrané", - "back": "Zpět" + "back": "Zpět", + "import_power_history": "Importovat historii výkonu", + "hist_read_recorder": "Načíst z Home Assistant", + "hist_scan": "Vyhledat cykly", + "hist_import_n": "Importovat cykly: {n}", + "hist_goto_cycles": "Zobrazit cykly" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Musí být pod Max. výkonem ochrany před zmačkáním ({max} W)" }, "attn_sub": "Před uložením opravte konflikty", - "attn_title": "{n} konflikt{s} nastavení", + "attn_title": "Konflikty nastavení: {n}", "confidence": { "auto": "Musí být na nebo nad Prahem shody ({match})", - "learning": "Musí být na nebo pod Prahem shody ({match})", + "learning": "Musí být na nebo nad Prahem shody ({match})", "match_for_auto": "Musí být na nebo pod Důvěrou auto-označení ({alc})", - "match_for_learning": "Musí být na nebo nad Důvěrou učení ({lc})" + "match_for_learning": "Musí být na nebo pod Důvěrou učení ({lc})" }, "duration_ratio": { "max": "Musí být větší než Min. poměr délky ({min})", @@ -296,14 +304,14 @@ "match": "Musí být nad Prahem neshody ({un})", "unmatch": "Musí být pod Prahem shody ({match}); jinak potvrzená shoda se okamžitě zruší" }, - "cascade_toast": "Bylo také upraveno {n} nastavení pro zachování konzistence.", + "cascade_toast": "Další nastavení upravena pro zachování konzistence: {n}", "suggestion_resolves": "Uplatněte níže čekající návrh ({val}) pro vyřešení tohoto konfliktu", "use_fix": "Použít {val}", "watchdog": { "interval": "Měl by být alespoň 2× Interval vzorkování ({si} s)", "sampling": "Interval vzorkování by měl být nejvýše polovina Intervalu hlídače ({wi} s)" }, - "settings_banner": "{n} konflikt{s} nastavení – zkontrolujte zvýrazněné sekce a opravte před uložením.", + "settings_banner": "Konflikty nastavení: {n}. Zkontrolujte zvýrazněné sekce a opravte je před uložením.", "settings_banner_btn": "Přejít na první" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Výsledek simulace", "pg_across_cycles": "Napříč vašimi cykly", "community_store": "Komunitní obchod", - "online_account": "Komunitní obchod a online funkce" + "online_account": "Komunitní obchod a online funkce", + "import_power_history": "Import historie výkonu" }, "health": { "fair": "Přijatelná shoda", @@ -364,6 +373,7 @@ "poor": "⚠ Špatná shoda" }, "lbl": { + "drag_to_resize": "Přetažením změníte velikost", "actions": "Akce", "activity": "Aktivita", "administrators": "Správci", @@ -435,7 +445,7 @@ "from": "Od", "gap_s": "mezera (y)", "group_name": "Název skupiny", - "head_trim": "Obložení hlavy (y)", + "head_trim": "Oříznutí začátku (s)", "health": "Zdraví", "hide_tabs": "Skrýt karty pro neadministrátory", "in_use": "Používá se", @@ -451,7 +461,7 @@ "metric": "Metrika", "mode_existing_profile": "Přidat do stávajícího profilu", "mode_new_profile": "Vytvořit nový profil", - "models_fine_tuned": "({count} modelů doladěno)", + "models_fine_tuned": "(doladěné modely: {count})", "n_classic_suggestions": "{n} klasický", "n_ml_suggestions": "{n} ML", "n_selected": "{n} vybráno", @@ -533,7 +543,7 @@ "stage3": "Fáze 3 – DTW", "stage4": "Fáze 4 – shoda", "status": "Stav", - "tail_trim": "Trimování ocasu (y)", + "tail_trim": "Oříznutí konce (s)", "timer_auto_pause": "Automatická pauza", "timer_min": "min", "timer_msg_placeholder": "Zpráva (volitelné, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_resolution": "Konflikty názvů", "conflict_import_copy": "Importovat jako kopii", "conflict_keep_mine": "Ponechat moje", - "conflict_overwrite": "Přepsat" + "conflict_overwrite": "Přepsat", + "hist_csv_data": "Data CSV", + "hist_from_recorder": "Nebo ji načtěte z Home Assistant", + "days": "dní", + "hist_keep": "Zachovat tento cyklus", + "hist_looks_complete": "dokončený", + "peak_power_short": "Špička", + "shape": "Tvar", + "hist_skip_idle": "nic neběželo", + "hist_skip_sparse": "měření příliš daleko od sebe", + "hist_skip_short": "příliš málo měření", + "hist_skip_long": "žádná dost dlouhá pauza pro rozdělení", + "hist_reason_short": "kratší než nejkratší skutečný cyklus tohoto spotřebiče", + "hist_reason_no_end": "nikdy řádně neskončil", + "hist_since": "Od", + "task_history_import": "Prohledávání historie výkonu", + "task_history_import_apply": "Import cyklů", + "evidence_real_cycles": "Cykly, které tento spotřebič provedl", + "evidence_reference_cycles": "Stažené z komunitního obchodu", + "evidence_backfill_cycles": "Nalezené v importované historii výkonu" }, "log": { "all_levels": "Všechny úrovně", @@ -756,9 +785,15 @@ "store_share": "Sdílet do komunitního obchodu", "store_share_device": "Sdílet nastavení zařízení", "export_select": "Export - vybrat data", - "import_wizard": "Import - vybrat data" + "import_wizard": "Import - vybrat data", + "history_import": "Import historie výkonu" }, "msg": { + "tail_trim_hint": "Kolik sekund odstranit z konce", + "store_sibling_hint": "Pro váš konkrétní model není nic nasdíleno? Velmi podobný model od stejné značky je obvykle dobrý výchozí bod.", + "store_declare_appliance": "Řekněte WashData, jaký spotřebič máte, a tato karta zobrazí nastavení, která pro něj nasdíleli ostatní uživatelé. Můžete také zadat značku výše a jen se rozhlédnout.", + "refresh_catalog_hint": "Seznamy značek a spotřebičů z komunity se ukládají do mezipaměti, aby sdílený obchod nepřekročil svůj denní limit dotazů. Obnovením načtete položky, které ostatní přidali nebo schválili.", + "head_trim_hint": "Kolik sekund odstranit ze začátku", "appliance_monitor": "Monitor spotřebiče", "artifact_dip_detail": "Klesl pod obvyklé pásmo výkonu po dobu ~{n} s.", "artifact_footer": "Zvýrazněno na grafu výše. Jedná se o přechodné artefakty (např. dveře otevřené uprostřed cyklu), ne nutně problémy.", @@ -769,7 +804,7 @@ "automations_intro": "WashData vyvolává události {start} / {end} a zpřístupňuje entity. Oznámení a akce se nejlépe nastavují jako standardní automatizace Home Assistant. Automatizace používající toto zařízení se zobrazují níže.", "cleanup_intro": "Každý označený cyklus se překrývá. Zaškrtnutím odlehlých hodnot a odstraněním se profil vyčistí.", "clear_debug_hint": "Odstraňte uložená ladicí data, abyste uvolnili místo.", - "collecting_data": "Shromažďování dat: ještě {need} cyklů do zahájení jemného doladění ({current}/{min}).", + "collecting_data": "Shromažďování dat. Zbývající cykly do zahájení doladění: {need} ({current}/{min}).", "compare_overlay_profiles": "Překryvné profily (slabé)", "compare_profiles_tip": "Překryjte další obálky profilu na výše uvedené tabulce a zjistěte, která z nich nejlépe vyhovuje tomuto cyklu.", "compare_selected_cycles": "Vybrané cykly (plné) – zobrazit / skrýt", @@ -779,7 +814,7 @@ "cycles_deleted": "Smazáno cyklů: {count}", "enough_data": "Dostatek dat k učení ({current}/{min} cyklů).", "export_description": "Vyberte přesně, které profily, cykly, nastavení a další data exportovat do souboru JSON, nebo analyzujte soubor a importujte jen ty části, které chcete.", - "feedback_cycles_pending": "{n} cyklus{s} ke kontrole", + "feedback_cycles_pending": "Ke kontrole: {n}", "feedback_prompt": "Potvrďte, že to bylo správné, opravte program nebo jej ignorujte.", "feedback_relabel_hint": "Přeznačení tohoto cyklu jej také vyřeší.", "filter_by_profile": "Filtrovat podle profilu…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "simulace nečinnosti začíná zde", "pg_sweep_intro": "Co kdyby {param} byl jiný? Otestujte {steps} hodnot na vašich posledních {cycles} cyklech a najděte nastavení, při kterém je správně spárováno nejvíce cyklů.", "pg_sweep_step": "Krok {done} / {total}", - "pg_undetected": "{n} nedetekovaných cyklů", "pg_verdict_bad": "Vyžaduje pozornost: mnoho cyklů zůstává nedetekováno.", "pg_verdict_good": "Dobře vyladěno: většina cyklů je správně rozpoznána a spárována.", "pg_verdict_ok": "Přijatelné: některé cykly nebyly zachyceny. Zkuste snížit práh spuštění.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Označte to jako ručně vybraný referenční cyklus pro svůj program – stejná role jako ručně zaznamenaný cyklus. Referenční cykly jsou vždy zachovány, nasazují odpovídající šablonu a při čištění se nikdy nevynechají. (Toto je \"zlatá\" / zaznamenaná vlajka; obě jsou totéž.)", "review_tags_tip": "Volitelné příznaky popisující, co se v tomto cyklu pokazilo, takže za to může školení a čištění.", "review_to_cycles": "Otevřete frontu kontroly cyklů", + "samples_decimated": "Zobrazeno {shown} z {total} vzorků (prořídnuto pro zobrazení; špičky zachovány). Široká mezera zde znamená prořídnutí, nikoli chybějící data.", "saving_triggers_reload": "Uložení spustí opětovné načtení integrace. Entity HA se mohou krátce zobrazit jako nedostupné.", "search_placeholder": "Vyhledávání nastavení…", "see_recorder": "Viz widget rekordéru níže", @@ -991,12 +1026,12 @@ "share_consent": "Sdílíte skutečná data ze svého spotřebiče. Nesdílejte, pokud jsou vaše vzorce používání soukromé.", "share_device_none": "Zatím nejsou nastavena žádná zařízení. Nejprve přidejte zařízení.", "share_guideline_naming": "Používejte srozumitelné názvy programů (např. 'Cotton 40', 'Eco 60'), aby je ostatní mohli identifikovat", - "share_guideline_quality": "Sdílejte pouze profily s ⭐ referenčními cykly nebo alespoň {n} potvrzeného spuštění", + "share_guideline_quality": "Sdílejte pouze cykly, které proběhly normálně: bez přerušení uprostřed cyklu, otevření dvířek nebo krátkých výpadků napájení.", "share_guideline_review": "Před sdílením zkontrolujte své profily -- odstraňte ty, které vypadají špatně", "share_guidelines_title": "Před sdílením", "store_download_device_intro": "Stáhnout nastavení zařízení komunity a použít ho na nové nebo stávající zařízení", - "store_share_device_intro": "Sdílet programy vašeho zařízení (profily + referenční cykly) s komunitou. Nastavení jsou volitelná.", - "share_profile_no_cycles": "Profil '{p}' nemá žádné ⭐ referenční cykly -- bude přeskočen, pokud nemáte {n}+ potvrzených spuštění", + "store_share_device_intro": "Nahrajte {brand} {model} s referenčními cykly, které vyberete. Ostatní se stejným spotřebičem mohou vaše programy přijmout. Záznamy jsou před zveřejněním zkontrolovány.", + "share_profile_no_cycles": "Žádné referenční cykly. Chcete-li tento profil zahrnout, označte cyklus jako ⭐ na kartě Cykly", "advisory_phase_inconsistent": "Zdá se, že '{name}' míchá různé programy nebo teploty - jeho cykly se ohřívají po velmi různě dlouhou dobu. Rozdělení na samostatné profily (např. podle teploty) zlepší shodu i odhady času.", "advisory_phase_inconsistent_title": "⚠ Možná smíšené programy", "export_select_intro": "Zaškrtněte přesně to, co má být zahrnuto. Výběr profilů bez jejich cyklů stále exportuje rozpoznatelný program (jeho naučená křivka se přenese s ním).", @@ -1006,7 +1041,29 @@ "merge_hint": "Importované položky se přidají; nic místního se neztratí. Konflikty názvů se řeší níže.", "replace_warn": "Každá zaškrtnutá kategorie se vymaže a nahradí daty ze souboru. Nezaškrtnuté kategorie zůstanou beze změny.", "dest_reference_hint": "Importované cykly pouze zlepšují rozpoznávání programů a nikdy neovlivňují statistiky spotřeby/energie.", - "dest_real_history_hint": "Importované cykly se počítají jako vlastní historie tohoto zařízení a přispívají do statistik energie/spotřeby. Použijte při přesunu jednoho spotřebiče do nové instalace." + "dest_real_history_hint": "Importované cykly se počítají jako vlastní historie tohoto zařízení a přispívají do statistik energie/spotřeby. Použijte při přesunu jednoho spotřebiče do nové instalace.", + "import_history_description": "Měli jste chytrou zástrčku už před WashData? Nahrajte export historie jejího snímače výkonu nebo ji načtěte přímo z Home Assistant a proběhne nad ní běžná detekce, takže se dřívější cykly objeví v seznamu Cykly připravené k pojmenování.", + "hist_input_hint": "Nahrajte CSV stažené z panelu Historie (entita, stav, poslední změna), nebo nechte WashData načíst historii snímače přímo. Detekce pak proběhne úplně stejně jako naživo a vy vyberete, které z nalezených cyklů zachovat.", + "hist_recorder_hint": "Načte data od zvoleného data až do teď. Home Assistant ve výchozím nastavení uchovává podrobnou historii 10 dní a poté už jen hodinové průměry, které jsou pro detekci cyklů příliš hrubé - dřívější datum volte jen tehdy, pokud máte recorder nastavený na delší uchovávání.", + "hist_scanning": "Přehráváme vaši historii přes detektor. Běží to na pozadí - tento dialog můžete zavřít a vrátit se k němu později.", + "hist_imported_count": "Importované cykly: {n}.", + "hist_duplicates": "Již dříve importováno a přeskočeno: {n}.", + "hist_capped": "Byl dosažen limit importovaných cyklů na zařízení; zbytek nebyl uložen.", + "hist_next_step": "Najdete je v seznamu Cykly, označené jako importovaná historie. Otevřete některý z nich a tlačítkem Označit pojmenujte program, ke kterému patří.", + "hist_rows_read": "Přečtená měření: {n}", + "hist_breaks": "Mezery, kdy byl snímač nedostupný: {n}", + "hist_other_entity": "Ignorovaná měření jiných entit: {n}", + "hist_entity_substituted": "Načteno {used} (toto zařízení má nastavené {wanted})", + "hist_skipped_spans": "Přeskočené úseky", + "hist_settings_used": "Detekováno s aktuálním nastavením tohoto zařízení (minimální výkon {w} W, zpoždění vypnutí {s} s).", + "hist_none_found": "V této historii se nepodařilo detekovat žádné cykly.", + "hist_found": "Nalezené cykly: {n}. Odškrtněte vše, co nevypadá jako skutečný běh - dokud neprovedete import, nic se neuloží.", + "hist_scan_capped": "Zobrazují se jen první kandidáti (nalezeno bylo {n}).", + "hist_recorder_empty": "Home Assistant nemá pro tento snímač v daném období žádnou podrobnou historii.", + "hist_scan_failed": "Vyhledávání se nezdařilo.", + "hist_scan_expired": "Toto vyhledávání už není dostupné. Spusťte je prosím znovu.", + "hist_import_failed": "Import se nezdařil.", + "imported_history_readonly": "Detekováno v importované historii výkonu. Ovlivňuje přiřazování programů, ale nezapočítává se do vašich statistik a nelze jej oříznout ani rozdělit. Označením mu přiřaďte program." }, "phase_desc": { "anti_crease": "Občasné krátké otáčení bubnu po dokončení cyklu pro prevenci mačkání.", @@ -1392,6 +1449,10 @@ "doc": "Ukládejte plný záznam příkonu a ladicí data párování pro každý cyklus. Užitečné pro odstraňování problémů, ale zvyšuje velikost úložiště.", "label": "Uložit ladící stopy" }, + "smart_termination_duration_ratio": { + "doc": "Jak daleko v očekávané době trvání přiřazeného programu musí cyklus být, než jej Chytré ukončení může po poklesu příkonu ukončit dříve. Očekávaná doba trvání je průměr programu, takže u spotřebičů, jejichž doba běhu hodně kolísá - pračky se studenou přiváděnou vodou v zimě oproti teplé v létě, sušičky se senzorovým sušením, programy závislé na náplni - skončí zhruba polovina všech běhů dříve než tento průměr a nikdy se rychlého ukončení nedočká, takže skončí až o několik minut později přes záložní časový limit. U takových strojů tuto hodnotu snižte (např. 0,85), aby se dřívější ukončení přesto spustilo; pro opatrnější chování ji zvyšte směrem k 1,0. Ponechte prázdné pro výchozí hodnotu (0,98, u myček nádobí 0,99). Cyklus může vždy jen ukončit dříve, nikdy později, a nikdy se nespustí u nejednoznačné shody nebo shody s nízkou spolehlivostí.", + "label": "Poměr chytrého ukončení" + }, "smoothing_window": { "doc": "Míra vyhlazení nezpracovaného signálu příkonu. Nízká hodnota (2) je citlivá, ale hlučná; vysoká (5) vyhlazuje špičky, ale přidává zpozdění.", "label": "Okno vyhlazení" @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Čekání na otevření dveří pro ukončení", "doc": "Jak dlouho musí dveře zůstat otevřené, než WashData ukončí cyklus, pokud je zapnuta funkce \"Dveře se na konci automaticky otevřou\". Dostatečně dlouhé k ignorování rychlého přidání nádobí (výchozí 60 s), dostatečně krátké pro rychlé ukončení po otevření dveří spotřebičem." + }, + "profile_evidence_sources": { + "label": "Cykly tvořící program", + "doc": "Které cykly se používají k sestavení křivky výkonu každého programu a k přiřazení dokončeného cyklu k ní. Když některý druh odškrtnete, přestane ovlivňovat vaše programy, ale nic se nesmaže - cykly zůstanou v seznamu Cykly a stále je lze označit nebo odstranit. Užitečné, pokud nedůvěřujete importovaným datům. Statistiky to neovlivní: vždy počítají jen cykly, které tento spotřebič skutečně provedl. Odškrtnutí všech možností se ignoruje, protože program bez cyklů by nikdy nemohl být přiřazen." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Základní konfigurace" + }, + "profile_evidence": { + "label": "Podklady profilu" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Ořezávání…", "splitting": "Rozdělování…", "deleting": "Mazání…", - "imported": "Importováno" + "imported": "Importováno", + "preparing": "Příprava…" }, "suggestion": { "both_agree": "WashData doporučuje", @@ -1725,7 +1794,7 @@ "thr_batch": "Ponecháno těsně nad nejnižším činným výkonem p05 napříč {cycles} cykly ({p05}W), aby byl start zachycen co nejdříve a práh zastavení zůstal pod nejnižším provozním výkonem stroje.", "tol_per_profile": "p75 rozptylu délky podle profilu napříč {profiles} profily ({cycles} cyklů); konzistentní profily nejsou penalizovány.", "tol_pooled": "Na základě sloučeného rozptylu délky {cycles} nedávných označených cyklů (odchylka p95={dev}).", - "watchdog": "Ponecháno co nejnižší při zachování bezpečnosti (těsně nad intervalem aktualizace p95 ve výši {p95}s, min. 30s), aby byla zaseknutí rychle odhalena bez falešných zastavení." + "watchdog": "Ponecháno co nejnižší při zachování bezpečnosti (těsně nad intervalem aktualizace p95 ve výši {p95}s a alespoň 2x nad intervalem vzorkování {median}s, min. 30s), aby byla zaseknutí rychle odhalena bez falešných zastavení." }, "exclusions": { "summary": "Vyloučeno {total} chybně detekovaných cyklů: {parts}.", @@ -1772,6 +1841,7 @@ "wrong_profile": "Špatný profil" }, "toast": { + "catalog_refreshed": "Katalog komunity obnoven", "access_saved": "Řízení přístupu bylo uloženo", "all_wiped": "Všechna data vymazána", "analysis_complete_none": "Analýza dokončena: žádné nové návrhy", @@ -1865,19 +1935,21 @@ "rating_saved": "Hodnocení kvality uloženo", "brand_added": "Značka přidána, čeká na schválení", "profile_added": "Profil přidán, čeká na schválení", - "saved_except_conflicts": "Nastavení uložena -- {n} položka{s} přeskočena kvůli konfliktům", + "saved_except_conflicts": "Uloženo. Opravte zvýrazněné konflikty, aby se uložil zbytek.", "share_device_none_sel": "Vyberte alespoň jeden program ke sdílení", - "store_device_downloaded": "Nastavení zařízení staženo: {created} profil{c} vytvořen, {dup} již existovalo", - "store_device_downloaded_phases": "Nastavení zařízení staženo: {created} profil{c} vytvořen, {dup} již existovalo, mapa fází použita", - "store_device_downloaded_settings": "Nastavení zařízení staženo: {created} profil{c} vytvořen, {dup} již existovalo, nastavení použita", - "store_device_shared": "Nastavení zařízení sdíleno: {n} program{s} nahrán", - "store_device_shared_all_dup": "Nic nového ke sdílení -- všechny programy v obchodě již existují", - "store_device_shared_partial": "Částečné sdílení: {n} program{s} nahrán, {failed} přeskočeno", - "store_device_shared_some_dup": "Nastavení zařízení sdíleno: {n} program{s} nahrán ({dup} již existovalo)", + "store_device_downloaded": "Přidáno programů: {p}, záznamů: {c}", + "store_device_downloaded_phases": "Přidáno programů: {p}, záznamů: {c}, map fází: {ph}", + "store_device_downloaded_settings": "Přidáno programů: {p}, záznamů: {c}, map fází: {ph}, nastavení: {s}", + "store_device_shared": "Sdíleno do komunitního obchodu (cyklů: {n}), čeká na kontrolu.", + "store_device_shared_all_dup": "Všechny vybrané cykly ({n}) už v komunitním obchodě jsou.", + "store_device_shared_partial": "Sdíleno cyklů: {n}; {failed} se nepodařilo nahrát.", + "store_device_shared_some_dup": "Sdíleno cyklů: {created}; již v obchodě: {dup}.", "store_download_failed": "Stahování selhalo: {error}", "store_download_nothing": "Nic ke stažení -- všechny profily na tomto zařízení již existují", "export_selective_done": "Export stažen", - "import_selective_done": "Importováno {profiles} profilů a {cycles} cyklů" + "import_selective_done": "Importováno {profiles} profilů a {cycles} cyklů", + "hist_csv_required": "Nejprve načtěte soubor CSV nebo vložte jeho obsah", + "file_read_failed": "Tento soubor se nepodařilo přečíst" }, "trend": { "down": "Klesající trend", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Myčka: tiché sekundy po očekávané době trvání, než se uvolní čekání na závěrečné odčerpání vody", + "smart_termination_duration_ratio": "Zlomek očekávané doby trvání přiřazeného programu, kterého musí cyklus dosáhnout, než jej Chytré ukončení může ukončit dříve; snižte jej u strojů závislých na náplni nebo teplotě", "anti_wrinkle_enabled": "Pohltit impulzy bubnu po hlavní fázi místo čtení jako nové cykly", "anti_wrinkle_exit_power": "Výkon musí mezi impulzy klesnout pod tuto hodnotu, aby ochrana před zmačkáním zůstala aktivní", "anti_wrinkle_idle_timeout": "Povolený klid mezi dvěma impulzy bubnu, než ochrana před zmačkáním skončí", @@ -1955,6 +2028,10 @@ "finished": "Cyklus dosáhl koncového stavu a skončil." }, "store": { + "your_model_tip": "Toto je spotřebič, který jste zadali v Nastavení", + "your_model": "Vaše", + "search_brand_ph": "Hledat podle značky…", + "programs_count": "Programy: {n}", "browse": "Procházet", "device": "Zařízení", "favorites": "Oblíbené", diff --git a/custom_components/ha_washdata/translations/panel/da.json b/custom_components/ha_washdata/translations/panel/da.json index 47869a4d..e1762b97 100644 --- a/custom_components/ha_washdata/translations/panel/da.json +++ b/custom_components/ha_washdata/translations/panel/da.json @@ -78,9 +78,12 @@ "awaiting": "Afventer godkendelse", "imported_tip": "Importeret fra fællesskabsbutikken. Bruges kun til matchning, tælles ikke med i statistikken.", "not_importable": "ikke her", - "exists": "findes" + "exists": "findes", + "backfilled_tip": "Registreret i importeret effekthistorik. Påvirker kun programmatchning, tælles ikke med i statistikken." }, "btn": { + "set_brand_model": "Angiv mærke og model", + "refresh_catalog": "Opdater katalog", "add_device": "+ Tilføj enhed", "add_device_tip": "Tilføj en anden WashData-enhed", "add_maintenance": "Tilføj vedligeholdelseshændelse", @@ -90,7 +93,7 @@ "apply_label": "Påfør etiket", "apply_set_b": "Anvend sæt B", "apply_split": "Påfør Split", - "apply_trim": "Påfør Trim", + "apply_trim": "Anvend beskæring", "auto_detect_split": "Automatisk registrering", "auto_label_cycles": "Auto-label cyklusser", "auto_label_cycles_tip": "Tildel automatisk profilnavne til umærkede cyklusser, hvis matchkonfidens overstiger tærsklen", @@ -209,8 +212,8 @@ "stop": "Stop", "submit_correction": "Send rettelse", "train_now": "Træn nu", - "trim": "Trim", - "trim_split": "Trim / Split", + "trim": "Beskær", + "trim_split": "Beskær / Opdel", "undo": "Fortryd", "use": "Brug", "wipe_all": "Slet alle data", @@ -241,7 +244,12 @@ "import_selected": "Importér valgte", "back": "Tilbage", "mute_suggestion": "Stop med at foreslå denne indstilling", - "reset_muted": "Nulstil undertrykte forslag" + "reset_muted": "Nulstil undertrykte forslag", + "import_power_history": "Importér effekthistorik", + "hist_read_recorder": "Læs fra Home Assistant", + "hist_scan": "Scan efter cyklusser", + "hist_import_n": "Importér {n} cyklusser", + "hist_goto_cycles": "Vis mig cyklusserne" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Skal være under antikrøl-maksimumeffekten ({max} W)" }, "attn_sub": "Ret konflikter inden du gemmer", - "attn_title": "{n} indstillingskonflikt{s}", + "attn_title": "Indstillingskonflikter: {n}", "confidence": { "auto": "Skal være på eller over matchningstærsklen ({match})", - "learning": "Skal være på eller under matchningstærsklen ({match})", + "learning": "Skal være på eller over matchningstærsklen ({match})", "match_for_auto": "Skal være på eller under auto-label-konfidensniveauet ({alc})", - "match_for_learning": "Skal være på eller over læringskonfidensniveauet ({lc})" + "match_for_learning": "Skal være på eller under læringskonfidensniveauet ({lc})" }, "duration_ratio": { "max": "Skal være større end den minimale varighedsratio ({min})", @@ -296,14 +304,14 @@ "match": "Skal være over ikke-matchningstærsklen ({un})", "unmatch": "Skal være under matchningstærsklen ({match}); ellers ophæves en bekræftet matchning øjeblikkeligt" }, - "cascade_toast": "Desuden {n} indstilling{s} justeret for konsistens.", + "cascade_toast": "Andre indstillinger justeret for konsistens: {n}", "suggestion_resolves": "Anvend forslaget nedenfor ({val}) for at løse dette", "use_fix": "Brug {val}", "watchdog": { "interval": "Bør være mindst 2x samplingsintervallet ({si} s)", "sampling": "Samplingsinterval bør højst være halvdelen af watchdog-intervallet ({wi} s)" }, - "settings_banner": "{n} indstillingskonflikt{s} – tjek de fremhævede sektioner og ret dem inden du gemmer.", + "settings_banner": "Indstillingskonflikter: {n}. Tjek de fremhævede sektioner og ret dem, inden du gemmer.", "settings_banner_btn": "Gå til første" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Simuleringsresultat", "pg_across_cycles": "På tværs af dine cyklusser", "community_store": "Fællesskabsbutik", - "online_account": "Fællesskabsbutik og onlinefunktioner" + "online_account": "Fællesskabsbutik og onlinefunktioner", + "import_power_history": "Importér effekthistorik" }, "health": { "fair": "Acceptabel matchkvalitet", @@ -364,6 +373,7 @@ "poor": "⚠ Dårlig matchkvalitet" }, "lbl": { + "drag_to_resize": "Træk for at ændre størrelse", "actions": "Handlinger", "activity": "Aktivitet", "administrators": "Administratorer", @@ -450,7 +460,7 @@ "metric": "Metrik", "mode_existing_profile": "Føj til eksisterende profil", "mode_new_profile": "Opret ny profil", - "models_fine_tuned": "({count} model{plural} finjusteret)", + "models_fine_tuned": "(finjusterede modeller: {count})", "n_classic_suggestions": "{n} klassisk", "n_ml_suggestions": "{n} ML", "n_selected": "{n} valgt", @@ -677,7 +687,26 @@ "conflict_keep_mine": "Behold mine", "conflict_overwrite": "Overskriv", "pg_anti_wrinkle": "Anti-rynke", - "font_size": "Panel-skriftstørrelse" + "font_size": "Panel-skriftstørrelse", + "hist_csv_data": "CSV-data", + "hist_from_recorder": "Eller læs den fra Home Assistant", + "hist_since": "Siden", + "days": "dage", + "hist_keep": "Behold denne cyklus", + "hist_looks_complete": "komplet", + "peak_power_short": "Spids", + "shape": "Form", + "hist_skip_idle": "intet kørte", + "hist_skip_sparse": "aflæsninger for langt fra hinanden", + "hist_skip_short": "for få aflæsninger", + "hist_skip_long": "ingen pause lang nok at opdele på", + "hist_reason_short": "kortere end dette apparats korteste rigtige cyklus", + "hist_reason_no_end": "sluttede aldrig rent", + "task_history_import": "Scanner effekthistorik", + "task_history_import_apply": "Importerer cyklusser", + "evidence_real_cycles": "Cyklusser, denne maskine har kørt", + "evidence_reference_cycles": "Hentet fra fællesskabsbutikken", + "evidence_backfill_cycles": "Fundet i importeret effekthistorik" }, "log": { "all_levels": "Alle niveauer", @@ -756,9 +785,15 @@ "store_share": "Del i fællesskabsbutikken", "store_share_device": "Del dette apparat", "export_select": "Eksport - vælg data", - "import_wizard": "Import - vælg data" + "import_wizard": "Import - vælg data", + "history_import": "Importér effekthistorik" }, "msg": { + "tail_trim_hint": "Fjern så mange sekunder fra slutningen", + "store_sibling_hint": "Er der intet delt til netop din model? En nært beslægtet model fra samme mærke er normalt et godt udgangspunkt.", + "store_declare_appliance": "Fortæl WashData, hvilket apparat du har, så viser denne fane de opsætninger, andre har delt til det. Du kan også skrive et mærke ovenfor for at se dig omkring.", + "refresh_catalog_hint": "Fællesskabets lister over mærker og apparater gemmes i cachen, så fællesskabsbutikken holder sig inden for sin daglige grænse. Opdater for at hente bidrag, som andre har tilføjet eller godkendt.", + "head_trim_hint": "Fjern så mange sekunder fra starten", "appliance_monitor": "Apparat monitor", "artifact_dip_detail": "Faldt under det normale effektbånd i ~{n}s.", "artifact_footer": "Fremhævet på grafen ovenfor. Disse er forbigående artefakter (f.eks. døren åbnet midt i cyklussen), ikke nødvendigvis problemer.", @@ -769,7 +804,7 @@ "automations_intro": "WashData udløser {start} / {end}-hændelser og stiller entiteter til rådighed, så notifikationer og handlinger bedst bygges som normale Home Assistant-automatiseringer. Automatiseringer, der bruger denne enhed, vises nedenfor.", "cleanup_intro": "Hver mærket cyklus overlejret. Sæt kryds ved afvigelser og slet for at rydde op i profilen.", "clear_debug_hint": "Fjern gemte fejlretningsdata for at frigøre plads.", - "collecting_data": "Indsamler data: {need} cyklus{plural} mere, før finjustering kan starte ({current}/{min}).", + "collecting_data": "Indsamler data. Cyklusser der stadig mangler, før finjustering kan starte: {need} ({current}/{min}).", "compare_overlay_profiles": "Overlejringsprofiler (svage)", "compare_profiles_tip": "Overlæg andre profilkonvolutter på skemaet ovenfor for at se, hvilken der passer bedst til denne cyklus.", "compare_selected_cycles": "Valgte cyklusser (fast) – vis/skjul", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} cyklus(ser) slettet", "enough_data": "Nok data til at lære af ({current}/{min} cyklusser).", "export_description": "Vælg præcis hvilke profiler, cyklusser, indstillinger og mere der skal eksporteres til JSON, eller analysér en fil og importér kun de dele, du vil have.", - "feedback_cycles_pending": "{n} cyklus{s} til gennemgang", + "feedback_cycles_pending": "Til gennemgang: {n}", "feedback_prompt": "Bekræft, at det var rigtigt, ret programmet, eller ignorer.", "feedback_relabel_hint": "At mærke denne cyklus om løser også gennemgangen.", "filter_by_profile": "Filtrer efter profil…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "inaktiv simulering begynder her", "pg_sweep_intro": "Hvad hvis {param} var anderledes? Test {steps} værdier på tværs af dine seneste {cycles} cyklusser for at finde indstillingen, hvor flest cyklusser matches korrekt.", "pg_sweep_step": "Trin {done} / {total}", - "pg_undetected": "{n} cyklus{s} ikke registreret", "pg_verdict_bad": "Kræver opmærksomhed: mange cyklusser bliver ikke registreret.", "pg_verdict_good": "Godt indstillet: de fleste cyklusser identificeres og matches korrekt.", "pg_verdict_ok": "Acceptabelt: nogle cyklusser blev overset. Prøv at sænke starttærsklen.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Marker dette som en håndplukket referencecyklus for dets program - samme rolle som en manuelt optaget cyklus. Referencecyklusser opbevares altid, danner grundlag for matchskabelonen og droppes aldrig ved oprydning. (Dette er det \"gyldne\"/optagede flag; begge er det samme.)", "review_tags_tip": "Valgfrie flag, der beskriver, hvad der gik galt med denne cyklus, så træning og oprydning kan forklare det.", "review_to_cycles": "Åbn køen Cycles review", + "samples_decimated": "Viser {shown} af {total} prøver (udtyndet til visning; spidser bevares). Et bredt hul her er udtynding, ikke manglende data.", "saving_triggers_reload": "Gemning udløser en genindlæsning af integration. HA-enheder kan kortvarigt vises som utilgængelige.", "search_placeholder": "Søgeindstillinger...", "see_recorder": "Se optager-widget nedenfor", @@ -925,7 +960,7 @@ "trend_energy_up": "Energitrends op ({pct} %/cyklus) – seneste gennemsnit {avg}", "trim_destructive_confirm": "Dermed bevares kun {pct}% af cyklussen. Dette kan ikke fortrydes. Fortsæt?", "trim_intro": "Træk i de røde håndtag, eller indtast værdier. Alt uden for vinduet fjernes.", - "tuning_suggestions_available": "{count} indstillingsforslag tilgængeligt fra observerede cyklusser. De vises ved siden af ​​de relevante felter.", + "tuning_suggestions_available": "{count} indstillingsforslag tilgængeligt fra observerede cyklusser. De vises ved siden af de relevante felter.", "unsure_detected_prefix": "WashData er usikker på, om det er registreret", "updated": "Opdateret", "warmup_badge": "Lærer stadig ({done}/{needed} cyklusser)", @@ -1006,10 +1041,33 @@ "sug_mute_failed": "Kunne ikke undertrykke forslaget", "sug_unmuted_all": "Undertrykte forslag nulstillet", "n_suggestions_muted": "{count} undertrykt; autotuner vil ikke foreslå disse.", - "font_size_hint": "Gør alt i dette panel større eller mindre. Gælder for din konto på denne enhed." + "font_size_hint": "Gør alt i dette panel større eller mindre. Gælder for din konto på denne enhed.", + "import_history_description": "Havde du allerede et smartstik, før du fik WashData? Upload en historikeksport fra dets effektsensor, eller lad den blive læst direkte fra Home Assistant, og den normale registrering kører hen over den, så tidligere cyklusser dukker op i din Cykler-liste, klar til at få navn.", + "hist_input_hint": "Upload en CSV-fil hentet fra Historik-panelet (entitet, tilstand, sidst ændret), eller lad WashData læse sensorens historik direkte. Registreringen kører derefter hen over den præcis som live, og du vælger, hvilke af de fundne cyklusser du vil beholde.", + "hist_recorder_hint": "Læser fra den dato, du vælger, og frem til nu. Home Assistant gemmer som standard detaljeret historik i 10 dage og derefter kun timegennemsnit, som er for grove at registrere cyklusser ud fra - vælg kun en dato længere tilbage, hvis din recorder er sat til at gemme mere.", + "hist_scanning": "Din historik spilles igennem registreringen. Dette kører i baggrunden - du kan lukke denne dialog og vende tilbage til den senere.", + "hist_imported_count": "{n} cyklusser importeret.", + "hist_duplicates": "{n} var allerede importeret og blev sprunget over.", + "hist_capped": "Grænsen pr. enhed for importerede cyklusser blev nået; resten blev ikke gemt.", + "hist_next_step": "De ligger i din Cykler-liste, mærket som importeret historik. Åbn en, og brug Mærk til at give det tilhørende program navn.", + "hist_rows_read": "{n} aflæsninger læst", + "hist_entity_substituted": "læste {used} (denne enhed er konfigureret til {wanted})", + "hist_breaks": "{n} huller hvor sensoren var utilgængelig", + "hist_other_entity": "{n} aflæsninger for andre entiteter ignoreret", + "hist_skipped_spans": "Udeladte strækninger", + "hist_settings_used": "Registreret med denne enheds nuværende indstillinger (minimumseffekt {w} W, slukkeforsinkelse {s} s).", + "hist_none_found": "Der kunne ikke registreres nogen cyklusser i den historik.", + "hist_found": "Fandt {n} cyklusser. Fjern fluebenet ved alt, der ikke ser ud som en rigtig kørsel - der gemmes intet, før du importerer.", + "hist_scan_capped": "Kun de første kandidater vises ({n} blev fundet).", + "hist_recorder_empty": "Home Assistant har ingen detaljeret historik for denne sensor i det vindue.", + "hist_scan_failed": "Scanningen mislykkedes.", + "hist_scan_expired": "Den scanning er ikke længere tilgængelig. Scan igen.", + "hist_import_failed": "Importen mislykkedes.", + "imported_history_readonly": "Registreret i importeret effekthistorik. Den påvirker programmatchning, men tælles ikke med i din statistik og kan ikke beskæres eller opdeles. Mærk den for at give programmet navn." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Opvaskemaskine: stille sekunder efter den forventede varighed, før ventetiden på udpumpning ved cyklusslut frigives", + "smart_termination_duration_ratio": "Andel af det genkendte programs forventede varighed, som en cyklus skal nå, før smart afslutning må afslutte den tidligt; sænk den for belastnings- eller temperaturafhængige maskiner", "completion_min_seconds": "Korteste kørsel, der tæller som en rigtig cyklus", "end_repeat_count": "Lave målinger i træk før afslutning", "interrupted_min_seconds": "Korte cyklusser markeres som afbrudt", @@ -1166,7 +1224,7 @@ "label": "Minimum cyklusvarighed" }, "delay_confirm_seconds": { - "doc": "Strømmen skal forblive i standby-båndet så længe, ​​før apparatet behandles som ventende på at starte i stedet for at køre.", + "doc": "Strømmen skal forblive i standby-båndet så længe, før apparatet behandles som ventende på at starte i stedet for at køre.", "label": "Bekræftelsessekunder" }, "delay_start_detect_enabled": { @@ -1194,7 +1252,7 @@ "label": "Anvend smarte modeller under en cyklus" }, "end_energy_threshold": { - "doc": "Under nedtællingen af ​​slukket forsinkelse sammenlignes akkumuleret energi (watt x tid) med denne tærskel. Hvis det overskrides, nulstilles nedtællingen - ved at holde anti-krøl tumblere og opvaskemaskinens tørrehaler fastgjort til cyklussen i stedet for at afkorte dem. Hæv den, hvis cyklusser slutter for tidligt under nedkøling; sænk den, hvis detektionen er træg.", + "doc": "Under nedtællingen af slukket forsinkelse sammenlignes akkumuleret energi (watt x tid) med denne tærskel. Hvis det overskrides, nulstilles nedtællingen - ved at holde anti-krøl tumblere og opvaskemaskinens tørrehaler fastgjort til cyklussen i stedet for at afkorte dem. Hæv den, hvis cyklusser slutter for tidligt under nedkøling; sænk den, hvis detektionen er træg.", "label": "Slutenergi" }, "end_repeat_count": { @@ -1222,7 +1280,7 @@ "label": "Ekstern udløserenhed" }, "external_end_trigger_enabled": { - "doc": "Lad en ekstern binær sensor signalere slutningen af ​​en cyklus, ud over den indbyggede strømbaserede detektering.", + "doc": "Lad en ekstern binær sensor signalere slutningen af en cyklus, ud over den indbyggede strømbaserede detektering.", "label": "Aktivér ekstern slutudløser" }, "external_end_trigger_inverted": { @@ -1242,7 +1300,7 @@ "label": "Minimum slukket mellemrum" }, "min_power": { - "doc": "Absolut minimumseffekt anses for aktiv. Aflæsninger under dette behandles som 0 W (standby), hvilket frafiltrerer fantombelastningen af ​​smarte stik og standby-LED'er.", + "doc": "Absolut minimumseffekt anses for aktiv. Aflæsninger under dette behandles som 0 W (standby), hvilket frafiltrerer fantombelastningen af smarte stik og standby-LED'er.", "label": "Minimumseffekt" }, "ml_training_enabled": { @@ -1402,7 +1460,7 @@ "label": "Fejlmatchtærskel" }, "progress_reset_delay": { - "doc": "Når du er færdig, skal du holde status på 100 % så længe, ​​så fuldført er synlig på dashboards, før du nulstiller til inaktiv.", + "doc": "Når du er færdig, skal du holde status på 100 % så længe, så fuldført er synlig på dashboards, før du nulstiller til inaktiv.", "label": "Forsinkelse ved nulstilling af fremskridt" }, "pump_stuck_duration": { @@ -1410,7 +1468,7 @@ "label": "Varighed for fastklemt pumpe" }, "running_dead_zone": { - "doc": "Efter en cyklus starter, ignoreres strømfald i dette vindue. Vaskemaskiner fyldes med koldt vand (falder i nærheden af ​​0 W før opvarmning) - uden denne beskyttelse ligner påfyldningsfasen en cyklusafslutning. Dette springer IKKE data over: sporet med fuld effekt registreres fra T=0. Forslagsmotoren måler din maskines faktiske opstartsmønster og størrelser dette automatisk.", + "doc": "Efter en cyklus starter, ignoreres strømfald i dette vindue. Vaskemaskiner fyldes med koldt vand (falder i nærheden af 0 W før opvarmning) - uden denne beskyttelse ligner påfyldningsfasen en cyklusafslutning. Dette springer IKKE data over: sporet med fuld effekt registreres fra T=0. Forslagsmotoren måler din maskines faktiske opstartsmønster og størrelser dette automatisk.", "label": "Kørende dødzone" }, "sampling_interval": { @@ -1421,6 +1479,10 @@ "doc": "Gem fuld effektsporing og matchende fejlretningsdata for hver cyklus. Nyttig til fejlfinding, men øger lagerstørrelsen.", "label": "Gem fejlretningsspor" }, + "smart_termination_duration_ratio": { + "doc": "Hvor langt en cyklus skal være inde i det genkendte programs forventede varighed, før smart afslutning må afslutte den tidligt, når effekten falder. Den forventede varighed er programmets gennemsnit, så på apparater med stærkt varierende køretid - vaskemaskiner med koldt vinter- kontra varmt sommerindløbsvand, tørretumblere med fugtsensor, belastningsafhængige programmer - slutter omkring halvdelen af alle kørsler kortere end dette gennemsnit og får aldrig den hurtige afslutning, men slutter først minutter senere via reserve-timeoutet. Sænk denne værdi (f.eks. 0,85) på sådanne maskiner, så den tidlige afslutning stadig udløses; hæv den mod 1,0 for at være mere forsigtig. Lad feltet stå tomt for standarden (0,98 eller 0,99 for opvaskemaskiner). Den kan kun afslutte en cyklus tidligere, aldrig senere, og udløses aldrig ved en tvetydig eller usikker genkendelse.", + "label": "Forhold for smart afslutning" + }, "smoothing_window": { "doc": "Hvor meget råeffektsignalet udjævnes. Lav (2) er lydhør, men støjende; høj (5) udglatter pigge, men tilføjer forsinkelse.", "label": "Glatningsvindue" @@ -1438,7 +1500,7 @@ "label": "Starttærskel" }, "stop_threshold_w": { - "doc": "Strømmen skal falde til under dette niveau, før nedtællingen af ​​slukket forsinkelse begynder. Indstil den under starttærsklen - mellemrummet mellem dem er hysteresebåndet, der forhindrer flimmer. Hvis den er indstillet for højt, vil laveffektfaser (skyllestop, anti-krøl) fejlagtigt udløse slutsekvensen.", + "doc": "Strømmen skal falde til under dette niveau, før nedtællingen af slukket forsinkelse begynder. Indstil den under starttærsklen - mellemrummet mellem dem er hysteresebåndet, der forhindrer flimmer. Hvis den er indstillet for højt, vil laveffektfaser (skyllestop, anti-krøl) fejlagtigt udløse slutsekvensen.", "label": "Stoptærskel" }, "switch_entity": { @@ -1551,6 +1613,10 @@ "door_end_dwell_seconds": { "label": "Dørens åbningstid ved afslutning", "doc": "Hvor længe døren skal forblive åben, før WashData afslutter cyklussen, når \"Dør åbner automatisk ved afslutning\" er aktiveret. Lang nok til at ignorere hurtig tilføjelse af en tallerken (standard 60 s), kort nok til at afslutte hurtigt, når maskinen springer døren op." + }, + "profile_evidence_sources": { + "label": "Cyklusser, der former et program", + "doc": "Hvilke cyklusser der bruges til at opbygge hvert programs effektkurve og til at matche en afsluttet cyklus mod den. Fravælger du en type, former den ikke længere dine programmer, uden at noget slettes - cyklusserne bliver i din Cykler-liste og kan stadig mærkes eller fjernes. Nyttigt, hvis du ikke stoler på importerede data. Statistikken påvirkes ikke: den tæller altid kun de cyklusser, som denne maskine faktisk har kørt. At fravælge alt ignoreres, da et program uden cyklusser bag sig aldrig kunne matche." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "Grundlæggende konfiguration" + }, + "profile_evidence": { + "label": "Profilgrundlag" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "Beskærer…", "splitting": "Opdeler…", "deleting": "Sletter…", - "imported": "Importeret" + "imported": "Importeret", + "preparing": "Forbereder…" }, "suggestion": { "both_agree": "WashData anbefaler", @@ -1754,7 +1824,7 @@ "thr_batch": "Holdt lige over den laveste aktive effekt ved p05 på tværs af {cycles} cyklusser ({p05}W), så en start fanges så tidligt som muligt, og stoptærsklen forbliver under maskinens laveste driftseffekt.", "tol_per_profile": "p75 for varighedsvariansen pr. profil på tværs af {profiles} profiler ({cycles} cyklusser); stramme profiler straffes ikke.", "tol_pooled": "Baseret på samlet varighedsvarians for {cycles} seneste mærkede cyklusser (p95 afvigelse={dev}).", - "watchdog": "Holdt så lavt som forsvarligt (lige over p95-opdateringsmellemrummet på {p95}s, min 30s), så stilstande fanges hurtigt uden falske stop." + "watchdog": "Holdt så lavt som forsvarligt (lige over p95-opdateringsmellemrummet på {p95}s og mindst 2x samplingintervallet på {median}s, min. 30s), så stilstande fanges hurtigt uden falske stop." }, "exclusions": { "summary": "{total} fejldetekterede cyklus(ser) udelukket: {parts}.", @@ -1801,6 +1871,7 @@ "wrong_profile": "Forkert profil" }, "toast": { + "catalog_refreshed": "Fællesskabskataloget er opdateret", "access_saved": "Adgangskontrol gemt", "all_wiped": "Alle data slettet", "analysis_complete_none": "Analyse færdig: ingen nye forslag", @@ -1812,7 +1883,7 @@ "cycle_labelled": "Cyklus mærket", "cycle_paused": "Cyklus sat på pause", "cycle_resumed": "Cyklus genoptaget", - "cycle_trimmed": "Cyklus trimmet", + "cycle_trimmed": "Cyklus beskåret", "cycles_merged": "Cykler slået sammen", "envelope_rebuilt": "Konvolut genopbygget", "envelopes_rebuilt": "Konvolutter genopbygget", @@ -1906,7 +1977,9 @@ "store_download_failed": "Download mislykkedes: {error}", "store_download_nothing": "Intet nyt at downloade -- denne opsætning er allerede på dit apparat.", "export_selective_done": "Eksport downloadet", - "import_selective_done": "Importerede {profiles} profil(er) og {cycles} cyklus(ser)" + "import_selective_done": "Importerede {profiles} profil(er) og {cycles} cyklus(ser)", + "hist_csv_required": "Indlæs først en CSV-fil eller indsæt dens indhold", + "file_read_failed": "Kunne ikke læse den fil" }, "trend": { "down": "Trend nedad", @@ -1955,6 +2028,10 @@ "finished": "Cyklussen nåede en sluttilstand og stoppede." }, "store": { + "your_model_tip": "Dette er det apparat, du har angivet i Indstillinger", + "your_model": "Dit apparat", + "search_brand_ph": "Søg efter mærke…", + "programs_count": "Programmer: {n}", "browse": "Gennemse", "device": "Enhed", "favorites": "Favoritter", diff --git a/custom_components/ha_washdata/translations/panel/de.json b/custom_components/ha_washdata/translations/panel/de.json index bc557a40..b8b086bf 100644 --- a/custom_components/ha_washdata/translations/panel/de.json +++ b/custom_components/ha_washdata/translations/panel/de.json @@ -78,9 +78,12 @@ "awaiting": "Warten auf Freigabe", "imported_tip": "Aus dem Community-Store importiert. Wird nur für den Abgleich verwendet, zählt nicht in die Statistik.", "not_importable": "hier n. v.", - "exists": "vorhanden" + "exists": "vorhanden", + "backfilled_tip": "Im importierten Leistungsverlauf erkannt. Beeinflusst nur die Programmzuordnung, zählt nicht in die Statistik." }, "btn": { + "set_brand_model": "Marke und Modell festlegen", + "refresh_catalog": "Katalog aktualisieren", "add_device": "+ Gerät hinzufügen", "add_device_tip": "Fügen Sie ein weiteres WashData-Gerät hinzu", "add_maintenance": "Wartungsereignis hinzufügen", @@ -90,7 +93,7 @@ "apply_label": "Etikett anwenden", "apply_set_b": "Set B anwenden", "apply_split": "Teilung anwenden", - "apply_trim": "Trimmen anwenden", + "apply_trim": "Zuschnitt anwenden", "auto_detect_split": "Automatische Erkennung", "auto_label_cycles": "Auto-Label-Zyklen", "auto_label_cycles_tip": "Weist unbeschrifteten Zyklen, deren Übereinstimmungskonfidens den Schwellenwert überschreitet, automatisch Profilnamen zu", @@ -209,8 +212,8 @@ "stop": "Stopp", "submit_correction": "Korrektur einreichen", "train_now": "Trainiere jetzt", - "trim": "Trimmen", - "trim_split": "Trimmen / Teilen", + "trim": "Zuschneiden", + "trim_split": "Zuschneiden / Teilen", "undo": "Rückgängig", "use": "Verwenden", "wipe_all": "Alle Daten löschen", @@ -241,7 +244,12 @@ "import_selected": "Auswahl importieren", "back": "Zurück", "mute_suggestion": "Diese Einstellung nicht mehr vorschlagen", - "reset_muted": "Stummschaltungen zurücksetzen" + "reset_muted": "Stummschaltungen zurücksetzen", + "import_power_history": "Leistungsverlauf importieren", + "hist_read_recorder": "Aus Home Assistant lesen", + "hist_scan": "Nach Zyklen suchen", + "hist_import_n": "{n} Zyklen importieren", + "hist_goto_cycles": "Zeig mir die Zyklen" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Muss unter der maximalen Knitterschutz-Leistung ({max} W) liegen" }, "attn_sub": "Konflikte vor dem Speichern beheben", - "attn_title": "{n} Einstellungskonflikt{s}", + "attn_title": "Einstellungskonflikte: {n}", "confidence": { "auto": "Muss beim oder über dem Übereinstimmungsschwellenwert ({match}) liegen", - "learning": "Muss beim oder unter dem Übereinstimmungsschwellenwert ({match}) liegen", + "learning": "Muss beim oder über dem Übereinstimmungsschwellenwert ({match}) liegen", "match_for_auto": "Muss beim oder unter der Auto-Label-Konfidenz ({alc}) liegen", - "match_for_learning": "Muss beim oder über der Lernkonfidenz ({lc}) liegen" + "match_for_learning": "Muss beim oder unter der Lernkonfidenz ({lc}) liegen" }, "duration_ratio": { "max": "Muss größer als das minimale Dauerverhältnis ({min}) sein", @@ -296,14 +304,14 @@ "match": "Muss über dem Nicht-Übereinstimmungs-Schwellenwert ({un}) liegen", "unmatch": "Muss unter dem Übereinstimmungsschwellenwert ({match}) liegen; andernfalls wird eine bestätigte Übereinstimmung sofort aufgehoben" }, - "cascade_toast": "Außerdem {n} Einstellung{s} zur Konsistenz angepasst.", + "cascade_toast": "Andere Einstellungen zur Konsistenz angepasst: {n}", "suggestion_resolves": "Unten den ausstehenden Vorschlag ({val}) übernehmen, um dies zu beheben", "use_fix": "Verwende {val}", "watchdog": { "interval": "Sollte mindestens das 2-fache des Abtastintervalls ({si} s) betragen", "sampling": "Abtastintervall sollte höchstens die Hälfte des Watchdog-Intervalls ({wi} s) betragen" }, - "settings_banner": "{n} Einstellungskonflikt{s} – markierte Abschnitte prüfen und vor dem Speichern beheben.", + "settings_banner": "Einstellungskonflikte: {n}. Prüfen Sie die markierten Abschnitte und beheben Sie sie vor dem Speichern.", "settings_banner_btn": "Zum ersten" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Simulationsergebnis", "pg_across_cycles": "Über deine Zyklen hinweg", "community_store": "Community-Store", - "online_account": "Community-Store und Onlinefunktionen" + "online_account": "Community-Store und Onlinefunktionen", + "import_power_history": "Leistungsverlauf importieren" }, "health": { "fair": "Akzeptable Übereinstimmungsqualität", @@ -364,6 +373,7 @@ "poor": "⚠ Schlechte Übereinstimmungsqualität" }, "lbl": { + "drag_to_resize": "Zum Ändern der Größe ziehen", "actions": "Aktionen", "activity": "Aktivität", "administrators": "Administratoren", @@ -450,7 +460,7 @@ "metric": "Metrik", "mode_existing_profile": "Zum vorhandenen Profil hinzufügen", "mode_new_profile": "Neues Profil erstellen", - "models_fine_tuned": "({count} Modell{plural} feinabgestimmt)", + "models_fine_tuned": "(feinabgestimmte Modelle: {count})", "n_classic_suggestions": "{n} klassisch", "n_ml_suggestions": "{n} ML", "n_selected": "{n} ausgewählt", @@ -677,7 +687,26 @@ "conflict_keep_mine": "Meine behalten", "conflict_overwrite": "Überschreiben", "pg_anti_wrinkle": "Knitterschutz", - "font_size": "Panel-Schriftgröße" + "font_size": "Panel-Schriftgröße", + "hist_csv_data": "CSV-Daten", + "hist_from_recorder": "Oder aus Home Assistant lesen", + "hist_since": "Seit", + "days": "Tage", + "hist_keep": "Diesen Zyklus behalten", + "hist_looks_complete": "vollständig", + "peak_power_short": "Spitze", + "shape": "Form", + "hist_skip_idle": "nichts in Betrieb", + "hist_skip_sparse": "Messwerte zu weit auseinander", + "hist_skip_short": "zu wenige Messwerte", + "hist_skip_long": "keine Pause lang genug zum Aufteilen", + "hist_reason_short": "kürzer als der kürzeste echte Zyklus dieses Geräts", + "hist_reason_no_end": "wurde nie sauber beendet", + "task_history_import": "Leistungsverlauf wird durchsucht", + "task_history_import_apply": "Zyklen werden importiert", + "evidence_real_cycles": "Zyklen, die diese Maschine ausgeführt hat", + "evidence_reference_cycles": "Aus dem Community-Store heruntergeladen", + "evidence_backfill_cycles": "Im importierten Leistungsverlauf gefunden" }, "log": { "all_levels": "Alle Ebenen", @@ -756,9 +785,15 @@ "store_share": "Im Community-Store teilen", "store_share_device": "Dieses Gerät teilen", "export_select": "Export - Daten wählen", - "import_wizard": "Import - Daten wählen" + "import_wizard": "Import - Daten wählen", + "history_import": "Leistungsverlauf importieren" }, "msg": { + "tail_trim_hint": "So viele Sekunden vom Ende entfernen", + "store_sibling_hint": "Für Ihr genaues Modell ist nichts geteilt? Ein nah verwandtes Modell derselben Marke ist meist ein guter Ausgangspunkt.", + "store_declare_appliance": "Teilen Sie WashData mit, welches Gerät Sie besitzen, dann zeigt dieser Tab die Setups, die andere dafür geteilt haben. Sie können oben auch eine Marke eingeben, um sich umzusehen.", + "refresh_catalog_hint": "Die Marken- und Gerätelisten der Community werden zwischengespeichert, damit der Community-Store sein Tageslimit einhält. Aktualisieren Sie, um Einträge zu übernehmen, die andere hinzugefügt oder freigegeben haben.", + "head_trim_hint": "So viele Sekunden vom Anfang entfernen", "appliance_monitor": "Gerätemonitor", "artifact_dip_detail": "Sank unter das übliche Leistungsband für ~{n}s.", "artifact_footer": "In der Grafik oben hervorgehoben. Hierbei handelt es sich um vorübergehende Artefakte (z. B. das Öffnen der Tür mitten im Zyklus), nicht unbedingt um Probleme.", @@ -769,7 +804,7 @@ "automations_intro": "WashData löst {start} / {end}-Ereignisse aus und stellt Entitäten bereit, sodass Benachrichtigungen und Aktionen am besten als normale Home Assistant-Automatisierungen eingerichtet werden. Automatisierungen, die dieses Gerät verwenden, werden unten angezeigt.", "cleanup_intro": "Jeder beschriftete Zyklus überlagert. Markieren Sie Ausreißer und löschen Sie sie, um das Profil zu bereinigen.", "clear_debug_hint": "Entfernen Sie gespeicherte Debug-Daten, um Speicherplatz freizugeben.", - "collecting_data": "Daten werden gesammelt: noch {need} Zyklus{plural} bis zum Start der Feinabstimmung ({current}/{min}).", + "collecting_data": "Daten werden gesammelt. Noch benötigte Zyklen bis zum Start der Feinabstimmung: {need} ({current}/{min}).", "compare_overlay_profiles": "Overlay-Profile (schwach)", "compare_profiles_tip": "Überlagern Sie andere Profilumschläge in der Tabelle oben, um zu sehen, welcher am besten zu diesem Zyklus passt.", "compare_selected_cycles": "Ausgewählte Zyklen (durchgehend) – ein-/ausblenden", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "Leerlaufsimulation beginnt hier", "pg_sweep_intro": "Was wäre, wenn {param} anders wäre? Testen Sie {steps} Werte über Ihre letzten {cycles} Zyklen, um die Einstellung zu finden, bei der die meisten Zyklen korrekt zugeordnet werden.", "pg_sweep_step": "Schritt {done} / {total}", - "pg_undetected": "{n} Zyklus{s} nicht erkannt", "pg_verdict_bad": "Erfordert Aufmerksamkeit: viele Zyklen bleiben unerkannt.", "pg_verdict_good": "Gut abgestimmt: die meisten Zyklen werden korrekt erkannt und zugeordnet.", "pg_verdict_ok": "Akzeptabel: einige Zyklen wurden verpasst. Versuchen Sie, den Startschwellenwert zu senken.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Markieren Sie dies als handverlesenen Referenzzyklus für sein Programm – die gleiche Rolle wie ein manuell aufgezeichneter Zyklus. Referenzzyklen bleiben immer erhalten, setzen die passende Vorlage als Seed und werden bei der Bereinigung niemals gelöscht. (Dies ist die „goldene“/aufgezeichnete Flagge; beide sind dasselbe.)", "review_tags_tip": "Optionale Flags, die beschreiben, was bei diesem Zyklus schief gelaufen ist, damit Training und Bereinigung dafür verantwortlich sein können.", "review_to_cycles": "Öffnen Sie die Cycles-Bewertungswarteschlange", + "samples_decimated": "Es werden {shown} von {total} Messwerten angezeigt (für die Anzeige ausgedünnt; Spitzen bleiben erhalten). Eine breite Lücke hier ist Ausdünnung, keine fehlenden Daten.", "saving_triggers_reload": "Durch das Speichern wird ein Neuladen der Integration ausgelöst. HA-Entitäten werden möglicherweise kurzzeitig als nicht verfügbar angezeigt.", "search_placeholder": "Einstellungen durchsuchen…", "see_recorder": "Siehe Rekorder-Widget unten", @@ -1006,10 +1041,33 @@ "sug_mute_failed": "Einstellung konnte nicht stummgeschaltet werden", "sug_unmuted_all": "Stummgeschaltete Vorschläge zurückgesetzt", "n_suggestions_muted": "{count} stummgeschaltet; der Autotuner schlägt diese nicht mehr vor.", - "font_size_hint": "Alles in diesem Panel vergrößern oder verkleinern. Gilt für Ihr Konto auf diesem Gerät." + "font_size_hint": "Alles in diesem Panel vergrößern oder verkleinern. Gilt für Ihr Konto auf diesem Gerät.", + "import_history_description": "Hatten Sie schon vor WashData einen Smart Plug? Laden Sie einen Verlaufsexport seines Leistungssensors hoch oder lassen Sie ihn direkt aus Home Assistant lesen: Die normale Erkennung läuft darüber, sodass vergangene Zyklen in Ihrer Zyklusliste auftauchen und dort benannt werden können.", + "hist_input_hint": "Laden Sie eine CSV-Datei aus dem Verlaufs-Panel hoch (Entität, Status, Letzte Änderung), oder lassen Sie WashData den Verlauf des Sensors direkt lesen. Die Erkennung läuft dann genau so darüber wie im Livebetrieb, und Sie wählen aus, welche der gefundenen Zyklen behalten werden.", + "hist_recorder_hint": "Liest ab dem gewählten Datum bis jetzt. Home Assistant speichert detaillierte Verlaufsdaten standardmäßig 10 Tage lang und danach nur noch Stundenmittelwerte, die für die Zykluserkennung zu grob sind - wählen Sie nur dann ein weiter zurückliegendes Datum, wenn Ihr Recorder auf eine längere Aufbewahrung eingestellt ist.", + "hist_scanning": "Ihr Verlauf wird durch die Erkennung abgespielt. Das läuft im Hintergrund - Sie können diesen Dialog schließen und später zurückkommen.", + "hist_imported_count": "{n} Zyklen importiert.", + "hist_duplicates": "{n} waren bereits importiert und wurden übersprungen.", + "hist_capped": "Das Limit pro Gerät für importierte Zyklen wurde erreicht; der Rest wurde nicht gespeichert.", + "hist_next_step": "Sie liegen in Ihrer Zyklusliste, gekennzeichnet als importierter Verlauf. Öffnen Sie einen und benennen Sie über \"Beschriften\" das zugehörige Programm.", + "hist_rows_read": "{n} Messwerte gelesen", + "hist_entity_substituted": "{used} gelesen (dieses Gerät ist für {wanted} konfiguriert)", + "hist_breaks": "{n} Lücken, in denen der Sensor nicht verfügbar war", + "hist_other_entity": "{n} Messwerte für andere Entitäten ignoriert", + "hist_skipped_spans": "Übersprungene Abschnitte", + "hist_settings_used": "Erkannt mit den aktuellen Einstellungen dieses Geräts (Mindestleistung {w} W, Abschaltverzögerung {s} s).", + "hist_none_found": "In diesem Verlauf konnten keine Zyklen erkannt werden.", + "hist_found": "{n} Zyklen gefunden. Entfernen Sie das Häkchen bei allem, was nicht wie ein echter Lauf aussieht - bis zum Import wird nichts gespeichert.", + "hist_scan_capped": "Es werden nur die ersten Kandidaten angezeigt ({n} wurden gefunden).", + "hist_recorder_empty": "Home Assistant hat für diesen Sensor in diesem Zeitraum keine detaillierten Verlaufsdaten.", + "hist_scan_failed": "Suche fehlgeschlagen.", + "hist_scan_expired": "Diese Suche ist nicht mehr verfügbar. Bitte erneut suchen.", + "hist_import_failed": "Import fehlgeschlagen.", + "imported_history_readonly": "Im importierten Leistungsverlauf erkannt. Er beeinflusst die Programmzuordnung, zählt aber nicht in Ihre Statistik und kann nicht zugeschnitten oder geteilt werden. Beschriften Sie ihn, um das Programm zu benennen." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Geschirrspüler: Ruhesekunden nach der erwarteten Dauer, bevor die Wartezeit auf das Abpumpen am Zyklusende freigegeben wird", + "smart_termination_duration_ratio": "Anteil der erwarteten Dauer des zugeordneten Programms, den ein Zyklus erreichen muss, bevor die intelligente Beendigung ihn vorzeitig beenden darf; bei last- oder temperaturabhängigen Geräten senken", "completion_min_seconds": "Kürzester Lauf, der als echter Zyklus zählt", "end_repeat_count": "Aufeinanderfolgende niedrige Messwerte vor dem Ende", "interrupted_min_seconds": "Kurze Zyklen werden als unterbrochen markiert", @@ -1417,6 +1475,10 @@ "doc": "Speichern Sie für jeden Zyklus die vollständige Leistungsverfolgung und die entsprechenden Debug-Daten. Nützlich zur Fehlerbehebung, erhöht jedoch die Speichergröße.", "label": "Debug-Traces speichern" }, + "smart_termination_duration_ratio": { + "doc": "Wie weit ein Zyklus in die erwartete Dauer des zugeordneten Programms fortgeschritten sein muss, bevor die intelligente Beendigung ihn bei einem Leistungsabfall vorzeitig beenden darf. Die erwartete Dauer ist der Durchschnitt des Programms; bei Geräten mit stark schwankender Laufzeit - Waschmaschinen bei kaltem Winter- gegenüber warmem Sommer-Zulaufwasser, Trockner mit Feuchtesensor, lastabhängige Programme - endet daher etwa die Hälfte aller Läufe kürzer als dieser Durchschnitt und erhält nie die schnelle Beendigung, sondern endet erst Minuten später über den Rückfall-Timeout. Senken Sie diesen Wert (z. B. 0,85) bei solchen Geräten, damit die vorzeitige Beendigung dennoch auslöst; erhöhen Sie ihn Richtung 1,0, um vorsichtiger zu sein. Leer lassen für den Standard (0,98 bzw. 0,99 für Geschirrspüler). Er kann einen Zyklus nur früher beenden, niemals später, und löst nie bei einer mehrdeutigen oder unsicheren Zuordnung aus.", + "label": "Verhältnis für intelligente Beendigung" + }, "smoothing_window": { "doc": "Wie stark wird das Rohleistungssignal geglättet? Niedrig (2) reagiert, ist aber laut; Hoch (5) glättet Spitzen, fügt aber Verzögerung hinzu.", "label": "Glättungsfenster" @@ -1551,6 +1613,10 @@ "door_end_dwell_seconds": { "label": "Türöffnungs-Endverweildauer", "doc": "Wie lange die Tür geöffnet bleiben muss, bevor WashData den Zyklus beendet, wenn \"Tür öffnet sich am Ende automatisch\" aktiv ist. Lang genug, um ein kurzes Öffnen zum Nachlegen eines Geschirrteils zu ignorieren (Standard 60 s), kurz genug, um den Zyklus zügig zu beenden, sobald die Maschine die Tür aufspringt." + }, + "profile_evidence_sources": { + "label": "Zyklen, die ein Programm prägen", + "doc": "Welche Zyklen zum Aufbau der Leistungskurve jedes Programms und zum Abgleich eines abgeschlossenen Zyklus damit verwendet werden. Wird eine Art abgewählt, prägt sie Ihre Programme nicht mehr, ohne dass etwas gelöscht wird - die Zyklen bleiben in Ihrer Zyklusliste und können weiterhin beschriftet oder entfernt werden. Nützlich, wenn Sie importierten Daten nicht vertrauen. Die Statistik ist davon nicht betroffen: Sie zählt immer nur die Zyklen, die diese Maschine wirklich ausgeführt hat. Alles abzuwählen wird ignoriert, da ein Programm ohne Zyklen dahinter nie zugeordnet werden könnte." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "Grundkonfiguration" + }, + "profile_evidence": { + "label": "Profilgrundlage" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "Wird zugeschnitten…", "splitting": "Wird geteilt…", "deleting": "Wird gelöscht…", - "imported": "Importiert" + "imported": "Importiert", + "preparing": "Wird vorbereitet…" }, "suggestion": { "both_agree": "WashData empfiehlt", @@ -1754,7 +1824,7 @@ "thr_batch": "Knapp über der p05-niedrigsten aktiven Leistung über {cycles} Zyklen ({p05}W) gehalten, damit ein Start so früh wie möglich erkannt wird und die Stopp-Schwelle unter der niedrigsten Betriebsleistung der Maschine bleibt.", "tol_per_profile": "p75 der Dauervarianz pro Profil über {profiles} Profile ({cycles} Zyklen); enge Profile werden nicht benachteiligt.", "tol_pooled": "Basierend auf der zusammengefassten Dauervarianz von {cycles} kürzlich gelabelten Zyklen (p95-Abweichung={dev}).", - "watchdog": "So niedrig wie sicher gehalten (knapp über der p95-Aktualisierungslücke von {p95}s, min 30s), damit Hänger schnell erkannt werden, ohne Fehl-Stopps auszulösen." + "watchdog": "So niedrig wie sicher gehalten (knapp über der p95-Aktualisierungslücke von {p95}s und mindestens 2x dem Abtastintervall von {median}s, min. 30s), damit Hänger schnell erkannt werden, ohne Fehl-Stopps auszulösen." }, "exclusions": { "summary": "{total} falsch erkannte Zyklen ausgeschlossen: {parts}.", @@ -1801,6 +1871,7 @@ "wrong_profile": "Falsches Profil" }, "toast": { + "catalog_refreshed": "Community-Katalog aktualisiert", "access_saved": "Zugangskontrolle gespeichert", "all_wiped": "Alle Daten gelöscht", "analysis_complete_none": "Analyse abgeschlossen: keine neuen Vorschläge", @@ -1812,7 +1883,7 @@ "cycle_labelled": "Zyklus beschriftet", "cycle_paused": "Zyklus pausiert", "cycle_resumed": "Der Zyklus wurde fortgesetzt", - "cycle_trimmed": "Zyklus getrimmt", + "cycle_trimmed": "Zyklus zugeschnitten", "cycles_merged": "Zyklen verschmolzen", "envelope_rebuilt": "Umschlag neu aufgebaut", "envelopes_rebuilt": "Umschläge neu aufgebaut", @@ -1906,7 +1977,9 @@ "store_download_failed": "Download fehlgeschlagen: {error}", "store_download_nothing": "Nichts Neues zum Herunterladen -- dieses Setup ist bereits auf Ihrem Gerät.", "export_selective_done": "Export heruntergeladen", - "import_selective_done": "{profiles} Profil(e) und {cycles} Zyklus/Zyklen importiert" + "import_selective_done": "{profiles} Profil(e) und {cycles} Zyklus/Zyklen importiert", + "hist_csv_required": "Laden Sie zuerst eine CSV-Datei oder fügen Sie deren Inhalt ein", + "file_read_failed": "Diese Datei konnte nicht gelesen werden" }, "trend": { "down": "Abwärtstrend", @@ -1955,6 +2028,10 @@ "finished": "Der Zyklus hat einen Endzustand erreicht und wurde beendet." }, "store": { + "your_model_tip": "Dies ist das Gerät, das Sie in den Einstellungen angegeben haben", + "your_model": "Ihr Gerät", + "search_brand_ph": "Nach Marke suchen…", + "programs_count": "Programme: {n}", "browse": "Durchsuchen", "device": "Gerät", "favorites": "Favoriten", diff --git a/custom_components/ha_washdata/translations/panel/el.json b/custom_components/ha_washdata/translations/panel/el.json index c576388a..7380bded 100644 --- a/custom_components/ha_washdata/translations/panel/el.json +++ b/custom_components/ha_washdata/translations/panel/el.json @@ -78,9 +78,12 @@ "awaiting": "Αναμονή έγκρισης", "imported_tip": "Εισαγωγή από το κοινοτικό κατάστημα. Χρησιμοποιείται μόνο για αντιστοίχιση, δεν προσμετράται στα στατιστικά.", "not_importable": "μη διαθέσιμο εδώ", - "exists": "υπάρχει" + "exists": "υπάρχει", + "backfilled_tip": "Ανιχνεύθηκε σε εισαγόμενο ιστορικό ισχύος. Διαμορφώνει μόνο την αντιστοίχιση προγραμμάτων, δεν προσμετράται στα στατιστικά." }, "btn": { + "set_brand_model": "Ορισμός μάρκας και μοντέλου", + "refresh_catalog": "Ανανέωση καταλόγου", "add_device": "+ Προσθήκη συσκευής", "add_device_tip": "Προσθέστε μια άλλη συσκευή WashData", "add_maintenance": "Προσθήκη συμβάντος συντήρησης", @@ -241,7 +244,12 @@ "import_selected": "Εισαγωγή επιλεγμένων", "back": "Πίσω", "mute_suggestion": "Διακοπή πρότασης αυτής της ρύθμισης", - "reset_muted": "Επαναφορά αποσιωπημένων" + "reset_muted": "Επαναφορά αποσιωπημένων", + "import_power_history": "Εισαγωγή ιστορικού ισχύος", + "hist_read_recorder": "Ανάγνωση από το Home Assistant", + "hist_scan": "Σάρωση για κύκλους", + "hist_import_n": "Εισαγωγή {n} κύκλων", + "hist_goto_cycles": "Εμφάνιση κύκλων" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "Πρέπει να είναι κάτω από τη Μέγιστη Ισχύ Αντί-Τσακίσματος ({max} W)" }, "attn_sub": "Διορθώστε τις συγκρούσεις πριν αποθηκεύσετε", - "attn_title": "{n} σύγκρουση{s} ρυθμίσεων", - "settings_banner": "{n} σύγκρουση{s} ρυθμίσεων – ελέγξτε τα επισημασμένα τμήματα και διορθώστε πριν αποθηκεύσετε.", + "attn_title": "Συγκρούσεις ρυθμίσεων: {n}", + "settings_banner": "Συγκρούσεις ρυθμίσεων: {n}. Ελέγξτε τα επισημασμένα τμήματα και διορθώστε τα πριν την αποθήκευση.", "settings_banner_btn": "Μετάβαση στο πρώτο", "confidence": { "auto": "Πρέπει να είναι μεγαλύτερο ή ίσο με το Κατώφλι Αντιστοίχισης ({match})", - "learning": "Πρέπει να είναι μικρότερο ή ίσο με το Κατώφλι Αντιστοίχισης ({match})", + "learning": "Πρέπει να είναι μεγαλύτερο ή ίσο με το Κατώφλι Αντιστοίχισης ({match})", "match_for_auto": "Πρέπει να είναι μικρότερο ή ίσο με την Εμπιστοσύνη Αυτόματης Ετικέτας ({alc})", - "match_for_learning": "Πρέπει να είναι μεγαλύτερο ή ίσο με την Εμπιστοσύνη Μάθησης ({lc})" + "match_for_learning": "Πρέπει να είναι μικρότερο ή ίσο με την Εμπιστοσύνη Μάθησης ({lc})" }, "duration_ratio": { "max": "Πρέπει να είναι μεγαλύτερο από τον Ελάχιστο Λόγο Διάρκειας ({min})", @@ -298,7 +306,7 @@ "match": "Πρέπει να είναι πάνω από το Κατώφλι Αποαντιστοίχισης ({un})", "unmatch": "Πρέπει να είναι κάτω από το Κατώφλι Αντιστοίχισης ({match})· διαφορετικά μια επιβεβαιωμένη αντιστοίχιση ακυρώνεται αμέσως" }, - "cascade_toast": "Προσαρμόστηκαν επίσης {n} ρύθμιση{s} για συνέπεια.", + "cascade_toast": "Άλλες ρυθμίσεις που προσαρμόστηκαν για συνέπεια: {n}", "suggestion_resolves": "Εφαρμόστε την εκκρεμή πρόταση ({val}) παρακάτω για να διορθώσετε αυτό", "use_fix": "Χρήση {val}", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Αποτέλεσμα προσομοίωσης", "pg_across_cycles": "Σε όλους τους κύκλους σας", "community_store": "Κοινοτικό Κατάστημα", - "online_account": "Κοινοτικό Κατάστημα & διαδικτυακές λειτουργίες" + "online_account": "Κοινοτικό Κατάστημα & διαδικτυακές λειτουργίες", + "import_power_history": "Εισαγωγή ιστορικού ισχύος" }, "health": { "fair": "Αποδεκτή ποιότητα αντιστοίχισης", @@ -364,6 +373,7 @@ "poor": "⚠ Κακή ποιότητα αντιστοίχισης" }, "lbl": { + "drag_to_resize": "Σύρετε για αλλαγή μεγέθους", "actions": "Ενέργειες", "activity": "Δραστηριότητα", "administrators": "Διαχειριστές", @@ -434,7 +444,7 @@ "from": "Από", "gap_s": "Κενό (δευτ.)", "group_name": "Όνομα ομάδας", - "head_trim": "Περικοπή κεφαλής (δευτ.)", + "head_trim": "Περικοπή αρχής (δευτ.)", "health": "Υγεία", "hide_tabs": "Απόκρυψη καρτελών για μη διαχειριστές", "in_use": "Σε χρήση", @@ -450,7 +460,7 @@ "metric": "Μετρική", "mode_existing_profile": "Προσθήκη στο υπάρχον προφίλ", "mode_new_profile": "Δημιουργία Νέου Προφίλ", - "models_fine_tuned": "({count} μοντέλο{plural} βελτιστοποιημένο)", + "models_fine_tuned": "(βελτιστοποιημένα μοντέλα: {count})", "n_classic_suggestions": "{n} κλασικά", "n_ml_suggestions": "{n} ML", "n_selected": "{n} επιλεγμένα", @@ -532,7 +542,7 @@ "stage3": "Στάδιο 3 – DTW", "stage4": "Στάδιο 4 – συμφωνία", "status": "Κατάσταση", - "tail_trim": "Περικοπή ουράς (δευτ.)", + "tail_trim": "Περικοπή τέλους (δευτ.)", "timer_auto_pause": "Αυτόματη παύση", "timer_min": "λεπτά", "timer_msg_placeholder": "Μήνυμα (προαιρετικό, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_import_copy": "Εισαγωγή ως αντίγραφο", "conflict_keep_mine": "Διατήρηση δικών μου", "conflict_overwrite": "Αντικατάσταση", - "font_size": "Μέγεθος γραμματοσειράς πίνακα" + "font_size": "Μέγεθος γραμματοσειράς πίνακα", + "hist_csv_data": "Δεδομένα CSV", + "hist_from_recorder": "Ή διαβάστε το από το Home Assistant", + "hist_since": "Από", + "days": "ημέρες", + "hist_keep": "Διατήρηση αυτού του κύκλου", + "hist_looks_complete": "πλήρης", + "peak_power_short": "Αιχμή", + "shape": "Σχήμα", + "hist_skip_idle": "καμία λειτουργία", + "hist_skip_sparse": "μετρήσεις πολύ αραιές", + "hist_skip_short": "πολύ λίγες μετρήσεις", + "hist_skip_long": "δεν υπάρχει αρκετά μεγάλο κενό για διαχωρισμό", + "hist_reason_short": "μικρότερος από τον συντομότερο πραγματικό κύκλο αυτής της συσκευής", + "hist_reason_no_end": "δεν ολοκληρώθηκε ποτέ καθαρά", + "task_history_import": "Σάρωση ιστορικού ισχύος", + "task_history_import_apply": "Εισαγωγή κύκλων", + "evidence_real_cycles": "Κύκλοι που εκτέλεσε αυτό το μηχάνημα", + "evidence_reference_cycles": "Λήψη από το κοινοτικό κατάστημα", + "evidence_backfill_cycles": "Βρέθηκαν σε εισαγόμενο ιστορικό ισχύος" }, "log": { "all_levels": "Όλα τα επίπεδα", @@ -756,9 +785,15 @@ "store_share": "Κοινοποίηση στο κοινοτικό κατάστημα", "store_share_device": "Κοινοποίηση αυτής της συσκευής", "export_select": "Εξαγωγή - επιλογή δεδομένων", - "import_wizard": "Εισαγωγή - επιλογή δεδομένων" + "import_wizard": "Εισαγωγή - επιλογή δεδομένων", + "history_import": "Εισαγωγή ιστορικού ισχύος" }, "msg": { + "tail_trim_hint": "Αφαιρέστε τόσα δευτερόλεπτα από το τέλος", + "store_sibling_hint": "Δεν υπάρχει τίποτα κοινοποιημένο για το ακριβές μοντέλο σας; Ένα στενά συγγενικό μοντέλο της ίδιας μάρκας είναι συνήθως καλή αφετηρία.", + "store_declare_appliance": "Δηλώστε στο WashData ποια συσκευή έχετε και αυτή η καρτέλα θα εμφανίσει τις διαμορφώσεις που έχουν κοινοποιήσει άλλοι για αυτήν. Μπορείτε επίσης να γράψετε μια μάρκα παραπάνω για να δείτε τον κατάλογο.", + "refresh_catalog_hint": "Οι λίστες μαρκών και συσκευών της κοινότητας αποθηκεύονται προσωρινά, ώστε το κοινοτικό κατάστημα να παραμένει μέσα στο ημερήσιο όριό του. Ανανεώστε για να εμφανιστούν καταχωρίσεις που πρόσθεσαν ή ενέκριναν άλλοι.", + "head_trim_hint": "Αφαιρέστε τόσα δευτερόλεπτα από την αρχή", "appliance_monitor": "Οθόνη συσκευής", "artifact_dip_detail": "Έπεσε κάτω από το συνηθισμένο εύρος ισχύος για ~{n}s.", "artifact_footer": "Επισημαίνεται στο παραπάνω γράφημα. Αυτά είναι παροδικά τεχνουργήματα (π.χ. η πόρτα άνοιξε στα μέσα του κύκλου), όχι απαραίτητα προβλήματα.", @@ -769,7 +804,7 @@ "automations_intro": "Το WashData ενεργοποιεί συμβάντα {start} / {end} και εκθέτει οντότητες, οπότε οι ειδοποιήσεις και οι ενέργειες κατασκευάζονται καλύτερα ως κανονικοί αυτοματισμοί του Home Assistant. Οι αυτοματισμοί που χρησιμοποιούν αυτή τη συσκευή εμφανίζονται παρακάτω.", "cleanup_intro": "Κάθε κύκλος με ετικέτα επικαλύπτεται. Σημειώστε ακραίες τιμές και διαγράψτε για να καθαρίσετε το προφίλ.", "clear_debug_hint": "Καταργήστε τα αποθηκευμένα δεδομένα εντοπισμού σφαλμάτων για να ελευθερώσετε χώρο.", - "collecting_data": "Συλλογή δεδομένων: απαιτούνται ακόμη {need} κύκλος{plural} πριν ξεκινήσει η βελτιστοποίηση ({current}/{min}).", + "collecting_data": "Συλλογή δεδομένων. Κύκλοι που απαιτούνται ακόμη πριν ξεκινήσει η βελτιστοποίηση: {need} ({current}/{min}).", "compare_overlay_profiles": "Προφίλ επικάλυψης (ασθενώς)", "compare_profiles_tip": "Επικαλύψτε άλλους φακέλους προφίλ στο παραπάνω γράφημα για να δείτε ποιος ταιριάζει καλύτερα σε αυτόν τον κύκλο.", "compare_selected_cycles": "Επιλεγμένοι κύκλοι (συμπαγής) – εμφάνιση / απόκρυψη", @@ -779,7 +814,7 @@ "cycles_deleted": "Διαγράφηκαν {count} κύκλοι", "enough_data": "Αρκετά δεδομένα για εκμάθηση ({current}/{min} κύκλοι).", "export_description": "Επιλέξτε ακριβώς ποια προφίλ, κύκλους, ρυθμίσεις και άλλα θα εξαχθούν σε JSON, ή αναλύστε ένα αρχείο και εισάγετε μόνο τα μέρη που θέλετε.", - "feedback_cycles_pending": "{n} κύκλος{s} για αξιολόγηση", + "feedback_cycles_pending": "Για αξιολόγηση: {n}", "feedback_prompt": "Επιβεβαιώστε ότι ήταν σωστό, διορθώστε το πρόγραμμα ή αγνοήστε.", "feedback_relabel_hint": "Η επανεπισήμανση αυτού του κύκλου τον επιλύει επίσης.", "filter_by_profile": "Φιλτράρισμα κατά προφίλ…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "η προσομοίωση αδράνειας ξεκινά εδώ", "pg_sweep_intro": "Τι θα γινόταν αν το {param} ήταν διαφορετικό; Δοκιμάστε {steps} τιμές στους τελευταίους {cycles} κύκλους για να βρείτε τη ρύθμιση όπου αντιστοιχίζονται σωστά οι περισσότεροι κύκλοι.", "pg_sweep_step": "Βήμα {done} / {total}", - "pg_undetected": "{n} μη εντοπισμένος κύκλος{s}", "pg_verdict_bad": "Χρειάζεται προσοχή: πολλοί κύκλοι δεν εντοπίζονται.", "pg_verdict_good": "Καλά ρυθμισμένο: οι περισσότεροι κύκλοι αναγνωρίζονται και αντιστοιχίζονται σωστά.", "pg_verdict_ok": "Αποδεκτό: ορισμένοι κύκλοι χάθηκαν. Δοκιμάστε να μειώσετε το κατώφλι εκκίνησης.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Επισημάνετε αυτό ως έναν επιλεγμένο από το χέρι κύκλο αναφοράς για το πρόγραμμά του - τον ίδιο ρόλο με έναν μη αυτόματα εγγεγραμμένο κύκλο. Οι κύκλοι αναφοράς διατηρούνται πάντα, εισάγονται το αντίστοιχο πρότυπο και δεν απορρίπτονται ποτέ με εκκαθάριση. (Αυτή είναι η \"χρυσή\"/ηχογραφημένη σημαία· και τα δύο είναι το ίδιο πράγμα.)", "review_tags_tip": "Προαιρετικές σημαίες που περιγράφουν τι πήγε στραβά με αυτόν τον κύκλο, οπότε η εκπαίδευση και ο καθαρισμός μπορούν να το εξηγήσουν.", "review_to_cycles": "Ανοίξτε την ουρά ελέγχου Κύκλων", + "samples_decimated": "Εμφάνιση {shown} από {total} δείγματα (αραιωμένα για προβολή· διατηρούνται οι αιχμές). Ένα μεγάλο κενό εδώ είναι αραίωση, όχι δεδομένα που λείπουν.", "saving_triggers_reload": "Η αποθήκευση ενεργοποιεί μια εκ νέου φόρτωση ενσωμάτωσης. Οι οντότητες HA ενδέχεται να εμφανίζονται για λίγο ως μη διαθέσιμες.", "search_placeholder": "Ρυθμίσεις αναζήτησης…", "see_recorder": "Δείτε παρακάτω το γραφικό στοιχείο εγγραφής", @@ -1006,7 +1041,29 @@ "sug_mute_failed": "Αποτυχία σίγασης πρότασης", "sug_unmuted_all": "Οι αποσιωπημένες προτάσεις επαναφέρθηκαν", "n_suggestions_muted": "{count} αποσιωπημένες· ο αυτόματος ρυθμιστής δεν θα τις προτείνει.", - "font_size_hint": "Κάντε τα πάντα σε αυτόν τον πίνακα μεγαλύτερα ή μικρότερα. Ισχύει για τον λογαριασμό σας σε αυτήν τη συσκευή." + "font_size_hint": "Κάντε τα πάντα σε αυτόν τον πίνακα μεγαλύτερα ή μικρότερα. Ισχύει για τον λογαριασμό σας σε αυτήν τη συσκευή.", + "import_history_description": "Είχατε ήδη έξυπνο βύσμα πριν από το WashData; Ανεβάστε μια εξαγωγή ιστορικού του αισθητήρα ισχύος του, ή διαβάστε το απευθείας από το Home Assistant, και η κανονική ανίχνευση θα εκτελεστεί πάνω σε αυτό, ώστε οι παλιοί κύκλοι να εμφανιστούν στη λίστα Κύκλοι έτοιμοι για ονομασία.", + "hist_input_hint": "Ανεβάστε ένα CSV που κατεβάσατε από το πάνελ Ιστορικού (entity, state, last changed), ή αφήστε το WashData να διαβάσει απευθείας το ιστορικό του αισθητήρα. Στη συνέχεια η ανίχνευση εκτελείται πάνω σε αυτό ακριβώς όπως και σε ζωντανή λειτουργία, και εσείς επιλέγετε ποιους από τους κύκλους που βρέθηκαν θα διατηρήσετε.", + "hist_recorder_hint": "Διαβάζει από την ημερομηνία που επιλέγετε έως τώρα. Το Home Assistant διατηρεί από προεπιλογή αναλυτικό ιστορικό για 10 ημέρες και μόνο ωριαίους μέσους όρους μετά από αυτό, οι οποίοι είναι πολύ χονδροειδείς για την ανίχνευση κύκλων - επιλέξτε παλαιότερη ημερομηνία μόνο αν ο recorder είναι ρυθμισμένος να διατηρεί περισσότερα.", + "hist_scanning": "Το ιστορικό σας αναπαράγεται μέσα από τον ανιχνευτή. Αυτό εκτελείται στο παρασκήνιο - μπορείτε να κλείσετε αυτό το παράθυρο και να επιστρέψετε αργότερα.", + "hist_imported_count": "Εισήχθησαν {n} κύκλοι.", + "hist_duplicates": "{n} είχαν εισαχθεί ήδη και παραλείφθηκαν.", + "hist_capped": "Συμπληρώθηκε το όριο εισαγόμενων κύκλων ανά συσκευή· οι υπόλοιποι δεν αποθηκεύτηκαν.", + "hist_next_step": "Βρίσκονται στη λίστα Κύκλοι, με σήμανση ως εισαγόμενο ιστορικό. Ανοίξτε έναν και χρησιμοποιήστε την Επισήμανση για να ορίσετε το πρόγραμμα στο οποίο ανήκει.", + "hist_rows_read": "Διαβάστηκαν {n} μετρήσεις", + "hist_entity_substituted": "διαβάστηκε το {used} (αυτή η συσκευή είναι ρυθμισμένη για {wanted})", + "hist_breaks": "{n} κενά όπου ο αισθητήρας ήταν μη διαθέσιμος", + "hist_other_entity": "Παραλείφθηκαν {n} μετρήσεις άλλων οντοτήτων", + "hist_skipped_spans": "Διαστήματα που παραλείφθηκαν", + "hist_settings_used": "Ανιχνεύθηκε με τις τρέχουσες ρυθμίσεις αυτής της συσκευής (ελάχιστη ισχύς {w} W, καθυστέρηση απενεργοποίησης {s} δευτ.).", + "hist_none_found": "Δεν ανιχνεύθηκε κανένας κύκλος σε αυτό το ιστορικό.", + "hist_found": "Βρέθηκαν {n} κύκλοι. Αποεπιλέξτε ό,τι δεν μοιάζει με πραγματική λειτουργία - τίποτα δεν αποθηκεύεται πριν από την εισαγωγή.", + "hist_scan_capped": "Εμφανίζονται μόνο οι πρώτοι υποψήφιοι (βρέθηκαν {n}).", + "hist_recorder_empty": "Το Home Assistant δεν έχει αναλυτικό ιστορικό για αυτόν τον αισθητήρα σε εκείνο το διάστημα.", + "hist_scan_failed": "Η σάρωση απέτυχε.", + "hist_scan_expired": "Αυτή η σάρωση δεν είναι πλέον διαθέσιμη. Κάντε ξανά σάρωση.", + "hist_import_failed": "Η εισαγωγή απέτυχε.", + "imported_history_readonly": "Ανιχνεύθηκε σε εισαγόμενο ιστορικό ισχύος. Διαμορφώνει την αντιστοίχιση προγραμμάτων, αλλά δεν προσμετράται στα στατιστικά σας και δεν μπορεί να περικοπεί ή να διαχωριστεί. Επισημάνετέ τον για να ορίσετε το πρόγραμμα." }, "phase_desc": { "anti_crease": "Περιστασιακά σύντομες ανατροπές μετά την ολοκλήρωση για μείωση των ρυτίδων.", @@ -1388,6 +1445,10 @@ "doc": "Αποθηκεύστε το πλήρες ίχνος ισχύος και τα αντίστοιχα δεδομένα εντοπισμού σφαλμάτων για κάθε κύκλο. Χρήσιμο για την αντιμετώπιση προβλημάτων, αλλά αυξάνει το μέγεθος του αποθηκευτικού χώρου.", "label": "Αποθήκευση Ιχνών Αποσφαλμάτωσης" }, + "smart_termination_duration_ratio": { + "doc": "Πόσο μέσα στην αναμενόμενη διάρκεια του αντιστοιχισμένου προγράμματος πρέπει να έχει προχωρήσει ένας κύκλος προτού ο Έξυπνος Τερματισμός μπορέσει να τον τερματίσει νωρίς μόλις πέσει η ισχύς. Η αναμενόμενη διάρκεια είναι ο μέσος όρος του προγράμματος, οπότε σε συσκευές των οποίων ο χρόνος λειτουργίας ποικίλλει πολύ (πλυντήρια ρούχων με κρύο νερό παροχής τον χειμώνα έναντι ζεστού το καλοκαίρι, στεγνωτήρια με αισθητήρα στεγνώματος, προγράμματα που εξαρτώνται από το φορτίο) περίπου οι μισές από όλες τις εκτελέσεις τελειώνουν συντομότερα από αυτόν τον μέσο όρο και δεν λαμβάνουν ποτέ τον γρήγορο τερματισμό, τελειώνοντας μόνο μέσω του εφεδρικού χρονικού ορίου με καθυστέρηση λεπτών. Χαμηλώστε αυτή την τιμή (π.χ. 0,85) σε αυτά τα μηχανήματα ώστε ο πρόωρος τερματισμός να εξακολουθεί να ενεργοποιείται. Αυξήστε την προς το 1,0 για να είστε πιο συντηρητικοί. Αφήστε το κενό για την προεπιλογή (0,98, ή 0,99 για πλυντήρια πιάτων). Μπορεί μόνο να τερματίσει έναν κύκλο νωρίτερα, ποτέ αργότερα, και δεν ενεργοποιείται ποτέ σε μια ασαφή αντιστοίχιση ή αντιστοίχιση χαμηλής εμπιστοσύνης.", + "label": "Αναλογία Έξυπνου Τερματισμού" + }, "smoothing_window": { "doc": "Πόσο εξομαλύνεται το πρωτογενές σήμα ισχύος. Το Low (2) ανταποκρίνεται αλλά είναι θορυβώδες. υψηλό (5) εξομαλύνει τις αιχμές αλλά προσθέτει υστέρηση.", "label": "Παράθυρο Εξομάλυνσης" @@ -1522,6 +1583,10 @@ "dishwasher_end_spike_quiet_release": { "label": "Απελευθέρωση σε ήσυχο παθητικό στέγνωμα", "doc": "Μόλις ο κύκλος ξεπεράσει την αναμενόμενη διάρκειά του, για πόσο χρόνο πρέπει το πλυντήριο πιάτων να παραμείνει ήσυχο (κάτω από το Κατώφλι Τερματισμού) πριν το WashData σταματήσει να περιμένει μια τελική αποστράγγιση και τερματίσει τον κύκλο. Αυξήστε το αν το μηχάνημά σας έχει μια μακρά, αθόρυβη φάση στεγνώματος πριν από μια καθυστερημένη τελική αποστράγγιση που δεν εντοπίζεται - ένα ευρύτερο παράθυρο επιτρέπει στη μαθημένη διάρκεια να ακολουθεί την εποχική μεταβολή (πιο κρύο νερό παροχής = μεγαλύτεροι κύκλοι) αντί να κλειδώνει στον παλιό μέσο όρο. Μπορεί μόνο να μειώσει την αναμονή σε σχέση με το εσωτερικό όριο των 30 λεπτών για την τελική αιχμή, ποτέ να την επεκτείνει." + }, + "profile_evidence_sources": { + "label": "Κύκλοι που διαμορφώνουν ένα πρόγραμμα", + "doc": "Ποιοι κύκλοι χρησιμοποιούνται για τη δημιουργία της καμπύλης ισχύος κάθε προγράμματος και για την αντιστοίχιση ενός ολοκληρωμένου κύκλου με αυτήν. Αν αφαιρέσετε την επιλογή ενός είδους, αυτό σταματά να διαμορφώνει τα προγράμματά σας χωρίς να διαγράφεται τίποτα - οι κύκλοι παραμένουν στη λίστα Κύκλοι και μπορείτε ακόμη να τους επισημάνετε ή να τους αφαιρέσετε. Χρήσιμο αν δεν εμπιστεύεστε τα εισαγόμενα δεδομένα. Τα στατιστικά δεν επηρεάζονται: μετρούν πάντα μόνο τους κύκλους που εκτέλεσε πραγματικά αυτό το μηχάνημα. Η αφαίρεση όλων των επιλογών αγνοείται, καθώς ένα πρόγραμμα χωρίς κύκλους πίσω του δεν θα μπορούσε ποτέ να αντιστοιχιστεί." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Βασική διαμόρφωση" + }, + "profile_evidence": { + "label": "Πηγές Προφίλ" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Περικοπή…", "splitting": "Διαίρεση…", "deleting": "Διαγραφή…", - "imported": "Εισηγμένο" + "imported": "Εισηγμένο", + "preparing": "Προετοιμασία…" }, "tab": { "advanced": "Για προχωρημένους", @@ -1659,6 +1728,7 @@ "wrong_profile": "Λάθος προφίλ" }, "toast": { + "catalog_refreshed": "Ο κατάλογος της κοινότητας ανανεώθηκε", "access_saved": "Ο έλεγχος πρόσβασης αποθηκεύτηκε", "all_wiped": "Όλα τα δεδομένα διαγράφηκαν", "analysis_complete_none": "Η ανάλυση ολοκληρώθηκε: δεν υπάρχουν νέες προτάσεις", @@ -1670,7 +1740,7 @@ "cycle_labelled": "Κύκλος με ετικέτα", "cycle_paused": "Ο κύκλος σταμάτησε", "cycle_resumed": "Ο κύκλος συνεχίστηκε", - "cycle_trimmed": "Κύκλος κομμένο", + "cycle_trimmed": "Ο κύκλος περικόπηκε", "cycles_merged": "Οι κύκλοι συγχωνεύτηκαν", "envelope_rebuilt": "Φάκελος ανακατασκευασμένος", "envelopes_rebuilt": "Φάκελοι ανακατασκευασμένοι", @@ -1764,7 +1834,9 @@ "store_download_failed": "Η λήψη απέτυχε: {error}", "store_download_nothing": "Δεν υπάρχει τίποτα νέο για λήψη - αυτή η ρύθμιση βρίσκεται ήδη στη συσκευή σας.", "export_selective_done": "Η εξαγωγή λήφθηκε", - "import_selective_done": "Εισήχθησαν {profiles} προφίλ και {cycles} κύκλοι" + "import_selective_done": "Εισήχθησαν {profiles} προφίλ και {cycles} κύκλοι", + "hist_csv_required": "Φορτώστε πρώτα ένα αρχείο CSV ή επικολλήστε το περιεχόμενό του", + "file_read_failed": "Δεν ήταν δυνατή η ανάγνωση του αρχείου" }, "suggestion": { "both_agree": "Το WashData προτείνει", @@ -1860,7 +1932,7 @@ "thr_batch": "Διατηρείται λίγο πάνω από τη χαμηλότερη ενεργό ισχύ p05 σε {cycles} κύκλους ({p05}W) ώστε μια έναρξη να εντοπίζεται όσο το δυνατόν νωρίτερα και το κατώφλι διακοπής να παραμένει κάτω από τη χαμηλότερη ισχύ λειτουργίας της μηχανής.", "tol_per_profile": "p75 της διακύμανσης διάρκειας ανά προφίλ σε {profiles} προφίλ ({cycles} κύκλοι)· τα σταθερά προφίλ δεν επιβαρύνονται.", "tol_pooled": "Βάσει της συγκεντρωτικής διακύμανσης διάρκειας {cycles} πρόσφατων κύκλων με ετικέτα (απόκλιση p95={dev}).", - "watchdog": "Διατηρείται όσο το δυνατόν χαμηλότερο με ασφάλεια (λίγο πάνω από το κενό ενημέρωσης p95 των {p95}s, ελάχ. 30s) ώστε οι διακοπές να εντοπίζονται γρήγορα χωρίς ψευδείς διακοπές." + "watchdog": "Διατηρείται όσο το δυνατόν χαμηλότερο με ασφάλεια (λίγο πάνω από το κενό ενημέρωσης p95 των {p95}s και τουλάχιστον 2× το διάστημα δειγματοληψίας των {median}s, ελάχ. 30s) ώστε οι διακοπές να εντοπίζονται γρήγορα χωρίς ψευδείς διακοπές." }, "exclusions": { "summary": "Εξαιρέθηκαν {total} εσφαλμένα εντοπισμένοι κύκλοι: {parts}.", @@ -1917,7 +1989,8 @@ "dtw_refine_top_n": "Στάδιο 3: υποψήφιοι που επαναβαθμολογεί η DTW; αυξήστε σε 7-9 αν το σωστό προφίλ βρίσκεται στη 4η-5η θέση (προεπιλογή 5)", "duration_scale": "Στάδιο 4: λόγος λογαρίθμου όπου η συμφωνία διάρκειας υποδιπλασιάζεται; μικρότερος = αυστηρότερη ποινή (προεπιλογή 0.175)", "energy_scale": "Στάδιο 4: λόγος λογαρίθμου όπου η συμφωνία ενέργειας υποδιπλασιάζεται; μικρότερος = αυστηρότερη ποινή (προεπιλογή 0.25)", - "dishwasher_end_spike_quiet_release": "Πλυντήριο πιάτων: δευτερόλεπτα ησυχίας μετά την αναμενόμενη διάρκεια πριν απελευθερωθεί η αναμονή αποστράγγισης τέλους κύκλου" + "dishwasher_end_spike_quiet_release": "Πλυντήριο πιάτων: δευτερόλεπτα ησυχίας μετά την αναμενόμενη διάρκεια πριν απελευθερωθεί η αναμονή αποστράγγισης τέλους κύκλου", + "smart_termination_duration_ratio": "Κλάσμα της αναμενόμενης διάρκειας του αντιστοιχισμένου προγράμματος που πρέπει να φτάσει ένας κύκλος προτού ο Έξυπνος Τερματισμός μπορέσει να τον τερματίσει νωρίς· χαμηλώστε το για μηχανήματα που εξαρτώνται από το φορτίο ή τη θερμοκρασία" }, "col": { "profile_tip": "Όνομα αντιστοιχισμένου προγράμματος. Χωρίς ετικέτα σημαίνει ότι κανένα προφίλ δεν αντιστοιχίστηκε στο τέλος του κύκλου.", @@ -1955,6 +2028,10 @@ "finished": "Ο κύκλος έφτασε σε τελική κατάσταση και ολοκληρώθηκε." }, "store": { + "your_model_tip": "Αυτή είναι η συσκευή που δηλώσατε στις Ρυθμίσεις", + "your_model": "Δική σας", + "search_brand_ph": "Αναζήτηση κατά μάρκα…", + "programs_count": "Προγράμματα: {n}", "browse": "Περιήγηση", "device": "Συσκευή", "favorites": "Αγαπημένα", diff --git a/custom_components/ha_washdata/translations/panel/en.json b/custom_components/ha_washdata/translations/panel/en.json index 98168144..10db94fb 100644 --- a/custom_components/ha_washdata/translations/panel/en.json +++ b/custom_components/ha_washdata/translations/panel/en.json @@ -54,6 +54,7 @@ "awaiting": "Awaiting approval", "awaiting_n": "Awaiting approval · {n} confirmed", "awaiting_tip": "Awaiting community approval", + "backfilled_tip": "Detected in imported power history. Shapes program matching only, not counted in stats.", "built_in_tag": "built-in", "declining": "↘ declining", "energy_low": "Lower energy than usual", @@ -120,21 +121,6 @@ "discard_tip": "Discard the recorded trace without saving", "disconnect": "Disconnect", "dismiss": "Dismiss", - "mute_suggestion": "Stop suggesting this setting", - "pg_load_calibrated": "Load Calibrated (ML) ({n})", - "pg_load_calibrated_tip": "Stage ML-calibrated settings as Playground overrides", - "pg_load_live": "Load live settings", - "pg_load_live_tip": "Re-read the integration's current settings and drop every Playground edit", - "pg_load_suggested": "Load suggested ({n})", - "pg_load_suggested_tip": "Stage the auto-tuner's current suggestions as Playground overrides", - "pg_preset_delete": "Delete preset", - "pg_preset_load": "Load preset", - "pg_preset_load_tip": "Replace the Playground values with this preset (live settings are untouched)", - "pg_preset_save": "Save as preset", - "pg_preset_save_tip": "Save every value below as a named preset for this device", - "pg_publish_all": "Publish {n} to integration", - "pg_publish_one": "Publish {label} to the integration", - "reset_muted": "Reset muted", "done": "Done", "download_device": "Download this setup", "download_export": "Download export", @@ -147,10 +133,15 @@ "force_stop_tip": "Immediately end the current cycle and mark it as force-stopped", "from_template": "From template ▾", "group_suggest": "Group {n}: {members}", + "hist_goto_cycles": "Show me the cycles", + "hist_import_n": "Import {n} cycles", + "hist_read_recorder": "Read from Home Assistant", + "hist_scan": "Scan for cycles", "ignore": "Ignore", "import": "Import", "import_json": "Import from JSON", "import_overwrite": "Import (overwrites data)", + "import_power_history": "Import power history", "import_raw": "Advanced: replace all from JSON", "import_selected": "Import selected", "inspect": "Inspect", @@ -159,6 +150,7 @@ "load_more": "Load more", "manage": "Manage", "merge": "Merge", + "mute_suggestion": "Stop suggesting this setting", "new_automation": "+ New Automation", "new_group": "+ New Group", "new_group_tip": "Group near-identical profiles (same shape/duration, different temperature or spin) so the matcher reliably picks between them", @@ -173,6 +165,19 @@ "pg_apply_to_settings": "Save to settings", "pg_apply_to_settings_tip": "Copy the values you edited here into this device's live settings", "pg_autofill": "Auto-fill", + "pg_load_calibrated": "Load Calibrated (ML) ({n})", + "pg_load_calibrated_tip": "Stage ML-calibrated settings as Playground overrides", + "pg_load_live": "Load live settings", + "pg_load_live_tip": "Re-read the integration's current settings and drop every Playground edit", + "pg_load_suggested": "Load suggested ({n})", + "pg_load_suggested_tip": "Stage the auto-tuner's current suggestions as Playground overrides", + "pg_preset_delete": "Delete preset", + "pg_preset_load": "Load preset", + "pg_preset_load_tip": "Replace the Playground values with this preset (live settings are untouched)", + "pg_preset_save": "Save as preset", + "pg_preset_save_tip": "Save every value below as a named preset for this device", + "pg_publish_all": "Publish {n} to integration", + "pg_publish_one": "Publish {label} to the integration", "play": "Play", "process": "Process", "process_history": "Process Now", @@ -186,6 +191,7 @@ "rec_stop_tip": "Stop recording and hold the captured trace for review", "record": "Start Recording", "refresh": "Refresh", + "refresh_catalog": "Refresh catalog", "refresh_diag_tip": "Reload storage statistics", "refresh_logs_tip": "Reload the latest log records", "refresh_settings_tip": "Reload settings from the server", @@ -194,6 +200,7 @@ "remove_timer": "Delete", "reset": "Reset", "reset_ml_models_tip": "Discard the fine-tuned models and go back to the built-in ones. WashData can re-learn them later.", + "reset_muted": "Reset muted", "reset_to_builtin": "Reset to built-in models", "reset_to_defaults": "Reset to defaults", "resume_cycle": "Resume cycle", @@ -225,6 +232,7 @@ "select_all": "Select all", "select_last20": "Last 20", "select_none": "Clear", + "set_brand_model": "Set brand & model", "share": "Share", "share_device": "⬆ Share this device", "share_device_tip": "Share this appliance and its recorded reference cycles to the community store so others with the same machine can adopt them", @@ -252,14 +260,14 @@ "start": "Must be below Anti-Wrinkle Max Power ({max} W)" }, "attn_sub": "Fix conflicts before saving", - "attn_title": "{n} setting conflict{s}", - "settings_banner": "{n} setting conflict{s} – check the highlighted sections and fix before saving.", + "attn_title": "Setting conflicts: {n}", + "settings_banner": "Setting conflicts: {n}. Check the highlighted sections and fix them before saving.", "settings_banner_btn": "Go to first", "confidence": { "auto": "Must be at or above Match Threshold ({match})", - "learning": "Must be at or below Match Threshold ({match})", + "learning": "Must be at or above Match Threshold ({match})", "match_for_auto": "Must be at or below Auto-Label Confidence ({alc})", - "match_for_learning": "Must be at or above Learning Confidence ({lc})" + "match_for_learning": "Must be at or below Learning Confidence ({lc})" }, "duration_ratio": { "max": "Must be greater than Min Duration Ratio ({min})", @@ -297,7 +305,7 @@ "match": "Must be above Unmatch Threshold ({un})", "unmatch": "Must be below Match Threshold ({match}); otherwise a committed match un-matches instantly" }, - "cascade_toast": "Also adjusted {n} setting{s} for consistency.", + "cascade_toast": "Other settings adjusted for consistency: {n}", "suggestion_resolves": "Stage the pending suggestion ({val}) below to fix this", "use_fix": "Use {val}", "watchdog": { @@ -315,12 +323,14 @@ "automation_review_this_cycle": "Review this cycle", "automations": "Automations", "clear_debug": "Clear Debug Traces", + "community_store": "Community Store", "cycle_simulator": "Cycle Simulator", "diagnostics_pref": "Diagnostics", "display": "Display", "dtw_inspector": "DTW Inspector", "export_import": "Export / Import", "getting_started": "Getting started", + "import_power_history": "Import power history", "live_power": "Live Power", "logs": "Logs", "logs_diagnostics": "Diagnostics", @@ -335,12 +345,15 @@ "ml_smart_learning": "Smart Learning", "my_preferences": "My Preferences", "no_automations": "No automations reference this device yet.", + "online_account": "Community Store & online features", "panel_settings": "Panel Settings", "param_sweep": "Parameter sweep", + "pg_across_cycles": "Across your cycles", "pg_settings_source": "Settings source", "phase_catalog": "Phase Catalog", "playground": "Playground", "process_history": "Process History", + "recommendations": "Recommendations ({n})", "settings_history": "Settings history", "status": "Status", "status_graph": "Status Graph", @@ -348,11 +361,7 @@ "test_history": "Test on history", "toggle_sidebar": "Toggle Home Assistant sidebar", "tools_and_data": "Tools & Data", - "wipe_history": "Wipe History", - "recommendations": "Recommendations ({n})", - "pg_across_cycles": "Across your cycles", - "community_store": "Community Store", - "online_account": "Community Store & online features" + "wipe_history": "Wipe History" }, "health": { "fair": "Acceptable profile quality", @@ -429,12 +438,12 @@ "cycles_title": "Cycles ({n})", "dashed": "dashed", "date": "Date", + "days": "days", "debug_traces": "Debug Traces", "default": "Default", "default_access_level": "Default level for users not listed below", "default_other": "Default (other devices)", "default_tab": "Default tab when opening the panel", - "font_size": "Panel font size", "delta": "Δ", "description": "Description", "dest_real_history": "Real history (counts in stats)", @@ -459,11 +468,15 @@ "eta_secs": "~{n}s left", "event_timeline": "Event timeline", "event_type": "Event type", + "evidence_backfill_cycles": "Found in imported power history", + "evidence_real_cycles": "Cycles this machine ran", + "evidence_reference_cycles": "Downloaded from the community store", "expected": "Expected", "expected_duration": "Expected Duration (min)", "file_kb": "File (kB)", "final_score": "Final score", "flags": "Flags", + "font_size": "Panel font size", "from": "From", "from_start": "From start", "gap_s": "Gap (s)", @@ -472,6 +485,17 @@ "head_trim": "Head Trim (s)", "health": "Health", "hide_tabs": "Hide tabs for non-admins", + "hist_csv_data": "CSV data", + "hist_from_recorder": "Or read it from Home Assistant", + "hist_keep": "Keep this cycle", + "hist_looks_complete": "complete", + "hist_reason_no_end": "never ended cleanly", + "hist_reason_short": "shorter than this appliance's shortest real cycle", + "hist_since": "Since", + "hist_skip_idle": "nothing running", + "hist_skip_long": "no break long enough to split on", + "hist_skip_short": "too few readings", + "hist_skip_sparse": "readings too far apart", "import_mode": "How to combine", "in_use": "In use", "include_phase_map": "Include phase map", @@ -492,7 +516,7 @@ "mode_merge": "Merge (keep mine)", "mode_new_profile": "Create New Profile", "mode_replace": "Replace selected", - "models_fine_tuned": "({count} model{plural} fine-tuned)", + "models_fine_tuned": "(fine-tuned models: {count})", "n_classic_suggestions": "{n} classic", "n_imported_note": ", {n} imported", "n_ml_suggestions": "{n} ML", @@ -509,12 +533,14 @@ "panel_default_tab": "Default tab", "panel_language": "Panel language", "pct_match_confidence": "{pct}% match confidence", + "peak_power_short": "Peak", "per_cycle_short": "cycle", "pg_alert_indefinite": "Would run indefinitely", "pg_alert_stress_cap": "Hit safety cap", "pg_alert_stress_ok": "Idle termination: cycle stopped", "pg_alert_stress_warn": "Idle draw above stop threshold", "pg_alert_timeout_end": "Ended by timeout, not prediction", + "pg_anti_wrinkle": "Anti-wrinkle", "pg_autofill_tip": "Auto-fill range from current value ({lo}–{hi})", "pg_best_marker": "Best: {v}", "pg_best_value": "Best value found", @@ -531,15 +557,6 @@ "pg_group_matching": "Program matching", "pg_group_timing": "Timing rules", "pg_idle": "Idle", - "pg_preset": "Playground preset", - "pg_preset_name": "New preset name", - "pg_preset_none": "Select a preset…", - "pg_stress_group": "Idle termination test", - "pg_stress_idle_auto": "Auto", - "pg_stress_idle_w": "Idle level (W)", - "pg_stress_idle_w_desc": "Override the auto-detected standby floor (leave blank for auto).", - "pg_stress_toggle": "Test idle termination", - "pg_stress_toggle_desc": "Simulates the appliance staying at its standby draw after recording ends -- shows if and when WashData stops the cycle.", "pg_last_run": "Last run: {date}", "pg_load_run": "Reload this run", "pg_match_confidence": "Match confidence", @@ -547,7 +564,16 @@ "pg_mode_optimize": "Optimize", "pg_pct_of": "{p}% of {total}", "pg_poor_match": "Poor match", + "pg_preset": "Playground preset", + "pg_preset_name": "New preset name", + "pg_preset_none": "Select a preset…", "pg_recent_runs": "Recent runs", + "pg_stress_group": "Idle termination test", + "pg_stress_idle_auto": "Auto", + "pg_stress_idle_w": "Idle level (W)", + "pg_stress_idle_w_desc": "Override the auto-detected standby floor (leave blank for auto).", + "pg_stress_toggle": "Test idle termination", + "pg_stress_toggle_desc": "Simulates the appliance staying at its standby draw after recording ends -- shows if and when WashData stops the cycle.", "pg_strong_match": "Strong match", "pg_tile_ambiguous": "Ambiguous", "pg_tile_ambiguous_tip": "Cycles with a near-tie between two programs", @@ -595,6 +621,7 @@ "settings_basic": "Basic", "settings_detail_level": "Settings detail level", "settings_overrides": "Settings overrides", + "shape": "Shape", "show_contributor": "Show contributor names", "show_debug": "Show live match debug card on the Status page (confidence, ambiguity, top candidates)", "show_expected": "Show expected curve overlay (matched profile, orange)", @@ -609,6 +636,8 @@ "store_model": "Appliance model", "tags": "Tags", "tail_trim": "Tail Trim (s)", + "task_history_import": "Scanning power history", + "task_history_import_apply": "Importing cycles", "task_merge": "Merging cycles", "task_ml_training": "Learning", "task_pg_detail": "Simulate cycle", @@ -630,8 +659,7 @@ "unlabelled": "Unlabelled", "unlabelled_paren": "(unlabelled)", "warp_path": "Warp path", - "zoom_hint": "scroll to zoom · dblclick to reset", - "pg_anti_wrinkle": "Anti-wrinkle" + "zoom_hint": "scroll to zoom · dblclick to reset" }, "log": { "all_levels": "All levels", @@ -699,6 +727,7 @@ "export_select": "Export - choose data", "force_stop_msg": "Force-stop the active cycle now? The cycle will be saved as interrupted.", "force_stop_title": "Force Stop Cycle", + "history_import": "Import power history", "import_config": "Import Configuration", "import_wizard": "Import - choose data", "label_cycle": "Label Cycle", @@ -739,7 +768,7 @@ "automations_intro": "WashData fires {start} / {end} events and exposes entities, so notifications and actions are best built as normal Home Assistant automations. Automations that use this device appear below.", "cleanup_intro": "Every labelled cycle overlaid. Tick outliers and delete to clean up the profile.", "clear_debug_hint": "Remove stored debug data to free space.", - "collecting_data": "Collecting data – {need} more cycle{plural} before fine-tuning can start ({current}/{min}).", + "collecting_data": "Collecting data. Cycles still needed before fine-tuning can start: {need} ({current}/{min}).", "compare_overlay_profiles": "Overlay profiles (faint)", "compare_overlay_tip": "Overlay learned profile envelopes to see which program each cycle resembles.", "compare_profiles_tip": "Overlay other profile envelopes on the chart above to see which one best fits this cycle.", @@ -760,16 +789,39 @@ "enough_data": "Enough data to learn from ({current}/{min} cycles).", "export_description": "Choose exactly which profiles, cycles, settings and more to export to JSON, or analyze a file and import only the parts you want.", "export_select_intro": "Tick exactly what to include. Selecting profiles without their cycles still exports a matchable program (its learned shape travels along).", - "feedback_cycles_pending": "{n} cycle{s} to review", + "feedback_cycles_pending": "To review: {n}", "feedback_prompt": "Confirm it was right, correct the program, or ignore.", "feedback_relabel_hint": "Re-labelling this cycle resolves it too.", "filter_by_profile": "Filter by profile…", + "font_size_hint": "Make everything in this panel larger or smaller. Applies to your account on this device.", "group_modal_help": "Group programs with the same shape that differ in temperature/spin (durations may vary). Matching scores the group as one candidate, then picks the best-fitting member. Pick at least 2; the overlay shows how alike they are.", "group_not_cohesive": "These profiles aren't similar enough to group reliably, so matching treats them individually until you remove the outlier or split the group.", "group_preview_hint": "Tick 2+ members to preview and compare their power curves.", "head_trim_hint": "Remove this many seconds from the start", + "hist_breaks": "{n} gaps where the sensor was unavailable", + "hist_capped": "The per-device limit for imported cycles was reached; the rest were not stored.", + "hist_duplicates": "{n} were already imported and were skipped.", + "hist_entity_substituted": "read {used} (this device is configured for {wanted})", + "hist_found": "Found {n} cycles. Untick anything that does not look like a real run - nothing is stored until you import.", + "hist_import_failed": "Import failed.", + "hist_imported_count": "{n} cycles imported.", + "hist_input_hint": "Upload a CSV downloaded from the History panel (entity, state, last changed), or let WashData read the sensor's history directly. Detection then runs over it exactly as it does live, and you choose which of the cycles it finds to keep.", + "hist_next_step": "They are in your Cycles list, tagged as imported history. Open one and use Label to name the program it belongs to.", + "hist_none_found": "No cycles could be detected in that history.", + "hist_other_entity": "{n} readings for other entities ignored", + "hist_recorder_empty": "Home Assistant has no detailed history for this sensor in that window.", + "hist_recorder_hint": "Reads from the date you pick up to now. Home Assistant keeps detailed history for 10 days by default and only hourly averages after that, which are too coarse to detect cycles from - pick a date further back only if your recorder is set to keep more.", + "hist_rows_read": "{n} readings read", + "hist_scan_capped": "Only the first candidates are shown ({n} were found).", + "hist_scan_expired": "That scan is no longer available. Please scan again.", + "hist_scan_failed": "Scanning failed.", + "hist_scanning": "Replaying your history through the detector. This runs in the background - you can close this dialog and come back to it.", + "hist_settings_used": "Detected using this device's current settings (minimum power {w} W, off delay {s} s).", + "hist_skipped_spans": "Skipped stretches", "import_analyze_hint": "Load an exported file (or paste its JSON). WashData analyzes it and shows exactly what can be imported before anything changes.", + "import_history_description": "Already had a smart plug before WashData? Upload a history export of its power sensor, or read it straight from Home Assistant, and the normal detection runs over it so past cycles turn up in your Cycles list ready to name.", "import_intro": "Load an exported file or paste a JSON payload below.", + "imported_history_readonly": "Detected in imported power history. It shapes program matching but is not counted in your statistics, and cannot be trimmed or split. Label it to name the program.", "imported_readonly": "Imported from the community store. Shown for reference and matching. It is not counted in your stats and cannot be edited.", "include_settings_hint": "Share this device's recognition and matching thresholds (not your notifications, entities or energy price). Adopters choose whether to apply them.", "legacy_actions_title": "{count} legacy custom action still running", @@ -794,6 +846,7 @@ "ml_loading": "loading ML…", "ml_settings_intro": "Two independent switches: one applies the models while a cycle runs, the other lets WashData fine-tune them to your machine over time.", "model_not_found": "Not in the catalog yet.", + "n_suggestions_muted": "{count} muted; the auto-tuner will not propose these.", "name_first_program": "You have enough cycles – name your first program to start matching.", "near_duplicate_cluster": "near-duplicate profile cluster detected. Grouping lets matching reliably pick between look-alikes (e.g. same program at different temperature/spin).", "no_cycles_match": "No cycles match the current filter.", @@ -836,37 +889,36 @@ "pg_analysis_hint2": "Pick a cycle and press Run to load match analysis.", "pg_apply_confirm": "Apply best value: {label} = {value}?", "pg_apply_settings_confirm": "Save these {n} setting(s) to this device? {list}", + "pg_canvas_empty": "Select a cycle above, then press Load to see its power trace.", + "pg_canvas_empty2": "Pick a cycle above and press Run to simulate it. Then hover to read values, scroll to zoom, and drag to pan.", + "pg_chart_caption": "Detection/match rate across values of {param}", "pg_ctrl_intro": "Values start from this device's live integration settings. Edits stay in the Playground until you publish them.", + "pg_history_intro2": "Replay your recent cycles through the real detector and matcher with the settings above. Click any row to load that cycle in the graph; edit a setting to see a before/after comparison.", "pg_live_unavailable": "Live settings unavailable, showing defaults.", "pg_load_live_confirm": "Discard {n} Playground edit(s) and reload the integration's current settings?", + "pg_match_rate": "{pct}% match rate on {total} cycles", "pg_matches_live": "Matches live settings", "pg_n_changed": "{n} changed vs live settings", - "pg_sugg_none": "All suggestions already match the current settings", "pg_preset_delete_confirm": "Delete the preset \"{name}\"?", "pg_preset_limit": "Preset limit reached ({n})", "pg_preset_overwrite": "Overwrite the preset \"{name}\"?", "pg_publish_one_confirm": "Save {label} = {value} to this device's settings?", - "pg_stress_terminated_detail": "Settled to ~{idle}W idle -- cycle ended {h}h {m}m later via {reason}.", - "pg_stress_above_threshold_detail": "Idle draw ~{idle}W is at or above the effective stop threshold ({stop}W) -- the cycle never registered as quiet. Raise stop_threshold_w to fix.", - "pg_stress_hit_cap_detail": "Cycle ran {h}h {m}m without stopping -- force-stopped by the safety cap. Idle draw {idle}W vs stop threshold {stop}W.", - "pg_canvas_empty": "Select a cycle above, then press Load to see its power trace.", - "pg_canvas_empty2": "Pick a cycle above and press Run to simulate it. Then hover to read values, scroll to zoom, and drag to pan.", - "pg_chart_caption": "Detection/match rate across values of {param}", - "pg_history_intro2": "Replay your recent cycles through the real detector and matcher with the settings above. Click any row to load that cycle in the graph; edit a setting to see a before/after comparison.", - "pg_match_rate": "{pct}% match rate on {total} cycles", "pg_restart_note": "Restart Home Assistant to enable simulation tools.", "pg_row_load_hint": "Load this cycle in the graph above", - "pg_stress_synthetic": "idle simulation starts here", "pg_running_history": "Replaying your cycles…", "pg_running_sweep": "Testing values across your cycles…", "pg_select_cycle_hint": "Select a cycle and click ↺ to load", "pg_sim_progress": "{done} / {total} cycles", "pg_simulating": "Simulating cycle…", "pg_starting": "Starting…", + "pg_stress_above_threshold_detail": "Idle draw ~{idle}W is at or above the effective stop threshold ({stop}W) -- the cycle never registered as quiet. Raise stop_threshold_w to fix.", + "pg_stress_hit_cap_detail": "Cycle ran {h}h {m}m without stopping -- force-stopped by the safety cap. Idle draw {idle}W vs stop threshold {stop}W.", + "pg_stress_synthetic": "idle simulation starts here", + "pg_stress_terminated_detail": "Settled to ~{idle}W idle -- cycle ended {h}h {m}m later via {reason}.", + "pg_sugg_none": "All suggestions already match the current settings", "pg_sweep_intro": "What if {param} were different? Test {steps} values across your last {cycles} cycles to find the setting where the most cycles are correctly matched.", "pg_sweep_progress": "Step {done} / {total}", "pg_sweep_step": "Step {done} / {total}", - "pg_undetected": "{n} cycle{s} undetected", "pg_verdict_bad": "Needs attention: many cycles are going undetected.", "pg_verdict_good": "Well tuned: most cycles are correctly identified and matched.", "pg_verdict_ok": "Acceptable: some cycles missed. Try lowering the start threshold.", @@ -878,7 +930,6 @@ "preferences_admin": "Preferences, panel & access control", "preferences_adv": "Preferences", "prefs_personal": "These apply to your Home Assistant account only.", - "font_size_hint": "Make everything in this panel larger or smaller. Applies to your account on this device.", "process_history_hint": "Re-run matching on all stored cycles, refresh tuning suggestions, retrain the ML models (if enabled), and recompute cycle health. Run this after a batch of reviews.", "profile_deleted": "Profile deleted", "profile_poor_health_detail": "Cycles assigned to this profile have inconsistent shapes or low confidence. Consider rebuilding the envelope or reviewing labelled cycles.", @@ -886,6 +937,7 @@ "rbac_hint": "When off, every Home Assistant user has full access (the default). Administrators always have full access and can manage everyone.", "recent_logs": "Recent ha_washdata records", "recording_in_progress": "Recording in progress", + "refresh_catalog_hint": "The community brand and appliance lists are cached to keep the shared store within its daily budget. Refresh to pick up entries added or approved by others.", "reminders_intro": "Show a reminder in the panel this many cycles after the last service. Leave blank or 0 to turn a reminder off.", "replace_warn": "Each ticked category is wiped and replaced from the file. Unticked categories are left untouched.", "restart_gap_footer": "Highlighted on the graph. Power data is missing for these intervals; matching used only real readings.", @@ -900,6 +952,7 @@ "review_recorded_tip": "Mark this as a hand-picked reference cycle for its program - the same role as a manually recorded cycle. Reference cycles are always kept, seed the matching template, and are never dropped by cleanup. (This is the \"golden\"/recorded flag; both are the same thing.)", "review_tags_tip": "Optional flags describing what went wrong with this cycle, so training and cleanup can account for it.", "review_to_cycles": "Open the Cycles review queue", + "samples_decimated": "Showing {shown} of {total} samples (thinned for display; peaks kept). A wide gap here is thinning, not missing data.", "saving_triggers_reload": "Saving triggers an integration reload. HA entities may briefly show as unavailable.", "search_placeholder": "Search settings…", "see_recorder": "See recorder widget below", @@ -918,16 +971,17 @@ "showing_suggestions": "Showing {count} setting with suggestions.", "split_intro": "Click the graph to add or remove a split point, or auto-detect by idle gaps. Each resulting segment can get its own profile.", "storage_diagnostics": "Storage stats, maintenance, export/import", + "store_declare_appliance": "Tell WashData which appliance you own and this tab shows the setups other people have shared for it. You can also type a brand above to look around.", "store_download_device_intro": "Adopt every shared program and its reference cycles onto your device. Your own recorded cycles and stats are not affected.", "store_enable_hint": "Enable online features in Settings to browse and import community reference cycles.", "store_picker_offline": "Enable online features in the settings gear to pick from the community catalog.", "store_share_device_intro": "Upload {brand} {model} with the reference cycles you select. Others with the same appliance can adopt your programs. Entries are reviewed before appearing publicly.", "store_share_intro": "Upload this reference cycle so others with the same appliance can use it. It is reviewed before appearing publicly.", - "sug_staged": "Set {key} = {val}. Save to apply.", - "sug_muted": "Won't suggest this setting again", + "store_sibling_hint": "Nothing shared for your exact model? A closely-related model from the same brand is usually a good starting point.", "sug_mute_failed": "Could not mute suggestion", + "sug_muted": "Won't suggest this setting again", + "sug_staged": "Set {key} = {val}. Save to apply.", "sug_unmuted_all": "Muted suggestions reset", - "n_suggestions_muted": "{count} muted; the auto-tuner will not propose these.", "tail_trim_hint": "Remove this many seconds from the end", "toast_auto_detect_enabled": "Auto-detect enabled", "toast_auto_label_complete": "Auto-label complete", @@ -988,6 +1042,7 @@ "energy_scale": "Stage 4: log-ratio where energy agreement halves; smaller = stricter penalty (default 0.25)", "anti_wrinkle_idle_timeout": "Quiet time allowed between two tumble pulses before anti-wrinkle ends", "dishwasher_end_spike_quiet_release": "Dishwasher: quiet seconds after expected duration before the end-of-cycle drain wait is released", + "smart_termination_duration_ratio": "Fraction of the matched program's expected duration a cycle must reach before Smart Termination may end it early; lower it for load- or temperature-dependent machines", "anti_wrinkle_max_power": "A pulse above this ends anti-wrinkle and opens a new cycle", "anti_wrinkle_enabled": "Absorb the tumble pulses after the main phase instead of reading them as new cycles", "anti_wrinkle_max_duration": "A pulse longer than this ends anti-wrinkle and opens a new cycle", @@ -1093,6 +1148,10 @@ "doc": "Power must fall below this between pulses for anti-wrinkle mode to stay active.", "label": "Exit Power Threshold" }, + "anti_wrinkle_idle_timeout": { + "label": "Max Pulse Gap", + "doc": "How long the machine may stay quiet between two tumble pulses before anti-wrinkle mode ends. Set it above the longest gap your dryer leaves between pulses, otherwise every later pulse is read as a false start." + }, "anti_wrinkle_max_duration": { "doc": "Pulses longer than this are treated as a real cycle rather than an anti-wrinkle tumble.", "label": "Max Duration" @@ -1113,6 +1172,10 @@ "doc": "Cycles shorter than this are discarded as ghost cycles (test runs, opening the door to add a sock).", "label": "Min Cycle Duration" }, + "corr_weight": { + "label": "Shape vs Level Weight", + "doc": "Balance between curve shape and absolute power level when scoring a match. Higher favours shape; lower favours matching the power level." + }, "delay_confirm_seconds": { "doc": "Power must stay in the standby band for this long before the appliance is treated as waiting-to-start rather than running.", "label": "Confirm Seconds" @@ -1133,18 +1196,58 @@ "doc": "Once the cycle passes its expected duration, how long the dishwasher must stay quiet (below the Stop Threshold) before WashData stops waiting for a final drain and ends the cycle. Raise it if your machine has a long silent drying phase before a late final drain that is being missed - a wider window lets the learned duration follow seasonal drift (colder inlet water = longer cycles) instead of locking to the old average. It only ever shortens the wait relative to the internal 30-minute end-spike cap, never extends it.", "label": "Passive-Dry Quiet Release" }, + "door_end_dwell_seconds": { + "label": "Door-Open End Dwell", + "doc": "How long the door must stay open before WashData ends the cycle, when \"Door Opens Automatically At End\" is on. Long enough to ignore quickly adding a dish (default 60 s), short enough to end promptly once the machine pops the door." + }, + "door_opens_at_end": { + "label": "Door Opens Automatically At End", + "doc": "For dishwashers that pop the door open at the end of the cycle to dry (AirDry and similar). With this on, a door-open on a running cycle no longer pauses it forever; instead, if the door stays open for the dwell below, WashData treats the cycle as finished. A brief open (adding an item) is ignored. Requires a Door Sensor Entity." + }, "door_sensor_entity": { "doc": "Optional door binary sensor. Used to detect when the appliance has been opened/unloaded after a cycle.", "label": "Door Sensor Entity" }, + "dtw_bandwidth": { + "label": "Shape Match Tolerance", + "doc": "How much the matcher may time-warp a cycle to fit a saved program's shape. Higher tolerates more speed variation; too high risks matching the wrong program." + }, + "dtw_blend": { + "label": "Warp Blend", + "doc": "How much the time-warp (DTW) alignment score replaces the Stage 2 core score. 0 = use only the Stage 2 score, 1 = use only the DTW score, 0.5 (default) = equal blend. Raise it to rely more on warp alignment when programs have similar power levels but different timing patterns." + }, + "dtw_ddtw_scale": { + "label": "Derivative Warp Scale", + "doc": "Half-saturation distance for derivative DTW scoring. The score halves when the DDTW distance equals this value. Smaller = more sensitive to shape differences. Default 30 is calibrated to typical appliance power traces. Only affects ensemble and ddtw DTW modes." + }, + "dtw_ensemble_w": { + "label": "Warp Ensemble Mix", + "doc": "In ensemble DTW mode (the default), the weight on scaled L1 versus derivative DTW (DDTW). 1.0 = all scaled-L1, 0 = all DDTW, 0.7 = default. The scaled-L1 variant compares power levels; the derivative variant reacts to how power changes over time. Ensemble blends both." + }, + "dtw_refine_top_n": { + "label": "Warp Refine Count", + "doc": "How many top Stage 2 candidates are re-scored by DTW. DTW is more expensive so it is only applied to the best N candidates. Default 5. Raise it (to 7-9) if the correct profile sometimes only reaches 4th or 5th place after Stage 2 - DTW can rescue it. Lowering speeds up matching slightly." + }, + "duration_scale": { + "label": "Duration Sharpness", + "doc": "Sharpness of the Stage 4 duration agreement penalty. This is the log-ratio at which the agreement score halves. Smaller = stricter: a duration mismatch hurts more. Default 0.175 corresponds to roughly 18% duration tolerance at half-weight. Pair with Duration Weight." + }, "duration_tolerance": { "doc": "Tolerance for time-remaining estimates (learning feedback, not matching). If the actual duration is within +/-X% of the estimate it counts as a good match.", "label": "Estimate Tolerance" }, + "duration_weight": { + "label": "Duration Weight", + "doc": "How strongly a program whose typical run-length matches this cycle is preferred." + }, "enable_ml_models": { "doc": "While a cycle runs, let the models refine the live results: a steadier time-remaining and energy/cost estimate, and an anti-premature-stop guard on end detection (it can only ever delay a finish, never end one early, and is bounded). Uses your fine-tuned models when available, otherwise the built-in ones. Off = the classic power-based logic only (still reliable).", "label": "Apply smart models during a cycle" }, + "enable_phase_matching": { + "doc": "Break each running cycle into phases (heating, wash, spin) and budget the time remaining per phase, blended with the classic estimate - leaning on the phase budget early in the cycle and the classic estimate near the end. This personalises the countdown to how long your machine actually heats and runs, which is most noticeable in the first half of a cycle. Off = the classic estimate only. Only the time-remaining display is affected; program matching and cycle detection are unchanged.", + "label": "Phase-aware time remaining" + }, "end_energy_threshold": { "doc": "During the off-delay countdown, accumulated energy (watts x time) is compared to this threshold. If exceeded, the countdown resets - keeping anti-crease tumbles and dishwasher drying tails attached to the cycle instead of cutting them short. Raise it if cycles end too early during cool-down; lower it if detection is sluggish.", "label": "End Energy" @@ -1161,10 +1264,18 @@ "doc": "Fixed price per kWh used for cost figures when no live price entity is set above.", "label": "Static Energy Price (per kWh)" }, + "energy_scale": { + "label": "Energy Sharpness", + "doc": "Sharpness of the Stage 4 energy agreement penalty. Smaller = stricter: an energy mismatch hurts more. Default 0.25 is intentionally more forgiving than Duration Sharpness because energy varies with load. Pair with Energy Weight." + }, "energy_sensor": { "doc": "Optional cumulative energy counter (total_increasing kWh/Wh, e.g. the plug's own lifetime meter). When set, each cycle's reported energy is taken from this counter's start-to-end delta, which avoids the under-counting you get from integrating a slow-reporting power sensor. Falls back to the integrated value if the reading is missing, its unit is unknown, or the delta is not positive. Leave blank to keep integrating the power sensor.", "label": "Energy Meter Entity" }, + "energy_weight": { + "label": "Energy Weight", + "doc": "How strongly a program whose typical energy use matches this cycle is preferred." + }, "expose_debug_entities": { "doc": "Publish extra diagnostic HA entities (match confidence, ambiguity, state internals). Off keeps the entity list clean for normal use.", "label": "Expose Debug Entities" @@ -1181,6 +1292,10 @@ "doc": "Treat the trigger sensor turning OFF (rather than ON) as the end-of-cycle signal.", "label": "Invert External Trigger (trigger on OFF)" }, + "keep_min_score": { + "label": "Min Match Score", + "doc": "Minimum similarity score a candidate needs to stay in the match race. The default 0.1 is deliberately permissive - it admits even weak candidates and relies on later stages to find the best match. Raise it to prune unlikely profiles earlier; lower it to widen the initial candidate pool." + }, "learning_confidence": { "doc": "If the match score falls between this and Auto-Label Confidence, a feedback notification asks you to verify the identified program. Below this score the match is too uncertain to surface. Must be kept below Auto-Label Confidence.", "label": "Learning Confidence" @@ -1257,6 +1372,10 @@ "doc": "Show a live-updating countdown timer in the notification (on platforms that support it) instead of a static estimate.", "label": "Use Live Chronometer" }, + "notify_live_click_action": { + "label": "Live Notification Tap Target", + "doc": "Android only. Where a tap on the live-progress notification opens (e.g. /lovelace/laundry, or a full URL) instead of the app landing page. Leave blank for the default." + }, "notify_live_interval_seconds": { "doc": "How often live-progress notifications are refreshed while a cycle runs.", "label": "Live Update Interval" @@ -1269,6 +1388,10 @@ "doc": "notify.* services called for live progress updates while a cycle runs. Leave empty to disable live-progress notifications.", "label": "Live Progress Services" }, + "notify_live_sticky": { + "label": "Keep Live Notification On Tap", + "doc": "Android only. Make the live-progress notification persistent (sticky) so tapping it does not dismiss the ongoing thread. Off keeps the default behaviour where a tap dismisses it." + }, "notify_milestone_message": { "doc": "Message for the milestone notification. Template variables: {device}, {cycle_count}.", "label": "Milestone Message" @@ -1349,6 +1472,10 @@ "doc": "Optional power-based Off detection. When above 0, once a cycle has finished and power stays below this level for the Power Off Delay, the machine is treated as switched off and the state returns to Off. Leave at 0 to disable (the default). Set it above the true switched-off floor and below the Stop Threshold and your machine's finished-but-on standby draw; if it is not below the Stop Threshold it is ignored. When enabled it replaces the Progress Reset Delay for returning to Off, so a finished machine stays in Finished/Clean until it is actually powered off.", "label": "Power Off Threshold" }, + "power_profile_interval_min": { + "label": "Power Profile Interval", + "doc": "Bucket size for the per-profile power_profile sensor attribute (the flat per-slot average-watts array consumed by external planners such as EMHASS and tibber_prices). Smaller buckets keep short power spikes sharp; larger buckets smooth the shape. Default 15 min. Read-time only; does not affect detection." + }, "power_sensor": { "doc": "The sensor entity reporting live power in watts for this appliance (e.g. sensor.washer_power). All cycle detection is based on this signal.", "label": "Power Sensor" @@ -1357,6 +1484,10 @@ "doc": "The +/- band around a profile average duration used during matching. 0.25 means a 60 min profile matches 45-75 min cycles.", "label": "Profile Duration Tolerance" }, + "profile_evidence_sources": { + "label": "Cycles that shape a program", + "doc": "Which cycles are used to build each program's power curve, and to match a finished cycle against it. Unticking a kind stops it shaping your programs without deleting anything - the cycles stay in your Cycles list and can still be labelled or removed. Useful if you do not trust imported data. Statistics are unaffected: they always count only the cycles this machine actually ran. Unticking everything is ignored, since a program with no cycles behind it could never match." + }, "profile_match_interval": { "doc": "How often to attempt profile matching during a running cycle. Default 300 s (5 minutes) balances detection speed and CPU.", "label": "Match Interval" @@ -1393,6 +1524,13 @@ "doc": "Store the full power trace and matching debug data for each cycle. Useful for troubleshooting but increases storage size.", "label": "Save Debug Traces" }, + "show_contributor": { + "doc": "Show the \"by \" attribution on community appliances and reference cycles." + }, + "smart_termination_duration_ratio": { + "doc": "How far into the matched program's expected duration a cycle must be before Smart Termination may end it early once power drops. The expected duration is the program's average, so on appliances whose runtime varies a lot - washers on cold winter vs warm summer inlet water, sensor-dry dryers, load-dependent programs - about half of all runs finish shorter than that average and never get the fast finish, ending only via the fallback timeout minutes late. Lower this (e.g. 0.85) on those machines so the early finish still fires; raise it toward 1.0 to be more conservative. Leave empty for the default (0.98, or 0.99 for dishwashers). It can only ever end a cycle earlier, never later, and never fires on an ambiguous or low-confidence match.", + "label": "Smart Termination Ratio" + }, "smoothing_window": { "doc": "How much the raw power signal is smoothed. Low (2) is responsive but noisy; high (5) smooths spikes but adds lag.", "label": "Smoothing Window" @@ -1413,30 +1551,6 @@ "doc": "Power must fall below this level before the off-delay countdown begins. Set it below the Start Threshold - the gap between them is the hysteresis band that prevents flicker. If set too high, low-power phases (rinse holds, anti-crease) falsely trigger the end sequence.", "label": "Stop Threshold" }, - "switch_entity": { - "doc": "Optional switch toggled off on pause and back on when resuming, used together with \"Pause also cuts power\".", - "label": "Switch Entity" - }, - "watchdog_interval": { - "doc": "How often the background watchdog checks for stalled sensors and elapsed timeouts. Default 30 s.", - "label": "Watchdog Interval" - }, - "dtw_bandwidth": { - "label": "Shape Match Tolerance", - "doc": "How much the matcher may time-warp a cycle to fit a saved program's shape. Higher tolerates more speed variation; too high risks matching the wrong program." - }, - "corr_weight": { - "label": "Shape vs Level Weight", - "doc": "Balance between curve shape and absolute power level when scoring a match. Higher favours shape; lower favours matching the power level." - }, - "duration_weight": { - "label": "Duration Weight", - "doc": "How strongly a program whose typical run-length matches this cycle is preferred." - }, - "energy_weight": { - "label": "Energy Weight", - "doc": "How strongly a program whose typical energy use matches this cycle is preferred." - }, "store_brand": { "label": "Appliance Brand", "doc": "Optional. The appliance brand, picked from the community catalog. Used to find and share matching reference recordings. Leave blank if you are not using online features." @@ -1445,70 +1559,22 @@ "label": "Appliance Model", "doc": "Optional. The appliance model, picked from the community catalog once a brand is set. If your model is not listed you can add it to the catalog." }, - "show_contributor": { - "doc": "Show the \"by \" attribution on community appliances and reference cycles." + "switch_entity": { + "doc": "Optional switch toggled off on pause and back on when resuming, used together with \"Pause also cuts power\".", + "label": "Switch Entity" }, - "enable_phase_matching": { - "doc": "Break each running cycle into phases (heating, wash, spin) and budget the time remaining per phase, blended with the classic estimate - leaning on the phase budget early in the cycle and the classic estimate near the end. This personalises the countdown to how long your machine actually heats and runs, which is most noticeable in the first half of a cycle. Off = the classic estimate only. Only the time-remaining display is affected; program matching and cycle detection are unchanged.", - "label": "Phase-aware time remaining" - }, - "keep_min_score": { - "label": "Min Match Score", - "doc": "Minimum similarity score a candidate needs to stay in the match race. The default 0.1 is deliberately permissive - it admits even weak candidates and relies on later stages to find the best match. Raise it to prune unlikely profiles earlier; lower it to widen the initial candidate pool." - }, - "dtw_blend": { - "label": "Warp Blend", - "doc": "How much the time-warp (DTW) alignment score replaces the Stage 2 core score. 0 = use only the Stage 2 score, 1 = use only the DTW score, 0.5 (default) = equal blend. Raise it to rely more on warp alignment when programs have similar power levels but different timing patterns." - }, - "dtw_ensemble_w": { - "label": "Warp Ensemble Mix", - "doc": "In ensemble DTW mode (the default), the weight on scaled L1 versus derivative DTW (DDTW). 1.0 = all scaled-L1, 0 = all DDTW, 0.7 = default. The scaled-L1 variant compares power levels; the derivative variant reacts to how power changes over time. Ensemble blends both." - }, - "dtw_ddtw_scale": { - "label": "Derivative Warp Scale", - "doc": "Half-saturation distance for derivative DTW scoring. The score halves when the DDTW distance equals this value. Smaller = more sensitive to shape differences. Default 30 is calibrated to typical appliance power traces. Only affects ensemble and ddtw DTW modes." - }, - "dtw_refine_top_n": { - "label": "Warp Refine Count", - "doc": "How many top Stage 2 candidates are re-scored by DTW. DTW is more expensive so it is only applied to the best N candidates. Default 5. Raise it (to 7-9) if the correct profile sometimes only reaches 4th or 5th place after Stage 2 - DTW can rescue it. Lowering speeds up matching slightly." - }, - "duration_scale": { - "label": "Duration Sharpness", - "doc": "Sharpness of the Stage 4 duration agreement penalty. This is the log-ratio at which the agreement score halves. Smaller = stricter: a duration mismatch hurts more. Default 0.175 corresponds to roughly 18% duration tolerance at half-weight. Pair with Duration Weight." - }, - "energy_scale": { - "label": "Energy Sharpness", - "doc": "Sharpness of the Stage 4 energy agreement penalty. Smaller = stricter: an energy mismatch hurts more. Default 0.25 is intentionally more forgiving than Duration Sharpness because energy varies with load. Pair with Energy Weight." - }, - "anti_wrinkle_idle_timeout": { - "label": "Max Pulse Gap", - "doc": "How long the machine may stay quiet between two tumble pulses before anti-wrinkle mode ends. Set it above the longest gap your dryer leaves between pulses, otherwise every later pulse is read as a false start." - }, - "power_profile_interval_min": { - "label": "Power Profile Interval", - "doc": "Bucket size for the per-profile power_profile sensor attribute (the flat per-slot average-watts array consumed by external planners such as EMHASS and tibber_prices). Smaller buckets keep short power spikes sharp; larger buckets smooth the shape. Default 15 min. Read-time only; does not affect detection." - }, - "notify_live_sticky": { - "label": "Keep Live Notification On Tap", - "doc": "Android only. Make the live-progress notification persistent (sticky) so tapping it does not dismiss the ongoing thread. Off keeps the default behaviour where a tap dismisses it." - }, - "notify_live_click_action": { - "label": "Live Notification Tap Target", - "doc": "Android only. Where a tap on the live-progress notification opens (e.g. /lovelace/laundry, or a full URL) instead of the app landing page. Leave blank for the default." - }, - "door_opens_at_end": { - "label": "Door Opens Automatically At End", - "doc": "For dishwashers that pop the door open at the end of the cycle to dry (AirDry and similar). With this on, a door-open on a running cycle no longer pauses it forever; instead, if the door stays open for the dwell below, WashData treats the cycle as finished. A brief open (adding an item) is ignored. Requires a Door Sensor Entity." - }, - "door_end_dwell_seconds": { - "label": "Door-Open End Dwell", - "doc": "How long the door must stay open before WashData ends the cycle, when \"Door Opens Automatically At End\" is on. Long enough to ignore quickly adding a dish (default 60 s), short enough to end promptly once the machine pops the door." + "watchdog_interval": { + "doc": "How often the background watchdog checks for stalled sensors and elapsed timeouts. Default 30 s.", + "label": "Watchdog Interval" } }, "setting_group": { "auto_labeling": { "label": "Auto-Labeling" }, + "basic_configuration": { + "label": "Basic configuration" + }, "cycle_end": { "label": "Cycle End" }, @@ -1524,6 +1590,9 @@ "debug": { "label": "Debug" }, + "device_info": { + "label": "Device info" + }, "dishwasher_end": { "label": "Dishwasher End" }, @@ -1557,6 +1626,9 @@ "predictive_end": { "label": "Predictive End" }, + "profile_evidence": { + "label": "Profile Evidence" + }, "quiet_hours_milestones": { "label": "Quiet Hours & Milestones" }, @@ -1580,37 +1652,32 @@ }, "watchdog": { "label": "Watchdog" - }, - "device_info": { - "label": "Device info" - }, - "basic_configuration": { - "label": "Basic configuration" } }, "status": { "active": "Active", "all_statuses": "All statuses", "ambiguous": "Ambiguous", + "analyzing": "Analyzing…", "auto_detect": "Auto-detect", "clear": "Clear", "completed": "Completed", + "deleting": "Deleting…", "fine_tuning": "fine-tuning now…", "force_stopped": "Force stopped", "idle": "Idle", + "imported": "Imported", "interrupted": "Interrupted", + "preparing": "Preparing…", "ready": "Ready to process", - "recording": "Recording", - "training": "Training…", - "saving": "Saving…", "rebuilding": "Rebuilding…", - "analyzing": "Analyzing…", + "recording": "Recording", "resetting": "Resetting…", "reverting": "Reverting…", - "trimming": "Trimming…", + "saving": "Saving…", "splitting": "Splitting…", - "deleting": "Deleting…", - "imported": "Imported" + "training": "Training…", + "trimming": "Trimming…" }, "suggestion": { "both_agree": "WashData recommends", @@ -1757,6 +1824,7 @@ "auto_label_complete": "Auto-label complete", "automation_deleted": "Automation deleted", "brand_added": "Brand added - awaiting approval", + "catalog_refreshed": "Community catalog refreshed", "correction_submitted": "Correction submitted", "cycle_deleted": "Cycle deleted", "cycle_force_stopped": "Cycle force-stopped", @@ -1775,9 +1843,11 @@ "feedback_all_dismissed": "All feedbacks dismissed", "feedback_confirmed": "Feedback confirmed", "feedback_dismissed": "Feedback dismissed", + "file_read_failed": "Could not read that file", "group_deleted": "Group deleted", "group_name_required": "Group name is required", "group_saved": "Group saved", + "hist_csv_required": "Load a CSV file or paste its contents first", "import_selective_done": "Imported {profiles} profile(s) and {cycles} cycle(s)", "import_successful": "Import successful; integration reloading", "json_required": "JSON data is required", @@ -1798,9 +1868,9 @@ "pause_no_cycle": "No active cycle to pause", "pg_preset_loaded": "Preset \"{name}\" loaded", "pg_preset_saved": "Preset \"{name}\" saved", + "pg_run_gone": "That run is no longer available.", "pg_sugg_loaded": "Staged {n} suggested value(s) - run the Playground to compare", "pg_sugg_ml_loaded": "Staged {n} ML-calibrated value(s) - run the Playground to compare", - "pg_run_gone": "That run is no longer available.", "phase_name_required": "Phase name is required", "phase_updated": "Phase updated", "phases_saved": "Phases saved", @@ -1899,7 +1969,11 @@ "browse": "Browse", "device": "Device", "favorites": "Favourites", + "programs_count": "Programs: {n}", + "search_brand_ph": "Search by brand…", "search_ph": "Search by brand or model…", + "your_model": "Yours", + "your_model_tip": "This is the appliance you declared in Settings", "no_results": "No matching appliances found. Try a different search.", "n_programs": "{n} programs", "n_cycles": "{n} cycles", diff --git a/custom_components/ha_washdata/translations/panel/es.json b/custom_components/ha_washdata/translations/panel/es.json index ba9c7f8f..44c6c825 100644 --- a/custom_components/ha_washdata/translations/panel/es.json +++ b/custom_components/ha_washdata/translations/panel/es.json @@ -30,9 +30,12 @@ "awaiting": "A la espera de aprobación", "imported_tip": "Importado de la tienda de la comunidad. Se usa solo para la coincidencia, no cuenta en las estadísticas.", "not_importable": "n/d aquí", - "exists": "ya existe" + "exists": "ya existe", + "backfilled_tip": "Detectado en un historial de potencia importado. Solo influye en la coincidencia de programas, no cuenta en las estadísticas." }, "btn": { + "set_brand_model": "Definir marca y modelo", + "refresh_catalog": "Actualizar catálogo", "add_device": "+ Agregar dispositivo", "add_device_tip": "Agregue otro dispositivo WashData", "add_maintenance": "Agregar evento de mantenimiento", @@ -193,7 +196,12 @@ "import_selected": "Importar lo seleccionado", "back": "Atrás", "mute_suggestion": "Dejar de sugerir este ajuste", - "reset_muted": "Restablecer silenciados" + "reset_muted": "Restablecer silenciados", + "import_power_history": "Importar historial de potencia", + "hist_read_recorder": "Leer desde Home Assistant", + "hist_scan": "Buscar ciclos", + "hist_import_n": "Importar {n} ciclos", + "hist_goto_cycles": "Ver los ciclos" }, "conflict": { "anti_wrinkle_exit": { @@ -205,14 +213,14 @@ "start": "Debe ser inferior a la Potencia máxima antiarrugas ({max} W)" }, "attn_sub": "Corrige los conflictos antes de guardar", - "attn_title": "{n} conflicto{s} de ajustes", - "settings_banner": "{n} conflicto{s} de ajustes – revisa las secciones resaltadas y corrígelos antes de guardar.", + "attn_title": "Conflictos de ajustes: {n}", + "settings_banner": "Conflictos de ajustes: {n}. Revisa las secciones resaltadas y corrígelos antes de guardar.", "settings_banner_btn": "Ir al primero", "confidence": { "auto": "Debe ser mayor o igual al Umbral de coincidencia ({match})", - "learning": "Debe ser menor o igual al Umbral de coincidencia ({match})", + "learning": "Debe ser mayor o igual al Umbral de coincidencia ({match})", "match_for_auto": "Debe ser menor o igual a la Confianza de etiquetado automático ({alc})", - "match_for_learning": "Debe ser mayor o igual a la Confianza de aprendizaje ({lc})" + "match_for_learning": "Debe ser menor o igual a la Confianza de aprendizaje ({lc})" }, "duration_ratio": { "max": "Debe ser mayor que el Ratio de duración mínimo ({min})", @@ -250,7 +258,7 @@ "match": "Debe ser superior al Umbral de no coincidencia ({un})", "unmatch": "Debe ser inferior al Umbral de coincidencia ({match}); de lo contrario, una coincidencia confirmada se deshace instantáneamente" }, - "cascade_toast": "También se ajustaron {n} ajuste{s} por coherencia.", + "cascade_toast": "Otros ajustes modificados por coherencia: {n}", "suggestion_resolves": "Aplique la sugerencia pendiente ({val}) abajo para corregir esto", "use_fix": "Usar {val}", "watchdog": { @@ -308,7 +316,8 @@ "pg_outcome": "Resultado de la simulación", "pg_across_cycles": "En todos tus ciclos", "community_store": "Tienda de la comunidad", - "online_account": "Tienda de la comunidad y funciones en línea" + "online_account": "Tienda de la comunidad y funciones en línea", + "import_power_history": "Importar historial de potencia" }, "health": { "fair": "Calidad de perfil aceptable", @@ -316,6 +325,7 @@ "poor": "⚠ Calidad de perfil deficiente" }, "lbl": { + "drag_to_resize": "Arrastre para cambiar el tamaño", "actions": "Comportamiento", "activity": "Actividad", "administrators": "Administradores", @@ -402,7 +412,7 @@ "metric": "Métrica", "mode_existing_profile": "Agregar al perfil existente", "mode_new_profile": "Crear nuevo perfil", - "models_fine_tuned": "({count} modelo{plural} ajustado{plural})", + "models_fine_tuned": "(modelos ajustados: {count})", "n_classic_suggestions": "{n} clásico", "n_ml_suggestions": "{n} ML", "n_selected": "{n} seleccionados", @@ -629,7 +639,26 @@ "conflict_import_copy": "Importar como copia", "conflict_keep_mine": "Conservar el mío", "conflict_overwrite": "Sobrescribir", - "font_size": "Tamaño de fuente del panel" + "font_size": "Tamaño de fuente del panel", + "hist_csv_data": "Datos CSV", + "hist_from_recorder": "O léalo desde Home Assistant", + "hist_since": "Desde", + "days": "días", + "hist_keep": "Conservar este ciclo", + "hist_looks_complete": "completo", + "peak_power_short": "Pico", + "shape": "Forma", + "hist_skip_idle": "nada en funcionamiento", + "hist_skip_sparse": "lecturas demasiado separadas", + "hist_skip_short": "muy pocas lecturas", + "hist_skip_long": "ninguna pausa lo bastante larga para dividir", + "hist_reason_short": "más corto que el ciclo real más corto de este electrodoméstico", + "hist_reason_no_end": "nunca terminó de forma limpia", + "task_history_import": "Analizando el historial de potencia", + "task_history_import_apply": "Importando ciclos", + "evidence_real_cycles": "Ciclos que ejecutó esta máquina", + "evidence_reference_cycles": "Descargados de la tienda de la comunidad", + "evidence_backfill_cycles": "Encontrados en un historial de potencia importado" }, "log": { "all_levels": "Todos los niveles", @@ -708,9 +737,15 @@ "store_share": "Compartir en la tienda de la comunidad", "store_share_device": "Compartir este dispositivo", "export_select": "Exportar - elegir datos", - "import_wizard": "Importar - elegir datos" + "import_wizard": "Importar - elegir datos", + "history_import": "Importar historial de potencia" }, "msg": { + "tail_trim_hint": "Número de segundos que se eliminarán del final", + "store_sibling_hint": "¿No hay nada compartido para su modelo exacto? Un modelo muy parecido de la misma marca suele ser un buen punto de partida.", + "store_declare_appliance": "Indique a WashData qué electrodoméstico tiene y esta pestaña mostrará las configuraciones que otras personas han compartido para él. También puede escribir una marca arriba para echar un vistazo.", + "refresh_catalog_hint": "Las listas de marcas y electrodomésticos de la comunidad se guardan en caché para mantener la tienda compartida dentro de su cuota diaria. Actualice para incorporar las entradas añadidas o aprobadas por otros usuarios.", + "head_trim_hint": "Número de segundos que se eliminarán del inicio", "appliance_monitor": "monitor de electrodomésticos", "artifact_dip_detail": "Cayó por debajo de la banda de potencia habitual durante ~{n}s.", "artifact_footer": "Destacado en el gráfico de arriba. Estos son artefactos transitorios (por ejemplo, la puerta se abrió a mitad del ciclo), no necesariamente problemas.", @@ -721,7 +756,7 @@ "automations_intro": "WashData dispara los eventos {start} / {end} y expone entidades, por lo que las notificaciones y acciones se construyen mejor como automatizaciones normales de Home Assistant. Las automatizaciones que usan este dispositivo aparecen a continuación.", "cleanup_intro": "Cada ciclo etiquetado se superpone. Marque los valores atípicos y elimínelos para limpiar el perfil.", "clear_debug_hint": "Elimine los datos de depuración almacenados para liberar espacio.", - "collecting_data": "Recopilando datos - faltan {need} ciclo{plural} más antes de que pueda comenzar el ajuste fino ({current}/{min}).", + "collecting_data": "Recopilando datos. Ciclos que faltan para que pueda comenzar el ajuste fino: {need} ({current}/{min}).", "compare_overlay_profiles": "Perfiles superpuestos (débil)", "compare_profiles_tip": "Superponga otros sobres de perfil en el cuadro anterior para ver cuál se adapta mejor a este ciclo.", "compare_selected_cycles": "Ciclos seleccionados (sólidos): mostrar/ocultar", @@ -731,7 +766,7 @@ "cycles_deleted": "{count} ciclo(s) eliminado(s)", "enough_data": "Suficientes datos para aprender ({current}/{min} ciclos).", "export_description": "Elige exactamente qué perfiles, ciclos, ajustes y más exportar a JSON, o analiza un archivo e importa solo las partes que quieras.", - "feedback_cycles_pending": "{n} ciclo{s} por revisar", + "feedback_cycles_pending": "Para revisar: {n}", "feedback_prompt": "Confirme que era correcto, corrija el programa o ignórelo.", "feedback_relabel_hint": "Volver a etiquetar este ciclo también lo resuelve.", "filter_by_profile": "Filtrar por perfil…", @@ -765,7 +800,7 @@ "no_cycles_yet": "Aún no se han registrado ciclos.", "no_device_selected": "Ningún dispositivo seleccionado.", "no_devices": "Aún no hay dispositivos WashData configurados.", - "no_envelope": "Aún no hay sobre: ​​reconstruir después de los ciclos de etiquetado.", + "no_envelope": "Aún no hay sobre: reconstruir después de los ciclos de etiquetado.", "no_envelope_overlay": "No hay sobre disponible para superponer.", "no_fine_tuned": "Aún no hay nada ajustado: WashData está utilizando sus modelos integrados.", "no_logs": "Aún no hay registros almacenados en el búfer.", @@ -808,7 +843,6 @@ "pg_stress_synthetic": "la simulación en reposo empieza aquí", "pg_sweep_intro": "¿Y si {param} fuera diferente? Pruebe {steps} valores en sus últimos {cycles} ciclos para encontrar el ajuste con el que más ciclos se reconocen correctamente.", "pg_sweep_step": "Paso {done} / {total}", - "pg_undetected": "{n} ciclo{s} no detectado{s}", "pg_verdict_bad": "Necesita atención: muchos ciclos no se detectan.", "pg_verdict_good": "Bien ajustado: la mayoría de los ciclos se identifican y reconocen correctamente.", "pg_verdict_ok": "Aceptable: algunos ciclos no se detectaron. Pruebe a bajar el umbral de inicio.", @@ -839,6 +873,7 @@ "review_recorded_tip": "Marque esto como un ciclo de referencia cuidadosamente seleccionado para su programa: la misma función que un ciclo registrado manualmente. Los ciclos de referencia siempre se mantienen, generan la plantilla coincidente y nunca se eliminan durante la limpieza. (Esta es la bandera \"dorada\"/grabada; ambas son lo mismo).", "review_tags_tip": "Banderas opcionales que describen lo que salió mal en este ciclo, para que el entrenamiento y la limpieza puedan explicarlo.", "review_to_cycles": "Abrir la cola de revisión de Ciclos", + "samples_decimated": "Mostrando {shown} de {total} muestras (reducidas para la visualización; se conservan los picos). Un hueco amplio aquí se debe a la reducción, no a datos faltantes.", "saving_triggers_reload": "Al guardar se activa una recarga de integración. Las entidades HA pueden aparecer brevemente como no disponibles.", "search_placeholder": "Configuración de búsqueda…", "see_recorder": "Vea el widget de grabadora a continuación", @@ -958,10 +993,33 @@ "sug_mute_failed": "No se pudo silenciar la sugerencia", "sug_unmuted_all": "Sugerencias silenciadas restablecidas", "n_suggestions_muted": "{count} silenciadas; el ajustador automático no propondrá estas.", - "font_size_hint": "Aumenta o reduce el tamaño de todo en este panel. Se aplica a tu cuenta en este dispositivo." + "font_size_hint": "Aumenta o reduce el tamaño de todo en este panel. Se aplica a tu cuenta en este dispositivo.", + "import_history_description": "¿Ya tenía un enchufe inteligente antes de WashData? Cargue una exportación del historial de su sensor de potencia, o léalo directamente desde Home Assistant: la detección normal se ejecuta sobre esos datos, así que los ciclos pasados aparecen en su lista de Ciclos listos para nombrar.", + "hist_input_hint": "Cargue un CSV descargado del panel Historial (entidad, estado, último cambio), o deje que WashData lea directamente el historial del sensor. Después la detección se ejecuta sobre él igual que en directo, y usted elige cuáles de los ciclos encontrados conservar.", + "hist_recorder_hint": "Lee desde la fecha que elija hasta ahora. Home Assistant guarda el historial detallado durante 10 días de forma predeterminada y, a partir de ahí, solo promedios por hora, demasiado imprecisos para detectar ciclos - elija una fecha más antigua solo si el recorder de Home Assistant está configurado para conservar más.", + "hist_scanning": "Se está reproduciendo su historial a través del detector. Esto se ejecuta en segundo plano: puede cerrar este diálogo y volver más tarde.", + "hist_imported_count": "{n} ciclos importados.", + "hist_duplicates": "{n} ya se habían importado y se omitieron.", + "hist_capped": "Se alcanzó el límite de ciclos importados por dispositivo; el resto no se guardó.", + "hist_next_step": "Están en su lista de Ciclos, marcados como historial importado. Abra uno y use Etiquetar para indicar el programa al que pertenece.", + "hist_rows_read": "{n} lecturas leídas", + "hist_breaks": "{n} huecos en los que el sensor no estaba disponible", + "hist_other_entity": "{n} lecturas de otras entidades ignoradas", + "hist_entity_substituted": "{used} leído (este dispositivo está configurado para {wanted})", + "hist_skipped_spans": "Tramos omitidos", + "hist_settings_used": "Detectado con los ajustes actuales de este dispositivo (potencia mínima {w} W, retardo de apagado {s} s).", + "hist_none_found": "No se pudo detectar ningún ciclo en ese historial.", + "hist_found": "Se encontraron {n} ciclos. Desmarque todo lo que no parezca un ciclo real: no se guarda nada hasta que importe.", + "hist_scan_capped": "Solo se muestran los primeros candidatos (se encontraron {n}).", + "hist_recorder_empty": "Home Assistant no tiene historial detallado de este sensor en ese periodo.", + "hist_scan_failed": "El análisis falló.", + "hist_scan_expired": "Ese análisis ya no está disponible. Vuelva a analizar el historial.", + "hist_import_failed": "La importación falló.", + "imported_history_readonly": "Detectado en un historial de potencia importado. Influye en la coincidencia de programas, pero no cuenta en sus estadísticas y no se puede recortar ni dividir. Etiquételo para indicar el programa." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Lavavajillas: segundos de silencio tras la duración esperada antes de liberar la espera del vaciado de fin de ciclo", + "smart_termination_duration_ratio": "Fracción de la duración esperada del programa coincidente que un ciclo debe alcanzar antes de que la Terminación inteligente pueda finalizarlo antes de tiempo; redúzcala para máquinas que dependen de la carga o la temperatura", "anti_wrinkle_enabled": "Absorber los pulsos de volteo tras la fase principal en lugar de leerlos como nuevos ciclos", "anti_wrinkle_exit_power": "La potencia debe caer por debajo de este valor entre pulsos para que el modo antiarrugas siga activo", "anti_wrinkle_idle_timeout": "Tiempo de silencio permitido entre dos pulsos de volteo antes de que finalice el modo antiarrugas", @@ -1198,7 +1256,7 @@ "label": "Potencia mínima" }, "ml_training_enabled": { - "doc": "Estudie periódicamente los ciclos revisados ​​durante la noche y ajuste los modelos a esta máquina específica. Un cambio sólo se mantiene cuando realmente obtiene una mejor puntuación en los ciclos prolongados, por lo que esto sólo puede ayudar o permanecer igual, nunca retroceder.", + "doc": "Estudie periódicamente los ciclos revisados durante la noche y ajuste los modelos a esta máquina específica. Un cambio sólo se mantiene cuando realmente obtiene una mejor puntuación en los ciclos prolongados, por lo que esto sólo puede ayudar o permanecer igual, nunca retroceder.", "label": "Aprender de esta máquina" }, "ml_training_hour": { @@ -1373,6 +1431,10 @@ "doc": "Almacene el seguimiento de potencia total y los datos de depuración coincidentes para cada ciclo. Útil para solucionar problemas pero aumenta el tamaño de almacenamiento.", "label": "Guardar trazas de depuración" }, + "smart_termination_duration_ratio": { + "doc": "Cuánto debe haber avanzado un ciclo dentro de la duración esperada del programa coincidente antes de que la Terminación inteligente pueda finalizarlo antes de tiempo una vez que la potencia cae. La duración esperada es el promedio del programa, por lo que en aparatos cuyo tiempo de funcionamiento varía mucho - lavadoras según el agua de entrada fría en invierno o templada en verano, secadoras con sensor de humedad, programas que dependen de la carga - cerca de la mitad de las ejecuciones terminan antes que ese promedio y nunca obtienen el final rápido, finalizando solo mediante el tiempo de espera de reserva con minutos de retraso. Baje este valor (p. ej. 0,85) en esas máquinas para que el final anticipado siga activándose; súbalo hacia 1,0 para ser más conservador. Déjelo vacío para el valor predeterminado (0,98, o 0,99 para lavavajillas). Solo puede finalizar un ciclo antes, nunca después, y nunca se activa en una coincidencia ambigua o de baja confianza.", + "label": "Proporción de terminación inteligente" + }, "smoothing_window": { "doc": "Cuánto se suaviza la señal de potencia bruta. Bajo (2) responde pero es ruidoso; alto (5) suaviza los picos pero agrega retraso.", "label": "Ventana de suavizado" @@ -1503,6 +1565,10 @@ "door_end_dwell_seconds": { "label": "Tiempo mínimo de puerta abierta para finalizar", "doc": "Cuánto tiempo debe permanecer abierta la puerta antes de que WashData finalice el ciclo, cuando «La puerta se abre automáticamente al final» está activada. Lo suficientemente largo como para ignorar añadir un elemento rápidamente (predeterminado 60 s), y lo suficientemente corto como para terminar pronto una vez que la máquina abra la puerta." + }, + "profile_evidence_sources": { + "label": "Ciclos que forman un programa", + "doc": "Qué ciclos se usan para construir la curva de potencia de cada programa y para comparar con ella un ciclo terminado. Al desmarcar un tipo, este deja de dar forma a sus programas sin que se borre nada - los ciclos siguen en su lista de Ciclos y aún se pueden etiquetar o eliminar. Útil si no confía en los datos importados. Las estadísticas no se ven afectadas: siempre cuentan solo los ciclos que esta máquina ejecutó realmente. Desmarcar todo se ignora, ya que un programa sin ciclos detrás nunca podría coincidir." } }, "setting_group": { @@ -1586,6 +1652,9 @@ }, "basic_configuration": { "label": "Configuración básica" + }, + "profile_evidence": { + "label": "Base de los perfiles" } }, "status": { @@ -1610,7 +1679,8 @@ "trimming": "Recortando…", "splitting": "Dividiendo…", "deleting": "Eliminando…", - "imported": "Importado" + "imported": "Importado", + "preparing": "Preparando…" }, "tab": { "advanced": "Avanzado", @@ -1640,6 +1710,7 @@ "wrong_profile": "perfil equivocado" }, "toast": { + "catalog_refreshed": "Catálogo de la comunidad actualizado", "access_saved": "Control de acceso guardado", "all_wiped": "Todos los datos borrados", "analysis_complete_none": "Análisis completo: no hay nuevas sugerencias", @@ -1745,7 +1816,9 @@ "store_download_failed": "Error al descargar: {error}", "store_download_nothing": "Nada nuevo que descargar - esta configuración ya está en tu dispositivo.", "export_selective_done": "Exportación descargada", - "import_selective_done": "Se importaron {profiles} perfil(es) y {cycles} ciclo(s)" + "import_selective_done": "Se importaron {profiles} perfil(es) y {cycles} ciclo(s)", + "hist_csv_required": "Cargue primero un archivo CSV o pegue su contenido", + "file_read_failed": "No se pudo leer ese archivo" }, "suggestion": { "both_agree": "WashData recomienda", @@ -1841,7 +1914,7 @@ "thr_batch": "Se mantiene justo por encima de la potencia activa más baja en el p05 en {cycles} ciclos ({p05}W) para captar un arranque lo antes posible y que el umbral de parada quede por debajo de la potencia de funcionamiento más baja de la máquina.", "tol_per_profile": "p75 de la varianza de duración por perfil en {profiles} perfiles ({cycles} ciclos); los perfiles ajustados no se penalizan.", "tol_pooled": "Según la varianza de duración agrupada de {cycles} ciclos etiquetados recientes (desviación p95={dev}).", - "watchdog": "Se mantiene lo más bajo que resulta seguro (justo por encima del intervalo de actualización p95 de {p95}s, mín. 30s) para detectar rápido los bloqueos sin paradas falsas." + "watchdog": "Se mantiene lo más bajo que resulta seguro (justo por encima del intervalo de actualización p95 de {p95}s y al menos 2x el intervalo de muestreo de {median}s, mín. 30s) para detectar rápido los bloqueos sin paradas falsas." }, "exclusions": { "summary": "Excluido(s) {total} ciclo(s) mal detectado(s): {parts}.", @@ -1907,6 +1980,10 @@ "finished": "El ciclo alcanzó un estado final y terminó." }, "store": { + "your_model_tip": "Este es el electrodoméstico que declaró en Ajustes", + "your_model": "El suyo", + "search_brand_ph": "Buscar por marca…", + "programs_count": "Programas: {n}", "browse": "Explorar", "device": "Dispositivo", "favorites": "Favoritos", diff --git a/custom_components/ha_washdata/translations/panel/et.json b/custom_components/ha_washdata/translations/panel/et.json index d2d88819..16c6a851 100644 --- a/custom_components/ha_washdata/translations/panel/et.json +++ b/custom_components/ha_washdata/translations/panel/et.json @@ -78,9 +78,12 @@ "awaiting": "Ootab heakskiitu", "imported_tip": "Imporditud kogukonna poest. Kasutatakse ainult sobitamiseks, ei arvestata statistikas.", "not_importable": "pole saadaval", - "exists": "on olemas" + "exists": "on olemas", + "backfilled_tip": "Tuvastatud imporditud võimsuse ajaloos. Mõjutab ainult programmide sobitamist, statistikas ei arvestata." }, "btn": { + "set_brand_model": "Määra bränd ja mudel", + "refresh_catalog": "Värskenda kataloogi", "add_device": "+ Lisa seade", "add_device_tip": "Lisage veel üks WashData seade", "add_maintenance": "Lisa hooldussündmus", @@ -241,7 +244,12 @@ "import_selected": "Impordi valitud", "back": "Tagasi", "mute_suggestion": "Lõpeta selle seade soovitamine", - "reset_muted": "Lähtesta vaigistatud" + "reset_muted": "Lähtesta vaigistatud", + "import_power_history": "Impordi võimsuse ajalugu", + "hist_read_recorder": "Loe Home Assistantist", + "hist_scan": "Otsi tsükleid", + "hist_import_n": "Impordi tsüklid ({n})", + "hist_goto_cycles": "Näita tsükleid" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "Peab olema alla kortsudevastase maksimaalse võimsuse ({max} W)" }, "attn_sub": "Lahendage konfliktid enne salvestamist", - "attn_title": "{n} seadete konflikt{s}", - "settings_banner": "{n} seadete konflikt{s} – kontrollige esile tõstetud sektsioone ja parandage enne salvestamist.", + "attn_title": "Seadete konfliktid: {n}", + "settings_banner": "Seadete konfliktid: {n}. Kontrollige esile tõstetud sektsioone ja parandage need enne salvestamist.", "settings_banner_btn": "Mine esimesele", "confidence": { "auto": "Peab olema vähemalt vaste läve tasemel ({match})", - "learning": "Peab olema kuni vaste läve ({match})", + "learning": "Peab olema vähemalt vaste läve tasemel ({match})", "match_for_auto": "Peab olema kuni automaatse sildi usalduse ({alc})", - "match_for_learning": "Peab olema vähemalt õppimise usalduse tasemel ({lc})" + "match_for_learning": "Peab olema kuni õppimise usalduse tasemel ({lc})" }, "duration_ratio": { "max": "Peab olema suurem kui minimaalse kestuse suhe ({min})", @@ -298,7 +306,7 @@ "match": "Peab olema üle mitte-vaste läve ({un})", "unmatch": "Peab olema alla vaste läve ({match}); muidu tühistatakse kinnitatud vaste kohe" }, - "cascade_toast": "Järjepidevuse tagamiseks kohandati automaatselt ka {n} seadistust.", + "cascade_toast": "Järjepidevuse tagamiseks kohandati muid seadeid: {n}", "suggestion_resolves": "Rakendage allpool kuvatav ootel soovitus ({val}), et see parandada", "use_fix": "Kasuta {val}", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Simulatsiooni tulemus", "pg_across_cycles": "Sinu tsüklite lõikes", "community_store": "Kogukonna pood", - "online_account": "Kogukonna pood ja veebifunktsioonid" + "online_account": "Kogukonna pood ja veebifunktsioonid", + "import_power_history": "Võimsuse ajaloo import" }, "health": { "fair": "Rahuldav profiili kvaliteet", @@ -364,6 +373,7 @@ "poor": "⚠ Kehv profiili kvaliteet" }, "lbl": { + "drag_to_resize": "Lohistage suuruse muutmiseks", "pg_anti_wrinkle": "Kortsudevastane", "actions": "Tegevused", "activity": "Tegevus", @@ -435,7 +445,7 @@ "from": "Alates", "gap_s": "Vahe(d)", "group_name": "Rühma nimi", - "head_trim": "Pea trimm (id)", + "head_trim": "Kärbe algusest (s)", "health": "Tervis", "hide_tabs": "Peida vahekaardid mitteadministraatorite jaoks", "in_use": "Kasutuses", @@ -451,7 +461,7 @@ "metric": "Mõõdik", "mode_existing_profile": "Lisa olemasolevale profiilile", "mode_new_profile": "Loo uus profiil", - "models_fine_tuned": "({count} mudelit peenhäälestatud)", + "models_fine_tuned": "(peenhäälestatud mudelid: {count})", "n_classic_suggestions": "{n} klassikaline", "n_ml_suggestions": "{n} ML", "n_selected": "{n} valitud", @@ -532,7 +542,7 @@ "stage3": "Etapp 3 – DTW", "stage4": "Etapp 4 – kokkulangevus", "status": "Olek", - "tail_trim": "Saba trimm (id)", + "tail_trim": "Kärbe lõpust (s)", "timer_auto_pause": "Automaatne paus", "timer_min": "min", "timer_msg_placeholder": "Sõnum (valikuline, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_import_copy": "Impordi koopiana", "conflict_keep_mine": "Säilita minu omad", "conflict_overwrite": "Kirjuta üle", - "font_size": "Paneeli fondi suurus" + "font_size": "Paneeli fondi suurus", + "hist_csv_data": "CSV-andmed", + "hist_from_recorder": "Või loe see Home Assistantist", + "hist_since": "Alates", + "days": "päeva", + "hist_keep": "Säilita see tsükkel", + "hist_looks_complete": "lõpetatud", + "peak_power_short": "Tipp", + "shape": "Kuju", + "hist_skip_idle": "miski ei töötanud", + "hist_skip_sparse": "näidud liiga hõredad", + "hist_skip_short": "liiga vähe näite", + "hist_skip_long": "pole piisavalt pikka pausi, kust jagada", + "hist_reason_short": "lühem kui selle seadme lühim tegelik tsükkel", + "hist_reason_no_end": "ei lõppenud korralikult", + "task_history_import": "Võimsuse ajaloo skannimine", + "task_history_import_apply": "Tsüklite importimine", + "evidence_real_cycles": "Selle seadme tehtud tsüklid", + "evidence_reference_cycles": "Kogukonna poest alla laaditud", + "evidence_backfill_cycles": "Leitud imporditud võimsuse ajaloos" }, "log": { "all_levels": "Kõik tasemed", @@ -756,9 +785,15 @@ "store_share": "Jaga kogukonna poodi", "store_share_device": "Jaga seda seadet", "export_select": "Eksport - vali andmed", - "import_wizard": "Import - vali andmed" + "import_wizard": "Import - vali andmed", + "history_import": "Võimsuse ajaloo import" }, "msg": { + "tail_trim_hint": "Mitu sekundit lõpust eemaldada", + "store_sibling_hint": "Sinu täpse mudeli jaoks pole midagi jagatud? Sama brändi lähedane mudel on tavaliselt hea lähtepunkt.", + "store_declare_appliance": "Ütle WashDatale, milline seade sul on, ja see vahekaart näitab teiste jagatud programme ja salvestisi selle seadme jaoks. Võid ka ülal brändi kirjutada ja lihtsalt ringi vaadata.", + "refresh_catalog_hint": "Kogukonna brändide ja seadmete loendid on vahemälus, et jagatud pood püsiks oma päevase päringulimiidi piires. Värskenda, et näha teiste lisatud või heaks kiidetud kirjeid.", + "head_trim_hint": "Mitu sekundit algusest eemaldada", "appliance_monitor": "Seadmete monitor", "artifact_dip_detail": "Langes alla tavapärase võimsusriba umbes {n}s jooksul.", "artifact_footer": "Ülaltoodud graafikul esile tõstetud. Need on mööduvad artefaktid (nt uks avati tsükli keskel), mitte tingimata probleemid.", @@ -769,7 +804,7 @@ "automations_intro": "WashData käivitab {start} / {end} sündmusi ja avaldab olemeid, seega on teated ja toimingud kõige paremini ehitada tavaliste Home Assistanti automaatikana. Seda seadet kasutavad automaatikad kuvatakse allpool.", "cleanup_intro": "Iga märgistatud tsükkel on kaetud. Profiili puhastamiseks märkige kõrvalekalded ja kustutage.", "clear_debug_hint": "Ruumi vabastamiseks eemaldage salvestatud silumisandmed.", - "collecting_data": "Andmete kogumine - {need} tsüklit veel enne peenhäälestuse alustamist ({current}/{min}).", + "collecting_data": "Andmete kogumine. Tsükleid on enne peenhäälestuse algust veel vaja: {need} ({current}/{min}).", "compare_overlay_profiles": "Ülekatteprofiilid (nõrkjad)", "compare_profiles_tip": "Katke ülaltoodud diagrammil teised profiiliümbrikud, et näha, milline neist selle tsükliga kõige paremini sobib.", "compare_selected_cycles": "Valitud tsüklid (tahked) – näita/peida", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} tsüklit kustutatud", "enough_data": "Piisavalt andmeid õppimiseks ({current}/{min} tsüklit).", "export_description": "Vali täpselt, millised profiilid, tsüklid, seaded ja muu JSON-i eksportida, või analüüsi fail ja impordi ainult soovitud osad.", - "feedback_cycles_pending": "{n} tsükkel{s} ülevaatamiseks", + "feedback_cycles_pending": "Ülevaatamiseks: {n}", "feedback_prompt": "Kinnitage, et see oli õige, parandage programmi või ignoreerige.", "feedback_relabel_hint": "Selle tsükli uuesti sildistamine lahendab ka selle.", "filter_by_profile": "Filtreeri profiili järgi…", @@ -804,7 +839,7 @@ "ml_intro": "WashData tarnitakse nutikate mudelitega, mis töötavad karbist välja.", "ml_learned_intro": "Selle masina jaoks peenhäälestatud mudelid.", "ml_loading": "ML laadimine…", - "ml_settings_intro": "Kaks sõltumatut lülitit: üks rakendab mudeleid tsükli töötamise ajal, teine ​​võimaldab WashDatal neid aja jooksul teie masinale täpselt häälestada.", + "ml_settings_intro": "Kaks sõltumatut lülitit: üks rakendab mudeleid tsükli töötamise ajal, teine võimaldab WashDatal neid aja jooksul teie masinale täpselt häälestada.", "name_first_program": "Sul on piisavalt tsükleid – pane oma esimesele programmile nimi, et alustada sobitamist.", "near_duplicate_cluster": "Tuvastati peaaegu duplikaatprofiili klaster. Rühmitamine võimaldab sobitamisel usaldusväärselt valida sarnasuste vahel (nt sama programm erineval temperatuuril/tsentrifuugimisel).", "no_cycles_match": "Ükski tsükkel ei vasta praegusele filtrile.", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "tühikäigu simulatsioon algab siit", "pg_sweep_intro": "Mis siis, kui {param} oleks teistsugune? Testi {steps} väärtust oma viimase {cycles} tsükli ulatuses, et leida seade, mille juures kõige rohkem tsükleid sobitatakse õigesti.", "pg_sweep_step": "Samm {done} / {total}", - "pg_undetected": "{n} tsüklit tuvastamata", "pg_verdict_bad": "Vajab tähelepanu: paljud tsüklid jäävad tuvastamata.", "pg_verdict_good": "Hästi häälestatud: enamik tsükleid tuvastatakse ja sobitatakse õigesti.", "pg_verdict_ok": "Vastuvõetav: mõned tsüklid jäid vahele. Proovi käivitusläve alandada.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Märkige see oma programmi jaoks käsitsi valitud võrdlustsükliks – sama roll kui käsitsi salvestatud tsüklil. Võrdlustsüklid jäetakse alati alles, külvatakse sobiv mall ja neid ei jäeta kunagi puhastamise käigus maha. (See on \"kuldne\" / salvestatud lipp; mõlemad on samad.)", "review_tags_tip": "Valikulised lipud, mis kirjeldavad, mis selle tsükliga valesti läks, nii et koolitus ja puhastamine võivad seda arvesse võtta.", "review_to_cycles": "Avage tsüklite ülevaatuse järjekord", + "samples_decimated": "Kuvatakse {shown}/{total} näidet (kuvamiseks hõrendatud; tipud säilitatud). Lai vahe siin on hõrendamine, mitte puuduvad andmed.", "saving_triggers_reload": "Salvestamine käivitab integratsiooni uuesti laadimise. HA olemid võivad hetkeks kuvada kui kättesaamatud.", "search_placeholder": "Otsinguseaded…", "see_recorder": "Vaadake salvesti vidinat allpool", @@ -1006,10 +1041,33 @@ "sug_mute_failed": "Soovituse vaigistamine ebaõnnestus", "sug_unmuted_all": "Vaigistatud soovitused lähtestatud", "n_suggestions_muted": "{count} vaigistatud; automaatne häälestaja ei paku neid enam.", - "font_size_hint": "Muuda kõik selles paneelil suuremaks või väiksemaks. Kehtib sinu kontole sellel seadmel." + "font_size_hint": "Muuda kõik selles paneelil suuremaks või väiksemaks. Kehtib sinu kontole sellel seadmel.", + "import_history_description": "Kas nutipistik oli sul olemas juba enne WashDatat? Laadi üles selle võimsusanduri ajaloo eksport või loe see otse Home Assistantist, ja tavaline tuvastus töötab need andmed läbi, nii et möödunud tsüklid ilmuvad sinu Tsüklite loendisse ja neile saab nime anda.", + "hist_input_hint": "Laadi üles Ajaloo paneelilt alla laaditud CSV (olem, olek, viimane muutus) või lase WashDatal anduri ajalugu otse lugeda. Seejärel töötab tuvastus need andmed läbi täpselt nii nagu reaalajas ja sina valid, millised leitud tsüklid alles jäävad.", + "hist_recorder_hint": "Loeb valitud kuupäevast kuni praeguse hetkeni. Home Assistant hoiab üksikasjalikku ajalugu vaikimisi 10 päeva ja pärast seda ainult tunniseid keskmisi, mis on tsüklite tuvastamiseks liiga jämedad - vali varasem kuupäev ainult siis, kui sinu recorder on seatud rohkem säilitama.", + "hist_scanning": "Sinu ajalugu jooksutatakse detektorist läbi. See toimub taustal - võid selle akna sulgeda ja hiljem tagasi tulla.", + "hist_imported_count": "Imporditud tsükleid: {n}.", + "hist_duplicates": "Juba varem imporditud ja vahele jäetud: {n}.", + "hist_capped": "Seadme imporditud tsüklite piirmäär sai täis; ülejäänuid ei salvestatud.", + "hist_next_step": "Need on sinu Tsüklite loendis, märgitud imporditud ajaloona. Ava üks ja kasuta nuppu Sildista, et määrata selle programm.", + "hist_rows_read": "Loetud näite: {n}", + "hist_entity_substituted": "loeti {used} (see seade on seadistatud kasutama {wanted})", + "hist_breaks": "Katkestusi, kus andur ei olnud saadaval: {n}", + "hist_other_entity": "Eiratud muude olemite näite: {n}", + "hist_skipped_spans": "Vahele jäetud lõigud", + "hist_settings_used": "Tuvastatud selle seadme praeguste seadetega (Minimaalne Võimsus {w} W, Väljalülitumise Viivitus {s} s).", + "hist_none_found": "Sellest ajaloost ei õnnestunud ühtegi tsüklit tuvastada.", + "hist_found": "Leitud tsükleid: {n}. Eemalda linnuke kõigelt, mis ei tundu tegelik käitus - enne importimist ei salvestata midagi.", + "hist_scan_capped": "Kuvatakse ainult esimesed kandidaadid (leiti: {n}).", + "hist_recorder_empty": "Home Assistantil pole selle anduri kohta sellest ajavahemikust üksikasjalikku ajalugu.", + "hist_scan_failed": "Skannimine ebaõnnestus.", + "hist_scan_expired": "See skannimistulemus pole enam saadaval. Palun skanni uuesti.", + "hist_import_failed": "Import ebaõnnestus.", + "imported_history_readonly": "Tuvastatud imporditud võimsuse ajaloos. See mõjutab programmide sobitamist, kuid ei arvestata sinu statistikas ning seda ei saa kärpida ega poolitada. Kasuta nuppu Sildista, et määrata programm." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Nõudepesumasin: vaiksed sekundid pärast eeldatavat kestust, enne kui tsükli lõpu tühjenduse ootamine vabastatakse", + "smart_termination_duration_ratio": "Osa vastendatud programmi eeldatavast kestusest, milleni tsükkel peab jõudma, enne kui nutikas lõpetamine tohib selle ennetähtaegselt lõpetada; vähendage seda koormusest või temperatuurist sõltuvatel masinatel", "anti_wrinkle_enabled": "Neelab pärast põhifaasi trummelimpulsid, selle asemel et lugeda neid uute tsüklitena", "anti_wrinkle_exit_power": "Kortsudevastase režiimi aktiivsena püsimiseks peab võimsus impulsside vahel sellest allapoole langema", "anti_wrinkle_idle_timeout": "Kahe trummelimpulsi vahel lubatud vaikuseaeg enne kortsudevastase režiimi lõppu", @@ -1134,7 +1192,7 @@ "doc": "Kui tsükkel ületab oma eeldatava kestuse, siis kui kaua peab nõudepesumasin püsima vaikne (allpool Peatumise Lävi), enne kui WashData lõpetab lõpliku tühjenduse ootamise ja lõpetab tsükli. Suurendage seda, kui teie masinal on pikk vaikne kuivatusfaas enne hilist lõplikku tühjendust, mis jääb märkamata - laiem aken laseb õpitud kestusel järgida hooajalist triivi (külmem sissevoolav vesi = pikemad tsüklid), selle asemel et lukustuda vana keskmise külge. See saab ooteaega üksnes lühendada võrreldes sisemise 30-minutilise lõpupiigi ülempiiriga, mitte kunagi seda pikendada." }, "anti_wrinkle_enabled": { - "doc": "Tuvastage lühikesed väikese võimsusega trummelimpulsid, mida kuivati ​​pärast peamist kuumutusfaasi kiirgab, ja hoidke need valmis tsükliga ühendatud, selle asemel, et lugeda neid uute tsüklitena.", + "doc": "Tuvastage lühikesed väikese võimsusega trummelimpulsid, mida kuivati pärast peamist kuumutusfaasi kiirgab, ja hoidke need valmis tsükliga ühendatud, selle asemel, et lugeda neid uute tsüklitena.", "label": "Luba Kortsudevastane Tuvastamine" }, "anti_wrinkle_exit_power": { @@ -1421,6 +1479,10 @@ "doc": "Salvestage iga tsükli täielik võimsusjälg ja vastavad silumisandmed. Kasulik tõrkeotsinguks, kuid suurendab salvestusruumi mahtu.", "label": "Salvesta Silumise Jäljed" }, + "smart_termination_duration_ratio": { + "doc": "Kui kaugele vastendatud programmi eeldatavast kestusest peab tsükkel jõudma, enne kui nutikas lõpetamine tohib selle võimsuse langedes ennetähtaegselt lõpetada. Eeldatav kestus on programmi keskmine, seega seadmetel, mille tööaeg kõigub palju - pesumasinad külma talvise vs sooja suvise sissevoolava veega, andurkuivatusega kuivatid, koormusest sõltuvad programmid - lõpeb umbes pool kõigist tsüklitest sellest keskmisest lühemalt ega saa kunagi kiiret lõpetamist, lõppedes alles tagavara-aegumise kaudu minuteid hiljem. Vähendage seda (nt 0,85) neil masinatel, et varajane lõpetamine ikkagi käivituks; tõstke seda 1,0 poole, et olla ettevaatlikum. Jätke tühjaks vaikeväärtuse jaoks (0,98 või nõudepesumasinatel 0,99). See saab tsükli üksnes varem lõpetada, mitte kunagi hiljem, ega käivitu kunagi ebaselge või madala kindlusega vaste korral.", + "label": "Nutika lõpetamise suhe" + }, "smoothing_window": { "doc": "Kui palju toorvõimsussignaali tasandatakse. Madal (2) on tundlik, kuid lärmakas; kõrge (5) silub naelu, kuid lisab mahajäämust.", "label": "Silumisaken" @@ -1551,6 +1613,10 @@ "door_end_dwell_seconds": { "label": "Ukse avamise lõppviibimisaeg", "doc": "Kui kaua uks peab jääma avatuks, enne kui WashData tsükli lõpetab, kui \"Uks avaneb automaatselt lõpus\" on sisse lülitatud. Piisavalt pikk, et kiire taldrikute lisamine ignoreeritaks (vaikimisi 60 s), piisavalt lühike, et lõpetada kiiresti, kui masin ukse avab." + }, + "profile_evidence_sources": { + "label": "Programmi kujundavad tsüklid", + "doc": "Milliseid tsükleid kasutatakse iga programmi võimsuskõvera koostamiseks ja lõpetatud tsükli sellega sobitamiseks. Kui mõne liigi märke eemaldad, ei kujunda see enam sinu programme, kuid midagi ei kustutata - tsüklid jäävad Tsüklite loendisse ning neid saab endiselt sildistada või eemaldada. Kasulik, kui sa imporditud andmeid ei usalda. Statistikat see ei mõjuta: seal arvestatakse alati ainult neid tsükleid, mida see seade tegelikult käivitas. Kõigi märkide eemaldamist eiratakse, sest ilma ühegi tsüklita programm ei saaks kunagi sobituda." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "Põhiseaded" + }, + "profile_evidence": { + "label": "Profiili alusandmed" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "Kärpimine…", "splitting": "Lõhestamine…", "deleting": "Kustutamine…", - "imported": "Imporditud" + "imported": "Imporditud", + "preparing": "Ettevalmistamine…" }, "tab": { "advanced": "Täpsemalt", @@ -1688,6 +1758,7 @@ "wrong_profile": "Vale profiil" }, "toast": { + "catalog_refreshed": "Kogukonna kataloog on värskendatud", "access_saved": "Juurdepääsukontroll on salvestatud", "all_wiped": "Kõik andmed on kustutatud", "analysis_complete_none": "Analüüs on lõpetatud: uusi ettepanekuid pole", @@ -1793,7 +1864,9 @@ "store_download_failed": "Allalaadimine ebaõnnestus: {error}", "store_download_nothing": "Midagi uut alla laadida pole - see seadistus on juba teie seadmel.", "export_selective_done": "Eksport alla laaditud", - "import_selective_done": "Imporditud {profiles} profiili ja {cycles} tsüklit" + "import_selective_done": "Imporditud {profiles} profiili ja {cycles} tsüklit", + "hist_csv_required": "Laadi esmalt CSV-fail või kleebi selle sisu", + "file_read_failed": "Seda faili ei õnnestunud lugeda" }, "suggestion": { "both_agree": "WashData soovitab", @@ -1889,7 +1962,7 @@ "thr_batch": "Hoitakse veidi üle madalaima aktiivvõimsuse p05 juures {cycles} tsükli lõikes ({p05}W), et käivitus tuvastataks võimalikult vara ja seiskamislävi jääks alla masina madalaima töövõimsuse.", "tol_per_profile": "p75 profiilipõhisest kestuse dispersioonist {profiles} profiili lõikes ({cycles} tsüklit); kitsaid profiile ei karistata.", "tol_pooled": "Põhineb {cycles} viimase märgistatud tsükli koondkestuse dispersioonil (p95 hälve={dev}).", - "watchdog": "Hoitakse nii madal kui ohutu (veidi üle p95 uuendusintervalli {p95}s, min 30s), et seiskumised tuvastataks kiiresti ilma valeseisakuteta." + "watchdog": "Hoitakse nii madal kui ohutu (veidi üle p95 uuendusintervalli {p95}s ja vähemalt 2x proovivõtu intervall {median}s, min 30s), et seiskumised tuvastataks kiiresti ilma valeseisakuteta." }, "exclusions": { "summary": "Välja jäetud {total} valesti tuvastatud tsüklit: {parts}.", @@ -1955,6 +2028,10 @@ "finished": "Tsükkel jõudis lõppolekusse ja lõppes." }, "store": { + "your_model_tip": "See on seade, mille sa seadetes määrasid", + "your_model": "Sinu oma", + "search_brand_ph": "Otsi brändi järgi…", + "programs_count": "Programmid: {n}", "browse": "Sirvi", "device": "Seade", "favorites": "Lemmikud", diff --git a/custom_components/ha_washdata/translations/panel/fi.json b/custom_components/ha_washdata/translations/panel/fi.json index 3029539c..6f2c9aea 100644 --- a/custom_components/ha_washdata/translations/panel/fi.json +++ b/custom_components/ha_washdata/translations/panel/fi.json @@ -78,9 +78,12 @@ "awaiting": "Odottaa hyväksyntää", "imported_tip": "Tuotu yhteisökaupasta. Käytetään vain täsmäytykseen, ei lasketa tilastoihin.", "not_importable": "ei käytettävissä", - "exists": "on jo" + "exists": "on jo", + "backfilled_tip": "Tunnistettu tuodusta tehohistoriasta. Vaikuttaa vain ohjelmien täsmäytykseen, ei lasketa tilastoihin." }, "btn": { + "set_brand_model": "Aseta merkki ja malli", + "refresh_catalog": "Päivitä luettelo", "add_device": "+ Lisää laite", "add_device_tip": "Lisää toinen WashData-laite", "add_maintenance": "Lisää huoltotapahtuma", @@ -241,7 +244,12 @@ "import_selected": "Tuo valitut", "back": "Takaisin", "mute_suggestion": "Lopeta tämän asetuksen ehdottaminen", - "reset_muted": "Palauta vaiennetut" + "reset_muted": "Palauta vaiennetut", + "import_power_history": "Tuo tehohistoria", + "hist_read_recorder": "Lue Home Assistantista", + "hist_scan": "Etsi syklejä", + "hist_import_n": "Tuo {n} sykliä", + "hist_goto_cycles": "Näytä syklit" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Täytyy olla alle ryppyjeneston enimmäistehon ({max} W)" }, "attn_sub": "Korjaa ristiriidat ennen tallentamista", - "attn_title": "{n} asetusten ristiriita{s}", + "attn_title": "Asetusten ristiriitoja: {n}", "confidence": { "auto": "Täytyy olla vähintään täsmäytyskynnys ({match})", - "learning": "Täytyy olla enintään täsmäytyskynnys ({match})", + "learning": "Täytyy olla vähintään täsmäytyskynnys ({match})", "match_for_auto": "Täytyy olla enintään automaattimerkinnän varmuus ({alc})", - "match_for_learning": "Täytyy olla vähintään oppimisen varmuus ({lc})" + "match_for_learning": "Täytyy olla enintään oppimisen varmuus ({lc})" }, "duration_ratio": { "max": "Täytyy olla yli vähimmäiskestosuhteen ({min})", @@ -296,14 +304,14 @@ "match": "Täytyy olla yli eritäsmäytyskynnyksen ({un})", "unmatch": "Täytyy olla alle täsmäytyskynnyksen ({match}); muuten vahvistettu vastaavuus purkautuu heti" }, - "cascade_toast": "Myös {n} asetusta muutettiin johdonmukaisuuden vuoksi.", + "cascade_toast": "Muita asetuksia muutettiin johdonmukaisuuden vuoksi: {n}", "suggestion_resolves": "Ota alla oleva ehdotus ({val}) käyttöön tämän korjaamiseksi", "use_fix": "Käytä {val}", "watchdog": { "interval": "Tulisi olla vähintään 2x näytteenottoväli ({si} s)", "sampling": "Näytteenottovälin tulee olla enintään puolet tarkkailijavälistä ({wi} s)" }, - "settings_banner": "{n} asetusten ristiriita{s} – tarkista korostetut osiot ja korjaa ne ennen tallentamista.", + "settings_banner": "Asetusten ristiriitoja: {n}. Tarkista korostetut osiot ja korjaa ne ennen tallentamista.", "settings_banner_btn": "Siirry ensimmäiseen" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Simulaation tulos", "pg_across_cycles": "Kaikissa jaksoissasi", "community_store": "Yhteisökauppa", - "online_account": "Yhteisökauppa ja verkkotoiminnot" + "online_account": "Yhteisökauppa ja verkkotoiminnot", + "import_power_history": "Tuo tehohistoria" }, "health": { "fair": "Tyydyttävä profiililaatu", @@ -364,6 +373,7 @@ "poor": "⚠ Heikko profiililaatu" }, "lbl": { + "drag_to_resize": "Vedä muuttaaksesi kokoa", "pg_anti_wrinkle": "Ryppyjenesto", "actions": "Toiminnot", "activity": "Toiminta", @@ -435,7 +445,7 @@ "from": "Mistä", "gap_s": "aukko (t)", "group_name": "Ryhmän nimi", - "head_trim": "Pään leikkaus (s)", + "head_trim": "Leikkaus alusta (s)", "health": "Terveys", "hide_tabs": "Piilota välilehdet muille kuin järjestelmänvalvojille", "in_use": "Käytössä", @@ -451,7 +461,7 @@ "metric": "Mittari", "mode_existing_profile": "Lisää olemassa olevaan profiiliin", "mode_new_profile": "Luo uusi profiili", - "models_fine_tuned": "({count} mallia hienosäädetty)", + "models_fine_tuned": "(hienosäädetyt mallit: {count})", "n_classic_suggestions": "{n} klassinen", "n_ml_suggestions": "{n} ML", "n_selected": "{n} valittu", @@ -532,7 +542,7 @@ "stage3": "Vaihe 3 – DTW", "stage4": "Vaihe 4 – vastaavuus", "status": "Tila", - "tail_trim": "Hännän leikkaus (s)", + "tail_trim": "Leikkaus lopusta (s)", "timer_auto_pause": "Automaattinen tauko", "timer_min": "min", "timer_msg_placeholder": "Viesti (valinnainen, {device}/{program}/{minutes})", @@ -650,7 +660,7 @@ "show_contributor": "Näytä osallistujien nimet", "task_pg_detail": "Simuloidaan sykliä", "task_split": "Jaetaan sykliä", - "task_trim": "Rajataan sykliä", + "task_trim": "Leikataan sykliä", "task_merge": "Yhdistetään syklejä", "task_rebuild": "Rakennetaan tehoalueita uudelleen", "cat_profiles": "Profiilit (ohjelmat)", @@ -677,7 +687,26 @@ "conflict_import_copy": "Tuo kopiona", "conflict_keep_mine": "Säilytä omat", "conflict_overwrite": "Korvaa", - "font_size": "Paneelin fonttikoko" + "font_size": "Paneelin fonttikoko", + "hist_csv_data": "CSV-data", + "hist_from_recorder": "Tai lue se Home Assistantista", + "hist_since": "Alkaen", + "days": "päivää", + "hist_keep": "Säilytä tämä sykli", + "hist_looks_complete": "kokonainen", + "peak_power_short": "Huippu", + "shape": "Muoto", + "hist_skip_idle": "ei toimintaa", + "hist_skip_sparse": "lukemat liian kaukana toisistaan", + "hist_skip_short": "liian vähän lukemia", + "hist_skip_long": "ei tarpeeksi pitkää katkoa jakamiseen", + "hist_reason_short": "lyhyempi kuin tämän laitteen lyhyin todellinen sykli", + "hist_reason_no_end": "ei päättynyt siististi", + "task_history_import": "Tehohistorian läpikäynti", + "task_history_import_apply": "Syklien tuonti", + "evidence_real_cycles": "Tämän koneen ajamat syklit", + "evidence_reference_cycles": "Ladattu yhteisökaupasta", + "evidence_backfill_cycles": "Löydetty tuodusta tehohistoriasta" }, "log": { "all_levels": "Kaikki tasot", @@ -756,9 +785,15 @@ "store_share": "Jaa yhteisökauppaan", "store_share_device": "Jaa tämä laite", "export_select": "Vienti - valitse tiedot", - "import_wizard": "Tuonti - valitse tiedot" + "import_wizard": "Tuonti - valitse tiedot", + "history_import": "Tuo tehohistoria" }, "msg": { + "tail_trim_hint": "Kuinka monta sekuntia poistetaan lopusta", + "store_sibling_hint": "Eikö juuri sinun mallillesi ole jaettu mitään? Saman merkin läheinen malli on yleensä hyvä lähtökohta.", + "store_declare_appliance": "Kerro WashDatalle, mikä laite sinulla on, niin tämä välilehti näyttää muiden sille jakamat ohjelmat ja tallennukset. Voit myös kirjoittaa merkin nimen yläkenttään ja katsella ympärillesi.", + "refresh_catalog_hint": "Yhteisön merkki- ja laiteluettelot on tallennettu välimuistiin, jotta jaettu kauppa pysyy päivittäisen kyselykuotansa rajoissa. Päivitä nähdäksesi muiden lisäämät tai hyväksymät merkinnät.", + "head_trim_hint": "Kuinka monta sekuntia poistetaan alusta", "appliance_monitor": "Laitteen näyttö", "artifact_dip_detail": "Alitti tavallisen tehon alarajan noin {n} sekunnin ajan.", "artifact_footer": "Korostettu yllä olevassa kaaviossa. Nämä ovat ohimeneviä esineitä (esim. ovi avautui kesken syklin), eivät välttämättä ongelmia.", @@ -769,7 +804,7 @@ "automations_intro": "WashData laukaisee {start} / {end}-tapahtumat ja tarjoaa entiteetit, joten ilmoitukset ja toiminnot kannattaa rakentaa tavallisina Home Assistant -automaatioina. Tätä laitetta käyttävät automaatiot näkyvät alla.", "cleanup_intro": "Jokainen merkitty sykli peitetään. Tyhjennä profiili valitsemalla poikkeamat ja poistamalla ne.", "clear_debug_hint": "Poista tallennetut virheenkorjaustiedot vapauttaaksesi tilaa.", - "collecting_data": "Kerätään tietoja - vielä {need} sykliä tarvitaan ennen hienosäädön käynnistymistä ({current}/{min}).", + "collecting_data": "Kerätään tietoja. Syklejä tarvitaan vielä ennen hienosäädön aloittamista: {need} ({current}/{min}).", "compare_overlay_profiles": "Päällekkäiset profiilit (himmeä)", "compare_profiles_tip": "Aseta muut profiilikuoret yllä olevaan kaavioon nähdäksesi, mikä sopii parhaiten tähän jaksoon.", "compare_selected_cycles": "Valitut jaksot (kiinteä) – näytä/piilota", @@ -779,12 +814,12 @@ "cycles_deleted": "{count} sykli(ä) poistettu", "enough_data": "Tarpeeksi tietoja oppimiseen ({current}/{min} sykliä).", "export_description": "Valitse tarkalleen, mitkä profiilit, jaksot, asetukset ja muut viedään JSON-tiedostoon, tai analysoi tiedosto ja tuo vain haluamasi osat.", - "feedback_cycles_pending": "{n} sykliä tarkistettavana", + "feedback_cycles_pending": "Tarkistettavana: {n}", "feedback_prompt": "Vahvista, että se oli oikein, korjaa ohjelma tai jätä huomiotta.", "feedback_relabel_hint": "Tämän syklin uudelleenmerkintä ratkaisee sen myös.", "filter_by_profile": "Suodata profiilin mukaan…", - "group_modal_help": "Ryhmäohjelmat, joilla on sama muoto ja jotka eroavat lämpötilassa/linkouksessa (kesto voi vaihdella). Matching antaa ryhmän yhdeksi ehdokkaaksi ja valitsee sitten parhaiten sopivan jäsenen. Valitse vähintään 2; peittokuva osoittaa, kuinka samanlaisia ​​ne ovat.", - "group_not_cohesive": "Nämä profiilit eivät ole riittävän samankaltaisia ​​ryhmittelemään niitä luotettavasti, joten vastaavuudessa käsitellään niitä yksitellen, kunnes poistat poikkeaman tai jaat ryhmän.", + "group_modal_help": "Ryhmäohjelmat, joilla on sama muoto ja jotka eroavat lämpötilassa/linkouksessa (kesto voi vaihdella). Matching antaa ryhmän yhdeksi ehdokkaaksi ja valitsee sitten parhaiten sopivan jäsenen. Valitse vähintään 2; peittokuva osoittaa, kuinka samanlaisia ne ovat.", + "group_not_cohesive": "Nämä profiilit eivät ole riittävän samankaltaisia ryhmittelemään niitä luotettavasti, joten vastaavuudessa käsitellään niitä yksitellen, kunnes poistat poikkeaman tai jaat ryhmän.", "group_preview_hint": "Valitse vähintään 2 jäsentä esikatsellaksesi ja vertaillaksesi tehokäyriä.", "import_intro": "Lataa viety tiedosto tai liitä JSON-hyötykuorma alle.", "legacy_actions_title": "{count} vanha muokattu toiminto on edelleen käynnissä", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "valmiustilan simulaatio alkaa tästä", "pg_sweep_intro": "Entä jos {param} olisi erilainen? Testaa {steps} arvoa viimeisten {cycles} syklin perusteella löytääksesi asetuksen, jolla useimmat syklit täsmätään oikein.", "pg_sweep_step": "Vaihe {done} / {total}", - "pg_undetected": "{n} sykliä tunnistamatta", "pg_verdict_bad": "Vaatii huomiota: monet syklit jäävät tunnistamatta.", "pg_verdict_good": "Hyvin viritetty: useimmat syklit tunnistetaan ja täsmätään oikein.", "pg_verdict_ok": "Hyväksyttävä: osa sykleistä jäi huomaamatta. Kokeile laskea aloituskynnystä.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Merkitse tämä käsin valituksi referenssijaksoksi ohjelmaansa varten - sama rooli kuin manuaalisesti tallennetulla jaksolla. Vertailusyklit säilytetään aina, siemennä vastaava malli, eikä niitä koskaan pudoteta puhdistuksen yhteydessä. (Tämä on \"kultainen\" / tallennettu lippu; molemmat ovat sama asia.)", "review_tags_tip": "Valinnaiset liput, jotka kuvaavat, mikä meni pieleen tässä syklissä, joten koulutus ja siivous voivat selittää sen.", "review_to_cycles": "Avaa Cycles tarkistusjono", + "samples_decimated": "Näytetään {shown}/{total} näytettä (harvennettu näyttöä varten; huiput säilytetty). Leveä väli tässä johtuu harvennuksesta, ei puuttuvasta datasta.", "saving_triggers_reload": "Tallentaminen käynnistää integroinnin uudelleenlatauksen. HA-entiteetit voivat hetken näkyä poissa käytöstä.", "search_placeholder": "Hakuasetukset…", "see_recorder": "Katso tallennin-widget alta", @@ -924,7 +959,7 @@ "trend_energy_down": "Energia laskee ({pct} %/sykli)", "trend_energy_up": "Energiatrendi nousussa ({pct} %/sykli) – viimeaikainen keskiarvo {avg}", "trim_destructive_confirm": "Tämä säilyttää vain {pct}% syklistä. Tätä ei voi kumota. Jatketaanko?", - "trim_intro": "Vedä punaisia ​​kahvoja tai syötä arvot. Kaikki ikkunan ulkopuolella poistetaan.", + "trim_intro": "Vedä punaisia kahvoja tai syötä arvot. Kaikki ikkunan ulkopuolella oleva poistetaan.", "tuning_suggestions_available": "{count} viritysehdotus saatavilla havaituista sykleistä. Ne näkyvät asianmukaisten kenttien vieressä.", "unsure_detected_prefix": "WashData ei ole varma, onko se havaittu", "updated": "Päivitetty", @@ -1006,10 +1041,33 @@ "sug_mute_failed": "Ehdotuksen vaimennus epäonnistui", "sug_unmuted_all": "Vaiennetut ehdotukset palautettu", "n_suggestions_muted": "{count} vaimennettu; automaattinen virittäjä ei ehdota näitä.", - "font_size_hint": "Suurenna tai pienennä kaikkea tässä paneelissa. Koskee tiliäsi tällä laitteella." + "font_size_hint": "Suurenna tai pienennä kaikkea tässä paneelissa. Koskee tiliäsi tällä laitteella.", + "import_history_description": "Oliko sinulla älypistoke jo ennen WashDataa? Lataa sen tehoanturin historiavienti tai lue historia suoraan Home Assistantista, niin tavallinen tunnistus ajetaan sen yli ja menneet syklit ilmestyvät Syklit-luetteloon valmiina nimettäviksi.", + "hist_input_hint": "Lataa Historia-paneelista ladattu CSV (entity, state, last changed) tai anna WashDatan lukea anturin historia suoraan. Tunnistus ajetaan sen yli täsmälleen kuten reaaliajassa, ja sinä valitset, mitkä löytyneistä sykleistä säilytetään.", + "hist_recorder_hint": "Lukee valitsemastasi päivästä nykyhetkeen. Home Assistant säilyttää tarkan historian oletuksena 10 päivää ja sen jälkeen vain tuntikeskiarvot, jotka ovat liian karkeita syklien tunnistamiseen - valitse kauempana menneisyydessä oleva päivä vain, jos recorder on asetettu säilyttämään enemmän.", + "hist_scanning": "Historiaasi ajetaan uudelleen tunnistimen läpi. Tämä tapahtuu taustalla - voit sulkea tämän ikkunan ja palata siihen myöhemmin.", + "hist_imported_count": "{n} sykliä tuotu.", + "hist_duplicates": "{n} oli tuotu jo aiemmin ja ohitettiin.", + "hist_capped": "Laitekohtainen tuotujen syklien raja täyttyi; loppuja ei tallennettu.", + "hist_next_step": "Ne ovat Syklit-luettelossasi merkittynä tuoduksi historiaksi. Avaa sykli ja nimeä sen ohjelma Merkitse-toiminnolla.", + "hist_rows_read": "{n} lukemaa luettu", + "hist_entity_substituted": "luettiin {used} (tälle laitteelle on määritetty {wanted})", + "hist_breaks": "{n} katkoa, joissa anturi ei ollut saatavilla", + "hist_other_entity": "{n} muiden entiteettien lukemaa ohitettiin", + "hist_skipped_spans": "Ohitetut ajanjaksot", + "hist_settings_used": "Tunnistettu tämän laitteen nykyisillä asetuksilla (minimiteho {w} W, sammutusviive {s} s).", + "hist_none_found": "Kyseisestä historiasta ei löytynyt yhtään sykliä.", + "hist_found": "Löytyi {n} sykliä. Poista valinta kaikesta, mikä ei näytä todelliselta ajolta - mitään ei tallenneta ennen tuontia.", + "hist_scan_capped": "Vain ensimmäiset ehdokkaat näytetään ({n} löytyi).", + "hist_recorder_empty": "Home Assistantilla ei ole tarkkaa historiaa tälle anturille kyseiseltä ajanjaksolta.", + "hist_scan_failed": "Läpikäynti epäonnistui.", + "hist_scan_expired": "Kyseinen läpikäynti ei ole enää saatavilla. Tee läpikäynti uudelleen.", + "hist_import_failed": "Tuonti epäonnistui.", + "imported_history_readonly": "Tunnistettu tuodusta tehohistoriasta. Se vaikuttaa ohjelmien täsmäytykseen, mutta ei näy tilastoissasi, eikä sitä voi rajata tai jakaa. Nimeä ohjelma Merkitse-toiminnolla." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Astianpesukone: hiljaiset sekunnit odotetun keston jälkeen ennen kuin syklin lopun vedenpoiston odotus vapautetaan", + "smart_termination_duration_ratio": "Osuus täsmätyn ohjelman odotetusta kestosta, joka syklin on saavutettava ennen kuin älykäs lopetus voi päättää sen ennenaikaisesti; laske sitä kuormasta tai lämpötilasta riippuvilla koneilla", "anti_wrinkle_enabled": "Sulauta päävaiheen jälkeiset rumpupulssit sen sijaan, että lukisit ne uusina sykleinä", "anti_wrinkle_exit_power": "Tehon on laskettava tämän alle pulssien välillä, jotta ryppyjenesto pysyy aktiivisena", "anti_wrinkle_idle_timeout": "Kahden rumpupulssin välillä sallittu hiljainen aika ennen kuin ryppyjenesto päättyy", @@ -1190,7 +1248,7 @@ "label": "Arviotoleranssi" }, "enable_ml_models": { - "doc": "Kun sykli on käynnissä, anna mallien tarkentaa reaaliaikaisia ​​tuloksia: vakaampi jäljellä oleva aika- ja energia-/kustannusarvio ja ennenaikaisen pysäytyksen eston varoitus päätteen havaitsemisessa (se voi vain viivästyttää maalia, ei koskaan lopettaa yhtä aikaisin ja on rajallinen). Käyttää hienosäädettyjä mallejasi, kun niitä on saatavilla, muuten sisäänrakennettuja. Off = vain klassinen tehopohjainen logiikka (vielä luotettava).", + "doc": "Kun sykli on käynnissä, anna mallien tarkentaa reaaliaikaisia tuloksia: vakaampi jäljellä oleva aika- ja energia-/kustannusarvio ja ennenaikaisen pysäytyksen eston varoitus päätteen havaitsemisessa (se voi vain viivästyttää maalia, ei koskaan lopettaa yhtä aikaisin ja on rajallinen). Käyttää hienosäädettyjä mallejasi, kun niitä on saatavilla, muuten sisäänrakennettuja. Off = vain klassinen tehopohjainen logiikka (vielä luotettava).", "label": "Käytä älykkäitä malleja syklin aikana" }, "end_energy_threshold": { @@ -1314,7 +1372,7 @@ "label": "Suoran yliajan % ennen hälytystä" }, "notify_live_services": { - "doc": "notify.* -palvelut vaativat reaaliaikaisia ​​edistymispäivityksiä syklin aikana. Jätä tyhjäksi, jos haluat poistaa live-edistymisilmoitukset käytöstä.", + "doc": "notify.* -palvelut vaativat reaaliaikaisia edistymispäivityksiä syklin aikana. Jätä tyhjäksi, jos haluat poistaa live-edistymisilmoitukset käytöstä.", "label": "Suoran edistymisen palvelut" }, "notify_only_when_home": { @@ -1421,6 +1479,10 @@ "doc": "Tallenna täyden tehon jäljitys ja vastaavat virheenkorjaustiedot jokaiselle jaksolle. Hyödyllinen vianetsinnässä, mutta lisää tallennustilaa.", "label": "Tallenna virheenkorjausjäljet" }, + "smart_termination_duration_ratio": { + "doc": "Kuinka pitkälle täsmätyn ohjelman odotettuun kestoon syklin on edettävä, ennen kuin älykäs lopetus voi päättää sen ennenaikaisesti tehon pudottua. Odotettu kesto on ohjelman keskiarvo, joten laitteilla, joiden ajoaika vaihtelee paljon - pesukoneet kylmällä talven tulovedellä vs. lämpimällä kesän tulovedellä, anturikuivaavat kuivausrummut, kuormasta riippuvat ohjelmat - noin puolet kaikista ajoista päättyy tätä keskiarvoa lyhyempinä eivätkä koskaan saa nopeaa lopetusta, vaan päättyvät vasta vara-aikakatkaisun kautta minuutteja myöhässä. Laske tätä (esim. 0,85) näissä koneissa, jotta ennenaikainen lopetus silti laukeaa; nosta sitä kohti arvoa 1,0 ollaksesi varovaisempi. Jätä tyhjäksi käyttääksesi oletusta (0,98, tai astianpesukoneilla 0,99). Se voi ainoastaan päättää syklin aiemmin, ei koskaan myöhemmin, eikä koskaan laukea epäselvässä tai matalan varmuuden täsmäyksessä.", + "label": "Älykkään lopetuksen suhde" + }, "smoothing_window": { "doc": "Kuinka paljon raakatehosignaali tasoittuu. Matala (2) on herkkä, mutta meluisa; korkea (5) tasoittaa piikkejä, mutta lisää viivettä.", "label": "Tasoitusikkuna" @@ -1551,6 +1613,10 @@ "door_end_dwell_seconds": { "label": "Oven avauksen loppuviive", "doc": "Kuinka kauan oven on pysyttävä auki ennen kuin WashData päättää syklin, kun \"Ovi avautuu automaattisesti lopussa\" on käytössä. Tarpeeksi kauan ohittamaan lautasen lisäämisen (oletus 60 s), tarpeeksi lyhyt päättämään nopeasti kun kone avaa oven." + }, + "profile_evidence_sources": { + "label": "Ohjelmaa muovaavat syklit", + "doc": "Mitä syklejä käytetään kunkin ohjelman tehokäyrän muodostamiseen ja valmistuneen syklin täsmäyttämiseen siihen. Valinnan poistaminen estää kyseisen tyypin vaikutuksen ohjelmiisi poistamatta mitään - syklit pysyvät Syklit-luettelossa, ja ne voi silti merkitä tai poistaa. Hyödyllinen, jos et luota tuotuun dataan. Tilastot eivät muutu: ne laskevat aina vain ne syklit, jotka tämä kone on todella ajanut. Kaikkien valintojen poistaminen ohitetaan, koska ohjelma ilman taustalla olevia syklejä ei voisi koskaan täsmätä." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "Perusmääritykset" + }, + "profile_evidence": { + "label": "Profiilin lähteet" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "Leikataan…", "splitting": "Jaetaan…", "deleting": "Poistetaan…", - "imported": "Tuotu" + "imported": "Tuotu", + "preparing": "Valmistellaan…" }, "suggestion": { "both_agree": "WashData suosittelee", @@ -1754,7 +1824,7 @@ "thr_batch": "Pidetään juuri pienimmän aktiivisen tehon yläpuolella p05:ssä {cycles} jakson joukossa ({p05}W), jotta käynnistys havaitaan mahdollisimman aikaisin ja pysäytyskynnys pysyy koneen pienimmän käyntitehon alapuolella.", "tol_per_profile": "p75 profiilikohtaisesta keston varianssista {profiles} profiilin joukossa ({cycles} jaksoa); tiiviitä profiileja ei rangaista.", "tol_pooled": "Perustuu {cycles} viimeisimmän merkityn jakson yhdistettyyn keston varianssiin (p95 poikkeama={dev}).", - "watchdog": "Pidetään niin matalana kuin on turvallista (hieman yli p95-päivitysvälin {p95}s, väh. 30s), jotta pysähdykset havaitaan nopeasti ilman vääriä pysäytyksiä." + "watchdog": "Pidetään niin matalana kuin on turvallista (hieman yli p95-päivitysvälin {p95}s ja vähintään 2x näytteenottoväli {median}s, väh. 30s), jotta pysähdykset havaitaan nopeasti ilman vääriä pysäytyksiä." }, "exclusions": { "summary": "Suljettu pois {total} väärin tunnistettua sykliä: {parts}.", @@ -1801,6 +1871,7 @@ "wrong_profile": "Väärä profiili" }, "toast": { + "catalog_refreshed": "Yhteisön luettelo päivitetty", "access_saved": "Kulunvalvonta tallennettu", "all_wiped": "Kaikki tiedot pyyhitty", "analysis_complete_none": "Analyysi valmis: ei uusia ehdotuksia", @@ -1812,7 +1883,7 @@ "cycle_labelled": "Sykli merkitty", "cycle_paused": "Jakso keskeytetty", "cycle_resumed": "Kierto jatkui", - "cycle_trimmed": "Pyörä trimmattu", + "cycle_trimmed": "Sykli leikattu", "cycles_merged": "Syklit yhdistettiin", "envelope_rebuilt": "Kirjekuori uusittu", "envelopes_rebuilt": "Kirjekuoret uusittu", @@ -1906,7 +1977,9 @@ "store_download_failed": "Lataus epäonnistui: {error}", "store_download_nothing": "Ei uutta ladattavaa - tämä asetus on jo laitteellasi.", "export_selective_done": "Vienti ladattu", - "import_selective_done": "Tuotu {profiles} profiilia ja {cycles} jaksoa" + "import_selective_done": "Tuotu {profiles} profiilia ja {cycles} jaksoa", + "hist_csv_required": "Lataa ensin CSV-tiedosto tai liitä sen sisältö", + "file_read_failed": "Tiedostoa ei voitu lukea" }, "trend": { "down": "Trendikäs alaspäin", @@ -1955,6 +2028,10 @@ "finished": "Jakso saavutti lopputilan ja päättyi." }, "store": { + "your_model_tip": "Tämä on laite, jonka määritit asetuksissa", + "your_model": "Omasi", + "search_brand_ph": "Hae merkin mukaan…", + "programs_count": "Ohjelmat: {n}", "browse": "Selaa", "device": "Laite", "favorites": "Suosikit", @@ -2010,7 +2087,7 @@ "apply": "Jaetaan sykliä" }, "trim": { - "apply": "Rajataan sykliä" + "apply": "Leikataan sykliä" }, "merge": { "apply": "Yhdistetään syklejä" diff --git a/custom_components/ha_washdata/translations/panel/fr.json b/custom_components/ha_washdata/translations/panel/fr.json index 781cdda7..0feb0f69 100644 --- a/custom_components/ha_washdata/translations/panel/fr.json +++ b/custom_components/ha_washdata/translations/panel/fr.json @@ -30,9 +30,12 @@ "awaiting": "En attente d'approbation", "imported_tip": "Importé depuis la boutique communautaire. Utilisé uniquement pour la correspondance, non compté dans les statistiques.", "not_importable": "n/a ici", - "exists": "existe déjà" + "exists": "existe déjà", + "backfilled_tip": "Détecté dans un historique de puissance importé. Influence uniquement la correspondance des programmes, non compté dans les statistiques." }, "btn": { + "set_brand_model": "Définir la marque et le modèle", + "refresh_catalog": "Rafraîchir le catalogue", "add_device": "+ Ajouter un appareil", "add_device_tip": "Ajouter un autre appareil WashData", "add_maintenance": "Ajouter un événement d'entretien", @@ -193,7 +196,12 @@ "import_selected": "Importer la sélection", "back": "Retour", "mute_suggestion": "Ne plus suggérer ce paramètre", - "reset_muted": "Réinitialiser les suggestions masquées" + "reset_muted": "Réinitialiser les suggestions masquées", + "import_power_history": "Importer l'historique de puissance", + "hist_read_recorder": "Lire depuis Home Assistant", + "hist_scan": "Rechercher des cycles", + "hist_import_n": "Importer {n} cycles", + "hist_goto_cycles": "Voir les cycles" }, "conflict": { "anti_wrinkle_exit": { @@ -205,14 +213,14 @@ "start": "Doit être inférieur à la Puissance max anti-froissage ({max} W)" }, "attn_sub": "Corriger les conflits avant d'enregistrer", - "attn_title": "{n} conflit{s} de paramètres", - "settings_banner": "{n} conflit{s} de paramètres – vérifiez les sections surlignées et corrigez avant d'enregistrer.", + "attn_title": "Conflits de paramètres : {n}", + "settings_banner": "Conflits de paramètres : {n}. Vérifiez les sections surlignées et corrigez-les avant d'enregistrer.", "settings_banner_btn": "Aller au premier", "confidence": { "auto": "Doit être supérieur ou égal au Seuil de correspondance ({match})", - "learning": "Doit être inférieur ou égal au Seuil de correspondance ({match})", + "learning": "Doit être supérieur ou égal au Seuil de correspondance ({match})", "match_for_auto": "Doit être inférieur ou égal à la Confiance d'étiquetage automatique ({alc})", - "match_for_learning": "Doit être supérieur ou égal à la Confiance d'apprentissage ({lc})" + "match_for_learning": "Doit être inférieur ou égal à la Confiance d'apprentissage ({lc})" }, "duration_ratio": { "max": "Doit être supérieur au Ratio de durée min ({min})", @@ -250,7 +258,7 @@ "match": "Doit être supérieur au Seuil de non-correspondance ({un})", "unmatch": "Doit être inférieur au Seuil de correspondance ({match}) ; sinon une correspondance confirmée est immédiatement annulée" }, - "cascade_toast": "Également {n} paramètre{s} mis à jour pour la cohérence.", + "cascade_toast": "Autres paramètres ajustés pour la cohérence : {n}", "suggestion_resolves": "Appliquez la suggestion en attente ({val}) ci-dessous pour corriger ceci", "use_fix": "Utiliser {val}", "watchdog": { @@ -308,7 +316,8 @@ "pg_outcome": "Résultat de la simulation", "pg_across_cycles": "Sur l'ensemble de vos cycles", "community_store": "Boutique communautaire", - "online_account": "Boutique communautaire et fonctionnalités en ligne" + "online_account": "Boutique communautaire et fonctionnalités en ligne", + "import_power_history": "Importer l'historique de puissance" }, "health": { "fair": "Qualité de profil acceptable", @@ -316,6 +325,7 @@ "poor": "⚠ Qualité de profil médiocre" }, "lbl": { + "drag_to_resize": "Faites glisser pour redimensionner", "actions": "Actions", "activity": "Activité", "administrators": "Administrateurs", @@ -402,7 +412,7 @@ "metric": "Métrique", "mode_existing_profile": "Ajouter au profil existant", "mode_new_profile": "Créer un nouveau profil", - "models_fine_tuned": "({count} modèle{plural} affiné{plural})", + "models_fine_tuned": "(modèles affinés : {count})", "n_classic_suggestions": "{n} classique", "n_ml_suggestions": "{n} ML", "n_selected": "{n} sélectionné(s)", @@ -629,7 +639,26 @@ "conflict_import_copy": "Importer comme copie", "conflict_keep_mine": "Garder le mien", "conflict_overwrite": "Écraser", - "font_size": "Taille de police du panneau" + "font_size": "Taille de police du panneau", + "hist_csv_data": "Données CSV", + "hist_from_recorder": "Ou le lire depuis Home Assistant", + "hist_since": "Depuis le", + "days": "jours", + "hist_keep": "Conserver ce cycle", + "hist_looks_complete": "complet", + "peak_power_short": "Crête", + "shape": "Forme", + "hist_skip_idle": "rien en fonctionnement", + "hist_skip_sparse": "mesures trop espacées", + "hist_skip_short": "trop peu de mesures", + "hist_skip_long": "aucune pause assez longue pour découper", + "hist_reason_short": "plus court que le cycle réel le plus court de cet appareil", + "hist_reason_no_end": "ne s'est jamais terminé proprement", + "task_history_import": "Analyse de l'historique de puissance", + "task_history_import_apply": "Importation des cycles", + "evidence_real_cycles": "Cycles effectués par cette machine", + "evidence_reference_cycles": "Téléchargés depuis la boutique communautaire", + "evidence_backfill_cycles": "Trouvés dans un historique de puissance importé" }, "log": { "all_levels": "Tous niveaux", @@ -708,9 +737,15 @@ "store_share": "Partager sur la boutique communautaire", "store_share_device": "Partager cet appareil", "export_select": "Exporter - choisir les données", - "import_wizard": "Importer - choisir les données" + "import_wizard": "Importer - choisir les données", + "history_import": "Importer l'historique de puissance" }, "msg": { + "tail_trim_hint": "Nombre de secondes à supprimer à la fin", + "store_sibling_hint": "Rien de partagé pour votre modèle exact ? Un modèle proche de la même marque constitue généralement un bon point de départ.", + "store_declare_appliance": "Indiquez à WashData l'appareil que vous possédez et cet onglet affichera les configurations que d'autres personnes ont partagées pour celui-ci. Vous pouvez aussi saisir une marque ci-dessus pour explorer.", + "refresh_catalog_hint": "Les listes de marques et d'appareils de la communauté sont mises en cache afin de maintenir la boutique partagée dans son quota quotidien. Rafraîchissez pour récupérer les entrées ajoutées ou approuvées par d'autres utilisateurs.", + "head_trim_hint": "Nombre de secondes à supprimer au début", "appliance_monitor": "Moniteur d'appareils", "artifact_dip_detail": "Est tombé en dessous de la bande de puissance habituelle pendant environ {n} s.", "artifact_footer": "Mis en évidence sur le graphique ci-dessus. Il s’agit d’artefacts transitoires (par exemple la porte ouverte en cours de cycle), pas nécessairement de problèmes.", @@ -721,7 +756,7 @@ "automations_intro": "WashData déclenche les événements {start} / {end} et expose des entités, les notifications et actions sont donc mieux construites comme des automatisations Home Assistant normales. Les automatisations utilisant cet appareil apparaissent ci-dessous.", "cleanup_intro": "Chaque cycle étiqueté superposé. Cochez les valeurs aberrantes et supprimez-les pour nettoyer le profil.", "clear_debug_hint": "Supprimez les données de débogage stockées pour libérer de l'espace.", - "collecting_data": "Collecte de données - encore {need} cycle{plural} avant que l'affinage puisse commencer ({current}/{min}).", + "collecting_data": "Collecte de données. Cycles encore nécessaires avant que l'affinage puisse commencer : {need} ({current}/{min}).", "compare_overlay_profiles": "Profils de superposition (faible)", "compare_profiles_tip": "Superposez d'autres enveloppes de profil sur le graphique ci-dessus pour voir laquelle correspond le mieux à ce cycle.", "compare_selected_cycles": "Cycles sélectionnés (solides) – afficher/masquer", @@ -731,7 +766,7 @@ "cycles_deleted": "{count} cycle(s) supprimé(s)", "enough_data": "Suffisamment de données pour apprendre ({current}/{min} cycles).", "export_description": "Choisissez précisément quels profils, cycles, réglages et autres exporter vers un JSON, ou analysez un fichier pour n'importer que les éléments souhaités.", - "feedback_cycles_pending": "{n} cycle{s} à examiner", + "feedback_cycles_pending": "À réviser : {n}", "feedback_prompt": "Confirmez que c'était correct, corrigez le programme ou ignorez-le.", "feedback_relabel_hint": "Réétiqueter ce cycle le résout également.", "filter_by_profile": "Filtrer par profil…", @@ -808,7 +843,6 @@ "pg_stress_synthetic": "la simulation de repos commence ici", "pg_sweep_intro": "Et si {param} était différent ? Testez {steps} valeurs sur vos {cycles} derniers cycles pour trouver le réglage où le plus de cycles sont correctement reconnus.", "pg_sweep_step": "Étape {done} / {total}", - "pg_undetected": "{n} cycle{s} non détecté{s}", "pg_verdict_bad": "Nécessite une attention: de nombreux cycles ne sont pas détectés.", "pg_verdict_good": "Bien réglé: la plupart des cycles sont correctement identifiés et reconnus.", "pg_verdict_ok": "Acceptable: certains cycles manqués. Essayez d'abaisser le seuil de démarrage.", @@ -839,6 +873,7 @@ "review_recorded_tip": "Marquez-le comme un cycle de référence trié sur le volet pour son programme - le même rôle qu'un cycle enregistré manuellement. Les cycles de référence sont toujours conservés, amorcent le modèle correspondant et ne sont jamais supprimés lors du nettoyage. (C'est le drapeau « doré »/enregistré ; les deux sont la même chose.)", "review_tags_tip": "Indicateurs facultatifs décrivant ce qui n'a pas fonctionné avec ce cycle, afin que la formation et le nettoyage puissent en tenir compte.", "review_to_cycles": "Ouvrir la file d'attente de révision des cycles", + "samples_decimated": "Affichage de {shown} sur {total} échantillons (réduits pour l'affichage ; pics conservés). Un grand écart ici est dû à la réduction, pas à des données manquantes.", "saving_triggers_reload": "L'enregistrement déclenche un rechargement de l'intégration. Les entités HA peuvent apparaître brièvement comme indisponibles.", "search_placeholder": "Paramètres de recherche…", "see_recorder": "Voir le widget enregistreur ci-dessous", @@ -958,10 +993,33 @@ "sug_mute_failed": "Impossible de masquer la suggestion", "sug_unmuted_all": "Les suggestions masquées ont été réinitialisées", "n_suggestions_muted": "{count} masquée(s) ; le réglage automatique ne proposera plus ces suggestions.", - "font_size_hint": "Agrandir ou réduire tout le contenu de ce panneau. S'applique à votre compte sur cet appareil." + "font_size_hint": "Agrandir ou réduire tout le contenu de ce panneau. S'applique à votre compte sur cet appareil.", + "import_history_description": "Vous aviez déjà une prise connectée avant WashData ? Chargez un export de l'historique de son capteur de puissance, ou lisez-le directement depuis Home Assistant : la détection habituelle s'applique à ces données, si bien que vos cycles passés apparaissent dans votre liste Cycles, prêts à être nommés.", + "hist_input_hint": "Chargez un CSV téléchargé depuis le panneau Historique (entité, état, dernière modification), ou laissez WashData lire directement l'historique du capteur. La détection s'exécute ensuite exactement comme en direct, et vous choisissez lesquels des cycles trouvés conserver.", + "hist_recorder_hint": "Lit depuis la date que vous choisissez jusqu'à maintenant. Home Assistant conserve l'historique détaillé pendant 10 jours par défaut, puis seulement des moyennes horaires, trop grossières pour y détecter des cycles - ne choisissez une date plus ancienne que si le recorder de Home Assistant est configuré pour en conserver davantage.", + "hist_scanning": "Votre historique est rejoué dans le détecteur. Cela s'exécute en arrière-plan : vous pouvez fermer cette fenêtre et y revenir plus tard.", + "hist_imported_count": "{n} cycles importés.", + "hist_duplicates": "{n} avaient déjà été importés et ont été ignorés.", + "hist_capped": "La limite de cycles importés par appareil a été atteinte ; le reste n'a pas été enregistré.", + "hist_next_step": "Ils sont dans votre liste Cycles, marqués comme historique importé. Ouvrez-en un et utilisez Étiqueter pour nommer le programme auquel il correspond.", + "hist_rows_read": "{n} mesures lues", + "hist_breaks": "{n} coupures où le capteur était indisponible", + "hist_other_entity": "{n} mesures d'autres entités ignorées", + "hist_entity_substituted": "{used} lu (cet appareil est configuré pour {wanted})", + "hist_skipped_spans": "Plages ignorées", + "hist_settings_used": "Détecté avec les réglages actuels de cet appareil (puissance minimale {w} W, délai d'arrêt {s} s).", + "hist_none_found": "Aucun cycle n'a pu être détecté dans cet historique.", + "hist_found": "{n} cycles trouvés. Décochez tout ce qui ne ressemble pas à un cycle réel : rien n'est enregistré avant l'importation.", + "hist_scan_capped": "Seuls les premiers candidats sont affichés ({n} ont été trouvés).", + "hist_recorder_empty": "Home Assistant n'a pas d'historique détaillé pour ce capteur sur cette période.", + "hist_scan_failed": "L'analyse a échoué.", + "hist_scan_expired": "Cette analyse n'est plus disponible. Veuillez relancer l'analyse.", + "hist_import_failed": "L'importation a échoué.", + "imported_history_readonly": "Détecté dans un historique de puissance importé. Il influence la correspondance des programmes mais n'est pas compté dans vos statistiques, et ne peut être ni rogné ni divisé. Étiquetez-le pour nommer le programme." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Lave-vaisselle : secondes de silence après la durée prévue avant de libérer l'attente de vidange de fin de cycle", + "smart_termination_duration_ratio": "Fraction de la durée prévue du programme correspondant qu'un cycle doit atteindre avant que la Terminaison intelligente puisse le terminer plus tôt ; abaissez-la pour les machines dépendant de la charge ou de la température", "anti_wrinkle_enabled": "Absorber les impulsions de culbutage après la phase principale au lieu de les lire comme de nouveaux cycles", "anti_wrinkle_exit_power": "La puissance doit tomber sous ce seuil entre les impulsions pour que l'anti-froissement reste actif", "anti_wrinkle_idle_timeout": "Temps de silence autorisé entre deux impulsions de culbutage avant la fin de l'anti-froissement", @@ -1158,7 +1216,7 @@ "label": "Entité de prix de l'énergie" }, "energy_price_static": { - "doc": "Prix ​​fixe par kWh utilisé pour les chiffres de coût lorsqu'aucune entité de prix en direct n'est définie ci-dessus.", + "doc": "Prix fixe par kWh utilisé pour les chiffres de coût lorsqu'aucune entité de prix en direct n'est définie ci-dessus.", "label": "Prix de l'énergie statique (par kWh)" }, "energy_sensor": { @@ -1373,6 +1431,10 @@ "doc": "Stockez la trace de puissance complète et les données de débogage correspondantes pour chaque cycle. Utile pour le dépannage mais augmente la taille du stockage.", "label": "Enregistrer les traces de débogage" }, + "smart_termination_duration_ratio": { + "doc": "À quel point un cycle doit avoir progressé dans la durée prévue du programme correspondant avant que la Terminaison intelligente puisse le terminer plus tôt une fois la puissance retombée. La durée prévue est la moyenne du programme ; ainsi, sur les appareils dont le temps de fonctionnement varie beaucoup - lave-linge selon l'eau d'arrivée froide en hiver ou tiède en été, sèche-linge à sonde d'humidité, programmes dépendant de la charge - environ la moitié des cycles se terminent plus tôt que cette moyenne et ne bénéficient jamais de la fin rapide, s'achevant seulement via le délai de secours avec plusieurs minutes de retard. Abaissez cette valeur (p. ex. 0,85) sur ces machines pour que la fin anticipée se déclenche quand même ; augmentez-la vers 1,0 pour être plus prudent. Laissez vide pour la valeur par défaut (0,98, ou 0,99 pour les lave-vaisselle). Elle ne peut que terminer un cycle plus tôt, jamais plus tard, et ne se déclenche jamais sur une correspondance ambiguë ou peu fiable.", + "label": "Ratio de terminaison intelligente" + }, "smoothing_window": { "doc": "Dans quelle mesure le signal de puissance brute est lissé. Low (2) est réactif mais bruyant ; high (5) lisse les pointes mais ajoute du décalage.", "label": "Fenêtre de lissage" @@ -1504,6 +1566,10 @@ "door_end_dwell_seconds": { "label": "Durée d'attente porte ouverte", "doc": "Durée pendant laquelle la porte doit rester ouverte avant que WashData ne termine le cycle, lorsque « La porte s'ouvre automatiquement en fin de cycle » est activé. Assez longue pour ignorer l'ajout rapide d'un article (par défaut 60 s), assez courte pour terminer rapidement une fois que la machine ouvre la porte." + }, + "profile_evidence_sources": { + "label": "Cycles qui façonnent un programme", + "doc": "Quels cycles servent à construire la courbe de puissance de chaque programme et à y comparer un cycle terminé. Décocher un type l'empêche de façonner vos programmes sans rien supprimer - les cycles restent dans votre liste Cycles et peuvent toujours être étiquetés ou supprimés. Utile si vous ne faites pas confiance aux données importées. Les statistiques ne sont pas concernées : elles ne comptent toujours que les cycles que cette machine a réellement effectués. Décocher tout est ignoré, car un programme sans aucun cycle derrière lui ne pourrait jamais correspondre." } }, "setting_group": { @@ -1587,6 +1653,9 @@ }, "basic_configuration": { "label": "Configuration de base" + }, + "profile_evidence": { + "label": "Base des profils" } }, "status": { @@ -1611,7 +1680,8 @@ "trimming": "Rognage…", "splitting": "Fractionnement…", "deleting": "Suppression…", - "imported": "Importé" + "imported": "Importé", + "preparing": "Préparation…" }, "tab": { "advanced": "Avancé", @@ -1641,6 +1711,7 @@ "wrong_profile": "Mauvais profil" }, "toast": { + "catalog_refreshed": "Catalogue de la communauté rafraîchi", "access_saved": "Contrôle d'accès enregistré", "all_wiped": "Toutes les données effacées", "analysis_complete_none": "Analyse terminée : aucune nouvelle suggestion", @@ -1652,7 +1723,7 @@ "cycle_labelled": "Cycle labellisé", "cycle_paused": "Cycle en pause", "cycle_resumed": "Cycle repris", - "cycle_trimmed": "Cycle coupé", + "cycle_trimmed": "Cycle rogné", "cycles_merged": "Cycles fusionnés", "envelope_rebuilt": "Enveloppe reconstruite", "envelopes_rebuilt": "Enveloppes reconstruites", @@ -1746,7 +1817,9 @@ "store_download_failed": "Échec du téléchargement : {error}", "store_download_nothing": "Rien de nouveau à télécharger -- cette configuration est déjà sur votre appareil.", "export_selective_done": "Export téléchargé", - "import_selective_done": "{profiles} profil(s) et {cycles} cycle(s) importés" + "import_selective_done": "{profiles} profil(s) et {cycles} cycle(s) importés", + "hist_csv_required": "Chargez d'abord un fichier CSV ou collez son contenu", + "file_read_failed": "Impossible de lire ce fichier" }, "suggestion": { "both_agree": "WashData recommande", @@ -1842,7 +1915,7 @@ "thr_batch": "Maintenu juste au-dessus de la puissance active la plus basse au p05 sur {cycles} cycles ({p05}W) pour qu'un démarrage soit détecté le plus tôt possible et que le seuil d'arrêt reste sous la puissance de fonctionnement la plus basse de la machine.", "tol_per_profile": "p75 de la variance de durée par profil sur {profiles} profils ({cycles} cycles) ; les profils resserrés ne sont pas pénalisés.", "tol_pooled": "D'après la variance de durée regroupée de {cycles} cycles récemment étiquetés (écart p95={dev}).", - "watchdog": "Maintenu aussi bas que possible en toute sécurité (juste au-dessus de l'écart de mise à jour p95 de {p95}s, min 30s) pour détecter rapidement les blocages sans faux arrêts." + "watchdog": "Maintenu aussi bas que possible en toute sécurité (juste au-dessus de l'écart de mise à jour p95 de {p95}s et au moins 2x l'intervalle d'échantillonnage de {median}s, min 30s) pour détecter rapidement les blocages sans faux arrêts." }, "exclusions": { "summary": "{total} cycle(s) mal détecté(s) exclu(s) : {parts}.", @@ -1908,6 +1981,10 @@ "finished": "Le cycle a atteint son état final et s'est terminé." }, "store": { + "your_model_tip": "C'est l'appareil que vous avez déclaré dans les Paramètres", + "your_model": "Le vôtre", + "search_brand_ph": "Rechercher par marque…", + "programs_count": "Programmes : {n}", "browse": "Parcourir", "device": "Appareil", "favorites": "Favoris", diff --git a/custom_components/ha_washdata/translations/panel/hr.json b/custom_components/ha_washdata/translations/panel/hr.json index 86e770bf..d6f5e586 100644 --- a/custom_components/ha_washdata/translations/panel/hr.json +++ b/custom_components/ha_washdata/translations/panel/hr.json @@ -78,9 +78,12 @@ "awaiting": "Čeka odobrenje", "imported_tip": "Uvezeno iz zajedničke trgovine. Koristi se samo za podudaranje, ne broji se u statistiku.", "not_importable": "nije dostupno", - "exists": "postoji" + "exists": "postoji", + "backfilled_tip": "Otkriveno u uvezenoj povijesti snage. Utječe samo na podudaranje programa, ne broji se u statistiku." }, "btn": { + "set_brand_model": "Postavi marku i model", + "refresh_catalog": "Osvježi katalog", "add_device": "+ Dodaj uređaj", "add_device_tip": "Dodajte još jedan WashData uređaj", "add_maintenance": "Dodaj događaj održavanja", @@ -234,14 +237,19 @@ "download_device": "Preuzmi postavke uređaja", "share_device": "Podijeli postavke uređaja", "share_device_tip": "Podijelite programe i postavke ovog uređaja sa zajednicom", - "share_n": "Podijeli {n} program{s}", + "share_n": "Podijeli cikluse ({n})", "export_selected": "Izvoz (odaberi podatke)", "export_all": "Brzi izvoz svega", "import_raw": "Napredno: zamijeni sve iz JSON datoteke", "download_export": "Preuzmi izvoz", "analyze_import": "Analiziraj datoteku", "import_selected": "Uvezi odabrano", - "back": "Natrag" + "back": "Natrag", + "import_power_history": "Uvezi povijest snage", + "hist_read_recorder": "Pročitaj iz Home Assistant", + "hist_scan": "Traži cikluse", + "hist_import_n": "Uvezi cikluse: {n}", + "hist_goto_cycles": "Prikaži cikluse" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Mora biti ispod Maks. snage anti-bora ({max} W)" }, "attn_sub": "Ispravite konflikte prije pohrane", - "attn_title": "{n} sukob{s} postavki", + "attn_title": "Sukobi postavki: {n}", "confidence": { "auto": "Mora biti jednak ili iznad Praga podudaranja ({match})", - "learning": "Mora biti jednak ili ispod Praga podudaranja ({match})", + "learning": "Mora biti jednak ili iznad Praga podudaranja ({match})", "match_for_auto": "Mora biti jednak ili ispod Pouzdanosti za automatsko označavanje ({alc})", - "match_for_learning": "Mora biti jednak ili iznad Pouzdanosti učenja ({lc})" + "match_for_learning": "Mora biti jednak ili ispod Pouzdanosti učenja ({lc})" }, "duration_ratio": { "max": "Mora biti veći od Min. omjera trajanja ({min})", @@ -296,14 +304,14 @@ "match": "Mora biti iznad Praga nepodudaranja ({un})", "unmatch": "Mora biti ispod Praga podudaranja ({match}); inače potvrđeno podudaranje odmah prestaje" }, - "cascade_toast": "Automatski je prilagođeno i {n} postavki radi dosljednosti.", + "cascade_toast": "Druge postavke prilagođene radi dosljednosti: {n}", "suggestion_resolves": "Primijenite prijedlog ({val}) u nastavku za rješavanje ovog sukoba", "use_fix": "Koristi {val}", "watchdog": { "interval": "Trebao bi biti najmanje 2× Interval uzorkovanja ({si} s)", "sampling": "Interval uzorkovanja trebao bi biti najviše pola Intervala nadzora ({wi} s)" }, - "settings_banner": "{n} sukob{s} postavki – provjerite istaknute odjeljke i ispravite prije pohrane.", + "settings_banner": "Sukobi postavki: {n}. Provjerite istaknute odjeljke i ispravite ih prije spremanja.", "settings_banner_btn": "Idi na prvi" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Ishod simulacije", "pg_across_cycles": "Kroz sve vaše cikluse", "community_store": "Zajednička trgovina", - "online_account": "Zajednička trgovina i online značajke" + "online_account": "Zajednička trgovina i online značajke", + "import_power_history": "Uvoz povijesti snage" }, "health": { "fair": "Prihvatljiva kvaliteta profila", @@ -364,6 +373,7 @@ "poor": "⚠ Loša kvaliteta profila" }, "lbl": { + "drag_to_resize": "Povucite za promjenu veličine", "actions": "Radnje", "activity": "Aktivnost", "administrators": "Administratori", @@ -435,7 +445,7 @@ "from": "Od", "gap_s": "Razmak (s)", "group_name": "Naziv grupe", - "head_trim": "Podrezivanje glave (s)", + "head_trim": "Obrezivanje početka (s)", "health": "Zdravlje", "hide_tabs": "Sakrij kartice za osobe koje nisu administratori", "in_use": "U upotrebi", @@ -451,7 +461,7 @@ "metric": "Metrika", "mode_existing_profile": "Dodaj u postojeći profil", "mode_new_profile": "Stvori novi profil", - "models_fine_tuned": "({count} modela fino podešenih)", + "models_fine_tuned": "(fino podešeni modeli: {count})", "n_classic_suggestions": "{n} klasičan", "n_ml_suggestions": "{n} ML", "n_selected": "{n} odabrano", @@ -533,7 +543,7 @@ "stage3": "Faza 3 – DTW", "stage4": "Faza 4 – podudaranje", "status": "Status", - "tail_trim": "Podrezivanje repa", + "tail_trim": "Obrezivanje kraja (s)", "timer_auto_pause": "Automatska pauza", "timer_min": "min", "timer_msg_placeholder": "Poruka (nije obavezno, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_resolution": "Sukobi naziva", "conflict_import_copy": "Uvezi kao kopiju", "conflict_keep_mine": "Zadrži moje", - "conflict_overwrite": "Prepiši" + "conflict_overwrite": "Prepiši", + "hist_csv_data": "CSV podaci", + "hist_from_recorder": "Ili je pročitajte iz Home Assistant", + "days": "dana", + "hist_keep": "Zadrži ovaj ciklus", + "hist_looks_complete": "završen", + "peak_power_short": "Vrh", + "shape": "Oblik", + "hist_skip_idle": "ništa nije radilo", + "hist_skip_sparse": "prerijetka očitanja", + "hist_skip_short": "premalo očitanja", + "hist_skip_long": "nema dovoljno duge pauze za dijeljenje", + "hist_reason_short": "kraći od najkraćeg stvarnog ciklusa ovog uređaja", + "hist_reason_no_end": "nikada se nije uredno završio", + "hist_since": "Od", + "task_history_import": "Pretraživanje povijesti snage", + "task_history_import_apply": "Uvoz ciklusa", + "evidence_real_cycles": "Ciklusi koje je izvršio ovaj uređaj", + "evidence_reference_cycles": "Preuzeti iz zajedničke trgovine", + "evidence_backfill_cycles": "Nađeni u uvezenoj povijesti snage" }, "log": { "all_levels": "Sve razine", @@ -756,9 +785,15 @@ "store_share": "Podijeli u zajedničku trgovinu", "store_share_device": "Podijeli postavke uređaja", "export_select": "Izvoz - odaberi podatke", - "import_wizard": "Uvoz - odaberi podatke" + "import_wizard": "Uvoz - odaberi podatke", + "history_import": "Uvoz povijesti snage" }, "msg": { + "tail_trim_hint": "Uklonite ovoliko sekundi s kraja", + "store_sibling_hint": "Nema ničega podijeljenog za vaš točan model? Blisko srodan model iste marke obično je dobra početna točka.", + "store_declare_appliance": "Recite integraciji WashData koji uređaj posjedujete i ova kartica prikazat će konfiguracije koje su drugi podijelili za njega. Možete i upisati marku iznad kako biste pregledali katalog.", + "refresh_catalog_hint": "Popisi marki i uređaja zajednice privremeno se spremaju kako bi zajednička trgovina ostala u okviru svog dnevnog ograničenja. Osvježite da preuzmete unose koje su drugi dodali ili odobrili.", + "head_trim_hint": "Uklonite ovoliko sekundi s početka", "appliance_monitor": "Monitor uređaja", "artifact_dip_detail": "Palo ispod uobičajenog pojasa snage ~{n}s.", "artifact_footer": "Istaknuto na gornjem grafikonu. To su prolazni artefakti (npr. vrata su se otvorila usred ciklusa), a ne nužno problemi.", @@ -769,7 +804,7 @@ "automations_intro": "WashData pokreće događaje {start} / {end} i izlaže entitete, pa je obavijesti i radnje najbolje graditi kao normalne automatizacije Home Assistanta. Automatizacije koje koriste ovaj uređaj prikazane su ispod.", "cleanup_intro": "Svaki označeni ciklus prekriven. Označite izvanredne vrijednosti i izbrišite da biste očistili profil.", "clear_debug_hint": "Uklonite pohranjene podatke o otklanjanju pogrešaka da biste oslobodili prostor.", - "collecting_data": "Prikupljanje podataka: još {need} ciklusa prije početka finog podešavanja ({current}/{min}).", + "collecting_data": "Prikupljanje podataka. Još potrebnih ciklusa prije početka finog podešavanja: {need} ({current}/{min}).", "compare_overlay_profiles": "Prekrivajući profili (slabo)", "compare_profiles_tip": "Prekrijte druge omotnice profila na gornjoj tablici kako biste vidjeli koja najbolje odgovara ovom ciklusu.", "compare_selected_cycles": "Odabrani ciklusi (puno) – prikaži / sakrij", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} ciklus(a) izbrisano", "enough_data": "Dovoljno podataka za učenje ({current}/{min} ciklusa).", "export_description": "Odaberite točno koje profile, cikluse, postavke i ostalo želite izvesti u JSON datoteku ili analizirajte datoteku i uvezite samo željene dijelove.", - "feedback_cycles_pending": "{n} ciklus{s} za pregled", + "feedback_cycles_pending": "Za pregled: {n}", "feedback_prompt": "Potvrdite da je ispravno, ispravite program ili zanemarite.", "feedback_relabel_hint": "Ponovno označavanje ovog ciklusa također ga rješava.", "filter_by_profile": "Filtriraj po profilu…", @@ -793,7 +828,7 @@ "loading_curve": "Učitavanje krivulje…", "loading_settings": "Učitavanje postavki…", "log_buffer_hint": "Prvo najnovije · sprema zadnjih 500 ha_washdata zapisa od ponovnog pokretanja · povucite donji rub za promjenu veličine.", - "maintenance_advisory": "Povećanje trajanja/energije može ukazivati ​​na potrebu održavanja uređaja (npr. uklanjanje kamenca, čišćenje filtera).", + "maintenance_advisory": "Povećanje trajanja/energije može ukazivati na potrebu održavanja uređaja (npr. uklanjanje kamenca, čišćenje filtera).", "maintenance_due": "Održavanje na redu: {items}", "maintenance_intro": "Bilježite servisiranje koje obavljate na ovom uređaju i primite podsjetnik kada svaki zadatak ponovno dođe na red.", "maintenance_load_error": "Nije moguće učitati podatke o održavanju: {error}", @@ -830,7 +865,7 @@ "no_split_points": "Još nema točaka razdvajanja.", "no_suggestions": "Nema aktivnih prijedloga.", "notify_services_hint": "Koristite ID-ove servisa {entity} (odvojene zarezima za više servisa). Varijable predloška: {vars}.", - "old_actions_warning": "Konfiguriran sa starim uređivačem akcija (sada uklonjen). I dalje se aktiviraju na događaje ciklusa, ali se više ne mogu uređivati ​​ovdje. Pretvorite ih u normalnu automatizaciju ili ih uklonite.", + "old_actions_warning": "Konfiguriran sa starim uređivačem akcija (sada uklonjen). I dalje se aktiviraju na događaje ciklusa, ali se više ne mogu uređivati ovdje. Pretvorite ih u normalnu automatizaciju ili ih uklonite.", "onboarding_progress": "{n} / 3 ciklusa promatrano", "onboarding_watching": "Koristite uređaj uobičajeno – WashData promatra. Nakon 3 ciklusa započet će podudaranje programa.", "pending_feedback": "Povratne informacije o otkrivanju na čekanju", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "simulacija mirovanja počinje ovdje", "pg_sweep_intro": "Što ako bi {param} bio drugačiji? Testirajte {steps} vrijednosti na svojih zadnjih {cycles} ciklusa kako biste pronašli postavku pri kojoj se najviše ciklusa ispravno podudara.", "pg_sweep_step": "Korak {done} / {total}", - "pg_undetected": "{n} neotkrivenih ciklusa", "pg_verdict_bad": "Zahtijeva pozornost: mnogi ciklusi ostaju neotkriveni.", "pg_verdict_good": "Dobro podešeno: većina ciklusa ispravno je prepoznata i podudarana.", "pg_verdict_ok": "Prihvatljivo: neki ciklusi su propušteni. Pokušajte sniziti prag pokretanja.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Označite ovo kao ručno odabran referentni ciklus za svoj program - ista uloga kao ručno snimljeni ciklus. Referentni ciklusi se uvijek čuvaju, postavljaju odgovarajući predložak i nikada se ne odbacuju čišćenjem. (Ovo je \"zlatna\"/snimljena zastava; obje su iste stvari.)", "review_tags_tip": "Neobavezne oznake koje opisuju što je pošlo po zlu s ovim ciklusom, tako da obuka i čišćenje mogu objasniti to.", "review_to_cycles": "Otvorite red čekanja za pregled ciklusa", + "samples_decimated": "Prikazano {shown} od {total} uzoraka (prorijeđeno za prikaz; vrhovi zadržani). Široki razmak ovdje znači prorjeđivanje, a ne nedostajuće podatke.", "saving_triggers_reload": "Spremanje pokreće ponovno učitavanje integracije. HA entiteti mogu se nakratko prikazati kao nedostupni.", "search_placeholder": "Postavke pretraživanja…", "see_recorder": "Pogledajte widget za snimanje u nastavku", @@ -991,12 +1026,12 @@ "share_consent": "Dijelite stvarne podatke sa svog uređaja. Ne dijelite ako su vaši obrasci korištenja privatni.", "share_device_none": "Još nisu postavljena nikakva uređaja. Prvo dodajte uređaj.", "share_guideline_naming": "Koristite jasna imena programa (npr. 'Cotton 40', 'Eco 60') kako bi ih drugi mogli prepoznati", - "share_guideline_quality": "Dijelite samo profile s ⭐ referentnim ciklusima ili najmanje {n} potvrđenih pokretanja", + "share_guideline_quality": "Dijelite samo cikluse koji su normalno završili -- bez prekida usred ciklusa, otvaranja vrata ili skokova napajanja.", "share_guideline_review": "Pregledajte profile prije dijeljenja -- uklonite one koji izgledaju pogrešno", "share_guidelines_title": "Prije dijeljenja", "store_download_device_intro": "Preuzmite postavke uređaja iz zajednice i primijenite ih na novi ili postojeći uređaj", - "store_share_device_intro": "Podijelite programe svog uređaja (profile + referentne cikluse) sa zajednicom. Postavke su opcionalne.", - "share_profile_no_cycles": "Profil '{p}' nema ⭐ referentnih ciklusa -- bit će preskočen osim ako nemate {n}+ potvrđenih pokretanja", + "store_share_device_intro": "Prenesite {brand} {model} s referentnim ciklusima koje odaberete. Drugi s istim uređajem mogu preuzeti vaše programe. Unosi se pregledavaju prije nego što postanu javno dostupni.", + "share_profile_no_cycles": "Nema referentnih ciklusa - označite ciklus sa ⭐ u kartici Ciklusi kako biste uključili ovaj profil", "advisory_phase_inconsistent": "Čini se da '{name}' miješa različite programe ili temperature - njegovi se ciklusi griju vrlo različito dugo. Podjela na zasebne profile (npr. po temperaturi) poboljšat će podudaranje i procjene vremena.", "advisory_phase_inconsistent_title": "⚠ Možda pomiješani programi", "export_select_intro": "Označite točno što treba uključiti. Odabir profila bez njihovih ciklusa i dalje izvozi prepoznatljiv program (njegov naučeni oblik prenosi se s njim).", @@ -1006,7 +1041,29 @@ "merge_hint": "Uvezene stavke se dodaju; ništa lokalno se ne gubi. Sukobi naziva rješavaju se u nastavku.", "replace_warn": "Svaka označena kategorija briše se i zamjenjuje podacima iz datoteke. Neoznačene kategorije ostaju nepromijenjene.", "dest_reference_hint": "Uvezeni ciklusi samo poboljšavaju prepoznavanje programa i nikada ne utječu na statistiku potrošnje/energije.", - "dest_real_history_hint": "Uvezeni ciklusi računaju se kao vlastita povijest ovog uređaja i pridonose statistici energije/potrošnje. Koristite pri premještanju jednog uređaja na novu instalaciju." + "dest_real_history_hint": "Uvezeni ciklusi računaju se kao vlastita povijest ovog uređaja i pridonose statistici energije/potrošnje. Koristite pri premještanju jednog uređaja na novu instalaciju.", + "import_history_description": "Imali ste pametni utikač i prije WashData? Učitajte izvoz povijesti njegovog senzora snage ili je pročitajte izravno iz Home Assistant, a uobičajeno otkrivanje proći će kroz te podatke, pa će se prošli ciklusi pojaviti u vašem popisu Ciklusi, spremni za imenovanje.", + "hist_input_hint": "Učitajte CSV preuzet s ploče Povijest (entitet, stanje, zadnja promjena) ili pustite WashData da izravno pročita povijest senzora. Otkrivanje se zatim izvodi jednako kao i uživo, a vi odabirete koje od nađenih ciklusa zadržati.", + "hist_recorder_hint": "Čita podatke od odabranog datuma do sada. Home Assistant standardno čuva detaljnu povijest 10 dana, a nakon toga samo satne prosjeke, koji su previše grubi za otkrivanje ciklusa - raniji datum odaberite samo ako je vaš recorder postavljen na dulje čuvanje.", + "hist_scanning": "Vašu povijest ponovno provodimo kroz detektor. Ovo se izvodi u pozadini - možete zatvoriti ovaj dijalog i vratiti se kasnije.", + "hist_imported_count": "Uvezeni ciklusi: {n}.", + "hist_duplicates": "Već prije uvezeno i preskočeno: {n}.", + "hist_capped": "Dosegnuto je ograničenje uvezenih ciklusa po uređaju; ostatak nije spremljen.", + "hist_next_step": "Nalaze se u vašem popisu Ciklusi, označeni kao uvezena povijest. Otvorite jedan i tipkom Označi mu dodijelite naziv programa kojem pripada.", + "hist_rows_read": "Pročitana očitanja: {n}", + "hist_breaks": "Praznine u kojima senzor nije bio dostupan: {n}", + "hist_other_entity": "Zanemarena očitanja drugih entiteta: {n}", + "hist_entity_substituted": "Očitano {used} (ovaj uređaj je postavljen na {wanted})", + "hist_skipped_spans": "Preskočeni odsječci", + "hist_settings_used": "Otkriveno s trenutnim postavkama ovog uređaja (minimalna snaga {w} W, odgoda isključivanja {s} s).", + "hist_none_found": "U toj povijesti nije bilo moguće otkriti nijedan ciklus.", + "hist_found": "Nađeni ciklusi: {n}. Odznačite sve što ne izgleda kao stvarni rad - ništa se ne sprema dok ne uvezete.", + "hist_scan_capped": "Prikazani su samo prvi kandidati (nađeno je {n}).", + "hist_recorder_empty": "Home Assistant nema detaljnu povijest za ovaj senzor u tom razdoblju.", + "hist_scan_failed": "Pretraživanje nije uspjelo.", + "hist_scan_expired": "To pretraživanje više nije dostupno. Pokrenite ga ponovno.", + "hist_import_failed": "Uvoz nije uspio.", + "imported_history_readonly": "Otkriveno u uvezenoj povijesti snage. Utječe na podudaranje programa, ali se ne broji u vašu statistiku i ne može se obrezati ni podijeliti. Označite ga da mu dodijelite program." }, "phase_desc": { "anti_crease": "Povremena kratka prevrtanja nakon završetka kako bi se smanjile bore.", @@ -1161,7 +1218,7 @@ "label": "Tolerancija procjene" }, "enable_ml_models": { - "doc": "While a cycle runs, let the models refine the live results: a steadier time-remaining and energy/cost estimate, and an anti-premature-stop guard on end detection (it can only ever delay a finish, never end one early, and is bounded). Koristi vaše fino podešene modele kada su dostupni, inače ugrađene. Isključeno = samo klasična logika koja se temelji na snazi ​​(još uvijek pouzdana).", + "doc": "While a cycle runs, let the models refine the live results: a steadier time-remaining and energy/cost estimate, and an anti-premature-stop guard on end detection (it can only ever delay a finish, never end one early, and is bounded). Koristi vaše fino podešene modele kada su dostupni, inače ugrađene. Isključeno = samo klasična logika koja se temelji na snazi (još uvijek pouzdana).", "label": "Primijeni pametne modele tijekom ciklusa" }, "end_energy_threshold": { @@ -1173,7 +1230,7 @@ "label": "Broj ponavljanja završetka" }, "energy_price_entity": { - "doc": "Senzor s trenutnom cijenom električne energije po kWh (npr. dinamička tarifa). Ima prednost nad statičnom cijenom ispod. Svaki ciklus zamrzava cijenu na snazi ​​kada završi.", + "doc": "Senzor s trenutnom cijenom električne energije po kWh (npr. dinamička tarifa). Ima prednost nad statičnom cijenom ispod. Svaki ciklus zamrzava cijenu na snazi kada završi.", "label": "Entitet cijene energije" }, "energy_price_static": { @@ -1392,6 +1449,10 @@ "doc": "Pohranite puno praćenje snage i odgovarajuće podatke o otklanjanju pogrešaka za svaki ciklus. Korisno za rješavanje problema, ali povećava veličinu pohrane.", "label": "Spremi tragove za otklanjanje pogrešaka" }, + "smart_termination_duration_ratio": { + "doc": "Koliko daleko u očekivanom trajanju podudarajućeg programa ciklus mora biti prije nego što ga Pametni završetak može ranije završiti nakon pada snage. Očekivano trajanje je prosjek programa, pa kod uređaja čije vrijeme rada jako varira - perilice s hladnom dovodnom vodom zimi u odnosu na toplu ljeti, sušilice sa senzorskim sušenjem, programi ovisni o količini rublja - otprilike polovica svih pokretanja završi kraće od tog prosjeka i nikada ne dobije brzo završavanje, već završi tek putem pričuvnog vremenskog ograničenja, nekoliko minuta kasnije. Kod takvih strojeva smanjite ovu vrijednost (npr. 0,85) kako bi se ranije završavanje ipak pokrenulo; povećajte je prema 1,0 za oprezniji pristup. Ostavite prazno za zadanu vrijednost (0,98, ili 0,99 za perilice posuđa). Ciklus može uvijek samo ranije završiti, nikada kasnije, i nikada se ne pokreće kod nejasnog podudaranja ili podudaranja niske pouzdanosti.", + "label": "Omjer pametnog završetka" + }, "smoothing_window": { "doc": "Koliko je neobrađeni signal snage izglađen. Low (2) je osjetljiv, ali bučan; visoko (5) izglađuje skokove, ali dodaje kašnjenje.", "label": "Prozor izglađivanja" @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Zadržavanje otvorenih vrata za završetak", "doc": "Koliko dugo vrata moraju ostati otvorena prije nego WashData završi ciklus, kada je uključena postavka \"Vrata se automatski otvaraju na kraju\". Dovoljno dugo da se ignorira brzo dodavanje posuđa (zadano 60 s), dovoljno kratko za brz završetak kad perilica otvori vrata." + }, + "profile_evidence_sources": { + "label": "Ciklusi koji oblikuju program", + "doc": "Koji se ciklusi koriste za izgradnju krivulje snage svakog programa i za podudaranje završenog ciklusa s njom. Kada odznačite neku vrstu, ona više ne utječe na vaše programe, ali se ništa ne briše - ciklusi ostaju na popisu Ciklusi i još ih je moguće označiti ili ukloniti. Korisno ako ne vjerujete uvezenim podacima. Na statistiku to ne utječe: uvijek se broje samo ciklusi koje je ovaj uređaj stvarno izvršio. Odznačavanje svega se ignorira jer se program bez ikakvih ciklusa nikada ne bi mogao podudarati." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Osnovna konfiguracija" + }, + "profile_evidence": { + "label": "Osnova profila" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Obrezivanje…", "splitting": "Dijeljenje…", "deleting": "Brisanje…", - "imported": "Uvezeno" + "imported": "Uvezeno", + "preparing": "Priprema…" }, "suggestion": { "both_agree": "WashData preporučuje", @@ -1725,7 +1794,7 @@ "thr_batch": "Zadržano tik iznad najniže radne snage p05 kroz {cycles} ciklusa ({p05}W) kako bi se početak uhvatio što ranije, a prag zaustavljanja ostao ispod najniže radne snage stroja.", "tol_per_profile": "p75 varijance trajanja po profilu kroz {profiles} profila ({cycles} ciklusa); dosljedni profili nisu kažnjeni.", "tol_pooled": "Na temelju objedinjene varijance trajanja {cycles} novijih označenih ciklusa (p95 odstupanje={dev}).", - "watchdog": "Zadržano što nižim uz sigurnost (tik iznad razmaka ažuriranja p95 od {p95}s, najmanje 30s) kako bi se zastoji brzo uhvatili bez lažnih zaustavljanja." + "watchdog": "Zadržano što nižim uz sigurnost (tik iznad razmaka ažuriranja p95 od {p95}s i najmanje 2× intervala uzorkovanja od {median}s, najmanje 30s) kako bi se zastoji brzo uhvatili bez lažnih zaustavljanja." }, "exclusions": { "summary": "Isključeno {total} pogrešno otkrivenih ciklusa: {parts}.", @@ -1772,6 +1841,7 @@ "wrong_profile": "Pogrešan profil" }, "toast": { + "catalog_refreshed": "Katalog zajednice je osvježen", "access_saved": "Kontrola pristupa spremljena", "all_wiped": "Svi podaci izbrisani", "analysis_complete_none": "Analiza dovršena: nema novih prijedloga", @@ -1783,7 +1853,7 @@ "cycle_labelled": "Ciklus označen", "cycle_paused": "Ciklus pauziran", "cycle_resumed": "Ciklus je nastavljen", - "cycle_trimmed": "Ciklus podrezan", + "cycle_trimmed": "Ciklus obrezan", "cycles_merged": "Ciklusi spojeni", "envelope_rebuilt": "Omotnica obnovljena", "envelopes_rebuilt": "Omotnice obnovljene", @@ -1865,19 +1935,21 @@ "rating_saved": "Ocjena kvalitete spremljena", "brand_added": "Marka dodana, čeka odobrenje", "profile_added": "Profil dodan, čeka odobrenje", - "saved_except_conflicts": "Postavke spremljene -- {n} postavka{s} preskočena zbog sukoba", + "saved_except_conflicts": "Spremljeno. Ispravite istaknute sukobe da spremite ostalo.", "share_device_none_sel": "Odaberite barem jedan program za dijeljenje", - "store_device_downloaded": "Postavke uređaja preuzete: {created} profil{c} stvoren, {dup} već postoji", - "store_device_downloaded_phases": "Postavke uređaja preuzete: {created} profil{c} stvoren, {dup} već postoji, karta faza primijenjena", - "store_device_downloaded_settings": "Postavke uređaja preuzete: {created} profil{c} stvoren, {dup} već postoji, postavke primijenjene", - "store_device_shared": "Postavke uređaja podijeljene: {n} program{s} učitan", - "store_device_shared_all_dup": "Nema ničeg novog za dijeljenje -- svi programi već postoje u trgovini", - "store_device_shared_partial": "Djelomično dijeljenje: {n} program{s} učitan, {failed} preskočeno", - "store_device_shared_some_dup": "Postavke uređaja podijeljene: {n} program{s} učitan ({dup} već postoji)", + "store_device_downloaded": "Dodano: programi {p}, snimke {c}", + "store_device_downloaded_phases": "Dodano: programi {p}, snimke {c}, karte faza {ph}", + "store_device_downloaded_settings": "Dodano: programi {p}, snimke {c}, karte faza {ph}, postavke {s}", + "store_device_shared": "Ciklusi podijeljeni u zajedničku trgovinu: {n}. Čeka se pregled.", + "store_device_shared_all_dup": "Svi ciklusi ({n}) već su bili u zajedničkoj trgovini.", + "store_device_shared_partial": "Podijeljeni ciklusi: {n}; nije preneseno: {failed}.", + "store_device_shared_some_dup": "Podijeljeni ciklusi: {created}; već u trgovini: {dup}.", "store_download_failed": "Preuzimanje nije uspjelo: {error}", "store_download_nothing": "Nema ništa za preuzimanje -- svi profili već postoje na ovom uređaju", "export_selective_done": "Izvoz preuzet", - "import_selective_done": "Uvezeno {profiles} profila i {cycles} ciklusa" + "import_selective_done": "Uvezeno {profiles} profila i {cycles} ciklusa", + "hist_csv_required": "Najprije učitajte CSV datoteku ili zalijepite njezin sadržaj", + "file_read_failed": "Nije bilo moguće pročitati tu datoteku" }, "trend": { "down": "Trend pada", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Perilica posuđa: sekunde tišine nakon očekivanog trajanja prije nego što se otpusti čekanje na završno ispumpavanje vode", + "smart_termination_duration_ratio": "Udio očekivanog trajanja podudarajućeg programa koji ciklus mora dosegnuti prije nego što ga Pametni završetak može ranije završiti; smanjite ga za strojeve ovisne o količini rublja ili temperaturi", "anti_wrinkle_enabled": "Upij impulse bubnja nakon glavne faze, a ne čitaj ih kao nove cikluse", "anti_wrinkle_exit_power": "Snaga mora pasti ispod ove vrijednosti između impulsa da bi anti-bora ostala aktivna", "anti_wrinkle_idle_timeout": "Dopušteno vrijeme mirovanja između dva impulsa bubnja prije nego što anti-bora završi", @@ -1955,6 +2028,10 @@ "finished": "Ciklus je dosegao završno stanje i završio." }, "store": { + "your_model_tip": "Ovo je uređaj koji ste naveli u Postavkama", + "your_model": "Vaš", + "search_brand_ph": "Pretraži po marki…", + "programs_count": "Programi: {n}", "browse": "Pregledaj", "device": "Uređaj", "favorites": "Favoriti", diff --git a/custom_components/ha_washdata/translations/panel/hu.json b/custom_components/ha_washdata/translations/panel/hu.json index 5218e679..214f16e6 100644 --- a/custom_components/ha_washdata/translations/panel/hu.json +++ b/custom_components/ha_washdata/translations/panel/hu.json @@ -78,9 +78,12 @@ "awaiting": "Jóváhagyásra vár", "imported_tip": "A közösségi boltból importálva. Csak egyeztetéshez használatos, a statisztikába nem számít bele.", "not_importable": "itt nem elérhető", - "exists": "létezik" + "exists": "létezik", + "backfilled_tip": "Importált teljesítményelőzményekben észlelve. Csak a programegyeztetést befolyásolja, a statisztikába nem számít bele." }, "btn": { + "set_brand_model": "Márka és modell megadása", + "refresh_catalog": "Katalógus frissítése", "add_device": "+ Eszköz hozzáadása", "add_device_tip": "Adjon hozzá egy másik WashData eszközt", "add_maintenance": "Karbantartási esemény hozzáadása", @@ -241,7 +244,12 @@ "import_selected": "Kiválasztottak importálása", "back": "Vissza", "mute_suggestion": "Ne javasolja többé ezt a beállítást", - "reset_muted": "Elhallgatottak visszaállítása" + "reset_muted": "Elhallgatottak visszaállítása", + "import_power_history": "Teljesítményelőzmények importálása", + "hist_read_recorder": "Beolvasás a Home Assistantból", + "hist_scan": "Ciklusok keresése", + "hist_import_n": "{n} ciklus importálása", + "hist_goto_cycles": "Ciklusok megjelenítése" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "A Ránctalanítás maximális teljesítménye alatt kell lennie ({max} W)" }, "attn_sub": "Mentés előtt javítsa az ütközéseket", - "attn_title": "{n} beállítási ütközés{s}", - "settings_banner": "{n} beállítási ütközés{s} – ellenőrizze a kijelölt szakaszokat és javítsa ki mentés előtt.", + "attn_title": "Beállítási ütközések: {n}", + "settings_banner": "Beállítási ütközések: {n}. Ellenőrizze a kijelölt szakaszokat, és javítsa ki őket mentés előtt.", "settings_banner_btn": "Ugrás az elsőhöz", "confidence": { "auto": "Legalább az Egyezési küszöb értékén kell lennie ({match})", - "learning": "Legfeljebb az Egyezési küszöb értékén lehet ({match})", + "learning": "Legalább az Egyezési küszöb értékén kell lennie ({match})", "match_for_auto": "Legfeljebb az Automatikus jelölési megbízhatóság értékén lehet ({alc})", - "match_for_learning": "Legalább a Tanulási megbízhatóság értékén kell lennie ({lc})" + "match_for_learning": "Legfeljebb a Tanulási megbízhatóság értékén lehet ({lc})" }, "duration_ratio": { "max": "Nagyobb kell lennie a Min. időtartam aránynál ({min})", @@ -298,7 +306,7 @@ "match": "A Nem egyező küszöb felett kell lennie ({un})", "unmatch": "Az Egyezési küszöb alatt kell lennie ({match}); különben egy megerősített egyezés azonnal visszavonódik" }, - "cascade_toast": "Konzisztencia megőrzése érdekében {n} beállítás is automatikusan módosult.", + "cascade_toast": "A konzisztencia érdekében módosított további beállítások: {n}", "suggestion_resolves": "A javításhoz alkalmazza az alábbi függőben lévő javaslatot ({val})", "use_fix": "Használja a következőt: {val}", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Szimuláció eredménye", "pg_across_cycles": "Az összes ciklusban", "community_store": "Közösségi bolt", - "online_account": "Közösségi bolt és online funkciók" + "online_account": "Közösségi bolt és online funkciók", + "import_power_history": "Teljesítményelőzmények importálása" }, "health": { "fair": "Elfogadható profilminőség", @@ -364,6 +373,7 @@ "poor": "⚠ Gyenge profilminőség" }, "lbl": { + "drag_to_resize": "Húzza az átméretezéshez", "pg_anti_wrinkle": "Ránctalanítás", "actions": "Akciók", "activity": "Tevékenység", @@ -435,7 +445,7 @@ "from": "Ettől", "gap_s": "rés(ek)", "group_name": "Csoport neve", - "head_trim": "Fejszegély(ek)", + "head_trim": "Vágás az elejéről (s)", "health": "Egészség", "hide_tabs": "Lapok elrejtése nem rendszergazdák számára", "in_use": "Használatban", @@ -451,7 +461,7 @@ "metric": "Mérőszám", "mode_existing_profile": "Hozzáadás a meglévő profilhoz", "mode_new_profile": "Új profil létrehozása", - "models_fine_tuned": "({count} modell finomhangolva)", + "models_fine_tuned": "(finomhangolt modellek: {count})", "n_classic_suggestions": "{n} klasszikus", "n_ml_suggestions": "{n} ML", "n_selected": "{n} kijelölve", @@ -532,7 +542,7 @@ "stage3": "3. szakasz – DTW", "stage4": "4. szakasz – egyezés", "status": "Állapot", - "tail_trim": "Farokszegély(ek)", + "tail_trim": "Vágás a végéről (s)", "timer_auto_pause": "Automatikus szünet", "timer_min": "min", "timer_msg_placeholder": "Üzenet (nem kötelező, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_import_copy": "Importálás másolatként", "conflict_keep_mine": "A sajátom megtartása", "conflict_overwrite": "Felülírás", - "font_size": "Panel betűmérete" + "font_size": "Panel betűmérete", + "hist_csv_data": "CSV-adatok", + "hist_from_recorder": "Vagy olvassa be a Home Assistantból", + "hist_since": "Kezdő dátum", + "days": "nap", + "hist_keep": "Ciklus megtartása", + "hist_looks_complete": "teljes", + "peak_power_short": "Csúcs", + "shape": "Alak", + "hist_skip_idle": "nem futott semmi", + "hist_skip_sparse": "túl ritka mérések", + "hist_skip_short": "túl kevés mérés", + "hist_skip_long": "nincs elég hosszú szünet a felosztáshoz", + "hist_reason_short": "rövidebb, mint a készülék legrövidebb valódi ciklusa", + "hist_reason_no_end": "soha nem fejeződött be tisztán", + "task_history_import": "Teljesítményelőzmények vizsgálata", + "task_history_import_apply": "Ciklusok importálása", + "evidence_real_cycles": "A gép által lefuttatott ciklusok", + "evidence_reference_cycles": "Letöltve a közösségi boltból", + "evidence_backfill_cycles": "Importált teljesítményelőzményekben találva" }, "log": { "all_levels": "Minden szinten", @@ -756,9 +785,15 @@ "store_share": "Megosztás a közösségi boltban", "store_share_device": "Eszköz megosztása", "export_select": "Exportálás - adatok kiválasztása", - "import_wizard": "Importálás - adatok kiválasztása" + "import_wizard": "Importálás - adatok kiválasztása", + "history_import": "Teljesítményelőzmények importálása" }, "msg": { + "tail_trim_hint": "Ennyi másodperc eltávolítása a végéről", + "store_sibling_hint": "Pontosan az Ön modelljéhez nincs semmi megosztva? Ugyanannak a márkának egy közeli modellje általában jó kiindulópont.", + "store_declare_appliance": "Adja meg a WashDatának, hogy melyik készülék az Öné, és ez a lap megjeleníti a mások által hozzá megosztott programokat és felvételeket. Fent egy márkát is beírhat, ha csak körül szeretne nézni.", + "refresh_catalog_hint": "A közösségi márka- és készüléklisták gyorsítótárba kerülnek, hogy a megosztott bolt a napi lekérdezési keretén belül maradjon. Frissítsen a mások által hozzáadott vagy jóváhagyott bejegyzések betöltéséhez.", + "head_trim_hint": "Ennyi másodperc eltávolítása az elejéről", "appliance_monitor": "Készülék monitor", "artifact_dip_detail": "~{n}s-re a szokásos energiasáv alá esett.", "artifact_footer": "A fenti grafikonon kiemelve. Ezek átmeneti műtermékek (pl. a ciklus közepén kinyílt ajtó), nem feltétlenül problémák.", @@ -769,7 +804,7 @@ "automations_intro": "A WashData {start} / {end} eseményeket küld és entitásokat tesz elérhetővé, ezért az értesítések és műveletek legjobban normál Home Assistant automatizálásként építhetők fel. Az ezt az eszközt használó automatizálások alább jelennek meg.", "cleanup_intro": "Minden címkézett ciklus átfedi. Jelölje be a kiugró értékeket, és törölje a profilt a profil megtisztításához.", "clear_debug_hint": "Távolítsa el a tárolt hibakeresési adatokat, hogy helyet szabadítson fel.", - "collecting_data": "Adatgyűjtés folyamatban - még {need} ciklus szükséges a finomhangolás megkezdéséhez ({current}/{min}).", + "collecting_data": "Adatgyűjtés folyamatban. A finomhangolás megkezdéséhez még szükséges ciklusok: {need} ({current}/{min}).", "compare_overlay_profiles": "Fedőprofilok (halvány)", "compare_profiles_tip": "Fedjen rá más profilborítékokat a fenti diagramra, hogy megtudja, melyik illik legjobban ehhez a ciklushoz.", "compare_selected_cycles": "Kiválasztott ciklusok (folytonos) – megjelenítés/elrejtés", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} ciklus törölve", "enough_data": "Elegendő adat a tanuláshoz ({current}/{min} ciklus).", "export_description": "Válaszd ki pontosan, mely profilokat, ciklusokat, beállításokat és egyebeket exportálsz JSON-ba, vagy elemezz egy fájlt, és csak a kívánt részeket importáld.", - "feedback_cycles_pending": "{n} ciklus{s} áttekintésre vár", + "feedback_cycles_pending": "Áttekintésre vár: {n}", "feedback_prompt": "Erősítse meg, hogy helyes volt, javítsa ki a programot, vagy hagyja figyelmen kívül.", "feedback_relabel_hint": "Ennek a ciklusnak az újracímkézése ezt is megoldja.", "filter_by_profile": "Szűrés profil szerint…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "az üresjárati szimuláció itt kezdődik", "pg_sweep_intro": "Mi lenne, ha a {param} más lenne? Teszteljen {steps} értéket az utolsó {cycles} cikluson, hogy megtalálja azt a beállítást, ahol a legtöbb ciklus helyesen egyezik.", "pg_sweep_step": "{done} / {total}. lépés", - "pg_undetected": "{n} nem észlelt ciklus", "pg_verdict_bad": "Figyelmet igényel: sok ciklus észrevétlen marad.", "pg_verdict_good": "Jól hangolva: a legtöbb ciklust helyesen azonosítja és párosítja.", "pg_verdict_ok": "Elfogadható: néhány ciklus kimaradt. Próbálja meg csökkenteni az indítási küszöböt.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Jelölje meg ezt a programhoz tartozó, kézzel kiválasztott referenciaciklusként – ugyanaz a szerep, mint egy kézzel rögzített ciklus. A referenciaciklusokat mindig megőrizzük, a megfelelő sablont vetjük be, és soha nem vetik el őket a tisztítás során. (Ez az \"arany\"/rekord zászló; mindkettő ugyanaz.)", "review_tags_tip": "Opcionális jelzők, amelyek leírják, hogy mi hibázott ebben a ciklusban, így a képzés és a tisztítás megmagyarázhatja.", "review_to_cycles": "Nyissa meg a Ciklusok áttekintési sort", + "samples_decimated": "{shown}/{total} minta megjelenítése (megjelenítéshez ritkítva; a csúcsok megtartva). Az itt látható széles hézag ritkítás, nem hiányzó adat.", "saving_triggers_reload": "A mentés az integráció újratöltését indítja el. A HA entitások rövid időre elérhetetlenként jelenhetnek meg.", "search_placeholder": "Keresési beállítások…", "see_recorder": "Lásd alább a felvevő widgetet", @@ -1006,7 +1041,29 @@ "sug_mute_failed": "Nem sikerült elhallgattatni a javaslatot", "sug_unmuted_all": "Az elhallgattatott javaslatok visszaállítva", "n_suggestions_muted": "{count} elhallgattatva; az automatikus finomhangolás nem fogja javasolni ezeket.", - "font_size_hint": "Tegye nagyobbá vagy kisebbé mindent a panelen. Az ezen az eszközön lévő fiókjára vonatkozik." + "font_size_hint": "Tegye nagyobbá vagy kisebbé mindent a panelen. Az ezen az eszközön lévő fiókjára vonatkozik.", + "import_history_description": "Volt már okoskonnektora a WashData előtt? Töltse fel a teljesítményérzékelő előzmény-exportját, vagy olvassa be közvetlenül a Home Assistantból: a szokásos észlelés lefut rajta, így a régi ciklusok megjelennek a Ciklusok listában, készen az elnevezésre.", + "hist_input_hint": "Töltsön fel az Előzmények panelről letöltött CSV-t (entity, state, last changed), vagy hagyja, hogy a WashData közvetlenül beolvassa az érzékelő előzményeit. Ezután pontosan ugyanaz az észlelés fut le rajta, mint élőben, és Ön választja ki, mely megtalált ciklusokat tartja meg.", + "hist_recorder_hint": "A kiválasztott dátumtól a mostani időpontig olvas be. A Home Assistant alapértelmezés szerint 10 napig tart meg részletes előzményeket, utána már csak óránkénti átlagokat, amelyek túl durvák a ciklusok észleléséhez - csak akkor válasszon korábbi dátumot, ha a recorder többet tart meg.", + "hist_scanning": "Az előzményeket újra lejátsszuk az észlelőn keresztül. Ez a háttérben fut - bezárhatja ezt a párbeszédpanelt, és később visszatérhet hozzá.", + "hist_imported_count": "{n} ciklus importálva.", + "hist_duplicates": "{n} korábban már importálva volt, ezért kimaradt.", + "hist_capped": "Elérte az eszközre vonatkozó importált ciklus korlátot; a többi nem lett eltárolva.", + "hist_next_step": "A Ciklusok listában találhatók, importált előzményként megjelölve. Nyisson meg egyet, és a Címkézés funkcióval adja meg a hozzá tartozó programot.", + "hist_rows_read": "{n} mérés beolvasva", + "hist_entity_substituted": "{used} beolvasva (ehhez az eszközhöz {wanted} van beállítva)", + "hist_breaks": "{n} szakasz, ahol az érzékelő nem volt elérhető", + "hist_other_entity": "{n} más entitáshoz tartozó mérés kihagyva", + "hist_skipped_spans": "Kihagyott szakaszok", + "hist_settings_used": "Az eszköz jelenlegi beállításaival észlelve (minimális teljesítmény {w} W, kikapcsolási késleltetés {s} s).", + "hist_none_found": "Az adott előzményekben nem sikerült ciklust észlelni.", + "hist_found": "{n} ciklus található. Vegye ki a jelölést mindenről, ami nem tűnik valódi futásnak - importálásig semmi nem kerül eltárolásra.", + "hist_scan_capped": "Csak az első találatok láthatók (összesen {n} volt).", + "hist_recorder_empty": "A Home Assistantnak nincs részletes előzménye erről az érzékelőről abban az időszakban.", + "hist_scan_failed": "A vizsgálat nem sikerült.", + "hist_scan_expired": "Ez a vizsgálat már nem elérhető. Kérjük, futtassa újra.", + "hist_import_failed": "Az importálás nem sikerült.", + "imported_history_readonly": "Importált teljesítményelőzményekben észlelve. Befolyásolja a programegyeztetést, de nem számít bele a statisztikákba, és nem vágható vagy osztható. Címkézze fel a hozzá tartozó program megadásához." }, "phase_desc": { "anti_crease": "Alkalmankénti rövid bukdácsolás a befejezés után a ráncok csökkentése érdekében.", @@ -1392,6 +1449,10 @@ "doc": "Tárolja a teljes teljesítmény nyomkövetést és a megfelelő hibakeresési adatokat minden ciklushoz. Hasznos hibaelhárításhoz, de növeli a tárhely méretét.", "label": "Hibakeresési nyomok mentése" }, + "smart_termination_duration_ratio": { + "doc": "Mennyire kell előrehaladnia a ciklusnak az egyeztetett program várható időtartamán belül, mielőtt az intelligens leállítás a teljesítmény csökkenésekor korábban befejezheti azt. A várható időtartam a program átlaga, így azokon a készülékeken, amelyek futásideje sokat ingadozik - mosógépek hideg téli és meleg nyári befolyó vízzel, szárazságérzékelős szárítógépek, terheléstől függő programok - az összes futás nagyjából fele ennél az átlagnál rövidebben fejeződik be, és soha nem kapja meg a gyors befejezést, csak a tartalék időtúllépésen keresztül ér véget, percekkel később. Csökkentse ezt (pl. 0.85) ezeken a gépeken, hogy a korai befejezés így is elinduljon; emelje 1.0 felé az óvatosabb működéshez. Hagyja üresen az alapértelmezett értékhez (0.98, mosogatógépeknél 0.99). Csak korábban fejezhet be egy ciklust, soha nem később, és soha nem indul el bizonytalan vagy alacsony megbízhatóságú egyezésnél.", + "label": "Intelligens leállítási arány" + }, "smoothing_window": { "doc": "Mennyire van simítva a nyers teljesítményjel. Alacsony (2) érzékeny, de zajos; magas (5) kisimítja a tüskéket, de növeli a késést.", "label": "Simítási ablak" @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Ajtó-nyitásvégi várakozási idő", "doc": "Mennyi ideig kell nyitva maradnia az ajtónak, mielőtt a WashData befejezi a ciklust, amikor az \"Ajtó automatikusan kinyílik a végén\" be van kapcsolva. Elég hosszú ahhoz, hogy figyelmen kívül hagyja a gyors edény betöltését (alapértelmezés: 60 s), elég rövid ahhoz, hogy gyorsan befejezzen, miután a gép kinyitja az ajtót." + }, + "profile_evidence_sources": { + "label": "A programot alakító ciklusok", + "doc": "Mely ciklusok szolgálnak az egyes programok teljesítménygörbéjének felépítéséhez, és egy befejezett ciklus ahhoz való egyeztetéséhez. Ha egy fajta jelölését kikapcsolja, az többé nem alakítja a programjait, de semmi nem törlődik - a ciklusok a Ciklusok listában maradnak, és továbbra is címkézhetők vagy eltávolíthatók. Akkor hasznos, ha nem bízik az importált adatokban. A statisztikákra nincs hatással: azok mindig csak azokat a ciklusokat számolják, amelyeket ez a gép valóban lefuttatott. Ha mindent kikapcsol, azt figyelmen kívül hagyjuk, mert egy ciklusok nélküli program soha nem tudna egyezni." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Alapkonfiguráció" + }, + "profile_evidence": { + "label": "Profil alapja" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Vágás…", "splitting": "Felosztás…", "deleting": "Törlés…", - "imported": "Importált" + "imported": "Importált", + "preparing": "Előkészítés…" }, "tab": { "advanced": "Fejlett", @@ -1659,6 +1728,7 @@ "wrong_profile": "Rossz profil" }, "toast": { + "catalog_refreshed": "A közösségi katalógus frissítve", "access_saved": "A hozzáférés-szabályozás mentve", "all_wiped": "Minden adat törölve", "analysis_complete_none": "Az elemzés kész: nincs új javaslat", @@ -1764,7 +1834,9 @@ "store_download_failed": "Letöltés sikertelen: {error}", "store_download_nothing": "Nincs új letöltendő - ez a beállítás már megvan az eszközön.", "export_selective_done": "Export letöltve", - "import_selective_done": "{profiles} profil és {cycles} ciklus importálva" + "import_selective_done": "{profiles} profil és {cycles} ciklus importálva", + "hist_csv_required": "Először töltsön be egy CSV-fájlt, vagy illessze be a tartalmát", + "file_read_failed": "A fájl nem olvasható" }, "suggestion": { "both_agree": "A WashData javasolja", @@ -1860,7 +1932,7 @@ "thr_batch": "Éppen a p05 legkisebb aktív teljesítmény fölött tartva {cycles} cikluson át ({p05}W), hogy az indítás a lehető legkorábban észlelhető legyen, a leállítási küszöb pedig a gép legkisebb üzemi teljesítménye alatt maradjon.", "tol_per_profile": "A profilonkénti időtartam-szórás p75 értéke {profiles} profilon át ({cycles} ciklus); a stabil profilok nem kapnak büntetést.", "tol_pooled": "{cycles} legutóbbi címkézett ciklus összevont időtartam-szórása alapján (p95 szórás={dev}).", - "watchdog": "A biztonságosan lehető legalacsonyabbra állítva (épp a p95 frissítési szünet, azaz {p95}s felett, min. 30s), hogy az elakadások gyorsan észlelhetők legyenek téves leállítások nélkül." + "watchdog": "A biztonságosan lehető legalacsonyabbra állítva (épp a p95 frissítési szünet, azaz {p95}s felett, és legalább a mintavételezési intervallum ({median}s) kétszerese, min. 30s), hogy az elakadások gyorsan észlelhetők legyenek téves leállítások nélkül." }, "exclusions": { "summary": "{total} tévesen észlelt ciklus kizárva: {parts}.", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Mosogatógép: csendes másodpercek a várható időtartam után, mielőtt a ciklus végi leeresztésre való várakozás feloldódik", + "smart_termination_duration_ratio": "Az egyeztetett program várható időtartamának hányada, amelyet a ciklusnak el kell érnie, mielőtt az intelligens leállítás korábban befejezheti; csökkentse a terheléstől vagy hőmérséklettől függő gépeknél", "anti_wrinkle_enabled": "Nyelje el a fő fázis utáni szárítópulzusokat, ahelyett hogy új ciklusként olvasná őket", "anti_wrinkle_exit_power": "A ránctalanítás aktívan tartásához a teljesítménynek az impulzusok között ez alá kell csökkennie", "anti_wrinkle_idle_timeout": "Két szárítópulzus között engedélyezett csendes idő, mielőtt a ránctalanítás véget ér", @@ -1955,6 +2028,10 @@ "finished": "A ciklus elérte a végállapotot és befejeződött." }, "store": { + "your_model_tip": "Ez az a készülék, amelyet a Beállításokban megadott", + "your_model": "Az Öné", + "search_brand_ph": "Keresés márka szerint…", + "programs_count": "Programok: {n}", "browse": "Böngészés", "device": "Készülék", "favorites": "Kedvencek", diff --git a/custom_components/ha_washdata/translations/panel/is.json b/custom_components/ha_washdata/translations/panel/is.json index ccbe80dc..2fccb597 100644 --- a/custom_components/ha_washdata/translations/panel/is.json +++ b/custom_components/ha_washdata/translations/panel/is.json @@ -78,9 +78,12 @@ "awaiting": "Bíður samþykkis", "imported_tip": "Flutt inn úr samfélagsversluninni. Aðeins notað til samsvörunar, ekki talið með í tölfræði.", "not_importable": "ekki hér", - "exists": "til staðar" + "exists": "til staðar", + "backfilled_tip": "Greint í innfluttri aflsögu. Hefur aðeins áhrif á kerfissamsvörun, telst ekki með í tölfræði." }, "btn": { + "set_brand_model": "Stilla vörumerki og gerð", + "refresh_catalog": "Endurnýja skrána", "add_device": "+ Bæta við tæki", "add_device_tip": "Bættu við öðru WashData tæki", "add_maintenance": "Bæta við viðhaldsatburði", @@ -90,7 +93,7 @@ "apply_label": "Notaðu merki", "apply_set_b": "Beita stillingum B", "apply_split": "Notaðu Split", - "apply_trim": "Notaðu Trim", + "apply_trim": "Beita klippingu", "auto_detect_split": "Sjálfvirk skynjun", "auto_label_cycles": "Sjálfvirk merkingarlotur", "auto_label_cycles_tip": "Úthlutaðu sjálfkrafa prófílnöfnum á ómerktar lotur þar sem samsvörunaröryggi nær þröskuldinum", @@ -133,7 +136,7 @@ "new_group_tip": "Flokkaðu næstum eins snið (söm lögun/lengd, mismunandi hitastig eða snúningur) svo samsvörun velji áreiðanlega á milli þeirra", "new_phase": "+ Nýr áfangi", "new_profile": "+ Nýtt snið", - "new_profile_tip": "Búðu til nýjan forritaprófíl úr núverandi merktri lotu eða upptöku", + "new_profile_tip": "Búðu til nýjan prófíl fyrir kerfi úr núverandi merktri lotu eða upptöku", "on_cycle_finished": "Á hringrás lokið", "on_cycle_started": "Á hringrás byrjaði", "pause_cycle": "Gera hlé", @@ -209,8 +212,8 @@ "stop": "Stöðva", "submit_correction": "Sendu leiðréttingu", "train_now": "Þjálfa núna", - "trim": "Snyrta", - "trim_split": "Snyrta / Kljúfa", + "trim": "Klippa til", + "trim_split": "Klippa til / Skipta", "undo": "Afturkalla", "use": "Notaðu", "wipe_all": "Þurrkaðu öll gögn", @@ -241,7 +244,12 @@ "import_selected": "Flytja inn valið", "back": "Til baka", "mute_suggestion": "Hætta að stinga upp á þessari stillingu", - "reset_muted": "Endurstilla þaggaðar tillögur" + "reset_muted": "Endurstilla þaggaðar tillögur", + "import_power_history": "Flytja inn aflsögu", + "hist_read_recorder": "Lesa úr Home Assistant", + "hist_scan": "Leita að lotum", + "hist_import_n": "Flytja inn {n} lotur", + "hist_goto_cycles": "Sýndu mér loturnar" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Verður að vera undir Hám. hrukkuleys-afli ({max} W)" }, "attn_sub": "Lagaðu árekstra áður en þú vistar", - "attn_title": "{n} stillingarárekstur{s}", + "attn_title": "Árekstrar í stillingum: {n}", "confidence": { "auto": "Verður að vera við eða yfir Samsvarþröskuldi ({match})", - "learning": "Verður að vera við eða undir Samsvarþröskuldi ({match})", + "learning": "Verður að vera við eða yfir Samsvarþröskuldi ({match})", "match_for_auto": "Verður að vera við eða undir Sjálfvirkri merkingaöryggi ({alc})", - "match_for_learning": "Verður að vera við eða yfir Námsöryggi ({lc})" + "match_for_learning": "Verður að vera við eða undir Námsöryggi ({lc})" }, "duration_ratio": { "max": "Verður að vera meira en Lágm. lengdarhlutfall ({min})", @@ -296,14 +304,14 @@ "match": "Verður að vera yfir Ósamsvarþröskuldi ({un})", "unmatch": "Verður að vera undir Samsvarþröskuldi ({match}); annars er staðfest samsvörun afturkölluð samstundis" }, - "cascade_toast": "Einnig var {n} stilling{s} leiðrétt til samræmis.", + "cascade_toast": "Aðrar stillingar leiðréttar til samræmis: {n}", "suggestion_resolves": "Notaðu tillöguna hér að neðan ({val}) til að laga þetta", "use_fix": "Nota {val}", "watchdog": { "interval": "Ætti að vera að minnsta kosti 2x Sýnitakabil ({si} s)", "sampling": "Sýnitakabil ætti að vera í mesta lagi helmingur Gæsluabils ({wi} s)" }, - "settings_banner": "{n} stillingarárekstur{s} – skoðaðu auðkenndu hlutana og lagaðu þá áður en þú vistar.", + "settings_banner": "Árekstrar í stillingum: {n}. Skoðaðu auðkenndu hlutana og lagaðu þá áður en þú vistar.", "settings_banner_btn": "Fara á fyrsta" }, "hdr": { @@ -331,7 +339,7 @@ "manual_recording": "Handvirk upptaka", "manual_recording_tip": "Keyrðu lotu af ásettu ráði á meðan WashData skráir aflsporið. Smelltu á Byrjaðu upptöku rétt áður en tækið ræsist, Hættu þegar því lýkur, og síðan Ferli til að vista það sem nafngreint snið.", "ml_learned": "Það sem WashData hefur lært", - "ml_matching_tuning": "Fínstilling í samræmi við forrit", + "ml_matching_tuning": "Fínstilling kerfissamsvörunar", "ml_settings_card": "Stillingar", "ml_smart_learning": "Snjallt nám", "my_preferences": "Mínar óskir", @@ -356,7 +364,8 @@ "pg_outcome": "Niðurstaða hermunar", "pg_across_cycles": "Yfir lotur þínar", "community_store": "Samfélagsverslun", - "online_account": "Samfélagsverslun og nettengdir eiginleikar" + "online_account": "Samfélagsverslun og nettengdir eiginleikar", + "import_power_history": "Flytja inn aflsögu" }, "health": { "fair": "Viðunanlegt prófíl", @@ -364,6 +373,7 @@ "poor": "⚠ Léleg samsvörun" }, "lbl": { + "drag_to_resize": "Dragðu til að breyta stærð", "actions": "Aðgerðir", "activity": "Virkni", "administrators": "Stjórnendur", @@ -434,7 +444,7 @@ "from": "Frá", "gap_s": "Bil (s)", "group_name": "Nafn flokks", - "head_trim": "Snyrta upphaf (s)", + "head_trim": "Klippt framan af (s)", "health": "Heilsa", "hide_tabs": "Fela flipa fyrir ekki stjórnendur", "in_use": "Í notkun", @@ -450,7 +460,7 @@ "metric": "Mæligildi", "mode_existing_profile": "Bæta við núverandi prófíl", "mode_new_profile": "Búðu til nýjan prófíl", - "models_fine_tuned": "({count} módel{plural} fínstillt)", + "models_fine_tuned": "(fínstillt líkön: {count})", "n_classic_suggestions": "{n} klassískt", "n_ml_suggestions": "{n} ML", "n_selected": "{n} valin", @@ -489,11 +499,11 @@ "pg_poor_match": "Léleg samsvörun", "pg_strong_match": "Sterk samsvörun", "pg_tile_ambiguous": "Óljóst", - "pg_tile_ambiguous_tip": "Lotur þar sem tvö forrit eru næstum jöfn", + "pg_tile_ambiguous_tip": "Lotur þar sem tvö kerfi eru næstum jöfn", "pg_tile_detected": "Greint", "pg_tile_detected_tip": "Lotur þar sem afl fór yfir upphafsþröskuldinn nógu lengi til að skrást sem í gangi", "pg_tile_matched": "Samsvarað", - "pg_tile_matched_tip": "Lotur sem eru rétt tengdar við þekkt forrit", + "pg_tile_matched_tip": "Lotur sem eru rétt tengdar við þekkt kerfi", "pg_time_left": "Tími eftir", "pg_weak_match": "Veik samsvörun", "phase_name": "Nafn", @@ -502,8 +512,8 @@ "profile": "Prófíll", "profile_index": "sniðnúmer →", "profile_name": "Nafn prófíls", - "program": "Forrit", - "program_tip": "Hnekktu því sniði sem er samsvarað við núverandi hringrás. Sjálfvirk greining lætur samþættinguna velja bestu samsvörun sjálfkrafa. Festtu ákveðið forrit til að þvinga samsvörun þegar sjálfvirk greining er röng eða þú veist hvað er að keyra.", + "program": "Kerfi", + "program_tip": "Hnekktu því sniði sem samsvarar núverandi lotu. Sjálfvirk greining lætur samþættinguna velja bestu samsvörun sjálfkrafa. Festu ákveðið kerfi til að þvinga samsvörun þegar sjálfvirk greining er röng eða þú veist hvað er í gangi.", "progress": "Framfarir", "raw_socket": "Hrár tengi", "read_only": "Eingöngu lesin", @@ -537,7 +547,7 @@ "stage3": "Þrep 3 – DTW", "stage4": "Þrep 4 – samræmi", "status": "Staða", - "tail_trim": "Snyrta enda (s)", + "tail_trim": "Klippt aftan af (s)", "timer_auto_pause": "Sjálfvirk hlé", "timer_min": "mín", "timer_msg_placeholder": "Skilaboð (valfrjálst, {device}/{program}/{minutes})", @@ -652,7 +662,7 @@ "task_trim": "Klippi lotu til", "task_merge": "Sameina lotur", "task_rebuild": "Endurreikna aflhjúpa", - "cat_profiles": "Prófílar (forrit)", + "cat_profiles": "Prófílar (kerfi)", "cat_real_cycles": "Lotur (keyrslusaga)", "cat_reference_cycles": "Viðmiðunarlotur (innfluttar)", "cat_custom_phases": "Sérsniðnir fasar", @@ -677,7 +687,26 @@ "conflict_keep_mine": "Halda mínum", "conflict_overwrite": "Skrifa yfir", "pg_anti_wrinkle": "Hrukkuleysi", - "font_size": "Leturstærð glugga" + "font_size": "Leturstærð glugga", + "hist_csv_data": "CSV-gögn", + "hist_from_recorder": "Eða lestu hana úr Home Assistant", + "hist_since": "Síðan", + "days": "dagar", + "hist_keep": "Halda þessari lotu", + "hist_looks_complete": "fullgerð", + "peak_power_short": "Toppur", + "shape": "Lögun", + "hist_skip_idle": "ekkert í gangi", + "hist_skip_sparse": "of langt á milli mælinga", + "hist_skip_short": "of fáar mælingar", + "hist_skip_long": "ekkert hlé nógu langt til að skipta á", + "hist_reason_short": "styttri en stysta raunverulega lota þessa heimilistækis", + "hist_reason_no_end": "lauk aldrei með eðlilegum hætti", + "task_history_import": "Skanna aflsögu", + "task_history_import_apply": "Flyt inn lotur", + "evidence_real_cycles": "Lotur sem þessi vél hefur keyrt", + "evidence_reference_cycles": "Hlaðnar niður úr samfélagsversluninni", + "evidence_backfill_cycles": "Fundnar í innfluttri aflsögu" }, "log": { "all_levels": "Öll stig", @@ -756,9 +785,15 @@ "store_share": "Deila í samfélagsverslun", "store_share_device": "Deila þessu tæki", "export_select": "Útflutningur - velja gögn", - "import_wizard": "Innflutningur - velja gögn" + "import_wizard": "Innflutningur - velja gögn", + "history_import": "Flytja inn aflsögu" }, "msg": { + "tail_trim_hint": "Fjarlægðu þennan fjölda sekúndna frá lokum", + "store_sibling_hint": "Er engu deilt fyrir nákvæmlega þína gerð? Náskyld gerð frá sama vörumerki er oftast góður upphafspunktur.", + "store_declare_appliance": "Segðu WashData hvaða tæki þú átt, þá sýnir þessi flipi þær uppsetningar sem aðrir hafa deilt fyrir það. Þú getur líka slegið inn vörumerki hér fyrir ofan til að skoða þig um.", + "refresh_catalog_hint": "Listar samfélagsins yfir vörumerki og tæki eru vistaðir í skyndiminni til að samfélagsverslunin haldi sig innan daglegra marka. Endurnýjaðu til að fá færslur sem aðrir hafa bætt við eða samþykkt.", + "head_trim_hint": "Fjarlægðu þennan fjölda sekúndna frá byrjun", "appliance_monitor": "Vöktunartæki", "artifact_dip_detail": "Féll undir venjulegt aflband í ~{n}s.", "artifact_footer": "Auðkenndur á grafinu hér að ofan. Þetta eru skammvinnir gripir (t.d. hurðin opnuð í miðjum lotu), ekki endilega vandamál.", @@ -769,7 +804,7 @@ "automations_intro": "WashData sendir {start} / {end} atburði og birtir einingar, þannig að tilkynningar og aðgerðir eru best smíðaðar sem venjulegar Home Assistant sjálfvirkingar. Sjálfvirkingar sem nota þetta tæki birtast hér að neðan.", "cleanup_intro": "Sérhver merkt hringrás lögð yfir. Merktu við frávik og eyddu til að hreinsa upp prófílinn.", "clear_debug_hint": "Fjarlægðu vistuð villuleitargögn til að losa um pláss.", - "collecting_data": "Safnar gögnum: {need} lota{plural} til viðbótar áður en fínstilling getur hafist ({current}/{min}).", + "collecting_data": "Safnar gögnum. Lotur sem vantar áður en fínstilling getur hafist: {need} ({current}/{min}).", "compare_overlay_profiles": "Yfirlagssnið (dauft)", "compare_profiles_tip": "Leggðu önnur prófílumslög yfir á töfluna hér að ofan til að sjá hver þeirra passar best við þessa lotu.", "compare_selected_cycles": "Valdar lotur (fastar) – sýna / fela", @@ -779,11 +814,11 @@ "cycles_deleted": "{count} lotu(m) eytt", "enough_data": "Nóg gögn til að læra af ({current}/{min} lotur).", "export_description": "Veldu nákvæmlega hvaða prófíla, lotur, stillingar og fleira á að flytja út í JSON, eða greindu skrá og fluttu aðeins inn þá hluta sem þú vilt.", - "feedback_cycles_pending": "{n} lota{s} til yfirferðar", - "feedback_prompt": "Staðfestu að það væri rétt, leiðréttu forritið eða hunsa.", + "feedback_cycles_pending": "Til yfirferðar: {n}", + "feedback_prompt": "Staðfestu að það hafi verið rétt, leiðréttu kerfið eða hunsaðu.", "feedback_relabel_hint": "Að endurmerkja þessa lotu lýkur einnig yfirferðinni.", "filter_by_profile": "Sía eftir prófíl…", - "group_modal_help": "Flokkaðu forrit með sömu lögun sem eru mismunandi hvað varðar hitastig/snúning (lengd getur verið mismunandi). Samsvörun skorar hópinn sem einn frambjóðanda og velur síðan þann sem hentar best. Veldu að minnsta kosti 2; yfirlagið sýnir hversu lík þau eru.", + "group_modal_help": "Flokkaðu kerfi með sömu lögun sem eru mismunandi hvað varðar hitastig/snúning (lengd getur verið mismunandi). Samsvörun gefur hópnum eina einkunn og velur síðan þann meðlim sem passar best. Veldu að minnsta kosti 2; yfirlagið sýnir hversu lík þau eru.", "group_not_cohesive": "Þessir snið eru ekki nógu lík til að hópa á áreiðanlegan hátt, þannig að samsvörun meðhöndlar þá hver fyrir sig þar til þú fjarlægir frávikið eða skiptir hópnum.", "group_preview_hint": "Merktu 2+ meðlimi til að forskoða og bera saman aflferla þeirra.", "import_intro": "Hlaðið út útfluttri skrá eða límdu JSON-hleðslu fyrir neðan.", @@ -799,14 +834,14 @@ "maintenance_load_error": "Ekki tókst að hlaða viðhaldsgögnum: {error}", "maintenance_requires_access": "Viðhald og útflutningur/innflutningur krefjast fulls aðgangs.", "manual_duration_hint": "Meðaltal/vænt hringrásarlengd sniðsins, notuð fyrir tíma sem eftir er. Breyta til að stilla það; ef það er óbreytt heldur núverandi gildi.", - "matching_tuning_intro": "Þegar þú lærir, stillir WashData einnig hversu mikið forritasamsvörun vegur lögun á móti lengd og orku.", + "matching_tuning_intro": "Þegar WashData lærir stillir það einnig hversu mikið kerfissamsvörun vegur lögun á móti lengd og orku.", "merge_intro": "Valdar lotur eru sameinaðar í eina (tímaröð; eyður fyllt með 0 W). Veldu prófílinn sem myndast.", "ml_intro": "WashData er sent með snjöllum gerðum sem vinna úr kassanum.", "ml_learned_intro": "Módel fínstillt að þessari vél.", "ml_loading": "hleður ML…", "ml_settings_intro": "Tveir óháðir rofar: annar notar gerðir á meðan hringrás er í gangi, hinn gerir WashData kleift að fínstilla þær að vélinni þinni með tímanum.", "name_first_program": "Þú ert með nægar lotur – gefðu fyrsta prógramminu nafn til að hefja samsvörun.", - "near_duplicate_cluster": "næstum tvítekinn prófílklasi fannst. Flokkun gerir samsvörun kleift að velja áreiðanlega á milli útlita (t.d. sama forritið við mismunandi hitastig/snúning).", + "near_duplicate_cluster": "næstum tvítekinn prófílklasi fannst. Flokkun gerir samsvörun kleift að velja áreiðanlega á milli þeirra sem líkjast hver öðrum (t.d. sama kerfið við mismunandi hitastig/snúning).", "no_cycles_match": "Engar lotur passa við núverandi síu.", "no_cycles_profile": "Engar lotur fyrir þetta snið.", "no_cycles_selected": "Veldu að minnsta kosti eina lotu fyrst.", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "aðgerðalaus hermun hefst hér", "pg_sweep_intro": "Hvað ef {param} væri öðruvísi? Prófaðu {steps} gildi yfir síðustu {cycles} loturnar þínar til að finna stillinguna þar sem flestar lotur samsvarast rétt.", "pg_sweep_step": "Skref {done} / {total}", - "pg_undetected": "{n} lota{s} ógreind", "pg_verdict_bad": "Þarfnast athygli: margar lotur greinast ekki.", "pg_verdict_good": "Vel stillt: flestar lotur eru rétt greindar og samsvaraðar.", "pg_verdict_ok": "Ásættanlegt: sumar lotur misstust. Prófaðu að lækka upphafsþröskuldinn.", @@ -870,7 +904,7 @@ "process_history_hint": "Keyra samsvörun aftur á öllum geymdum lotum, uppfæra fínstillingartillögur, þjálfa ML módel aftur (ef virkt) og endurreikna heilsu lotu. Keyrðu þetta eftir hóp yfirfarna.", "profile_deleted": "Sniði eytt", "profile_poor_health_detail": "Hringrásir sem eru úthlutaðar á þetta snið hafa ósamræmda lögun eða lítið öryggi. Íhugaðu að endurbyggja umslagið eða endurskoða merktar lotur.", - "profiles_intro": "Smelltu á prófíl fyrir tölfræði, áfanga og hreinsun. Flokkaðu næstum eins forrit (sama lögun/lengd, mismunandi hitastig eða snúningur) þannig að þú velur á áreiðanlegan hátt á milli þeirra.", + "profiles_intro": "Smelltu á prófíl fyrir tölfræði, áfanga og hreinsun. Flokkaðu næstum eins kerfi (sama lögun/lengd, mismunandi hitastig eða snúningur) þannig að samsvörun velji áreiðanlega á milli þeirra.", "rbac_hint": "Þegar slökkt er á því hefur hver Home Assistant notandi fullan aðgang (sjálfgefið). Stjórnendur hafa alltaf fullan aðgang og geta stjórnað öllum.", "recent_logs": "Nýlegar ha_washdata færslur", "recording_in_progress": "Upptaka í gangi", @@ -882,11 +916,12 @@ "review_in_settings": "Skoðaðu í stillingum", "review_notes_placeholder": "Skýringar (valfrjálst)", "review_notes_tip": "Skýringar með frjálsum texta til eigin viðmiðunar. Ekki notað við samsvörun eða þjálfun.", - "review_profile_tip": "Forritið sem þessi lota er merkt sem. Ef sjálfvirkt greind forrit var rangt skaltu leiðrétta það hér - merking kennir samsvörun fyrir framtíðarlotur.", - "review_quality_tip": "Hversu hreinn þessi hringrás er. Gott = kennslubókardæmi um þetta forrit; Slæmt = greinist en hávær eða óhefðbundin; Ónothæft = rangt greint (sameinað, stytt eða falsað). Keyrir heilsustigið og hvaða lotur eru leyfðar til að þjálfa líkanið.", - "review_recorded_tip": "Merktu þetta sem handvalna viðmiðunarlotu fyrir forritið sitt - sama hlutverk og handvirkt skráð lota. Viðmiðunarlotum er alltaf haldið, sáð samsvarandi sniðmát og er aldrei sleppt við hreinsun. (Þetta er „gyllti“/skráður fáninn; báðir eru það sama.)", + "review_profile_tip": "Kerfið sem þessi lota er merkt sem. Ef sjálfvirkt greint kerfi var rangt skaltu leiðrétta það hér - merking kennir samsvöruninni fyrir framtíðarlotur.", + "review_quality_tip": "Hversu hrein þessi lota er. Gott = kennslubókardæmi um þetta kerfi; Slæmt = greinist en hávaðasöm eða óhefðbundin; Ónothæft = rangt greint (sameinað, stytt eða falsað). Stýrir heilsustiginu og því hvaða lotur má nota til að þjálfa líkanið.", + "review_recorded_tip": "Merktu þetta sem handvalda viðmiðunarlotu fyrir kerfið sem hún tilheyrir - sama hlutverk og handvirkt skráð lota. Viðmiðunarlotum er alltaf haldið, þær móta samsvörunarsniðmátið og þeim er aldrei sleppt við hreinsun. (Þetta er „gyllti“/skráði fáninn; hvort tveggja er það sama.)", "review_tags_tip": "Valfrjálsir fánar sem lýsa því sem fór úrskeiðis við þessa lotu, svo þjálfun og hreinsun getur gert grein fyrir því.", "review_to_cycles": "Opnaðu Cycles review biðröðina", + "samples_decimated": "Sýnir {shown} af {total} sýnum (grisjað fyrir birtingu; toppum haldið). Breið eyða hér stafar af grisjun, ekki af gögnum sem vantar.", "saving_triggers_reload": "Vistun kallar á endurhleðslu samþættingar. HA einingar gætu sýnt stuttlega sem ófáanlegur.", "search_placeholder": "Leitarstillingar...", "see_recorder": "Sjá upptökugræju hér að neðan", @@ -913,7 +948,7 @@ "toast_name_required": "Nafn er krafist", "toast_no_automation": "Get ekki búið til sjálfvirkni hér", "toast_profile_name_required": "Nafn sniðs er krafist", - "toast_program_set": "Forrit stillt: {program}", + "toast_program_set": "Kerfi stillt: {program}", "toast_rebuild_failed": "Endurgerð mistókst: {error}", "toast_resume_failed": "Haldið áfram mistókst: {error}", "toast_revert_failed": "Endurstilling mistókst: {error}", @@ -924,14 +959,14 @@ "trend_energy_down": "Orka á niðurleið ({pct}%/lotu)", "trend_energy_up": "Orka hækkar ({pct}%/lotu) – nýleg meðaltal {avg}", "trim_destructive_confirm": "Aðeins {pct}% af lotunni verður eftir. Þetta er ekki hægt að afturkalla. Halda áfram?", - "trim_intro": "Dragðu rauðu handföngin eða sláðu inn gildi. Allt fyrir utan gluggann er fjarlægt.", + "trim_intro": "Dragðu rauðu handföngin eða sláðu inn gildi. Allt utan valda tímabilsins er fjarlægt.", "tuning_suggestions_available": "{count} uppástunga að stilla í boði frá athuguðum lotum. Þau birtast við hlið viðkomandi reita.", "unsure_detected_prefix": "WashData er ekki viss um að það hafi fundist", "updated": "Uppfært", "warmup_badge": "Enn að læra ({done}/{needed} lotur)", "warmup_detail": "Þessi prófíll þarfnast {needed} merktra lota áður en sjálfvirk samsvörun hefst. Sérhver staðfest lota hjálpar honum að læra.", "wipe_history_warning": "Eyða öllum lotum og sniðum varanlega. Ekki hægt að afturkalla.", - "compare_overlay_tip": "Leggðu lærð aflsvið prófíla yfir hvert annað til að sjá hvaða forriti hver lota líkist.", + "compare_overlay_tip": "Leggðu lærð aflsvið prófíla yfir hvert annað til að sjá hvaða kerfi hver lota líkist.", "duration_trend_up_tip": "Lengd eykst ({pct}%/lotu)", "duration_trend_down_tip": "Lengd minnkar ({pct}%/lotu)", "energy_trend_up_tip": "Orka eykst ({pct}%/lotu)", @@ -982,34 +1017,57 @@ "connect_to_confirm": "Tengstu í stillingatannhjólinu til að staðfesta eða gefa einkunn.", "adopt_settings_hint": "Skrifar yfir greiningar- og samsvörunarmörk þessa tækis með deildum. Tilkynningar þínar, einingar og orkuverð breytast aldrei.", "include_settings_hint": "Deilir greiningar- og samsvörunarmörkum þessa tækis (ekki tilkynningar, einingar eða orkuverð). Notendur velja sjálfir hvort þeir vilji nota þær.", - "onboard_download": "Nýtt tæki? Taktu yfir tilbúna uppsetningu (forrit, viðmiðunarlotir og fasar) frá öðrum WashData-notanda með sama tæki.", + "onboard_download": "Nýtt tæki? Taktu yfir tilbúna uppsetningu (kerfi, viðmiðunarlotur og fasa) frá öðrum WashData-notanda með sama tæki.", "share_consent": "Ég staðfesti að þessar lotir voru kláraðar eðlilega án truflana", "share_device_none": "Engar deilanlegar lotir enn. Merktu fyrst skráða eða handvalda lotu sem viðmiðunarlotu (⭐) í flipanum Lotir.", "share_guideline_naming": "Gefðu hverjum prófíl nákvæmlega það nafn sem sést á hringtakkanum eða skjánum tækisins (t.d. 'Cotton 40', 'Eco 60').", "share_guideline_quality": "Deildu aðeins lotum sem kláruðust eðlilega -- engar truflanir á miðjum lotu, opnaðar hurðir eða aflbrostir.", "share_guideline_review": "Upphleðslan þín byrjar sem í bið og birtist opinberlega þegar nægilega margir samfélagsmeðlimir staðfesta hana.", "share_guidelines_title": "Áður en þú deilir", - "store_download_device_intro": "Taktu yfir öll deildu forritin og viðmiðunarlotir þeirra á tækið þitt. Þínar eigin skráðar lotir og tölfræði eru ekki fyrir áhrifum.", - "store_share_device_intro": "Hlaðaðu upp {brand} {model} með viðmiðunarlotum sem þú velur. Aðrir með sama tæki geta tekið yfir forritin þín. Færslur eru yfirfarnar áður en þær birtast opinberlega.", + "store_download_device_intro": "Taktu yfir öll deildu kerfin og viðmiðunarlotur þeirra á tækið þitt. Þínar eigin skráðu lotur og tölfræði verða ekki fyrir áhrifum.", + "store_share_device_intro": "Hlaðaðu upp {brand} {model} með viðmiðunarlotunum sem þú velur. Aðrir með sama tæki geta tekið yfir kerfin þín. Færslur eru yfirfarnar áður en þær birtast opinberlega.", "share_profile_no_cycles": "Engar viðmiðunarlotir -- merktu lotu sem ⭐ í flipanum Lotir til að hafa þennan prófíl með", - "advisory_phase_inconsistent": "'{name}' virðist blanda saman ólíkum forritum eða hitastigum - lotur þess hita mislengi. Ef þú skiptir því upp í aðskilin snið (t.d. eftir hitastigi) batnar samsvörun og tímamat.", - "advisory_phase_inconsistent_title": "⚠ Hugsanlega blönduð forrit", - "export_select_intro": "Merktu nákvæmlega við hvað á að hafa með. Að velja prófíla án lotanna þeirra flytur samt út greinanlegt forrit (lærði ferillinn fylgir með).", + "advisory_phase_inconsistent": "'{name}' virðist blanda saman ólíkum kerfum eða hitastigum - lotur þess hita mislengi. Ef þú skiptir því upp í aðskilin snið (t.d. eftir hitastigi) batnar samsvörun og tímamat.", + "advisory_phase_inconsistent_title": "⚠ Hugsanlega blönduð kerfi", + "export_select_intro": "Merktu nákvæmlega við hvað á að hafa með. Að velja prófíla án lotanna þeirra flytur samt út kerfi sem hægt er að samsvara (lærða lögunin fylgir með).", "import_analyze_hint": "Hladdu inn útfluttri skrá (eða límdu inn JSON-ið hennar). WashData greinir hana og sýnir nákvæmlega hvað er hægt að flytja inn áður en nokkuð breytist.", "analyzing": "Greini…", - "device_type_mismatch_warn": "Þessi útflutningur er frá annarri tegund tækis ({src} á móti {local}). Enn er hægt að flytja inn forrit og lotur sem viðmiðunargögn, en tækjabundnar stillingar og innflutningur á raunverulegri sögu eru óvirk.", + "device_type_mismatch_warn": "Þessi útflutningur er frá annarri tegund tækis ({src} á móti {local}). Enn er hægt að flytja inn kerfi og lotur sem viðmiðunargögn, en tækjabundnar stillingar og innflutningur á raunverulegri sögu eru óvirk.", "merge_hint": "Innfluttum atriðum er bætt við; ekkert staðbundið tapast. Nafnaárekstrar eru leystir hér að neðan.", "replace_warn": "Hver valinn flokkur er hreinsaður og settur í staðinn úr skránni. Óvaldir flokkar haldast óbreyttir.", - "dest_reference_hint": "Innfluttar lotur bæta aðeins forritasamsvörun og hafa aldrei áhrif á notkunar-/orkutölfræði.", + "dest_reference_hint": "Innfluttar lotur bæta aðeins kerfissamsvörun og hafa aldrei áhrif á notkunar-/orkutölfræði.", "dest_real_history_hint": "Innfluttar lotur teljast sem eigin saga þessa tækis og fæða orku-/notkunartölfræði. Notaðu þetta til að flytja eitt tæki yfir í nýja uppsetningu.", "sug_muted": "Mun ekki stinga upp á þessari stillingu aftur", "sug_mute_failed": "Gat ekki þaggað tillögu", "sug_unmuted_all": "Þaggaðar tillögur endurstilltar", "n_suggestions_muted": "{count} þaggaðar; sjálfvirki stillirinn leggur ekki til þessar.", - "font_size_hint": "Gerðu allt á þessum glugga stærra eða minna. Gildir fyrir reikning þinn á þessum tæki." + "font_size_hint": "Gerðu allt á þessum glugga stærra eða minna. Gildir fyrir reikning þinn á þessum tæki.", + "import_history_description": "Varstu þegar með snjalltengil áður en þú fékkst WashData? Hlaðdu upp söguútflutningi frá aflmælinum hans, eða láttu lesa hana beint úr Home Assistant, og venjuleg greining fer yfir hana svo eldri lotur birtast í Hringrásir-listanum þínum, tilbúnar til að fá heiti.", + "hist_input_hint": "Hlaðdu upp CSV-skrá sem þú hlóðst niður úr Saga-spjaldinu (eining, staða, síðast breytt), eða láttu WashData lesa sögu mælisins beint. Greiningin fer svo yfir hana alveg eins og í beinni, og þú velur hvaða lotur á að halda af þeim sem finnast.", + "hist_recorder_hint": "Les frá þeirri dagsetningu sem þú velur og fram til nú. Home Assistant heldur nákvæmri sögu í 10 daga sjálfgefið og eftir það aðeins klukkustundarmeðaltölum, sem eru of grófgerð til að greina lotur úr - veldu dagsetningu lengra aftur í tímann aðeins ef recorder er stilltur á að geyma meira.", + "hist_scanning": "Sagan þín er spiluð í gegnum greininguna. Þetta gengur í bakgrunni - þú getur lokað þessum glugga og komið til baka síðar.", + "hist_imported_count": "{n} lotur fluttar inn.", + "hist_duplicates": "{n} voru þegar fluttar inn og var sleppt.", + "hist_capped": "Hámarki innfluttra lota fyrir hvert tæki var náð; það sem eftir stóð var ekki vistað.", + "hist_next_step": "Þær eru í Hringrásir-listanum þínum, merktar sem innflutt saga. Opnaðu eina og notaðu Merki til að gefa kerfinu sem hún tilheyrir heiti.", + "hist_rows_read": "{n} mælingar lesnar", + "hist_entity_substituted": "las {used} (þetta tæki er stillt á {wanted})", + "hist_breaks": "{n} göt þar sem mælirinn var ekki tiltækur", + "hist_other_entity": "{n} mælingum fyrir aðrar einingar var sleppt", + "hist_skipped_spans": "Bil sem var sleppt", + "hist_settings_used": "Greint með núverandi stillingum þessa tækis (lágmarksafl {w} W, slökkviðráð {s} s).", + "hist_none_found": "Engar lotur greindust í þeirri sögu.", + "hist_found": "Fann {n} lotur. Taktu hakið af öllu sem lítur ekki út eins og raunveruleg keyrsla - ekkert er vistað fyrr en þú flytur inn.", + "hist_scan_capped": "Aðeins fyrstu lotutillögurnar eru sýndar ({n} fundust).", + "hist_recorder_empty": "Home Assistant hefur enga nákvæma sögu fyrir þennan mæli á því tímabili.", + "hist_scan_failed": "Skönnun mistókst.", + "hist_scan_expired": "Sú skönnun er ekki lengur tiltæk. Skannaðu aftur.", + "hist_import_failed": "Innflutningur mistókst.", + "imported_history_readonly": "Greint í innfluttri aflsögu. Hún hefur áhrif á kerfissamsvörun en telst ekki með í tölfræðinni þinni og ekki er hægt að klippa hana til eða skipta henni. Merktu hana til að gefa kerfinu heiti." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Uppþvottavél: hljóðlátar sekúndur eftir áætlaða lengd áður en biðin eftir útdælingu í lok lotu er losuð", + "smart_termination_duration_ratio": "Hlutfall af væntanlegri lengd samsvaraða kerfisins sem lota þarf að ná áður en snjöll lokun má ljúka henni snemma; lækkaðu það fyrir álags- eða hitaháðar vélar", "completion_min_seconds": "Stysta keyrsla sem telst raunveruleg lota", "end_repeat_count": "Lágir álestrar í röð áður en lýkur", "interrupted_min_seconds": "Stuttar lotur merktar sem truflaðar", @@ -1154,11 +1212,11 @@ "label": "Hám. hrukkuleys-afl" }, "auto_label_confidence": { - "doc": "Ef leikskor í lok lotu er við eða yfir þessu er forritið merkt sjálfkrafa án staðfestingar. Hækka það til að krefjast meiri vissu fyrir sjálfvirka merkingu; lækka það til að gera sjálfvirkan meira. Virkar í tengslum við Learning Confidence fyrir neðan það.", + "doc": "Ef samsvörunarskorið í lok lotu er við eða yfir þessu er kerfið merkt sjálfkrafa án staðfestingar. Hækkaðu það til að krefjast meiri vissu fyrir sjálfvirka merkingu; lækkaðu það til að auka sjálfvirkni. Virkar í tengslum við „Námsöryggi“ hér fyrir neðan.", "label": "Sjálfvirk merkingaöryggi" }, "auto_maintenance": { - "doc": "Keyrðu næturþrif: endurbyggðu prófílumslög, endurreiknaðu heilsu hringrásarinnar, klipptu kembiforrit og geymdu nýjustu loturnar.", + "doc": "Keyrðu næturþrif: endurbyggðu aflsvið prófíla, endurreiknaðu heilsu lotanna, fjarlægðu kembiferla og geymdu nýjustu loturnar.", "label": "Sjálfvirkt viðhald (næturþrif)" }, "completion_min_seconds": { @@ -1230,7 +1288,7 @@ "label": "Snúa ytri kveikin (kvikna á SLÖKKT)" }, "learning_confidence": { - "doc": "Ef stigaskorið er á milli þessa og sjálfvirkrar merkingar sjálfstrausts, biður endurgjöfstilkynning þig um að staðfesta auðkennda forritið. Fyrir neðan þetta stig er leikurinn of óviss til að komast upp á yfirborðið. Verður að vera undir sjálfvirku merki sjálfstrausts.", + "doc": "Ef samsvörunarskorið fellur á milli þessa og „Sjálfvirk merkingaöryggi“ biður endurgjöfstilkynning þig um að staðfesta kerfið sem var auðkennt. Undir þessu skori er samsvörunin of óviss til að birta. Verður að vera lægra en „Sjálfvirk merkingaöryggi“.", "label": "Námsöryggi" }, "linked_device": { @@ -1394,7 +1452,7 @@ "label": "Lágm. lengdarhlutfall" }, "profile_match_threshold": { - "doc": "Lágmarks líkindisstig (0-1) sem krafist er í lok lotu til að samþykkja forritaauðkenni. Hækka það til að draga úr röngum auðkenningum; lækka það ef forrit vélarinnar þinnar eru ekki samræmd. Sjálfgefið 0.4 er íhaldssamt upphafspunktur.", + "doc": "Lágmarks líkindisstig (0-1) sem krafist er í lok lotu til að samþykkja auðkenningu kerfis. Hækkaðu það til að draga úr röngum auðkenningum; lækkaðu það ef kerfi vélarinnar þinnar fá ekki samsvörun. Sjálfgefið 0.4 er íhaldssamur upphafspunktur.", "label": "Samsvarþröskuldur" }, "profile_unmatch_threshold": { @@ -1421,6 +1479,10 @@ "doc": "Geymdu fulla aflrekningu og samsvarandi villuleitargögn fyrir hverja lotu. Gagnlegt fyrir bilanaleit en eykur geymslustærð.", "label": "Vista villuleitarspor" }, + "smart_termination_duration_ratio": { + "doc": "Hversu langt inn í væntanlega lengd samsvaraða kerfisins lota þarf að vera komin áður en snjöll lokun má ljúka henni snemma þegar aflið fellur. Væntanlega lengdin er meðaltal kerfisins, þannig að á tækjum þar sem keyrslutími er mjög breytilegur - þvottavélar með köldu inntaksvatni að vetri á móti heitu að sumri, þurrkarar með rakaskynjara, álagsháð kerfi - ljúka um það bil helmingur allra keyrslna styttri en það meðaltal og fá aldrei hina hröðu lokun, heldur ljúka fyrst mínútum síðar með vara-tímamörkunum. Lækkaðu þetta gildi (t.d. 0,85) á slíkum vélum svo að snemmbúna lokunin virkist samt; hækkaðu það í átt að 1,0 til að vera varkárari. Skildu eftir autt fyrir sjálfgefið gildi (0,98, eða 0,99 fyrir uppþvottavélar). Hún getur aðeins lokið lotu fyrr, aldrei síðar, og virkist aldrei við óljósa eða óörugga samsvörun.", + "label": "Hlutfall snjallrar lokunar" + }, "smoothing_window": { "doc": "Hversu mikið hráorkumerkið er sléttað. Low (2) er móttækilegur en hávær; hár (5) sléttir toppa en bætir við töf.", "label": "Jöfnunargluggi" @@ -1434,7 +1496,7 @@ "label": "Ræsingaorka" }, "start_threshold_w": { - "doc": "Afl verður að hækka yfir þetta stig til að staðfesta að hringrás hafi hafist. Ef það er stillt of lágt veldur rangræsingum frá biðstöðu; of há og hægt ræsandi forrit (köld fylling) missir. Tillöguvélin setur þetta rétt fyrir ofan lægsta virka afl vélarinnar.", + "doc": "Afl verður að hækka yfir þetta stig til að staðfesta að lota sé hafin. Of lág stilling veldur fölskum upphafsgreiningum frá biðstöðuafli; of há stilling lætur kerfi sem byrja hægt (köld fylling) fara fram hjá. Tillöguvélin setur þetta rétt fyrir ofan lægsta virka afl vélarinnar.", "label": "Ræsingarþröskuldur" }, "stop_threshold_w": { @@ -1502,7 +1564,7 @@ }, "enable_phase_matching": { "label": "Fasaskiptur tími sem eftir er", - "doc": "Skiptu hverri lotu í gangi upp í fasa (upphitun, þvottur, vinda) og áætlaðu tímann sem eftir er fyrir hvern fasa, blandað saman við klassíska matið - snemma í lotunni styðst það við fasaáætlunina og undir lokin við klassíska matið. Þetta sérsníður niðurtalninguna að því hversu lengi vélin þín hitar og gengur í raun, sem er mest áberandi í fyrri helmingi lotu. Slökkt = aðeins klassíska matið. Aðeins birting á tíma sem eftir er verður fyrir áhrifum; forritasamsvörun og lotugreining haldast óbreytt." + "doc": "Skiptu hverri lotu í gangi upp í fasa (upphitun, þvottur, vinda) og áætlaðu tímann sem eftir er fyrir hvern fasa, blandað saman við klassíska matið - snemma í lotunni styðst það við fasaáætlunina og undir lokin við klassíska matið. Þetta sérsníður niðurtalninguna að því hversu lengi vélin þín hitar og gengur í raun, sem er mest áberandi í fyrri helmingi lotu. Slökkt = aðeins klassíska matið. Aðeins birting á tíma sem eftir er verður fyrir áhrifum; kerfissamsvörun og lotugreining haldast óbreytt." }, "keep_min_score": { "label": "Lágm. samsvörunareinkunn", @@ -1510,7 +1572,7 @@ }, "dtw_blend": { "label": "DTW-blandning", - "doc": "Hversu mikið DTW-jöfnunarskorið kemur í stað kjarnaskorinnar frá Þrepi 2. 0 = notaðu aðeins Þreps 2 skor, 1 = notaðu aðeins DTW-skor, 0,5 (sjálfgefið) = jöfn blanda. Hækkið til að treystir meira á tímaröðun þegar forrit hafa svipaðar aflstigseiningar en ólík tímasetningarmynstur." + "doc": "Hversu mikið DTW-jöfnunarskorið kemur í stað kjarnaskorsins frá Þrepi 2. 0 = notaðu aðeins skor Þreps 2, 1 = notaðu aðeins DTW-skor, 0,5 (sjálfgefið) = jöfn blanda. Hækkaðu það til að treysta meira á tímajöfnun þegar kerfi hafa svipuð aflstig en ólík tímamynstur." }, "dtw_ensemble_w": { "label": "DTW-ensemble-blanda", @@ -1551,6 +1613,10 @@ "door_end_dwell_seconds": { "label": "Dvaltími hurðar við lok", "doc": "Hversu lengi hurðin þarf að vera opin áður en WashData lýkur hringnum, þegar \"Hurð opnast sjálfkrafa í lok\" er virkt. Nógu lengi til að hunsa hraða tilbúningu fats (sjálfgefið 60 s), nógu stutt til að ljúka fljótt þegar vél spretter hurðinni upp." + }, + "profile_evidence_sources": { + "label": "Lotur sem móta kerfi", + "doc": "Hvaða lotur eru notaðar til að byggja upp aflferil hvers kerfis og til að finna samsvörun við hann fyrir lokna lotu. Ef hakið er tekið af einni gerð hefur hún ekki lengur áhrif á kerfin þín, án þess að neinu sé eytt - loturnar verða áfram í Hringrásir-listanum þínum og enn má merkja þær eða fjarlægja. Gagnlegt ef þú treystir ekki innfluttum gögnum. Tölfræðin verður óbreytt: hún telur alltaf aðeins þær lotur sem þessi vél hefur í raun keyrt. Sé hakið tekið af öllu er það hunsað, því kerfi sem hefur engar lotur að baki gæti aldrei fundið samsvörun." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "Grunnstillingar" + }, + "profile_evidence": { + "label": "Grunnur sniða" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "Klippi til…", "splitting": "Skipti…", "deleting": "Eyði…", - "imported": "Flutt inn" + "imported": "Flutt inn", + "preparing": "Undirbý…" }, "suggestion": { "both_agree": "WashData mælir með", @@ -1690,7 +1760,7 @@ "lower": "Stutt hlé nægir – lotur á eftir hverri annarri kunna að skiptast" }, "min_power": { - "higher": "Minna næmt fyrir biðstöðuafli – mjög hljóð forrit kunna að gleymast", + "higher": "Minna næmt fyrir biðstöðuafli; mjög hljóðlát kerfi kunna að gleymast", "lower": "Næmara – biðstöðuafl gæti skráðst sem virkni" }, "no_update_active_timeout": { @@ -1711,7 +1781,7 @@ }, "start_threshold_w": { "higher": "Forðast rangar ræsingar vegna stuttra aflhækkinga", - "lower": "Þekkir forrit með lágt afl – gæti kviknaðu af hávaða" + "lower": "Þekkir kerfi með lágt afl; getur kviknað af hávaða" }, "stop_threshold_w": { "higher": "Lýkur lotu hraðar – gæti kviknaður í hlé", @@ -1754,7 +1824,7 @@ "thr_batch": "Haldið rétt yfir lægsta virka afli við p05 yfir {cycles} lotur ({p05}W) svo að ræsing náist eins snemma og hægt er og stöðvunarþröskuldurinn haldist undir lægsta gangafli vélarinnar.", "tol_per_profile": "p75 af dreifni lengdar á hvert snið yfir {profiles} snið ({cycles} lotur); þröngum sniðum er ekki refsað.", "tol_pooled": "Byggt á sameinaðri dreifni lengdar {cycles} nýjustu merktu lota (p95 frávik={dev}).", - "watchdog": "Haldið eins lágu og óhætt er (rétt yfir p95-uppfærslubilinu {p95}s, lágm. 30s) svo að stöðnun greinist fljótt án falskra stöðvana." + "watchdog": "Haldið eins lágu og óhætt er (rétt yfir p95-uppfærslubilinu {p95}s og að minnsta kosti 2x sýnatökubilinu {median}s, lágm. 30s) svo að stöðnun greinist fljótt án falskra stöðvana." }, "exclusions": { "summary": "Útilokaði {total} ranggreindar lotur: {parts}.", @@ -1801,6 +1871,7 @@ "wrong_profile": "Rangt snið" }, "toast": { + "catalog_refreshed": "Samfélagsskráin endurnýjuð", "access_saved": "Aðgangsstýring vistuð", "all_wiped": "Öll gögn þurrkuð út", "analysis_complete_none": "Greiningu lokið: engar nýjar tillögur", @@ -1812,7 +1883,7 @@ "cycle_labelled": "Hringrás merkt", "cycle_paused": "Hlé gert á hringrásinni", "cycle_resumed": "Hringrás hafin aftur", - "cycle_trimmed": "Hringrás klippt", + "cycle_trimmed": "Lota klippt til", "cycles_merged": "Hringrásir sameinaðar", "envelope_rebuilt": "Umslag endurbyggt", "envelopes_rebuilt": "Umslög endurbyggð", @@ -1884,7 +1955,7 @@ "store_name_required": "Sláðu inn heiti prófíls", "store_import_failed": "Innflutningur mistókst: {error}", "store_imported": "Flutt inn í {profile}", - "store_program_required": "Sláðu inn heiti forrits", + "store_program_required": "Sláðu inn heiti kerfis", "store_no_appliance": "Stilltu fyrst vörumerki og gerð tækisins í Stillingum.", "store_share_failed": "Deiling mistókst: {error}", "store_shared": "Deilt í samfélagsversluninni, bíður yfirferðar.", @@ -1896,9 +1967,9 @@ "profile_added": "Prófíl bætt við, bíður samþykkis", "saved_except_conflicts": "Vistað. Leggðu úr hófi merktu árekstrana til að vista afganginn.", "share_device_none_sel": "Veldu að minnsta kosti eina lotu til að deila", - "store_device_downloaded": "{p} forrit, {c} upptöku/upptökur bætt við", - "store_device_downloaded_phases": "{p} forrit, {c} upptöku/upptökur, {ph} fassakort bætt við", - "store_device_downloaded_settings": "{p} forrit, {c} upptöku/upptökur, {ph} fassakort, {s} stilling(ar) bætt við", + "store_device_downloaded": "{p} kerfi, {c} upptöku/upptökur bætt við", + "store_device_downloaded_phases": "{p} kerfi, {c} upptöku/upptökur, {ph} fasakort bætt við", + "store_device_downloaded_settings": "{p} kerfi, {c} upptöku/upptökur, {ph} fasakort, {s} stilling(ar) bætt við", "store_device_shared": "{n} lota/lotir deilt í samfélagsverslunina -- bíður yfirferðar.", "store_device_shared_all_dup": "Allar {n} lota/lotir voru þegar í samfélagsverslunina.", "store_device_shared_partial": "{n} lota/lotir deilt; {failed} tókst ekki að hlaða upp.", @@ -1906,7 +1977,9 @@ "store_download_failed": "Niðurhal mistókst: {error}", "store_download_nothing": "Ekkert nýtt til að hlaða niður -- þessi uppsetning er þegar á tækinu þínu.", "export_selective_done": "Útflutningur sóttur", - "import_selective_done": "Flutti inn {profiles} prófíl(a) og {cycles} lotu(r)" + "import_selective_done": "Flutti inn {profiles} prófíl(a) og {cycles} lotu(r)", + "hist_csv_required": "Hlaðdu fyrst inn CSV-skrá eða límdu innihald hennar", + "file_read_failed": "Gat ekki lesið þá skrá" }, "trend": { "down": "Stefnir niður", @@ -1920,13 +1993,13 @@ "other": "Annað" }, "col": { - "profile_tip": "Nafn samsvarandi forrits. Ómerkt þýðir að enginn prófíll samsvaraði við lok lotu.", + "profile_tip": "Nafn kerfisins sem samsvaraði. Ómerkt þýðir að enginn prófíll samsvaraði við lok lotu.", "status_tip": "Niðurstaða lotu: Lokið (eðlilegur endir), Truflað (skyndilegt aflfall), Þvingað stopp (handvirkt) eða Þarf yfirferð (endurgjöf í bið).", "date_tip": "Dagsetning og tími þegar lotan hófst.", "duration_tip": "Heildarkeyrslutími lotu frá upphafi til enda.", "energy_tip": "Heildarorka sem notuð er (kWh). Reiknuð með því að heilda afl yfir tíma.", "cost_tip": "Orkukostnaður fyrir þessa lotu, fastsettur við lok út frá verðinu sem var í gildi þá (orka x verð á kWh). Stilltu verð undir Stillingar til að fylla það út.", - "confidence_tip": "Samsvörunaröryggi prófíls (0-100%). Hversu vel aflferill lotu samsvaraði auðkenndu forriti.", + "confidence_tip": "Samsvörunaröryggi prófíls (0-100%). Hversu vel aflferill lotu samsvaraði auðkenndu kerfi.", "health_tip": "ML-heilsa lotu (hærra = betra). Smelltu á lotu til að skoða og fara yfir hana.", "flags_tip": "Yfirferðar-, frávik- og upprunaflagg fyrir lotuna. Haltu músarbendlinum yfir tákn til að sjá upplýsingar." }, @@ -1955,15 +2028,19 @@ "finished": "Lotan náði lokastöðu og endaði." }, "store": { + "your_model_tip": "Þetta er tækið sem þú tilgreindir í Stillingum", + "your_model": "Þitt tæki", + "search_brand_ph": "Leita eftir vörumerki…", + "programs_count": "Kerfi: {n}", "browse": "Skoða", "device": "Tæki", "favorites": "Uppáhald", "search_ph": "Leita eftir vörumerki eða gerð…", "no_results": "Engin samsvarandi tæki fundust. Prófaðu aðra leit.", - "n_programs": "{n} forrit", + "n_programs": "{n} kerfi", "n_cycles": "{n} lotur", - "no_programs": "Engin deild forrit fyrir þetta tæki enn.", - "no_cycles": "Engum viðmiðunarlotum deilt fyrir þetta forrit enn.", + "no_programs": "Engum kerfum hefur verið deilt fyrir þetta tæki enn.", + "no_cycles": "Engum viðmiðunarlotum hefur verið deilt fyrir þetta kerfi enn.", "anon": "nafnlaus", "uploaded_by": "Deilt af {name}", "connected_as": "Tengt sem {name}", @@ -1993,6 +2070,7 @@ "add_profile": "Bættu við prófíl fyrir þetta tæki á samfélagssíðunni" }, "task": { + "cancelling": "Hætti við...", "reprocess": { "matching": "Endurvinnsla: samsvara lotur", "golden": "Endurvinnsla: fylli inn viðmiðunarlotur", @@ -2025,27 +2103,27 @@ "healthy_chip": "Uppsetningu lokið" }, "phase0": { - "washer": "WashData greinir nú þegar lotur þínar. Taktu upp fyrstu lotuna til að virkja forritsheiti og tímamat.", + "washer": "WashData greinir nú þegar lotur þínar. Taktu upp fyrstu lotuna til að virkja heiti kerfa og tímamat.", "dishwasher": "WashData fylgist með. Uppþvottavélar hafa flóknar lotur – mjög mælt er með að taka upp fyrstu lotuna. Ef greind lota keyrir of lengi skaltu nota lotu-ritilinn til að snyrta hana áður en hún er vistuð sem snið.", "generic": "WashData fylgist með. Taktu upp eða merktu greinda lotu til að hefja uppbyggingu sniða." }, "phase1a": { - "labelled": "Gott upphaf – fyrsta forritið þitt er vistað. Fyrir hreinustu gögn skaltu íhuga að taka upp næstu lotu með upptökugræjunni." + "labelled": "Gott upphaf - fyrsta kerfið þitt er vistað. Fyrir hreinustu gögn skaltu íhuga að taka upp næstu lotu með upptökugræjunni." }, "phase1b": { - "recorded": "Upptakan þín var vistuð sem {profile_name}. Taktu nú upp eða merktu önnur algeng forrit þín til að byggja upp þekju." + "recorded": "Upptakan þín var vistuð sem {profile_name}. Taktu nú upp eða merktu önnur algeng kerfi þín til að byggja upp þekju." }, "phase1c": { - "verify": "Þú ert með {count} forrit úr samfélaginu. Keyrðu lotu til að staðfesta að WashData þekki hana rétt – samsvörun batnar eftir því sem tækið byggir upp sína eigin sögu." + "verify": "Þú ert með {count} kerfi úr samfélaginu. Keyrðu lotu til að staðfesta að WashData þekki hana rétt - samsvörun batnar eftir því sem tækið byggir upp sína eigin sögu." }, "phase2": { - "cluster": "WashData hefur séð {count} lotur sem samsvara ekki neinu vistuðu forriti – þær líkjast hvor annarri. Viltu búa til nýtt snið fyrir þær?", - "unmatched": "Síðasta lota þín samsvaraði ekki neinu vistuðu forriti. Er þetta nýtt forrit?" + "cluster": "WashData hefur séð {count} lotur sem samsvara ekki neinu vistuðu kerfi - þær líkjast hver annarri. Viltu búa til nýtt snið fyrir þær?", + "unmatched": "Síðasta lota þín samsvaraði ekki neinu vistuðu kerfi. Er þetta nýtt kerfi?" }, "phase3": { "suggestions": "WashData hefur stillingatillögur byggðar á lotuferli þínum – farðu yfir þær til að bæta nákvæmni greiningar.", - "groups": "Sum snið þín líkjast sama forriti við mismunandi hitastig. Skiptu þeim í hóp til að fá betri samsvörun.", - "phases": "Bættu forritsfasum við {profile_name} fyrir nákvæmara mat á tíma sem eftir er." + "groups": "Sum snið þín líkjast sama kerfi við mismunandi hitastig. Settu þau í hóp til að fá betri samsvörun.", + "phases": "Bættu fösum kerfisins við {profile_name} fyrir nákvæmara mat á tíma sem eftir er." }, "phase4": { "healthy": "Þetta tæki er að fullu sett upp ({profile_count} snið)." diff --git a/custom_components/ha_washdata/translations/panel/it.json b/custom_components/ha_washdata/translations/panel/it.json index 2f701345..988460c4 100644 --- a/custom_components/ha_washdata/translations/panel/it.json +++ b/custom_components/ha_washdata/translations/panel/it.json @@ -30,9 +30,12 @@ "awaiting": "In attesa di approvazione", "imported_tip": "Importato dallo store della community. Usato solo per la corrispondenza, non conteggiato nelle statistiche.", "not_importable": "n/d qui", - "exists": "esiste già" + "exists": "esiste già", + "backfilled_tip": "Rilevato in uno storico di potenza importato. Influenza solo la corrispondenza dei programmi, non conteggiato nelle statistiche." }, "btn": { + "set_brand_model": "Imposta marca e modello", + "refresh_catalog": "Aggiorna catalogo", "add_device": "+ Aggiungi dispositivo", "add_device_tip": "Aggiungi un altro dispositivo WashData", "add_maintenance": "Aggiungi evento di manutenzione", @@ -42,7 +45,7 @@ "apply_label": "Applica etichetta", "apply_set_b": "Applica set B", "apply_split": "Applica divisione", - "apply_trim": "Applicare Rifinitura", + "apply_trim": "Applica ritaglio", "auto_detect_split": "Rilevamento automatico", "auto_label_cycles": "Cicli di etichettatura automatica", "auto_label_cycles_tip": "Assegna automaticamente nomi di profilo ai cicli senza etichetta la cui confidenza di corrispondenza supera la soglia", @@ -161,8 +164,8 @@ "stop": "Ferma", "submit_correction": "Invia correzione", "train_now": "Allenati adesso", - "trim": "Ordinare", - "trim_split": "Taglia/Dividi", + "trim": "Ritaglia", + "trim_split": "Ritaglia / Dividi", "undo": "Annulla", "use": "Usa", "wipe_all": "Cancella tutti i dati", @@ -193,7 +196,12 @@ "import_selected": "Importa la selezione", "back": "Indietro", "mute_suggestion": "Non suggerire più questa impostazione", - "reset_muted": "Ripristina i silenziati" + "reset_muted": "Ripristina i silenziati", + "import_power_history": "Importa lo storico di potenza", + "hist_read_recorder": "Leggi da Home Assistant", + "hist_scan": "Cerca cicli", + "hist_import_n": "Importa {n} cicli", + "hist_goto_cycles": "Mostrami i cicli" }, "conflict": { "anti_wrinkle_exit": { @@ -205,14 +213,14 @@ "start": "Deve essere inferiore alla Potenza massima anti-grinze ({max} W)" }, "attn_sub": "Correggi i conflitti prima di salvare", - "attn_title": "{n} conflitto{s} nelle impostazioni", - "settings_banner": "{n} conflitto{s} nelle impostazioni – controlla le sezioni evidenziate e correggile prima di salvare.", + "attn_title": "Conflitti nelle impostazioni: {n}", + "settings_banner": "Conflitti nelle impostazioni: {n}. Controlla le sezioni evidenziate e correggile prima di salvare.", "settings_banner_btn": "Vai al primo", "confidence": { "auto": "Deve essere maggiore o uguale alla Soglia di corrispondenza ({match})", - "learning": "Deve essere minore o uguale alla Soglia di corrispondenza ({match})", + "learning": "Deve essere maggiore o uguale alla Soglia di corrispondenza ({match})", "match_for_auto": "Deve essere minore o uguale alla Confidenza di etichettatura automatica ({alc})", - "match_for_learning": "Deve essere maggiore o uguale alla Confidenza di apprendimento ({lc})" + "match_for_learning": "Deve essere minore o uguale alla Confidenza di apprendimento ({lc})" }, "duration_ratio": { "max": "Deve essere superiore al Rapporto di durata minimo ({min})", @@ -250,7 +258,7 @@ "match": "Deve essere superiore alla Soglia di non corrispondenza ({un})", "unmatch": "Deve essere inferiore alla Soglia di corrispondenza ({match}); altrimenti una corrispondenza confermata viene annullata istantaneamente" }, - "cascade_toast": "Adattate anche {n} impostazione{s} per coerenza.", + "cascade_toast": "Altre impostazioni adattate per coerenza: {n}", "suggestion_resolves": "Applica la proposta in attesa ({val}) in basso per correggere questo", "use_fix": "Usa {val}", "watchdog": { @@ -308,7 +316,8 @@ "pg_outcome": "Esito della simulazione", "pg_across_cycles": "Su tutti i tuoi cicli", "community_store": "Store della community", - "online_account": "Store della community e funzionalità online" + "online_account": "Store della community e funzionalità online", + "import_power_history": "Importa lo storico di potenza" }, "health": { "fair": "Qualità profilo accettabile", @@ -316,6 +325,7 @@ "poor": "⚠ Qualità profilo scarsa" }, "lbl": { + "drag_to_resize": "Trascina per ridimensionare", "actions": "Azioni", "activity": "Attività", "administrators": "Amministratori", @@ -386,7 +396,7 @@ "from": "Da", "gap_s": "Divario/i", "group_name": "Nome del gruppo", - "head_trim": "Taglio della testa", + "head_trim": "Ritaglio iniziale (s)", "health": "Salute", "hide_tabs": "Nascondi le schede per i non amministratori", "in_use": "In uso", @@ -402,7 +412,7 @@ "metric": "Metrica", "mode_existing_profile": "Aggiungi al profilo esistente", "mode_new_profile": "Crea nuovo profilo", - "models_fine_tuned": "({count} modello{plural} ottimizzato)", + "models_fine_tuned": "(modelli ottimizzati: {count})", "n_classic_suggestions": "{n} classico", "n_ml_suggestions": "{n} ML", "n_selected": "{n} selezionati", @@ -484,7 +494,7 @@ "stage3": "Fase 3 – DTW", "stage4": "Fase 4 – concordanza", "status": "Stato", - "tail_trim": "Taglio(i) di coda", + "tail_trim": "Ritaglio finale (s)", "timer_auto_pause": "Pausa automatica", "timer_min": "min", "timer_msg_placeholder": "Messaggio (facoltativo, {device}/{program}/{minutes})", @@ -629,7 +639,26 @@ "conflict_import_copy": "Importa come copia", "conflict_keep_mine": "Mantieni il mio", "conflict_overwrite": "Sovrascrivi", - "font_size": "Dimensione testo del pannello" + "font_size": "Dimensione testo del pannello", + "hist_csv_data": "Dati CSV", + "hist_from_recorder": "Oppure leggilo da Home Assistant", + "hist_since": "Dal", + "days": "giorni", + "hist_keep": "Mantieni questo ciclo", + "hist_looks_complete": "completo", + "peak_power_short": "Picco", + "shape": "Forma", + "hist_skip_idle": "niente in funzione", + "hist_skip_sparse": "letture troppo distanti tra loro", + "hist_skip_short": "troppo poche letture", + "hist_skip_long": "nessuna pausa abbastanza lunga per dividere", + "hist_reason_short": "più corto del ciclo reale più breve di questo elettrodomestico", + "hist_reason_no_end": "non è mai terminato in modo pulito", + "task_history_import": "Scansione dello storico di potenza", + "task_history_import_apply": "Importazione dei cicli", + "evidence_real_cycles": "Cicli eseguiti da questa macchina", + "evidence_reference_cycles": "Scaricati dallo store della community", + "evidence_backfill_cycles": "Trovati in uno storico di potenza importato" }, "log": { "all_levels": "Tutti i livelli", @@ -708,9 +737,15 @@ "store_share": "Condividi nello store della community", "store_share_device": "Condividi questo dispositivo", "export_select": "Esporta - scegli i dati", - "import_wizard": "Importa - scegli i dati" + "import_wizard": "Importa - scegli i dati", + "history_import": "Importa lo storico di potenza" }, "msg": { + "tail_trim_hint": "Numero di secondi da rimuovere dalla fine", + "store_sibling_hint": "Non c'è nulla di condiviso per il tuo modello esatto? Un modello molto simile della stessa marca è di solito un buon punto di partenza.", + "store_declare_appliance": "Indica a WashData quale elettrodomestico possiedi e questa scheda mostrerà le configurazioni che altre persone hanno condiviso per esso. Puoi anche digitare una marca qui sopra per dare un'occhiata.", + "refresh_catalog_hint": "Gli elenchi di marche ed elettrodomestici della community sono memorizzati nella cache per mantenere lo store condiviso entro il suo limite giornaliero. Aggiorna per recuperare le voci aggiunte o approvate da altri utenti.", + "head_trim_hint": "Numero di secondi da rimuovere dall'inizio", "appliance_monitor": "Monitor degli elettrodomestici", "artifact_dip_detail": "È sceso sotto la banda di potenza normale per ~{n}s.", "artifact_footer": "Evidenziato nel grafico sopra. Si tratta di artefatti temporanei (ad esempio la porta aperta a metà ciclo), non necessariamente problemi.", @@ -721,7 +756,7 @@ "automations_intro": "WashData genera gli eventi {start} / {end} ed espone entità, quindi le notifiche e le azioni si costruiscono al meglio come normali automatizzazioni di Home Assistant. Le automatizzazioni che utilizzano questo dispositivo appaiono di seguito.", "cleanup_intro": "Ogni ciclo etichettato è sovrapposto. Spunta i valori anomali ed elimina per ripulire il profilo.", "clear_debug_hint": "Rimuovi i dati di debug archiviati per liberare spazio.", - "collecting_data": "Raccolta dati - ancora {need} ciclo{plural} prima che l'ottimizzazione possa iniziare ({current}/{min}).", + "collecting_data": "Raccolta dati. Cicli ancora necessari prima che l'ottimizzazione possa iniziare: {need} ({current}/{min}).", "compare_overlay_profiles": "Profili sovrapposti (debole)", "compare_profiles_tip": "Sovrapponi altri involucri del profilo sulla tabella sopra per vedere quale si adatta meglio a questo ciclo.", "compare_selected_cycles": "Cicli selezionati (fissi) – mostra/nascondi", @@ -731,7 +766,7 @@ "cycles_deleted": "{count} ciclo/i eliminato/i", "enough_data": "Dati sufficienti per apprendere ({current}/{min} cicli).", "export_description": "Scegli esattamente quali profili, cicli, impostazioni e altro esportare in JSON, oppure analizza un file e importa solo le parti che vuoi.", - "feedback_cycles_pending": "{n} ciclo{s} da revisionare", + "feedback_cycles_pending": "Da revisionare: {n}", "feedback_prompt": "Conferma che fosse corretto, correggi il programma o ignoralo.", "feedback_relabel_hint": "Anche rietichettare questo ciclo lo risolve.", "filter_by_profile": "Filtra per profilo…", @@ -808,7 +843,6 @@ "pg_stress_synthetic": "la simulazione in standby inizia qui", "pg_sweep_intro": "E se {param} fosse diverso? Testa {steps} valori sui tuoi ultimi {cycles} cicli per trovare l'impostazione in cui il maggior numero di cicli viene riconosciuto correttamente.", "pg_sweep_step": "Passaggio {done} / {total}", - "pg_undetected": "{n} ciclo{s} non rilevato", "pg_verdict_bad": "Richiede attenzione: molti cicli non vengono rilevati.", "pg_verdict_good": "Ben regolato: la maggior parte dei cicli viene identificata e riconosciuta correttamente.", "pg_verdict_ok": "Accettabile: alcuni cicli mancati. Prova ad abbassare la soglia di avvio.", @@ -839,6 +873,7 @@ "review_recorded_tip": "Contrassegnalo come ciclo di riferimento selezionato manualmente per il suo programma: lo stesso ruolo di un ciclo registrato manualmente. I cicli di riferimento vengono sempre mantenuti, seminano il modello corrispondente e non vengono mai eliminati dalla pulizia. (Questa è la bandiera \"d'oro\"/registrata; entrambe sono la stessa cosa.)", "review_tags_tip": "Flag facoltativi che descrivono cosa è andato storto in questo ciclo, in modo che l'addestramento e la pulizia possano tenerne conto.", "review_to_cycles": "Apri la coda di revisione dei cicli", + "samples_decimated": "Visualizzazione di {shown} di {total} campioni (ridotti per la visualizzazione; picchi mantenuti). Un ampio spazio vuoto qui è dovuto alla riduzione, non a dati mancanti.", "saving_triggers_reload": "Il salvataggio attiva un ricaricamento dell'integrazione. Le entità HA potrebbero essere visualizzate brevemente come non disponibili.", "search_placeholder": "Cerca impostazioni...", "see_recorder": "Vedi il widget del registratore di seguito", @@ -958,10 +993,33 @@ "sug_mute_failed": "Impossibile silenziare il suggerimento", "sug_unmuted_all": "Suggerimenti silenziati ripristinati", "n_suggestions_muted": "{count} silenziati; il sistema di ottimizzazione automatica non proporrà questi.", - "font_size_hint": "Rendi tutto in questo pannello più grande o più piccolo. Si applica al tuo account su questo dispositivo." + "font_size_hint": "Rendi tutto in questo pannello più grande o più piccolo. Si applica al tuo account su questo dispositivo.", + "import_history_description": "Avevi già una presa intelligente prima di WashData? Carica un'esportazione dello storico del suo sensore di potenza, oppure leggilo direttamente da Home Assistant: il normale rilevamento viene eseguito su quei dati, così i cicli passati compaiono nel tuo elenco Cicli, pronti da nominare.", + "hist_input_hint": "Carica un CSV scaricato dal pannello Cronologia (entità, stato, ultima modifica), oppure lascia che WashData legga direttamente lo storico del sensore. Il rilevamento viene poi eseguito esattamente come in tempo reale e sei tu a scegliere quali dei cicli trovati mantenere.", + "hist_recorder_hint": "Legge dalla data che scegli fino a ora. Home Assistant conserva lo storico dettagliato per 10 giorni per impostazione predefinita e successivamente solo medie orarie, troppo grossolane per rilevare i cicli - scegli una data più indietro nel tempo solo se il recorder di Home Assistant è configurato per conservarne di più.", + "hist_scanning": "Il tuo storico viene rieseguito attraverso il rilevatore. L'operazione avviene in background: puoi chiudere questa finestra e tornarci più tardi.", + "hist_imported_count": "{n} cicli importati.", + "hist_duplicates": "{n} erano già stati importati e sono stati saltati.", + "hist_capped": "È stato raggiunto il limite di cicli importati per dispositivo; il resto non è stato salvato.", + "hist_next_step": "Sono nel tuo elenco Cicli, contrassegnati come storico importato. Aprine uno e usa Assegna etichetta per indicare il programma a cui appartiene.", + "hist_rows_read": "{n} letture lette", + "hist_breaks": "{n} interruzioni in cui il sensore non era disponibile", + "hist_other_entity": "{n} letture di altre entità ignorate", + "hist_entity_substituted": "{used} letto (questo dispositivo è configurato per {wanted})", + "hist_skipped_spans": "Tratti saltati", + "hist_settings_used": "Rilevato con le impostazioni attuali di questo dispositivo (potenza minima {w} W, ritardo spegnimento {s} s).", + "hist_none_found": "Non è stato possibile rilevare alcun ciclo in quello storico.", + "hist_found": "Trovati {n} cicli. Deseleziona tutto ciò che non sembra un ciclo reale: nulla viene salvato finché non importi.", + "hist_scan_capped": "Vengono mostrati solo i primi candidati ({n} trovati in totale).", + "hist_recorder_empty": "Home Assistant non ha uno storico dettagliato per questo sensore in quel periodo.", + "hist_scan_failed": "Scansione non riuscita.", + "hist_scan_expired": "Quella scansione non è più disponibile. Esegui di nuovo la scansione.", + "hist_import_failed": "Importazione non riuscita.", + "imported_history_readonly": "Rilevato in uno storico di potenza importato. Influenza la corrispondenza dei programmi ma non viene conteggiato nelle tue statistiche e non può essere ritagliato né diviso. Assegna un'etichetta per indicare il programma." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Lavastoviglie: secondi di silenzio dopo la durata prevista prima che venga rilasciata l'attesa dello scarico di fine ciclo", + "smart_termination_duration_ratio": "Frazione della durata prevista del programma corrispondente che un ciclo deve raggiungere prima che la Terminazione intelligente possa concluderlo in anticipo; abbassala per le macchine che dipendono dal carico o dalla temperatura", "anti_wrinkle_enabled": "Assorbire gli impulsi di rotazione dopo la fase principale invece di leggerli come nuovi cicli", "anti_wrinkle_exit_power": "La potenza deve scendere sotto questo valore tra gli impulsi affinché la modalità antirughe resti attiva", "anti_wrinkle_idle_timeout": "Tempo di silenzio consentito tra due impulsi di rotazione prima che la modalità antirughe termini", @@ -1373,6 +1431,10 @@ "doc": "Memorizzare la traccia di potenza completa e i dati di debug corrispondenti per ciascun ciclo. Utile per la risoluzione dei problemi ma aumenta le dimensioni dello spazio di archiviazione.", "label": "Salva tracce di debug" }, + "smart_termination_duration_ratio": { + "doc": "Quanto un ciclo deve essere avanzato nella durata prevista del programma corrispondente prima che la Terminazione intelligente possa concluderlo in anticipo una volta che la potenza cala. La durata prevista è la media del programma, quindi sugli apparecchi il cui tempo di funzionamento varia molto - lavatrici a seconda dell'acqua di ingresso fredda d'inverno o tiepida d'estate, asciugatrici con sensore di umidità, programmi che dipendono dal carico - circa la metà dei cicli termina prima di quella media e non ottiene mai la conclusione rapida, terminando solo tramite il timeout di riserva con minuti di ritardo. Abbassa questo valore (ad es. 0,85) su quelle macchine affinché la conclusione anticipata si attivi comunque; alzalo verso 1,0 per essere più prudente. Lascia vuoto per il valore predefinito (0,98, o 0,99 per le lavastoviglie). Può solo concludere un ciclo prima, mai dopo, e non si attiva mai su una corrispondenza ambigua o a bassa affidabilità.", + "label": "Rapporto di terminazione intelligente" + }, "smoothing_window": { "doc": "Quanto viene attenuato il segnale di potenza grezza. Basso (2) è reattivo ma rumoroso; alto (5) attenua i picchi ma aggiunge ritardo.", "label": "Finestra di smussamento" @@ -1503,6 +1565,10 @@ "door_end_dwell_seconds": { "label": "Attesa apertura porta al termine", "doc": "Per quanto tempo lo sportello deve restare aperto prima che WashData termini il ciclo, quando \"Porta si apre automaticamente al termine\" è attiva. Abbastanza lungo da ignorare l'aggiunta rapida di un piatto (predefinito 60 s), abbastanza breve da terminare prontamente quando la macchina apre lo sportello." + }, + "profile_evidence_sources": { + "label": "Cicli che formano un programma", + "doc": "Quali cicli vengono usati per costruire la curva di potenza di ogni programma e per confrontare con essa un ciclo terminato. Deselezionare un tipo fa sì che non modelli più i tuoi programmi senza eliminare nulla - i cicli restano nel tuo elenco Cicli e possono ancora essere etichettati o rimossi. Utile se non ti fidi dei dati importati. Le statistiche non sono influenzate: conteggiano sempre solo i cicli che questa macchina ha effettivamente eseguito. Deselezionare tutto viene ignorato, perché un programma senza cicli alle spalle non potrebbe mai corrispondere." } }, "setting_group": { @@ -1586,6 +1652,9 @@ }, "basic_configuration": { "label": "Configurazione di base" + }, + "profile_evidence": { + "label": "Base dei profili" } }, "status": { @@ -1607,10 +1676,11 @@ "analyzing": "Analisi…", "resetting": "Reimpostazione…", "reverting": "Ripristino…", - "trimming": "Rifinitura…", + "trimming": "Ritaglio…", "splitting": "Divisione…", "deleting": "Eliminazione…", - "imported": "Importato" + "imported": "Importato", + "preparing": "Preparazione…" }, "tab": { "advanced": "Avanzato", @@ -1640,6 +1710,7 @@ "wrong_profile": "Profilo sbagliato" }, "toast": { + "catalog_refreshed": "Catalogo della community aggiornato", "access_saved": "Controllo accessi salvato", "all_wiped": "Tutti i dati cancellati", "analysis_complete_none": "Analisi completata: nessun nuovo suggerimento", @@ -1651,7 +1722,7 @@ "cycle_labelled": "Ciclo etichettato", "cycle_paused": "Ciclo in pausa", "cycle_resumed": "Ciclo ripreso", - "cycle_trimmed": "Ciclo tagliato", + "cycle_trimmed": "Ciclo ritagliato", "cycles_merged": "Cicli uniti", "envelope_rebuilt": "Busta ricostruita", "envelopes_rebuilt": "Buste ricostruite", @@ -1745,7 +1816,9 @@ "store_download_failed": "Download non riuscito: {error}", "store_download_nothing": "Nulla di nuovo da scaricare - questa configurazione è già sul tuo dispositivo.", "export_selective_done": "Esportazione scaricata", - "import_selective_done": "Importati {profiles} profilo/i e {cycles} ciclo/i" + "import_selective_done": "Importati {profiles} profilo/i e {cycles} ciclo/i", + "hist_csv_required": "Carica prima un file CSV o incolla il suo contenuto", + "file_read_failed": "Impossibile leggere quel file" }, "suggestion": { "both_agree": "WashData consiglia", @@ -1841,7 +1914,7 @@ "thr_batch": "Mantenuto appena sopra la potenza attiva più bassa al p05 su {cycles} cicli ({p05}W) affinché un avvio venga rilevato il prima possibile e la soglia di arresto resti sotto la potenza di funzionamento più bassa della macchina.", "tol_per_profile": "p75 della varianza di durata per profilo su {profiles} profili ({cycles} cicli); i profili stretti non vengono penalizzati.", "tol_pooled": "In base alla varianza di durata aggregata di {cycles} cicli etichettati di recente (deviazione p95={dev}).", - "watchdog": "Mantenuto il più basso possibile in sicurezza (poco sopra l'intervallo di aggiornamento p95 di {p95}s, min 30s) per rilevare rapidamente i blocchi senza arresti falsi." + "watchdog": "Mantenuto il più basso possibile in sicurezza (poco sopra l'intervallo di aggiornamento p95 di {p95}s e almeno 2x l'intervallo di campionamento di {median}s, min 30s) per rilevare rapidamente i blocchi senza arresti falsi." }, "exclusions": { "summary": "Esclusi {total} cicli rilevati male: {parts}.", @@ -1907,6 +1980,10 @@ "finished": "Il ciclo ha raggiunto lo stato finale e si è concluso." }, "store": { + "your_model_tip": "Questo è l'elettrodomestico che hai dichiarato nelle Impostazioni", + "your_model": "Il tuo", + "search_brand_ph": "Cerca per marca…", + "programs_count": "Programmi: {n}", "browse": "Sfoglia", "device": "Dispositivo", "favorites": "Preferiti", diff --git a/custom_components/ha_washdata/translations/panel/ja.json b/custom_components/ha_washdata/translations/panel/ja.json index bdf3fea4..7c27d9b6 100644 --- a/custom_components/ha_washdata/translations/panel/ja.json +++ b/custom_components/ha_washdata/translations/panel/ja.json @@ -78,9 +78,12 @@ "awaiting": "承認待ち", "imported_tip": "コミュニティストアからインポートしました。マッチングにのみ使用され、統計には含まれません。", "not_importable": "対象外", - "exists": "既存" + "exists": "既存", + "backfilled_tip": "インポートした電力履歴から検出されました。プログラムのマッチングにのみ影響し、統計には含まれません。" }, "btn": { + "set_brand_model": "ブランドとモデルを設定", + "refresh_catalog": "カタログを更新", "add_device": "+ デバイスを追加", "add_device_tip": "別の WashData デバイスを追加する", "add_maintenance": "メンテナンスイベントを追加", @@ -241,7 +244,12 @@ "import_selected": "選択項目をインポート", "back": "戻る", "mute_suggestion": "この設定の提案を停止", - "reset_muted": "ミュートをリセット" + "reset_muted": "ミュートをリセット", + "import_power_history": "電力履歴をインポート", + "hist_read_recorder": "Home Assistant から読み込む", + "hist_scan": "サイクルを検索", + "hist_import_n": "{n} サイクルをインポート", + "hist_goto_cycles": "サイクルを表示" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "しわ防止最大電力 ({max} W) を下回る必要があります" }, "attn_sub": "保存する前に競合を解消してください", - "attn_title": "{n}件の設定競合{s}", + "attn_title": "設定の競合: {n}", "confidence": { "auto": "マッチしきい値 ({match}) 以上である必要があります", - "learning": "マッチしきい値 ({match}) 以下である必要があります", + "learning": "マッチしきい値 ({match}) 以上である必要があります", "match_for_auto": "自動ラベル信頼度 ({alc}) 以下である必要があります", - "match_for_learning": "学習信頼度 ({lc}) 以上である必要があります" + "match_for_learning": "学習信頼度 ({lc}) 以下である必要があります" }, "duration_ratio": { "max": "最小時間比率 ({min}) より大きい必要があります", @@ -296,14 +304,14 @@ "match": "アンマッチしきい値 ({un}) を上回る必要があります", "unmatch": "マッチしきい値 ({match}) を下回る必要があります。そうでないと確定した一致が即座に解除されます" }, - "cascade_toast": "整合性のため、さらに{n}件の設定{s}も自動調整しました。", + "cascade_toast": "整合性のため調整した他の設定: {n}", "suggestion_resolves": "下の保留中の提案({val})を適用してこれを修正してください", "use_fix": "{val} を使用", "watchdog": { "interval": "サンプリング間隔 ({si} 秒) の少なくとも 2 倍である必要があります", "sampling": "サンプリング間隔はウォッチドッグ間隔 ({wi} 秒) の半分以下である必要があります" }, - "settings_banner": "{n} 件の設定競合{s} – 強調表示されたセクションを確認し、保存前に修正してください。", + "settings_banner": "設定の競合: {n}。強調表示されたセクションを確認し、保存前に修正してください。", "settings_banner_btn": "最初へ" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "シミュレーション結果", "pg_across_cycles": "全サイクルにわたって", "community_store": "コミュニティストア", - "online_account": "コミュニティストアとオンライン機能" + "online_account": "コミュニティストアとオンライン機能", + "import_power_history": "電力履歴のインポート" }, "health": { "fair": "プロファイル品質: 普通", @@ -364,6 +373,7 @@ "poor": "⚠ プロファイル品質: 低" }, "lbl": { + "drag_to_resize": "ドラッグしてサイズ変更", "actions": "アクション", "activity": "活動", "administrators": "管理者", @@ -434,7 +444,7 @@ "from": "開始", "gap_s": "ギャップ (秒)", "group_name": "グループ名", - "head_trim": "ヘッドトリム (秒)", + "head_trim": "先頭のトリム (秒)", "health": "健康", "hide_tabs": "管理者以外のタブを非表示にする", "in_use": "使用中", @@ -450,7 +460,7 @@ "metric": "指標", "mode_existing_profile": "既存のプロファイルに追加", "mode_new_profile": "新しいプロファイルの作成", - "models_fine_tuned": "({count}モデル{plural}微調整済み)", + "models_fine_tuned": "(微調整済みモデル: {count})", "n_classic_suggestions": "{n} クラシック", "n_ml_suggestions": "{n} ML", "n_selected": "{n} 件選択中", @@ -532,7 +542,7 @@ "stage3": "ステージ 3 – DTW", "stage4": "ステージ 4 – 一致度", "status": "状態", - "tail_trim": "テールトリム (秒)", + "tail_trim": "末尾のトリム (秒)", "timer_auto_pause": "自動一時停止", "timer_min": "分", "timer_msg_placeholder": "メッセージ (オプション、{device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_import_copy": "コピーとしてインポート", "conflict_keep_mine": "自分のを保持", "conflict_overwrite": "上書き", - "font_size": "パネルのフォントサイズ" + "font_size": "パネルのフォントサイズ", + "hist_csv_data": "CSV データ", + "hist_from_recorder": "または Home Assistant から読み込む", + "hist_since": "開始日", + "days": "日", + "hist_keep": "このサイクルを保持", + "hist_looks_complete": "完全", + "peak_power_short": "ピーク", + "shape": "形状", + "hist_skip_idle": "稼働なし", + "hist_skip_sparse": "計測値の間隔が広すぎる", + "hist_skip_short": "計測値が少なすぎる", + "hist_skip_long": "分割できる十分な中断がない", + "hist_reason_short": "この家電の実際の最短サイクルより短い", + "hist_reason_no_end": "正常に終了していない", + "task_history_import": "電力履歴のスキャン", + "task_history_import_apply": "サイクルのインポート", + "evidence_real_cycles": "この機器が実行したサイクル", + "evidence_reference_cycles": "コミュニティストアからダウンロード", + "evidence_backfill_cycles": "インポートした電力履歴から検出" }, "log": { "all_levels": "すべてのレベル", @@ -756,9 +785,15 @@ "store_share": "コミュニティストアに共有", "store_share_device": "このデバイスを共有", "export_select": "エクスポート - データを選択", - "import_wizard": "インポート - データを選択" + "import_wizard": "インポート - データを選択", + "history_import": "電力履歴のインポート" }, "msg": { + "tail_trim_hint": "末尾から削除する秒数", + "store_sibling_hint": "お使いの機種そのものが共有されていない場合は、同じブランドの近い機種から始めるとうまくいくことが多いです。", + "store_declare_appliance": "お使いの家電を WashData に指定すると、このタブに他のユーザーが共有したプログラムや録音が表示されます。上でブランド名を入力して見て回ることもできます。", + "refresh_catalog_hint": "コミュニティのブランドと家電の一覧はキャッシュされ、共有ストアが 1 日あたりの読み取り上限内に収まるようにしています。更新すると、他のユーザーが追加または承認した項目を取得できます。", + "head_trim_hint": "先頭から削除する秒数", "appliance_monitor": "アプライアンスモニター", "artifact_dip_detail": "通常の電力帯の下側に約{n}秒間落ちました。", "artifact_footer": "上のグラフで強調表示されています。これらは一時的なアーチファクト (サイクルの途中でドアが開いたなど) であり、必ずしも問題があるわけではありません。", @@ -769,7 +804,7 @@ "automations_intro": "WashData は {start} / {end} イベントを発火しエンティティを公開するため、通知やアクションは通常の Home Assistant オートメーションとして構築するのが最適です。このデバイスを使用するオートメーションは以下に表示されます。", "cleanup_intro": "すべてのラベル付きサイクルがオーバーレイされます。外れ値にチェックを入れて削除し、プロファイルをクリーンアップします。", "clear_debug_hint": "保存されているデバッグ データを削除して領域を解放します。", - "collecting_data": "データを収集中です。微調整を開始するにはあと {need} サイクル{plural}必要です({current}/{min})。", + "collecting_data": "データを収集中です。微調整を開始するために必要な残りサイクル数: {need}({current}/{min})。", "compare_overlay_profiles": "オーバーレイプロファイル (淡い)", "compare_profiles_tip": "他のプロファイル エンベロープを上のチャートに重ねて、どれがこのサイクルに最も適合するかを確認します。", "compare_selected_cycles": "選択されたサイクル (実線) – 表示/非表示", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} 件のサイクルを削除しました", "enough_data": "学習に十分なデータがあります({current}/{min} サイクル)。", "export_description": "JSONにエクスポートするプロファイル、サイクル、設定などを正確に選択するか、ファイルを解析して必要な部分だけをインポートできます。", - "feedback_cycles_pending": "レビューが必要なサイクル: {n}件{s}", + "feedback_cycles_pending": "要レビュー: {n}", "feedback_prompt": "それが正しかったことを確認し、プログラムを修正するか、無視してください。", "feedback_relabel_hint": "このサイクルを再ラベル付けすると、これも解決します。", "filter_by_profile": "プロファイルでフィルター…", @@ -827,7 +862,7 @@ "no_profiles_yet": "まだプロフィールはありません。ラベル付きサイクルからサイクルを作成します。", "no_profiles_yet_short": "まだプロフィールはありません。", "no_settings_match": "「{q}」に一致する設定はありません", - "no_split_points": "まだ分​​割ポイントはありません。", + "no_split_points": "まだ分 割ポイントはありません。", "no_suggestions": "積極的な提案はありません。", "notify_services_hint": "{entity} サービス ID を使用してください(複数はカンマ区切り)。テンプレート変数:{vars}。", "old_actions_warning": "古いアクション エディター (現在は削除されています) で構成されています。これらは引き続きサイクル イベントで起動されますが、ここでは編集できなくなりました。それらを通常のオートメーションに変換するか、削除します。", @@ -856,12 +891,11 @@ "pg_stress_synthetic": "待機シミュレーションはここから開始", "pg_sweep_intro": "もし {param} が違っていたら? 最も多くのサイクルが正しくマッチする設定を見つけるため、直近 {cycles} サイクルにわたって {steps} 個の値をテストします。", "pg_sweep_step": "ステップ {done} / {total}", - "pg_undetected": "{n} 件のサイクル{s}が未検出", "pg_verdict_bad": "要注意: 多くのサイクルが検出されずに終わっています。", "pg_verdict_good": "よく調整されています: ほとんどのサイクルが正しく識別・マッチされています。", "pg_verdict_ok": "許容範囲: 一部のサイクルを見逃しています。開始しきい値を下げてみてください。", "phase_catalog_intro": "サイクルの名前付きセグメント (前洗浄、加熱、脱水など)。コントロール パネルからプロファイルにそれらを割り当てます。", - "phase_ranges_intro": "位相範囲 (サイクル開始からの分) が平均曲線に重ねられます。値を編​​集してライブでプレビューします。", + "phase_ranges_intro": "位相範囲 (サイクル開始からの分) が平均曲線に重ねられます。値を編 集してライブでプレビューします。", "playground_intro": "過去のサイクルを異なる設定で再生し、2 つの構成を並べて比較し、サイクルがプロフィールにどう整合するかを確認できる安全なサンドボックスです。明示的に適用するまで、ここでの操作が実データを変更することはありません。", "playground_needs_restart": "プレイグラウンドツールを有効にするには Home Assistant を再起動してください。", "preferences_admin": "設定、パネル、アクセス制御", @@ -887,6 +921,7 @@ "review_recorded_tip": "これをプログラム用に厳選された参照サイクルとしてマークします。これは、手動で記録されたサイクルと同じ役割です。参照サイクルは常に保持され、一致するテンプレートにシードされ、クリーンアップによって削除されることはありません。 (これは「ゴールデン」/記録済みフラグです。両方とも同じものです。)", "review_tags_tip": "このサイクルで何が問題だったかを説明するオプションのフラグ。トレーニングとクリーンアップで問題を解決できます。", "review_to_cycles": "Cycles レビューキューを開く", + "samples_decimated": "{total} サンプル中 {shown} 件を表示中 (表示用に間引き。ピークは保持)。ここでの大きな間隔は間引きであり、データの欠落ではありません。", "saving_triggers_reload": "保存すると統合のリロードがトリガーされます。 HA エンティティが一時的に利用不可として表示される場合があります。", "search_placeholder": "検索設定…", "see_recorder": "以下のレコーダー ウィジェットを参照してください", @@ -1006,7 +1041,29 @@ "sug_mute_failed": "提案のミュートに失敗しました", "sug_unmuted_all": "ミュートした提案をリセットしました", "n_suggestions_muted": "{count} 件がミュート済み。自動チューナーはこれらを提案しません。", - "font_size_hint": "このパネル全体の文字を大きくまたは小さくします。このデバイスのあなたのアカウントに適用されます。" + "font_size_hint": "このパネル全体の文字を大きくまたは小さくします。このデバイスのあなたのアカウントに適用されます。", + "import_history_description": "WashData を使う前からスマートプラグをお使いでしたか?その電力センサーの履歴エクスポートをアップロードするか、Home Assistant から直接読み込むと、通常の検出がそのデータに対して実行され、過去のサイクルが「サイクル」一覧に表示されて名前を付けられる状態になります。", + "hist_input_hint": "履歴パネルからダウンロードした CSV (entity, state, last changed) をアップロードするか、WashData にセンサーの履歴を直接読み込ませてください。その後、ライブ動作とまったく同じ検出が実行され、見つかったサイクルのうちどれを保持するかを選択できます。", + "hist_recorder_hint": "選択した日付から現在までを読み込みます。Home Assistant は既定で詳細な履歴を 10 日間保持し、それ以降は 1 時間ごとの平均値のみを保持します。平均値は粗すぎてサイクルを検出できないため、さらに過去の日付を選ぶのは recorder がより長く保持する設定になっている場合だけにしてください。", + "hist_scanning": "履歴を検出器で再生しています。この処理はバックグラウンドで実行されます。このダイアログを閉じて後で戻ってきても問題ありません。", + "hist_imported_count": "{n} サイクルをインポートしました。", + "hist_duplicates": "{n} 件はすでにインポート済みのためスキップしました。", + "hist_capped": "デバイスごとのインポート済みサイクル数の上限に達したため、残りは保存されませんでした。", + "hist_next_step": "「サイクル」一覧にインポートした履歴として表示されています。1 つ開いて「ラベル付け」から該当するプログラム名を設定してください。", + "hist_rows_read": "{n} 件の計測値を読み込みました", + "hist_entity_substituted": "{used} を読み込みました(このデバイスには {wanted} が設定されています)", + "hist_breaks": "センサーが利用できなかった空白が {n} 箇所", + "hist_other_entity": "他のエンティティの計測値 {n} 件は無視されました", + "hist_skipped_spans": "スキップした区間", + "hist_settings_used": "このデバイスの現在の設定 (最小電力 {w} W、オフ遅延 {s} 秒) で検出しました。", + "hist_none_found": "その履歴からはサイクルを検出できませんでした。", + "hist_found": "{n} サイクルが見つかりました。実際の稼働に見えないものはチェックを外してください。インポートするまで何も保存されません。", + "hist_scan_capped": "最初の候補のみを表示しています (合計 {n} 件見つかりました)。", + "hist_recorder_empty": "Home Assistant には、その期間のこのセンサーの詳細な履歴がありません。", + "hist_scan_failed": "スキャンに失敗しました。", + "hist_scan_expired": "そのスキャン結果はすでに利用できません。もう一度スキャンしてください。", + "hist_import_failed": "インポートに失敗しました。", + "imported_history_readonly": "インポートした電力履歴から検出されました。プログラムのマッチングには反映されますが、統計には含まれず、トリミングや分割はできません。ラベル付けしてプログラム名を設定してください。" }, "pg_desc": { "completion_min_seconds": "実サイクルとみなす最短の稼働時間", @@ -1035,7 +1092,8 @@ "dtw_refine_top_n": "ステージ 3: DTW が再スコアリングする候補数; 正しいプロファイルが 4-5 位になる場合は 7-9 に増やす(デフォルト 5)", "duration_scale": "ステージ 4: 運転時間の一致スコアが半分になる対数比率; 小さいほど厳格なペナルティ(デフォルト 0.175)", "energy_scale": "ステージ 4: エネルギーの一致スコアが半分になる対数比率; 小さいほど厳格なペナルティ(デフォルト 0.25)", - "dishwasher_end_spike_quiet_release": "食器洗い機: 予想所要時間を過ぎた後、サイクル終了時の排水待機を解除するまでの静音秒数" + "dishwasher_end_spike_quiet_release": "食器洗い機: 予想所要時間を過ぎた後、サイクル終了時の排水待機を解除するまでの静音秒数", + "smart_termination_duration_ratio": "一致したプログラムの想定所要時間のうち、スマート終了が早期に終了できるようになるまでにサイクルが到達すべき割合。負荷や温度に左右されるマシンでは下げる" }, "phase_desc": { "anti_crease": "メインサイクル後にドラムが断続的に回転し、衣類のシワを防止します。", @@ -1214,7 +1272,7 @@ "label": "デバッグエンティティを公開" }, "external_end_trigger": { - "doc": "状態の変化がサイクルの終了を示すバイナリ センサー (例: アプライアンスの「完了」接触またはコンパニオン統合)​​。", + "doc": "状態の変化がサイクルの終了を示すバイナリ センサー (例: アプライアンスの「完了」接触またはコンパニオン統合)。", "label": "外部トリガーエンティティ" }, "external_end_trigger_enabled": { @@ -1338,7 +1396,7 @@ "label": "開始サービス" }, "notify_timeout_seconds": { - "doc": "この秒数が経過すると、通知が自動的に閉じられます (通知をサポートしているプラ​​ットフォームの場合)。 0 は手動で終了するまで保持します。", + "doc": "この秒数が経過すると、通知が自動的に閉じられます (通知をサポートしているプラ ットフォームの場合)。 0 は手動で終了するまで保持します。", "label": "自動消去まで" }, "notify_title": { @@ -1386,7 +1444,7 @@ "label": "最大時間比率" }, "profile_match_min_duration_ratio": { - "doc": "プロファイルに対する最小サイクル長。 0.9 は、サイクルがプロファイル期間の少なくとも 90% でな​​ければ一致しないことを意味します。", + "doc": "プロファイルに対する最小サイクル長。 0.9 は、サイクルがプロファイル期間の少なくとも 90% でな ければ一致しないことを意味します。", "label": "最小時間比率" }, "profile_match_threshold": { @@ -1417,6 +1475,10 @@ "doc": "各サイクルの全電力トレースと一致するデバッグ データを保存します。トラブルシューティングには役立ちますが、ストレージ サイズが増加します。", "label": "デバッグトレースを保存" }, + "smart_termination_duration_ratio": { + "doc": "一致したプログラムの想定所要時間のうち、どこまでサイクルが進んでから、電力低下時にスマート終了がサイクルを早期に終了できるかを指定します。想定所要時間はそのプログラムの平均値のため、実行時間が大きく変動する家電 (冬の冷たい給水と夏の温かい給水で変わる洗濯機、センサー乾燥式の乾燥機、負荷に依存するプログラムなど) では、全実行の約半数が平均より短く終わり、早期終了が働かず、フォールバックのタイムアウトによって数分遅れて終了するだけになります。こうしたマシンではこの値を下げて (例: 0.85)、早期終了が確実に作動するようにし、より慎重にしたい場合は 1.0 に近づけます。デフォルト (0.98、食器洗い機は 0.99) にするには空欄のままにします。サイクルを早めに終了することしかできず、遅らせることは決してなく、あいまいな一致や信頼度の低い一致では作動しません。", + "label": "スマート終了比率" + }, "smoothing_window": { "doc": "生の電力信号がどの程度平滑化されるか。低 (2) は応答性は高くなりますが、ノイズが多くなります。高 (5) はスパイクを滑らかにしますが、ラグが追加されます。", "label": "スムージングウィンドウ" @@ -1551,6 +1613,10 @@ "dishwasher_end_spike_quiet_release": { "label": "パッシブ乾燥の静音解除", "doc": "サイクルが予想所要時間を過ぎた後、WashData が最終排水を待つのをやめてサイクルを終了するまでに、食器洗い機が静音状態(停止しきい値を下回る状態)を維持しなければならない時間です。遅れて発生する最終排水の前に長い無音の乾燥フェーズがあり、その排水が見逃されている場合は、この値を上げてください。ウィンドウを広げると、学習された所要時間が古い平均値に固定されず、季節変動(給水温度が低い=サイクルが長くなる)に追従できます。この設定は、内部の 30 分間の終了スパイク待機上限に対して待機時間を短縮することしかできず、延長することはありません。" + }, + "profile_evidence_sources": { + "label": "プログラムを形づくるサイクル", + "doc": "各プログラムの電力カーブを作成し、完了したサイクルをそれと照合するために使用するサイクルの種類です。種類のチェックを外すと、その種類はプログラムに反映されなくなりますが、何も削除されません。サイクルはサイクル一覧に残り、ラベル付けや削除も引き続きできます。インポートしたデータを信頼できない場合に便利です。統計には影響しません。統計は常に、この機器が実際に実行したサイクルのみを数えます。すべてのチェックを外した場合は無視されます。裏付けとなるサイクルがないプログラムは、決してマッチングできないためです。" } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "基本設定" + }, + "profile_evidence": { + "label": "プロファイルの根拠" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "トリム中…", "splitting": "分割中…", "deleting": "削除中…", - "imported": "インポート済み" + "imported": "インポート済み", + "preparing": "準備中…" }, "tab": { "advanced": "高度な", @@ -1688,6 +1758,7 @@ "wrong_profile": "間違ったプロフィール" }, "toast": { + "catalog_refreshed": "コミュニティカタログを更新しました", "access_saved": "アクセス制御が保存されました", "all_wiped": "すべてのデータが消去されました", "analysis_complete_none": "分析が完了しました: 新しい提案はありません", @@ -1699,7 +1770,7 @@ "cycle_labelled": "サイクルラベル付き", "cycle_paused": "サイクルが一時停止されました", "cycle_resumed": "サイクルが再開されました", - "cycle_trimmed": "サイクルトリム", + "cycle_trimmed": "サイクルをトリミングしました", "cycles_merged": "統合されたサイクル", "envelope_rebuilt": "再構築されたエンベロープ", "envelopes_rebuilt": "再構築された封筒", @@ -1708,7 +1779,7 @@ "feedback_confirmed": "フィードバックが確認されました", "feedback_dismissed": "フィードバックは却下されました", "group_deleted": "グループが削除されました", - "group_name_required": "グループ名は必​​須です", + "group_name_required": "グループ名は必 須です", "group_saved": "グループが保存されました", "import_successful": "インポートは成功しました。統合のリロード", "json_required": "JSONデータは必須です", @@ -1793,7 +1864,9 @@ "store_download_failed": "ダウンロードに失敗しました: {error}", "store_download_nothing": "ダウンロードする新しいものはありません - このセットアップは既にデバイスにあります。", "export_selective_done": "エクスポートをダウンロードしました", - "import_selective_done": "{profiles} 件のプロファイルと {cycles} 件のサイクルをインポートしました" + "import_selective_done": "{profiles} 件のプロファイルと {cycles} 件のサイクルをインポートしました", + "hist_csv_required": "先に CSV ファイルを読み込むか、内容を貼り付けてください", + "file_read_failed": "そのファイルを読み込めませんでした" }, "suggestion": { "both_agree": "WashDataの推奨", @@ -1889,7 +1962,7 @@ "thr_batch": "{cycles} サイクルにわたる p05 の最低稼働電力({p05}W)のすぐ上に設定。これにより開始をできるだけ早く捉え、停止しきい値は機器の最低稼働電力を下回ったままになります。", "tol_per_profile": "{profiles} 件のプロファイル({cycles} サイクル)にわたるプロファイルごとの時間分散の p75。ばらつきの小さいプロファイルは不利になりません。", "tol_pooled": "{cycles} 件の直近のラベル付きサイクルの統合時間分散に基づく(p95 偏差={dev})。", - "watchdog": "安全な範囲でできるだけ低く設定(p95 の更新間隔 {p95}s をわずかに上回る値、最小 30s)。誤った停止なしに停滞を素早く検出します。" + "watchdog": "安全な範囲でできるだけ低く設定(p95 の更新間隔 {p95}s をわずかに上回り、かつサンプリング間隔 {median}s の 2 倍以上、最小 30s)。誤った停止なしに停滞を素早く検出します。" }, "exclusions": { "summary": "{total} 件の誤検出サイクルを除外しました: {parts}。", @@ -1955,6 +2028,10 @@ "finished": "サイクルが終了状態に達して終了しました。" }, "store": { + "your_model_tip": "設定で指定した家電です", + "your_model": "自分の機種", + "search_brand_ph": "ブランドで検索…", + "programs_count": "プログラム: {n}", "browse": "閲覧", "device": "デバイス", "favorites": "お気に入り", @@ -1993,6 +2070,7 @@ "add_profile": "コミュニティサイトでこの家電のプロファイルを追加してください" }, "task": { + "cancelling": "キャンセル中...", "reprocess": { "matching": "再処理: サイクルのマッチング", "golden": "再処理: 基準サイクルの補完", diff --git a/custom_components/ha_washdata/translations/panel/ko.json b/custom_components/ha_washdata/translations/panel/ko.json index a9e9ae0c..480884af 100644 --- a/custom_components/ha_washdata/translations/panel/ko.json +++ b/custom_components/ha_washdata/translations/panel/ko.json @@ -78,9 +78,12 @@ "awaiting": "승인 대기 중", "imported_tip": "커뮤니티 스토어에서 가져왔습니다. 매칭에만 사용되며 통계에는 포함되지 않습니다.", "not_importable": "해당 없음", - "exists": "존재함" + "exists": "존재함", + "backfilled_tip": "가져온 전력 이력에서 감지되었습니다. 프로그램 매칭에만 영향을 주며 통계에는 포함되지 않습니다." }, "btn": { + "set_brand_model": "브랜드 및 모델 설정", + "refresh_catalog": "카탈로그 새로 고침", "add_device": "+ 장치 추가", "add_device_tip": "다른 WashData 장치 추가", "add_maintenance": "유지보수 이벤트 추가", @@ -90,7 +93,7 @@ "apply_label": "라벨 적용", "apply_set_b": "세트 B 적용", "apply_split": "분할 적용", - "apply_trim": "트림 적용", + "apply_trim": "자르기 적용", "auto_detect_split": "자동 감지", "auto_label_cycles": "사이클 자동 라벨 지정", "auto_label_cycles_tip": "일치 신뢰도가 임계값을 초과하는 미라벨 주기에 프로필 이름을 자동으로 할당합니다", @@ -209,8 +212,8 @@ "stop": "정지", "submit_correction": "정정사항 제출", "train_now": "지금 훈련", - "trim": "다듬기", - "trim_split": "다듬기/분할", + "trim": "자르기", + "trim_split": "자르기/분할", "undo": "실행 취소", "use": "사용", "wipe_all": "모든 데이터 지우기", @@ -241,7 +244,12 @@ "import_selected": "선택 항목 가져오기", "back": "뒤로", "mute_suggestion": "이 설정 제안 중지", - "reset_muted": "뮤트 초기화" + "reset_muted": "뮤트 초기화", + "import_power_history": "전력 이력 가져오기", + "hist_read_recorder": "Home Assistant에서 읽기", + "hist_scan": "사이클 검색", + "hist_import_n": "{n}개 사이클 가져오기", + "hist_goto_cycles": "사이클 보기" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "구김 방지 최대 전력 ({max} W) 미만이어야 합니다" }, "attn_sub": "저장하기 전에 충돌을 해결하세요", - "attn_title": "설정 충돌 {n}건{s}", + "attn_title": "설정 충돌: {n}", "confidence": { "auto": "매칭 임계값 ({match}) 이상이어야 합니다", - "learning": "매칭 임계값 ({match}) 이하이어야 합니다", + "learning": "매칭 임계값 ({match}) 이상이어야 합니다", "match_for_auto": "자동 라벨 신뢰도 ({alc}) 이하이어야 합니다", - "match_for_learning": "학습 신뢰도 ({lc}) 이상이어야 합니다" + "match_for_learning": "학습 신뢰도 ({lc}) 이하이어야 합니다" }, "duration_ratio": { "max": "최소 기간 비율 ({min}) 보다 커야 합니다", @@ -296,14 +304,14 @@ "match": "언매칭 임계값 ({un}) 이상이어야 합니다", "unmatch": "매칭 임계값 ({match}) 미만이어야 합니다. 그렇지 않으면 확정된 일치가 즉시 해제됩니다" }, - "cascade_toast": "일관성을 위해 {n}개 설정{s}을 추가로 자동 조정했습니다.", + "cascade_toast": "일관성을 위해 조정된 다른 설정: {n}", "suggestion_resolves": "이 문제를 해결하려면 아래의 보류 중인 제안({val})을 적용하세요", "use_fix": "{val} 사용", "watchdog": { "interval": "샘플링 간격 ({si} 초)의 최소 2배이어야 합니다", "sampling": "샘플링 간격은 워치독 간격 ({wi} 초)의 절반 이하이어야 합니다" }, - "settings_banner": "{n}건의 설정 충돌{s} – 강조 표시된 섹션을 확인하고 저장 전에 수정하세요.", + "settings_banner": "설정 충돌: {n}. 강조 표시된 섹션을 확인하고 저장 전에 수정하세요.", "settings_banner_btn": "첫 번째로" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "시뮬레이션 결과", "pg_across_cycles": "전체 사이클에 걸쳐", "community_store": "커뮤니티 스토어", - "online_account": "커뮤니티 스토어 및 온라인 기능" + "online_account": "커뮤니티 스토어 및 온라인 기능", + "import_power_history": "전력 이력 가져오기" }, "health": { "fair": "프로파일 품질: 보통", @@ -364,6 +373,7 @@ "poor": "⚠ 프로파일 품질: 불량" }, "lbl": { + "drag_to_resize": "드래그하여 크기 조정", "actions": "작업", "activity": "활동", "administrators": "관리자", @@ -434,7 +444,7 @@ "from": "시작", "gap_s": "간격(초)", "group_name": "그룹 이름", - "head_trim": "헤드 트림(초)", + "head_trim": "시작 부분 자르기(초)", "health": "건강", "hide_tabs": "관리자가 아닌 사용자를 위한 탭 숨기기", "in_use": "사용 중", @@ -450,7 +460,7 @@ "metric": "지표", "mode_existing_profile": "기존 프로필에 추가", "mode_new_profile": "새 프로필 만들기", - "models_fine_tuned": "({count}개 모델{plural} 미세 조정됨)", + "models_fine_tuned": "(미세 조정된 모델: {count})", "n_classic_suggestions": "{n} 클래식", "n_ml_suggestions": "{n} ML", "n_selected": "{n}개 선택됨", @@ -532,7 +542,7 @@ "stage3": "3단계 – DTW", "stage4": "4단계 – 일치도", "status": "상태", - "tail_trim": "테일 트림(초)", + "tail_trim": "끝부분 자르기(초)", "timer_auto_pause": "자동 일시중지", "timer_min": "분", "timer_msg_placeholder": "메시지(선택사항, {device}/{program}/{minutes})", @@ -650,7 +660,7 @@ "show_contributor": "기여자 이름 표시", "task_pg_detail": "사이클 시뮬레이션", "task_split": "사이클 분할 중", - "task_trim": "사이클 트리밍 중", + "task_trim": "사이클 자르는 중", "task_merge": "사이클 병합 중", "task_rebuild": "엔벨로프 재구성 중", "cat_profiles": "프로필(프로그램)", @@ -677,7 +687,26 @@ "conflict_import_copy": "사본으로 가져오기", "conflict_keep_mine": "내 것 유지", "conflict_overwrite": "덮어쓰기", - "font_size": "패널 글꼴 크기" + "font_size": "패널 글꼴 크기", + "hist_csv_data": "CSV 데이터", + "hist_from_recorder": "또는 Home Assistant에서 읽기", + "hist_since": "시작 날짜", + "days": "일", + "hist_keep": "이 사이클 유지", + "hist_looks_complete": "완전", + "peak_power_short": "피크", + "shape": "형태", + "hist_skip_idle": "작동 없음", + "hist_skip_sparse": "측정값 간격이 너무 넓음", + "hist_skip_short": "측정값이 너무 적음", + "hist_skip_long": "나눌 만큼 긴 공백이 없음", + "hist_reason_short": "이 가전의 실제 최단 사이클보다 짧음", + "hist_reason_no_end": "정상적으로 종료되지 않음", + "task_history_import": "전력 이력 검색", + "task_history_import_apply": "사이클 가져오기", + "evidence_real_cycles": "이 기기가 실행한 사이클", + "evidence_reference_cycles": "커뮤니티 스토어에서 다운로드", + "evidence_backfill_cycles": "가져온 전력 이력에서 발견" }, "log": { "all_levels": "모든 레벨", @@ -756,9 +785,15 @@ "store_share": "커뮤니티 스토어에 공유", "store_share_device": "이 기기 공유", "export_select": "내보내기 - 데이터 선택", - "import_wizard": "가져오기 - 데이터 선택" + "import_wizard": "가져오기 - 데이터 선택", + "history_import": "전력 이력 가져오기" }, "msg": { + "tail_trim_hint": "끝부분에서 제거할 초 수", + "store_sibling_hint": "정확히 같은 모델로 공유된 항목이 없나요? 같은 브랜드의 비슷한 모델이 보통 좋은 출발점이 됩니다.", + "store_declare_appliance": "보유한 가전제품을 WashData에 지정하면 이 탭에 다른 사용자가 공유한 프로그램과 녹음이 표시됩니다. 위에 브랜드를 입력해 둘러볼 수도 있습니다.", + "refresh_catalog_hint": "커뮤니티 브랜드 및 가전제품 목록은 공유 스토어가 일일 조회 한도를 넘지 않도록 캐시됩니다. 새로 고치면 다른 사용자가 추가하거나 승인한 항목을 가져옵니다.", + "head_trim_hint": "시작 부분에서 제거할 초 수", "appliance_monitor": "가전제품 모니터", "artifact_dip_detail": "일반 전력 범위 아래로 약 {n}초 동안 내려갔습니다.", "artifact_footer": "위 그래프에 강조표시되어 있습니다. 이는 일시적인 아티팩트(예: 사이클 중간에 문이 열림)이며 반드시 문제가 되는 것은 아닙니다.", @@ -769,7 +804,7 @@ "automations_intro": "WashData는 {start} / {end} 이벤트를 발생시키고 엔티티를 노출하므로, 알림과 동작은 일반 Home Assistant 자동화로 구축하는 것이 가장 좋습니다. 이 기기를 사용하는 자동화가 아래에 표시됩니다.", "cleanup_intro": "라벨이 붙은 모든 사이클이 중첩됩니다. 이상값을 선택하고 삭제하여 프로필을 정리하세요.", "clear_debug_hint": "저장된 디버그 데이터를 제거하여 여유 공간을 확보하세요.", - "collecting_data": "데이터 수집 중입니다. 미세 조정을 시작하려면 {need}번의 사이클{plural}이 더 필요합니다({current}/{min})。", + "collecting_data": "데이터 수집 중입니다. 미세 조정을 시작하기까지 더 필요한 사이클: {need} ({current}/{min}).", "compare_overlay_profiles": "오버레이 프로필(희미함)", "compare_profiles_tip": "위 차트에 다른 프로필 봉투를 겹쳐서 어느 것이 이 주기에 가장 적합한지 확인하세요.", "compare_selected_cycles": "선택한 주기(단색) – 표시/숨기기", @@ -779,7 +814,7 @@ "cycles_deleted": "{count}개 사이클이 삭제되었습니다", "enough_data": "학습할 충분한 데이터가 있습니다({current}/{min}사이클)。", "export_description": "JSON으로 내보낼 프로필, 사이클, 설정 등을 정확히 선택하거나, 파일을 분석하여 원하는 부분만 가져올 수 있습니다.", - "feedback_cycles_pending": "검토가 필요한 사이클 {n}개{s}", + "feedback_cycles_pending": "검토 대기: {n}", "feedback_prompt": "그것이 옳았는지 확인하고, 프로그램을 수정하거나, 무시하십시오.", "feedback_relabel_hint": "이 사이클을 다시 라벨 지정하면 이것도 해결됩니다.", "filter_by_profile": "프로필로 필터링…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "대기 시뮬레이션이 여기서 시작됩니다", "pg_sweep_intro": "{param}이(가) 달랐다면 어땠을까요? 가장 많은 사이클이 올바르게 매칭되는 설정을 찾기 위해 최근 {cycles}개 사이클에서 {steps}개 값을 테스트합니다.", "pg_sweep_step": "단계 {done} / {total}", - "pg_undetected": "{n}개 사이클{s} 미감지", "pg_verdict_bad": "주의 필요: 많은 사이클이 감지되지 않고 있습니다.", "pg_verdict_good": "잘 조정됨: 대부분의 사이클이 올바르게 식별되고 매칭됩니다.", "pg_verdict_ok": "양호: 일부 사이클을 놓쳤습니다. 시작 임계값을 낮춰 보세요.", @@ -887,6 +921,7 @@ "review_recorded_tip": "이를 프로그램에 대해 직접 선택한 참조 주기로 표시합니다. 이는 수동으로 기록된 주기와 동일한 역할입니다. 참조 순환은 항상 유지되고 일치하는 템플릿을 시드하며 정리로 인해 삭제되지 않습니다. (이것은 \"황금\"/기록된 플래그입니다. 둘 다 동일합니다.)", "review_tags_tip": "이 주기에 무엇이 잘못되었는지 설명하는 선택적 플래그이므로 훈련과 정리가 이를 설명할 수 있습니다.", "review_to_cycles": "사이클 검토 대기열 열기", + "samples_decimated": "{total}개 샘플 중 {shown}개 표시 중 (표시용으로 솎아냄; 피크는 유지). 여기서 넓은 간격은 솎아낸 것이며 누락된 데이터가 아닙니다.", "saving_triggers_reload": "저장하면 통합 다시 로드가 트리거됩니다. HA 엔터티는 일시적으로 사용할 수 없는 것으로 표시될 수 있습니다.", "search_placeholder": "검색 설정…", "see_recorder": "아래의 녹음기 위젯을 참조하세요", @@ -1006,7 +1041,29 @@ "sug_mute_failed": "제안을 음소거할 수 없습니다", "sug_unmuted_all": "뮤트된 제안을 초기화했습니다", "n_suggestions_muted": "{count}개 뮤트됨. 자동 튜너가 이 설정들을 제안하지 않습니다.", - "font_size_hint": "이 패널의 모든 내용을 크거나 작게 표시합니다. 이 기기의 내 계정에 적용됩니다." + "font_size_hint": "이 패널의 모든 내용을 크거나 작게 표시합니다. 이 기기의 내 계정에 적용됩니다.", + "import_history_description": "WashData를 사용하기 전에도 스마트 플러그를 쓰고 있었나요? 해당 전력 센서의 이력 내보내기 파일을 업로드하거나 Home Assistant에서 바로 읽어오면 평소와 같은 감지가 그 데이터에 실행되어, 과거 사이클이 사이클 목록에 나타나 이름을 지정할 수 있습니다.", + "hist_input_hint": "이력 패널에서 다운로드한 CSV(entity, state, last changed)를 업로드하거나, WashData가 센서 이력을 직접 읽도록 하세요. 그러면 실시간과 똑같은 감지가 실행되고, 찾은 사이클 중 어떤 것을 보관할지 직접 선택합니다.", + "hist_recorder_hint": "선택한 날짜부터 현재까지 읽습니다. Home Assistant는 기본적으로 상세 이력을 10일간 보관하고 그 이후에는 시간별 평균만 남기는데, 이는 사이클을 감지하기에 너무 거칩니다. recorder가 더 오래 보관하도록 설정되어 있을 때만 더 이전 날짜를 선택하세요.", + "hist_scanning": "이력을 감지기로 다시 재생하고 있습니다. 이 작업은 백그라운드에서 실행되므로 이 창을 닫고 나중에 다시 열어도 됩니다.", + "hist_imported_count": "{n}개 사이클을 가져왔습니다.", + "hist_duplicates": "{n}개는 이미 가져온 항목이라 건너뛰었습니다.", + "hist_capped": "기기당 가져온 사이클 한도에 도달하여 나머지는 저장되지 않았습니다.", + "hist_next_step": "가져온 이력으로 표시되어 사이클 목록에 있습니다. 하나를 열고 라벨 지정으로 해당 프로그램 이름을 지정하세요.", + "hist_rows_read": "측정값 {n}개 읽음", + "hist_entity_substituted": "{used}을(를) 읽었습니다 (이 기기에는 {wanted}이(가) 설정되어 있습니다)", + "hist_breaks": "센서를 사용할 수 없었던 공백 {n}곳", + "hist_other_entity": "다른 엔티티의 측정값 {n}개 무시됨", + "hist_skipped_spans": "건너뛴 구간", + "hist_settings_used": "이 기기의 현재 설정(최소 전력 {w} W, 오프 지연 {s}초)으로 감지했습니다.", + "hist_none_found": "해당 이력에서 사이클을 감지할 수 없었습니다.", + "hist_found": "{n}개 사이클을 찾았습니다. 실제 작동으로 보이지 않는 항목은 선택을 해제하세요. 가져오기 전에는 아무것도 저장되지 않습니다.", + "hist_scan_capped": "처음 후보만 표시합니다(총 {n}개 발견).", + "hist_recorder_empty": "Home Assistant에 해당 기간의 이 센서 상세 이력이 없습니다.", + "hist_scan_failed": "검색에 실패했습니다.", + "hist_scan_expired": "해당 검색 결과는 더 이상 사용할 수 없습니다. 다시 검색해 주세요.", + "hist_import_failed": "가져오기에 실패했습니다.", + "imported_history_readonly": "가져온 전력 이력에서 감지되었습니다. 프로그램 매칭에 반영되지만 통계에는 포함되지 않으며, 자르거나 분할할 수 없습니다. 라벨을 지정해 프로그램 이름을 정하세요." }, "pg_desc": { "completion_min_seconds": "실제 사이클로 인정되는 최단 실행", @@ -1035,7 +1092,8 @@ "dtw_refine_top_n": "Stage 3: DTW가 재점수화하는 후보 수; 올바른 프로파일이 4-5위면 7-9로 높임(기본값 5)", "duration_scale": "Stage 4: 지속 시간 일치 점수가 반으로 줄어드는 로그 비율; 작을수록 엄격한 패널티(기본값 0.175)", "energy_scale": "Stage 4: 에너지 일치 점수가 반으로 줄어드는 로그 비율; 작을수록 엄격한 패널티(기본값 0.25)", - "dishwasher_end_spike_quiet_release": "식기세척기: 예상 소요 시간 이후 사이클 종료 배수 대기가 해제되기 전 조용히 유지되는 초" + "dishwasher_end_spike_quiet_release": "식기세척기: 예상 소요 시간 이후 사이클 종료 배수 대기가 해제되기 전 조용히 유지되는 초", + "smart_termination_duration_ratio": "일치된 프로그램의 예상 소요 시간 중 스마트 종료가 조기에 끝낼 수 있으려면 주기가 도달해야 하는 비율; 부하나 온도에 따라 달라지는 기기에서는 낮추십시오" }, "phase_desc": { "anti_crease": "메인 사이클 후 드럼이 간헐적으로 회전하여 의류 구김을 방지합니다.", @@ -1417,6 +1475,10 @@ "doc": "각 사이클에 대해 전체 전력 추적 및 일치하는 디버그 데이터를 저장합니다. 문제 해결에 유용하지만 저장소 크기가 늘어납니다.", "label": "디버그 추적 저장" }, + "smart_termination_duration_ratio": { + "doc": "전력이 떨어졌을 때 스마트 종료가 주기를 조기에 끝낼 수 있으려면, 일치된 프로그램의 예상 소요 시간 중 어느 정도까지 주기가 진행되어야 하는지를 지정합니다. 예상 소요 시간은 해당 프로그램의 평균값이므로, 실행 시간이 크게 달라지는 가전제품(겨울철 차가운 급수 대 여름철 따뜻한 급수의 세탁기, 센서 건조식 건조기, 부하에 따라 달라지는 프로그램)에서는 전체 실행의 약 절반이 그 평균보다 짧게 끝나 빠른 종료가 적용되지 않고, 폴백 시간 초과를 통해서만 몇 분 늦게 종료됩니다. 이러한 기기에서는 이 값을 낮춰(예: 0.85) 조기 종료가 계속 작동하도록 하고, 더 보수적으로 하려면 1.0에 가깝게 높이십시오. 기본값(0.98, 식기세척기는 0.99)을 사용하려면 비워 두십시오. 주기를 더 일찍 끝낼 수만 있고 더 늦출 수는 없으며, 모호하거나 신뢰도가 낮은 일치에서는 작동하지 않습니다.", + "label": "스마트 종료 비율" + }, "smoothing_window": { "doc": "원시 전력 신호가 얼마나 평탄화되는지입니다. 낮음(2)은 반응이 좋지만 소음이 있습니다. 높음(5)은 스파이크를 부드럽게 하지만 지연을 추가합니다.", "label": "스무딩 윈도우" @@ -1551,6 +1613,10 @@ "dishwasher_end_spike_quiet_release": { "label": "자연 건조 조용 시 해제", "doc": "사이클이 예상 소요 시간을 지난 후, WashData가 최종 배수를 더 이상 기다리지 않고 사이클을 종료하기까지 식기세척기가 조용한 상태(중지 임계값 미만)를 유지해야 하는 시간입니다. 늦게 발생하는 최종 배수 전에 긴 무음 건조 단계가 있고 그 배수가 감지되지 않고 있다면 값을 높이세요. 창을 넓히면 학습된 소요 시간이 예전 평균값에 고정되지 않고 계절적 변동(급수 온도가 낮을수록 사이클이 길어짐)을 따라갈 수 있습니다. 이 설정은 내부의 30분 종료 스파이크 상한에 비해 대기 시간을 단축할 수만 있고 연장하지는 않습니다." + }, + "profile_evidence_sources": { + "label": "프로그램을 형성하는 사이클", + "doc": "각 프로그램의 전력 곡선을 만들고 완료된 사이클을 그것과 매칭할 때 사용할 사이클 종류입니다. 종류의 선택을 해제하면 해당 종류는 더 이상 프로그램에 반영되지 않지만 아무것도 삭제되지 않습니다. 사이클은 사이클 목록에 그대로 남아 있으며 계속 라벨을 지정하거나 삭제할 수 있습니다. 가져온 데이터를 신뢰할 수 없을 때 유용합니다. 통계에는 영향이 없습니다. 통계는 항상 이 기기가 실제로 실행한 사이클만 계산합니다. 모두 선택 해제하면 무시됩니다. 근거가 되는 사이클이 없는 프로그램은 결코 매칭될 수 없기 때문입니다." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "기본 구성" + }, + "profile_evidence": { + "label": "프로필 근거" } }, "status": { @@ -1655,10 +1724,11 @@ "analyzing": "분석 중…", "resetting": "재설정 중…", "reverting": "되돌리는 중…", - "trimming": "다듬는 중…", + "trimming": "자르는 중…", "splitting": "분할 중…", "deleting": "삭제 중…", - "imported": "가져옴" + "imported": "가져옴", + "preparing": "준비 중…" }, "tab": { "advanced": "고급", @@ -1688,6 +1758,7 @@ "wrong_profile": "잘못된 프로필" }, "toast": { + "catalog_refreshed": "커뮤니티 카탈로그를 새로 고쳤습니다", "access_saved": "액세스 제어가 저장되었습니다.", "all_wiped": "모든 데이터가 지워졌습니다.", "analysis_complete_none": "분석 완료: 새로운 제안 없음", @@ -1699,7 +1770,7 @@ "cycle_labelled": "라벨이 붙은 사이클", "cycle_paused": "주기가 일시중지됨", "cycle_resumed": "사이클 재개", - "cycle_trimmed": "주기가 잘림", + "cycle_trimmed": "사이클을 잘랐습니다", "cycles_merged": "사이클이 병합되었습니다.", "envelope_rebuilt": "봉투 재구축", "envelopes_rebuilt": "봉투 재구축", @@ -1793,7 +1864,9 @@ "store_download_failed": "다운로드 실패: {error}", "store_download_nothing": "다운로드할 새 항목 없음 - 이 설정은 이미 기기에 있습니다.", "export_selective_done": "내보내기 다운로드 완료", - "import_selective_done": "{profiles}개 프로필과 {cycles}개 사이클을 가져왔습니다" + "import_selective_done": "{profiles}개 프로필과 {cycles}개 사이클을 가져왔습니다", + "hist_csv_required": "먼저 CSV 파일을 불러오거나 내용을 붙여넣으세요", + "file_read_failed": "해당 파일을 읽을 수 없습니다" }, "suggestion": { "both_agree": "WashData 권장", @@ -1889,7 +1962,7 @@ "thr_batch": "{cycles} 사이클의 p05 최저 활성 전력({p05}W) 바로 위로 유지합니다. 그래서 시작을 최대한 빨리 포착하고, 정지 임계값은 기기의 최저 작동 전력보다 낮게 유지됩니다.", "tol_per_profile": "{profiles}개 프로필({cycles} 사이클)의 프로필별 시간 분산 p75. 편차가 작은 프로필은 불이익을 받지 않습니다.", "tol_pooled": "{cycles}개 최근 라벨 사이클의 통합 시간 분산 기반(p95 편차={dev}).", - "watchdog": "안전한 범위에서 최대한 낮게 유지(p95 업데이트 간격 {p95}s보다 약간 큰 값, 최소 30s). 잘못된 정지 없이 멈춤을 빠르게 감지합니다." + "watchdog": "안전한 범위에서 최대한 낮게 유지(p95 업데이트 간격 {p95}s보다 약간 크고, 샘플링 간격 {median}s의 2배 이상, 최소 30s). 잘못된 정지 없이 멈춤을 빠르게 감지합니다." }, "exclusions": { "summary": "잘못 감지된 사이클 {total}개를 제외했습니다: {parts}.", @@ -1955,6 +2028,10 @@ "finished": "사이클이 종료 상태에 도달하여 끝났습니다." }, "store": { + "your_model_tip": "설정에서 지정한 가전제품입니다", + "your_model": "내 기기", + "search_brand_ph": "브랜드로 검색…", + "programs_count": "프로그램: {n}", "browse": "탐색", "device": "기기", "favorites": "즐겨찾기", @@ -2010,7 +2087,7 @@ "apply": "사이클 분할 중" }, "trim": { - "apply": "사이클 트리밍 중" + "apply": "사이클 자르는 중" }, "merge": { "apply": "사이클 병합 중" diff --git a/custom_components/ha_washdata/translations/panel/lt.json b/custom_components/ha_washdata/translations/panel/lt.json index cf4645b3..cdd20a40 100644 --- a/custom_components/ha_washdata/translations/panel/lt.json +++ b/custom_components/ha_washdata/translations/panel/lt.json @@ -78,9 +78,12 @@ "awaiting": "Laukiama patvirtinimo", "imported_tip": "Importuota iš bendruomenės parduotuvės. Naudojama tik atitikčiai, neįskaičiuojama į statistiką.", "not_importable": "čia nepasiekiama", - "exists": "jau yra" + "exists": "jau yra", + "backfilled_tip": "Aptikta importuotoje galios istorijoje. Turi įtakos tik programų atpažinimui, neįskaičiuojama į statistiką." }, "btn": { + "set_brand_model": "Nustatyti prekės ženklą ir modelį", + "refresh_catalog": "Atnaujinti katalogą", "add_device": "+ Pridėti įrenginį", "add_device_tip": "Pridėkite kitą WashData įrenginį", "add_maintenance": "Pridėti priežiūros įvykį", @@ -241,7 +244,12 @@ "import_selected": "Importuoti pasirinktus", "back": "Atgal", "mute_suggestion": "Nebesiūlyti šio nustatymo", - "reset_muted": "Atstatyti nutildytus" + "reset_muted": "Atstatyti nutildytus", + "import_power_history": "Importuoti galios istoriją", + "hist_read_recorder": "Nuskaityti iš Home Assistant", + "hist_scan": "Ieškoti ciklų", + "hist_import_n": "Importuoti ciklus ({n})", + "hist_goto_cycles": "Parodyti ciklus" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "Turi būti žemiau maksimalios apsaugos nuo raukšlių galios ({max} W)" }, "attn_sub": "Prieš išsaugant ištaisykite konfliktus", - "attn_title": "{n} nustatymų konfliktas{s}", - "settings_banner": "{n} nustatymų konfliktas{s} – patikrinkite pažymėtas sekcijas ir ištaisykite prieš išsaugant.", + "attn_title": "Nustatymų konfliktai: {n}", + "settings_banner": "Nustatymų konfliktai: {n}. Patikrinkite pažymėtas sekcijas ir ištaisykite jas prieš išsaugant.", "settings_banner_btn": "Eiti į pirmą", "confidence": { "auto": "Turi būti lygus arba aukščiau atitikimo slenksčio ({match})", - "learning": "Turi būti lygus arba žemiau atitikimo slenksčio ({match})", + "learning": "Turi būti lygus arba aukščiau atitikimo slenksčio ({match})", "match_for_auto": "Turi būti lygus arba žemiau automatinio žymėjimo pasitikėjimo ({alc})", - "match_for_learning": "Turi būti lygus arba aukščiau mokymosi pasitikėjimo ({lc})" + "match_for_learning": "Turi būti lygus arba žemiau mokymosi pasitikėjimo ({lc})" }, "duration_ratio": { "max": "Turi būti daugiau nei minimalaus trukmės santykio ({min})", @@ -298,7 +306,7 @@ "match": "Turi būti aukščiau neatitikimo slenksčio ({un})", "unmatch": "Turi būti žemiau atitikimo slenksčio ({match}); priešingu atveju patvirtintas atitikimas iškart panaikinamas" }, - "cascade_toast": "Taip pat automatiškai pakoreguoti {n} nustatymai dėl nuoseklumo.", + "cascade_toast": "Dėl nuoseklumo pakoreguoti ir kiti nustatymai: {n}", "suggestion_resolves": "Pritaikykite žemiau esantį laukiantį pasiūlymą ({val}), kad tai ištaisytumėte", "use_fix": "Naudoti {val}", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Simuliacijos rezultatas", "pg_across_cycles": "Per visus jūsų ciklus", "community_store": "Bendruomenės parduotuvė", - "online_account": "Bendruomenės parduotuvė ir interneto funkcijos" + "online_account": "Bendruomenės parduotuvė ir interneto funkcijos", + "import_power_history": "Galios istorijos importavimas" }, "health": { "fair": "Priimtina profilio kokybė", @@ -364,6 +373,7 @@ "poor": "⚠ Prasta profilio kokybė" }, "lbl": { + "drag_to_resize": "Vilkite, kad pakeistumėte dydį", "pg_anti_wrinkle": "Apsauga nuo raukšlių", "actions": "Veiksmai", "activity": "Veikla", @@ -435,7 +445,7 @@ "from": "Nuo", "gap_s": "Tarpas (-iai)", "group_name": "Grupės pavadinimas", - "head_trim": "Galvos apdaila (-ai)", + "head_trim": "Apkarpymas nuo pradžios (s)", "health": "Sveikata", "hide_tabs": "Slėpti skirtukus ne administratoriams", "in_use": "Naudojamas", @@ -451,7 +461,7 @@ "metric": "Metrika", "mode_existing_profile": "Pridėti prie esamo profilio", "mode_new_profile": "Sukurti naują profilį", - "models_fine_tuned": "({count} modeliai tiksliai sureguliuoti)", + "models_fine_tuned": "(tiksliai suderinti modeliai: {count})", "n_classic_suggestions": "{n} klasikinis", "n_ml_suggestions": "{n} ML", "n_selected": "Pasirinkta: {n}", @@ -532,7 +542,7 @@ "stage3": "3 etapas – DTW", "stage4": "4 etapas – atitikimas", "status": "Būsena", - "tail_trim": "Uodegos apdaila (-ai)", + "tail_trim": "Apkarpymas nuo galo (s)", "timer_auto_pause": "Automatinis pristabdymas", "timer_min": "min", "timer_msg_placeholder": "Pranešimas (pasirenkama, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_import_copy": "Importuoti kaip kopiją", "conflict_keep_mine": "Išlaikyti manuosius", "conflict_overwrite": "Perrašyti", - "font_size": "Skydelio šrifto dydis" + "font_size": "Skydelio šrifto dydis", + "hist_csv_data": "CSV duomenys", + "hist_from_recorder": "Arba nuskaitykite iš Home Assistant", + "hist_since": "Nuo", + "days": "d.", + "hist_keep": "Palikti šį ciklą", + "hist_looks_complete": "užbaigtas", + "peak_power_short": "Pikas", + "shape": "Forma", + "hist_skip_idle": "niekas neveikė", + "hist_skip_sparse": "per reti rodmenys", + "hist_skip_short": "per mažai rodmenų", + "hist_skip_long": "nėra pakankamai ilgos pauzės padalijimui", + "hist_reason_short": "trumpesnis nei trumpiausias tikras šio prietaiso ciklas", + "hist_reason_no_end": "niekada tvarkingai nepasibaigė", + "task_history_import": "Skenuojama galios istorija", + "task_history_import_apply": "Importuojami ciklai", + "evidence_real_cycles": "Šio prietaiso atlikti ciklai", + "evidence_reference_cycles": "Atsisiųsti iš bendruomenės parduotuvės", + "evidence_backfill_cycles": "Rasti importuotoje galios istorijoje" }, "log": { "all_levels": "Visi lygiai", @@ -756,9 +785,15 @@ "store_share": "Bendrinti bendruomenės parduotuvėje", "store_share_device": "Bendrinti šį įrenginį", "export_select": "Eksportas - pasirinkti duomenis", - "import_wizard": "Importas - pasirinkti duomenis" + "import_wizard": "Importas - pasirinkti duomenis", + "history_import": "Galios istorijos importavimas" }, "msg": { + "tail_trim_hint": "Kiek sekundžių pašalinti nuo galo", + "store_sibling_hint": "Jūsų tiksliam modeliui nieko nebendrinta? Artimas to paties prekės ženklo modelis paprastai yra gera pradžia.", + "store_declare_appliance": "Nurodykite WashData, kurį prietaisą turite, ir šiame skirtuke bus rodomos kitų jam bendrintos programos bei įrašai. Taip pat galite viršuje įrašyti prekės ženklą ir tiesiog pasižiūrėti.", + "refresh_catalog_hint": "Bendruomenės prekės ženklų ir prietaisų sąrašai laikomi podėlyje, kad bendrinama parduotuvė neviršytų savo dienos užklausų limito. Atnaujinkite, kad pamatytumėte kitų pridėtus ar patvirtintus įrašus.", + "head_trim_hint": "Kiek sekundžių pašalinti nuo pradžios", "appliance_monitor": "Prietaisų monitorius", "artifact_dip_detail": "Nukrito žemiau įprastos galios juostos maždaug {n}s.", "artifact_footer": "Paryškinta aukščiau esančiame grafike. Tai yra trumpalaikiai artefaktai (pvz., durys atidarytos ciklo viduryje), nebūtinai problemos.", @@ -769,7 +804,7 @@ "automations_intro": "WashData paleidžia {start} / {end} įvykius ir atskleidžia esybes, todėl pranešimai ir veiksmai geriausiai kuriami kaip įprasti Home Assistant automatizavimai. Žemiau rodomi automatizavimai, naudojantys šį prietaisą.", "cleanup_intro": "Kiekvienas pažymėtas ciklas padengtas. Pažymėkite nuokrypius ir ištrinkite, kad išvalytumėte profilį.", "clear_debug_hint": "Pašalinkite saugomus derinimo duomenis, kad atlaisvintumėte vietos.", - "collecting_data": "Renkami duomenys - reikia dar {need} ciklų, kad galėtų prasidėti tikslus derinimas ({current}/{min}).", + "collecting_data": "Renkami duomenys. Dar reikia ciklų, kad galėtų prasidėti tikslus derinimas: {need} ({current}/{min}).", "compare_overlay_profiles": "Perdangos profiliai (blankūs)", "compare_profiles_tip": "Aukščiau pateiktoje diagramoje uždėkite kitus profilio vokus, kad sužinotumėte, kuris iš jų geriausiai tinka šiam ciklui.", "compare_selected_cycles": "Pasirinkti ciklai (vientisas) – rodyti / slėpti", @@ -779,7 +814,7 @@ "cycles_deleted": "Ištrinta ciklų: {count}", "enough_data": "Pakankamai duomenų mokymui ({current}/{min} ciklų).", "export_description": "Pasirinkite tiksliai, kuriuos profilius, ciklus, nustatymus ir kita eksportuoti į JSON, arba išanalizuokite failą ir importuokite tik norimas dalis.", - "feedback_cycles_pending": "{n} cikl{s} peržiūrai", + "feedback_cycles_pending": "Peržiūrai: {n}", "feedback_prompt": "Patvirtinkite, kad tai buvo teisinga, pataisykite programą arba ignoruokite.", "feedback_relabel_hint": "Šio ciklo pažymėjimas iš naujo taip pat išsprendžia peržiūrą.", "filter_by_profile": "Filtruoti pagal profilį…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "budėjimo imitacija prasideda čia", "pg_sweep_intro": "O kas, jei {param} būtų kitokia? Išbandykite {steps} reikšmes paskutiniuose {cycles} cikluose ir raskite nustatymą, su kuriuo teisingai atitinka daugiausiai ciklų.", "pg_sweep_step": "Žingsnis {done} / {total}", - "pg_undetected": "Neaptikta ciklų: {n}", "pg_verdict_bad": "Reikia dėmesio: daugelis ciklų lieka neaptikti.", "pg_verdict_good": "Gerai suderinta: dauguma ciklų teisingai atpažįstami ir atitinkami.", "pg_verdict_ok": "Priimtina: kai kurie ciklai praleisti. Pabandykite sumažinti paleidimo slenkstį.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Pažymėkite tai kaip savo programos etaloninį ciklą – tą patį vaidmenį kaip ir rankiniu būdu įrašytą ciklą. Etaloniniai ciklai visada išsaugomi, imamasi atitinkamo šablono ir niekada nenuleidžiami valant. (Tai yra „auksinė“ / įrašyta vėliava; abi yra tas pats.)", "review_tags_tip": "Pasirenkamos vėliavėlės, nurodančios, kas nutiko šiame cikle, todėl mokymai ir valymas gali tai paaiškinti.", "review_to_cycles": "Atidarykite ciklų peržiūros eilę", + "samples_decimated": "Rodoma {shown} iš {total} pavyzdžių (praretinta rodymui; smailės išsaugotos). Platus tarpas čia yra praretinimas, o ne trūkstami duomenys.", "saving_triggers_reload": "Išsaugojus suaktyvinamas integracijos įkėlimas iš naujo. HA objektai trumpam gali būti rodomi kaip nepasiekiami.", "search_placeholder": "Paieškos nustatymai…", "see_recorder": "Žiūrėkite įrašymo valdiklį žemiau", @@ -1006,7 +1041,29 @@ "sug_mute_failed": "Nepavyko nutildyti pasiūlymo", "sug_unmuted_all": "Nutildyti pasiūlymai atstatyti", "n_suggestions_muted": "{count} nutildyti; automatinis derintuvas jų nebesiūlys.", - "font_size_hint": "Padidinkite arba sumažinkite visą tekstą šiame skydelyje. Galioja jūsų paskyrai šiame įrenginyje." + "font_size_hint": "Padidinkite arba sumažinkite visą tekstą šiame skydelyje. Galioja jūsų paskyrai šiame įrenginyje.", + "import_history_description": "Išmanųjį kištuką turėjote dar prieš WashData? Įkelkite jo galios sensoriaus istorijos eksportą arba nuskaitykite ją tiesiai iš Home Assistant, ir įprastas aptikimas peržiūrės tuos duomenis, todėl ankstesni ciklai atsiras jūsų „Ciklai“ sąraše ir juos bus galima pavadinti.", + "hist_input_hint": "Įkelkite CSV, atsisiųstą iš „Istorijos“ skydelio (objektas, būsena, paskutinis pakeitimas), arba leiskite WashData nuskaityti sensoriaus istoriją tiesiogiai. Tada aptikimas vykdomas lygiai taip pat kaip realiu laiku, o jūs pasirenkate, kuriuos rastus ciklus palikti.", + "hist_recorder_hint": "Nuskaito nuo jūsų pasirinktos datos iki dabar. Home Assistant pagal numatytuosius nustatymus išsamią istoriją saugo 10 dienų, o vėliau tik valandinius vidurkius, kurie yra per stambūs, kad iš jų būtų galima aptikti ciklus - senesnę datą rinkitės tik tuo atveju, jei jūsų recorder nustatytas saugoti ilgiau.", + "hist_scanning": "Jūsų istorija leidžiama per detektorių. Tai vykdoma fone - galite užverti šį dialogą ir sugrįžti vėliau.", + "hist_imported_count": "Importuota ciklų: {n}.", + "hist_duplicates": "Jau anksčiau importuota ir praleista: {n}.", + "hist_capped": "Pasiektas šio įrenginio importuotų ciklų limitas; likusieji neišsaugoti.", + "hist_next_step": "Jie yra jūsų „Ciklai“ sąraše, pažymėti kaip importuota istorija. Atverkite vieną ir paspauskite „Pažymėti“, kad nurodytumėte jo programą.", + "hist_rows_read": "Nuskaityta rodmenų: {n}", + "hist_entity_substituted": "nuskaityta {used} (šiam įrenginiui sukonfigūruotas {wanted})", + "hist_breaks": "Tarpai, kai sensorius buvo nepasiekiamas: {n}", + "hist_other_entity": "Praleisti kitų objektų rodmenys: {n}", + "hist_skipped_spans": "Praleistos atkarpos", + "hist_settings_used": "Aptikta naudojant esamus šio įrenginio nustatymus (minimali galia {w} W, išjungimo delsimas {s} s).", + "hist_none_found": "Toje istorijoje nepavyko aptikti nė vieno ciklo.", + "hist_found": "Rasta ciklų: {n}. Atžymėkite tai, kas neatrodo kaip tikras paleidimas - kol neimportuojate, nieko neišsaugoma.", + "hist_scan_capped": "Rodomi tik pirmieji kandidatai (rasta: {n}).", + "hist_recorder_empty": "Home Assistant neturi išsamios šio sensoriaus istorijos tuo laikotarpiu.", + "hist_scan_failed": "Skenavimas nepavyko.", + "hist_scan_expired": "To skenavimo rezultatų jau nėra. Nuskenuokite dar kartą.", + "hist_import_failed": "Importavimas nepavyko.", + "imported_history_readonly": "Aptikta importuotoje galios istorijoje. Tai daro įtaką programų atpažinimui, bet neįskaičiuojama į jūsų statistiką, o ciklo negalima apkarpyti ar padalyti. Paspauskite „Pažymėti“, kad nurodytumėte programą." }, "phase_desc": { "anti_crease": "Po pagrindinio ciklo būgnas intermitiškai sukasi, kad išvengtų audinių raukšlių.", @@ -1392,6 +1449,10 @@ "doc": "Išsaugokite visą galios pėdsaką ir atitinkamus kiekvieno ciklo derinimo duomenis. Naudinga trikčių šalinimui, bet padidina saugyklos dydį.", "label": "Įrašyti derinimo pėdsakus" }, + "smart_termination_duration_ratio": { + "doc": "Kaip toli į atitiktos programos numatytą trukmę ciklas turi būti pažengęs, kad išmanusis užbaigimas galėtų jį užbaigti anksčiau, kai sumažėja galia. Numatyta trukmė yra programos vidurkis, todėl prietaisuose, kurių veikimo laikas labai svyruoja - skalbyklės su šaltu žiemos ir šiltu vasaros tiekiamu vandeniu, jutiklinio džiovinimo džiovyklės, nuo apkrovos priklausančios programos - maždaug pusė visų paleidimų baigiasi trumpiau nei šis vidurkis ir niekada nesulaukia greito užbaigimo, o baigiasi tik per atsarginį skirtąjį laiką, kelias minutes vėliau. Sumažinkite tai (pvz., 0,85) tose mašinose, kad ankstyvas užbaigimas vis tiek suveiktų; padidinkite jį link 1,0, kad veiktų atsargiau. Palikite tuščią numatytajai vertei (0,98 arba 0,99 indaplovėms). Jis gali užbaigti ciklą tik anksčiau, niekada ne vėliau, ir niekada nesuveikia esant neaiškiai ar mažo patikimumo atitikčiai.", + "label": "Išmaniojo užbaigimo santykis" + }, "smoothing_window": { "doc": "Kiek išlyginamas neapdorotos galios signalas. Žemas (2) yra jautrus, bet triukšmingas; aukštas (5) išlygina smailes, bet padidina atsilikimą.", "label": "Išlyginimo langas" @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Durų atidarymo pabaigos laukimas", "doc": "Kiek laiko durys turi likti atviros, kad WashData užbaigtų ciklą, kai įjungta parinktis \"Durys automatiškai atsidaromos pabaigoje\". Pakankamai ilgai, kad ignoruotų greitai dedamą indą (numatytasis 60 s), pakankamai trumpai, kad greitai užbaigtų, kai prietaisas atidaro duris." + }, + "profile_evidence_sources": { + "label": "Ciklai, formuojantys programą", + "doc": "Kurie ciklai naudojami kiekvienos programos galios kreivei sudaryti ir baigtam ciklui su ja palyginti. Atžymėjus tam tikrą rūšį, ji nebeturi įtakos jūsų programoms, tačiau nieko neištrinama - ciklai lieka sąraše Ciklai ir juos vis tiek galima pažymėti arba ištrinti. Naudinga, jei nepasitikite importuotais duomenimis. Statistikai tai neturi įtakos: joje visada įskaičiuojami tik tie ciklai, kuriuos šis prietaisas tikrai atliko. Visų žymių pašalinimas ignoruojamas, nes programa be jokių ciklų niekada negalėtų būti atpažinta." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Pagrindinė konfigūracija" + }, + "profile_evidence": { + "label": "Profilio duomenys" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Apkarpoma…", "splitting": "Padalijama…", "deleting": "Trinama…", - "imported": "Importuota" + "imported": "Importuota", + "preparing": "Ruošiama…" }, "tab": { "advanced": "Išplėstinė", @@ -1659,6 +1728,7 @@ "wrong_profile": "Neteisingas profilis" }, "toast": { + "catalog_refreshed": "Bendruomenės katalogas atnaujintas", "access_saved": "Prieigos valdymas išsaugotas", "all_wiped": "Visi duomenys ištrinti", "analysis_complete_none": "Analizė baigta: jokių naujų pasiūlymų", @@ -1764,7 +1834,9 @@ "store_download_failed": "Atsisiuntimas nepavyko: {error}", "store_download_nothing": "Nieko naujo atsisiųsti -- ši konfigūracija jau yra jūsų įrenginyje.", "export_selective_done": "Eksportas atsisiųstas", - "import_selective_done": "Importuota {profiles} profilių ir {cycles} ciklų" + "import_selective_done": "Importuota {profiles} profilių ir {cycles} ciklų", + "hist_csv_required": "Pirmiausia įkelkite CSV failą arba įklijuokite jo turinį", + "file_read_failed": "Nepavyko nuskaityti to failo" }, "suggestion": { "both_agree": "WashData rekomenduoja", @@ -1860,7 +1932,7 @@ "thr_batch": "Paliktas šiek tiek virš p05 mažiausios aktyvios galios per {cycles} ciklus ({p05}W), kad pradžia būtų aptikta kuo anksčiau, o sustabdymo slenkstis liktų žemiau mažiausios veikiančios mašinos galios.", "tol_per_profile": "p75 kiekvieno profilio trukmės dispersijos per {profiles} profilius ({cycles} ciklai); stabilūs profiliai nebaudžiami.", "tol_pooled": "Pagrįsta sujungta {cycles} naujausių pažymėtų ciklų trukmės dispersija (p95 nuokrypis={dev}).", - "watchdog": "Paliktas kuo mažesnis, kiek saugu (vos didesnis nei p95 atnaujinimo tarpas {p95}s, min. 30s), kad strigtys būtų aptiktos greitai be klaidingų sustabdymų." + "watchdog": "Paliktas kuo mažesnis, kiek saugu (vos didesnis nei p95 atnaujinimo tarpas {p95}s ir bent 2x mėginių ėmimo intervalas {median}s, min. 30s), kad strigtys būtų aptiktos greitai be klaidingų sustabdymų." }, "exclusions": { "summary": "Neįtraukta {total} klaidingai aptiktų ciklų: {parts}.", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Indaplovė: tylos sekundės po numatytos trukmės, kol atleidžiamas ciklo pabaigos vandens išleidimo laukimas", + "smart_termination_duration_ratio": "Atitiktos programos numatytos trukmės dalis, kurią ciklas turi pasiekti, kad išmanusis užbaigimas galėtų jį užbaigti anksčiau; sumažinkite ją nuo apkrovos ar temperatūros priklausančioms mašinoms", "anti_wrinkle_enabled": "Sugerkite būgninius impulsus po pagrindinės fazės, o ne skaitykite juos kaip naujus ciklus", "anti_wrinkle_exit_power": "Kad apsauga nuo raukšlių išliktų aktyvi, galia tarp impulsų turi nukristi žemiau šios ribos", "anti_wrinkle_idle_timeout": "Tylos laikas, leidžiamas tarp dviejų būgninių impulsų prieš pasibaigiant apsaugai nuo raukšlių", @@ -1955,6 +2028,10 @@ "finished": "Ciklas pasiekė galutinę būseną ir baigėsi." }, "store": { + "your_model_tip": "Tai prietaisas, kurį nurodėte nustatymuose", + "your_model": "Jūsų modelis", + "search_brand_ph": "Ieškoti pagal prekės ženklą…", + "programs_count": "Programos: {n}", "browse": "Naršyti", "device": "Prietaisas", "favorites": "Mėgstamiausi", diff --git a/custom_components/ha_washdata/translations/panel/lv.json b/custom_components/ha_washdata/translations/panel/lv.json index dc8a45df..30e9b8aa 100644 --- a/custom_components/ha_washdata/translations/panel/lv.json +++ b/custom_components/ha_washdata/translations/panel/lv.json @@ -78,9 +78,12 @@ "awaiting": "Gaida apstiprinājumu", "imported_tip": "Importēts no kopienas veikala. Izmanto tikai saskaņošanai, netiek ieskaitīts statistikā.", "not_importable": "šeit nav pieejams", - "exists": "jau eksistē" + "exists": "jau eksistē", + "backfilled_tip": "Noteikts importētā jaudas vēsturē. Ietekmē tikai programmu saskaņošanu, netiek ieskaitīts statistikā." }, "btn": { + "set_brand_model": "Iestatīt zīmolu un modeli", + "refresh_catalog": "Atsvaidzināt katalogu", "add_device": "+ Pievienot ierīci", "add_device_tip": "Pievienojiet citu WashData ierīci", "add_maintenance": "Pievienot apkopes notikumu", @@ -241,7 +244,12 @@ "import_selected": "Importēt atlasītos", "back": "Atpakaļ", "mute_suggestion": "Pārtraukt ieteikt šo iestatījumu", - "reset_muted": "Atiestatīt klusinātos" + "reset_muted": "Atiestatīt klusinātos", + "import_power_history": "Importēt jaudas vēsturi", + "hist_read_recorder": "Nolasīt no Home Assistant", + "hist_scan": "Meklēt ciklus", + "hist_import_n": "Importēt ciklus ({n})", + "hist_goto_cycles": "Parādīt ciklus" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "Jābūt zem maksimālās pretgrumbu jaudas ({max} W)" }, "attn_sub": "Pirms saglabāšanas novērsiet konfliktus", - "attn_title": "{n} iestatījumu konflikts{s}", - "settings_banner": "{n} iestatījumu konflikts{s} – pārbaudiet iezīmētās sadaļas un labojiet pirms saglabāšanas.", + "attn_title": "Iestatījumu konflikti: {n}", + "settings_banner": "Iestatījumu konflikti: {n}. Pārbaudiet iezīmētās sadaļas un labojiet tās pirms saglabāšanas.", "settings_banner_btn": "Doties uz pirmo", "confidence": { "auto": "Jābūt vienādam vai virs atbilstības sliekšņa ({match})", - "learning": "Jābūt vienādam vai zem atbilstības sliekšņa ({match})", + "learning": "Jābūt vienādam vai virs atbilstības sliekšņa ({match})", "match_for_auto": "Jābūt vienādam vai zem automātiskās marķēšanas pārliecības ({alc})", - "match_for_learning": "Jābūt vienādam vai virs mācīšanās pārliecības ({lc})" + "match_for_learning": "Jābūt vienādam vai zem mācīšanās pārliecības ({lc})" }, "duration_ratio": { "max": "Jābūt lielākam par minimālo ilguma attiecību ({min})", @@ -298,7 +306,7 @@ "match": "Jābūt virs neatbilstības sliekšņa ({un})", "unmatch": "Jābūt zem atbilstības sliekšņa ({match}); pretējā gadījumā apstiprināta atbilstība uzreiz tiek atcelta" }, - "cascade_toast": "Automātiski tika koriģēti arī {n} iestatījumi konsekvences labad.", + "cascade_toast": "Konsekvences labad pielāgoti arī citi iestatījumi: {n}", "suggestion_resolves": "Lai to labotu, lietojiet zemāk esošo gaidošo ieteikumu ({val})", "use_fix": "Izmantot {val}", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Simulācijas rezultāts", "pg_across_cycles": "Pa visiem jūsu cikliem", "community_store": "Kopienas veikals", - "online_account": "Kopienas veikals un tiešsaistes funkcijas" + "online_account": "Kopienas veikals un tiešsaistes funkcijas", + "import_power_history": "Jaudas vēstures importēšana" }, "health": { "fair": "Pieņemama profila kvalitāte", @@ -364,6 +373,7 @@ "poor": "⚠ Slikta profila kvalitāte" }, "lbl": { + "drag_to_resize": "Velciet, lai mainītu izmēru", "pg_anti_wrinkle": "Pretgrumbu režīms", "actions": "Darbības", "activity": "Aktivitāte", @@ -435,7 +445,7 @@ "from": "No", "gap_s": "Atstarpe (s)", "group_name": "Grupas nosaukums", - "head_trim": "Galvas apdare (-es)", + "head_trim": "Apgriešana no sākuma (s)", "health": "Veselība", "hide_tabs": "Slēpt cilnes, kas nav administratori", "in_use": "Lietošanā", @@ -451,7 +461,7 @@ "metric": "Metrika", "mode_existing_profile": "Pievienot esošajam profilam", "mode_new_profile": "Izveidot jaunu profilu", - "models_fine_tuned": "({count} modeļi precizēti)", + "models_fine_tuned": "(precizētie modeļi: {count})", "n_classic_suggestions": "{n} klasiskais", "n_ml_suggestions": "{n} ML", "n_selected": "Atlasīti: {n}", @@ -532,7 +542,7 @@ "stage3": "3. posms – DTW", "stage4": "4. posms – atbilstība", "status": "Statuss", - "tail_trim": "Astes apdare (-es)", + "tail_trim": "Apgriešana no beigām (s)", "timer_auto_pause": "Automātiska pauze", "timer_min": "min", "timer_msg_placeholder": "Ziņojums (neobligāti, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_import_copy": "Importēt kā kopiju", "conflict_keep_mine": "Paturēt manējos", "conflict_overwrite": "Pārrakstīt", - "font_size": "Paneļa fonta lielums" + "font_size": "Paneļa fonta lielums", + "hist_csv_data": "CSV dati", + "hist_from_recorder": "Vai nolasiet to no Home Assistant", + "hist_since": "No", + "days": "dienas", + "hist_keep": "Saglabāt šo ciklu", + "hist_looks_complete": "pabeigts", + "peak_power_short": "Maksimums", + "shape": "Forma", + "hist_skip_idle": "nekas nedarbojās", + "hist_skip_sparse": "rādījumi pārāk reti", + "hist_skip_short": "pārāk maz rādījumu", + "hist_skip_long": "nav pietiekami garas pauzes, kur sadalīt", + "hist_reason_short": "īsāks par šīs iekārtas īsāko īsto ciklu", + "hist_reason_no_end": "nekad netika korekti pabeigts", + "task_history_import": "Jaudas vēstures skenēšana", + "task_history_import_apply": "Ciklu importēšana", + "evidence_real_cycles": "Šīs ierīces izpildītie cikli", + "evidence_reference_cycles": "Lejupielādēti no kopienas veikala", + "evidence_backfill_cycles": "Atrasti importētā jaudas vēsturē" }, "log": { "all_levels": "Visi līmeņi", @@ -756,9 +785,15 @@ "store_share": "Kopīgot kopienas veikalā", "store_share_device": "Kopīgot šo ierīci", "export_select": "Eksports - izvēlēties datus", - "import_wizard": "Imports - izvēlēties datus" + "import_wizard": "Imports - izvēlēties datus", + "history_import": "Jaudas vēstures importēšana" }, "msg": { + "tail_trim_hint": "Cik sekundes noņemt no beigām", + "store_sibling_hint": "Jūsu precīzajam modelim nekas nav kopīgots? Tuvs tā paša zīmola modelis parasti ir labs sākumpunkts.", + "store_declare_appliance": "Norādiet WashData, kura ierīce jums pieder, un šajā cilnē tiks parādītas citu tai kopīgotās programmas un ieraksti. Varat arī augšā ievadīt zīmolu un vienkārši paskatīties apkārt.", + "refresh_catalog_hint": "Kopienas zīmolu un ierīču saraksti tiek saglabāti kešatmiņā, lai kopīgotais veikals nepārsniegtu savu dienas pieprasījumu limitu. Atsvaidziniet, lai iegūtu citu pievienotos vai apstiprinātos ierakstus.", + "head_trim_hint": "Cik sekundes noņemt no sākuma", "appliance_monitor": "Ierīces monitors", "artifact_dip_detail": "Krita zem parastā jaudas joslas ~{n}s.", "artifact_footer": "Izcelts augstāk esošajā grafikā. Tie ir pārejoši artefakti (piemēram, durvis atvērtas cikla vidū), ne vienmēr problēmas.", @@ -769,7 +804,7 @@ "automations_intro": "WashData aktivizē {start} / {end} notikumus un atklāj entītijas, tāpēc paziņojumus un darbības vislabāk veidot kā parastas Home Assistant automatizācijas. Zemāk redzamas automatizācijas, kas izmanto šo ierīci.", "cleanup_intro": "Katrs iezīmētais cikls ir pārklāts. Atzīmējiet izņēmumus un izdzēsiet, lai notīrītu profilu.", "clear_debug_hint": "Noņemiet saglabātos atkļūdošanas datus, lai atbrīvotu vietu.", - "collecting_data": "Datu vākšana - nepieciešami vēl {need} cikli, lai varētu sākt precizēšanu ({current}/{min}).", + "collecting_data": "Notiek datu vākšana. Vēl nepieciešamie cikli, lai varētu sākt precizēšanu: {need} ({current}/{min}).", "compare_overlay_profiles": "Pārklājuma profili (blāvi)", "compare_profiles_tip": "Iepriekš redzamajā diagrammā pārklājiet citas profila aploksnes, lai redzētu, kura no tām vislabāk atbilst šim ciklam.", "compare_selected_cycles": "Atlasītie cikli (pastāvīgi) – parādīt/slēpt", @@ -779,7 +814,7 @@ "cycles_deleted": "Dzēsti cikli: {count}", "enough_data": "Pietiek datu mācīšanai ({current}/{min} cikli).", "export_description": "Izvēlieties precīzi, kurus profilus, ciklus, iestatījumus un citus datus eksportēt uz JSON, vai analizējiet failu un importējiet tikai vēlamās daļas.", - "feedback_cycles_pending": "{n} cikl{s} pārskatīšanai", + "feedback_cycles_pending": "Pārskatīšanai: {n}", "feedback_prompt": "Apstipriniet, ka tas bija pareizi, labojiet programmu vai ignorējiet.", "feedback_relabel_hint": "Šī cikla atkārtota marķēšana arī to atrisina.", "filter_by_profile": "Filtrēt pēc profila…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "dīkstāves simulācija sākas šeit", "pg_sweep_intro": "Kā būtu, ja {param} atšķirtos? Pārbaudiet {steps} vērtības pēdējos {cycles} ciklos, lai atrastu iestatījumu, kurā pareizi tiek saskaņoti visvairāk ciklu.", "pg_sweep_step": "Solis {done} / {total}", - "pg_undetected": "Neatklāti cikli: {n}", "pg_verdict_bad": "Nepieciešama uzmanība: daudzi cikli netiek noteikti.", "pg_verdict_good": "Labi noregulēts: lielākā daļa ciklu tiek pareizi identificēti un saskaņoti.", "pg_verdict_ok": "Pieņemami: daži cikli palaisti garām. Mēģiniet pazemināt sākuma slieksni.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Atzīmējiet to kā manuāli izvēlētu atsauces ciklu savai programmai – tāda pati loma kā manuāli ierakstītam ciklam. Atsauces cikli vienmēr tiek saglabāti, ievietojiet atbilstošo veidni, un tīrīšanas laikā tie nekad netiek atmesti. (Šis ir \"zelta\"/ierakstītais karogs; abi ir viens un tas pats.)", "review_tags_tip": "Izvēles karodziņi, kas apraksta, kas šajā ciklā nogāja greizi, tāpēc apmācība un tīrīšana var to izskaidrot.", "review_to_cycles": "Atveriet ciklu pārskatīšanas rindu", + "samples_decimated": "Rāda {shown} no {total} paraugiem (attēlošanai retināts; virsotnes saglabātas). Plaša atstarpe šeit ir retināšana, nevis trūkstoši dati.", "saving_triggers_reload": "Saglabāšana aktivizē integrācijas atkārtotu ielādi. HA entītijas var īslaicīgi parādīties kā nepieejamas.", "search_placeholder": "Meklēšanas iestatījumi…", "see_recorder": "Skatiet ierakstītāja logrīku zemāk", @@ -1006,7 +1041,29 @@ "sug_mute_failed": "Neizdevās klusināt ieteikumu", "sug_unmuted_all": "Klusināto ieteikumu atiestatīšana pabeigta", "n_suggestions_muted": "{count} klusināti; automātiskais regulētājs tos vairs nepiedāvās.", - "font_size_hint": "Palieliniet vai samaziniet visu šajā panelī. Attiecas uz jūsu kontu šajā ierīcē." + "font_size_hint": "Palieliniet vai samaziniet visu šajā panelī. Attiecas uz jūsu kontu šajā ierīcē.", + "import_history_description": "Viedā kontaktdakša jums bija jau pirms WashData? Augšupielādējiet tās jaudas sensora vēstures eksportu vai nolasiet to tieši no Home Assistant, un parastā noteikšana izies tam cauri, tāpēc iepriekšējie cikli parādīsies jūsu Ciklu sarakstā gatavi nosaukšanai.", + "hist_input_hint": "Augšupielādējiet CSV, kas lejupielādēts no Vēstures paneļa (entītija, stāvoklis, pēdējās izmaiņas), vai ļaujiet WashData nolasīt sensora vēsturi tieši. Pēc tam noteikšana izies tam cauri tieši tāpat kā reāllaikā, un jūs izvēlaties, kurus no atrastajiem cikliem paturēt.", + "hist_recorder_hint": "Nolasa no jūsu izvēlētā datuma līdz pašreizējam brīdim. Home Assistant pēc noklusējuma glabā detalizētu vēsturi 10 dienas, bet pēc tam tikai vidējās vērtības pa stundām, kas ir pārāk rupjas, lai no tām noteiktu ciklus - senāku datumu izvēlieties tikai tad, ja jūsu recorder ir iestatīts glabāt vairāk.", + "hist_scanning": "Jūsu vēsture tiek izlaista caur detektoru. Tas notiek fonā - varat aizvērt šo dialogu un atgriezties vēlāk.", + "hist_imported_count": "Importēti cikli: {n}.", + "hist_duplicates": "Jau iepriekš importēti un izlaisti: {n}.", + "hist_capped": "Sasniegts ierīces importēto ciklu ierobežojums; pārējie netika saglabāti.", + "hist_next_step": "Tie ir jūsu Ciklu sarakstā, atzīmēti kā importēta vēsture. Atveriet vienu un izmantojiet pogu Iezīmēt, lai norādītu tā programmu.", + "hist_rows_read": "Nolasīti rādījumi: {n}", + "hist_entity_substituted": "nolasīts {used} (šai ierīcei ir konfigurēts {wanted})", + "hist_breaks": "Pārtraukumi, kad sensors nebija pieejams: {n}", + "hist_other_entity": "Izlaisti citu entītiju rādījumi: {n}", + "hist_skipped_spans": "Izlaistie posmi", + "hist_settings_used": "Noteikts, izmantojot šīs ierīces pašreizējos iestatījumus (minimālā jauda {w} W, izslēgšanas aizkave {s} s).", + "hist_none_found": "Šajā vēsturē nevienu ciklu nebija iespējams noteikt.", + "hist_found": "Atrasti cikli: {n}. Noņemiet atzīmi visam, kas neizskatās pēc īstas darbības - līdz importēšanai nekas netiek saglabāts.", + "hist_scan_capped": "Tiek parādīti tikai pirmie kandidāti (atrasti: {n}).", + "hist_recorder_empty": "Home Assistant nav detalizētas vēstures par šo sensoru šajā periodā.", + "hist_scan_failed": "Skenēšana neizdevās.", + "hist_scan_expired": "Šī skenēšana vairs nav pieejama. Lūdzu, skenējiet vēlreiz.", + "hist_import_failed": "Importēšana neizdevās.", + "imported_history_readonly": "Noteikts importētā jaudas vēsturē. Tas ietekmē programmu saskaņošanu, bet netiek ieskaitīts jūsu statistikā, un to nevar apgriezt vai sadalīt. Izmantojiet pogu Iezīmēt, lai norādītu programmu." }, "phase_desc": { "anti_crease": "Laiku pa laikam pēc pabeigšanas veiciet īsus gājienus, lai samazinātu grumbiņas.", @@ -1392,6 +1449,10 @@ "doc": "Saglabājiet pilnas jaudas izsekošanu un atbilstošos atkļūdošanas datus katram ciklam. Noderīgs problēmu novēršanai, bet palielina krātuves apjomu.", "label": "Saglabāt atkļūdošanas pēdas" }, + "smart_termination_duration_ratio": { + "doc": "Cik tālu atbilstošās programmas paredzētajā ilgumā ciklam jābūt nonākušam, pirms vieda pabeigšana drīkst to pabeigt agrāk, kad jauda samazinās. Paredzētais ilgums ir programmas vidējais rādītājs, tāpēc ierīcēs, kuru darbības laiks stipri mainās - veļas mašīnas ar aukstu ziemas un siltu vasaras ieplūstošo ūdeni, žāvētāji ar sensora žāvēšanu, no slodzes atkarīgas programmas - apmēram puse no visiem cikliem beidzas īsāk par šo vidējo un nekad nesaņem ātro pabeigšanu, beidzoties tikai caur rezerves noildzi, dažas minūtes vēlāk. Samaziniet to (piem., 0,85) šajās mašīnās, lai agrā pabeigšana tomēr nostrādātu; palieliniet to virzienā uz 1,0, lai būtu piesardzīgāks. Atstājiet tukšu noklusējuma vērtībai (0,98 vai 0,99 trauku mazgājamām mašīnām). Tā var pabeigt ciklu tikai agrāk, nekad ne vēlāk, un nekad nenostrādā pie neskaidras vai zemas pārliecības atbilstības.", + "label": "Viedas pabeigšanas attiecība" + }, "smoothing_window": { "doc": "Cik daudz neapstrādāts jaudas signāls ir izlīdzināts. Zems (2) ir atsaucīgs, bet trokšņains; augsts (5) izlīdzina tapas, bet palielina nobīdi.", "label": "Izlīdzināšanas logs" @@ -1417,7 +1478,7 @@ "label": "Slēdža entītija" }, "watchdog_interval": { - "doc": "Cik bieži fona sargsuns pārbauda, ​​vai sensori nav apstājušies un vai nav pagājis noildzes laiks. Noklusējums 30 s.", + "doc": "Cik bieži fona sargsuns pārbauda, vai sensori nav apstājušies un vai nav pagājis noildzes laiks. Noklusējums 30 s.", "label": "Sargsuna intervāls" }, "notify_milestone_message": { @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Durvju atvēršanas beigu uzturēšanās laiks", "doc": "Cik ilgi durvis jāpaliek atvērtām, lai WashData beigtu ciklu, kad ir ieslēgts iestatījums \"Durvis automātiski atveras beigās\". Pietiekami ilgi, lai ignorētu ātru trauka pievienošanu (noklusējums 60 s), pietiekami īsi, lai ātri beigtu, kad mašīna atver durvis." + }, + "profile_evidence_sources": { + "label": "Cikli, kas veido programmu", + "doc": "Kuri cikli tiek izmantoti katras programmas jaudas līknes izveidei un pabeigta cikla saskaņošanai ar to. Ja kādam veidam noņemat atzīmi, tas vairs neietekmē jūsu programmas, taču nekas netiek dzēsts - cikli paliek sarakstā Cikli, un tos joprojām var iezīmēt vai izņemt. Noderīgi, ja neuzticaties importētajiem datiem. Statistiku tas neietekmē: tajā vienmēr tiek ieskaitīti tikai tie cikli, ko šī ierīce patiešām ir izpildījusi. Visu atzīmju noņemšana tiek ignorēta, jo programma bez neviena cikla nekad nevarētu tikt saskaņota." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Pamatkonfigurācija" + }, + "profile_evidence": { + "label": "Profila pamatdati" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Apgriež…", "splitting": "Sadala…", "deleting": "Dzēš…", - "imported": "Importēts" + "imported": "Importēts", + "preparing": "Sagatavo…" }, "tab": { "advanced": "Papildu", @@ -1659,6 +1728,7 @@ "wrong_profile": "Nepareizs profils" }, "toast": { + "catalog_refreshed": "Kopienas katalogs atsvaidzināts", "access_saved": "Piekļuves kontrole saglabāta", "all_wiped": "Visi dati ir izdzēsti", "analysis_complete_none": "Analīze pabeigta: nav jaunu ieteikumu", @@ -1764,7 +1834,9 @@ "store_download_failed": "Lejupielāde neizdevās: {error}", "store_download_nothing": "Nav nekā jauna lejupielādei - šis iestatījums jau ir jūsų ierīcē.", "export_selective_done": "Eksports lejupielādēts", - "import_selective_done": "Importēti {profiles} profili un {cycles} cikli" + "import_selective_done": "Importēti {profiles} profili un {cycles} cikli", + "hist_csv_required": "Vispirms ielādējiet CSV failu vai ielīmējiet tā saturu", + "file_read_failed": "Šo failu nevarēja nolasīt" }, "suggestion": { "both_agree": "WashData iesaka", @@ -1860,7 +1932,7 @@ "thr_batch": "Saglabāts nedaudz virs p05 zemākās aktīvās jaudas {cycles} ciklos ({p05}W), lai sākums tiktu uztverts pēc iespējas agrāk un apturēšanas slieksnis paliktu zem mašīnas zemākās darbības jaudas.", "tol_per_profile": "p75 no katra profila ilguma dispersijas {profiles} profilos ({cycles} cikli); stabili profili netiek sodīti.", "tol_pooled": "Pamatots ar apvienoto ilguma dispersiju {cycles} nesen marķētiem cikliem (p95 novirze={dev}).", - "watchdog": "Saglabāts pēc iespējas zemāks, cik droši (tikai nedaudz virs p95 atjaunināšanas intervāla {p95}s, min. 30s), lai apstāšanās tiktu atklāta ātri bez viltus apturēšanas." + "watchdog": "Saglabāts pēc iespējas zemāks, cik droši (tikai nedaudz virs p95 atjaunināšanas intervāla {p95}s un vismaz 2x paraugu ņemšanas intervāls {median}s, min. 30s), lai apstāšanās tiktu atklāta ātri bez viltus apturēšanas." }, "exclusions": { "summary": "Izslēgti {total} kļūdaini atpazīti cikli: {parts}.", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Trauku mazgājamā mašīna: klusās sekundes pēc paredzētā ilguma, pirms tiek atbrīvota cikla beigu ūdens izvadīšanas gaidīšana", + "smart_termination_duration_ratio": "Atbilstošās programmas paredzētā ilguma daļa, kas ciklam jāsasniedz, pirms vieda pabeigšana drīkst to pabeigt agrāk; samaziniet to no slodzes vai temperatūras atkarīgām mašīnām", "anti_wrinkle_enabled": "Absorbējiet griešanās impulsus pēc galvenās fāzes, nevis nolasiet tos kā jaunus ciklus", "anti_wrinkle_exit_power": "Lai pretgrumbu režīms paliktu aktīvs, jaudai starp impulsiem jānokrītas zem šīs vērtības", "anti_wrinkle_idle_timeout": "Atļautais klusuma laiks starp diviem griešanās impulsiem, pirms pretgrumbu režīms beidzas", @@ -1955,6 +2028,10 @@ "finished": "Cikls sasniedza gala stāvokli un beidzās." }, "store": { + "your_model_tip": "Šī ir ierīce, ko norādījāt iestatījumos", + "your_model": "Jūsu modelis", + "search_brand_ph": "Meklēt pēc zīmola…", + "programs_count": "Programmas: {n}", "browse": "Pārlūkot", "device": "Ierīce", "favorites": "Izlase", diff --git a/custom_components/ha_washdata/translations/panel/mk.json b/custom_components/ha_washdata/translations/panel/mk.json index 2cdf106e..23d9c077 100644 --- a/custom_components/ha_washdata/translations/panel/mk.json +++ b/custom_components/ha_washdata/translations/panel/mk.json @@ -78,9 +78,12 @@ "awaiting": "Чека одобрување", "imported_tip": "Увезено од продавницата на заедницата. Се користи само за совпаѓање, не се брои во статистиката.", "not_importable": "неприменливо", - "exists": "постои" + "exists": "постои", + "backfilled_tip": "Откриено во увезена историја на моќност. Влијае само на совпаѓањето на програмите, не се брои во статистиката." }, "btn": { + "set_brand_model": "Постави бренд и модел", + "refresh_catalog": "Освежи го каталогот", "add_device": "+ Додај уред", "add_device_tip": "Додадете друг уред WashData", "add_maintenance": "Додади настан за одржување", @@ -90,7 +93,7 @@ "apply_label": "Примени етикета", "apply_set_b": "Примени сет B", "apply_split": "Примени Сплит", - "apply_trim": "Нанесете го Trim", + "apply_trim": "Примени скратување", "auto_detect_split": "Автоматско откривање", "auto_label_cycles": "Циклуси за автоматско обележување", "auto_label_cycles_tip": "Автоматски доделува имиња на профили на неозначени циклуси чијашто доверба за совпаѓање го надминува прагот", @@ -211,8 +214,8 @@ "stop": "Стопирај", "submit_correction": "Поднесете корекција", "train_now": "Тренирај сега", - "trim": "Намали", - "trim_split": "Намали / Сплит", + "trim": "Скрати", + "trim_split": "Скрати / Раздели", "undo": "Врати", "use": "Користете", "wipe_all": "Избришете ги сите податоци", @@ -234,14 +237,19 @@ "download_device": "Преземи поставки на уредот", "share_device": "Сподели поставки на уредот", "share_device_tip": "Споделете ги програмите и поставките на овој уред со заедницата", - "share_n": "Сподели {n} програм{s}", + "share_n": "Сподели циклуси ({n})", "export_selected": "Извоз (избор на податоци)", "export_all": "Брз извоз на сè", "import_raw": "Напредно: замени сè од JSON", "download_export": "Преземи извоз", "analyze_import": "Анализирај датотека", "import_selected": "Увези избрано", - "back": "Назад" + "back": "Назад", + "import_power_history": "Увези историја на моќност", + "hist_read_recorder": "Прочитај од Home Assistant", + "hist_scan": "Побарај циклуси", + "hist_import_n": "Увези циклуси ({n})", + "hist_goto_cycles": "Прикажи ги циклусите" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "Мора да е под Макс. Моќноста Против Брчки ({max} Вт)" }, "attn_sub": "Поправете ги конфликтите пред зачувување", - "attn_title": "{n} конфликт{s} во поставките", - "settings_banner": "{n} конфликт{s} во поставките – проверете ги означените раздели и поправете пред зачувување.", + "attn_title": "Конфликти во поставките: {n}", + "settings_banner": "Конфликти во поставките: {n}. Проверете ги означените раздели и поправете ги пред зачувување.", "settings_banner_btn": "Оди на прв", "confidence": { "auto": "Мора да е на или над Прагот за Совпаѓање ({match})", - "learning": "Мора да е на или под Прагот за Совпаѓање ({match})", + "learning": "Мора да е на или над Прагот за Совпаѓање ({match})", "match_for_auto": "Мора да е на или под Довербата за Автоматско Означување ({alc})", - "match_for_learning": "Мора да е на или над Довербата при Учење ({lc})" + "match_for_learning": "Мора да е на или под Довербата при Учење ({lc})" }, "duration_ratio": { "max": "Мора да е поголем од Мин. Соодносот на Траење ({min})", @@ -298,7 +306,7 @@ "match": "Мора да е над Прагот за Несовпаѓање ({un})", "unmatch": "Мора да е под Прагот за Совпаѓање ({match}); инаку потврденото совпаѓање веднаш се откажува" }, - "cascade_toast": "За конзистентност, исто така се прилагодени уште {n} параметар{s}.", + "cascade_toast": "Други поставки прилагодени за конзистентност: {n}", "suggestion_resolves": "Активирајте го чекечкиот предлог ({val}) подолу за да го поправите ова", "use_fix": "Користи {val}", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Резултат од симулацијата", "pg_across_cycles": "Низ сите ваши циклуси", "community_store": "Продавница на заедницата", - "online_account": "Продавница на заедницата и онлајн функции" + "online_account": "Продавница на заедницата и онлајн функции", + "import_power_history": "Увоз на историја на моќност" }, "health": { "fair": "Прифатлив квалитет на профилот", @@ -364,6 +373,7 @@ "poor": "⚠ Лош квалитет на профилот" }, "lbl": { + "drag_to_resize": "Повлечете за промена на големината", "actions": "Акции", "activity": "Активност", "administrators": "Администраторите", @@ -435,7 +445,7 @@ "from": "Од", "gap_s": "Јаз (и)", "group_name": "Назив на групата", - "head_trim": "Намалување на главата (и)", + "head_trim": "Скратување на почетокот (с)", "health": "Здравје", "hide_tabs": "Скриј јазичиња за не-администратори", "in_use": "Во употреба", @@ -451,7 +461,7 @@ "metric": "Метрика", "mode_existing_profile": "Додај во постоечкиот профил", "mode_new_profile": "Креирај нов профил", - "models_fine_tuned": "({count} модел{plural} дотерани)", + "models_fine_tuned": "(фино подесени модели: {count})", "n_classic_suggestions": "{n} класично", "n_ml_suggestions": "{n} ML", "n_selected": "{n} избрани", @@ -533,7 +543,7 @@ "stage3": "Етапа 3 – DTW", "stage4": "Етапа 4 – согласување", "status": "Статус", - "tail_trim": "Намалување на опашката (и)", + "tail_trim": "Скратување на крајот (с)", "timer_auto_pause": "Автоматска пауза", "timer_min": "мин", "timer_msg_placeholder": "Порака (изборно, {device}/{program}/{minutes})", @@ -651,7 +661,7 @@ "show_contributor": "Прикажи соработник", "task_pg_detail": "Симулирај циклус", "task_split": "Разделување на циклус", - "task_trim": "Отсекување на циклус", + "task_trim": "Скратување на циклус", "task_merge": "Спојување на циклуси", "task_rebuild": "Повторно градење на обвивки", "cat_profiles": "Профили (програми)", @@ -677,7 +687,26 @@ "conflict_resolution": "Конфликти на имиња", "conflict_import_copy": "Увези како копија", "conflict_keep_mine": "Задржи ги моите", - "conflict_overwrite": "Презапиши" + "conflict_overwrite": "Презапиши", + "hist_csv_data": "CSV податоци", + "hist_from_recorder": "Или прочитај ја од Home Assistant", + "days": "дена", + "hist_keep": "Зачувај го овој циклус", + "hist_looks_complete": "завршен", + "peak_power_short": "Врв", + "shape": "Форма", + "hist_skip_idle": "ништо не работело", + "hist_skip_sparse": "читањата се премногу разретчени", + "hist_skip_short": "премалку читања", + "hist_skip_long": "нема доволно долга пауза за разделување", + "hist_reason_short": "пократок од најкраткиот вистински циклус на овој уред", + "hist_reason_no_end": "никогаш не заврши правилно", + "hist_since": "Од", + "task_history_import": "Скенирање на историјата на моќност", + "task_history_import_apply": "Увоз на циклуси", + "evidence_real_cycles": "Циклуси што ги извршил овој уред", + "evidence_reference_cycles": "Преземени од продавницата на заедницата", + "evidence_backfill_cycles": "Најдени во увезена историја на моќност" }, "log": { "all_levels": "Сите нивоа", @@ -756,9 +785,15 @@ "store_share": "Сподели во продавницата на заедницата", "store_share_device": "Сподели поставки на уредот", "export_select": "Извоз - избор на податоци", - "import_wizard": "Увоз - избор на податоци" + "import_wizard": "Увоз - избор на податоци", + "history_import": "Увоз на историја на моќност" }, "msg": { + "tail_trim_hint": "Отстранете толку секунди од крајот", + "store_sibling_hint": "Нема ништо споделено за вашиот точен модел? Близок модел од истиот бренд обично е добра почетна точка.", + "store_declare_appliance": "Кажете му на WashData кој уред го поседувате и оваа картичка ќе ги прикаже конфигурациите што другите ги споделиле за него. Можете и да напишете бренд погоре за да го разгледате каталогот.", + "refresh_catalog_hint": "Списоците со брендови и уреди на заедницата се чуваат во кеш за да остане продавницата на заедницата во рамките на своето дневно ограничување. Освежете за да ги преземете записите што други ги додале или одобриле.", + "head_trim_hint": "Отстранете толку секунди од почетокот", "appliance_monitor": "Монитор на апаратот", "artifact_dip_detail": "Падна под вообичаениот опсег на моќност за ~{n}с.", "artifact_footer": "Нагласено на графиконот погоре. Ова се минливи артефакти (на пр. вратата отворена во средината на циклусот), а не нужно проблеми.", @@ -769,7 +804,7 @@ "automations_intro": "WashData активира настани {start} / {end} и изложува ентитети, па известувањата и акциите е најдобро да се градат како нормални автоматизации на Home Assistant. Автоматизациите кои го користат овој уред се прикажани подолу.", "cleanup_intro": "Секој означен циклус е преклопен. Штиклирајте ги оддалечените и избришете за да го исчистите профилот.", "clear_debug_hint": "Отстранете ги зачуваните податоци за отстранување грешки за да ослободите простор.", - "collecting_data": "Собирање на податоци – уште {need} циклус{plural} пред почетокот на финото подесување ({current}/{min}).", + "collecting_data": "Собирање на податоци. Циклуси што уште се потребни пред да почне финото подесување: {need} ({current}/{min}).", "compare_overlay_profiles": "Преклопени профили (бледо)", "compare_profiles_tip": "Преклопете ги другите пликови на профили на табелата погоре за да видите кој најдобро одговара на овој циклус.", "compare_selected_cycles": "Избрани циклуси (цврсти) - прикажи / скриј", @@ -779,7 +814,7 @@ "cycles_deleted": "Избришани се {count} циклуси", "enough_data": "Доволно податоци за учење ({current}/{min} циклуси).", "export_description": "Изберете точно кои профили, циклуси, поставки и друго да се извезат во JSON, или анализирајте датотека и увезете само деловите што ги сакате.", - "feedback_cycles_pending": "{n} циклус{s} за преглед", + "feedback_cycles_pending": "За преглед: {n}", "feedback_prompt": "Потврдете дека е точно, поправете ја програмата или игнорирајте.", "feedback_relabel_hint": "Повторното етикетирање на овој циклус исто така го решава.", "filter_by_profile": "Филтрирај по профил…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "симулацијата на мирување почнува тука", "pg_sweep_intro": "Што ако {param} беше поинаков? Тестирајте {steps} вредности врз вашите последни {cycles} циклуси за да ја пронајдете поставката каде што најмногу циклуси се совпаѓаат правилно.", "pg_sweep_step": "Чекор {done} / {total}", - "pg_undetected": "{n} неоткриен циклус{s}", "pg_verdict_bad": "Бара внимание: многу циклуси остануваат неоткриени.", "pg_verdict_good": "Добро наместено: повеќето циклуси правилно се откриваат и совпаѓаат.", "pg_verdict_ok": "Прифатливо: некои циклуси се пропуштени. Обидете се да го намалите прагот за старт.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Означете го ова како рачно избран референтен циклус за неговата програма - иста улога како рачно снимен циклус. Референтните циклуси секогаш се чуваат, се засноваат соодветниот шаблон и никогаш не се испуштаат со чистење. (Ова е „златното“/снимено знаме; и двете се иста работа.)", "review_tags_tip": "Изборни знаменца што опишуваат што тргнало наопаку со овој циклус, така што обуката и чистењето можат да одговорат за тоа.", "review_to_cycles": "Отворете го редот за преглед на циклуси", + "samples_decimated": "Прикажани {shown} од {total} примероци (проретчени за приказ; врвовите се задржани). Широк процеп тука е проретчување, а не податоци што недостасуваат.", "saving_triggers_reload": "Зачувувањето активира повторно вчитување на интеграцијата. Ентитетите на HA може накратко да се покажат како недостапни.", "search_placeholder": "Поставки за пребарување…", "see_recorder": "Погледнете го додатокот за рекордер подолу", @@ -991,12 +1026,12 @@ "share_consent": "Споделувате вистински податоци од вашиот уред. Не споделувајте ако вашите обрасци за користење се приватни.", "share_device_none": "Сè уште нема поставени уреди. Прво додајте уред.", "share_guideline_naming": "Користете јасни имиња на програмите (напр. 'Cotton 40', 'Eco 60') за да можат другите да ги препознаат", - "share_guideline_quality": "Споделувајте само профили со ⭐ референтни циклуси или барем {n} потврдени стартувања", + "share_guideline_quality": "Споделувајте само циклуси што завршиле нормално -- без прекини среде циклусот, отворања на вратата или трепкања во напојувањето.", "share_guideline_review": "Прегледајте ги профилите пред споделување -- отстранете ги оние кои изгледаат погрешно", "share_guidelines_title": "Пред споделување", "store_download_device_intro": "Преземете поставки на уредот од заедницата и применете ги на нов или постоечки уред", - "store_share_device_intro": "Споделете ги програмите на вашиот уред (профили + референтни циклуси) со заедницата. Поставките се опционални.", - "share_profile_no_cycles": "Профилот '{p}' нема ⭐ референтни циклуси -- ќе биде прескокнат освен ако немате {n}+ потврдени стартувања", + "store_share_device_intro": "Прикачете {brand} {model} со референтните циклуси што ќе ги изберете. Други со истиот уред можат да ги преземат вашите програми. Записите се прегледуваат пред да станат јавни.", + "share_profile_no_cycles": "Нема референтни циклуси - означете циклус со ⭐ во картичката Циклуси за да го вклучите овој профил", "advisory_phase_inconsistent": "Изгледа дека '{name}' меша различни програми или температури - неговите циклуси се загреваат многу различно долго. Поделбата на посебни профили (на пр. по температура) ќе ги подобри совпаѓањето и проценките на времето.", "advisory_phase_inconsistent_title": "⚠ Можеби измешани програми", "export_select_intro": "Означете што точно да се вклучи. Изборот на профили без нивните циклуси сепак извезува препознатлива програма (нејзината научена форма се пренесува заедно со неа).", @@ -1006,7 +1041,29 @@ "merge_hint": "Увезените ставки се додаваат; ништо локално не се губи. Конфликтите на имиња се решаваат подолу.", "replace_warn": "Секоја означена категорија се брише и се заменува со податоците од датотеката. Неозначените категории остануваат непроменети.", "dest_reference_hint": "Увезените циклуси само го подобруваат препознавањето на програми и никогаш не влијаат на статистиката за користење/енергија.", - "dest_real_history_hint": "Увезените циклуси се сметаат за сопствена историја на овој уред и ја хранат статистиката за енергија/користење. Користете за преместување на еден уред на нова инсталација." + "dest_real_history_hint": "Увезените циклуси се сметаат за сопствена историја на овој уред и ја хранат статистиката за енергија/користење. Користете за преместување на еден уред на нова инсталација.", + "import_history_description": "Имавте паметен приклучок и пред WashData? Поставете извоз од историјата на неговиот сензор за моќност или прочитајте ја директно од Home Assistant, и вообичаената детекција ќе помине низ неа, така што минатите циклуси ќе се појават во списокот „Циклуси“ подготвени за именување.", + "hist_input_hint": "Поставете CSV преземен од панелот „Историја“ (ентитет, состојба, последна промена) или дозволете WashData да ја прочита историјата на сензорот директно. Потоа детекцијата поминува низ неа исто како и во живо, а вие избирате кои од најдените циклуси да ги зачувате.", + "hist_recorder_hint": "Ги чита податоците од избраниот датум до сега. Home Assistant стандардно чува детална историја 10 дена, а потоа само часовни просеци, кои се премногу груби за детекција на циклуси - изберете поран датум само ако вашиот recorder е поставен да чува повеќе.", + "hist_scanning": "Вашата историја се пушта низ детекторот. Ова се извршува во заднина - можете да го затворите овој дијалог и да се вратите подоцна.", + "hist_imported_count": "Увезени циклуси: {n}.", + "hist_duplicates": "Веќе увезени и прескокнати: {n}.", + "hist_capped": "Достигнато е ограничувањето на увезени циклуси по уред; останатите не се зачувани.", + "hist_next_step": "Тие се во списокот „Циклуси“, означени како увезена историја. Отворете еден и користете „Етикета“ за да ја наведете неговата програма.", + "hist_rows_read": "Прочитани читања: {n}", + "hist_breaks": "Празнини каде сензорот бил недостапен: {n}", + "hist_other_entity": "Прескокнати читања за други ентитети: {n}", + "hist_entity_substituted": "Прочитано {used} (овој уред е поставен на {wanted})", + "hist_skipped_spans": "Прескокнати делови", + "hist_settings_used": "Откриено со тековните поставки на овој уред (Минимална моќност {w} Вт, Одложување на исклучување {s} с).", + "hist_none_found": "Во таа историја не можеше да се открие ниту еден циклус.", + "hist_found": "Најдени циклуси: {n}. Отштиклирајте сè што не изгледа како вистинско пуштање - ништо не се зачувува додека не увезете.", + "hist_scan_capped": "Прикажани се само првите кандидати (најдени: {n}).", + "hist_recorder_empty": "Home Assistant нема детална историја за овој сензор во тој период.", + "hist_scan_failed": "Скенирањето не успеа.", + "hist_scan_expired": "Тоа скенирање повеќе не е достапно. Скенирајте повторно.", + "hist_import_failed": "Увозот не успеа.", + "imported_history_readonly": "Откриено во увезена историја на моќност. Влијае на совпаѓањето на програмите, но не се брои во вашата статистика и не може да се скратува или разделува. Користете „Етикета“ за да ја наведете програмата." }, "phase_desc": { "anti_crease": "Повремени кратки превртувања по завршувањето за да се намалат брчките.", @@ -1409,7 +1466,7 @@ "label": "Праг за почеток" }, "stop_threshold_w": { - "doc": "Напојувањето мора да падне под ова ниво пред да започне одбројувањето за исклучување. Поставете го под прагот за почеток - јазот меѓу нив е лентата за хистереза ​​што го спречува треперењето. Ако е поставено премногу високо, фазите со мала моќност (држење на плакнење, против туткање) лажно ја активираат крајната низа.", + "doc": "Напојувањето мора да падне под ова ниво пред да започне одбројувањето за исклучување. Поставете го под прагот за почеток - јазот меѓу нив е лентата за хистереза што го спречува треперењето. Ако е поставено премногу високо, фазите со мала моќност (држење на плакнење, против туткање) лажно ја активираат крајната низа.", "label": "Праг за стоп" }, "switch_entity": { @@ -1471,6 +1528,10 @@ "show_contributor": { "doc": "Прикажи го името на соработникот на профилите преземени од продавницата на заедницата" }, + "smart_termination_duration_ratio": { + "doc": "Колку длабоко во очекуваното траење на совпаднатата програма мора да навлезе циклусот пред Паметното завршување да може да го заврши предвреме штом падне моќноста. Очекуваното траење е просекот на програмата, па кај уреди со многу променливо време на работа - машини за перење при студена зимска и топла летна влезна вода, машини за сушење со сензор за влажност, програми што зависат од полнењето - околу половина од сите циклуси завршуваат пократко од тој просек и никогаш не го добиваат брзото завршување, туку завршуваат дури преку резервниот тајмаут со неколку минути задоцнување. Намалете ја оваа вредност (пр. 0.85) кај такви машини за да се активира сепак предвременото завршување; зголемете ја кон 1.0 за поконзервативно однесување. Оставете празно за стандардната вредност (0.98 или 0.99 за мијалници). Може само да заврши циклус порано, никогаш подоцна, и никогаш не се активира при двосмислено или недоволно сигурно совпаѓање.", + "label": "Сооднос на паметно завршување" + }, "enable_phase_matching": { "label": "Преостанато време според фазите", "doc": "Го дели секој активен циклус на фази (загревање, перење, центрифугирање) и го распределува преостанатото време по фази, во комбинација со класичната проценка - потпирајќи се на распределбата по фази на почетокот на циклусот, а на класичната проценка кон крајот. Ова го прилагодува одбројувањето на тоа колку навистина се загрева и работи вашата машина, што е најзабележливо во првата половина од циклусот. Исклучено = само класичната проценка. Влијае само на приказот на преостанатото време; совпаѓањето на програми и откривањето на циклуси остануваат непроменети." @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Задржување при отворена врата", "doc": "Колку долго вратата мора да остане отворена пред WashData да го заврши циклусот, кога е вклучено \"Вратата се отвора автоматски на крај\". Доволно долго за да се игнора брзото додавање сад (стандардно 60 с), доволно кратко за брзо завршување откако машината ја отвора вратата." + }, + "profile_evidence_sources": { + "label": "Циклуси што обликуваат програма", + "doc": "Кои циклуси се користат за изградба на кривата на моќност на секоја програма и за совпаѓање на завршен циклус со неа. Ако одзначите некој вид, тој престанува да ги обликува вашите програми, но ништо не се брише - циклусите остануваат во списокот Циклуси и сѐ уште можат да се означат или отстранат. Корисно ако не им верувате на увезените податоци. Статистиката не се менува: во неа секогаш се бројат само циклусите што овој уред навистина ги извршил. Одзначувањето на сè се игнорира, бидејќи програма без ниту еден циклус никогаш не би можела да се совпадне." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Основна конфигурација" + }, + "profile_evidence": { + "label": "Основа на профилот" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Скратување…", "splitting": "Разделување…", "deleting": "Бришење…", - "imported": "Увезено" + "imported": "Увезено", + "preparing": "Подготовка…" }, "tab": { "advanced": "Напредно", @@ -1659,6 +1728,7 @@ "wrong_profile": "Погрешен профил" }, "toast": { + "catalog_refreshed": "Каталогот на заедницата е освежен", "access_saved": "Контролата за пристап е зачувана", "all_wiped": "Сите податоци се избришани", "analysis_complete_none": "Анализата е завршена: нема нови предлози", @@ -1670,7 +1740,7 @@ "cycle_labelled": "Циклус означен", "cycle_paused": "Циклусот е паузиран", "cycle_resumed": "Циклусот продолжи", - "cycle_trimmed": "Циклус скратен", + "cycle_trimmed": "Циклусот е скратен", "cycles_merged": "Циклусите се споија", "envelope_rebuilt": "Пликот повторно изграден", "envelopes_rebuilt": "Пликовите повторно изградени", @@ -1752,19 +1822,21 @@ "rating_saved": "Оцената за квалитет е зачувана", "brand_added": "Брендот е додаден, чека одобрување", "profile_added": "Профилот е додаден, чека одобрување", - "saved_except_conflicts": "Поставките се зачувани -- {n} поставка{s} прескокната поради конфликти", + "saved_except_conflicts": "Зачувано. Поправете ги означените конфликти за да го зачувате останатото.", "share_device_none_sel": "Изберете барем една програма за споделување", - "store_device_downloaded": "Поставките на уредот се преземени: {created} профил{c} создаден, {dup} веќе постоеше", - "store_device_downloaded_phases": "Поставките на уредот се преземени: {created} профил{c} создаден, {dup} веќе постоеше, картата на фазите е применета", - "store_device_downloaded_settings": "Поставките на уредот се преземени: {created} профил{c} создаден, {dup} веќе постоеше, поставките се применети", - "store_device_shared": "Поставките на уредот се споделени: {n} програм{s} прикачена", - "store_device_shared_all_dup": "Нема ништо ново за споделување -- сите програми веќе постојат во продавницата", - "store_device_shared_partial": "Делумно споделување: {n} програм{s} прикачена, {failed} прескокнато", - "store_device_shared_some_dup": "Поставките на уредот се споделени: {n} програм{s} прикачена ({dup} веќе постоеше)", + "store_device_downloaded": "Додадено: програми {p}, снимки {c}", + "store_device_downloaded_phases": "Додадено: програми {p}, снимки {c}, карти на фазите {ph}", + "store_device_downloaded_settings": "Додадено: програми {p}, снимки {c}, карти на фазите {ph}, поставки {s}", + "store_device_shared": "Циклуси споделени во продавницата на заедницата: {n}. Се чека преглед.", + "store_device_shared_all_dup": "Сите циклуси ({n}) веќе беа во продавницата на заедницата.", + "store_device_shared_partial": "Споделени циклуси: {n}; неприкачени: {failed}.", + "store_device_shared_some_dup": "Споделени циклуси: {created}; веќе во продавницата: {dup}.", "store_download_failed": "Неуспешно преземање: {error}", "store_download_nothing": "Нема ништо за преземање -- сите профили веќе постојат на овој уред", "export_selective_done": "Извозот е преземен", - "import_selective_done": "Увезени се {profiles} профил(и) и {cycles} циклус(и)" + "import_selective_done": "Увезени се {profiles} профил(и) и {cycles} циклус(и)", + "hist_csv_required": "Најпрво вчитајте CSV датотека или вметнете ја нејзината содржина", + "file_read_failed": "Таа датотека не можеше да се прочита" }, "suggestion": { "both_agree": "WashData препорачува", @@ -1860,7 +1932,7 @@ "thr_batch": "Задржано веднаш над најниската работна моќност p05 низ {cycles} циклуси ({p05}W) за стартот да се фати што е можно порано, а прагот на запирање да остане под најниската работна моќност на машината.", "tol_per_profile": "p75 на варијансата на траењето по профил низ {profiles} профили ({cycles} циклуси); доследните профили не се казнуваат.", "tol_pooled": "Врз основа на здружената варијанса на траењето на {cycles} неодамнешни означени циклуси (p95 отстапување={dev}).", - "watchdog": "Задржано што е можно пониско додека е безбедно (малку над интервалот на ажурирање p95 од {p95}s, мин. 30s) за застоите брзо да се фатат без лажни запирања." + "watchdog": "Задржано што е можно пониско додека е безбедно (малку над интервалот на ажурирање p95 од {p95}s и барем 2x интервалот на земање примероци од {median}s, мин. 30s) за застоите брзо да се фатат без лажни запирања." }, "exclusions": { "summary": "Исклучени се {total} погрешно откриени циклуси: {parts}.", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Машина за миење садови: секунди неактивност по очекуваното траење пред да се ослободи чекањето за испумпување на крајот од циклусот", + "smart_termination_duration_ratio": "Дел од очекуваното траење на совпаднатата програма што циклусот мора да го достигне пред Паметното завршување да може да го заврши предвреме; намалете го за машини што зависат од полнењето или температурата", "completion_min_seconds": "Најкратко извршување што се смета за вистински циклус", "end_repeat_count": "Ниски отчитувања по ред пред завршување", "interrupted_min_seconds": "Кратки циклуси означени како прекинати", @@ -1955,6 +2028,10 @@ "finished": "Циклусот достигна крајна состојба и заврши." }, "store": { + "your_model_tip": "Ова е уредот што го наведовте во поставките", + "your_model": "Ваш", + "search_brand_ph": "Пребарување по бренд…", + "programs_count": "Програми: {n}", "browse": "Прелистување", "device": "Уред", "favorites": "Омилени", @@ -1993,6 +2070,7 @@ "add_profile": "Додајте профил за овој уред на сајтот на заедницата" }, "task": { + "cancelling": "Откажување...", "reprocess": { "matching": "Повторна обработка: совпаѓање на циклуси", "golden": "Повторна обработка: пополнување референтни", @@ -2010,7 +2088,7 @@ "apply": "Разделување на циклус" }, "trim": { - "apply": "Отсекување на циклус" + "apply": "Скратување на циклус" }, "merge": { "apply": "Спојување на циклуси" diff --git a/custom_components/ha_washdata/translations/panel/nb.json b/custom_components/ha_washdata/translations/panel/nb.json index ff0769e1..f18ce3b4 100644 --- a/custom_components/ha_washdata/translations/panel/nb.json +++ b/custom_components/ha_washdata/translations/panel/nb.json @@ -78,9 +78,12 @@ "awaiting": "Venter på godkjenning", "imported_tip": "Importert fra fellesskapsbutikken. Brukes kun til matching, telles ikke med i statistikken.", "not_importable": "ikke her", - "exists": "finnes" + "exists": "finnes", + "backfilled_tip": "Oppdaget i importert effekthistorikk. Påvirker bare programmatching, telles ikke med i statistikken." }, "btn": { + "set_brand_model": "Angi merke og modell", + "refresh_catalog": "Oppdater katalog", "add_device": "+ Legg til enhet", "add_device_tip": "Legg til en annen WashData-enhet", "add_maintenance": "Legg til vedlikeholdshendelse", @@ -90,7 +93,7 @@ "apply_label": "Påfør etikett", "apply_set_b": "Bruk sett B", "apply_split": "Påfør Split", - "apply_trim": "Påfør Trim", + "apply_trim": "Bruk beskjæring", "auto_detect_split": "Automatisk gjenkjenning", "auto_label_cycles": "Automatiske etikettsykluser", "auto_label_cycles_tip": "Tilordne automatisk profilnavn til umerkede sykluser der matchkonfidensen overstiger terskelen", @@ -209,8 +212,8 @@ "stop": "Stopp", "submit_correction": "Send inn rettelse", "train_now": "Tren nå", - "trim": "Trim", - "trim_split": "Trim / Splitt", + "trim": "Beskjær", + "trim_split": "Beskjær / Splitt", "undo": "Angre", "use": "Bruk", "wipe_all": "Tørk alle data", @@ -241,7 +244,12 @@ "import_selected": "Importer valgte", "back": "Tilbake", "mute_suggestion": "Slutt å foreslå denne innstillingen", - "reset_muted": "Tilbakestill dempede forslag" + "reset_muted": "Tilbakestill dempede forslag", + "import_power_history": "Importer effekthistorikk", + "hist_read_recorder": "Les fra Home Assistant", + "hist_scan": "Søk etter sykluser", + "hist_import_n": "Importer {n} sykluser", + "hist_goto_cycles": "Vis meg syklusene" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Må være under maksimal anti-krøll-effekt ({max} W)" }, "attn_sub": "Rett konflikter før du lagrer", - "attn_title": "{n} innstillingskonflikt{s}", + "attn_title": "Innstillingskonflikter: {n}", "confidence": { "auto": "Må være ved eller over matchingsterskelen ({match})", - "learning": "Må være ved eller under matchingsterskelen ({match})", + "learning": "Må være ved eller over matchingsterskelen ({match})", "match_for_auto": "Må være ved eller under auto-merke-konfidensen ({alc})", - "match_for_learning": "Må være ved eller over læringskonfidensen ({lc})" + "match_for_learning": "Må være ved eller under læringskonfidensen ({lc})" }, "duration_ratio": { "max": "Må være større enn min varighetsforhold ({min})", @@ -296,14 +304,14 @@ "match": "Må være over avmatch-terskelen ({un})", "unmatch": "Må være under matchingsterskelen ({match}); ellers avmatchs en bekreftet match øyeblikkelig" }, - "cascade_toast": "Også {n} innstilling{s} justert for konsistens.", + "cascade_toast": "Andre innstillinger justert for konsistens: {n}", "suggestion_resolves": "Bruk forslaget nedenfor ({val}) for å løse dette", "use_fix": "Bruk {val}", "watchdog": { "interval": "Bør være minst 2x samplingsintervallet ({si} s)", "sampling": "Samplingsintervall bør være høyst halvparten av vaktbikkjeintervallet ({wi} s)" }, - "settings_banner": "{n} innstillingskonflikt{s} – sjekk de fremhevede seksjonene og rett dem opp før du lagrer.", + "settings_banner": "Innstillingskonflikter: {n}. Sjekk de fremhevede seksjonene og rett dem opp før du lagrer.", "settings_banner_btn": "Gå til første" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Simuleringsresultat", "pg_across_cycles": "På tvers av syklusene dine", "community_store": "Fellesskapsbutikk", - "online_account": "Fellesskapsbutikk og nettfunksjoner" + "online_account": "Fellesskapsbutikk og nettfunksjoner", + "import_power_history": "Importer effekthistorikk" }, "health": { "fair": "Akseptabel profilkvalitet", @@ -364,6 +373,7 @@ "poor": "⚠ Dårlig profilkvalitet" }, "lbl": { + "drag_to_resize": "Dra for å endre størrelse", "actions": "Handlinger", "activity": "Aktivitet", "administrators": "Administratorer", @@ -450,7 +460,7 @@ "metric": "Metrikk", "mode_existing_profile": "Legg til i eksisterende profil", "mode_new_profile": "Opprett ny profil", - "models_fine_tuned": "({count} modell{plural} finjustert)", + "models_fine_tuned": "(finjusterte modeller: {count})", "n_classic_suggestions": "{n} klassisk", "n_ml_suggestions": "{n} ML", "n_selected": "{n} valgt", @@ -677,7 +687,26 @@ "conflict_keep_mine": "Behold mine", "conflict_overwrite": "Overskriv", "pg_anti_wrinkle": "Anti-rynke", - "font_size": "Panelets skriftstørrelse" + "font_size": "Panelets skriftstørrelse", + "hist_csv_data": "CSV-data", + "hist_from_recorder": "Eller les den fra Home Assistant", + "hist_since": "Siden", + "days": "dager", + "hist_keep": "Behold denne syklusen", + "hist_looks_complete": "komplett", + "peak_power_short": "Topp", + "shape": "Form", + "hist_skip_idle": "ingenting kjørte", + "hist_skip_sparse": "avlesninger for langt fra hverandre", + "hist_skip_short": "for få avlesninger", + "hist_skip_long": "ingen pause lang nok å dele på", + "hist_reason_short": "kortere enn dette apparatets korteste virkelige syklus", + "hist_reason_no_end": "avsluttet aldri rent", + "task_history_import": "Søker gjennom effekthistorikk", + "task_history_import_apply": "Importerer sykluser", + "evidence_real_cycles": "Sykluser denne maskinen har kjørt", + "evidence_reference_cycles": "Lastet ned fra fellesskapsbutikken", + "evidence_backfill_cycles": "Funnet i importert effekthistorikk" }, "log": { "all_levels": "Alle nivåer", @@ -756,9 +785,15 @@ "store_share": "Del i fellesskapsbutikken", "store_share_device": "Del dette apparatet", "export_select": "Eksport - velg data", - "import_wizard": "Import - velg data" + "import_wizard": "Import - velg data", + "history_import": "Importer effekthistorikk" }, "msg": { + "tail_trim_hint": "Fjern så mange sekunder fra slutten", + "store_sibling_hint": "Er ingenting delt for nøyaktig din modell? En nært beslektet modell fra samme merke er vanligvis et godt utgangspunkt.", + "store_declare_appliance": "Fortell WashData hvilket apparat du har, så viser denne fanen oppsettene andre har delt for det. Du kan også skrive et merke ovenfor for å se deg rundt.", + "refresh_catalog_hint": "Fellesskapets lister over merker og apparater mellomlagres for at fellesskapsbutikken skal holde seg innenfor sin daglige grense. Oppdater for å hente bidrag som andre har lagt til eller godkjent.", + "head_trim_hint": "Fjern så mange sekunder fra starten", "appliance_monitor": "Apparatmonitor", "artifact_dip_detail": "Falt under det vanlige effektbåndet i ~{n}s.", "artifact_footer": "Fremhevet på grafen over. Dette er forbigående artefakter (f.eks. døren åpnet midt i syklusen), ikke nødvendigvis problemer.", @@ -769,7 +804,7 @@ "automations_intro": "WashData utløser {start} / {end}-hendelser og gjør entiteter tilgjengelige, slik at varsler og handlinger best bygges som vanlige Home Assistant-automatiseringer. Automatiseringer som bruker denne enheten, vises nedenfor.", "cleanup_intro": "Hver merket syklus er lagt over. Kryss av for uteliggere og slett for å rydde opp i profilen.", "clear_debug_hint": "Fjern lagrede feilsøkingsdata for å frigjøre plass.", - "collecting_data": "Samler inn data: {need} syklus{plural} til før finjustering kan starte ({current}/{min}).", + "collecting_data": "Samler inn data. Sykluser som mangler før finjustering kan starte: {need} ({current}/{min}).", "compare_overlay_profiles": "Overleggsprofiler (svak)", "compare_profiles_tip": "Legg over andre profilkonvolutter på diagrammet ovenfor for å se hvilken som passer best til denne syklusen.", "compare_selected_cycles": "Valgte sykluser (fast) – vis / skjul", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} syklus(er) slettet", "enough_data": "Nok data til å lære av ({current}/{min} sykluser).", "export_description": "Velg nøyaktig hvilke profiler, sykluser, innstillinger og mer som skal eksporteres til JSON, eller analyser en fil og importer bare delene du vil ha.", - "feedback_cycles_pending": "{n} syklus{s} til gjennomgang", + "feedback_cycles_pending": "Til gjennomgang: {n}", "feedback_prompt": "Bekreft at det var riktig, korriger programmet eller ignorer.", "feedback_relabel_hint": "Å merke denne syklusen på nytt løser også gjennomgangen.", "filter_by_profile": "Filtrer etter profil…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "inaktiv simulering starter her", "pg_sweep_intro": "Hva om {param} var annerledes? Test {steps} verdier på tvers av dine siste {cycles} sykluser for å finne innstillingen der flest sykluser blir riktig matchet.", "pg_sweep_step": "Trinn {done} / {total}", - "pg_undetected": "{n} syklus{s} ikke oppdaget", "pg_verdict_bad": "Trenger oppmerksomhet: mange sykluser blir ikke oppdaget.", "pg_verdict_good": "Godt innstilt: de fleste sykluser blir riktig identifisert og matchet.", "pg_verdict_ok": "Akseptabelt: noen sykluser ble oversett. Prøv å senke startterskelen.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Merk dette som en håndplukket referansesyklus for programmet - samme rolle som en manuelt registrert syklus. Referansesykluser beholdes alltid, setter den matchende malen, og blir aldri droppet ved opprydding. (Dette er det \"gyldne\"/innspilte flagget; begge er det samme.)", "review_tags_tip": "Valgfrie flagg som beskriver hva som gikk galt med denne syklusen, slik at trening og opprydding kan forklare det.", "review_to_cycles": "Åpne Cycles review-køen", + "samples_decimated": "Viser {shown} av {total} prøver (tynnet ut for visning; topper beholdes). Et bredt hull her er uttynning, ikke manglende data.", "saving_triggers_reload": "Lagring utløser en ny integrasjon. HA-enheter kan kort vises som utilgjengelige.", "search_placeholder": "Søkeinnstillinger...", "see_recorder": "Se opptaker-widget nedenfor", @@ -1006,10 +1041,33 @@ "sug_mute_failed": "Kunne ikke dempe forslaget", "sug_unmuted_all": "Dempede forslag tilbakestilt", "n_suggestions_muted": "{count} dempet; autotuner vil ikke foreslå disse.", - "font_size_hint": "Gjør alt i dette panelet større eller mindre. Gjelder for kontoen din på denne enheten." + "font_size_hint": "Gjør alt i dette panelet større eller mindre. Gjelder for kontoen din på denne enheten.", + "import_history_description": "Hadde du allerede en smartplugg før du tok i bruk WashData? Last opp en historikkeksport fra effektsensoren, eller la den leses rett fra Home Assistant, og den vanlige deteksjonen kjører over den slik at tidligere sykluser dukker opp i Sykluser-listen din, klare til å navngis.", + "hist_input_hint": "Last opp en CSV-fil lastet ned fra Historikk-panelet (entitet, tilstand, sist endret), eller la WashData lese sensorens historikk direkte. Deteksjonen kjører deretter over den akkurat som live, og du velger hvilke av syklusene den finner du vil beholde.", + "hist_recorder_hint": "Leser fra datoen du velger og fram til nå. Home Assistant beholder detaljert historikk i 10 dager som standard og deretter bare timesgjennomsnitt, som er for grove å oppdage sykluser fra - velg en dato lenger tilbake bare hvis recorderen din er satt til å beholde mer.", + "hist_scanning": "Historikken din spilles gjennom deteksjonen. Dette kjører i bakgrunnen - du kan lukke denne dialogen og komme tilbake til den senere.", + "hist_imported_count": "{n} sykluser importert.", + "hist_duplicates": "{n} var allerede importert og ble hoppet over.", + "hist_capped": "Grensen per enhet for importerte sykluser ble nådd; resten ble ikke lagret.", + "hist_next_step": "De ligger i Sykluser-listen din, merket som importert historikk. Åpne en og bruk Merk for å navngi programmet den hører til.", + "hist_rows_read": "{n} avlesninger lest", + "hist_entity_substituted": "leste {used} (denne enheten er konfigurert for {wanted})", + "hist_breaks": "{n} hull der sensoren var utilgjengelig", + "hist_other_entity": "{n} avlesninger for andre entiteter ignorert", + "hist_skipped_spans": "Utelatte strekninger", + "hist_settings_used": "Oppdaget med denne enhetens gjeldende innstillinger (minimum effekt {w} W, av-forsinkelse {s} s).", + "hist_none_found": "Ingen sykluser kunne oppdages i den historikken.", + "hist_found": "Fant {n} sykluser. Fjern avkryssingen for alt som ikke ser ut som en virkelig kjøring - ingenting lagres før du importerer.", + "hist_scan_capped": "Bare de første kandidatene vises ({n} ble funnet).", + "hist_recorder_empty": "Home Assistant har ingen detaljert historikk for denne sensoren i det vinduet.", + "hist_scan_failed": "Søket mislyktes.", + "hist_scan_expired": "Det søket er ikke lenger tilgjengelig. Søk på nytt.", + "hist_import_failed": "Importen mislyktes.", + "imported_history_readonly": "Oppdaget i importert effekthistorikk. Den påvirker programmatching, men telles ikke med i statistikken din og kan ikke beskjæres eller deles. Merk den for å navngi programmet." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Oppvaskmaskin: stille sekunder etter forventet varighet før ventingen på tømming ved slutten av syklusen frigjøres", + "smart_termination_duration_ratio": "Andel av det gjenkjente programmets forventede varighet som en syklus må nå før smart avslutning kan avslutte den tidlig; senk den for last- eller temperaturavhengige maskiner", "completion_min_seconds": "Korteste kjøring som teller som en ekte syklus", "end_repeat_count": "Lave avlesninger på rad før avslutning", "interrupted_min_seconds": "Korte sykluser merkes som avbrutt", @@ -1421,6 +1479,10 @@ "doc": "Lagre full effektsporing og samsvarende feilsøkingsdata for hver syklus. Nyttig for feilsøking, men øker lagringsstørrelsen.", "label": "Lagre feilsøkingsspor" }, + "smart_termination_duration_ratio": { + "doc": "Hvor langt inn i det gjenkjente programmets forventede varighet en syklus må være før smart avslutning kan avslutte den tidlig når effekten faller. Den forventede varigheten er programmets gjennomsnitt, så på apparater med sterkt varierende kjøretid - vaskemaskiner med kaldt vinter- kontra varmt sommerinnløpsvann, tørketromler med fuktsensor, lastavhengige programmer - avslutter omtrent halvparten av alle kjøringer kortere enn dette gjennomsnittet og får aldri den raske avslutningen, men avsluttes først minutter senere via reserve-tidsavbruddet. Senk denne verdien (f.eks. 0,85) på slike maskiner slik at den tidlige avslutningen fortsatt utløses; øk den mot 1,0 for å være mer forsiktig. La feltet stå tomt for standarden (0,98, eller 0,99 for oppvaskmaskiner). Den kan bare avslutte en syklus tidligere, aldri senere, og utløses aldri ved en tvetydig eller usikker gjenkjenning.", + "label": "Forhold for smart avslutning" + }, "smoothing_window": { "doc": "Hvor mye råeffektsignalet jevnes ut. Lav (2) er responsiv, men støyende; høy (5) jevner ut pigger, men legger til etterslep.", "label": "Utjevningsvindu" @@ -1551,6 +1613,10 @@ "door_end_dwell_seconds": { "label": "Dørens åpningstid ved avslutning", "doc": "Hvor lenge døren må forbli åpen før WashData avslutter syklusen, når \"Dør åpnes automatisk ved avslutning\" er aktivert. Lang nok til å ignorere rask tillegging av et fat (standard 60 s), kort nok til å avslutte raskt når maskinen spretter opp døren." + }, + "profile_evidence_sources": { + "label": "Sykluser som former et program", + "doc": "Hvilke sykluser som brukes til å bygge opp effektkurven for hvert program, og til å matche en ferdig syklus mot den. Fjerner du haken for en type, former den ikke lenger programmene dine, uten at noe slettes - syklusene blir liggende i Sykluser-listen din og kan fortsatt merkes eller fjernes. Nyttig hvis du ikke stoler på importerte data. Statistikken påvirkes ikke: den teller alltid bare syklusene denne maskinen faktisk har kjørt. Å fjerne alle hakene blir ignorert, siden et program uten sykluser bak seg aldri kunne matche." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "Grunnleggende konfigurasjon" + }, + "profile_evidence": { + "label": "Profilgrunnlag" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "Beskjærer…", "splitting": "Deler opp…", "deleting": "Sletter…", - "imported": "Importert" + "imported": "Importert", + "preparing": "Forbereder…" }, "suggestion": { "both_agree": "WashData anbefaler", @@ -1754,7 +1824,7 @@ "thr_batch": "Holdt like over den laveste aktive effekten ved p05 på tvers av {cycles} sykluser ({p05}W) slik at en start fanges så tidlig som mulig og stoppterskelen holder seg under maskinens laveste driftseffekt.", "tol_per_profile": "p75 for varighetsvariansen per profil på tvers av {profiles} profiler ({cycles} sykluser); stramme profiler straffes ikke.", "tol_pooled": "Basert på samlet varighetsvarians for {cycles} nyeste merkede sykluser (p95 avvik={dev}).", - "watchdog": "Holdt så lavt som forsvarlig (like over p95-oppdateringsmellomrommet på {p95}s, min 30s) slik at stans fanges raskt uten falske stopp." + "watchdog": "Holdt så lavt som forsvarlig (like over p95-oppdateringsmellomrommet på {p95}s og minst 2x samplingsintervallet på {median}s, min. 30s) slik at stans fanges raskt uten falske stopp." }, "exclusions": { "summary": "Ekskluderte {total} feildetekterte syklus(er): {parts}.", @@ -1801,6 +1871,7 @@ "wrong_profile": "Feil profil" }, "toast": { + "catalog_refreshed": "Fellesskapskatalogen er oppdatert", "access_saved": "Tilgangskontroll lagret", "all_wiped": "Alle data slettet", "analysis_complete_none": "Analysen er fullført: ingen nye forslag", @@ -1812,7 +1883,7 @@ "cycle_labelled": "Syklus merket", "cycle_paused": "Syklus stoppet", "cycle_resumed": "Syklusen ble gjenopptatt", - "cycle_trimmed": "Syklus trimmet", + "cycle_trimmed": "Syklus beskjært", "cycles_merged": "Sykluser slått sammen", "envelope_rebuilt": "Konvolutt gjenoppbygd", "envelopes_rebuilt": "Konvolutter gjenoppbygd", @@ -1906,7 +1977,9 @@ "store_download_failed": "Nedlasting mislyktes: {error}", "store_download_nothing": "Ingenting nytt å laste ned -- dette oppsettet er allerede på apparatet ditt.", "export_selective_done": "Eksport lastet ned", - "import_selective_done": "Importerte {profiles} profil(er) og {cycles} syklus(er)" + "import_selective_done": "Importerte {profiles} profil(er) og {cycles} syklus(er)", + "hist_csv_required": "Last inn en CSV-fil eller lim inn innholdet først", + "file_read_failed": "Kunne ikke lese den filen" }, "trend": { "down": "Trender nedover", @@ -1955,6 +2028,10 @@ "finished": "Syklusen nådde en sluttilstand og stoppet." }, "store": { + "your_model_tip": "Dette er apparatet du har angitt i Innstillinger", + "your_model": "Ditt apparat", + "search_brand_ph": "Søk etter merke…", + "programs_count": "Programmer: {n}", "browse": "Bla gjennom", "device": "Enhet", "favorites": "Favoritter", diff --git a/custom_components/ha_washdata/translations/panel/nl.json b/custom_components/ha_washdata/translations/panel/nl.json index b8e74e75..4582a9d1 100644 --- a/custom_components/ha_washdata/translations/panel/nl.json +++ b/custom_components/ha_washdata/translations/panel/nl.json @@ -78,9 +78,12 @@ "awaiting": "Wacht op goedkeuring", "imported_tip": "Geïmporteerd uit de communitystore. Alleen gebruikt voor matchen, telt niet mee in statistieken.", "not_importable": "hier n.v.t.", - "exists": "bestaat" + "exists": "bestaat", + "backfilled_tip": "Gedetecteerd in geïmporteerde vermogensgeschiedenis. Beïnvloedt alleen het matchen van programma's, telt niet mee in statistieken." }, "btn": { + "set_brand_model": "Merk en model instellen", + "refresh_catalog": "Catalogus vernieuwen", "add_device": "+ Apparaat toevoegen", "add_device_tip": "Voeg nog een WashData-apparaat toe", "add_maintenance": "Onderhoudsgebeurtenis toevoegen", @@ -90,7 +93,7 @@ "apply_label": "Etiket toepassen", "apply_set_b": "Set B toepassen", "apply_split": "Splitsing toepassen", - "apply_trim": "Pas trimmen toe", + "apply_trim": "Bijsnijden toepassen", "auto_detect_split": "Automatische detectie", "auto_label_cycles": "Cycli automatisch labelen", "auto_label_cycles_tip": "Wijs automatisch profielnamen toe aan ongelabelde cycli waarvan de matchbetrouwbaarheid de drempel haalt", @@ -209,8 +212,8 @@ "stop": "Stoppen", "submit_correction": "Correctie indienen", "train_now": "Train nu", - "trim": "Trimmen", - "trim_split": "Trimmen/splitsen", + "trim": "Bijsnijden", + "trim_split": "Bijsnijden / splitsen", "undo": "Ongedaan maken", "use": "Gebruik", "wipe_all": "Wis alle gegevens", @@ -241,7 +244,12 @@ "import_selected": "Selectie importeren", "back": "Terug", "mute_suggestion": "Deze instelling niet meer voorstellen", - "reset_muted": "Gedempte suggesties herstellen" + "reset_muted": "Gedempte suggesties herstellen", + "import_power_history": "Vermogensgeschiedenis importeren", + "hist_read_recorder": "Uit Home Assistant lezen", + "hist_scan": "Zoeken naar cycli", + "hist_import_n": "{n} cycli importeren", + "hist_goto_cycles": "Toon me de cycli" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Moet onder het maximale anti-kreuk-vermogen ({max} W) liggen" }, "attn_sub": "Los conflicten op voor het opslaan", - "attn_title": "{n} instellingsconflict{s}", + "attn_title": "Instellingsconflicten: {n}", "confidence": { "auto": "Moet op of boven de overeenkomstdrempel ({match}) liggen", - "learning": "Moet op of onder de overeenkomstdrempel ({match}) liggen", + "learning": "Moet op of boven de overeenkomstdrempel ({match}) liggen", "match_for_auto": "Moet op of onder het automatisch-label-betrouwbaarheidsniveau ({alc}) liggen", - "match_for_learning": "Moet op of boven het leerbetrouwbaarheidsniveau ({lc}) liggen" + "match_for_learning": "Moet op of onder het leerbetrouwbaarheidsniveau ({lc}) liggen" }, "duration_ratio": { "max": "Moet groter zijn dan de minimale duurverhouding ({min})", @@ -296,14 +304,14 @@ "match": "Moet boven de niet-overeenkomstdrempel ({un}) liggen", "unmatch": "Moet onder de overeenkomstdrempel ({match}) liggen; anders wordt een bevestigde overeenkomst direct ongedaan gemaakt" }, - "cascade_toast": "Ook {n} instelling{s} aangepast voor consistentie.", + "cascade_toast": "Andere instellingen aangepast voor consistentie: {n}", "suggestion_resolves": "Pas hieronder de openstaande suggestie ({val}) toe om dit op te lossen", "use_fix": "Gebruik {val}", "watchdog": { "interval": "Moet minimaal 2x het bemonsteringsinterval ({si} s) zijn", "sampling": "Bemonsteringsinterval moet maximaal de helft van het watchdog-interval ({wi} s) zijn" }, - "settings_banner": "{n} instellingsconflict{s} – controleer de gemarkeerde secties en los ze op voor het opslaan.", + "settings_banner": "Instellingsconflicten: {n}. Controleer de gemarkeerde secties en los ze op voordat u opslaat.", "settings_banner_btn": "Ga naar eerste" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Simulatieresultaat", "pg_across_cycles": "Over al je cycli", "community_store": "Communitystore", - "online_account": "Communitystore en onlinefuncties" + "online_account": "Communitystore en onlinefuncties", + "import_power_history": "Vermogensgeschiedenis importeren" }, "health": { "fair": "Acceptabele profielkwaliteit", @@ -364,6 +373,7 @@ "poor": "⚠ Slechte profielkwaliteit" }, "lbl": { + "drag_to_resize": "Sleep om het formaat te wijzigen", "actions": "Acties", "activity": "Activiteit", "administrators": "Beheerders", @@ -450,7 +460,7 @@ "metric": "Metriek", "mode_existing_profile": "Toevoegen aan bestaand profiel", "mode_new_profile": "Nieuw profiel maken", - "models_fine_tuned": "({count} model{plural} verfijnd)", + "models_fine_tuned": "(verfijnde modellen: {count})", "n_classic_suggestions": "{n} klassiek", "n_ml_suggestions": "{n} ML", "n_selected": "{n} geselecteerd", @@ -677,7 +687,26 @@ "conflict_keep_mine": "Die van mij behouden", "conflict_overwrite": "Overschrijven", "pg_anti_wrinkle": "Anti-kreuk", - "font_size": "Paneel lettergrootte" + "font_size": "Paneel lettergrootte", + "hist_csv_data": "CSV-gegevens", + "hist_from_recorder": "Of lees het uit Home Assistant", + "hist_since": "Sinds", + "days": "dagen", + "hist_keep": "Deze cyclus bewaren", + "hist_looks_complete": "volledig", + "peak_power_short": "Piek", + "shape": "Vorm", + "hist_skip_idle": "niets actief", + "hist_skip_sparse": "metingen te ver uit elkaar", + "hist_skip_short": "te weinig metingen", + "hist_skip_long": "geen pauze lang genoeg om op te splitsen", + "hist_reason_short": "korter dan de kortste echte cyclus van dit apparaat", + "hist_reason_no_end": "nooit netjes beëindigd", + "task_history_import": "Vermogensgeschiedenis doorzoeken", + "task_history_import_apply": "Cycli importeren", + "evidence_real_cycles": "Cycli die deze machine heeft uitgevoerd", + "evidence_reference_cycles": "Gedownload uit de communitystore", + "evidence_backfill_cycles": "Gevonden in geïmporteerde vermogensgeschiedenis" }, "log": { "all_levels": "Alle niveaus", @@ -756,9 +785,15 @@ "store_share": "Delen in communitystore", "store_share_device": "Dit apparaat delen", "export_select": "Export - gegevens kiezen", - "import_wizard": "Import - gegevens kiezen" + "import_wizard": "Import - gegevens kiezen", + "history_import": "Vermogensgeschiedenis importeren" }, "msg": { + "tail_trim_hint": "Dit aantal seconden aan het einde verwijderen", + "store_sibling_hint": "Niets gedeeld voor precies uw model? Een nauw verwant model van hetzelfde merk is meestal een goed startpunt.", + "store_declare_appliance": "Laat WashData weten welk apparaat u bezit, dan toont dit tabblad de setups die anderen ervoor hebben gedeeld. U kunt hierboven ook een merk invoeren om rond te kijken.", + "refresh_catalog_hint": "De merken- en apparatenlijsten van de community worden in de cache opgeslagen zodat de communitystore binnen zijn daglimiet blijft. Vernieuw om vermeldingen op te halen die anderen hebben toegevoegd of goedgekeurd.", + "head_trim_hint": "Dit aantal seconden aan het begin verwijderen", "appliance_monitor": "Apparaatmonitor", "artifact_dip_detail": "Daalde onder de gebruikelijke vermogensband voor ~{n}s.", "artifact_footer": "Gemarkeerd in de bovenstaande grafiek. Dit zijn tijdelijke artefacten (bijvoorbeeld de deur die halverwege de cyclus wordt geopend), en niet noodzakelijkerwijs problemen.", @@ -769,7 +804,7 @@ "automations_intro": "WashData activeert {start} / {end}-gebeurtenissen en stelt entiteiten beschikbaar, waardoor meldingen en acties het best als gewone Home Assistant-automatiseringen worden gebouwd. Automatiseringen die dit apparaat gebruiken, worden hieronder weergegeven.", "cleanup_intro": "Elke gelabelde cyclus wordt overlay. Vink uitschieters aan en verwijder deze om het profiel op te schonen.", "clear_debug_hint": "Verwijder opgeslagen foutopsporingsgegevens om ruimte vrij te maken.", - "collecting_data": "Gegevens worden verzameld: nog {need} cyclus{plural} voordat verfijning kan beginnen ({current}/{min}).", + "collecting_data": "Gegevens worden verzameld. Nog benodigde cycli voordat verfijning kan beginnen: {need} ({current}/{min}).", "compare_overlay_profiles": "Overlay-profielen (vaag)", "compare_profiles_tip": "Leg andere profielenveloppen op het schema hierboven om te zien welke het beste bij deze cyclus past.", "compare_selected_cycles": "Geselecteerde cycli (vast) – tonen/verbergen", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} cyclus(sen) verwijderd", "enough_data": "Voldoende gegevens om van te leren ({current}/{min} cycli).", "export_description": "Kies precies welke profielen, cycli, instellingen en meer u naar JSON wilt exporteren, of analyseer een bestand en importeer alleen de onderdelen die u wilt.", - "feedback_cycles_pending": "{n} cyclus{s} te beoordelen", + "feedback_cycles_pending": "Te beoordelen: {n}", "feedback_prompt": "Bevestig dat het goed was, corrigeer het programma of negeer het.", "feedback_relabel_hint": "Deze cyclus opnieuw labelen lost de beoordeling ook op.", "filter_by_profile": "Filteren op profiel…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "inactieve simulatie begint hier", "pg_sweep_intro": "Wat als {param} anders was? Test {steps} waarden over je laatste {cycles} cycli om de instelling te vinden waarbij de meeste cycli correct worden herkend.", "pg_sweep_step": "Stap {done} / {total}", - "pg_undetected": "{n} cyclus{s} niet gedetecteerd", "pg_verdict_bad": "Vereist aandacht: veel cycli worden niet gedetecteerd.", "pg_verdict_good": "Goed afgestemd: de meeste cycli worden correct herkend en gematcht.", "pg_verdict_ok": "Acceptabel: sommige cycli gemist. Probeer de startdrempel te verlagen.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Markeer dit als een zorgvuldig uitgekozen referentiecyclus voor het programma - dezelfde rol als een handmatig geregistreerde cyclus. Referentiecycli worden altijd bewaard, voorzien van de overeenkomende sjabloon en worden nooit verwijderd door opschoning. (Dit is de \"gouden\"/opgenomen vlag; beide zijn hetzelfde.)", "review_tags_tip": "Optionele vlaggen die beschrijven wat er mis is gegaan in deze cyclus, zodat training en opruiming hiervan een verklaring kunnen zijn.", "review_to_cycles": "Open de wachtrij voor het beoordelen van cycli", + "samples_decimated": "{shown} van {total} metingen weergegeven (uitgedund voor weergave; pieken behouden). Een brede leemte hier is uitdunning, geen ontbrekende gegevens.", "saving_triggers_reload": "Opslaan activeert een herladen van de integratie. HA-entiteiten kunnen kort worden weergegeven als niet beschikbaar.", "search_placeholder": "Zoekinstellingen…", "see_recorder": "Zie recorderwidget hieronder", @@ -896,7 +931,7 @@ "shape_drift_detail": "Het vermogenspatroon voor dit profiel is in de loop van de tijd verschoven – mogelijk slijtage van het apparaat of onderhoud nodig (bijv. ontkalken, filterreiniging).", "show_all_settings": "Toon alle instellingen", "showing_suggestions": "Instelling {count} met suggesties weergegeven.", - "split_intro": "Klik op de grafiek om een ​​splitspunt toe te voegen of te verwijderen, of om automatisch gaten in de grafiek te detecteren. Elk resulterend segment kan zijn eigen profiel krijgen.", + "split_intro": "Klik op de grafiek om een splitspunt toe te voegen of te verwijderen, of om automatisch gaten in de grafiek te detecteren. Elk resulterend segment kan zijn eigen profiel krijgen.", "storage_diagnostics": "Opslagstatistieken, onderhoud, export/import", "sug_staged": "{key} = {val} ingesteld. Sla op om toe te passen.", "toast_auto_detect_enabled": "Automatische detectie ingeschakeld", @@ -924,7 +959,7 @@ "trend_energy_down": "Energietrend omlaag ({pct}%/cyclus)", "trend_energy_up": "Energie stijgt ({pct}%/cyclus) – recent gemiddelde {avg}", "trim_destructive_confirm": "Hiermee wordt slechts {pct}% van de cyclus behouden. Dit kan niet ongedaan worden gemaakt. Doorgaan?", - "trim_intro": "Versleep de rode grepen of voer waarden in. Alles buiten het raam wordt verwijderd.", + "trim_intro": "Versleep de rode handgrepen of voer waarden in. Alles buiten het venster wordt verwijderd.", "tuning_suggestions_available": "{count} afstemmingssuggestie beschikbaar op basis van waargenomen cycli. Ze verschijnen naast de relevante velden.", "unsure_detected_prefix": "WashData weet niet zeker of het is gedetecteerd", "updated": "Bijgewerkt", @@ -1006,10 +1041,33 @@ "sug_mute_failed": "Instelling kon niet worden gedempt", "sug_unmuted_all": "Gedempte suggesties hersteld", "n_suggestions_muted": "{count} gedempt; de autotuner stelt deze niet meer voor.", - "font_size_hint": "Maak alles in dit paneel groter of kleiner. Geldt voor uw account op dit apparaat." + "font_size_hint": "Maak alles in dit paneel groter of kleiner. Geldt voor uw account op dit apparaat.", + "import_history_description": "Had je al een slimme stekker voordat je WashData ging gebruiken? Upload een geschiedenisexport van de vermogenssensor, of laat die rechtstreeks uit Home Assistant lezen, en de normale detectie loopt eroverheen zodat oude cycli in je Cycli-lijst verschijnen, klaar om benoemd te worden.", + "hist_input_hint": "Upload een CSV die je uit het Geschiedenis-paneel hebt gedownload (entiteit, status, laatst gewijzigd), of laat WashData de geschiedenis van de sensor direct lezen. De detectie loopt er dan precies zo over als live, en jij kiest welke van de gevonden cycli je bewaart.", + "hist_recorder_hint": "Leest vanaf de datum die je kiest tot nu. Home Assistant bewaart standaard 10 dagen gedetailleerde geschiedenis en daarna alleen uurgemiddelden, die te grof zijn om cycli in te detecteren - kies alleen een datum verder terug als je recorder is ingesteld om meer te bewaren.", + "hist_scanning": "Je geschiedenis wordt door de detectie gehaald. Dit gebeurt op de achtergrond - je kunt dit venster sluiten en later terugkomen.", + "hist_imported_count": "{n} cycli geïmporteerd.", + "hist_duplicates": "{n} waren al geïmporteerd en zijn overgeslagen.", + "hist_capped": "De limiet per apparaat voor geïmporteerde cycli is bereikt; de rest is niet opgeslagen.", + "hist_next_step": "Ze staan in je Cycli-lijst, gemarkeerd als geïmporteerde geschiedenis. Open er een en gebruik Labelen om het bijbehorende programma te benoemen.", + "hist_rows_read": "{n} metingen gelezen", + "hist_entity_substituted": "{used} gelezen (dit apparaat is geconfigureerd voor {wanted})", + "hist_breaks": "{n} gaten waarin de sensor niet beschikbaar was", + "hist_other_entity": "{n} metingen voor andere entiteiten genegeerd", + "hist_skipped_spans": "Overgeslagen stukken", + "hist_settings_used": "Gedetecteerd met de huidige instellingen van dit apparaat (minimaal vermogen {w} W, uitschakelvertraging {s} s).", + "hist_none_found": "Er konden geen cycli in die geschiedenis worden gedetecteerd.", + "hist_found": "{n} cycli gevonden. Vink alles uit wat niet op een echte draaibeurt lijkt - er wordt niets opgeslagen totdat je importeert.", + "hist_scan_capped": "Alleen de eerste kandidaten worden weergegeven ({n} gevonden).", + "hist_recorder_empty": "Home Assistant heeft in dat tijdvak geen gedetailleerde geschiedenis voor deze sensor.", + "hist_scan_failed": "Zoeken mislukt.", + "hist_scan_expired": "Die zoekopdracht is niet meer beschikbaar. Zoek opnieuw.", + "hist_import_failed": "Importeren mislukt.", + "imported_history_readonly": "Gedetecteerd in geïmporteerde vermogensgeschiedenis. Het beïnvloedt het matchen van programma's, maar telt niet mee in je statistieken en kan niet worden bijgesneden of gesplitst. Label het om het programma te benoemen." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Vaatwasser: stille seconden na de verwachte duur voordat de wachttijd op het wegpompen aan het einde van de cyclus wordt vrijgegeven", + "smart_termination_duration_ratio": "Deel van de verwachte duur van het herkende programma dat een cyclus moet bereiken voordat de slimme beëindiging hem vroegtijdig mag afsluiten; verlaag deze voor belasting- of temperatuurafhankelijke machines", "completion_min_seconds": "Kortste run die als een echte cyclus telt", "end_repeat_count": "Opeenvolgende lage metingen voor het einde", "interrupted_min_seconds": "Korte cycli gemarkeerd als onderbroken", @@ -1162,7 +1220,7 @@ "label": "Automatisch onderhoud (nachtelijke opruiming)" }, "completion_min_seconds": { - "doc": "Cycli korter dan dit worden weggegooid als spookcycli (testruns, de deur openen om een ​​sok toe te voegen).", + "doc": "Cycli korter dan dit worden weggegooid als spookcycli (testruns, de deur openen om een sok toe te voegen).", "label": "Min. cyclusduur" }, "delay_confirm_seconds": { @@ -1246,7 +1304,7 @@ "label": "Minimaal vermogen" }, "ml_training_enabled": { - "doc": "Bestudeer uw beoordeelde cycli regelmatig van de ene op de andere dag en stem de modellen af ​​op deze specifieke machine. Een verandering wordt alleen behouden als deze daadwerkelijk beter scoort op aanhoudende cycli, dus dit kan alleen maar helpen of hetzelfde blijven - nooit achteruitgaan.", + "doc": "Bestudeer uw beoordeelde cycli regelmatig van de ene op de andere dag en stem de modellen af op deze specifieke machine. Een verandering wordt alleen behouden als deze daadwerkelijk beter scoort op aanhoudende cycli, dus dit kan alleen maar helpen of hetzelfde blijven - nooit achteruitgaan.", "label": "Leren van deze machine" }, "ml_training_hour": { @@ -1266,7 +1324,7 @@ "label": "Apparaatnaam" }, "no_update_active_timeout": { - "doc": "Als er gedurende deze tijd geen stroomupdates arriveren tijdens het gebruik, ga er dan van uit dat de stekker offline is gevallen en forceer een stop om een ​​zombiecyclus te voorkomen. Standaard 600 s maakt cloud- of mesh-vertraging mogelijk.", + "doc": "Als er gedurende deze tijd geen stroomupdates arriveren tijdens het gebruik, ga er dan van uit dat de stekker offline is gevallen en forceer een stop om een zombiecyclus te voorkomen. Standaard 600 s maakt cloud- of mesh-vertraging mogelijk.", "label": "Time-out zonder update" }, "notify_before_end_minutes": { @@ -1394,7 +1452,7 @@ "label": "Min. duurverhouding" }, "profile_match_threshold": { - "doc": "Minimale gelijkheidsscore (0-1) vereist aan het einde van de cyclus om een ​​programma-identificatie te accepteren. Verhoog het om verkeerde identificaties te verminderen; verlaag het als de programma's van uw machine niet overeenkomen. Standaard 0,4 is een conservatief uitgangspunt.", + "doc": "Minimale gelijkheidsscore (0-1) vereist aan het einde van de cyclus om een programma-identificatie te accepteren. Verhoog het om verkeerde identificaties te verminderen; verlaag het als de programma's van uw machine niet overeenkomen. Standaard 0,4 is een conservatief uitgangspunt.", "label": "Afstemmingsdrempel" }, "profile_unmatch_threshold": { @@ -1421,12 +1479,16 @@ "doc": "Bewaar de volledige vermogenstrace en overeenkomende foutopsporingsgegevens voor elke cyclus. Handig voor het oplossen van problemen, maar vergroot de opslaggrootte.", "label": "Foutopsporingssporen opslaan" }, + "smart_termination_duration_ratio": { + "doc": "Hoe ver een cyclus in de verwachte duur van het herkende programma moet zijn voordat de slimme beëindiging hem vroegtijdig mag afsluiten zodra het vermogen daalt. De verwachte duur is het gemiddelde van het programma, dus bij apparaten waarvan de looptijd sterk varieert - wasmachines met koud toevoerwater in de winter versus warm in de zomer, drogers met vochtsensor, belastingafhankelijke programma's - eindigt ongeveer de helft van alle draaien korter dan dat gemiddelde en krijgt nooit de snelle beëindiging, maar eindigt pas minuten later via de fallback-time-out. Verlaag deze waarde (bijv. 0,85) op zulke machines zodat de vroegtijdige beëindiging toch wordt geactiveerd; verhoog hem richting 1,0 om voorzichtiger te zijn. Laat leeg voor de standaard (0,98, of 0,99 voor vaatwassers). Hij kan een cyclus alleen eerder beëindigen, nooit later, en wordt nooit geactiveerd bij een dubbelzinnige of onzekere herkenning.", + "label": "Verhouding slimme beëindiging" + }, "smoothing_window": { "doc": "Hoeveel het ruwe stroomsignaal wordt afgevlakt. Laag (2) is responsief maar luidruchtig; hoog (5) verzacht pieken maar voegt vertraging toe.", "label": "Afvlakkingsvenster" }, "start_duration_threshold": { - "doc": "Het vermogen moet zo lang boven de startdrempel blijven om een ​​echte start te bevestigen, waardoor wordt voorkomen dat aan/uit-schakelaars van een fractie van een seconde een cyclus starten.", + "doc": "Het vermogen moet zo lang boven de startdrempel blijven om een echte start te bevestigen, waardoor wordt voorkomen dat aan/uit-schakelaars van een fractie van een seconde een cyclus starten.", "label": "Startduur" }, "start_energy_threshold": { @@ -1551,6 +1613,10 @@ "door_end_dwell_seconds": { "label": "Deuropen-eindverwijltijd", "doc": "Hoe lang de deur open moet blijven voordat WashData de cyclus beëindigt, wanneer \"Deur opent automatisch aan het einde\" is ingeschakeld. Lang genoeg om het snel toevoegen van een bord te negeren (standaard 60 s), kort genoeg om de cyclus snel te beëindigen zodra de machine de deur opent." + }, + "profile_evidence_sources": { + "label": "Cycli die een programma vormen", + "doc": "Welke cycli worden gebruikt om de vermogenscurve van elk programma op te bouwen en om een voltooide cyclus daarmee te matchen. Een soort uitvinken laat die uw programma's niet langer vormen zonder dat er iets wordt verwijderd - de cycli blijven in uw Cycli-lijst staan en kunnen nog steeds gelabeld of verwijderd worden. Handig als u geïmporteerde gegevens niet vertrouwt. Statistieken blijven ongewijzigd: die tellen altijd alleen de cycli die deze machine echt heeft uitgevoerd. Alles uitvinken wordt genegeerd, want een programma zonder cycli erachter zou nooit kunnen matchen." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "Basisconfiguratie" + }, + "profile_evidence": { + "label": "Profielonderbouwing" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "Bijsnijden…", "splitting": "Splitsen…", "deleting": "Verwijderen…", - "imported": "Geïmporteerd" + "imported": "Geïmporteerd", + "preparing": "Voorbereiden…" }, "suggestion": { "both_agree": "WashData raadt aan", @@ -1754,7 +1824,7 @@ "thr_batch": "Net boven het laagste actieve vermogen op p05 over {cycles} cycli ({p05}W) gehouden zodat een start zo vroeg mogelijk wordt opgemerkt en de stopdrempel onder het laagste draaivermogen van de machine blijft.", "tol_per_profile": "p75 van de duurvariantie per profiel over {profiles} profielen ({cycles} cycli); strakke profielen worden niet benadeeld.", "tol_pooled": "Op basis van de gebundelde duurvariantie van {cycles} recent gelabelde cycli (p95-afwijking={dev}).", - "watchdog": "Zo laag als veilig gehouden (net boven de p95-updatekloof van {p95}s, min. 30s) zodat vastlopers snel worden opgemerkt zonder valse stops." + "watchdog": "Zo laag als veilig gehouden (net boven de p95-updatekloof van {p95}s en minstens 2x het meetinterval van {median}s, min. 30s) zodat vastlopers snel worden opgemerkt zonder valse stops." }, "exclusions": { "summary": "{total} verkeerd gedetecteerde cyclus(sen) uitgesloten: {parts}.", @@ -1801,6 +1871,7 @@ "wrong_profile": "Verkeerd profiel" }, "toast": { + "catalog_refreshed": "Communitycatalogus vernieuwd", "access_saved": "Toegangscontrole opgeslagen", "all_wiped": "Alle gegevens gewist", "analysis_complete_none": "Analyse voltooid: geen nieuwe suggesties", @@ -1812,7 +1883,7 @@ "cycle_labelled": "Cyclus gelabeld", "cycle_paused": "Cyclus onderbroken", "cycle_resumed": "Cyclus hervat", - "cycle_trimmed": "Cyclus ingekort", + "cycle_trimmed": "Cyclus bijgesneden", "cycles_merged": "Cycli samengevoegd", "envelope_rebuilt": "Envelop opnieuw opgebouwd", "envelopes_rebuilt": "Enveloppen opnieuw opgebouwd", @@ -1906,7 +1977,9 @@ "store_download_failed": "Downloaden mislukt: {error}", "store_download_nothing": "Niets nieuws te downloaden -- deze instelling staat al op uw apparaat.", "export_selective_done": "Export gedownload", - "import_selective_done": "{profiles} profiel(en) en {cycles} cyclus(sen) geïmporteerd" + "import_selective_done": "{profiles} profiel(en) en {cycles} cyclus(sen) geïmporteerd", + "hist_csv_required": "Laad eerst een CSV-bestand of plak de inhoud ervan", + "file_read_failed": "Kon dat bestand niet lezen" }, "trend": { "down": "Trend naar beneden", @@ -1955,6 +2028,10 @@ "finished": "De cyclus bereikte een eindtoestand en stopte." }, "store": { + "your_model_tip": "Dit is het apparaat dat u bij Instellingen hebt opgegeven", + "your_model": "Uw apparaat", + "search_brand_ph": "Zoeken op merk…", + "programs_count": "Programma's: {n}", "browse": "Bladeren", "device": "Apparaat", "favorites": "Favorieten", diff --git a/custom_components/ha_washdata/translations/panel/pl.json b/custom_components/ha_washdata/translations/panel/pl.json index 2e1f1efa..2dc67e23 100644 --- a/custom_components/ha_washdata/translations/panel/pl.json +++ b/custom_components/ha_washdata/translations/panel/pl.json @@ -78,9 +78,12 @@ "awaiting": "Oczekuje na zatwierdzenie", "imported_tip": "Zaimportowano ze sklepu społecznościowego. Używane tylko do dopasowywania, nie liczy się do statystyk.", "not_importable": "niedostępne", - "exists": "istnieje" + "exists": "istnieje", + "backfilled_tip": "Wykryty w zaimportowanej historii mocy. Wpływa tylko na dopasowywanie programów, nie liczy się do statystyk." }, "btn": { + "set_brand_model": "Ustaw markę i model", + "refresh_catalog": "Odśwież katalog", "add_device": "+ Dodaj urządzenie", "add_device_tip": "Dodaj kolejne urządzenie WashData", "add_maintenance": "Dodaj zdarzenie konserwacji", @@ -234,14 +237,19 @@ "download_device": "Pobierz konfigurację urządzenia", "share_device": "Udostępnij konfigurację urządzenia", "share_device_tip": "Udostępnij programy i ustawienia tego urządzenia społeczności", - "share_n": "Udostępnij {n} program{s}", + "share_n": "Udostępnij cykle: {n}", "export_selected": "Eksport (wybierz dane)", "export_all": "Szybki eksport wszystkiego", "import_raw": "Zaawansowane: zastąp wszystko z pliku JSON", "download_export": "Pobierz eksport", "analyze_import": "Analizuj plik", "import_selected": "Importuj wybrane", - "back": "Wstecz" + "back": "Wstecz", + "import_power_history": "Importuj historię mocy", + "hist_read_recorder": "Wczytaj z Home Assistant", + "hist_scan": "Szukaj cykli", + "hist_import_n": "Importuj cykle: {n}", + "hist_goto_cycles": "Pokaż cykle" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Musi być poniżej Maks. Mocy Antygniotowej ({max} W)" }, "attn_sub": "Przed zapisaniem napraw konflikty", - "attn_title": "{n} konflikt{s} ustawień", + "attn_title": "Konflikty ustawień: {n}", "confidence": { "auto": "Musi być równy lub wyższy niż Próg Dopasowania ({match})", - "learning": "Musi być równy lub niższy niż Próg Dopasowania ({match})", + "learning": "Musi być równy lub wyższy niż Próg Dopasowania ({match})", "match_for_auto": "Musi być równy lub niższy niż Pewność Automatycznego Etykietowania ({alc})", - "match_for_learning": "Musi być równy lub wyższy niż Pewność Uczenia ({lc})" + "match_for_learning": "Musi być równy lub niższy niż Pewność Uczenia ({lc})" }, "duration_ratio": { "max": "Musi być większy niż Minimalna Proporcja Czasu Trwania ({min})", @@ -296,14 +304,14 @@ "match": "Musi być powyżej Progu Niedopasowania ({un})", "unmatch": "Musi być poniżej Progu Dopasowania ({match}); w przeciwnym razie potwierdzone dopasowanie natychmiast się cofa" }, - "cascade_toast": "Dodatkowo dostosowano {n} ustawień w celu zachowania spójności.", + "cascade_toast": "Inne ustawienia dostosowane w celu zachowania spójności: {n}", "suggestion_resolves": "Zastosuj oczekującą sugestię ({val}) poniżej, aby rozwiązać ten konflikt", "use_fix": "Użyj {val}", "watchdog": { "interval": "Powinien wynosić co najmniej 2× Interwał Próbkowania ({si} s)", "sampling": "Interwał Próbkowania powinien wynosić co najwyżej połowę Interwału Watchdog ({wi} s)" }, - "settings_banner": "{n} konflikt{s} ustawień – sprawdź podświetlone sekcje i popraw przed zapisaniem.", + "settings_banner": "Konflikty ustawień: {n}. Sprawdź podświetlone sekcje i popraw je przed zapisaniem.", "settings_banner_btn": "Przejdź do pierwszego" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Wynik symulacji", "pg_across_cycles": "We wszystkich Twoich cyklach", "community_store": "Sklep społecznościowy", - "online_account": "Sklep społecznościowy i funkcje online" + "online_account": "Sklep społecznościowy i funkcje online", + "import_power_history": "Import historii mocy" }, "health": { "fair": "Akceptowalna jakość profilu", @@ -364,6 +373,7 @@ "poor": "⚠ Słaba jakość profilu" }, "lbl": { + "drag_to_resize": "Przeciągnij, aby zmienić rozmiar", "actions": "Działania", "activity": "Działalność", "administrators": "Administratorzy", @@ -435,7 +445,7 @@ "from": "Od", "gap_s": "Luka (y)", "group_name": "Nazwa grupy", - "head_trim": "Głowica (e)", + "head_trim": "Przycięcie początku (s)", "health": "Zdrowie", "hide_tabs": "Ukryj karty dla osób niebędących administratorami", "in_use": "W użyciu", @@ -451,7 +461,7 @@ "metric": "Metryka", "mode_existing_profile": "Dodaj do istniejącego profilu", "mode_new_profile": "Utwórz nowy profil", - "models_fine_tuned": "({count} modeli dostrojonych)", + "models_fine_tuned": "(dostrojone modele: {count})", "n_classic_suggestions": "{n} klasyczne", "n_ml_suggestions": "{n} ML", "n_selected": "Wybrano {n}", @@ -533,7 +543,7 @@ "stage3": "Etap 3 – DTW", "stage4": "Etap 4 – zgodność", "status": "Status", - "tail_trim": "Trymowanie ogona", + "tail_trim": "Przycięcie końca (s)", "timer_auto_pause": "Automatyczna pauza", "timer_min": "min", "timer_msg_placeholder": "Wiadomość (opcjonalnie, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_resolution": "Konflikty nazw", "conflict_import_copy": "Importuj jako kopię", "conflict_keep_mine": "Zachowaj moje", - "conflict_overwrite": "Nadpisz" + "conflict_overwrite": "Nadpisz", + "hist_csv_data": "Dane CSV", + "hist_from_recorder": "Albo wczytaj ją z Home Assistant", + "days": "dni", + "hist_keep": "Zachowaj ten cykl", + "hist_looks_complete": "zakończony", + "peak_power_short": "Szczyt", + "shape": "Kształt", + "hist_skip_idle": "nic nie działało", + "hist_skip_sparse": "odczyty zbyt oddalone od siebie", + "hist_skip_short": "za mało odczytów", + "hist_skip_long": "brak przerwy dość długiej, by podzielić", + "hist_reason_short": "krótszy niż najkrótszy rzeczywisty cykl tego urządzenia", + "hist_reason_no_end": "nigdy nie zakończył się poprawnie", + "hist_since": "Od", + "task_history_import": "Skanowanie historii mocy", + "task_history_import_apply": "Importowanie cykli", + "evidence_real_cycles": "Cykle wykonane przez to urządzenie", + "evidence_reference_cycles": "Pobrane ze sklepu społecznościowego", + "evidence_backfill_cycles": "Znalezione w zaimportowanej historii mocy" }, "log": { "all_levels": "Wszystkie poziomy", @@ -756,9 +785,15 @@ "store_share": "Udostępnij w sklepie społecznościowym", "store_share_device": "Udostępnij konfigurację urządzenia", "export_select": "Eksport - wybierz dane", - "import_wizard": "Import - wybierz dane" + "import_wizard": "Import - wybierz dane", + "history_import": "Import historii mocy" }, "msg": { + "tail_trim_hint": "Ile sekund usunąć z końca", + "store_sibling_hint": "Nic nie udostępniono dla Twojego dokładnego modelu? Bardzo podobny model tej samej marki to zwykle dobry punkt wyjścia.", + "store_declare_appliance": "Wskaż WashData, jakie urządzenie posiadasz, a ta karta pokaże konfiguracje udostępnione dla niego przez innych użytkowników. Możesz też wpisać markę powyżej, aby się rozejrzeć.", + "refresh_catalog_hint": "Listy marek i urządzeń społeczności są przechowywane w pamięci podręcznej, aby wspólny sklep nie przekroczył swojego dziennego limitu zapytań. Odśwież, aby pobrać pozycje dodane lub zatwierdzone przez innych.", + "head_trim_hint": "Ile sekund usunąć z początku", "appliance_monitor": "Monitor urządzenia", "artifact_dip_detail": "Moc spadła poniżej normalnego zakresu przez ~{n}s.", "artifact_footer": "Zaznaczono na powyższym wykresie. Są to artefakty przejściowe (np. otwarcie drzwi w połowie cyklu), niekoniecznie problemy.", @@ -769,7 +804,7 @@ "automations_intro": "WashData uruchamia zdarzenia {start} / {end} i udostępnia encje, więc powiadomienia i akcje najlepiej budować jako normalne automatyzacje Home Assistant. Automatyzacje korzystające z tego urządzenia są widoczne poniżej.", "cleanup_intro": "Każdy oznaczony cykl został nałożony. Zaznacz wartości odstające i usuń, aby oczyścić profil.", "clear_debug_hint": "Usuń zapisane dane debugowania, aby zwolnić miejsce.", - "collecting_data": "Zbieranie danych: jeszcze {need} cykli do rozpoczęcia dostrajania ({current}/{min}).", + "collecting_data": "Zbieranie danych. Brakujące cykle do rozpoczęcia dostrajania: {need} ({current}/{min}).", "compare_overlay_profiles": "Profile nakładkowe (słabe)", "compare_profiles_tip": "Nałóż inne koperty profili na powyższy wykres, aby zobaczyć, która z nich najlepiej pasuje do tego cyklu.", "compare_selected_cycles": "Wybrane cykle (pełne) – pokaż / ukryj", @@ -779,7 +814,7 @@ "cycles_deleted": "Usunięto cykle: {count}", "enough_data": "Wystarczająco danych do nauki ({current}/{min} cykli).", "export_description": "Wybierz dokładnie, które profile, cykle, ustawienia i inne dane wyeksportować do pliku JSON, albo przeanalizuj plik i zaimportuj tylko wybrane części.", - "feedback_cycles_pending": "{n} cykl{s} do sprawdzenia", + "feedback_cycles_pending": "Do sprawdzenia: {n}", "feedback_prompt": "Potwierdź, że wszystko było w porządku, popraw program lub zignoruj.", "feedback_relabel_hint": "Ponowne oznaczenie tego cyklu również go rozwiązuje.", "filter_by_profile": "Filtruj według profilu…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "symulacja bezczynności zaczyna się tutaj", "pg_sweep_intro": "Co by było, gdyby {param} był inny? Przetestuj {steps} wartości na swoich ostatnich {cycles} cyklach, aby znaleźć ustawienie, przy którym najwięcej cykli jest poprawnie dopasowanych.", "pg_sweep_step": "Krok {done} / {total}", - "pg_undetected": "{n} niewykrytych cykli", "pg_verdict_bad": "Wymaga uwagi: wiele cykli pozostaje niewykrytych.", "pg_verdict_good": "Dobrze dostrojone: większość cykli jest poprawnie rozpoznawana i dopasowywana.", "pg_verdict_ok": "Akceptowalnie: część cykli pominięto. Spróbuj obniżyć próg startu.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Oznacz to jako ręcznie wybrany cykl referencyjny dla swojego programu – pełni tę samą rolę, co cykl nagrany ręcznie. Cykle referencyjne są zawsze zachowywane, zaszczepiają pasujący szablon i nigdy nie są usuwane podczas czyszczenia. (To jest „złota”/nagrana flaga; obie są tym samym.)", "review_tags_tip": "Opcjonalne flagi opisujące, co poszło nie tak w tym cyklu, więc szkolenie i czyszczenie mogą to wyjaśnić.", "review_to_cycles": "Otwórz kolejkę przeglądu Cykli", + "samples_decimated": "Wyświetlanie {shown} z {total} próbek (przerzedzone do wyświetlania; szczyty zachowane). Szeroka przerwa oznacza tu przerzedzenie, a nie brakujące dane.", "saving_triggers_reload": "Zapisanie powoduje przeładowanie integracji. Jednostki HA mogą przez chwilę być wyświetlane jako niedostępne.", "search_placeholder": "Ustawienia wyszukiwania…", "see_recorder": "Zobacz widżet rejestratora poniżej", @@ -929,7 +964,7 @@ "trend_energy_down": "Energia wykazuje tendencję spadkową ({pct}%/cykl)", "trend_energy_up": "Trend wzrostowy w zakresie energii ({pct}%/cykl) – ostatnia średnia {avg}", "trim_destructive_confirm": "To zachowa tylko {pct}% cyklu i tej operacji nie można cofnąć. Kontynuować?", - "trim_intro": "Przeciągnij czerwone uchwyty lub wprowadź wartości. Wszystko za oknem jest usuwane.", + "trim_intro": "Przeciągnij czerwone uchwyty lub wprowadź wartości. Wszystko poza oknem jest usuwane.", "tuning_suggestions_available": "Sugestia dostrojenia {count} dostępna z obserwowanych cykli. Pojawiają się obok odpowiednich pól.", "unsure_detected_prefix": "WashData nie jest pewien, czy został wykryty", "updated": "Zaktualizowano", @@ -991,12 +1026,12 @@ "share_consent": "Udostępniasz rzeczywiste dane ze swojego urządzenia. Nie udostępniaj, jeśli Twoje wzorce użytkowania są prywatne.", "share_device_none": "Nie skonfigurowano jeszcze żadnych urządzeń. Najpierw dodaj urządzenie.", "share_guideline_naming": "Używaj czytelnych nazw programów (np. 'Cotton 40', 'Eco 60'), żeby inni mogli je zidentyfikować", - "share_guideline_quality": "Udostępniaj tylko profile z ⭐ cyklami referencyjnymi lub co najmniej {n} potwierdzonymi uruchomieniami", + "share_guideline_quality": "Udostępniaj tylko cykle, które zakończyły się normalnie: bez przerw w połowie cyklu, otwierania drzwi ani chwilowych zaników zasilania.", "share_guideline_review": "Przejrzyj swoje profile przed udostępnieniem -- usuń te, które wyglądają błędnie", "share_guidelines_title": "Przed udostępnieniem", "store_download_device_intro": "Pobierz konfigurację urządzenia ze społeczności i zastosuj ją do nowego lub istniejącego urządzenia", - "store_share_device_intro": "Udostępnij programy swojego urządzenia (profile + cykle referencyjne) społeczności. Ustawienia są opcjonalne.", - "share_profile_no_cycles": "Profil '{p}' nie ma ⭐ cykli referencyjnych -- zostanie pominięty, chyba że masz {n}+ potwierdzonych uruchomień", + "store_share_device_intro": "Prześlij {brand} {model} wraz z wybranymi cyklami referencyjnymi. Inne osoby z tym samym urządzeniem mogą przyjąć Twoje programy. Wpisy są sprawdzane przed publikacją.", + "share_profile_no_cycles": "Brak cykli referencyjnych. Aby dołączyć ten profil, oznacz cykl jako ⭐ na karcie Cykle", "advisory_phase_inconsistent": "Wygląda na to, że '{name}' miesza różne programy lub temperatury - jego cykle nagrzewają się przez bardzo różne czasy. Podzielenie go na osobne profile (np. dla każdej temperatury) poprawi dopasowanie i szacowanie czasu.", "advisory_phase_inconsistent_title": "⚠ Prawdopodobnie pomieszane programy", "export_select_intro": "Zaznacz dokładnie to, co ma zostać uwzględnione. Wybranie profili bez ich cykli i tak eksportuje rozpoznawalny program (jego wyuczony kształt jest przenoszony razem z nim).", @@ -1006,7 +1041,29 @@ "merge_hint": "Zaimportowane elementy są dodawane; nic lokalnego nie zostaje utracone. Konflikty nazw rozwiązuje się poniżej.", "replace_warn": "Każda zaznaczona kategoria zostaje wyczyszczona i zastąpiona danymi z pliku. Niezaznaczone kategorie pozostają nietknięte.", "dest_reference_hint": "Zaimportowane cykle jedynie poprawiają rozpoznawanie programów i nigdy nie wpływają na statystyki zużycia/energii.", - "dest_real_history_hint": "Zaimportowane cykle liczą się jako własna historia tego urządzenia i zasilają statystyki energii/zużycia. Użyj przy przenoszeniu jednego urządzenia do nowej instalacji." + "dest_real_history_hint": "Zaimportowane cykle liczą się jako własna historia tego urządzenia i zasilają statystyki energii/zużycia. Użyj przy przenoszeniu jednego urządzenia do nowej instalacji.", + "import_history_description": "Twoja inteligentna wtyczka działała już przed WashData? Wgraj eksport historii jej czujnika mocy albo wczytaj ją wprost z Home Assistant - normalne wykrywanie przebiegnie po tych danych, więc dawne cykle pojawią się na liście Cykle, gotowe do nazwania.", + "hist_input_hint": "Wgraj plik CSV pobrany z panelu Historia (encja, stan, ostatnia zmiana) albo pozwól WashData wczytać historię czujnika bezpośrednio. Wykrywanie przebiegnie dokładnie tak samo jak na żywo, a Ty wybierasz, które z odnalezionych cykli zachować.", + "hist_recorder_hint": "Odczytuje dane od wybranej daty do teraz. Home Assistant domyślnie przechowuje szczegółową historię przez 10 dni, a potem już tylko średnie godzinowe, zbyt zgrubne do wykrywania cykli - wcześniejszą datę wybieraj tylko wtedy, gdy Twój recorder jest ustawiony na dłuższe przechowywanie.", + "hist_scanning": "Odtwarzamy Twoją historię przez detektor. Działa to w tle - możesz zamknąć to okno i wrócić później.", + "hist_imported_count": "Zaimportowane cykle: {n}.", + "hist_duplicates": "Już wcześniej zaimportowane i pominięte: {n}.", + "hist_capped": "Osiągnięto limit zaimportowanych cykli na urządzenie; reszta nie została zapisana.", + "hist_next_step": "Są na liście Cykle, oznaczone jako zaimportowana historia. Otwórz jeden z nich i przyciskiem Oznacz nadaj mu nazwę programu, do którego należy.", + "hist_rows_read": "Wczytane odczyty: {n}", + "hist_breaks": "Przerwy, w których czujnik był niedostępny: {n}", + "hist_other_entity": "Pominięte odczyty innych encji: {n}", + "hist_entity_substituted": "Odczytano {used} (to urządzenie jest skonfigurowane dla {wanted})", + "hist_skipped_spans": "Pominięte odcinki", + "hist_settings_used": "Wykryto przy aktualnych ustawieniach tego urządzenia (minimalna moc {w} W, opóźnienie wyłączenia {s} s).", + "hist_none_found": "W tej historii nie udało się wykryć żadnych cykli.", + "hist_found": "Znalezione cykle: {n}. Odznacz wszystko, co nie wygląda na prawdziwy przebieg - nic nie zostanie zapisane, dopóki nie zaimportujesz.", + "hist_scan_capped": "Pokazywane są tylko pierwsze kandydatury (znaleziono: {n}).", + "hist_recorder_empty": "Home Assistant nie ma szczegółowej historii tego czujnika w tym okresie.", + "hist_scan_failed": "Skanowanie nie powiodło się.", + "hist_scan_expired": "To skanowanie nie jest już dostępne. Uruchom je ponownie.", + "hist_import_failed": "Import nie powiódł się.", + "imported_history_readonly": "Wykryty w zaimportowanej historii mocy. Wpływa na dopasowywanie programów, ale nie liczy się do Twoich statystyk i nie można go przyciąć ani podzielić. Oznacz go, aby nadać mu nazwę programu." }, "phase_desc": { "anti_crease": "Sporadyczne krótkie obroty bębna po zakończeniu w celu zapobiegania gnieceniu tkanin.", @@ -1349,7 +1406,7 @@ "label": "Czujnik Mocy" }, "profile_duration_tolerance": { - "doc": "Pasmo +/- wokół średniego czasu trwania profilu używanego podczas dopasowywania. Wartość 0,25 oznacza, że ​​profil 60-minutowy odpowiada cyklom 45-75-minutowym.", + "doc": "Pasmo +/- wokół średniego czasu trwania profilu używanego podczas dopasowywania. Wartość 0,25 oznacza, że profil 60-minutowy odpowiada cyklom 45-75-minutowym.", "label": "Tolerancja Czasu Profilu" }, "profile_match_interval": { @@ -1357,11 +1414,11 @@ "label": "Interwał Dopasowania" }, "profile_match_max_duration_ratio": { - "doc": "Maksymalna długość cyklu w stosunku do profilu. 1.3 oznacza, że ​​cykl musi trwać krócej niż 130% czasu trwania profilu, aby uzyskać zgodność.", + "doc": "Maksymalna długość cyklu w stosunku do profilu. 1.3 oznacza, że cykl musi trwać krócej niż 130% czasu trwania profilu, aby uzyskać zgodność.", "label": "Maksymalna Proporcja Czasu Trwania" }, "profile_match_min_duration_ratio": { - "doc": "Minimalna długość cyklu w stosunku do profilu. Wartość 0,9 oznacza, że ​​aby cykl był zgodny, musi wynosić co najmniej 90% czasu trwania profilu.", + "doc": "Minimalna długość cyklu w stosunku do profilu. Wartość 0,9 oznacza, że aby cykl był zgodny, musi wynosić co najmniej 90% czasu trwania profilu.", "label": "Minimalna Proporcja Czasu Trwania" }, "profile_match_threshold": { @@ -1392,6 +1449,10 @@ "doc": "Przechowuj ślad pełnej mocy i pasujące dane debugowania dla każdego cyklu. Przydatne do rozwiązywania problemów, ale zwiększa rozmiar pamięci.", "label": "Zapisuj Ślady Debugowania" }, + "smart_termination_duration_ratio": { + "doc": "Jak daleko w oczekiwanym czasie trwania dopasowanego programu musi znajdować się cykl, zanim Inteligentne zakończenie może go zakończyć wcześniej po spadku mocy. Oczekiwany czas trwania to średnia programu, więc w urządzeniach, których czas pracy bardzo się waha - pralki z zimną wodą dopływową zimą wobec ciepłej latem, suszarki z suszeniem czujnikowym, programy zależne od wsadu - około połowa wszystkich uruchomień kończy się krócej niż ta średnia i nigdy nie doczeka się szybkiego zakończenia, kończąc dopiero przez awaryjny limit czasu, kilka minut później. W takich maszynach obniż tę wartość (np. 0,85), aby wcześniejsze zakończenie nadal się uruchamiało; zwiększ ją w stronę 1,0, aby zachować większą ostrożność. Pozostaw puste dla wartości domyślnej (0,98, a dla zmywarek 0,99). Może jedynie zakończyć cykl wcześniej, nigdy później, i nigdy nie uruchamia się przy niejednoznacznym dopasowaniu lub dopasowaniu o niskiej pewności.", + "label": "Współczynnik inteligentnego zakończenia" + }, "smoothing_window": { "doc": "W jakim stopniu surowy sygnał mocy jest wygładzany. Niski (2) reaguje, ale jest głośny; wysoki (5) wygładza skoki, ale dodaje opóźnienia.", "label": "Okno Wygładzania" @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Czas otwarcia drzwi do zakończenia cyklu", "doc": "Jak długo drzwi muszą pozostać otwarte, zanim WashData zakończy cykl, gdy włączone jest \"Drzwi otwierają się automatycznie na końcu\". Wystarczająco długo, aby zignorować szybkie dodawanie naczyń (domyślnie 60 s), wystarczająco krótko, aby szybko zakończyć, gdy maszyna otworzy drzwi." + }, + "profile_evidence_sources": { + "label": "Cykle kształtujące program", + "doc": "Które cykle są używane do zbudowania krzywej mocy każdego programu i do dopasowania do niej zakończonego cyklu. Odznaczenie danego rodzaju sprawia, że przestaje on wpływać na Twoje programy, ale nic nie zostaje usunięte - cykle pozostają na liście Cykle i nadal można je oznaczyć lub usunąć. Przydatne, jeśli nie masz zaufania do zaimportowanych danych. Nie ma to wpływu na statystyki: zawsze liczą tylko cykle, które to urządzenie faktycznie wykonało. Odznaczenie wszystkiego jest ignorowane, ponieważ program bez żadnych cykli nigdy nie mógłby zostać dopasowany." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Podstawowa konfiguracja" + }, + "profile_evidence": { + "label": "Podstawa profilu" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Przycinanie…", "splitting": "Dzielenie…", "deleting": "Usuwanie…", - "imported": "Zaimportowano" + "imported": "Zaimportowano", + "preparing": "Przygotowywanie…" }, "suggestion": { "both_agree": "WashData zaleca", @@ -1725,7 +1794,7 @@ "thr_batch": "Utrzymane tuż powyżej najniższej mocy czynnej p05 w {cycles} cyklach ({p05}W), aby start był wykrywany jak najwcześniej, a próg zatrzymania pozostawał poniżej najniższej mocy pracy urządzenia.", "tol_per_profile": "p75 wariancji czasu trwania na profil w {profiles} profilach ({cycles} cykli); spójne profile nie są karane.", "tol_pooled": "Na podstawie łącznej wariancji czasu trwania {cycles} ostatnich oznaczonych cykli (odchylenie p95={dev}).", - "watchdog": "Utrzymane tak nisko, jak to bezpieczne (tuż powyżej przerwy aktualizacji p95 wynoszącej {p95}s, min. 30s), aby zawieszenia były szybko wykrywane bez fałszywych zatrzymań." + "watchdog": "Utrzymane tak nisko, jak to bezpieczne (tuż powyżej przerwy aktualizacji p95 wynoszącej {p95}s i co najmniej 2x powyżej interwału próbkowania {median}s, min. 30s), aby zawieszenia były szybko wykrywane bez fałszywych zatrzymań." }, "exclusions": { "summary": "Wykluczono {total} błędnie wykrytych cykli: {parts}.", @@ -1772,6 +1841,7 @@ "wrong_profile": "Zły profil" }, "toast": { + "catalog_refreshed": "Katalog społeczności odświeżony", "access_saved": "Kontrola dostępu zapisana", "all_wiped": "Wszystkie dane zostały usunięte", "analysis_complete_none": "Analiza ukończona: brak nowych sugestii", @@ -1811,7 +1881,7 @@ "pg_sugg_ml_loaded": "Załadowano {n} wartości skalibrowanych przez ML - uruchom piaskownicę, aby porównać", "phase_name_required": "Nazwa fazy jest wymagana", "phase_updated": "Faza zaktualizowana", - "phases_saved": "Fazy ​​zapisane", + "phases_saved": "Fazy zapisane", "preferences_saved": "Preferencje zostały zapisane", "profile_name_required": "Nazwa profilu jest wymagana", "profile_renamed": "Zmieniono nazwę profilu", @@ -1865,19 +1935,21 @@ "rating_saved": "Ocena jakości zapisana", "brand_added": "Marka dodana, oczekuje na zatwierdzenie", "profile_added": "Profil dodany, oczekuje na zatwierdzenie", - "saved_except_conflicts": "Ustawienia zapisane -- {n} ustawienie{s} pominięte z powodu konfliktów", + "saved_except_conflicts": "Zapisano. Popraw podświetlone konflikty, aby zapisać pozostałe.", "share_device_none_sel": "Wybierz co najmniej jeden program do udostępnienia", - "store_device_downloaded": "Konfiguracja urządzenia pobrana: {created} profil{c} utworzony, {dup} już istniało", - "store_device_downloaded_phases": "Konfiguracja urządzenia pobrana: {created} profil{c} utworzony, {dup} już istniało, mapa faz zastosowana", - "store_device_downloaded_settings": "Konfiguracja urządzenia pobrana: {created} profil{c} utworzony, {dup} już istniało, ustawienia zastosowane", - "store_device_shared": "Konfiguracja urządzenia udostępniona: {n} program{s} przesłany", - "store_device_shared_all_dup": "Nic nowego do udostępnienia -- wszystkie programy już istnieją w sklepie", - "store_device_shared_partial": "Częściowe udostępnienie: {n} program{s} przesłany, {failed} pominięte", - "store_device_shared_some_dup": "Konfiguracja urządzenia udostępniona: {n} program{s} przesłany ({dup} już istniało)", + "store_device_downloaded": "Dodano programów: {p}, nagrań: {c}", + "store_device_downloaded_phases": "Dodano programów: {p}, nagrań: {c}, map faz: {ph}", + "store_device_downloaded_settings": "Dodano programów: {p}, nagrań: {c}, map faz: {ph}, ustawień: {s}", + "store_device_shared": "Udostępniono w sklepie społecznościowym (cykli: {n}), oczekuje na weryfikację.", + "store_device_shared_all_dup": "Wszystkie wybrane cykle ({n}) były już w sklepie społecznościowym.", + "store_device_shared_partial": "Udostępniono cykli: {n}; nie udało się przesłać: {failed}.", + "store_device_shared_some_dup": "Udostępniono cykli: {created}; już w sklepie: {dup}.", "store_download_failed": "Pobieranie nie powiodło się: {error}", "store_download_nothing": "Nic do pobrania -- wszystkie profile już istnieją na tym urządzeniu", "export_selective_done": "Eksport pobrany", - "import_selective_done": "Zaimportowano {profiles} profil(e) i {cycles} cykl(e)" + "import_selective_done": "Zaimportowano {profiles} profil(e) i {cycles} cykl(e)", + "hist_csv_required": "Najpierw wczytaj plik CSV lub wklej jego zawartość", + "file_read_failed": "Nie udało się odczytać tego pliku" }, "trend": { "down": "Trend w dół", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Zmywarka: sekundy ciszy po oczekiwanym czasie trwania, zanim zwolnione zostanie oczekiwanie na końcowe odpompowanie wody", + "smart_termination_duration_ratio": "Ułamek oczekiwanego czasu trwania dopasowanego programu, który cykl musi osiągnąć, zanim Inteligentne zakończenie może go zakończyć wcześniej; obniż go w przypadku maszyn zależnych od wsadu lub temperatury", "anti_wrinkle_enabled": "Pochłaniaj impulsy bębna po głównej fazie, zamiast odczytywać je jako nowe cykle", "anti_wrinkle_exit_power": "Moc musi spaść poniżej tej wartości pomiędzy impulsami, aby tryb antygniotowy pozostał aktywny", "anti_wrinkle_idle_timeout": "Dozwolony czas ciszy między dwoma impulsami bębna, zanim tryb antygniotowy się zakończy", @@ -1955,6 +2028,10 @@ "finished": "Cykl osiągnął stan końcowy i zakończył się." }, "store": { + "your_model_tip": "To urządzenie wskazane przez Ciebie w Ustawieniach", + "your_model": "Twoje", + "search_brand_ph": "Szukaj według marki…", + "programs_count": "Programy: {n}", "browse": "Przeglądaj", "device": "Urządzenie", "favorites": "Ulubione", diff --git a/custom_components/ha_washdata/translations/panel/pt-BR.json b/custom_components/ha_washdata/translations/panel/pt-BR.json index 6a5eff9e..8df517f0 100644 --- a/custom_components/ha_washdata/translations/panel/pt-BR.json +++ b/custom_components/ha_washdata/translations/panel/pt-BR.json @@ -30,9 +30,12 @@ "awaiting": "Aguardando aprovação", "imported_tip": "Importado da loja da comunidade. Usado apenas para correspondência, não contabilizado nas estatísticas.", "not_importable": "n/d aqui", - "exists": "já existe" + "exists": "já existe", + "backfilled_tip": "Detectado em um histórico de potência importado. Influencia apenas a correspondência de programas, não é contabilizado nas estatísticas." }, "btn": { + "set_brand_model": "Definir marca e modelo", + "refresh_catalog": "Atualizar catálogo", "add_device": "+ Adicionar dispositivo", "add_device_tip": "Adicionar outro dispositivo WashData", "add_maintenance": "Adicionar evento de manutenção", @@ -193,7 +196,12 @@ "import_selected": "Importar seleção", "back": "Voltar", "mute_suggestion": "Parar de sugerir esta configuração", - "reset_muted": "Redefinir silenciados" + "reset_muted": "Redefinir silenciados", + "import_power_history": "Importar histórico de potência", + "hist_read_recorder": "Ler do Home Assistant", + "hist_scan": "Procurar ciclos", + "hist_import_n": "Importar {n} ciclos", + "hist_goto_cycles": "Ver os ciclos" }, "conflict": { "anti_wrinkle_exit": { @@ -205,14 +213,14 @@ "start": "Deve ser abaixo da Potência máxima antiamassado ({max} W)" }, "attn_sub": "Corrija os conflitos antes de salvar", - "attn_title": "{n} conflito{s} de configurações", - "settings_banner": "{n} conflito{s} de configurações – verifique as seções destacadas e corrija antes de salvar.", + "attn_title": "Conflitos de configurações: {n}", + "settings_banner": "Conflitos de configurações: {n}. Verifique as seções destacadas e corrija-os antes de salvar.", "settings_banner_btn": "Ir para o primeiro", "confidence": { "auto": "Deve ser maior ou igual ao Limiar de correspondência ({match})", - "learning": "Deve ser menor ou igual ao Limiar de correspondência ({match})", + "learning": "Deve ser maior ou igual ao Limiar de correspondência ({match})", "match_for_auto": "Deve ser menor ou igual à Confiança de rotulagem automática ({alc})", - "match_for_learning": "Deve ser maior ou igual à Confiança de aprendizado ({lc})" + "match_for_learning": "Deve ser menor ou igual à Confiança de aprendizado ({lc})" }, "duration_ratio": { "max": "Deve ser maior que a Proporção de duração mínima ({min})", @@ -250,7 +258,7 @@ "match": "Deve ser acima do Limiar de não correspondência ({un})", "unmatch": "Deve ser abaixo do Limiar de correspondência ({match}); caso contrário, uma correspondência confirmada é imediatamente desfeita" }, - "cascade_toast": "Também foram aplicados {n} ajuste{s} por consistência.", + "cascade_toast": "Outras configurações ajustadas por consistência: {n}", "suggestion_resolves": "Aplique a sugestão pendente ({val}) abaixo para corrigir este conflito", "use_fix": "Usar {val}", "watchdog": { @@ -308,7 +316,8 @@ "pg_outcome": "Resultado da simulação", "pg_across_cycles": "Em todos os seus ciclos", "community_store": "Loja da comunidade", - "online_account": "Loja da comunidade e recursos online" + "online_account": "Loja da comunidade e recursos online", + "import_power_history": "Importar histórico de potência" }, "health": { "fair": "Qualidade de perfil aceitável", @@ -316,6 +325,7 @@ "poor": "⚠ Qualidade de perfil fraca" }, "lbl": { + "drag_to_resize": "Arraste para redimensionar", "actions": "Ações", "activity": "Atividade", "administrators": "Administradores", @@ -402,7 +412,7 @@ "metric": "Métrica", "mode_existing_profile": "Adicionar a perfil existente", "mode_new_profile": "Criar novo perfil", - "models_fine_tuned": "({count} modelo{plural} ajustado{plural})", + "models_fine_tuned": "(modelos ajustados: {count})", "n_classic_suggestions": "{n} clássico", "n_ml_suggestions": "{n} ML", "n_selected": "{n} selecionados", @@ -629,7 +639,26 @@ "conflict_import_copy": "Importar como cópia", "conflict_keep_mine": "Manter o meu", "conflict_overwrite": "Sobrescrever", - "font_size": "Tamanho da fonte do painel" + "font_size": "Tamanho da fonte do painel", + "hist_csv_data": "Dados CSV", + "hist_from_recorder": "Ou ler direto do Home Assistant", + "hist_since": "Desde", + "days": "dias", + "hist_keep": "Manter este ciclo", + "hist_looks_complete": "completo", + "peak_power_short": "Pico", + "shape": "Forma", + "hist_skip_idle": "nada em funcionamento", + "hist_skip_sparse": "leituras muito espaçadas", + "hist_skip_short": "leituras insuficientes", + "hist_skip_long": "nenhuma pausa longa o suficiente para dividir", + "hist_reason_short": "mais curto que o ciclo real mais curto deste aparelho", + "hist_reason_no_end": "nunca terminou de forma limpa", + "task_history_import": "Analisando o histórico de potência", + "task_history_import_apply": "Importando ciclos", + "evidence_real_cycles": "Ciclos que esta máquina executou", + "evidence_reference_cycles": "Baixados da loja da comunidade", + "evidence_backfill_cycles": "Encontrados em um histórico de potência importado" }, "log": { "all_levels": "Todos os níveis", @@ -708,9 +737,15 @@ "store_share": "Compartilhar na loja da comunidade", "store_share_device": "Compartilhar este dispositivo", "export_select": "Exportar - escolher dados", - "import_wizard": "Importar - escolher dados" + "import_wizard": "Importar - escolher dados", + "history_import": "Importar histórico de potência" }, "msg": { + "tail_trim_hint": "Número de segundos a remover do final", + "store_sibling_hint": "Nada compartilhado para o seu modelo exato? Um modelo muito parecido da mesma marca costuma ser um bom ponto de partida.", + "store_declare_appliance": "Informe ao WashData qual eletrodoméstico você tem e esta aba mostrará as configurações que outras pessoas compartilharam para ele. Você também pode digitar uma marca acima para dar uma olhada.", + "refresh_catalog_hint": "As listas de marcas e eletrodomésticos da comunidade ficam em cache para manter a loja compartilhada dentro do seu limite diário. Atualize para obter as entradas adicionadas ou aprovadas por outras pessoas.", + "head_trim_hint": "Número de segundos a remover do início", "appliance_monitor": "Monitor de eletrodoméstico", "artifact_dip_detail": "Caiu abaixo da banda de potência habitual por ~{n}s.", "artifact_footer": "Destacadas no gráfico acima. São artefatos transitórios (por exemplo, a porta foi aberta no meio do ciclo), não necessariamente problemas.", @@ -721,7 +756,7 @@ "automations_intro": "O WashData dispara eventos {start} / {end} e expõe entidades, portanto as notificações e ações são melhor criadas como automatizações normais do Home Assistant. As automatizações que usam este dispositivo aparecem abaixo.", "cleanup_intro": "Todos os ciclos rotulados sobrepostos. Marque os valores discrepantes e exclua para limpar o perfil.", "clear_debug_hint": "Remova os dados de depuração armazenados para liberar espaço.", - "collecting_data": "Coletando dados - {need} ciclo{plural} a mais antes que o ajuste fino possa começar ({current}/{min}).", + "collecting_data": "Coletando dados. Ciclos ainda necessários para que o ajuste fino possa começar: {need} ({current}/{min}).", "compare_overlay_profiles": "Sobrepor perfis (fraco)", "compare_profiles_tip": "Sobreponha outros envelopes de perfil no gráfico acima para ver qual deles melhor se adapta a este ciclo.", "compare_selected_cycles": "Ciclos selecionados (sólido) – mostrar / ocultar", @@ -731,7 +766,7 @@ "cycles_deleted": "{count} ciclo(s) excluído(s)", "enough_data": "Dados suficientes para aprender ({current}/{min} ciclos).", "export_description": "Escolha exatamente quais perfis, ciclos, configurações e mais exportar para JSON, ou analise um arquivo e importe apenas as partes que quiser.", - "feedback_cycles_pending": "{n} ciclo{s} para revisar", + "feedback_cycles_pending": "Para revisar: {n}", "feedback_prompt": "Confirme que estava correto, corrija o programa ou ignore.", "feedback_relabel_hint": "Rotular novamente este ciclo também o resolve.", "filter_by_profile": "Filtrar por perfil…", @@ -808,7 +843,6 @@ "pg_stress_synthetic": "a simulação de espera começa aqui", "pg_sweep_intro": "E se {param} fosse diferente? Teste {steps} valores nos seus últimos {cycles} ciclos para encontrar a configuração em que o maior número de ciclos é correspondido corretamente.", "pg_sweep_step": "Etapa {done} / {total}", - "pg_undetected": "{n} ciclo{s} sem detecção", "pg_verdict_bad": "Requer atenção: muitos ciclos estão passando sem detecção.", "pg_verdict_good": "Bem ajustado: a maioria dos ciclos é identificada e correspondida corretamente.", "pg_verdict_ok": "Aceitável: alguns ciclos não foram detectados. Tente reduzir o limite de início.", @@ -839,6 +873,7 @@ "review_recorded_tip": "Marque este ciclo como referência escolhida a dedo para seu programa – o mesmo papel de um ciclo gravado manualmente. Os ciclos de referência são sempre mantidos, alimentam o modelo de correspondência e nunca são removidos pela limpeza. (Esta é a bandeira \"dourada\"/gravada; ambas são a mesma coisa.)", "review_tags_tip": "Etiquetas opcionais que descrevem o que deu errado com este ciclo, para que o treinamento e a limpeza possam levar isso em conta.", "review_to_cycles": "Abrir a fila de revisão de Ciclos", + "samples_decimated": "Mostrando {shown} de {total} amostras (reduzidas para exibição; picos mantidos). Um intervalo grande aqui se deve à redução, não a dados faltantes.", "saving_triggers_reload": "Salvar aciona um recarregamento da integração. As entidades do HA podem ficar brevemente indisponíveis.", "search_placeholder": "Pesquisar configurações…", "see_recorder": "Veja o widget de gravação abaixo", @@ -958,7 +993,29 @@ "sug_mute_failed": "Não foi possível silenciar a sugestão", "sug_unmuted_all": "Sugestões silenciadas redefinidas", "n_suggestions_muted": "{count} silenciadas; o ajuste automático não irá propô-las.", - "font_size_hint": "Amplie ou reduza o texto neste painel. Aplica-se à sua conta neste dispositivo." + "font_size_hint": "Amplie ou reduza o texto neste painel. Aplica-se à sua conta neste dispositivo.", + "import_history_description": "Você já tinha uma tomada inteligente antes do WashData? Carregue uma exportação do histórico do sensor de potência dela, ou leia direto do Home Assistant: a detecção normal é executada sobre esses dados, então os ciclos antigos aparecem na sua lista de Ciclos, prontos para nomear.", + "hist_input_hint": "Carregue um CSV baixado do painel Histórico (entidade, estado, última alteração), ou deixe o WashData ler o histórico do sensor diretamente. A detecção é executada em seguida exatamente como em tempo real, e você escolhe quais dos ciclos encontrados quer manter.", + "hist_recorder_hint": "Lê desde a data que você escolher até agora. O Home Assistant mantém o histórico detalhado por 10 dias por padrão e, depois disso, apenas médias por hora, grosseiras demais para detectar ciclos - escolha uma data mais antiga somente se o recorder do Home Assistant estiver configurado para guardar mais.", + "hist_scanning": "Seu histórico está sendo reproduzido no detector. Isso é executado em segundo plano: você pode fechar esta janela e voltar depois.", + "hist_imported_count": "{n} ciclos importados.", + "hist_duplicates": "{n} já tinham sido importados e foram ignorados.", + "hist_capped": "O limite de ciclos importados por dispositivo foi atingido; os restantes não foram salvos.", + "hist_next_step": "Eles estão na sua lista de Ciclos, marcados como histórico importado. Abra um e use Rotular para indicar o programa a que ele pertence.", + "hist_rows_read": "{n} leituras lidas", + "hist_breaks": "{n} intervalos em que o sensor estava indisponível", + "hist_other_entity": "{n} leituras de outras entidades ignoradas", + "hist_entity_substituted": "{used} lido (este dispositivo está configurado para {wanted})", + "hist_skipped_spans": "Trechos ignorados", + "hist_settings_used": "Detectado com as configurações atuais deste dispositivo (potência mínima {w} W, atraso de desligamento {s} s).", + "hist_none_found": "Não foi possível detectar nenhum ciclo nesse histórico.", + "hist_found": "Encontrados {n} ciclos. Desmarque tudo o que não pareça um ciclo real: nada é salvo até você importar.", + "hist_scan_capped": "Somente os primeiros candidatos são exibidos ({n} foram encontrados).", + "hist_recorder_empty": "O Home Assistant não tem histórico detalhado para este sensor nesse período.", + "hist_scan_failed": "A análise falhou.", + "hist_scan_expired": "Essa análise não está mais disponível. Analise novamente.", + "hist_import_failed": "A importação falhou.", + "imported_history_readonly": "Detectado em um histórico de potência importado. Influencia a correspondência de programas, mas não é contabilizado nas suas estatísticas e não pode ser recortado nem dividido. Rotule-o para indicar o programa." }, "phase_desc": { "anti_crease": "Giros curtos ocasionais após a conclusão para reduzir amassados.", @@ -1169,7 +1226,7 @@ "label": "Potência Mínima" }, "ml_training_enabled": { - "doc": "Estude periodicamente seus ciclos revisados ​​durante a noite e ajuste os modelos para esta máquina específica. Uma mudança só é mantida quando realmente obtém resultados melhores em ciclos prolongados, portanto, isso só pode ajudar ou permanecer igual - nunca regredir.", + "doc": "Estude periodicamente seus ciclos revisados durante a noite e ajuste os modelos para esta máquina específica. Uma mudança só é mantida quando realmente obtém resultados melhores em ciclos prolongados, portanto, isso só pode ajudar ou permanecer igual - nunca regredir.", "label": "Aprender com esta máquina" }, "ml_training_hour": { @@ -1269,7 +1326,7 @@ "label": "Dispensar Automaticamente Após" }, "notify_title": { - "doc": "Título da notificação. Variáveis ​​de modelo: {device}, {program}, {duration}, {energy}, {cost}, {date}, {time}, {minutes}.", + "doc": "Título da notificação. Variáveis de modelo: {device}, {program}, {duration}, {energy}, {cost}, {date}, {time}, {minutes}.", "label": "Título da Notificação" }, "notify_unload_delay_minutes": { @@ -1344,6 +1401,10 @@ "doc": "Armazene o rastreamento de energia completo e os dados de depuração correspondentes para cada ciclo. Útil para solução de problemas, mas aumenta o tamanho do armazenamento.", "label": "Salvar Rastros de Depuração" }, + "smart_termination_duration_ratio": { + "doc": "Até que ponto um ciclo deve ter avançado na duração esperada do programa correspondente antes de o Encerramento inteligente poder encerrá-lo mais cedo assim que a potência cai. A duração esperada é a média do programa, então em aparelhos cujo tempo de funcionamento varia muito - lavadoras conforme a água de entrada fria no inverno ou morna no verão, secadoras com sensor de umidade, programas que dependem da carga - cerca de metade das execuções termina mais cedo do que essa média e nunca recebe o fim rápido, terminando apenas pelo tempo limite de reserva com minutos de atraso. Reduza este valor (p. ex. 0,85) nessas máquinas para que o fim antecipado continue disparando; aumente-o para perto de 1,0 para ser mais conservador. Deixe vazio para o valor padrão (0,98, ou 0,99 para lava-louças). Ele só pode encerrar um ciclo mais cedo, nunca mais tarde, e nunca dispara em uma correspondência ambígua ou de baixa confiança.", + "label": "Proporção de encerramento inteligente" + }, "smoothing_window": { "doc": "Quanto o sinal de potência bruta é suavizado. Baixo (2) é responsivo, mas barulhento; alto (5) suaviza os picos, mas adiciona atraso.", "label": "Janela de Suavização" @@ -1369,7 +1430,7 @@ "label": "Entidade do Switch" }, "watchdog_interval": { - "doc": "Com que frequência o watchdog em segundo plano verifica sensores paralisados ​​e tempos limite decorridos. Padrão 30 seg.", + "doc": "Com que frequência o watchdog em segundo plano verifica sensores paralisados e tempos limite decorridos. Padrão 30 seg.", "label": "Intervalo do Watchdog" }, "notify_milestone_message": { @@ -1474,6 +1535,10 @@ "door_end_dwell_seconds": { "label": "Tempo de Espera da Porta Aberta", "doc": "Por quanto tempo a porta deve permanecer aberta antes de o WashData encerrar o ciclo, quando a opção \"Porta Abre Automaticamente ao Final\" está ativa. Longo o suficiente para ignorar a adição rápida de um prato (padrão 60 s), curto o suficiente para encerrar prontamente assim que a máquina abre a porta." + }, + "profile_evidence_sources": { + "label": "Ciclos que moldam um programa", + "doc": "Quais ciclos são usados para construir a curva de potência de cada programa e para comparar um ciclo finalizado com ela. Ao desmarcar um tipo, ele para de moldar seus programas sem que nada seja excluído - os ciclos continuam na sua lista de Ciclos e ainda podem ser rotulados ou removidos. Útil se você não confia nos dados importados. As estatísticas não são afetadas: elas sempre contabilizam apenas os ciclos que esta máquina realmente executou. Desmarcar tudo é ignorado, pois um programa sem ciclos por trás nunca conseguiria corresponder." } }, "setting_group": { @@ -1557,6 +1622,9 @@ }, "basic_configuration": { "label": "Configuração básica" + }, + "profile_evidence": { + "label": "Base dos Perfis" } }, "status": { @@ -1581,7 +1649,8 @@ "trimming": "Recortando…", "splitting": "Dividindo…", "deleting": "Excluindo…", - "imported": "Importado" + "imported": "Importado", + "preparing": "Preparando…" }, "tab": { "advanced": "Avançado", @@ -1611,6 +1680,7 @@ "wrong_profile": "Perfil errado" }, "toast": { + "catalog_refreshed": "Catálogo da comunidade atualizado", "access_saved": "Controle de acesso salvo", "all_wiped": "Todos os dados apagados", "analysis_complete_none": "Análise concluída: sem novas sugestões", @@ -1716,7 +1786,9 @@ "store_download_failed": "Falha no download: {error}", "store_download_nothing": "Nada novo para baixar - esta configuração já está neste dispositivo.", "export_selective_done": "Exportação baixada", - "import_selective_done": "Importados {profiles} perfil(is) e {cycles} ciclo(s)" + "import_selective_done": "Importados {profiles} perfil(is) e {cycles} ciclo(s)", + "hist_csv_required": "Carregue primeiro um arquivo CSV ou cole o conteúdo dele", + "file_read_failed": "Não foi possível ler esse arquivo" }, "suggestion": { "both_agree": "WashData recomenda", @@ -1812,7 +1884,7 @@ "thr_batch": "Mantido logo acima da menor potência ativa p05 em {cycles} ciclos ({p05}W) para que um início seja captado o mais cedo possível e o limiar de parada permaneça abaixo da menor potência de funcionamento da máquina.", "tol_per_profile": "p75 da variância de duração por perfil em {profiles} perfis ({cycles} ciclos); perfis consistentes não são penalizados.", "tol_pooled": "Com base na variância de duração agregada de {cycles} ciclos rotulados recentes (desvio p95={dev}).", - "watchdog": "Mantido tão baixo quanto é seguro (logo acima do intervalo de atualização p95 de {p95}s, mín. 30s) para que travamentos sejam detectados rapidamente sem paradas falsas." + "watchdog": "Mantido tão baixo quanto é seguro (logo acima do intervalo de atualização p95 de {p95}s e pelo menos 2x o intervalo de amostragem de {median}s, mín. 30s) para que travamentos sejam detectados rapidamente sem paradas falsas." }, "exclusions": { "summary": "Excluído(s) {total} ciclo(s) mal detectado(s): {parts}.", @@ -1844,6 +1916,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Lava-louças: segundos de silêncio após a duração esperada antes de liberar a espera da descarga de fim de ciclo", + "smart_termination_duration_ratio": "Fração da duração esperada do programa correspondente que um ciclo deve atingir antes de o Encerramento inteligente poder encerrá-lo mais cedo; reduza-a para máquinas que dependem da carga ou da temperatura", "anti_wrinkle_enabled": "Absorver os pulsos de giro após a fase principal em vez de lê-los como novos ciclos", "anti_wrinkle_exit_power": "A potência deve cair abaixo deste valor entre os pulsos para que o modo antiamasso permaneça ativo", "anti_wrinkle_idle_timeout": "Tempo de silêncio permitido entre dois pulsos de giro antes de o modo antiamasso terminar", @@ -1907,6 +1980,10 @@ "finished": "O ciclo atingiu um estado final e foi encerrado." }, "store": { + "your_model_tip": "Este é o eletrodoméstico que você declarou em Configurações", + "your_model": "Seu", + "search_brand_ph": "Pesquisar por marca…", + "programs_count": "Programas: {n}", "browse": "Navegar", "device": "Dispositivo", "favorites": "Favoritos", diff --git a/custom_components/ha_washdata/translations/panel/pt.json b/custom_components/ha_washdata/translations/panel/pt.json index c9c51893..ee2cf703 100644 --- a/custom_components/ha_washdata/translations/panel/pt.json +++ b/custom_components/ha_washdata/translations/panel/pt.json @@ -30,9 +30,12 @@ "awaiting": "A aguardar aprovação", "imported_tip": "Importado da loja da comunidade. Usado apenas para correspondência, não contabilizado nas estatísticas.", "not_importable": "n/d aqui", - "exists": "já existe" + "exists": "já existe", + "backfilled_tip": "Detetado num histórico de potência importado. Influencia apenas a correspondência de programas, não é contabilizado nas estatísticas." }, "btn": { + "set_brand_model": "Definir marca e modelo", + "refresh_catalog": "Actualizar catálogo", "add_device": "+ Adicionar dispositivo", "add_device_tip": "Adicionar outro dispositivo WashData", "add_maintenance": "Adicionar evento de manutenção", @@ -193,7 +196,12 @@ "import_selected": "Importar seleção", "back": "Voltar", "mute_suggestion": "Não sugerir mais esta definição", - "reset_muted": "Repor silenciados" + "reset_muted": "Repor silenciados", + "import_power_history": "Importar histórico de potência", + "hist_read_recorder": "Ler do Home Assistant", + "hist_scan": "Procurar ciclos", + "hist_import_n": "Importar {n} ciclos", + "hist_goto_cycles": "Ver os ciclos" }, "conflict": { "anti_wrinkle_exit": { @@ -205,14 +213,14 @@ "start": "Deve ser inferior à Potência máxima anti-vincos ({max} W)" }, "attn_sub": "Corrija os conflitos antes de guardar", - "attn_title": "{n} conflito{s} de definições", - "settings_banner": "{n} conflito{s} de definições – verifique as secções realçadas e corrija antes de guardar.", + "attn_title": "Conflitos de definições: {n}", + "settings_banner": "Conflitos de definições: {n}. Verifique as secções realçadas e corrija-os antes de guardar.", "settings_banner_btn": "Ir para o primeiro", "confidence": { "auto": "Deve ser superior ou igual ao Limiar de correspondência ({match})", - "learning": "Deve ser inferior ou igual ao Limiar de correspondência ({match})", + "learning": "Deve ser superior ou igual ao Limiar de correspondência ({match})", "match_for_auto": "Deve ser inferior ou igual à Confiança de etiquetagem automática ({alc})", - "match_for_learning": "Deve ser superior ou igual à Confiança de aprendizagem ({lc})" + "match_for_learning": "Deve ser inferior ou igual à Confiança de aprendizagem ({lc})" }, "duration_ratio": { "max": "Deve ser superior ao Rácio de duração mínimo ({min})", @@ -250,7 +258,7 @@ "match": "Deve ser superior ao Limiar de não correspondência ({un})", "unmatch": "Deve ser inferior ao Limiar de correspondência ({match}); caso contrário, uma correspondência confirmada é imediatamente anulada" }, - "cascade_toast": "Também foram aplicados {n} ajuste{s} por consistência.", + "cascade_toast": "Outras definições ajustadas por consistência: {n}", "suggestion_resolves": "Aplique a sugestão pendente ({val}) abaixo para corrigir este conflito", "use_fix": "Usar {val}", "watchdog": { @@ -308,7 +316,8 @@ "pg_outcome": "Resultado da simulação", "pg_across_cycles": "Em todos os seus ciclos", "community_store": "Loja da comunidade", - "online_account": "Loja da comunidade e funcionalidades online" + "online_account": "Loja da comunidade e funcionalidades online", + "import_power_history": "Importar histórico de potência" }, "health": { "fair": "Qualidade de perfil aceitável", @@ -316,6 +325,7 @@ "poor": "⚠ Qualidade de perfil fraca" }, "lbl": { + "drag_to_resize": "Arraste para redimensionar", "actions": "Acções", "activity": "Actividade", "administrators": "Administradores", @@ -402,7 +412,7 @@ "metric": "Métrica", "mode_existing_profile": "Adicionar a perfil existente", "mode_new_profile": "Criar novo perfil", - "models_fine_tuned": "({count} modelo{plural} ajustado{plural})", + "models_fine_tuned": "(modelos ajustados: {count})", "n_classic_suggestions": "{n} clássico", "n_ml_suggestions": "{n} ML", "n_selected": "{n} selecionados", @@ -602,7 +612,7 @@ "show_contributor": "Mostrar nomes dos colaboradores", "task_pg_detail": "Simular ciclo", "task_split": "A dividir o ciclo", - "task_trim": "A recortar o ciclo", + "task_trim": "A cortar o ciclo", "task_merge": "A combinar ciclos", "task_rebuild": "A reconstruir envelopes", "cat_profiles": "Perfis (programas)", @@ -629,7 +639,26 @@ "conflict_import_copy": "Importar como cópia", "conflict_keep_mine": "Manter o meu", "conflict_overwrite": "Substituir", - "font_size": "Tamanho da letra do painel" + "font_size": "Tamanho da letra do painel", + "hist_csv_data": "Dados CSV", + "hist_from_recorder": "Ou ler diretamente do Home Assistant", + "hist_since": "Desde", + "days": "dias", + "hist_keep": "Manter este ciclo", + "hist_looks_complete": "completo", + "peak_power_short": "Pico", + "shape": "Forma", + "hist_skip_idle": "nada em funcionamento", + "hist_skip_sparse": "leituras demasiado espaçadas", + "hist_skip_short": "muito poucas leituras", + "hist_skip_long": "nenhuma pausa suficientemente longa para dividir", + "hist_reason_short": "mais curto do que o ciclo real mais curto deste aparelho", + "hist_reason_no_end": "nunca terminou de forma limpa", + "task_history_import": "A analisar o histórico de potência", + "task_history_import_apply": "A importar ciclos", + "evidence_real_cycles": "Ciclos que esta máquina executou", + "evidence_reference_cycles": "Transferidos da loja da comunidade", + "evidence_backfill_cycles": "Encontrados num histórico de potência importado" }, "log": { "all_levels": "Todos os níveis", @@ -708,9 +737,15 @@ "store_share": "Partilhar na loja da comunidade", "store_share_device": "Partilhar este dispositivo", "export_select": "Exportar - escolher dados", - "import_wizard": "Importar - escolher dados" + "import_wizard": "Importar - escolher dados", + "history_import": "Importar histórico de potência" }, "msg": { + "tail_trim_hint": "Número de segundos a remover do fim", + "store_sibling_hint": "Não há nada partilhado para o seu modelo exato? Um modelo muito semelhante da mesma marca é normalmente um bom ponto de partida.", + "store_declare_appliance": "Indique ao WashData qual o electrodoméstico que possui e este separador mostrará as configurações que outras pessoas partilharam para ele. Também pode escrever uma marca acima para explorar.", + "refresh_catalog_hint": "As listas de marcas e electrodomésticos da comunidade são guardadas em cache para manter a loja partilhada dentro do seu limite diário. Actualize para obter as entradas adicionadas ou aprovadas por outros utilizadores.", + "head_trim_hint": "Número de segundos a remover do início", "appliance_monitor": "Monitor de electrodoméstico", "artifact_dip_detail": "Caiu abaixo da banda de potência habitual durante ~{n}s.", "artifact_footer": "Realçadas no gráfico acima. São artefactos transitórios (por exemplo, a porta foi aberta a meio do ciclo), não necessariamente problemas.", @@ -721,7 +756,7 @@ "automations_intro": "O WashData dispara os eventos {start} / {end} e expõe entidades, por isso as notificações e ações são melhor criadas como automatizações normais do Home Assistant. As automatizações que utilizam este dispositivo aparecem abaixo.", "cleanup_intro": "Todos os ciclos rotulados sobrepostos. Assinale os valores aberrantes e elimine para limpar o perfil.", "clear_debug_hint": "Remova os dados de depuração armazenados para libertar espaço.", - "collecting_data": "A recolher dados - mais {need} ciclo{plural} antes de o ajuste fino poder começar ({current}/{min}).", + "collecting_data": "A recolher dados. Ciclos ainda necessários para o ajuste fino poder começar: {need} ({current}/{min}).", "compare_overlay_profiles": "Sobrepor perfis (ténue)", "compare_profiles_tip": "Sobreponha outros envelopes de perfil no gráfico acima para ver qual deles melhor se adequa a este ciclo.", "compare_selected_cycles": "Ciclos seleccionados (sólido) – mostrar / ocultar", @@ -731,7 +766,7 @@ "cycles_deleted": "{count} ciclo(s) eliminado(s)", "enough_data": "Dados suficientes para aprender ({current}/{min} ciclos).", "export_description": "Escolha exatamente que perfis, ciclos, definições e mais exportar para JSON, ou analise um ficheiro e importe apenas as partes que quiser.", - "feedback_cycles_pending": "{n} ciclo{s} para rever", + "feedback_cycles_pending": "Para rever: {n}", "feedback_prompt": "Confirme que estava correcto, corrija o programa ou ignore.", "feedback_relabel_hint": "Voltar a rotular este ciclo também o resolve.", "filter_by_profile": "Filtrar por perfil…", @@ -808,7 +843,6 @@ "pg_stress_synthetic": "a simulação de repouso começa aqui", "pg_sweep_intro": "E se {param} fosse diferente? Teste {steps} valores nos seus últimos {cycles} ciclos para encontrar a definição em que mais ciclos são reconhecidos corretamente.", "pg_sweep_step": "Passo {done} / {total}", - "pg_undetected": "{n} ciclo{s} não detetado{s}", "pg_verdict_bad": "Requer atenção: muitos ciclos não são detetados.", "pg_verdict_good": "Bem afinado: a maioria dos ciclos é identificada e reconhecida corretamente.", "pg_verdict_ok": "Aceitável: alguns ciclos não foram detetados. Tente baixar o limiar de início.", @@ -839,6 +873,7 @@ "review_recorded_tip": "Marque este ciclo como referência escolhida a dedo para o seu programa – o mesmo papel de um ciclo gravado manualmente. Os ciclos de referência são sempre mantidos, semeiam o modelo de correspondência e nunca são removidos pela limpeza. (Esta é a bandeira \"dourada\"/gravada; ambas são a mesma coisa.)", "review_tags_tip": "Etiquetas opcionais que descrevem o que correu mal com este ciclo, para que o treino e a limpeza possam ter isso em conta.", "review_to_cycles": "Abrir a fila de revisão de Ciclos", + "samples_decimated": "A mostrar {shown} de {total} amostras (reduzidas para exibição; picos mantidos). Um intervalo grande aqui deve-se à redução, não a dados em falta.", "saving_triggers_reload": "Guardar desencadeia um recarregamento da integração. As entidades HA podem ficar brevemente indisponíveis.", "search_placeholder": "Pesquisar definições…", "see_recorder": "Veja o widget de gravação abaixo", @@ -958,10 +993,33 @@ "sug_mute_failed": "Não foi possível silenciar a sugestão", "sug_unmuted_all": "Sugestões silenciadas repostas", "n_suggestions_muted": "{count} silenciadas; o ajustador automático não proporá estas definições.", - "font_size_hint": "Torna tudo neste painel maior ou menor. Aplica-se à sua conta neste dispositivo." + "font_size_hint": "Torna tudo neste painel maior ou menor. Aplica-se à sua conta neste dispositivo.", + "import_history_description": "Já tinha uma tomada inteligente antes do WashData? Carregue uma exportação do histórico do respetivo sensor de potência, ou leia-o diretamente do Home Assistant: a deteção normal é executada sobre esses dados, pelo que os ciclos passados aparecem na sua lista de Ciclos, prontos a nomear.", + "hist_input_hint": "Carregue um CSV descarregado do painel Histórico (entidade, estado, última alteração), ou deixe o WashData ler diretamente o histórico do sensor. A deteção é depois executada exatamente como em tempo real e escolhe quais dos ciclos encontrados quer manter.", + "hist_recorder_hint": "Lê desde a data que escolher até agora. O Home Assistant guarda o histórico detalhado durante 10 dias por predefinição e, depois disso, apenas médias horárias, demasiado grosseiras para detetar ciclos - escolha uma data mais antiga apenas se o recorder do Home Assistant estiver configurado para guardar mais.", + "hist_scanning": "O seu histórico está a ser reproduzido através do detetor. Isto é executado em segundo plano: pode fechar esta janela e voltar mais tarde.", + "hist_imported_count": "{n} ciclos importados.", + "hist_duplicates": "{n} já tinham sido importados e foram ignorados.", + "hist_capped": "Foi atingido o limite de ciclos importados por dispositivo; os restantes não foram guardados.", + "hist_next_step": "Estão na sua lista de Ciclos, marcados como histórico importado. Abra um e use Rotular para indicar o programa a que pertence.", + "hist_rows_read": "{n} leituras lidas", + "hist_breaks": "{n} intervalos em que o sensor estava indisponível", + "hist_other_entity": "{n} leituras de outras entidades ignoradas", + "hist_entity_substituted": "{used} lido (este dispositivo está configurado para {wanted})", + "hist_skipped_spans": "Trechos ignorados", + "hist_settings_used": "Detetado com as definições atuais deste dispositivo (potência mínima {w} W, atraso de desligamento {s} s).", + "hist_none_found": "Não foi possível detetar nenhum ciclo nesse histórico.", + "hist_found": "Encontrados {n} ciclos. Desmarque tudo o que não pareça um ciclo real: nada é guardado até importar.", + "hist_scan_capped": "São mostrados apenas os primeiros candidatos (foram encontrados {n}).", + "hist_recorder_empty": "O Home Assistant não tem histórico detalhado para este sensor nesse período.", + "hist_scan_failed": "A análise falhou.", + "hist_scan_expired": "Essa análise já não está disponível. Analise novamente.", + "hist_import_failed": "A importação falhou.", + "imported_history_readonly": "Detetado num histórico de potência importado. Influencia a correspondência de programas, mas não é contabilizado nas suas estatísticas e não pode ser cortado nem dividido. Rotule-o para indicar o programa." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Máquina de lavar louça: segundos de silêncio após a duração esperada antes de libertar a espera da descarga de fim de ciclo", + "smart_termination_duration_ratio": "Fração da duração esperada do programa correspondente que um ciclo deve atingir antes de a Terminação inteligente o poder terminar mais cedo; reduza-a para máquinas que dependem da carga ou da temperatura", "anti_wrinkle_enabled": "Absorver os pulsos de rotação após a fase principal em vez de os ler como novos ciclos", "anti_wrinkle_exit_power": "A potência deve descer abaixo deste valor entre pulsos para que o modo anti-vincos permaneça ativo", "anti_wrinkle_idle_timeout": "Tempo de silêncio permitido entre dois pulsos de rotação antes de o modo anti-vincos terminar", @@ -1198,7 +1256,7 @@ "label": "Potência Mínima" }, "ml_training_enabled": { - "doc": "Estude periodicamente seus ciclos revisados ​​durante a noite e ajuste os modelos para esta máquina específica. Uma mudança só é mantida quando realmente obtém resultados melhores em ciclos prolongados, portanto, isso só pode ajudar ou permanecer igual - nunca regredir.", + "doc": "Estude periodicamente seus ciclos revisados durante a noite e ajuste os modelos para esta máquina específica. Uma mudança só é mantida quando realmente obtém resultados melhores em ciclos prolongados, portanto, isso só pode ajudar ou permanecer igual - nunca regredir.", "label": "Aprender com esta máquina" }, "ml_training_hour": { @@ -1298,7 +1356,7 @@ "label": "Dispensar Automaticamente Após" }, "notify_title": { - "doc": "Título da notificação. Variáveis ​​de modelo: {device}, {program}, {duration}, {energy}, {cost}, {date}, {time}, {minutes}.", + "doc": "Título da notificação. Variáveis de modelo: {device}, {program}, {duration}, {energy}, {cost}, {date}, {time}, {minutes}.", "label": "Título da Notificação" }, "notify_unload_delay_minutes": { @@ -1373,6 +1431,10 @@ "doc": "Armazene o rastreamento de energia completo e os dados de depuração correspondentes para cada ciclo. Útil para solução de problemas, mas aumenta o tamanho do armazenamento.", "label": "Guardar Traços de Depuração" }, + "smart_termination_duration_ratio": { + "doc": "Até que ponto um ciclo deve ter avançado na duração esperada do programa correspondente antes de a Terminação inteligente o poder terminar mais cedo assim que a potência cai. A duração esperada é a média do programa, por isso em aparelhos cujo tempo de funcionamento varia muito - máquinas de lavar consoante a água de entrada fria no inverno ou morna no verão, secadores com sensor de humidade, programas que dependem da carga - cerca de metade das execuções termina mais cedo do que essa média e nunca beneficia do fim rápido, terminando apenas através do tempo limite de reserva com minutos de atraso. Reduza este valor (p. ex. 0,85) nessas máquinas para que o fim antecipado continue a disparar; aumente-o para perto de 1,0 para ser mais conservador. Deixe vazio para o valor predefinido (0,98, ou 0,99 para máquinas de lavar louça). Só pode terminar um ciclo mais cedo, nunca mais tarde, e nunca dispara numa correspondência ambígua ou de baixa confiança.", + "label": "Rácio de terminação inteligente" + }, "smoothing_window": { "doc": "Quanto o sinal de potência bruta é suavizado. Baixo (2) é responsivo, mas barulhento; alto (5) suaviza os picos, mas adiciona atraso.", "label": "Janela de Suavização" @@ -1398,7 +1460,7 @@ "label": "Entidade do Switch" }, "watchdog_interval": { - "doc": "Com que frequência o watchdog em segundo plano verifica sensores paralisados ​​e tempos limite decorridos. Padrão 30 seg.", + "doc": "Com que frequência o watchdog em segundo plano verifica sensores paralisados e tempos limite decorridos. Padrão 30 seg.", "label": "Intervalo do Watchdog" }, "notify_milestone_message": { @@ -1503,6 +1565,10 @@ "door_end_dwell_seconds": { "label": "Tempo de Espera com Porta Aberta para Fim", "doc": "Quanto tempo a porta tem de permanecer aberta antes de o WashData terminar o ciclo, quando a opção \"Porta Abre Automaticamente no Fim\" está ativa. Suficientemente longo para ignorar a adição rápida de um prato (predefinição: 60 s), suficientemente curto para terminar prontamente quando a máquina abre a porta." + }, + "profile_evidence_sources": { + "label": "Ciclos que moldam um programa", + "doc": "Que ciclos são usados para construir a curva de potência de cada programa e para comparar um ciclo terminado com ela. Ao desmarcar um tipo, este deixa de moldar os seus programas sem que nada seja apagado - os ciclos permanecem na sua lista de Ciclos e podem continuar a ser rotulados ou removidos. Útil se não confiar nos dados importados. As estatísticas não são afetadas: contabilizam sempre apenas os ciclos que esta máquina realmente executou. Desmarcar tudo é ignorado, pois um programa sem ciclos por trás nunca poderia corresponder." } }, "setting_group": { @@ -1586,6 +1652,9 @@ }, "basic_configuration": { "label": "Configuração básica" + }, + "profile_evidence": { + "label": "Base dos Perfis" } }, "status": { @@ -1610,7 +1679,8 @@ "trimming": "A cortar…", "splitting": "A dividir…", "deleting": "A eliminar…", - "imported": "Importado" + "imported": "Importado", + "preparing": "A preparar…" }, "tab": { "advanced": "Avançado", @@ -1640,6 +1710,7 @@ "wrong_profile": "Perfil errado" }, "toast": { + "catalog_refreshed": "Catálogo da comunidade actualizado", "access_saved": "Controlo de acesso guardado", "all_wiped": "Todos os dados apagados", "analysis_complete_none": "Análise concluída: sem novas sugestões", @@ -1745,7 +1816,9 @@ "store_download_failed": "Falha na transferência: {error}", "store_download_nothing": "Nada de novo para transferir -- esta configuração já está no seu dispositivo.", "export_selective_done": "Exportação transferida", - "import_selective_done": "Importados {profiles} perfil(is) e {cycles} ciclo(s)" + "import_selective_done": "Importados {profiles} perfil(is) e {cycles} ciclo(s)", + "hist_csv_required": "Carregue primeiro um ficheiro CSV ou cole o respetivo conteúdo", + "file_read_failed": "Não foi possível ler esse ficheiro" }, "suggestion": { "both_agree": "WashData recomenda", @@ -1841,7 +1914,7 @@ "thr_batch": "Mantido logo acima da potência ativa mais baixa no p05 ao longo de {cycles} ciclos ({p05}W) para que um arranque seja captado o mais cedo possível e o limiar de paragem se mantenha abaixo da potência de funcionamento mais baixa da máquina.", "tol_per_profile": "p75 da variância de duração por perfil em {profiles} perfis ({cycles} ciclos); os perfis restritos não são penalizados.", "tol_pooled": "Com base na variância de duração agrupada de {cycles} ciclos rotulados recentes (desvio p95={dev}).", - "watchdog": "Mantido o mais baixo que é seguro (ligeiramente acima do intervalo de atualização p95 de {p95}s, mín. 30s) para detetar rapidamente os bloqueios sem paragens falsas." + "watchdog": "Mantido o mais baixo que é seguro (ligeiramente acima do intervalo de atualização p95 de {p95}s e pelo menos 2x o intervalo de amostragem de {median}s, mín. 30s) para detetar rapidamente os bloqueios sem paragens falsas." }, "exclusions": { "summary": "Excluído(s) {total} ciclo(s) mal detetado(s): {parts}.", @@ -1907,6 +1980,10 @@ "finished": "O ciclo atingiu um estado final e terminou." }, "store": { + "your_model_tip": "Este é o electrodoméstico que declarou nas Definições", + "your_model": "O seu", + "search_brand_ph": "Pesquisar por marca…", + "programs_count": "Programas: {n}", "browse": "Explorar", "device": "Dispositivo", "favorites": "Favoritos", @@ -1962,7 +2039,7 @@ "apply": "A dividir o ciclo" }, "trim": { - "apply": "A recortar o ciclo" + "apply": "A cortar o ciclo" }, "merge": { "apply": "A combinar ciclos" diff --git a/custom_components/ha_washdata/translations/panel/ro.json b/custom_components/ha_washdata/translations/panel/ro.json index 41f4fff9..b060a516 100644 --- a/custom_components/ha_washdata/translations/panel/ro.json +++ b/custom_components/ha_washdata/translations/panel/ro.json @@ -30,9 +30,12 @@ "awaiting": "În așteptarea aprobării", "imported_tip": "Importat din magazinul comunității. Folosit doar pentru potrivire, necontabilizat în statistici.", "not_importable": "n/a aici", - "exists": "există deja" + "exists": "există deja", + "backfilled_tip": "Detectat într-un istoric de putere importat. Influențează doar potrivirea programelor, nu este contabilizat în statistici." }, "btn": { + "set_brand_model": "Stabiliți marca și modelul", + "refresh_catalog": "Actualizați catalogul", "add_device": "+ Adăugați dispozitiv", "add_device_tip": "Adăugați un alt dispozitiv WashData", "add_maintenance": "Adăugați eveniment de întreținere", @@ -193,7 +196,12 @@ "import_selected": "Importă selecția", "back": "Înapoi", "mute_suggestion": "Nu mai sugerați această setare", - "reset_muted": "Resetați cele ignorate" + "reset_muted": "Resetați cele ignorate", + "import_power_history": "Importați istoricul de putere", + "hist_read_recorder": "Citiți din Home Assistant", + "hist_scan": "Căutați cicluri", + "hist_import_n": "Importați {n} cicluri", + "hist_goto_cycles": "Vedeți ciclurile" }, "conflict": { "anti_wrinkle_exit": { @@ -205,14 +213,14 @@ "start": "Trebuie să fie sub Puterea maximă anti-șifonare ({max} W)" }, "attn_sub": "Rezolvați conflictele înainte de salvare", - "attn_title": "{n} conflict{s} de setări", - "settings_banner": "{n} conflict{s} de setări – verificați secțiunile evidențiate și remediați înainte de salvare.", + "attn_title": "Conflicte de setări: {n}", + "settings_banner": "Conflicte de setări: {n}. Verificați secțiunile evidențiate și remediați-le înainte de salvare.", "settings_banner_btn": "Salt la primul", "confidence": { "auto": "Trebuie să fie cel puțin egal cu Pragul de potrivire ({match})", - "learning": "Trebuie să fie cel mult egal cu Pragul de potrivire ({match})", + "learning": "Trebuie să fie cel puțin egal cu Pragul de potrivire ({match})", "match_for_auto": "Trebuie să fie cel mult egal cu Încrederea de etichetare automată ({alc})", - "match_for_learning": "Trebuie să fie cel puțin egal cu Încrederea de învățare ({lc})" + "match_for_learning": "Trebuie să fie cel mult egal cu Încrederea de învățare ({lc})" }, "duration_ratio": { "max": "Trebuie să fie mai mare decât Raportul de durată minim ({min})", @@ -250,7 +258,7 @@ "match": "Trebuie să fie peste Pragul de non-potrivire ({un})", "unmatch": "Trebuie să fie sub Pragul de potrivire ({match}); altfel o potrivire confirmată este anulată instantaneu" }, - "cascade_toast": "De asemenea, s-au ajustat {n} setare{s} pentru consecvență.", + "cascade_toast": "Alte setări ajustate pentru consecvență: {n}", "suggestion_resolves": "Aplicați sugestia în așteptare ({val}) de mai jos pentru a remedia aceasta", "use_fix": "Folosiți {val}", "watchdog": { @@ -308,7 +316,8 @@ "pg_outcome": "Rezultatul simulării", "pg_across_cycles": "În toate ciclurile tale", "community_store": "Magazinul comunității", - "online_account": "Magazinul comunității și funcții online" + "online_account": "Magazinul comunității și funcții online", + "import_power_history": "Importați istoricul de putere" }, "health": { "fair": "Calitate acceptabilă a profilului", @@ -316,6 +325,7 @@ "poor": "⚠ Calitate slabă a profilului" }, "lbl": { + "drag_to_resize": "Trageți pentru redimensionare", "actions": "Acțiuni", "activity": "Activitate", "administrators": "Administratori", @@ -402,7 +412,7 @@ "metric": "Metrică", "mode_existing_profile": "Adăugați la profilul existent", "mode_new_profile": "Creați un profil nou", - "models_fine_tuned": "({count} model{plural} reglat fin)", + "models_fine_tuned": "(modele ajustate: {count})", "n_classic_suggestions": "{n} clasice", "n_ml_suggestions": "{n} ML", "n_selected": "{n} selectate", @@ -602,7 +612,7 @@ "show_contributor": "Afișați numele contribuitorilor", "task_pg_detail": "Simulare ciclu", "task_split": "Se divide ciclul", - "task_trim": "Se decupează ciclul", + "task_trim": "Se taie ciclul", "task_merge": "Se îmbină ciclurile", "task_rebuild": "Se reconstruiesc anvelopele", "cat_profiles": "Profiluri (programe)", @@ -629,7 +639,26 @@ "conflict_import_copy": "Importă drept copie", "conflict_keep_mine": "Păstrează-l pe al meu", "conflict_overwrite": "Suprascrie", - "font_size": "Dimensiunea fontului panoului" + "font_size": "Dimensiunea fontului panoului", + "hist_csv_data": "Date CSV", + "hist_from_recorder": "Sau citiți-l din Home Assistant", + "hist_since": "De la data", + "days": "zile", + "hist_keep": "Păstrați acest ciclu", + "hist_looks_complete": "complet", + "peak_power_short": "Vârf", + "shape": "Formă", + "hist_skip_idle": "nimic în funcțiune", + "hist_skip_sparse": "citiri prea îndepărtate între ele", + "hist_skip_short": "prea puține citiri", + "hist_skip_long": "nicio pauză suficient de lungă pentru divizare", + "hist_reason_short": "mai scurt decât cel mai scurt ciclu real al acestui aparat", + "hist_reason_no_end": "nu s-a încheiat niciodată curat", + "task_history_import": "Se analizează istoricul de putere", + "task_history_import_apply": "Se importă ciclurile", + "evidence_real_cycles": "Cicluri rulate de această mașină", + "evidence_reference_cycles": "Descărcate din magazinul comunității", + "evidence_backfill_cycles": "Găsite într-un istoric de putere importat" }, "log": { "all_levels": "Toate nivelurile", @@ -708,9 +737,15 @@ "store_share": "Partajați în magazinul comunității", "store_share_device": "Partajați acest dispozitiv", "export_select": "Exportă - alege datele", - "import_wizard": "Importă - alege datele" + "import_wizard": "Importă - alege datele", + "history_import": "Importați istoricul de putere" }, "msg": { + "tail_trim_hint": "Numărul de secunde de eliminat de la sfârșit", + "store_sibling_hint": "Nu există nimic partajat pentru modelul dvs. exact? Un model foarte apropiat de la aceeași marcă este de obicei un bun punct de plecare.", + "store_declare_appliance": "Indicați în WashData ce aparat dețineți, iar această filă va afișa configurațiile pe care alte persoane le-au partajat pentru el. Puteți și să scrieți o marcă mai sus pentru a explora.", + "refresh_catalog_hint": "Listele de mărci și aparate ale comunității sunt păstrate în cache pentru a menține magazinul partajat în limita zilnică. Actualizați pentru a prelua intrările adăugate sau aprobate de alții.", + "head_trim_hint": "Numărul de secunde de eliminat de la început", "appliance_monitor": "Monitor de aparate", "artifact_dip_detail": "A scăzut sub banda de putere obișnuită timp de ~{n}s.", "artifact_footer": "Evidențiate în graficul de mai sus. Acestea sunt artefacte tranzitorii (de exemplu, ușa deschisă la jumătatea ciclului), nu neapărat probleme.", @@ -721,7 +756,7 @@ "automations_intro": "WashData declanșează evenimente {start} / {end} și expune entități, astfel că notificările și acțiunile sunt cel mai bine construite ca automatizări normale Home Assistant. Automatizările care utilizează acest dispozitiv apar mai jos.", "cleanup_intro": "Toate ciclurile etichetate suprapuse. Bifați valorile aberante și ștergeți pentru a curăța profilul.", "clear_debug_hint": "Eliminați datele de depanare stocate pentru a elibera spațiu.", - "collecting_data": "Se colectează date - mai sunt necesare {need} ciclu{plural} înainte ca reglajul fin să poată începe ({current}/{min}).", + "collecting_data": "Se colectează date. Cicluri încă necesare înainte ca reglajul fin să poată începe: {need} ({current}/{min}).", "compare_overlay_profiles": "Suprapuneți profiluri (estompat)", "compare_profiles_tip": "Suprapuneți alte plicuri de profil pe graficul de mai sus pentru a vedea care dintre ele se potrivește cel mai bine acestui ciclu.", "compare_selected_cycles": "Cicluri selectate (solid) – afișați / ascundeți", @@ -731,7 +766,7 @@ "cycles_deleted": "{count} ciclu(ri) șterse", "enough_data": "Date suficiente pentru a învăța ({current}/{min} cicluri).", "export_description": "Alege exact ce profiluri, cicluri, setări și altele să exporți în JSON, sau analizează un fișier și importă doar părțile dorite.", - "feedback_cycles_pending": "{n} ciclu{s} de revizuit", + "feedback_cycles_pending": "De revizuit: {n}", "feedback_prompt": "Confirmați că a fost corect, corectați programul sau ignorați.", "feedback_relabel_hint": "Și reetichetarea acestui ciclu îl rezolvă.", "filter_by_profile": "Filtrează după profil…", @@ -808,7 +843,6 @@ "pg_stress_synthetic": "simularea în repaus începe aici", "pg_sweep_intro": "Ce-ar fi dacă {param} ar fi diferit? Testați {steps} valori pe ultimele {cycles} cicluri pentru a găsi setarea la care se potrivesc corect cele mai multe cicluri.", "pg_sweep_step": "Pasul {done} / {total}", - "pg_undetected": "{n} ciclu{s} nedetectat", "pg_verdict_bad": "Necesită atenție: multe cicluri rămân nedetectate.", "pg_verdict_good": "Bine reglat: majoritatea ciclurilor sunt identificate și potrivite corect.", "pg_verdict_ok": "Acceptabil: unele cicluri au fost ratate. Încercați să reduceți pragul de pornire.", @@ -839,6 +873,7 @@ "review_recorded_tip": "Marcați acest lucru ca un ciclu de referință ales manual pentru programul său – același rol ca un ciclu înregistrat manual. Ciclurile de referință sunt întotdeauna păstrate, generează șablonul de potrivire și nu sunt niciodată eliminate de curățare. (Acesta este steagul \"de aur\"/înregistrat; ambele sunt același lucru.)", "review_tags_tip": "Semnale opționale care descriu ce a mers prost cu acest ciclu, astfel încât antrenamentul și curățarea pot ține cont de acest lucru.", "review_to_cycles": "Deschideți coada de revizuire a Ciclurilor", + "samples_decimated": "Se afișează {shown} din {total} eșantioane (reduse pentru afișare; vârfurile păstrate). Un spațiu mare aici se datorează reducerii, nu unor date lipsă.", "saving_triggers_reload": "Salvarea declanșează o reîncărcare a integrării. Entitățile HA pot apărea pe scurt ca indisponibile.", "search_placeholder": "Căutați setări…", "see_recorder": "Consultați widgetul de înregistrare de mai jos", @@ -958,7 +993,29 @@ "sug_mute_failed": "Nu s-a putut dezactiva sugestia", "sug_unmuted_all": "Sugestiile dezactivate au fost resetate", "n_suggestions_muted": "{count} dezactivate; reglajul automat nu le va propune.", - "font_size_hint": "Faceți tot din acest panou mai mare sau mai mic. Se aplică contului dvs. pe acest dispozitiv." + "font_size_hint": "Faceți tot din acest panou mai mare sau mai mic. Se aplică contului dvs. pe acest dispozitiv.", + "import_history_description": "Aveați deja o priză inteligentă înainte de WashData? Încărcați un export al istoricului senzorului său de putere sau citiți-l direct din Home Assistant: detectarea obișnuită rulează pe aceste date, astfel încât ciclurile anterioare apar în lista dvs. Cicluri, pregătite pentru a fi denumite.", + "hist_input_hint": "Încărcați un CSV descărcat din panoul Istoric (entitate, stare, ultima modificare) sau lăsați WashData să citească direct istoricul senzorului. Detectarea rulează apoi exact ca în timp real, iar dvs. alegeți care dintre ciclurile găsite să fie păstrate.", + "hist_recorder_hint": "Citește de la data pe care o alegeți până în prezent. Home Assistant păstrează implicit istoricul detaliat timp de 10 zile, iar după aceea doar medii orare, prea grosiere pentru a detecta cicluri - alegeți o dată mai veche doar dacă recorder-ul din Home Assistant este configurat să păstreze mai mult.", + "hist_scanning": "Istoricul dvs. este reluat prin detector. Această operațiune rulează în fundal: puteți închide această fereastră și reveni mai târziu.", + "hist_imported_count": "{n} cicluri importate.", + "hist_duplicates": "{n} fuseseră deja importate și au fost omise.", + "hist_capped": "Limita de cicluri importate pentru acest dispozitiv a fost atinsă; restul nu au fost salvate.", + "hist_next_step": "Se află în lista dvs. Cicluri, marcate ca istoric importat. Deschideți unul și folosiți Etichetează pentru a indica programul căruia îi aparține.", + "hist_rows_read": "{n} citiri preluate", + "hist_breaks": "{n} întreruperi în care senzorul nu a fost disponibil", + "hist_other_entity": "{n} citiri pentru alte entități ignorate", + "hist_entity_substituted": "{used} citit (acest dispozitiv este configurat pentru {wanted})", + "hist_skipped_spans": "Porțiuni omise", + "hist_settings_used": "Detectat cu setările actuale ale acestui dispozitiv (putere minimă {w} W, întârziere la oprire {s} s).", + "hist_none_found": "Nu a putut fi detectat niciun ciclu în acel istoric.", + "hist_found": "S-au găsit {n} cicluri. Debifați tot ce nu pare un ciclu real: nimic nu este salvat până la import.", + "hist_scan_capped": "Sunt afișați doar primii candidați (au fost găsiți {n}).", + "hist_recorder_empty": "Home Assistant nu are istoric detaliat pentru acest senzor în acea perioadă.", + "hist_scan_failed": "Analiza a eșuat.", + "hist_scan_expired": "Acea analiză nu mai este disponibilă. Reluați analiza.", + "hist_import_failed": "Importul a eșuat.", + "imported_history_readonly": "Detectat într-un istoric de putere importat. Influențează potrivirea programelor, dar nu este contabilizat în statisticile dvs. și nu poate fi tăiat sau divizat. Etichetați-l pentru a indica programul." }, "phase_desc": { "anti_crease": "Tumblări scurte ocazionale după finalizare pentru a reduce șifonarea.", @@ -1344,6 +1401,10 @@ "doc": "Stocați urmărirea completă a puterii și datele de depanare corespunzătoare pentru fiecare ciclu. Util pentru depanare, dar mărește dimensiunea de stocare.", "label": "Salvați Urmele de Depanare" }, + "smart_termination_duration_ratio": { + "doc": "Cât de mult trebuie să fi avansat un ciclu în durata așteptată a programului corespunzător înainte ca Terminarea inteligentă să îl poată încheia mai devreme odată ce puterea scade. Durata așteptată este media programului, așa că pe aparatele al căror timp de funcționare variază mult - mașini de spălat în funcție de apa de alimentare rece iarna sau caldă vara, uscătoare cu senzor de umiditate, programe care depind de încărcătură - aproximativ jumătate dintre rulări se termină mai devreme decât acea medie și nu beneficiază niciodată de încheierea rapidă, terminându-se doar prin expirarea de rezervă cu minute întârziere. Reduceți această valoare (de ex. 0,85) pe acele mașini pentru ca încheierea anticipată să se declanșeze totuși; măriți-o spre 1,0 pentru a fi mai conservator. Lăsați gol pentru valoarea implicită (0,98 sau 0,99 pentru mașinile de spălat vase). Poate doar să încheie un ciclu mai devreme, niciodată mai târziu, și nu se declanșează niciodată la o potrivire ambiguă sau cu încredere scăzută.", + "label": "Raport de terminare inteligentă" + }, "smoothing_window": { "doc": "Cât de mult este netezit semnalul de putere brută. Scăzut (2) este receptiv, dar zgomotos; ridicat (5) netezește vârfurile, dar adaugă lag.", "label": "Fereastră de Netezire" @@ -1474,6 +1535,10 @@ "door_end_dwell_seconds": { "label": "Durată de așteptare la deschiderea ușii", "doc": "Cât timp trebuie să rămână ușa deschisă înainte ca WashData să termine ciclul, când opțiunea \"Ușa se deschide automat la final\" este activată. Destul de lungă pentru a ignora adăugarea rapidă a unui vas (implicit 60 s), destul de scurtă pentru a termina prompt odată ce mașina deschide ușa." + }, + "profile_evidence_sources": { + "label": "Ciclurile care formează un program", + "doc": "Ce cicluri sunt folosite pentru a construi curba de putere a fiecărui program și pentru a compara un ciclu încheiat cu ea. Debifarea unui tip face ca acesta să nu mai modeleze programele dvs., fără a șterge nimic - ciclurile rămân în lista dvs. Cicluri și pot fi în continuare etichetate sau eliminate. Util dacă nu aveți încredere în datele importate. Statisticile nu sunt afectate: ele numără întotdeauna doar ciclurile pe care această mașină le-a rulat efectiv. Debifarea tuturor este ignorată, deoarece un program fără cicluri în spate nu s-ar putea potrivi niciodată." } }, "setting_group": { @@ -1557,6 +1622,9 @@ }, "basic_configuration": { "label": "Configurație de bază" + }, + "profile_evidence": { + "label": "Baza Profilurilor" } }, "status": { @@ -1581,7 +1649,8 @@ "trimming": "Se taie…", "splitting": "Se divide…", "deleting": "Se șterge…", - "imported": "Importat" + "imported": "Importat", + "preparing": "Se pregătește…" }, "tab": { "advanced": "Avansat", @@ -1611,6 +1680,7 @@ "wrong_profile": "Profil greșit" }, "toast": { + "catalog_refreshed": "Catalogul comunității a fost actualizat", "access_saved": "Controlul accesului a fost salvat", "all_wiped": "Toate datele au fost șterse", "analysis_complete_none": "Analiză finalizată: nicio sugestie nouă", @@ -1716,7 +1786,9 @@ "store_download_failed": "Descărcare eșuată: {error}", "store_download_nothing": "Nimic nou de descărcat - această configurație este deja pe dispozitivul dvs.", "export_selective_done": "Export descărcat", - "import_selective_done": "Importate {profiles} profil(uri) și {cycles} ciclu(ri)" + "import_selective_done": "Importate {profiles} profil(uri) și {cycles} ciclu(ri)", + "hist_csv_required": "Încărcați mai întâi un fișier CSV sau lipiți conținutul acestuia", + "file_read_failed": "Fișierul nu a putut fi citit" }, "suggestion": { "both_agree": "WashData recomandă", @@ -1812,7 +1884,7 @@ "thr_batch": "Păstrat chiar deasupra celei mai mici puteri active p05 pe {cycles} cicluri ({p05}W), astfel încât o pornire să fie surprinsă cât mai devreme posibil, iar pragul de oprire să rămână sub cea mai mică putere de funcționare a mașinii.", "tol_per_profile": "p75 al varianței duratei per profil pe {profiles} profiluri ({cycles} cicluri); profilurile stabile nu sunt penalizate.", "tol_pooled": "Pe baza varianței combinate a duratei a {cycles} cicluri recente etichetate (deviație p95={dev}).", - "watchdog": "Păstrat cât mai mic în siguranță (puțin peste intervalul de actualizare p95 de {p95}s, min. 30s), astfel încât blocajele să fie surprinse rapid fără opriri false." + "watchdog": "Păstrat cât mai mic în siguranță (puțin peste intervalul de actualizare p95 de {p95}s și cel puțin 2x intervalul de eșantionare de {median}s, min. 30s), astfel încât blocajele să fie surprinse rapid fără opriri false." }, "exclusions": { "summary": "Excluse {total} cicluri detectate greșit: {parts}.", @@ -1844,6 +1916,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Mașină de spălat vase: secunde de liniște după durata așteptată înainte de a elibera așteptarea evacuării de la sfârșitul ciclului", + "smart_termination_duration_ratio": "Fracțiunea din durata așteptată a programului corespunzător pe care un ciclu trebuie să o atingă înainte ca Terminarea inteligentă să îl poată încheia mai devreme; reduceți-o pentru mașinile care depind de încărcătură sau de temperatură", "anti_wrinkle_enabled": "Absoarbe impulsurile de rotire de după faza principală în loc să le trateze drept cicluri noi", "anti_wrinkle_exit_power": "Puterea trebuie să scadă sub această valoare între impulsuri pentru ca modul anti-șifonare să rămână activ", "anti_wrinkle_idle_timeout": "Timp de liniște permis între două impulsuri de rotire înainte ca modul anti-șifonare să se încheie", @@ -1907,6 +1980,10 @@ "finished": "Ciclul a ajuns într-o stare finală și s-a încheiat." }, "store": { + "your_model_tip": "Acesta este aparatul pe care l-ați declarat în Setări", + "your_model": "Al dvs.", + "search_brand_ph": "Căutați după marcă…", + "programs_count": "Programe: {n}", "browse": "Răsfoiți", "device": "Dispozitiv", "favorites": "Favorite", @@ -1962,7 +2039,7 @@ "apply": "Se divide ciclul" }, "trim": { - "apply": "Se decupează ciclul" + "apply": "Se taie ciclul" }, "merge": { "apply": "Se îmbină ciclurile" diff --git a/custom_components/ha_washdata/translations/panel/ru.json b/custom_components/ha_washdata/translations/panel/ru.json index f419bc7f..50c42339 100644 --- a/custom_components/ha_washdata/translations/panel/ru.json +++ b/custom_components/ha_washdata/translations/panel/ru.json @@ -78,9 +78,12 @@ "awaiting": "Ожидает одобрения", "imported_tip": "Импортировано из магазина сообщества. Используется только для сопоставления, не учитывается в статистике.", "not_importable": "недоступно", - "exists": "существует" + "exists": "существует", + "backfilled_tip": "Обнаружено в импортированной истории мощности. Влияет только на сопоставление программ, не учитывается в статистике." }, "btn": { + "set_brand_model": "Указать бренд и модель", + "refresh_catalog": "Обновить каталог", "add_device": "+ Добавить устройство", "add_device_tip": "Добавить ещё одно устройство WashData", "add_maintenance": "Добавить событие обслуживания", @@ -234,14 +237,19 @@ "download_device": "Скачать конфигурацию устройства", "share_device": "Поделиться конфигурацией устройства", "share_device_tip": "Поделиться программами и настройками этого устройства с сообществом", - "share_n": "Поделиться: {n} программ{s}", + "share_n": "Поделиться циклами: {n}", "export_selected": "Экспорт (выбрать данные)", "export_all": "Быстрый экспорт всего", "import_raw": "Расширенно: заменить всё из JSON", "download_export": "Скачать экспорт", "analyze_import": "Анализировать файл", "import_selected": "Импортировать выбранное", - "back": "Назад" + "back": "Назад", + "import_power_history": "Импорт истории мощности", + "hist_read_recorder": "Считать из Home Assistant", + "hist_scan": "Найти циклы", + "hist_import_n": "Импортировать циклы ({n})", + "hist_goto_cycles": "Показать циклы" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "Должен быть ниже Макс. Мощности Защиты от Морщин ({max} Вт)" }, "attn_sub": "Исправьте конфликты перед сохранением", - "attn_title": "{n} конфликт{s} настроек", - "settings_banner": "{n} конфликт{s} настроек – проверьте выделенные разделы и исправьте перед сохранением.", + "attn_title": "Конфликты настроек: {n}", + "settings_banner": "Конфликты настроек: {n}. Проверьте выделенные разделы и исправьте их перед сохранением.", "settings_banner_btn": "К первому", "confidence": { "auto": "Должен быть не ниже Порога Совпадения ({match})", - "learning": "Должен быть не выше Порога Совпадения ({match})", + "learning": "Должен быть не ниже Порога Совпадения ({match})", "match_for_auto": "Должен быть не выше Уверенности Авто-Маркировки ({alc})", - "match_for_learning": "Должен быть не ниже Уверенности Обучения ({lc})" + "match_for_learning": "Должен быть не выше Уверенности Обучения ({lc})" }, "duration_ratio": { "max": "Должен быть больше Мин. Соотношения Длительности ({min})", @@ -298,7 +306,7 @@ "match": "Должен быть выше Порога Несовпадения ({un})", "unmatch": "Должен быть ниже Порога Совпадения ({match}); иначе принятое совпадение немедленно теряется" }, - "cascade_toast": "Для согласованности также автоматически изменено ещё {n} параметр{s}.", + "cascade_toast": "Другие настройки изменены для согласованности: {n}", "suggestion_resolves": "Активируйте ожидающее предложение ({val}) ниже, чтобы исправить это", "use_fix": "Использовать {val}", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Результат симуляции", "pg_across_cycles": "По всем вашим циклам", "community_store": "Магазин сообщества", - "online_account": "Магазин сообщества и онлайн-функции" + "online_account": "Магазин сообщества и онлайн-функции", + "import_power_history": "Импорт истории мощности" }, "health": { "fair": "Приемлемое качество профиля", @@ -364,6 +373,7 @@ "poor": "⚠ Низкое качество профиля" }, "lbl": { + "drag_to_resize": "Перетащите, чтобы изменить размер", "actions": "Действия", "activity": "Активность", "administrators": "Администраторы", @@ -451,7 +461,7 @@ "metric": "Метрика", "mode_existing_profile": "Добавить к существующему профилю", "mode_new_profile": "Создать новый профиль", - "models_fine_tuned": "({count} модель{plural} точно настроено)", + "models_fine_tuned": "(точно настроенные модели: {count})", "n_classic_suggestions": "{n} классических", "n_ml_suggestions": "{n} ML", "n_selected": "{n} выбрано", @@ -677,7 +687,26 @@ "conflict_resolution": "Конфликты имён", "conflict_import_copy": "Импортировать как копию", "conflict_keep_mine": "Сохранить мои", - "conflict_overwrite": "Перезаписать" + "conflict_overwrite": "Перезаписать", + "hist_csv_data": "Данные CSV", + "hist_from_recorder": "Или считать из Home Assistant", + "days": "дн.", + "hist_keep": "Сохранить этот цикл", + "hist_looks_complete": "завершён", + "peak_power_short": "Пик", + "shape": "Форма", + "hist_skip_idle": "ничего не работало", + "hist_skip_sparse": "показания слишком редкие", + "hist_skip_short": "слишком мало показаний", + "hist_skip_long": "нет достаточно длинной паузы для разделения", + "hist_reason_short": "короче самого короткого реального цикла этого прибора", + "hist_reason_no_end": "так и не завершился корректно", + "hist_since": "Начиная с", + "task_history_import": "Сканирование истории мощности", + "task_history_import_apply": "Импорт циклов", + "evidence_real_cycles": "Циклы, выполненные этой машиной", + "evidence_reference_cycles": "Загружены из магазина сообщества", + "evidence_backfill_cycles": "Найдены в импортированной истории мощности" }, "log": { "all_levels": "Все уровни", @@ -756,9 +785,15 @@ "store_share": "Поделиться в магазине сообщества", "store_share_device": "Поделиться конфигурацией устройства", "export_select": "Экспорт - выбор данных", - "import_wizard": "Импорт - выбор данных" + "import_wizard": "Импорт - выбор данных", + "history_import": "Импорт истории мощности" }, "msg": { + "tail_trim_hint": "Сколько секунд обрезать с конца", + "store_sibling_hint": "Для вашей точной модели ничего не опубликовано? Очень похожая модель того же бренда обычно является хорошей отправной точкой.", + "store_declare_appliance": "Укажите WashData, какое у вас устройство, и на этой вкладке появятся конфигурации, опубликованные для него другими пользователями. Можно также ввести бренд выше и просто посмотреть, что есть.", + "refresh_catalog_hint": "Списки брендов и устройств сообщества кэшируются, чтобы общий магазин не превышал свой дневной лимит запросов. Обновите, чтобы получить записи, добавленные или одобренные другими.", + "head_trim_hint": "Сколько секунд обрезать с начала", "appliance_monitor": "Монитор прибора", "artifact_dip_detail": "Мощность упала ниже обычного диапазона на ~{n}с.", "artifact_footer": "Выделено на графике выше. Это временные артефакты (например, дверь открылась в середине цикла), а не обязательно проблемы.", @@ -769,7 +804,7 @@ "automations_intro": "WashData запускает события {start} / {end} и предоставляет объекты, поэтому уведомления и действия лучше всего строить как обычные автоматизации Home Assistant. Автоматизации, использующие это устройство, отображаются ниже.", "cleanup_intro": "Все помеченные циклы наложены. Отметьте выбросы и удалите для очистки профиля.", "clear_debug_hint": "Удалите сохраненные данные отладки, чтобы освободить место.", - "collecting_data": "Сбор данных – ещё {need} цикл{plural} до начала точной настройки ({current}/{min}).", + "collecting_data": "Сбор данных. Осталось циклов до начала точной настройки: {need} ({current}/{min}).", "compare_overlay_profiles": "Наложить профили (бледные)", "compare_profiles_tip": "Наложите конверты других профилей на диаграмму выше, чтобы увидеть, какой из них лучше всего подходит для этого цикла.", "compare_selected_cycles": "Выбранные циклы (чёткие) – показать / скрыть", @@ -779,7 +814,7 @@ "cycles_deleted": "Удалено циклов: {count}", "enough_data": "Достаточно данных для обучения ({current}/{min} циклов).", "export_description": "Выберите, какие именно профили, циклы, настройки и прочее экспортировать в JSON, или проанализируйте файл и импортируйте только нужные части.", - "feedback_cycles_pending": "{n} цикл{s} для проверки", + "feedback_cycles_pending": "На проверку: {n}", "feedback_prompt": "Подтвердите правильность, исправьте программу или проигнорируйте.", "feedback_relabel_hint": "Повторная маркировка этого цикла также решает его.", "filter_by_profile": "Фильтр по профилю…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "здесь начинается симуляция режима ожидания", "pg_sweep_intro": "Что если бы {param} было другим? Проверьте {steps} значений на ваших последних {cycles} циклах, чтобы найти настройку, при которой правильно сопоставляется больше всего циклов.", "pg_sweep_step": "Шаг {done} / {total}", - "pg_undetected": "{n} цикл{s} не обнаружено", "pg_verdict_bad": "Требует внимания: многие циклы остаются необнаруженными.", "pg_verdict_good": "Хорошо настроено: большинство циклов правильно определяются и сопоставляются.", "pg_verdict_ok": "Приемлемо: некоторые циклы пропущены. Попробуйте снизить порог запуска.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Отметьте это как выбранный вручную эталонный цикл для своей программы – та же роль, что и цикл, записанный вручную. Ссылочные циклы всегда сохраняются, заполняют соответствующий шаблон и никогда не удаляются при очистке. (Это «золотой»/записанный флаг; оба – одно и то же.)", "review_tags_tip": "Необязательные флаги, описывающие, что пошло не так в этом цикле, чтобы это можно было учесть при обучении и очистке.", "review_to_cycles": "Открыть очередь проверки циклов", + "samples_decimated": "Показано {shown} из {total} отсчётов (прорежено для отображения; пики сохранены). Широкий разрыв здесь - это прореживание, а не пропуск данных.", "saving_triggers_reload": "Сохранение вызывает перезагрузку интеграции. Объекты HA могут кратко отображаться как недоступные.", "search_placeholder": "Поиск настроек…", "see_recorder": "Смотрите виджет записи ниже", @@ -991,12 +1026,12 @@ "share_consent": "Вы делитесь реальными данными вашего прибора. Не делитесь, если ваши сценарии использования являются личными.", "share_device_none": "Устройства ещё не настроены. Сначала добавьте устройство.", "share_guideline_naming": "Используйте понятные названия программ (например, 'Cotton 40', 'Eco 60'), чтобы другие могли их идентифицировать", - "share_guideline_quality": "Делитесь только профилями с ⭐ эталонными циклами или не менее {n} подтверждёнными запусками", + "share_guideline_quality": "Делитесь только циклами, которые завершились нормально: без прерываний в середине цикла, открытия дверцы или кратковременных сбоев питания.", "share_guideline_review": "Просмотрите профили перед публикацией -- удалите те, которые выглядят неправильно", "share_guidelines_title": "Перед публикацией", "store_download_device_intro": "Скачать конфигурацию устройства из сообщества и применить её к новому или существующему устройству", - "store_share_device_intro": "Поделиться программами вашего устройства (профили + эталонные циклы) с сообществом. Настройки являются необязательными.", - "share_profile_no_cycles": "Профиль '{p}' не имеет ⭐ эталонных циклов -- он будет пропущен, если у вас нет {n}+ подтверждённых запусков", + "store_share_device_intro": "Загрузите {brand} {model} с выбранными вами эталонными циклами. Другие пользователи с таким же устройством смогут принять ваши программы. Записи проверяются перед публикацией.", + "share_profile_no_cycles": "Нет эталонных циклов. Чтобы включить этот профиль, отметьте цикл как ⭐ на вкладке Циклы", "advisory_phase_inconsistent": "Похоже, что '{name}' смешивает разные программы или температуры - его циклы нагреваются в течение очень разного времени. Разделение на отдельные профили (напр. по температуре) улучшит сопоставление и оценки времени.", "advisory_phase_inconsistent_title": "⚠ Возможно, смешанные программы", "export_select_intro": "Отметьте, что именно включить. Выбор профилей без их циклов всё равно экспортирует распознаваемую программу (её изученная форма передаётся вместе с ней).", @@ -1006,7 +1041,29 @@ "merge_hint": "Импортируемые элементы добавляются; ничего локального не теряется. Конфликты имён разрешаются ниже.", "replace_warn": "Каждая отмеченная категория очищается и заменяется данными из файла. Неотмеченные категории остаются без изменений.", "dest_reference_hint": "Импортированные циклы только улучшают распознавание программ и никогда не влияют на статистику использования/энергии.", - "dest_real_history_hint": "Импортированные циклы считаются собственной историей этого устройства и учитываются в статистике энергии/использования. Используйте для переноса одного прибора в новую установку." + "dest_real_history_hint": "Импортированные циклы считаются собственной историей этого устройства и учитываются в статистике энергии/использования. Используйте для переноса одного прибора в новую установку.", + "import_history_description": "Умная розетка была у вас ещё до WashData? Загрузите экспорт истории её датчика мощности или считайте её прямо из Home Assistant, и обычное обнаружение пройдёт по этим данным, так что прошлые циклы появятся в списке «Циклы» и их можно будет назвать.", + "hist_input_hint": "Загрузите CSV, скачанный из панели «История» (объект, состояние, время изменения), или позвольте WashData считать историю датчика напрямую. Затем обнаружение пройдёт по этим данным точно так же, как в реальном времени, а вы выберете, какие из найденных циклов сохранить.", + "hist_recorder_hint": "Читает данные с выбранной даты по текущий момент. По умолчанию Home Assistant хранит подробную историю 10 дней, а после этого только часовые средние значения, которые слишком грубые для обнаружения циклов - выбирайте более раннюю дату, только если в настройках recorder задан больший срок хранения.", + "hist_scanning": "История воспроизводится через детектор. Это выполняется в фоне - можно закрыть это окно и вернуться позже.", + "hist_imported_count": "Импортировано циклов: {n}.", + "hist_duplicates": "Уже импортировано ранее и пропущено: {n}.", + "hist_capped": "Достигнут лимит импортированных циклов для устройства; остальные не сохранены.", + "hist_next_step": "Они в списке «Циклы» с отметкой об импортированной истории. Откройте цикл и нажмите «Пометить», чтобы указать его программу.", + "hist_rows_read": "Прочитано показаний: {n}", + "hist_breaks": "Пропусков, где датчик был недоступен: {n}", + "hist_other_entity": "Показаний других объектов пропущено: {n}", + "hist_entity_substituted": "Прочитано {used} (для этого устройства настроен {wanted})", + "hist_skipped_spans": "Пропущенные участки", + "hist_settings_used": "Обнаружено с текущими настройками этого устройства (Минимальная Мощность {w} Вт, Задержка Выключения {s} с).", + "hist_none_found": "В этой истории не удалось обнаружить ни одного цикла.", + "hist_found": "Найдено циклов: {n}. Снимите отметки с того, что не похоже на реальный запуск - до импорта ничего не сохраняется.", + "hist_scan_capped": "Показаны только первые кандидаты (найдено: {n}).", + "hist_recorder_empty": "В Home Assistant нет подробной истории для этого датчика за указанный период.", + "hist_scan_failed": "Сканирование не удалось.", + "hist_scan_expired": "Это сканирование больше недоступно. Выполните сканирование заново.", + "hist_import_failed": "Импорт не удался.", + "imported_history_readonly": "Обнаружено в импортированной истории мощности. Влияет на сопоставление программ, но не учитывается в вашей статистике; такой цикл нельзя обрезать или разделить. Нажмите «Пометить», чтобы указать программу." }, "phase_desc": { "anti_crease": "Периодические короткие вращения барабана после завершения для уменьшения складок.", @@ -1471,6 +1528,10 @@ "show_contributor": { "doc": "Показать имя автора на профилях, загруженных из магазина сообщества" }, + "smart_termination_duration_ratio": { + "doc": "Насколько глубоко в ожидаемую длительность сопоставленной программы должен зайти цикл, прежде чем Умное завершение сможет завершить его досрочно при падении мощности. Ожидаемая длительность - это среднее значение программы, поэтому на приборах с сильно изменчивым временем работы - стиральные машины при холодной зимней и тёплой летней воде на входе, сушильные машины с датчиком влажности, программы, зависящие от загрузки - около половины всех запусков завершаются короче этого среднего и никогда не получают быстрого завершения, заканчиваясь только по резервному тайм-ауту с опозданием на несколько минут. Уменьшите это значение (напр., 0.85) на таких машинах, чтобы досрочное завершение всё же срабатывало; повышайте ближе к 1.0 для большей осторожности. Оставьте пустым для значения по умолчанию (0.98 или 0.99 для посудомоечных машин). Оно может только завершить цикл раньше, но никогда позже, и никогда не срабатывает при неоднозначном или недостаточно уверенном совпадении.", + "label": "Коэффициент умного завершения" + }, "enable_phase_matching": { "label": "Оставшееся время с учётом фаз", "doc": "Разбивает каждый активный цикл на фазы (нагрев, стирка, отжим) и распределяет оставшееся время по фазам, сочетая это с классической оценкой - опираясь на распределение по фазам в начале цикла и на классическую оценку ближе к концу. Это персонализирует отсчёт под то, сколько на самом деле нагревается и работает ваша машина, что наиболее заметно в первой половине цикла. Выключено = только классическая оценка. Влияет только на отображение оставшегося времени; сопоставление программ и обнаружение цикла остаются без изменений." @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Выдержка при открытой дверце", "doc": "Как долго дверца должна оставаться открытой до завершения цикла, когда включено \"Дверца открывается автоматически в конце\". Достаточно долго, чтобы игнорировать быструю загрузку посуды (по умолчанию 60 с), достаточно коротко для быстрого завершения после открытия дверцы машиной." + }, + "profile_evidence_sources": { + "label": "Циклы, формирующие программу", + "doc": "Какие циклы используются для построения кривой мощности каждой программы и для сопоставления с ней завершённого цикла. Если снять галочку с какого-то вида, он перестанет влиять на ваши программы, но ничего не удалится: циклы останутся в списке Циклы, их по-прежнему можно пометить или удалить. Полезно, если вы не доверяете импортированным данным. На статистику это не влияет: в ней всегда учитываются только циклы, которые эта машина действительно выполнила. Если снять все галочки, это будет проигнорировано, ведь программа без циклов никогда не смогла бы совпасть." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Основные настройки" + }, + "profile_evidence": { + "label": "Основа профиля" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Обрезка…", "splitting": "Разбивка…", "deleting": "Удаление…", - "imported": "Импортировано" + "imported": "Импортировано", + "preparing": "Подготовка…" }, "tab": { "advanced": "Дополнительно", @@ -1659,6 +1728,7 @@ "wrong_profile": "Неверный профиль" }, "toast": { + "catalog_refreshed": "Каталог сообщества обновлён", "access_saved": "Управление доступом сохранено", "all_wiped": "Все данные удалены", "analysis_complete_none": "Анализ завершён: нет новых предложений", @@ -1752,19 +1822,21 @@ "rating_saved": "Оценка качества сохранена", "brand_added": "Бренд добавлен, ожидает одобрения", "profile_added": "Профиль добавлен, ожидает одобрения", - "saved_except_conflicts": "Настройки сохранены -- {n} настройка{s} пропущена из-за конфликтов", + "saved_except_conflicts": "Сохранено. Исправьте выделенные конфликты, чтобы сохранить остальное.", "share_device_none_sel": "Выберите хотя бы одну программу для публикации", - "store_device_downloaded": "Конфигурация устройства загружена: {created} профиль{c} создан, {dup} уже существовало", - "store_device_downloaded_phases": "Конфигурация устройства загружена: {created} профиль{c} создан, {dup} уже существовало, карта фаз применена", - "store_device_downloaded_settings": "Конфигурация устройства загружена: {created} профиль{c} создан, {dup} уже существовало, настройки применены", - "store_device_shared": "Конфигурация устройства опубликована: {n} программ{s} загружено", - "store_device_shared_all_dup": "Нет ничего нового для публикации -- все программы уже есть в магазине", - "store_device_shared_partial": "Частичная публикация: {n} программ{s} загружено, {failed} пропущено", - "store_device_shared_some_dup": "Конфигурация устройства опубликована: {n} программ{s} загружено ({dup} уже существовало)", + "store_device_downloaded": "Добавлено программ: {p}, записей: {c}", + "store_device_downloaded_phases": "Добавлено программ: {p}, записей: {c}, карт фаз: {ph}", + "store_device_downloaded_settings": "Добавлено программ: {p}, записей: {c}, карт фаз: {ph}, настроек: {s}", + "store_device_shared": "Опубликовано в магазине сообщества (циклов: {n}), ожидает проверки.", + "store_device_shared_all_dup": "Все выбранные циклы ({n}) уже были в магазине сообщества.", + "store_device_shared_partial": "Опубликовано циклов: {n}; не удалось загрузить: {failed}.", + "store_device_shared_some_dup": "Опубликовано циклов: {created}; уже в магазине: {dup}.", "store_download_failed": "Ошибка загрузки: {error}", "store_download_nothing": "Нечего загружать -- все профили уже существуют на этом устройстве", "export_selective_done": "Экспорт скачан", - "import_selective_done": "Импортировано профилей: {profiles}, циклов: {cycles}" + "import_selective_done": "Импортировано профилей: {profiles}, циклов: {cycles}", + "hist_csv_required": "Сначала загрузите файл CSV или вставьте его содержимое", + "file_read_failed": "Не удалось прочитать этот файл" }, "suggestion": { "both_agree": "WashData рекомендует", @@ -1860,7 +1932,7 @@ "thr_batch": "Оставлено чуть выше наименьшей активной мощности p05 по {cycles} циклам ({p05}W), чтобы запуск фиксировался как можно раньше, а порог остановки оставался ниже минимальной рабочей мощности машины.", "tol_per_profile": "p75 разброса длительности по профилям среди {profiles} профилей ({cycles} циклов); стабильные профили не штрафуются.", "tol_pooled": "На основе объединённого разброса длительности {cycles} недавних размеченных циклов (отклонение p95={dev}).", - "watchdog": "Оставлено настолько низким, насколько это безопасно (чуть выше интервала обновления p95, равного {p95}s, мин. 30s), чтобы зависания выявлялись быстро без ложных остановок." + "watchdog": "Оставлено настолько низким, насколько это безопасно (чуть выше интервала обновления p95, равного {p95}s, и не менее 2x интервала выборки {median}s, мин. 30s), чтобы зависания выявлялись быстро без ложных остановок." }, "exclusions": { "summary": "Исключено {total} ошибочно определённых циклов: {parts}.", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Посудомоечная машина: секунды неактивности после ожидаемой длительности перед снятием ожидания финального слива в конце цикла", + "smart_termination_duration_ratio": "Доля ожидаемой длительности сопоставленной программы, которую должен достичь цикл, прежде чем Умное завершение сможет завершить его досрочно; уменьшите её для машин, зависящих от загрузки или температуры", "completion_min_seconds": "Кратчайший запуск, считающийся настоящим циклом", "end_repeat_count": "Сколько низких показаний подряд до завершения", "interrupted_min_seconds": "Короткие циклы помечаются как прерванные", @@ -1955,6 +2028,10 @@ "finished": "Цикл достиг конечного состояния и завершился." }, "store": { + "your_model_tip": "Это устройство, которое вы указали в настройках", + "your_model": "Ваше", + "search_brand_ph": "Поиск по бренду…", + "programs_count": "Программы: {n}", "browse": "Обзор", "device": "Устройство", "favorites": "Избранное", diff --git a/custom_components/ha_washdata/translations/panel/sk.json b/custom_components/ha_washdata/translations/panel/sk.json index 7e51cb87..285b8c54 100644 --- a/custom_components/ha_washdata/translations/panel/sk.json +++ b/custom_components/ha_washdata/translations/panel/sk.json @@ -78,9 +78,12 @@ "awaiting": "Čaká na schválenie", "imported_tip": "Importované z komunitného obchodu. Používa sa iba na porovnávanie, nezapočítava sa do štatistík.", "not_importable": "nedostupné", - "exists": "existuje" + "exists": "existuje", + "backfilled_tip": "Detekované v importovanej histórii výkonu. Ovplyvňuje iba priraďovanie programov, nezapočítava sa do štatistík." }, "btn": { + "set_brand_model": "Nastaviť značku a model", + "refresh_catalog": "Obnoviť katalóg", "add_device": "+ Pridať zariadenie", "add_device_tip": "Pridajte ďalšie zariadenie WashData", "add_maintenance": "Pridať udalosť údržby", @@ -234,14 +237,19 @@ "download_device": "Stiahnuť nastavenie zariadenia", "share_device": "Zdieľať nastavenie zariadenia", "share_device_tip": "Zdieľať programy a nastavenia tohto zariadenia s komunitou", - "share_n": "Zdieľať {n} program{s}", + "share_n": "Zdieľať cykly: {n}", "export_selected": "Export (vybrať údaje)", "export_all": "Rýchly export všetkého", "import_raw": "Pokročilé: nahradiť všetko zo súboru JSON", "download_export": "Stiahnuť export", "analyze_import": "Analyzovať súbor", "import_selected": "Importovať vybrané", - "back": "Späť" + "back": "Späť", + "import_power_history": "Importovať históriu výkonu", + "hist_read_recorder": "Načítať z Home Assistant", + "hist_scan": "Vyhľadať cykly", + "hist_import_n": "Importovať cykly: {n}", + "hist_goto_cycles": "Zobraziť cykly" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Musí byť pod Max. výkonom ochrany pred krčením ({max} W)" }, "attn_sub": "Pred uložením opravte konflikty", - "attn_title": "{n} konflikt{s} nastavení", + "attn_title": "Konflikty nastavení: {n}", "confidence": { "auto": "Musí byť na úrovni alebo nad Prahom zhody ({match})", - "learning": "Musí byť na úrovni alebo pod Prahom zhody ({match})", + "learning": "Musí byť na úrovni alebo nad Prahom zhody ({match})", "match_for_auto": "Musí byť na úrovni alebo pod Spoľahlivosťou autooznačovania ({alc})", - "match_for_learning": "Musí byť na úrovni alebo nad Spoľahlivosťou učenia ({lc})" + "match_for_learning": "Musí byť na úrovni alebo pod Spoľahlivosťou učenia ({lc})" }, "duration_ratio": { "max": "Musí byť väčší ako Min. pomer trvania ({min})", @@ -296,14 +304,14 @@ "match": "Musí byť nad Prahom nezhody ({un})", "unmatch": "Musí byť pod Prahom zhody ({match}); inak sa potvrdená zhoda ihneď zruší" }, - "cascade_toast": "Bolo tiež automaticky upravených {n} nastavení pre zachovanie konzistencie.", + "cascade_toast": "Ďalšie nastavenia upravené pre zachovanie konzistencie: {n}", "suggestion_resolves": "Uplatnite nižšie čakajúci návrh ({val}) na vyriešenie tohto konfliktu", "use_fix": "Použiť {val}", "watchdog": { "interval": "Mal by byť aspoň 2× Interval vzorkovania ({si} s)", "sampling": "Interval vzorkovania by mal byť najviac polovica Intervalu watchdog ({wi} s)" }, - "settings_banner": "{n} konflikt{s} nastavení – skontrolujte zvýraznené sekcie a opravte pred uložením.", + "settings_banner": "Konflikty nastavení: {n}. Skontrolujte zvýraznené sekcie a opravte ich pred uložením.", "settings_banner_btn": "Prejsť na prvý" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Výsledok simulácie", "pg_across_cycles": "Naprieč vašimi cyklami", "community_store": "Komunitný obchod", - "online_account": "Komunitný obchod a online funkcie" + "online_account": "Komunitný obchod a online funkcie", + "import_power_history": "Import histórie výkonu" }, "health": { "fair": "Prijateľná kvalita profilu", @@ -364,6 +373,7 @@ "poor": "⚠ Zlá kvalita profilu" }, "lbl": { + "drag_to_resize": "Potiahnutím zmeníte veľkosť", "actions": "Akcie", "activity": "Aktivita", "administrators": "Administrátori", @@ -451,7 +461,7 @@ "metric": "Metrika", "mode_existing_profile": "Pridať k existujúcemu profilu", "mode_new_profile": "Vytvoriť nový profil", - "models_fine_tuned": "({count} modelov doladených)", + "models_fine_tuned": "(doladené modely: {count})", "n_classic_suggestions": "{n} klasické", "n_ml_suggestions": "{n} ML", "n_selected": "{n} vybraných", @@ -677,7 +687,26 @@ "conflict_resolution": "Konflikty názvov", "conflict_import_copy": "Importovať ako kópiu", "conflict_keep_mine": "Ponechať moje", - "conflict_overwrite": "Prepísať" + "conflict_overwrite": "Prepísať", + "hist_csv_data": "Údaje CSV", + "hist_from_recorder": "Alebo ju načítajte z Home Assistant", + "days": "dní", + "hist_keep": "Zachovať tento cyklus", + "hist_looks_complete": "dokončený", + "peak_power_short": "Špička", + "shape": "Tvar", + "hist_skip_idle": "nič nebežalo", + "hist_skip_sparse": "merania príliš vzdialené od seba", + "hist_skip_short": "príliš málo meraní", + "hist_skip_long": "žiadna dostatočne dlhá prestávka na rozdelenie", + "hist_reason_short": "kratší než najkratší skutočný cyklus tohto spotrebiča", + "hist_reason_no_end": "nikdy sa riadne neskončil", + "hist_since": "Od", + "task_history_import": "Prehľadávanie histórie výkonu", + "task_history_import_apply": "Import cyklov", + "evidence_real_cycles": "Cykly, ktoré tento spotrebič vykonal", + "evidence_reference_cycles": "Stiahnuté z komunitného obchodu", + "evidence_backfill_cycles": "Nájdené v importovanej histórii výkonu" }, "log": { "all_levels": "Všetky úrovne", @@ -756,9 +785,15 @@ "store_share": "Zdieľať do komunitného obchodu", "store_share_device": "Zdieľať nastavenie zariadenia", "export_select": "Export - vybrať údaje", - "import_wizard": "Import - vybrať údaje" + "import_wizard": "Import - vybrať údaje", + "history_import": "Import histórie výkonu" }, "msg": { + "tail_trim_hint": "Koľko sekúnd odstrániť z konca", + "store_sibling_hint": "Pre váš konkrétny model nie je nič zdieľané? Veľmi podobný model od tej istej značky je zvyčajne dobrý východiskový bod.", + "store_declare_appliance": "Povedzte WashData, aký spotrebič máte, a táto karta zobrazí nastavenia, ktoré pre neho zdieľali ostatní používatelia. Môžete tiež zadať značku vyššie a poobzerať sa.", + "refresh_catalog_hint": "Zoznamy značiek a spotrebičov z komunity sa ukladajú do medzipamäte, aby zdieľaný obchod neprekročil svoj denný limit dotazov. Obnovením načítate položky, ktoré ostatní pridali alebo schválili.", + "head_trim_hint": "Koľko sekúnd odstrániť zo začiatku", "appliance_monitor": "Monitor spotrebiča", "artifact_dip_detail": "Poklesol pod bežné pásmo výkonu počas ~{n} s.", "artifact_footer": "Zvýraznené na grafe vyššie. Sú to prechodné artefakty (napr. dvere otvorené uprostred cyklu), nie nevyhnutne problémy.", @@ -769,7 +804,7 @@ "automations_intro": "WashData spúšťa udalosti {start} / {end} a sprístupňuje entity, takže notifikácie a akcie sa najlepšie vytvárajú ako bežné automatizácie Home Assistant. Automatizácie používajúce toto zariadenie sú zobrazené nižšie.", "cleanup_intro": "Všetky označené cykly naložené. Označte odchýlky a odstráňte pre vyčistenie profilu.", "clear_debug_hint": "Odstráňte uložené údaje ladenia, aby ste uvoľnili miesto.", - "collecting_data": "Zbieranie dát: ešte {need} cyklov do spustenia doladenia ({current}/{min}).", + "collecting_data": "Zbieranie dát. Zostávajúce cykly do spustenia doladenia: {need} ({current}/{min}).", "compare_overlay_profiles": "Prekryté profily (bledé)", "compare_profiles_tip": "Prekryte ostatné profilové obálky v tabuľke vyššie, aby ste videli, ktorá z nich najlepšie vyhovuje tomuto cyklu.", "compare_selected_cycles": "Vybraté cykly (tučné) – zobraziť / skryť", @@ -779,7 +814,7 @@ "cycles_deleted": "Odstránených cyklov: {count}", "enough_data": "Dostatok dát na učenie ({current}/{min} cyklov).", "export_description": "Vyberte presne, ktoré profily, cykly, nastavenia a ďalšie údaje exportovať do súboru JSON, alebo analyzujte súbor a importujte len tie časti, ktoré chcete.", - "feedback_cycles_pending": "{n} cyklov na kontrolu", + "feedback_cycles_pending": "Na kontrolu: {n}", "feedback_prompt": "Potvrďte správnosť, opravte program alebo ignorujte.", "feedback_relabel_hint": "Opätovné označenie tohto cyklu ho tiež vyrieši.", "filter_by_profile": "Filtrovať podľa profilu…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "simulácia nečinnosti začína tu", "pg_sweep_intro": "Čo keby bol {param} iný? Otestujte {steps} hodnôt na vašich posledných {cycles} cykloch a nájdite nastavenie, pri ktorom je správne priradených najviac cyklov.", "pg_sweep_step": "Krok {done} / {total}", - "pg_undetected": "{n} nezistených cyklov", "pg_verdict_bad": "Vyžaduje pozornosť: mnoho cyklov zostáva nezistených.", "pg_verdict_good": "Dobre vyladené: väčšina cyklov je správne rozpoznaná a priradená.", "pg_verdict_ok": "Prijateľné: niektoré cykly neboli zachytené. Skúste znížiť prah spustenia.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Označte to ako ručne vybraný referenčný cyklus pre svoj program – rovnakú úlohu ako manuálne zaznamenaný cyklus. Referenčné cykly sa vždy uchovávajú, nasadzujú zodpovedajúcu šablónu a pri čistení sa nikdy nezrušia. (Toto je zlatá/zaznamenaná vlajka; obe sú to isté.)", "review_tags_tip": "Voliteľné príznaky popisujúce, čo sa v tomto cykle pokazilo, aby to tréning a čistenie mohli zohľadniť.", "review_to_cycles": "Otvoriť front kontroly cyklov", + "samples_decimated": "Zobrazených {shown} z {total} vzoriek (preriedené pre zobrazenie; špičky zachované). Široká medzera tu znamená preriedenie, nie chýbajúce dáta.", "saving_triggers_reload": "Uloženie spustí opätovné načítanie integrácie. Entity HA sa môžu nakrátko zobraziť ako nedostupné.", "search_placeholder": "Hľadať nastavenia…", "see_recorder": "Pozrite si widget nahrávania nižšie", @@ -991,12 +1026,12 @@ "share_consent": "Zdieľate skutočné dáta zo svojho spotrebiča. Nezdieľajte, ak sú vaše vzorce používania súkromné.", "share_device_none": "Zatiaľ nie sú nastavené žiadne zariadenia. Najprv pridajte zariadenie.", "share_guideline_naming": "Používajte zrozumiteľné názvy programov (napr. 'Cotton 40', 'Eco 60'), aby ich ostatní mohli identifikovať", - "share_guideline_quality": "Zdieľajte iba profily s ⭐ referenčnými cyklami alebo aspoň {n} potvrdenými spusteniami", + "share_guideline_quality": "Zdieľajte iba cykly, ktoré prebehli normálne: bez prerušení uprostred cyklu, otvorenia dvierok alebo krátkych výpadkov napájania.", "share_guideline_review": "Pred zdieľaním skontrolujte svoje profily -- odstráňte tie, ktoré vyzerajú zle", "share_guidelines_title": "Pred zdieľaním", "store_download_device_intro": "Stiahnuť nastavenie zariadenia komunity a použiť ho na nové alebo existujúce zariadenie", - "store_share_device_intro": "Zdieľať programy vášho zariadenia (profily + referenčné cykly) s komunitou. Nastavenia sú voliteľné.", - "share_profile_no_cycles": "Profil '{p}' nemá žiadne ⭐ referenčné cykly -- bude preskočený, ak nemáte {n}+ potvrdených spustení", + "store_share_device_intro": "Nahrajte {brand} {model} s referenčnými cyklami, ktoré vyberiete. Ostatní s rovnakým spotrebičom môžu vaše programy prevziať. Záznamy sú pred zverejnením skontrolované.", + "share_profile_no_cycles": "Žiadne referenčné cykly. Ak chcete zahrnúť tento profil, označte cyklus ako ⭐ na karte Cykly", "advisory_phase_inconsistent": "Zdá sa, že '{name}' mieša rôzne programy alebo teploty - jeho cykly sa ohrievajú veľmi rôzne dlho. Rozdelenie na samostatné profily (napr. podľa teploty) zlepší zhodu aj odhady času.", "advisory_phase_inconsistent_title": "⚠ Možno zmiešané programy", "export_select_intro": "Zaškrtnite presne to, čo sa má zahrnúť. Výber profilov bez ich cyklov aj tak exportuje rozpoznateľný program (jeho naučená krivka sa prenesie spolu s ním).", @@ -1006,7 +1041,29 @@ "merge_hint": "Importované položky sa pridajú; nič miestne sa nestratí. Konflikty názvov sa riešia nižšie.", "replace_warn": "Každá zaškrtnutá kategória sa vymaže a nahradí údajmi zo súboru. Nezaškrtnuté kategórie zostanú bez zmeny.", "dest_reference_hint": "Importované cykly len zlepšujú rozpoznávanie programov a nikdy neovplyvňujú štatistiky spotreby/energie.", - "dest_real_history_hint": "Importované cykly sa počítajú ako vlastná história tohto zariadenia a prispievajú do štatistík energie/spotreby. Použite pri presune jedného spotrebiča do novej inštalácie." + "dest_real_history_hint": "Importované cykly sa počítajú ako vlastná história tohto zariadenia a prispievajú do štatistík energie/spotreby. Použite pri presune jedného spotrebiča do novej inštalácie.", + "import_history_description": "Mali ste inteligentnú zásuvku už pred WashData? Nahrajte export histórie jej senzora výkonu alebo ju načítajte priamo z Home Assistant a prebehne nad ňou bežná detekcia, takže sa staršie cykly objavia v zozname Cykly pripravené na pomenovanie.", + "hist_input_hint": "Nahrajte CSV stiahnuté z panela História (entita, stav, posledná zmena), alebo nechajte WashData načítať históriu senzora priamo. Detekcia potom prebehne presne tak ako naživo a vy vyberiete, ktoré z nájdených cyklov si ponecháte.", + "hist_recorder_hint": "Načíta údaje od zvoleného dátumu až doteraz. Home Assistant štandardne uchováva podrobnú históriu 10 dní a potom už len hodinové priemery, ktoré sú na detekciu cyklov príliš hrubé - skorší dátum voľte len vtedy, ak máte recorder nastavený na dlhšie uchovávanie.", + "hist_scanning": "Prehrávame vašu históriu cez detektor. Beží to na pozadí - tento dialóg môžete zavrieť a vrátiť sa k nemu neskôr.", + "hist_imported_count": "Importované cykly: {n}.", + "hist_duplicates": "Už predtým importované a preskočené: {n}.", + "hist_capped": "Bol dosiahnutý limit importovaných cyklov na zariadenie; zvyšok sa neuložil.", + "hist_next_step": "Nájdete ich v zozname Cykly, označené ako importovaná história. Otvorte niektorý z nich a tlačidlom Označiť pomenujte program, ku ktorému patrí.", + "hist_rows_read": "Prečítané merania: {n}", + "hist_breaks": "Medzery, keď bol senzor nedostupný: {n}", + "hist_other_entity": "Ignorované merania iných entít: {n}", + "hist_entity_substituted": "Načítané {used} (toto zariadenie má nastavené {wanted})", + "hist_skipped_spans": "Preskočené úseky", + "hist_settings_used": "Detekované s aktuálnymi nastaveniami tohto zariadenia (minimálny výkon {w} W, oneskorenie vypnutia {s} s).", + "hist_none_found": "V tejto histórii sa nepodarilo detekovať žiadne cykly.", + "hist_found": "Nájdené cykly: {n}. Odškrtnite všetko, čo nevyzerá ako skutočný beh - kým neurobíte import, nič sa neuloží.", + "hist_scan_capped": "Zobrazujú sa len prví kandidáti (nájdených bolo {n}).", + "hist_recorder_empty": "Home Assistant nemá pre tento senzor v danom období žiadnu podrobnú históriu.", + "hist_scan_failed": "Vyhľadávanie sa nepodarilo.", + "hist_scan_expired": "Toto vyhľadávanie už nie je dostupné. Spustite ho prosím znova.", + "hist_import_failed": "Import sa nepodaril.", + "imported_history_readonly": "Detekované v importovanej histórii výkonu. Ovplyvňuje priraďovanie programov, ale nezapočítava sa do vašich štatistík a nedá sa orezať ani rozdeliť. Označením mu priraďte program." }, "phase_desc": { "anti_crease": "Príležitostné krátke vírenia po dokončení na predchádzanie krčeniu oblečenia.", @@ -1392,6 +1449,10 @@ "doc": "Uložte úplnú stopu výkonu a zodpovedajúce údaje ladenia pre každý cyklus. Užitočné pri riešení problémov, ale zvyšuje veľkosť úložiska.", "label": "Uložiť ladiace záznamy" }, + "smart_termination_duration_ratio": { + "doc": "Ako ďaleko v očakávanej dobe trvania priradeného programu musí byť cyklus, než ho Inteligentné ukončenie môže po poklese príkonu ukončiť skôr. Očakávaná doba trvania je priemer programu, takže pri spotrebičoch, ktorých doba behu veľmi kolíše - práčky so studenou prívodnou vodou v zime oproti teplej v lete, sušičky so senzorovým sušením, programy závislé od náplne - skončí približne polovica všetkých behov skôr než tento priemer a nikdy sa rýchleho ukončenia nedočká, takže skončí až o niekoľko minút neskôr cez záložný časový limit. Pri takýchto strojoch túto hodnotu znížte (napr. 0,85), aby sa skoršie ukončenie napriek tomu spustilo; pre opatrnejšie správanie ju zvýšte smerom k 1,0. Ponechajte prázdne pre predvolenú hodnotu (0,98, pri umývačkách riadu 0,99). Cyklus môže vždy len ukončiť skôr, nikdy neskôr, a nikdy sa nespustí pri nejednoznačnej zhode alebo zhode s nízkou spoľahlivosťou.", + "label": "Pomer inteligentného ukončenia" + }, "smoothing_window": { "doc": "Ako veľmi je vyhladený signál surového výkonu. Nízka (2) reaguje, ale je hlučná; vysoká (5) vyhladzuje hroty, ale pridáva oneskorenie.", "label": "Okno vyhladzovania" @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Čakanie na otvorenie dverí pre ukončenie", "doc": "Ako dlho musia dvere zostať otvorené, kým WashData ukončí cyklus, keď je zapnutá funkcia \"Dvere sa na konci automaticky otvoria\". Dostatočne dlho na ignorovanie rýchleho pridania riadu (predvolene 60 s), dostatočne krátko pre rýchle ukončenie po otvorení dverí spotrebičom." + }, + "profile_evidence_sources": { + "label": "Cykly tvoriace program", + "doc": "Ktoré cykly sa používajú na zostavenie krivky výkonu každého programu a na priradenie dokončeného cyklu k nej. Keď niektorý druh odškrtnete, prestane ovplyvňovať vaše programy, ale nič sa nezmaže - cykly zostanú v zozname Cykly a stále ich možno označiť alebo odstrániť. Užitočné, ak nedôverujete importovaným údajom. Štatistiky to neovplyvní: vždy počítajú len cykly, ktoré tento spotrebič skutočne vykonal. Odškrtnutie všetkých možností sa ignoruje, pretože program bez cyklov by nikdy nemohol byť priradený." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Základná konfigurácia" + }, + "profile_evidence": { + "label": "Podklady profilu" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Orezávanie…", "splitting": "Rozdeľovanie…", "deleting": "Odstraňovanie…", - "imported": "Importované" + "imported": "Importované", + "preparing": "Príprava…" }, "suggestion": { "both_agree": "WashData odporúča", @@ -1725,7 +1794,7 @@ "thr_batch": "Ponechané tesne nad najnižším činným výkonom p05 naprieč {cycles} cyklami ({p05}W), aby sa štart zachytil čo najskôr a prah zastavenia zostal pod najnižším prevádzkovým výkonom stroja.", "tol_per_profile": "p75 rozptylu dĺžky podľa profilu naprieč {profiles} profilmi ({cycles} cyklov); konzistentné profily nie sú penalizované.", "tol_pooled": "Na základe zlúčeného rozptylu dĺžky {cycles} nedávnych označených cyklov (odchýlka p95={dev}).", - "watchdog": "Ponechané čo najnižšie pri zachovaní bezpečnosti (tesne nad intervalom aktualizácie p95 vo výške {p95}s, min. 30s), aby sa zaseknutia rýchlo odhalili bez falošných zastavení." + "watchdog": "Ponechané čo najnižšie pri zachovaní bezpečnosti (tesne nad intervalom aktualizácie p95 vo výške {p95}s a aspoň 2x nad intervalom vzorkovania {median}s, min. 30s), aby sa zaseknutia rýchlo odhalili bez falešných zastavení." }, "exclusions": { "summary": "Vylúčených {total} chybne zistených cyklov: {parts}.", @@ -1772,6 +1841,7 @@ "wrong_profile": "Nesprávny profil" }, "toast": { + "catalog_refreshed": "Katalóg komunity obnovený", "access_saved": "Kontrola prístupu uložená", "all_wiped": "Všetky dáta vymazané", "analysis_complete_none": "Analýza dokončená: žiadne nové návrhy", @@ -1865,19 +1935,21 @@ "rating_saved": "Hodnotenie kvality uložené", "brand_added": "Značka pridaná, čaká na schválenie", "profile_added": "Profil pridaný, čaká na schválenie", - "saved_except_conflicts": "Nastavenia uložené -- {n} nastavenie{s} preskočené kvôli konfliktom", + "saved_except_conflicts": "Uložené. Opravte zvýraznené konflikty, aby sa uložil zvyšok.", "share_device_none_sel": "Vyberte aspoň jeden program na zdieľanie", - "store_device_downloaded": "Nastavenie zariadenia stiahnuté: {created} profil{c} vytvorený, {dup} už existovalo", - "store_device_downloaded_phases": "Nastavenie zariadenia stiahnuté: {created} profil{c} vytvorený, {dup} už existovalo, mapa fáz použitá", - "store_device_downloaded_settings": "Nastavenie zariadenia stiahnuté: {created} profil{c} vytvorený, {dup} už existovalo, nastavenia použité", - "store_device_shared": "Nastavenie zariadenia zdieľané: {n} program{s} nahraný", - "store_device_shared_all_dup": "Nič nové na zdieľanie -- všetky programy v obchode už existujú", - "store_device_shared_partial": "Čiastočné zdieľanie: {n} program{s} nahraný, {failed} preskočené", - "store_device_shared_some_dup": "Nastavenie zariadenia zdieľané: {n} program{s} nahraný ({dup} už existovalo)", + "store_device_downloaded": "Pridaných programov: {p}, nahrávok: {c}", + "store_device_downloaded_phases": "Pridaných programov: {p}, nahrávok: {c}, máp fáz: {ph}", + "store_device_downloaded_settings": "Pridaných programov: {p}, nahrávok: {c}, máp fáz: {ph}, nastavení: {s}", + "store_device_shared": "Zdieľané do komunitného obchodu (cyklov: {n}), čaká na kontrolu.", + "store_device_shared_all_dup": "Všetky vybrané cykly ({n}) už v komunitnom obchode sú.", + "store_device_shared_partial": "Zdieľaných cyklov: {n}; {failed} sa nepodarilo nahrať.", + "store_device_shared_some_dup": "Zdieľaných cyklov: {created}; už v obchode: {dup}.", "store_download_failed": "Sťahovanie zlyhalo: {error}", "store_download_nothing": "Nič na stiahnutie -- všetky profily na tomto zariadení už existujú", "export_selective_done": "Export stiahnutý", - "import_selective_done": "Importovaných {profiles} profilov a {cycles} cyklov" + "import_selective_done": "Importovaných {profiles} profilov a {cycles} cyklov", + "hist_csv_required": "Najprv načítajte súbor CSV alebo prilepte jeho obsah", + "file_read_failed": "Tento súbor sa nepodarilo prečítať" }, "trend": { "down": "Klesajúci trend", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Umývačka: tiché sekundy po očakávanom trvaní, než sa uvoľní čakanie na záverečné odčerpanie vody", + "smart_termination_duration_ratio": "Zlomok očakávanej doby trvania priradeného programu, ktorý musí cyklus dosiahnuť, než ho Inteligentné ukončenie môže ukončiť skôr; znížte ho pri strojoch závislých od náplne alebo teploty", "anti_wrinkle_enabled": "Pohltiť impulzy bubna po hlavnej fáze namiesto ich čítania ako nové cykly", "anti_wrinkle_exit_power": "Výkon musí medzi impulzmi klesnúť pod túto hodnotu, aby ochrana pred krčením zostala aktívna", "anti_wrinkle_idle_timeout": "Povolený pokoj medzi dvoma impulzmi bubna, než sa ochrana pred krčením skončí", @@ -1955,6 +2028,10 @@ "finished": "Cyklus dosiahol koncový stav a skončil." }, "store": { + "your_model_tip": "Toto je spotrebič, ktorý ste zadali v Nastaveniach", + "your_model": "Vaše", + "search_brand_ph": "Hľadať podľa značky…", + "programs_count": "Programy: {n}", "browse": "Prehliadať", "device": "Zariadenie", "favorites": "Obľúbené", diff --git a/custom_components/ha_washdata/translations/panel/sl.json b/custom_components/ha_washdata/translations/panel/sl.json index 39b64d7c..800b84eb 100644 --- a/custom_components/ha_washdata/translations/panel/sl.json +++ b/custom_components/ha_washdata/translations/panel/sl.json @@ -78,9 +78,12 @@ "awaiting": "Čaka na odobritev", "imported_tip": "Uvoženo iz skupnostne trgovine. Uporablja se samo za ujemanje, ne šteje se v statistiko.", "not_importable": "ni na voljo", - "exists": "obstaja" + "exists": "obstaja", + "backfilled_tip": "Zaznano v uvoženi zgodovini moči. Vpliva samo na ujemanje programov, ne šteje se v statistiko." }, "btn": { + "set_brand_model": "Nastavi znamko in model", + "refresh_catalog": "Osveži katalog", "add_device": "+ Dodaj napravo", "add_device_tip": "Dodajte drugo napravo WashData", "add_maintenance": "Dodaj dogodek vzdrževanja", @@ -234,14 +237,19 @@ "download_device": "Prenesi nastavitve naprave", "share_device": "Deli nastavitve naprave", "share_device_tip": "Deli programe in nastavitve te naprave s skupnostjo", - "share_n": "Deli {n} program{s}", + "share_n": "Deli cikle ({n})", "export_selected": "Izvoz (izberi podatke)", "export_all": "Hitri izvoz vsega", "import_raw": "Napredno: zamenjaj vse iz datoteke JSON", "download_export": "Prenesi izvoz", "analyze_import": "Analiziraj datoteko", "import_selected": "Uvozi izbrano", - "back": "Nazaj" + "back": "Nazaj", + "import_power_history": "Uvozi zgodovino moči", + "hist_read_recorder": "Preberi iz Home Assistant", + "hist_scan": "Poišči cikle", + "hist_import_n": "Uvozi cikle: {n}", + "hist_goto_cycles": "Pokaži cikle" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Mora biti pod Maks. močjo zaščite pred gubami ({max} W)" }, "attn_sub": "Pred shranjevanjem odpravite konflikte", - "attn_title": "{n} konflikt{s} nastavitev", + "attn_title": "Konflikti nastavitev: {n}", "confidence": { "auto": "Mora biti enak ali nad Pragom ujemanja ({match})", - "learning": "Mora biti enak ali pod Pragom ujemanja ({match})", + "learning": "Mora biti enak ali nad Pragom ujemanja ({match})", "match_for_auto": "Mora biti enak ali pod Zaupanjem samodejnega označevanja ({alc})", - "match_for_learning": "Mora biti enak ali nad Zaupanjem učenja ({lc})" + "match_for_learning": "Mora biti enak ali pod Zaupanjem učenja ({lc})" }, "duration_ratio": { "max": "Mora biti večje od Min. razmerja trajanja ({min})", @@ -296,14 +304,14 @@ "match": "Mora biti nad Pragom neujemanja ({un})", "unmatch": "Mora biti pod Pragom ujemanja ({match}); sicer se potrjeno ujemanje takoj razveljavi" }, - "cascade_toast": "Samodejno je bilo prilagojenih {n} nastavitev za doslednost.", + "cascade_toast": "Druge nastavitve, prilagojene za doslednost: {n}", "suggestion_resolves": "Uveljavite čakajoči predlog ({val}) spodaj za odpravo tega konflikta", "use_fix": "Uporabi {val}", "watchdog": { "interval": "Mora biti vsaj 2× Interval vzorčenja ({si} s)", "sampling": "Interval vzorčenja mora biti največ polovica Intervala nadzornika ({wi} s)" }, - "settings_banner": "{n} konflikt{s} nastavitev – preverite označene razdelke in popravite pred shranjevanjem.", + "settings_banner": "Konflikti nastavitev: {n}. Preverite označene razdelke in jih popravite pred shranjevanjem.", "settings_banner_btn": "Pojdi na prvega" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Izid simulacije", "pg_across_cycles": "Po vseh vaših ciklih", "community_store": "Skupnostna trgovina", - "online_account": "Skupnostna trgovina in spletne funkcije" + "online_account": "Skupnostna trgovina in spletne funkcije", + "import_power_history": "Uvoz zgodovine moči" }, "health": { "fair": "Sprejemljiva kakovost profila", @@ -364,6 +373,7 @@ "poor": "⚠ Slaba kakovost profila" }, "lbl": { + "drag_to_resize": "Povlecite za spremembo velikosti", "actions": "Dejanja", "activity": "Aktivnost", "administrators": "Skrbniki", @@ -451,7 +461,7 @@ "metric": "Meritev", "mode_existing_profile": "Dodaj k obstoječemu profilu", "mode_new_profile": "Ustvari nov profil", - "models_fine_tuned": "({count} modelov prilagojenih)", + "models_fine_tuned": "(natančno prilagojeni modeli: {count})", "n_classic_suggestions": "{n} klasično", "n_ml_suggestions": "{n} ML", "n_selected": "{n} izbranih", @@ -677,7 +687,26 @@ "conflict_resolution": "Spori imen", "conflict_import_copy": "Uvozi kot kopijo", "conflict_keep_mine": "Obdrži moje", - "conflict_overwrite": "Prepiši" + "conflict_overwrite": "Prepiši", + "hist_csv_data": "Podatki CSV", + "hist_from_recorder": "Ali jo preberite iz Home Assistant", + "days": "dni", + "hist_keep": "Ohrani ta cikel", + "hist_looks_complete": "zaključen", + "peak_power_short": "Vrh", + "shape": "Oblika", + "hist_skip_idle": "nič ni delovalo", + "hist_skip_sparse": "odčitki preveč oddaljeni med seboj", + "hist_skip_short": "premalo odčitkov", + "hist_skip_long": "ni dovolj dolgega premora za razdelitev", + "hist_reason_short": "krajši od najkrajšega resničnega cikla te naprave", + "hist_reason_no_end": "se ni nikoli pravilno končal", + "hist_since": "Od", + "task_history_import": "Pregledovanje zgodovine moči", + "task_history_import_apply": "Uvažanje ciklov", + "evidence_real_cycles": "Cikli, ki jih je izvedla ta naprava", + "evidence_reference_cycles": "Preneseni iz skupnostne trgovine", + "evidence_backfill_cycles": "Najdeni v uvoženi zgodovini moči" }, "log": { "all_levels": "Vse ravni", @@ -756,9 +785,15 @@ "store_share": "Deli v skupnostno trgovino", "store_share_device": "Deli nastavitve naprave", "export_select": "Izvoz - izberi podatke", - "import_wizard": "Uvoz - izberi podatke" + "import_wizard": "Uvoz - izberi podatke", + "history_import": "Uvoz zgodovine moči" }, "msg": { + "tail_trim_hint": "Odstranite toliko sekund s konca", + "store_sibling_hint": "Za vaš točni model ni nič deljenega? Tesno soroden model iste znamke je običajno dobro izhodišče.", + "store_declare_appliance": "Povejte integraciji WashData, katero napravo imate, in ta zavihek bo prikazal konfiguracije, ki so jih zanjo delili drugi. Zgoraj lahko tudi vpišete znamko in pregledate katalog.", + "refresh_catalog_hint": "Seznama znamk in naprav skupnosti sta shranjena v predpomnilniku, da skupnostna trgovina ostane v okviru svoje dnevne omejitve. Osvežite, da prevzamete vnose, ki so jih dodali ali odobrili drugi.", + "head_trim_hint": "Odstranite toliko sekund z začetka", "appliance_monitor": "Monitor naprave", "artifact_dip_detail": "Padel pod normalni pas moči za ~{n} s.", "artifact_footer": "Označeno na zgornjem grafu. To so prehodni artefakti (npr. vrata so se odprla sredi cikla), ne pa nujno težave.", @@ -769,7 +804,7 @@ "automations_intro": "WashData sproži dogodke {start} / {end} in razkrije entitete, zato je obvestila in dejanja najbolje graditi kot navadne avtomatizacije Home Assistant. Spodaj so prikazane avtomatizacije, ki uporabljajo to napravo.", "cleanup_intro": "Vsi označeni cikli naloženi. Označi odmike in briši za čiščenje profila.", "clear_debug_hint": "Odstranite shranjene podatke za odpravljanje napak, da sprostite prostor.", - "collecting_data": "Zbiranje podatkov: še {need} ciklov pred začetkom finega nastavljanja ({current}/{min}).", + "collecting_data": "Zbiranje podatkov. Še potrebnih ciklov pred začetkom natančnega prilagajanja: {need} ({current}/{min}).", "compare_overlay_profiles": "Prekriti profili (bledi)", "compare_profiles_tip": "Prekrijte druge ovojnice profila na zgornji tabeli, da vidite, katera najbolj ustreza temu ciklu.", "compare_selected_cycles": "Izbrani cikli (polni) – prikaži / skrij", @@ -779,7 +814,7 @@ "cycles_deleted": "Izbrisanih ciklov: {count}", "enough_data": "Dovolj podatkov za učenje ({current}/{min} ciklov).", "export_description": "Izberite natančno, katere profile, cikle, nastavitve in drugo želite izvoziti v datoteko JSON, ali analizirajte datoteko in uvozite samo želene dele.", - "feedback_cycles_pending": "{n} ciklov za pregled", + "feedback_cycles_pending": "Za pregled: {n}", "feedback_prompt": "Potrdite pravilnost, popravite program ali prezrite.", "feedback_relabel_hint": "Ponovno označevanje tega cikla ga prav tako razreši.", "filter_by_profile": "Filtriraj po profilu…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "simulacija mirovanja se začne tukaj", "pg_sweep_intro": "Kaj če bi bil {param} drugačen? Preizkusite {steps} vrednosti na svojih zadnjih {cycles} ciklih in poiščite nastavitev, pri kateri se pravilno ujema največ ciklov.", "pg_sweep_step": "Korak {done} / {total}", - "pg_undetected": "{n} nezaznanih ciklov", "pg_verdict_bad": "Zahteva pozornost: veliko ciklov ostaja nezaznanih.", "pg_verdict_good": "Dobro nastavljeno: večina ciklov je pravilno prepoznanih in ujemanih.", "pg_verdict_ok": "Sprejemljivo: nekateri cikli so bili spregledani. Poskusite znižati prag zagona.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Označite to kot ročno izbran referenčni cikel za svoj program – enaka vloga kot ročno posnet cikel. Referenčni cikli so vedno shranjeni, zasejejo ujemajočo se predlogo in se nikoli ne izbrišejo s čiščenjem. (To je zlata/posneta zastava; obe sta enaki.)", "review_tags_tip": "Neobvezne zastavice, ki opisujejo, kaj je šlo narobe s tem ciklom, tako da lahko to pojasnita usposabljanje in čiščenje.", "review_to_cycles": "Odpri čakalno vrsto pregleda ciklov", + "samples_decimated": "Prikazanih {shown} od {total} vzorcev (razredčeno za prikaz; konice ohranjene). Široka vrzel tukaj pomeni razredčitev, ne manjkajočih podatkov.", "saving_triggers_reload": "Shranjevanje sproži ponovni zagon integracije. Entitete HA se lahko za kratek čas prikažejo kot nedostopne.", "search_placeholder": "Iskanje nastavitev…", "see_recorder": "Glejte pripomoček za snemanje spodaj", @@ -991,12 +1026,12 @@ "share_consent": "Delite dejanske podatke svojega aparata. Ne delite, če so vaši vzorci uporabe zasebni.", "share_device_none": "Še ni nastavljenih naprav. Najprej dodajte napravo.", "share_guideline_naming": "Uporabite jasna imena programov (npr. 'Cotton 40', 'Eco 60'), da jih drugi lažje prepoznajo", - "share_guideline_quality": "Delite samo profile z ⭐ referenčnimi cikli ali vsaj {n} potrjenimi zagoni", + "share_guideline_quality": "Delite samo cikle, ki so se normalno zaključili -- brez prekinitev sredi cikla, odpiranja vrat ali nihanj napajanja.", "share_guideline_review": "Preglejte profile pred deljenjem -- odstranite tiste, ki izgledajo napačni", "share_guidelines_title": "Pred deljenjem", "store_download_device_intro": "Prenesi nastavitve skupnostne naprave in jih uporabi za novo ali obstoječo napravo", - "store_share_device_intro": "Deli programe svoje naprave (profile + referenčne cikle) s skupnostjo. Nastavitve so neobvezne.", - "share_profile_no_cycles": "Profil '{p}' nima ⭐ referenčnih ciklov -- preskočen bo, razen če imate {n}+ potrjenih zagonov", + "store_share_device_intro": "Naložite {brand} {model} z referenčnimi cikli, ki jih izberete. Drugi z enako napravo lahko prevzamejo vaše programe. Vnosi so pregledani pred javno objavo.", + "share_profile_no_cycles": "Ni referenčnih ciklov - označite cikel z ⭐ v zavihku Cikli, da vključite ta profil", "advisory_phase_inconsistent": "Videti je, da '{name}' meša različne programe ali temperature - njegovi cikli se segrevajo zelo različno dolgo. Če ga razdelite na ločene profile (npr. po temperaturi), boste izboljšali ujemanje in ocene časa.", "advisory_phase_inconsistent_title": "⚠ Morda pomešani programi", "export_select_intro": "Označite natančno, kaj naj bo vključeno. Izbira profilov brez njihovih ciklov še vedno izvozi prepoznaven program (njegova naučena oblika se prenese z njim).", @@ -1006,7 +1041,29 @@ "merge_hint": "Uvoženi elementi se dodajo; nič lokalnega se ne izgubi. Spori imen se rešujejo spodaj.", "replace_warn": "Vsaka označena kategorija se izbriše in zamenja s podatki iz datoteke. Neoznačene kategorije ostanejo nespremenjene.", "dest_reference_hint": "Uvoženi cikli le izboljšajo prepoznavanje programov in nikoli ne vplivajo na statistiko porabe/energije.", - "dest_real_history_hint": "Uvoženi cikli se štejejo kot lastna zgodovina te naprave in napajajo statistiko energije/porabe. Uporabite pri prenosu ene naprave na novo namestitev." + "dest_real_history_hint": "Uvoženi cikli se štejejo kot lastna zgodovina te naprave in napajajo statistiko energije/porabe. Uporabite pri prenosu ene naprave na novo namestitev.", + "import_history_description": "Ste pametni vtič uporabljali že pred WashData? Naložite izvoz zgodovine njegovega senzorja moči ali jo preberite neposredno iz Home Assistant in nad njo se izvede običajno zaznavanje, tako da se pretekli cikli pojavijo v vašem seznamu Cikli, pripravljeni za poimenovanje.", + "hist_input_hint": "Naložite CSV, prenesen iz plošče Zgodovina (entiteta, stanje, zadnja sprememba), ali pustite, da WashData prebere zgodovino senzorja neposredno. Zaznavanje nato poteka povsem enako kot v živo, vi pa izberete, katere najdene cikle boste ohranili.", + "hist_recorder_hint": "Bere podatke od izbranega datuma do zdaj. Home Assistant privzeto hrani podrobno zgodovino 10 dni, po tem pa le urna povprečja, ki so za zaznavanje ciklov preveč groba - zgodnejši datum izberite samo, če je vaš recorder nastavljen na daljše hranjenje.", + "hist_scanning": "Vašo zgodovino predvajamo skozi zaznavalnik. To poteka v ozadju - to pogovorno okno lahko zaprete in se vrnete pozneje.", + "hist_imported_count": "Uvoženi cikli: {n}.", + "hist_duplicates": "Že prej uvoženo in preskočeno: {n}.", + "hist_capped": "Dosežena je bila omejitev uvoženih ciklov na napravo; ostali niso bili shranjeni.", + "hist_next_step": "So v vašem seznamu Cikli, označeni kot uvožena zgodovina. Odprite enega in z gumbom Označi poimenujte program, ki mu pripada.", + "hist_rows_read": "Prebrani odčitki: {n}", + "hist_breaks": "Vrzeli, ko senzor ni bil dosegljiv: {n}", + "hist_other_entity": "Prezrti odčitki drugih entitet: {n}", + "hist_entity_substituted": "Prebrano {used} (ta naprava je nastavljena na {wanted})", + "hist_skipped_spans": "Preskočeni odseki", + "hist_settings_used": "Zaznano s trenutnimi nastavitvami te naprave (minimalna moč {w} W, zamuda izklopa {s} s).", + "hist_none_found": "V tej zgodovini ni bilo mogoče zaznati nobenega cikla.", + "hist_found": "Najdeni cikli: {n}. Odstranite kljukico pri vsem, kar ni videti kot resničen zagon - nič ni shranjeno, dokler ne uvozite.", + "hist_scan_capped": "Prikazani so samo prvi kandidati (najdenih je bilo {n}).", + "hist_recorder_empty": "Home Assistant za ta senzor v tem obdobju nima podrobne zgodovine.", + "hist_scan_failed": "Pregledovanje ni uspelo.", + "hist_scan_expired": "Ta pregled ni več na voljo. Zaženite ga znova.", + "hist_import_failed": "Uvoz ni uspel.", + "imported_history_readonly": "Zaznano v uvoženi zgodovini moči. Vpliva na ujemanje programov, ne šteje pa se v vašo statistiko in ga ni mogoče obrezati ali razdeliti. Označite ga, da mu določite program." }, "phase_desc": { "anti_crease": "Občasni kratki premiki po zaključku za preprečevanje gub na oblačilih.", @@ -1377,7 +1434,7 @@ "label": "Zamuda ponastavitve napredka" }, "pump_stuck_duration": { - "doc": "Nekaj ​​sekund lahko črpalka deluje neprekinjeno, preden je označena kot morebitna zastoj (sproži dogodek zastoja črpalke).", + "doc": "Nekaj sekund lahko črpalka deluje neprekinjeno, preden je označena kot morebitna zastoj (sproži dogodek zastoja črpalke).", "label": "Trajanje zataknjenosti črpalke" }, "running_dead_zone": { @@ -1392,6 +1449,10 @@ "doc": "Shranite celotno sled moči in ustrezne podatke o odpravljanju napak za vsak cikel. Uporabno za odpravljanje težav, vendar poveča velikost pomnilnika.", "label": "Shrani sledove za odpravljanje napak" }, + "smart_termination_duration_ratio": { + "doc": "Kako daleč v pričakovanem trajanju ujemajočega programa mora biti cikel, preden ga Pametni zaključek lahko po padcu moči predčasno konča. Pričakovano trajanje je povprečje programa, zato se pri napravah, katerih čas delovanja močno niha - pralni stroji s hladno dovodno vodo pozimi v primerjavi s toplo poleti, sušilni stroji s senzorskim sušenjem, programi, odvisni od količine perila - približno polovica vseh zagonov konča prej kot to povprečje in nikoli ne dobi hitrega zaključka, temveč se konča šele prek nadomestnega časovnega zamika, nekaj minut prepozno. Pri takih strojih to vrednost znižajte (npr. 0,85), da se predčasni zaključek vseeno sproži; za bolj konservativno delovanje jo zvišajte proti 1,0. Pustite prazno za privzeto vrednost (0,98 ali 0,99 za pomivalne stroje). Cikel lahko vedno le predčasno konča, nikoli pozneje, in se nikoli ne sproži pri nejasnem ujemanju ali ujemanju z nizko zanesljivostjo.", + "label": "Razmerje pametnega zaključka" + }, "smoothing_window": { "doc": "Koliko je zglajen signal neobdelane moči. Nizka (2) je odzivna, a hrupna; visoko (5) zgladi konice, vendar doda zamik.", "label": "Okno glajenja" @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Čas odprtosti vrat za zaključek", "doc": "Kako dolgo morajo vrata ostati odprta, preden WashData zaključi cikel, ko je vklopljeno \"Vrata se ob koncu samodejno odprejo\". Dovolj dolgo, da se prezre hitro dodajanje posode (privzeto 60 s), dovolj kratko za hiter zaključek, ko stroj odpre vrata." + }, + "profile_evidence_sources": { + "label": "Cikli, ki oblikujejo program", + "doc": "Kateri cikli se uporabijo za izgradnjo krivulje moči vsakega programa in za ujemanje zaključenega cikla z njo. Če neko vrsto odkljukate, ta ne vpliva več na vaše programe, vendar se nič ne izbriše - cikli ostanejo na seznamu Cikli in jih je še vedno mogoče označiti ali odstraniti. Uporabno, če uvoženim podatkom ne zaupate. Na statistiko to ne vpliva: vedno šteje samo cikle, ki jih je ta naprava res izvedla. Če odkljukate vse, se to prezre, saj se program brez ciklov nikoli ne bi mogel ujemati." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Osnovna konfiguracija" + }, + "profile_evidence": { + "label": "Podlaga profila" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Obrezovanje…", "splitting": "Deljenje…", "deleting": "Brisanje…", - "imported": "Uvoženo" + "imported": "Uvoženo", + "preparing": "Pripravljanje…" }, "suggestion": { "both_agree": "WashData priporoča", @@ -1725,7 +1794,7 @@ "thr_batch": "Ohranjeno tik nad najnižjo delovno močjo p05 čez {cycles} ciklov ({p05}W), da se začetek zazna čim prej in da prag ustavitve ostane pod najnižjo delovno močjo naprave.", "tol_per_profile": "p75 variance trajanja po posameznih profilih čez {profiles} profilov ({cycles} ciklov); dosledni profili niso kaznovani.", "tol_pooled": "Na podlagi združene variance trajanja {cycles} nedavnih označenih ciklov (p95 odklon={dev}).", - "watchdog": "Ohranjeno tako nizko, kot je varno (tik nad vrzeljo posodobitve p95 {p95}s, najmanj 30s), da se zastoji hitro zaznajo brez lažnih ustavitev." + "watchdog": "Ohranjeno tako nizko, kot je varno (tik nad vrzeljo posodobitve p95 {p95}s in vsaj 2× interval vzorčenja {median}s, najmanj 30s), da se zastoji hitro zaznajo brez lažnih ustavitev." }, "exclusions": { "summary": "Izključeno {total} napačno zaznanih ciklov: {parts}.", @@ -1772,6 +1841,7 @@ "wrong_profile": "Napačen profil" }, "toast": { + "catalog_refreshed": "Katalog skupnosti je osvežen", "access_saved": "Nadzor dostopa shranjen", "all_wiped": "Vsi podatki izbrisani", "analysis_complete_none": "Analiza zaključena: ni novih predlogov", @@ -1865,19 +1935,21 @@ "rating_saved": "Ocena kakovosti shranjena", "brand_added": "Znamka dodana, čaka na odobritev", "profile_added": "Profil dodan, čaka na odobritev", - "saved_except_conflicts": "Nastavitve shranjene -- {n} nastavitev{s} preskočena zaradi konfliktov", + "saved_except_conflicts": "Shranjeno. Popravite označene konflikte, da shranite še ostalo.", "share_device_none_sel": "Izberite vsaj en program za deljenje", - "store_device_downloaded": "Nastavitve naprave prenesene: {created} profil{c} ustvarjen, {dup} že obstaja", - "store_device_downloaded_phases": "Nastavitve naprave prenesene: {created} profil{c} ustvarjen, {dup} že obstaja, fazna karta uporabljena", - "store_device_downloaded_settings": "Nastavitve naprave prenesene: {created} profil{c} ustvarjen, {dup} že obstaja, nastavitve uporabljene", - "store_device_shared": "Nastavitve naprave deljene: {n} program{s} naložen", - "store_device_shared_all_dup": "Nič novega za deljenje -- vsi programi že obstajajo v trgovini", - "store_device_shared_partial": "Delno deljenje: {n} program{s} naložen, {failed} preskočenih", - "store_device_shared_some_dup": "Nastavitve naprave deljene: {n} program{s} naložen ({dup} že obstaja)", + "store_device_downloaded": "Dodano: programi {p}, posnetki {c}", + "store_device_downloaded_phases": "Dodano: programi {p}, posnetki {c}, fazne karte {ph}", + "store_device_downloaded_settings": "Dodano: programi {p}, posnetki {c}, fazne karte {ph}, nastavitve {s}", + "store_device_shared": "Cikli, deljeni v skupnostno trgovino: {n}. Čaka na pregled.", + "store_device_shared_all_dup": "Vsi cikli ({n}) so bili že v skupnostni trgovini.", + "store_device_shared_partial": "Deljeni cikli: {n}; ni bilo mogoče naložiti: {failed}.", + "store_device_shared_some_dup": "Deljeni cikli: {created}; že v trgovini: {dup}.", "store_download_failed": "Prenos ni uspel: {error}", "store_download_nothing": "Ni ničesar za prenos -- vsi profili že obstajajo na tej napravi", "export_selective_done": "Izvoz prenesen", - "import_selective_done": "Uvoženih {profiles} profilov in {cycles} ciklov" + "import_selective_done": "Uvoženih {profiles} profilov in {cycles} ciklov", + "hist_csv_required": "Najprej naložite datoteko CSV ali prilepite njeno vsebino", + "file_read_failed": "Te datoteke ni bilo mogoče prebrati" }, "trend": { "down": "Padajoči trend", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Pomivalni stroj: sekunde tišine po pričakovanem trajanju, preden se sprosti čakanje na končno izčrpavanje vode", + "smart_termination_duration_ratio": "Delež pričakovanega trajanja ujemajočega programa, ki ga mora cikel doseči, preden ga Pametni zaključek lahko predčasno konča; znižajte ga pri strojih, odvisnih od količine perila ali temperature", "anti_wrinkle_enabled": "Vsrkaj vrtilne impulze po glavni fazi, namesto da jih obravnavaš kot nove cikle", "anti_wrinkle_exit_power": "Moč mora med impulzi pasti pod to vrednost, da zaščita pred gubami ostane aktivna", "anti_wrinkle_idle_timeout": "Dovoljeni mir med dvema vrtilnima impulzoma, preden se zaščita pred gubami konča", @@ -1955,6 +2028,10 @@ "finished": "Cikel je dosegel končno stanje in se je končal." }, "store": { + "your_model_tip": "To je naprava, ki ste jo navedli v Nastavitvah", + "your_model": "Vaša", + "search_brand_ph": "Iskanje po znamki…", + "programs_count": "Programi: {n}", "browse": "Brskaj", "device": "Naprava", "favorites": "Priljubljeni", diff --git a/custom_components/ha_washdata/translations/panel/sq.json b/custom_components/ha_washdata/translations/panel/sq.json index 404098ee..5c864573 100644 --- a/custom_components/ha_washdata/translations/panel/sq.json +++ b/custom_components/ha_washdata/translations/panel/sq.json @@ -78,9 +78,12 @@ "awaiting": "Në pritje të miratimit", "imported_tip": "Importuar nga dyqani i komunitetit. Përdoret vetëm për përputhje, nuk llogaritet në statistika.", "not_importable": "s'aplikohet këtu", - "exists": "ekziston" + "exists": "ekziston", + "backfilled_tip": "Zbuluar në historikun e importuar të fuqisë. Ndikon vetëm në përputhjen e programeve, nuk llogaritet në statistika." }, "btn": { + "set_brand_model": "Cakto markën dhe modelin", + "refresh_catalog": "Rifresko katalogun", "add_device": "+ Shto pajisje", "add_device_tip": "Shto një pajisje tjetër WashData", "add_maintenance": "Shto ngjarje mirëmbajtjeje", @@ -209,8 +212,8 @@ "stop": "Ndalo", "submit_correction": "Dërgo korrigjimin", "train_now": "Trajno tani", - "trim": "Priti", - "trim_split": "Priti / Ndaj", + "trim": "Prerje", + "trim_split": "Prerje / Ndarje", "undo": "Zhbëj", "use": "Përdor", "wipe_all": "Fshi të gjitha të dhënat", @@ -241,7 +244,12 @@ "import_selected": "Importo të zgjedhurat", "back": "Prapa", "mute_suggestion": "Mos e sugjeroni këtë cilësim", - "reset_muted": "Rivendos të heshturat" + "reset_muted": "Rivendos të heshturat", + "import_power_history": "Importo historikun e fuqisë", + "hist_read_recorder": "Lexo nga Home Assistant", + "hist_scan": "Kërko cikle", + "hist_import_n": "Importo {n} cikle", + "hist_goto_cycles": "Shfaq ciklet" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Duhet të jetë nën Fuqinë Maks. Kundër Rrudhave ({max} W)" }, "attn_sub": "Rregulloni konfliktet para se të ruani", - "attn_title": "{n} konflikt{s} cilësimi", + "attn_title": "Konflikte cilësimesh: {n}", "confidence": { "auto": "Duhet të jetë në ose mbi Pragun e Përputhjes ({match})", - "learning": "Duhet të jetë në ose nën Pragun e Përputhjes ({match})", + "learning": "Duhet të jetë në ose mbi Pragun e Përputhjes ({match})", "match_for_auto": "Duhet të jetë në ose nën Besueshmërinë e Etiketimit Automatik ({alc})", - "match_for_learning": "Duhet të jetë në ose mbi Besueshmërinë e të Mësuarit ({lc})" + "match_for_learning": "Duhet të jetë në ose nën Besueshmërinë e të Mësuarit ({lc})" }, "duration_ratio": { "max": "Duhet të jetë më i madh se Raporti Min. i Kohëzgjatjes ({min})", @@ -296,14 +304,14 @@ "match": "Duhet të jetë mbi Pragun e Mosperputhjeve ({un})", "unmatch": "Duhet të jetë nën Pragun e Përputhjes ({match}); përndryshe një përputhje e konfirmuar zhbëhet menjëherë" }, - "cascade_toast": "U rregulluan edhe {n} cilësim{s} për konsistencë.", + "cascade_toast": "Cilësime të tjera të rregulluara për konsistencë: {n}", "suggestion_resolves": "Zbato sugjerimin e pritshëm ({val}) më poshtë për ta rregulluar këtë", "use_fix": "Përdor {val}", "watchdog": { "interval": "Duhet të jetë të paktën 2× Intervali i Kampionimit ({si} s)", "sampling": "Intervali i Kampionimit duhet të jetë të paktën gjysma e Intervalit të Rojtarit ({wi} s)" }, - "settings_banner": "{n} konflikt{s} cilësimi – kontrolloni seksionet e theksuara dhe korrigjojini para ruajtjes.", + "settings_banner": "Konflikte cilësimesh: {n}. Kontrolloni seksionet e theksuara dhe korrigjojini para ruajtjes.", "settings_banner_btn": "Shko te i pari" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Rezultati i simulimit", "pg_across_cycles": "Në të gjithë ciklet tuaja", "community_store": "Dyqani i Komunitetit", - "online_account": "Dyqani i Komunitetit dhe veçoritë online" + "online_account": "Dyqani i Komunitetit dhe veçoritë online", + "import_power_history": "Importo historikun e fuqisë" }, "health": { "fair": "Cilësi e pranueshme e profilit", @@ -364,6 +373,7 @@ "poor": "⚠ Cilësi e keqe e profilit" }, "lbl": { + "drag_to_resize": "Tërhiqni për të ndryshuar madhësinë", "actions": "Veprime", "activity": "Aktivitet", "administrators": "Administratorë", @@ -434,7 +444,7 @@ "from": "Nga", "gap_s": "Ndërrimi (s)", "group_name": "Emri i grupit", - "head_trim": "Prerja e kreut (s)", + "head_trim": "Prerje në fillim (s)", "health": "Shëndeti", "hide_tabs": "Fshih skedat për jo-administratorët", "in_use": "Në përdorim", @@ -450,7 +460,7 @@ "metric": "Metrika", "mode_existing_profile": "Shto te profili ekzistues", "mode_new_profile": "Krijo profil të ri", - "models_fine_tuned": "({count} model{plural} të sintonizuar)", + "models_fine_tuned": "(modele të rregulluara mirë: {count})", "n_classic_suggestions": "{n} klasike", "n_ml_suggestions": "{n} ML", "n_selected": "{n} të zgjedhura", @@ -532,7 +542,7 @@ "stage3": "Etapa 3 – DTW", "stage4": "Etapa 4 – përputhja", "status": "Statusi", - "tail_trim": "Prerja e fundit (s)", + "tail_trim": "Prerje në fund (s)", "timer_auto_pause": "Auto-pauzë", "timer_min": "min", "timer_msg_placeholder": "Mesazh (opsionale, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_import_copy": "Importo si kopje", "conflict_keep_mine": "Mba të miat", "conflict_overwrite": "Mbishkruaj", - "font_size": "Madhësia e shkronjave të panelit" + "font_size": "Madhësia e shkronjave të panelit", + "hist_csv_data": "Të dhëna CSV", + "hist_from_recorder": "Ose lexoje nga Home Assistant", + "hist_since": "Që nga", + "days": "ditë", + "hist_keep": "Mbaje këtë cikël", + "hist_looks_complete": "i plotë", + "peak_power_short": "Kulmi", + "shape": "Forma", + "hist_skip_idle": "asgjë në punë", + "hist_skip_sparse": "lexime shumë të shpërndara", + "hist_skip_short": "shumë pak lexime", + "hist_skip_long": "nuk ka pushim aq të gjatë sa të ndahet", + "hist_reason_short": "më i shkurtër se cikli real më i shkurtër i kësaj pajisjeje", + "hist_reason_no_end": "nuk përfundoi kurrë si duhet", + "task_history_import": "Skanim i historikut të fuqisë", + "task_history_import_apply": "Importim i cikleve", + "evidence_real_cycles": "Ciklet që kreu kjo makinë", + "evidence_reference_cycles": "Shkarkuar nga dyqani i komunitetit", + "evidence_backfill_cycles": "Gjetur në historikun e importuar të fuqisë" }, "log": { "all_levels": "Të gjitha nivelet", @@ -756,9 +785,15 @@ "store_share": "Ndaj në dyqanin e komunitetit", "store_share_device": "Ndaj këtë pajisje", "export_select": "Eksporto - zgjidh të dhënat", - "import_wizard": "Importo - zgjidh të dhënat" + "import_wizard": "Importo - zgjidh të dhënat", + "history_import": "Importo historikun e fuqisë" }, "msg": { + "tail_trim_hint": "Hiqni kaq sekonda nga fundi", + "store_sibling_hint": "Nuk ka asgjë të ndarë për modelin tuaj të saktë? Një model i afërt i të njëjtës markë është zakonisht një pikënisje e mirë.", + "store_declare_appliance": "Përcaktoni në WashData pajisjen që zotëroni dhe kjo skedë shfaq konfigurimet që të tjerët kanë ndarë për të. Mund edhe të shkruani një markë më lart për t'i hedhur një sy katalogut.", + "refresh_catalog_hint": "Listat e markave dhe pajisjeve të komunitetit mbahen në kujtesën e përkohshme, që dyqani i komunitetit të mbetet brenda kufirit të tij ditor. Rifreskoni për të marrë hyrjet e shtuara ose të miratuara nga të tjerët.", + "head_trim_hint": "Hiqni kaq sekonda nga fillimi", "appliance_monitor": "Monitor i pajisjes", "artifact_dip_detail": "Ra nën brezin e zakonshëm të fuqisë për ~{n} s.", "artifact_footer": "E theksuar në grafikun e mësipërm. Këto janë objekte kalimtare (p.sh. dera e hapur në mes të ciklit), jo domosdoshmërisht probleme.", @@ -769,7 +804,7 @@ "automations_intro": "WashData ndez ngjarje {start} / {end} dhe ekspozon entitete, kështu njoftimet dhe veprimet ndërtohen më mirë si automatizime normale të Home Assistant. Automatizimi që përdorin këtë pajisje shfaqen më poshtë.", "cleanup_intro": "Çdo cikël i etiketuar mbivendosur. Shëno outlier-ët dhe fshi për të pastruar profilin.", "clear_debug_hint": "Hiqni të dhënat e ruajtura të korrigjimit për të liruar hapësirën.", - "collecting_data": "Duke mbledhur të dhëna: nevojiten edhe {need} cikël{plural} para se të fillojë sintonizimi i imët ({current}/{min}).", + "collecting_data": "Duke mbledhur të dhëna. Cikle që nevojiten para se të fillojë rregullimi i hollë: {need} ({current}/{min}).", "compare_overlay_profiles": "Mbivendos profilet (të zbetura)", "compare_profiles_tip": "Mbivendosni zarfat e tjerë të profileve në grafikun e mësipërm për të parë se cili i përshtatet më mirë këtij cikli.", "compare_selected_cycles": "Ciklet e zgjedhura (solide) – shfaq / fshih", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} cikël(e) u fshinë", "enough_data": "Të dhëna të mjaftueshme për të mësuar ({current}/{min} cikle).", "export_description": "Zgjidhni saktësisht cilat profile, cikle, cilësime dhe të tjera do të eksportohen në JSON, ose analizoni një skedar dhe importoni vetëm pjesët që dëshironi.", - "feedback_cycles_pending": "{n} cikël{s} për rishikim", + "feedback_cycles_pending": "Për rishikim: {n}", "feedback_prompt": "Konfirmo nëse ishte i saktë, korrigjo programin ose injorojë.", "feedback_relabel_hint": "Rietiketimi i këtij cikli e zgjidh atë gjithashtu.", "filter_by_profile": "Filtro sipas profilit…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "simulimi i gjendjes statike fillon këtu", "pg_sweep_intro": "Po sikur {param} të ishte ndryshe? Testoni {steps} vlera në {cycles} ciklet tuaja të fundit për të gjetur cilësimin ku përputhen saktë sa më shumë cikle.", "pg_sweep_step": "Hapi {done} / {total}", - "pg_undetected": "{n} cikël{s} të pazbuluar", "pg_verdict_bad": "Kërkon vëmendje: shumë cikle mbeten të pazbuluara.", "pg_verdict_good": "Rregulluar mirë: shumica e cikleve identifikohen dhe përputhen saktë.", "pg_verdict_ok": "I pranueshëm: disa cikle munguan. Provoni të ulni pragun e fillimit.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Shënojeni këtë si një cikël referimi të zgjedhur vetë për programin e tij - i njëjti rol si një cikël i regjistruar manualisht. Ciklet e referencës mbahen gjithmonë, vendosin shabllonin që përputhet dhe nuk hiqen kurrë nga pastrimi. (Ky është flamuri \"i artë\"/regjistruar; të dy janë e njëjta gjë.)", "review_tags_tip": "Flamujt opsionalë që përshkruajnë atë që shkoi keq me këtë cikël, kështu që trajnimi dhe pastrimi mund të jenë përgjegjës për këtë.", "review_to_cycles": "Hap radhën e rishikimit të Cikleve", + "samples_decimated": "Duke shfaqur {shown} nga {total} kampione (të holluara për shfaqje; kulmet janë ruajtur). Një hapësirë e gjerë këtu është hollim, jo të dhëna që mungojnë.", "saving_triggers_reload": "Ruajtja aktivizon ringarkimin e integrimit. Njësitë HA mund të shfaqen shkurtimisht si të padisponueshme.", "search_placeholder": "Kërko cilësime…", "see_recorder": "Shiko veglën e regjistruesit më poshtë", @@ -1006,7 +1041,29 @@ "sug_mute_failed": "Nuk mund të heshtte sugjerimi", "sug_unmuted_all": "Sugjerime të heshtura u rivendosën", "n_suggestions_muted": "{count} të heshtura; motori i sugjerimeve nuk do t'i propozojë këto.", - "font_size_hint": "Bëni gjithçka në këtë panel më të madhe ose më të vogël. Aplikohet për llogarinë tuaj në këtë pajisje." + "font_size_hint": "Bëni gjithçka në këtë panel më të madhe ose më të vogël. Aplikohet për llogarinë tuaj në këtë pajisje.", + "import_history_description": "Kishit priz inteligjent edhe përpara WashData? Ngarkoni një eksport të historikut të sensorit të fuqisë, ose lexojeni drejtpërdrejt nga Home Assistant, dhe zbulimi i zakonshëm kalon mbi të, kështu ciklet e kaluara shfaqen në listën e Cikleve, gati për t'u emërtuar.", + "hist_input_hint": "Ngarkoni një CSV të shkarkuar nga paneli i Historikut (entity, state, last changed), ose lini WashData t'i lexojë drejtpërdrejt historikun e sensorit. Më pas zbulimi kalon mbi të saktësisht si në kohë reale, dhe ju zgjidhni cilat nga ciklet e gjetura do të mbani.", + "hist_recorder_hint": "Lexon nga data që zgjidhni deri tani. Home Assistant mban historik të detajuar për 10 ditë si parazgjedhje dhe vetëm mesatare orare më pas, të cilat janë tepër të përafërta për të zbuluar cikle - zgjidhni një datë më të hershme vetëm nëse recorder është konfiguruar të mbajë më shumë.", + "hist_scanning": "Historiku po riluhet përmes zbuluesit. Kjo ekzekutohet në sfond - mund ta mbyllni këtë dritare dhe të ktheheni më vonë.", + "hist_imported_count": "{n} cikle u importuan.", + "hist_duplicates": "{n} ishin importuar më parë dhe u anashkaluan.", + "hist_capped": "U arrit kufiri i cikleve të importuara për pajisje; pjesa tjetër nuk u ruajt.", + "hist_next_step": "Ndodhen në listën e Cikleve, të shënuara si historik i importuar. Hapni një dhe përdorni Etiketoje për të emërtuar programin të cilit i përket.", + "hist_rows_read": "U lexuan {n} lexime", + "hist_entity_substituted": "u lexua {used} (kjo pajisje është konfiguruar për {wanted})", + "hist_breaks": "{n} boshllëqe ku sensori nuk ishte i disponueshëm", + "hist_other_entity": "{n} lexime për entitete të tjera u shpërfillën", + "hist_skipped_spans": "Intervale të anashkaluara", + "hist_settings_used": "Zbuluar me cilësimet aktuale të kësaj pajisjeje (fuqia minimale {w} W, vonesë fikjeje {s} s).", + "hist_none_found": "Në atë historik nuk u zbulua asnjë cikël.", + "hist_found": "U gjetën {n} cikle. Hiqni shenjën nga çdo gjë që nuk ngjan me një punë reale - asgjë nuk ruhet derisa të importoni.", + "hist_scan_capped": "Shfaqen vetëm kandidatët e parë (u gjetën {n}).", + "hist_recorder_empty": "Home Assistant nuk ka historik të detajuar për këtë sensor në atë periudhë.", + "hist_scan_failed": "Skanimi dështoi.", + "hist_scan_expired": "Ai skanim nuk është më i disponueshëm. Skanoni përsëri.", + "hist_import_failed": "Importimi dështoi.", + "imported_history_readonly": "Zbuluar në historikun e importuar të fuqisë. Ndikon në përputhjen e programeve, por nuk llogaritet në statistikat tuaja dhe nuk mund të shkurtohet ose të ndahet. Etiketojeni për të emërtuar programin." }, "phase_desc": { "anti_crease": "Rrotullime të shkurtra të herëpashershme pas përfundimit për të reduktuar rrudhat.", @@ -1467,6 +1524,10 @@ "show_contributor": { "doc": "Shfaq atribuimin \"nga \" në pajisjet dhe ciklet referuese të komunitetit." }, + "smart_termination_duration_ratio": { + "doc": "Sa larg brenda kohëzgjatjes së pritur të programit të përputhur duhet të ketë ecur një cikël përpara se Përfundimi i Zgjuar të mund ta mbyllë atë herët sapo bie fuqia. Kohëzgjatja e pritur është mesatarja e programit, prandaj te pajisjet kohëzgjatja e të cilave ndryshon shumë - makinat larëse me ujë hyrës të ftohtë në dimër dhe të ngrohtë në verë, tharëset me sensor lagështie, programet që varen nga ngarkesa - rreth gjysma e të gjitha nisjeve përfundojnë më shkurt se ajo mesatare dhe nuk e marrin kurrë përfundimin e shpejtë, duke mbaruar vetëm përmes afatit rezervë me disa minuta vonesë. Uleni këtë vlerë (p.sh. 0.85) te këto makina që përfundimi i hershëm të aktivizohet gjithsesi; ngrijeni drejt 1.0 për një sjellje më konservatore. Lëreni bosh për vlerën e parazgjedhur (0.98, ose 0.99 për pjatalarëset). Ai mund vetëm ta mbyllë një cikël më herët, kurrë më vonë, dhe nuk aktivizohet kurrë në një përputhje të paqartë ose me besim të ulët.", + "label": "Raporti i Përfundimit të Zgjuar" + }, "enable_phase_matching": { "label": "Kohë e mbetur e ndërgjegjshme për fazat", "doc": "Ndan çdo cikël që po funksionon në faza (ngrohje, larje, centrifugim) dhe shpërndan kohën e mbetur për çdo fazë, e përzier me vlerësimin klasik - duke u mbështetur te shpërndarja sipas fazave në fillim të ciklit dhe te vlerësimi klasik afër fundit. Kjo e personalizon numërimin mbrapsht sipas kohës që makina juaj në të vërtetë ngroh dhe punon, gjë që vihet re më së shumti në gjysmën e parë të një cikli. Off = vetëm vlerësimi klasik. Preket vetëm shfaqja e kohës së mbetur; përputhja e programeve dhe zbulimi i ciklit mbeten të pandryshuara." @@ -1522,6 +1583,10 @@ "dishwasher_end_spike_quiet_release": { "label": "Lirim në qetësi gjatë tharjes pasive", "doc": "Sapo cikli të kalojë kohëzgjatjen e tij të pritur, sa kohë duhet të qëndrojë e qetë pjatalarësja (nën Pragun e ndalimit) përpara se WashData të ndalojë pritjen për një shkarkim përfundimtar dhe ta mbyllë ciklin. Rriteni nëse makina juaj ka një fazë të gjatë tharjeje të heshtur përpara një shkarkimi përfundimtar të vonuar që po humbet - një dritare më e gjerë e lejon kohëzgjatjen e mësuar të ndjekë zhvendosjen sezonale (ujë hyrës më i ftohtë = cikle më të gjata) në vend që të bllokohet në mesataren e vjetër. Ai vetëm sa e shkurton pritjen në raport me kufirin e brendshëm 30-minutësh të kulmit të fundit, kurrë nuk e zgjat." + }, + "profile_evidence_sources": { + "label": "Ciklet që formojnë një program", + "doc": "Cilat cikle përdoren për të ndërtuar kurbën e fuqisë së secilit program dhe për të përputhur me të një cikël të përfunduar. Heqja e shenjës nga një lloj e ndalon atë të formojë programet tuaja pa fshirë asgjë - ciklet mbeten në listën Cikle dhe mund të etiketohen ose të hiqen si më parë. E dobishme nëse nuk i besoni të dhënave të importuara. Statistikat nuk preken: ato numërojnë gjithmonë vetëm ciklet që kjo makinë kreu vërtet. Heqja e shenjës nga të gjitha llojet shpërfillet, sepse një program pa cikle pas tij nuk mund të përputhej kurrë." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Konfigurimi bazë" + }, + "profile_evidence": { + "label": "Burimet e profilit" } }, "status": { @@ -1626,10 +1694,11 @@ "analyzing": "Po analizohet…", "resetting": "Po rivendoset…", "reverting": "Po kthehet…", - "trimming": "Po pritet…", + "trimming": "Duke prerë…", "splitting": "Po ndahet…", "deleting": "Po fshihet…", - "imported": "Importuar" + "imported": "Importuar", + "preparing": "Po përgatitet…" }, "tab": { "advanced": "I avancuar", @@ -1659,6 +1728,7 @@ "wrong_profile": "Profil i gabuar" }, "toast": { + "catalog_refreshed": "Katalogu i komunitetit u rifreskua", "access_saved": "Kontrolli i aksesit u ruajt", "all_wiped": "Të gjitha të dhënat u fshien", "analysis_complete_none": "Analiza e plotë: asnjë sugjerim i ri", @@ -1670,7 +1740,7 @@ "cycle_labelled": "Cikli u etiketua", "cycle_paused": "Cikli u ndal", "cycle_resumed": "Cikli u vazhdua", - "cycle_trimmed": "Cikli u prit", + "cycle_trimmed": "Cikli u pre", "cycles_merged": "Ciklet u bashkuan", "envelope_rebuilt": "Zarfi u rindërtua", "envelopes_rebuilt": "Zarfet u rindërtuan", @@ -1764,7 +1834,9 @@ "store_download_failed": "Shkarkimi dështoi: {error}", "store_download_nothing": "Asgjë e re për të shkarkuar - ky konfigurim është tashmë në pajisjen tuaj.", "export_selective_done": "Eksporti u shkarkua", - "import_selective_done": "U importuan {profiles} profil(e) dhe {cycles} cikël(cikle)" + "import_selective_done": "U importuan {profiles} profil(e) dhe {cycles} cikël(cikle)", + "hist_csv_required": "Ngarkoni së pari një skedar CSV ose ngjitni përmbajtjen e tij", + "file_read_failed": "Skedari nuk mund të lexohej" }, "suggestion": { "both_agree": "WashData rekomandon", @@ -1860,7 +1932,7 @@ "thr_batch": "Mbahet pak mbi fuqinë aktive më të ulët p05 në {cycles} cikle ({p05}W) që një nisje të kapet sa më herët të jetë e mundur dhe pragu i ndalimit të mbetet nën fuqinë më të ulët të punës së makinës.", "tol_per_profile": "p75 e variancës së kohëzgjatjes për profil në {profiles} profile ({cycles} cikle); profilet e qëndrueshme nuk penalizohen.", "tol_pooled": "Bazuar në variancën e bashkuar të kohëzgjatjes së {cycles} cikleve të fundit të etiketuara (devijimi p95={dev}).", - "watchdog": "Mbahet sa më i ulët në mënyrë të sigurt (pak mbi boshllëkun e përditësimit p95 prej {p95}s, min. 30s) që bllokimet të kapen shpejt pa ndalime të rreme." + "watchdog": "Mbahet sa më i ulët në mënyrë të sigurt (pak mbi boshllëkun e përditësimit p95 prej {p95}s dhe të paktën 2× intervalin e kampionimit prej {median}s, min. 30s) që bllokimet të kapen shpejt pa ndalime të rreme." }, "exclusions": { "summary": "U përjashtuan {total} cikle të zbuluar gabimisht: {parts}.", @@ -1917,7 +1989,8 @@ "dtw_refine_top_n": "Etapa 3: kandidatë të rishënuara nga DTW; rriteni në 7-9 nëse profili i saktë renditet i 4-5 (parazgjedhja 5)", "duration_scale": "Etapa 4: raporti logaritmik ku marrëveshja e kohëzgjatjes gjysmohet; më i vogël = ndëshkim më i rreptë (parazgjedhja 0.175)", "energy_scale": "Etapa 4: raporti logaritmik ku marrëveshja e energjisë gjysmohet; më i vogël = ndëshkim më i rreptë (parazgjedhja 0.25)", - "dishwasher_end_spike_quiet_release": "Pjatalarëse: sekonda qetësie pas kohëzgjatjes së pritur para se të lirohet pritja e shkarkimit në fund të ciklit" + "dishwasher_end_spike_quiet_release": "Pjatalarëse: sekonda qetësie pas kohëzgjatjes së pritur para se të lirohet pritja e shkarkimit në fund të ciklit", + "smart_termination_duration_ratio": "Pjesa e kohëzgjatjes së pritur të programit të përputhur që një cikël duhet të arrijë përpara se Përfundimi i Zgjuar të mund ta mbyllë atë herët; uleni për makina që varen nga ngarkesa ose temperatura" }, "col": { "profile_tip": "Emri i programit të përputhur. I paetiketuar do të thotë se asnjë profil nuk u përputh në fund të ciklit.", @@ -1955,6 +2028,10 @@ "finished": "Cikli arriti gjendjen përfundimtare dhe përfundoi." }, "store": { + "your_model_tip": "Kjo është pajisja që deklaruat te Cilësimet", + "your_model": "E juaja", + "search_brand_ph": "Kërko sipas markës…", + "programs_count": "Programe: {n}", "browse": "Shfleto", "device": "Pajisja", "favorites": "Të preferuarat", @@ -1993,6 +2070,7 @@ "add_profile": "Shtoni një profil për këtë pajisje në sitin e komunitetit" }, "task": { + "cancelling": "Po anulohet...", "reprocess": { "matching": "Ripërpunim: përputhja e cikleve", "golden": "Ripërpunim: shënimi i cikleve referues", diff --git a/custom_components/ha_washdata/translations/panel/sr-Latn.json b/custom_components/ha_washdata/translations/panel/sr-Latn.json index 001becfb..71a3326f 100644 --- a/custom_components/ha_washdata/translations/panel/sr-Latn.json +++ b/custom_components/ha_washdata/translations/panel/sr-Latn.json @@ -78,9 +78,12 @@ "awaiting": "Čeka odobrenje", "imported_tip": "Uvezeno iz prodavnice zajednice. Koristi se samo za podudaranje, ne računa se u statistiku.", "not_importable": "nije primenljivo", - "exists": "postoji" + "exists": "postoji", + "backfilled_tip": "Detektovano u uvezenoj istoriji snage. Utiče samo na podudaranje programa, ne računa se u statistiku." }, "btn": { + "set_brand_model": "Postavi marku i model", + "refresh_catalog": "Osveži katalog", "add_device": "+ Dodaj uređaj", "add_device_tip": "Dodaj još jedan WashData uređaj", "add_maintenance": "Dodaj događaj održavanja", @@ -234,14 +237,19 @@ "download_device": "Preuzmi podešavanja uređaja", "share_device": "Podeli podešavanja uređaja", "share_device_tip": "Podelite programe i podešavanja ovog uređaja sa zajednicom", - "share_n": "Podeli {n} program{s}", + "share_n": "Podeli cikluse ({n})", "export_selected": "Izvoz (izbor podataka)", "export_all": "Brzi izvoz svega", "import_raw": "Napredno: zameni sve iz JSON-a", "download_export": "Preuzmi izvoz", "analyze_import": "Analiziraj datoteku", "import_selected": "Uvezi izabrano", - "back": "Nazad" + "back": "Nazad", + "import_power_history": "Uvezi istoriju snage", + "hist_read_recorder": "Pročitaj iz Home Assistant", + "hist_scan": "Potraži cikluse", + "hist_import_n": "Uvezi cikluse: {n}", + "hist_goto_cycles": "Prikaži cikluse" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Mora biti ispod Maks. snage zaštite od gužvanja ({max} W)" }, "attn_sub": "Ispravite konflikte pre čuvanja", - "attn_title": "{n} konflikt{s} podešavanja", + "attn_title": "Konflikti podešavanja: {n}", "confidence": { "auto": "Mora biti jednak ili iznad Praga podudaranja ({match})", - "learning": "Mora biti jednak ili ispod Praga podudaranja ({match})", + "learning": "Mora biti jednak ili iznad Praga podudaranja ({match})", "match_for_auto": "Mora biti jednak ili ispod Pouzdanosti automatskog označavanja ({alc})", - "match_for_learning": "Mora biti jednak ili iznad Pouzdanosti učenja ({lc})" + "match_for_learning": "Mora biti jednak ili ispod Pouzdanosti učenja ({lc})" }, "duration_ratio": { "max": "Mora biti veći od Min. odnosa trajanja ({min})", @@ -296,14 +304,14 @@ "match": "Mora biti iznad Praga nepodudaranja ({un})", "unmatch": "Mora biti ispod Praga podudaranja ({match}); inače potvrđeno podudaranje odmah prestaje" }, - "cascade_toast": "Automatski je prilagođeno i {n} postavki radi doslednosti.", + "cascade_toast": "Druga podešavanja prilagođena radi doslednosti: {n}", "suggestion_resolves": "Primenite predlog na čekanju ({val}) ispod za rešavanje ovog konflikta", "use_fix": "Koristi {val}", "watchdog": { "interval": "Treba biti najmanje 2× Interval uzorkovanja ({si} s)", "sampling": "Interval uzorkovanja treba biti najviše pola Intervala nadzornika ({wi} s)" }, - "settings_banner": "{n} konflikt{s} podešavanja – proverite istaknute odeljke i ispravite pre čuvanja.", + "settings_banner": "Konflikti podešavanja: {n}. Proverite istaknute odeljke i ispravite ih pre čuvanja.", "settings_banner_btn": "Idi na prvi" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Ishod simulacije", "pg_across_cycles": "Kroz sve vaše cikluse", "community_store": "Prodavnica zajednice", - "online_account": "Prodavnica zajednice i online funkcije" + "online_account": "Prodavnica zajednice i online funkcije", + "import_power_history": "Uvoz istorije snage" }, "health": { "fair": "Prihvatljiva kvaliteta profila", @@ -364,6 +373,7 @@ "poor": "⚠ Loša kvaliteta profila" }, "lbl": { + "drag_to_resize": "Prevucite za promenu veličine", "actions": "Akcije", "activity": "Aktivnost", "administrators": "Administratori", @@ -451,7 +461,7 @@ "metric": "Metrika", "mode_existing_profile": "Dodaj u postojeći profil", "mode_new_profile": "Napravi novi profil", - "models_fine_tuned": "({count} modela fino podešenih)", + "models_fine_tuned": "(fino podešeni modeli: {count})", "n_classic_suggestions": "{n} klasično", "n_ml_suggestions": "{n} ML", "n_selected": "{n} izabrano", @@ -651,7 +661,7 @@ "show_contributor": "Prikaži saradnika", "task_pg_detail": "Simuliraj ciklus", "task_split": "Deljenje ciklusa", - "task_trim": "Obrezivanje ciklusa", + "task_trim": "Isecanje ciklusa", "task_merge": "Spajanje ciklusa", "task_rebuild": "Ponovna izgradnja omotača", "cat_profiles": "Profili (programi)", @@ -677,7 +687,26 @@ "conflict_resolution": "Sukobi imena", "conflict_import_copy": "Uvezi kao kopiju", "conflict_keep_mine": "Zadrži moje", - "conflict_overwrite": "Prepiši" + "conflict_overwrite": "Prepiši", + "hist_csv_data": "CSV podaci", + "hist_from_recorder": "Ili je pročitajte iz Home Assistant", + "days": "dana", + "hist_keep": "Zadrži ovaj ciklus", + "hist_looks_complete": "završen", + "peak_power_short": "Vrh", + "shape": "Oblik", + "hist_skip_idle": "nije bilo aktivnosti", + "hist_skip_sparse": "očitavanja previše razmaknuta", + "hist_skip_short": "nedovoljno očitavanja", + "hist_skip_long": "nema dovoljno duge pauze za deljenje", + "hist_reason_short": "kraći od najkraćeg stvarnog ciklusa ovog uređaja", + "hist_reason_no_end": "nikada se nije ispravno završio", + "hist_since": "Od", + "task_history_import": "Pretraživanje istorije snage", + "task_history_import_apply": "Uvoz ciklusa", + "evidence_real_cycles": "Ciklusi koje je ovaj uređaj obavio", + "evidence_reference_cycles": "Preuzeti iz prodavnice zajednice", + "evidence_backfill_cycles": "Pronađeni u uvezenoj istoriji snage" }, "log": { "all_levels": "Svi nivoi", @@ -756,9 +785,15 @@ "store_share": "Podeli u prodavnicu zajednice", "store_share_device": "Podeli podešavanja uređaja", "export_select": "Izvoz - izbor podataka", - "import_wizard": "Uvoz - izbor podataka" + "import_wizard": "Uvoz - izbor podataka", + "history_import": "Uvoz istorije snage" }, "msg": { + "tail_trim_hint": "Uklonite ovoliko sekundi sa kraja", + "store_sibling_hint": "Nema ničega podeljenog za vaš tačan model? Blisko srodan model iste marke obično je dobra početna tačka.", + "store_declare_appliance": "Recite integraciji WashData koji uređaj posedujete i ova kartica će prikazati konfiguracije koje su drugi podelili za njega. Možete i da upišete marku iznad da biste pregledali katalog.", + "refresh_catalog_hint": "Liste marki i uređaja zajednice privremeno se čuvaju kako bi prodavnica zajednice ostala u okviru svog dnevnog ograničenja. Osvežite da preuzmete unose koje su drugi dodali ili odobrili.", + "head_trim_hint": "Uklonite ovoliko sekundi sa početka", "appliance_monitor": "Monitor uređaja", "artifact_dip_detail": "Pao ispod uobičajenog opsega snage za ~{n} s.", "artifact_footer": "Istaknuto na grafikonu iznad. Ovo su prolazni artefakti (npr. vrata se otvaraju usred ciklusa), ne nužno problemi.", @@ -769,7 +804,7 @@ "automations_intro": "WashData okida događaje {start} / {end} i izlaže entitete, pa je obaveštenja i akcije najbolje graditi kao normalne automatizacije Home Assistanta. Automatizacije koje koriste ovaj uređaj prikazane su ispod.", "cleanup_intro": "Svaki označeni ciklus je prekriven. Označi outlier-e i izbriši za čišćenje profila.", "clear_debug_hint": "Uklonite sačuvane podatke za otklanjanje grešaka da biste oslobodili prostor.", - "collecting_data": "Prikupljanje podataka: još {need} ciklusa do početka finog podešavanja ({current}/{min}).", + "collecting_data": "Prikupljanje podataka. Još potrebnih ciklusa pre početka finog podešavanja: {need} ({current}/{min}).", "compare_overlay_profiles": "Preklapanje profila (bledi)", "compare_profiles_tip": "Prekrijte druge omotače profila na gornjem grafikonu da vidite koji najbolje odgovara ovom ciklusu.", "compare_selected_cycles": "Izabrani ciklusi (neprozirni) – prikaži / sakrij", @@ -779,7 +814,7 @@ "cycles_deleted": "Obrisano ciklusa: {count}", "enough_data": "Dovoljno podataka za učenje ({current}/{min} ciklusa).", "export_description": "Izaberite tačno koje profile, cikluse, podešavanja i drugo želite da izvezete u JSON, ili analizirajte datoteku i uvezite samo delove koje želite.", - "feedback_cycles_pending": "{n} ciklusa za pregled", + "feedback_cycles_pending": "Za pregled: {n}", "feedback_prompt": "Potvrdi da je bio tačan, ispravi program ili ignoriši.", "feedback_relabel_hint": "Ponovno označavanje ovog ciklusa takođe ga rešava.", "filter_by_profile": "Filtriraj po profilu…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "simulacija mirovanja počinje ovde", "pg_sweep_intro": "Šta ako bi {param} bio drugačiji? Testirajte {steps} vrednosti na vaših poslednjih {cycles} ciklusa da pronađete podešavanje pri kojem se najviše ciklusa ispravno poklapa.", "pg_sweep_step": "Korak {done} / {total}", - "pg_undetected": "{n} neotkrivenih ciklusa", "pg_verdict_bad": "Zahteva pažnju: mnogi ciklusi ostaju neotkriveni.", "pg_verdict_good": "Dobro podešeno: većina ciklusa se ispravno prepoznaje i poklapa.", "pg_verdict_ok": "Prihvatljivo: neki ciklusi su propušteni. Pokušajte da smanjite prag pokretanja.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Označite ovo kao ručno odabran referentni ciklus za njegov program - istu ulogu kao i ručno snimljeni ciklus. Referentni ciklusi se uvek čuvaju, postavljaju odgovarajući šablon i nikada se ne ispuštaju čišćenjem. (Ovo je \"zlatna\"/snimljena zastavica; obe su ista stvar.)", "review_tags_tip": "Opcionalne zastavice koje opisuju šta je pošlo po zlu sa ovim ciklusom, tako da obuka i čišćenje mogu da budu odgovorni za to.", "review_to_cycles": "Otvori red za pregled Ciklusa", + "samples_decimated": "Prikazano {shown} od {total} uzoraka (proređeno za prikaz; pikovi zadržani). Široki razmak ovde je proređivanje, a ne nedostajući podaci.", "saving_triggers_reload": "Čuvanje pokreće ponovni pun integracije. HA entiteti mogu se kratko prikazati kao nedostupni.", "search_placeholder": "Pretraži podešavanja…", "see_recorder": "Pogledaj widget za snimanje ispod", @@ -991,12 +1026,12 @@ "share_consent": "Delite stvarne podatke sa svog uređaja. Nemojte deliti ako su vaši obrasci korišćenja privatni.", "share_device_none": "Još nema postavljenih uređaja. Prvo dodajte uređaj.", "share_guideline_naming": "Koristite jasna imena programa (npr. 'Cotton 40', 'Eco 60') kako bi ih drugi mogli prepoznati", - "share_guideline_quality": "Delite samo profile sa ⭐ referentnim ciklusima ili najmanje {n} potvrđenih pokretanja", + "share_guideline_quality": "Delite samo cikluse koji su se normalno završili -- bez prekida usred ciklusa, otvaranja vrata ili skokova napajanja.", "share_guideline_review": "Pregledajte profile pre deljenja -- uklonite one koji izgledaju pogrešno", "share_guidelines_title": "Pre deljenja", "store_download_device_intro": "Preuzmite podešavanja uređaja iz zajednice i primenite ih na novi ili postojeći uređaj", - "store_share_device_intro": "Podelite programe svog uređaja (profile + referentne cikluse) sa zajednicom. Podešavanja su opcionalna.", - "share_profile_no_cycles": "Profil '{p}' nema ⭐ referentnih ciklusa -- biće preskočen osim ako nemate {n}+ potvrđenih pokretanja", + "store_share_device_intro": "Otpremite {brand} {model} sa referentnim ciklusima koje izaberete. Drugi sa istim uređajem mogu da preuzmu vaše programe. Unosi se pregledaju pre nego što postanu javni.", + "share_profile_no_cycles": "Nema referentnih ciklusa - označite ciklus sa ⭐ u kartici Ciklusi da uključite ovaj profil", "advisory_phase_inconsistent": "Izgleda da '{name}' meša različite programe ili temperature - njegovi ciklusi se greju veoma različito dugo. Podela na zasebne profile (npr. po temperaturi) poboljšaće podudaranje i procene vremena.", "advisory_phase_inconsistent_title": "⚠ Možda pomešani programi", "export_select_intro": "Označite tačno šta želite da uključite. Izbor profila bez njihovih ciklusa i dalje izvozi prepoznatljiv program (njegov naučeni oblik ide zajedno sa njim).", @@ -1006,7 +1041,29 @@ "merge_hint": "Uvezene stavke se dodaju; ništa lokalno se ne gubi. Sukobi imena rešavaju se ispod.", "replace_warn": "Svaka označena kategorija se briše i zamenjuje podacima iz datoteke. Neoznačene kategorije ostaju netaknute.", "dest_reference_hint": "Uvezeni ciklusi samo poboljšavaju prepoznavanje programa i nikada ne utiču na statistiku potrošnje/energije.", - "dest_real_history_hint": "Uvezeni ciklusi se računaju kao sopstvena istorija ovog uređaja i pune statistiku energije/potrošnje. Koristite za premeštanje jednog uređaja na novu instalaciju." + "dest_real_history_hint": "Uvezeni ciklusi se računaju kao sopstvena istorija ovog uređaja i pune statistiku energije/potrošnje. Koristite za premeštanje jednog uređaja na novu instalaciju.", + "import_history_description": "Imali ste pametni priključak i pre WashData? Učitajte izvoz istorije njegovog senzora snage ili je pročitajte direktno iz Home Assistant, a uobičajena detekcija proći će kroz te podatke, pa će se prošli ciklusi pojaviti na vašoj listi Ciklusi, spremni za imenovanje.", + "hist_input_hint": "Učitajte CSV preuzet sa panela Istorija (entitet, stanje, poslednja promena) ili pustite WashData da direktno pročita istoriju senzora. Detekcija se zatim izvodi isto kao i uživo, a vi birate koje od pronađenih ciklusa ćete zadržati.", + "hist_recorder_hint": "Čita podatke od izabranog datuma do sada. Home Assistant podrazumevano čuva detaljnu istoriju 10 dana, a posle toga samo satne proseke, koji su previše grubi za detekciju ciklusa - raniji datum birajte samo ako je vaš recorder podešen na duže čuvanje.", + "hist_scanning": "Vašu istoriju ponovo provodimo kroz detektor. Ovo se izvodi u pozadini - možete zatvoriti ovaj dijalog i vratiti se kasnije.", + "hist_imported_count": "Uvezeni ciklusi: {n}.", + "hist_duplicates": "Već ranije uvezeno i preskočeno: {n}.", + "hist_capped": "Dostignuto je ograničenje uvezenih ciklusa po uređaju; ostatak nije sačuvan.", + "hist_next_step": "Nalaze se na vašoj listi Ciklusi, obeleženi kao uvezena istorija. Otvorite jedan i tasterom Označi mu dodelite naziv programa kojem pripada.", + "hist_rows_read": "Pročitana očitavanja: {n}", + "hist_breaks": "Praznine u kojima senzor nije bio dostupan: {n}", + "hist_other_entity": "Zanemarena očitavanja drugih entiteta: {n}", + "hist_entity_substituted": "Očitano {used} (ovaj uređaj je podešen na {wanted})", + "hist_skipped_spans": "Preskočeni delovi", + "hist_settings_used": "Detektovano sa trenutnim podešavanjima ovog uređaja (minimalna snaga {w} W, kašnjenje isklopa {s} s).", + "hist_none_found": "U toj istoriji nije bilo moguće detektovati nijedan ciklus.", + "hist_found": "Pronađeni ciklusi: {n}. Odznačite sve što ne izgleda kao stvarni rad - ništa se ne čuva dok ne uvezete.", + "hist_scan_capped": "Prikazani su samo prvi kandidati (pronađeno je {n}).", + "hist_recorder_empty": "Home Assistant nema detaljnu istoriju za ovaj senzor u tom periodu.", + "hist_scan_failed": "Pretraživanje nije uspelo.", + "hist_scan_expired": "To pretraživanje više nije dostupno. Pokrenite ga ponovo.", + "hist_import_failed": "Uvoz nije uspeo.", + "imported_history_readonly": "Detektovano u uvezenoj istoriji snage. Utiče na podudaranje programa, ali se ne računa u vašu statistiku i ne može se isecati ni deliti. Označite ga da mu dodelite program." }, "phase_desc": { "anti_crease": "Povremena kratka okretanja nakon završetka radi smanjenja gužvanja.", @@ -1471,6 +1528,10 @@ "show_contributor": { "doc": "Prikaži ime saradnika na profilima preuzetim iz prodavnice zajednice" }, + "smart_termination_duration_ratio": { + "doc": "Koliko duboko u očekivano trajanje podudarenog programa ciklus mora da odmakne pre nego što Pametno završavanje sme da ga okonča ranije kad snaga opadne. Očekivano trajanje je prosek programa, pa kod uređaja čije vreme rada dosta varira - mašine za pranje veša pri hladnoj zimskoj i toploj letnjoj dovodnoj vodi, mašine za sušenje sa senzorom vlage, programi zavisni od količine veša - oko polovine svih ciklusa završi kraće od tog proseka i nikada ne dobije brzi završetak, već se okonča tek preko rezervnog tajmauta sa nekoliko minuta zakašnjenja. Smanjite ovu vrednost (npr. 0.85) na takvim mašinama da bi se rano završavanje ipak aktiviralo; povećajte je ka 1.0 za oprezniji rad. Ostavite prazno za podrazumevanu vrednost (0.98, ili 0.99 za mašine za pranje sudova). Može samo da okonča ciklus ranije, nikada kasnije, i nikada se ne aktivira pri dvosmislenom ili nepouzdanom podudaranju.", + "label": "Koeficijent pametnog završavanja" + }, "enable_phase_matching": { "label": "Preostalo vreme prilagođeno fazama", "doc": "Deli svaki aktivni ciklus na faze (zagrevanje, pranje, centrifuga) i raspoređuje preostalo vreme po fazama, u kombinaciji sa klasičnom procenom - oslanjajući se na raspored po fazama na početku ciklusa, a na klasičnu procenu pri kraju. Ovo prilagođava odbrojavanje tome koliko se vaša mašina zaista greje i radi, što je najuočljivije u prvoj polovini ciklusa. Isključeno = samo klasična procena. Utiče samo na prikaz preostalog vremena; podudaranje programa i detekcija ciklusa ostaju nepromenjeni." @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Zadržavanje pri otvorenim vratima", "doc": "Koliko dugo vrata moraju ostati otvorena pre nego WashData završi ciklus, kada je uključeno \"Vrata se automatski otvaraju na kraju\". Dovoljno dugo da se ignoriše brzo dodavanje posuđa (podrazumevano 60 s), dovoljno kratko za brzo završavanje kada mašina otvori vrata." + }, + "profile_evidence_sources": { + "label": "Ciklusi koji grade program", + "doc": "Koji se ciklusi koriste za izgradnju krive snage svakog programa i za podudaranje završenog ciklusa s njom. Kada odznačite neku vrstu, ona više ne utiče na vaše programe, ali se ništa ne briše - ciklusi ostaju na spisku Ciklusi i mogu se i dalje označiti ili ukloniti. Korisno ako ne verujete uvezenim podacima. Na statistiku to ne utiče: uvek se računaju samo ciklusi koje je ovaj uređaj zaista izvršio. Odznačavanje svega se ignoriše jer program bez ikakvih ciklusa nikada ne bi mogao da se podudari." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Osnovna konfiguracija" + }, + "profile_evidence": { + "label": "Osnov profila" } }, "status": { @@ -1626,10 +1694,11 @@ "analyzing": "Analiziranje…", "resetting": "Resetovanje…", "reverting": "Vraćanje…", - "trimming": "Obrezivanje…", + "trimming": "Isecanje…", "splitting": "Deljenje…", "deleting": "Brisanje…", - "imported": "Uvezeno" + "imported": "Uvezeno", + "preparing": "Priprema…" }, "suggestion": { "both_agree": "WashData preporučuje", @@ -1725,7 +1794,7 @@ "thr_batch": "Zadržano tik iznad najniže radne snage p05 kroz {cycles} ciklusa ({p05}W) kako bi se početak uhvatio što ranije, a prag zaustavljanja ostao ispod najniže radne snage mašine.", "tol_per_profile": "p75 varijanse trajanja po profilu kroz {profiles} profila ({cycles} ciklusa); dosledni profili nisu kažnjeni.", "tol_pooled": "Na osnovu objedinjene varijanse trajanja {cycles} novijih označenih ciklusa (p95 odstupanje={dev}).", - "watchdog": "Zadržano što nižim uz bezbednost (tik iznad razmaka ažuriranja p95 od {p95}s, najmanje 30s) kako bi se zastoji brzo uhvatili bez lažnih zaustavljanja." + "watchdog": "Zadržano što nižim uz bezbednost (tik iznad razmaka ažuriranja p95 od {p95}s i najmanje 2× intervala uzorkovanja od {median}s, najmanje 30s) kako bi se zastoji brzo uhvatili bez lažnih zaustavljanja." }, "exclusions": { "summary": "Isključeno {total} pogrešno detektovanih ciklusa: {parts}.", @@ -1772,6 +1841,7 @@ "wrong_profile": "Pogrešan profil" }, "toast": { + "catalog_refreshed": "Katalog zajednice je osvežen", "access_saved": "Kontrola pristupa sačuvana", "all_wiped": "Svi podaci obrisani", "analysis_complete_none": "Analiza završena: nema novih prijedloga", @@ -1865,19 +1935,21 @@ "rating_saved": "Ocena kvaliteta sačuvana", "brand_added": "Marka dodata, čeka odobrenje", "profile_added": "Profil dodat, čeka odobrenje", - "saved_except_conflicts": "Podešavanja sačuvana -- {n} podešavanje{s} preskočeno zbog konflikata", + "saved_except_conflicts": "Sačuvano. Ispravite istaknute konflikte da sačuvate ostalo.", "share_device_none_sel": "Odaberite barem jedan program za deljenje", - "store_device_downloaded": "Podešavanja uređaja preuzeta: {created} profil{c} kreiran, {dup} već postoji", - "store_device_downloaded_phases": "Podešavanja uređaja preuzeta: {created} profil{c} kreiran, {dup} već postoji, mapa faza primenjena", - "store_device_downloaded_settings": "Podešavanja uređaja preuzeta: {created} profil{c} kreiran, {dup} već postoji, podešavanja primenjena", - "store_device_shared": "Podešavanja uređaja podeljena: {n} program{s} učitan", - "store_device_shared_all_dup": "Nema ničeg novog za deljenje -- svi programi već postoje u prodavnici", - "store_device_shared_partial": "Delimično deljenje: {n} program{s} učitan, {failed} preskočeno", - "store_device_shared_some_dup": "Podešavanja uređaja podeljena: {n} program{s} učitan ({dup} već postoji)", + "store_device_downloaded": "Dodato: programi {p}, snimci {c}", + "store_device_downloaded_phases": "Dodato: programi {p}, snimci {c}, mape faza {ph}", + "store_device_downloaded_settings": "Dodato: programi {p}, snimci {c}, mape faza {ph}, podešavanja {s}", + "store_device_shared": "Ciklusi podeljeni u prodavnicu zajednice: {n}. Čeka se pregled.", + "store_device_shared_all_dup": "Svi ciklusi ({n}) su već bili u prodavnici zajednice.", + "store_device_shared_partial": "Podeljeni ciklusi: {n}; nije otpremljeno: {failed}.", + "store_device_shared_some_dup": "Podeljeni ciklusi: {created}; već u prodavnici: {dup}.", "store_download_failed": "Preuzimanje nije uspelo: {error}", "store_download_nothing": "Nema ničeg za preuzimanje -- svi profili već postoje na ovom uređaju", "export_selective_done": "Izvoz preuzet", - "import_selective_done": "Uvezeno {profiles} profil(a) i {cycles} ciklus(a)" + "import_selective_done": "Uvezeno {profiles} profil(a) i {cycles} ciklus(a)", + "hist_csv_required": "Prvo učitajte CSV datoteku ili nalepite njen sadržaj", + "file_read_failed": "Nije bilo moguće pročitati tu datoteku" }, "trend": { "down": "Opadajući trend", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Mašina za pranje sudova: sekunde tišine nakon očekivanog trajanja pre nego što se otpusti čekanje na završno ispumpavanje vode", + "smart_termination_duration_ratio": "Deo očekivanog trajanja podudarenog programa koji ciklus mora da dostigne pre nego što Pametno završavanje sme da ga okonča ranije; smanjite ga za mašine zavisne od količine veša ili temperature", "completion_min_seconds": "Najkraće pokretanje koje se računa kao pravi ciklus", "end_repeat_count": "Niska očitavanja zaredom pre završetka", "interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti", @@ -1955,6 +2028,10 @@ "finished": "Ciklus je dostigao završno stanje i završio." }, "store": { + "your_model_tip": "Ovo je uređaj koji ste naveli u Podešavanjima", + "your_model": "Vaš", + "search_brand_ph": "Pretraži po marki…", + "programs_count": "Programi: {n}", "browse": "Pregledaj", "device": "Uređaj", "favorites": "Omiljeni", @@ -1993,6 +2070,7 @@ "add_profile": "Dodajte profil za ovaj uređaj na sajtu zajednice" }, "task": { + "cancelling": "Otkazivanje...", "reprocess": { "matching": "Ponovna obrada: podudaranje ciklusa", "golden": "Ponovna obrada: popunjavanje referentnih", @@ -2010,7 +2088,7 @@ "apply": "Deljenje ciklusa" }, "trim": { - "apply": "Obrezivanje ciklusa" + "apply": "Isecanje ciklusa" }, "merge": { "apply": "Spajanje ciklusa" diff --git a/custom_components/ha_washdata/translations/panel/sv.json b/custom_components/ha_washdata/translations/panel/sv.json index 64a42c58..dc6381df 100644 --- a/custom_components/ha_washdata/translations/panel/sv.json +++ b/custom_components/ha_washdata/translations/panel/sv.json @@ -78,9 +78,12 @@ "awaiting": "Väntar på godkännande", "imported_tip": "Importerad från gemenskapsbutiken. Används endast för matchning, räknas inte in i statistiken.", "not_importable": "ej här", - "exists": "finns" + "exists": "finns", + "backfilled_tip": "Detekterad i importerad effekthistorik. Påverkar bara programmatchningen, räknas inte in i statistiken." }, "btn": { + "set_brand_model": "Ange märke och modell", + "refresh_catalog": "Uppdatera katalog", "add_device": "+ Lägg till enhet", "add_device_tip": "Lägg till ytterligare en WashData-enhet", "add_maintenance": "Lägg till underhållshändelse", @@ -90,7 +93,7 @@ "apply_label": "Tillämpa etikett", "apply_set_b": "Använd uppsättning B", "apply_split": "Tillämpa delning", - "apply_trim": "Tillämpa trimning", + "apply_trim": "Tillämpa beskärning", "auto_detect_split": "Autoidentifiera", "auto_label_cycles": "Automatisk märkning av cykler", "auto_label_cycles_tip": "Tilldela automatiskt profilnamn till omärkta cykler vars matchningskonfidens klarar tröskeln", @@ -209,8 +212,8 @@ "stop": "Stoppa", "submit_correction": "Skicka korrigering", "train_now": "Träna nu", - "trim": "Trimma", - "trim_split": "Trimma / Dela", + "trim": "Beskär", + "trim_split": "Beskär / Dela", "undo": "Ångra", "use": "Använd", "wipe_all": "Rensa all data", @@ -241,7 +244,12 @@ "import_selected": "Importera valda", "back": "Tillbaka", "mute_suggestion": "Sluta föreslå den här inställningen", - "reset_muted": "Återställ tysta förslag" + "reset_muted": "Återställ tysta förslag", + "import_power_history": "Importera effekthistorik", + "hist_read_recorder": "Läs från Home Assistant", + "hist_scan": "Sök efter cykler", + "hist_import_n": "Importera {n} cykler", + "hist_goto_cycles": "Visa mig cyklerna" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "Måste vara under skrynkelskyddets maxeffekt ({max} W)" }, "attn_sub": "Åtgärda konflikter innan du sparar", - "attn_title": "{n} inställningskonflikt{s}", + "attn_title": "Inställningskonflikter: {n}", "confidence": { "auto": "Måste vara vid eller över matchningströskel ({match})", - "learning": "Måste vara vid eller under matchningströskel ({match})", + "learning": "Måste vara vid eller över matchningströskel ({match})", "match_for_auto": "Måste vara vid eller under automatisk-märknings-konfidensen ({alc})", - "match_for_learning": "Måste vara vid eller över inlärningskonfidensen ({lc})" + "match_for_learning": "Måste vara vid eller under inlärningskonfidensen ({lc})" }, "duration_ratio": { "max": "Måste vara större än minimal varaktighetskvot ({min})", @@ -296,14 +304,14 @@ "match": "Måste vara över avmatchningströskel ({un})", "unmatch": "Måste vara under matchningströskel ({match}); annars avmatchas en bekräftad matchning omedelbart" }, - "cascade_toast": "Även {n} inställning{s} justerades för konsekvens.", + "cascade_toast": "Andra inställningar justerades för konsekvens: {n}", "suggestion_resolves": "Tillämpa förslaget nedan ({val}) för att lösa detta", "use_fix": "Använd {val}", "watchdog": { "interval": "Bör vara minst 2x samplingsintervallet ({si} s)", "sampling": "Samplingsintervall bör vara högst hälften av vakthundsintervallet ({wi} s)" }, - "settings_banner": "{n} inställningskonflikt{s} – kontrollera de markerade avsnitten och åtgärda dem innan du sparar.", + "settings_banner": "Inställningskonflikter: {n}. Kontrollera de markerade avsnitten och åtgärda dem innan du sparar.", "settings_banner_btn": "Gå till första" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "Simuleringsresultat", "pg_across_cycles": "Över dina cykler", "community_store": "Gemenskapsbutik", - "online_account": "Gemenskapsbutik och onlinefunktioner" + "online_account": "Gemenskapsbutik och onlinefunktioner", + "import_power_history": "Importera effekthistorik" }, "health": { "fair": "Acceptabel profilkvalitet", @@ -364,6 +373,7 @@ "poor": "⚠ Dålig profilkvalitet" }, "lbl": { + "drag_to_resize": "Dra för att ändra storlek", "actions": "Åtgärder", "activity": "Aktivitet", "administrators": "Administratörer", @@ -434,7 +444,7 @@ "from": "Från", "gap_s": "Lucka (s)", "group_name": "Gruppnamn", - "head_trim": "Starttrimning (s)", + "head_trim": "Beskär start (s)", "health": "Hälsa", "hide_tabs": "Dölj flikar för icke-administratörer", "in_use": "Används", @@ -450,7 +460,7 @@ "metric": "Mått", "mode_existing_profile": "Lägg till i befintlig profil", "mode_new_profile": "Skapa ny profil", - "models_fine_tuned": "({count} modell{plural} finjusterad)", + "models_fine_tuned": "(finjusterade modeller: {count})", "n_classic_suggestions": "{n} klassisk", "n_ml_suggestions": "{n} ML", "n_selected": "{n} valda", @@ -537,7 +547,7 @@ "stage3": "Steg 3 – DTW", "stage4": "Steg 4 – överensstämmelse", "status": "Status", - "tail_trim": "Sluttrimning (s)", + "tail_trim": "Beskär slut (s)", "timer_auto_pause": "Automatisk paus", "timer_min": "min", "timer_msg_placeholder": "Meddelande (valfritt, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_keep_mine": "Behåll mina", "conflict_overwrite": "Skriv över", "pg_anti_wrinkle": "Skrynkelskydd", - "font_size": "Panelens teckenstorlek" + "font_size": "Panelens teckenstorlek", + "hist_csv_data": "CSV-data", + "hist_from_recorder": "Eller läs den från Home Assistant", + "hist_since": "Sedan", + "days": "dagar", + "hist_keep": "Behåll den här cykeln", + "hist_looks_complete": "komplett", + "peak_power_short": "Topp", + "shape": "Form", + "hist_skip_idle": "inget kördes", + "hist_skip_sparse": "avläsningar för långt ifrån varandra", + "hist_skip_short": "för få avläsningar", + "hist_skip_long": "ingen paus lång nog att dela på", + "hist_reason_short": "kortare än den här apparatens kortaste verkliga cykel", + "hist_reason_no_end": "avslutades aldrig rent", + "task_history_import": "Söker igenom effekthistorik", + "task_history_import_apply": "Importerar cykler", + "evidence_real_cycles": "Cykler den här maskinen har kört", + "evidence_reference_cycles": "Nedladdade från gemenskapsbutiken", + "evidence_backfill_cycles": "Hittade i importerad effekthistorik" }, "log": { "all_levels": "Alla nivåer", @@ -756,9 +785,15 @@ "store_share": "Dela till gemenskapsbutiken", "store_share_device": "Dela den här apparaten", "export_select": "Export - välj data", - "import_wizard": "Import - välj data" + "import_wizard": "Import - välj data", + "history_import": "Importera effekthistorik" }, "msg": { + "tail_trim_hint": "Ta bort så här många sekunder från slutet", + "store_sibling_hint": "Finns inget delat för exakt din modell? En närbesläktad modell från samma märke är oftast en bra utgångspunkt.", + "store_declare_appliance": "Berätta för WashData vilken apparat du har, så visar den här fliken de färdiga inställningar andra har delat för den. Du kan också skriva ett märke ovanför för att se dig omkring.", + "refresh_catalog_hint": "Gemenskapens listor över märken och apparater cachelagras för att gemenskapsbutiken ska hålla sig inom sin dagliga gräns. Uppdatera för att hämta poster som andra har lagt till eller godkänt.", + "head_trim_hint": "Ta bort så här många sekunder från början", "appliance_monitor": "Apparatövervakning", "artifact_dip_detail": "Föll under det normala effektbandet i ~{n} s.", "artifact_footer": "Markerad i grafen ovan. Dessa är övergående artefakter (t.ex. dörren öppnas mitt i cykeln), inte nödvändigtvis problem.", @@ -769,7 +804,7 @@ "automations_intro": "WashData utlöser {start} / {end}-händelser och tillhandahåller entiteter – aviseringar och åtgärder byggs bäst som vanliga Home Assistant-automatiseringar. Automatiseringar som använder den här enheten visas nedan.", "cleanup_intro": "Alla märkta cykler är överlagda. Kryssa i avvikare och ta bort dem för att rensa profilen.", "clear_debug_hint": "Ta bort lagrad felsökningsdata för att frigöra utrymme.", - "collecting_data": "Samlar in data: {need} cykel{plural} kvar innan finjustering kan starta ({current}/{min}).", + "collecting_data": "Samlar in data. Cykler som återstår innan finjustering kan starta: {need} ({current}/{min}).", "compare_overlay_profiles": "Lägg över profiler (svag)", "compare_profiles_tip": "Lägg över andra profilkuvert på diagrammet ovan för att se vilket som passar bäst för denna cykel.", "compare_selected_cycles": "Valda cykler (solid) – visa / dölj", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} cykler borttagna", "enough_data": "Tillräckligt med data för att lära sig av ({current}/{min} cykler).", "export_description": "Välj exakt vilka profiler, cykler, inställningar och mer som ska exporteras till JSON, eller analysera en fil och importera bara de delar du vill ha.", - "feedback_cycles_pending": "{n} cykel{s} att granska", + "feedback_cycles_pending": "Att granska: {n}", "feedback_prompt": "Bekräfta att det var rätt, korrigera programmet eller ignorera.", "feedback_relabel_hint": "Att märka om den här cykeln löser också granskningen.", "filter_by_profile": "Filtrera efter profil…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "inaktiv simulering börjar här", "pg_sweep_intro": "Tänk om {param} vore annorlunda? Testa {steps} värden över dina senaste {cycles} cykler för att hitta inställningen där flest cykler matchas korrekt.", "pg_sweep_step": "Steg {done} / {total}", - "pg_undetected": "{n} cykel{s} oupptäckt", "pg_verdict_bad": "Behöver åtgärdas: många cykler förblir oupptäckta.", "pg_verdict_good": "Väl inställt: de flesta cykler identifieras och matchas korrekt.", "pg_verdict_ok": "Godtagbart: några cykler missades. Prova att sänka startröskeln.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Markera detta som en handplockad referenscykel för sitt program - samma roll som en manuellt inspelad cykel. Referenscykler hålls alltid, såddar den matchande mallen och släpps aldrig vid rengöring. (Detta är den \"gyllene\"/inspelade flaggan; båda är samma sak.)", "review_tags_tip": "Valfria flaggor som beskriver vad som gick fel med den här cykeln, så träning och städning kan förklara det.", "review_to_cycles": "Öppna granskningskön för Cykler", + "samples_decimated": "Visar {shown} av {total} sampel (gallrat för visning; toppar behålls). En bred lucka här är gallring, inte saknade data.", "saving_triggers_reload": "Sparande utlöser en integration-omladdning. HA-enheter kan kortvarigt visas som otillgängliga.", "search_placeholder": "Sök inställningar…", "see_recorder": "Se inspelningswidgeten nedan", @@ -1006,10 +1041,33 @@ "sug_mute_failed": "Kunde inte tysta förslaget", "sug_unmuted_all": "Tysta förslag återställda", "n_suggestions_muted": "{count} tystade; auto-tunern föreslår inte dessa.", - "font_size_hint": "Gör allt i den här panelen större eller mindre. Gäller för ditt konto på den här enheten." + "font_size_hint": "Gör allt i den här panelen större eller mindre. Gäller för ditt konto på den här enheten.", + "import_history_description": "Hade du redan en smart kontakt innan du började med WashData? Ladda upp en historikexport från dess effektsensor, eller låt den läsas direkt från Home Assistant, och den vanliga detekteringen körs över den så att tidigare cykler dyker upp i din Cykler-lista, klara att namnges.", + "hist_input_hint": "Ladda upp en CSV-fil som hämtats från Historik-panelen (entitet, tillstånd, senast ändrad), eller låt WashData läsa sensorns historik direkt. Detekteringen körs sedan över den precis som live, och du väljer vilka av cyklerna den hittar du vill behålla.", + "hist_recorder_hint": "Läser från det datum du väljer fram till nu. Home Assistant sparar detaljerad historik i 10 dagar som standard och därefter bara timmedelvärden, som är för grova för att detektera cykler ur - välj ett datum längre tillbaka bara om din recorder är inställd på att spara mer.", + "hist_scanning": "Din historik spelas upp genom detekteringen. Detta körs i bakgrunden - du kan stänga den här dialogen och komma tillbaka till den senare.", + "hist_imported_count": "{n} cykler importerade.", + "hist_duplicates": "{n} var redan importerade och hoppades över.", + "hist_capped": "Gränsen per enhet för importerade cykler nåddes; resten sparades inte.", + "hist_next_step": "De finns i din Cykler-lista, märkta som importerad historik. Öppna en och använd Märk för att namnge programmet den hör till.", + "hist_rows_read": "{n} avläsningar lästa", + "hist_entity_substituted": "läste {used} (den här enheten är konfigurerad för {wanted})", + "hist_breaks": "{n} luckor där sensorn var otillgänglig", + "hist_other_entity": "{n} avläsningar för andra entiteter ignorerade", + "hist_skipped_spans": "Överhoppade sträckor", + "hist_settings_used": "Detekterad med den här enhetens nuvarande inställningar (minimieffekt {w} W, avfördröjning {s} s).", + "hist_none_found": "Inga cykler kunde detekteras i den historiken.", + "hist_found": "Hittade {n} cykler. Avmarkera allt som inte ser ut som en verklig körning - inget sparas förrän du importerar.", + "hist_scan_capped": "Endast de första kandidaterna visas ({n} hittades).", + "hist_recorder_empty": "Home Assistant har ingen detaljerad historik för den här sensorn i det tidsfönstret.", + "hist_scan_failed": "Sökningen misslyckades.", + "hist_scan_expired": "Den sökningen är inte längre tillgänglig. Sök igen.", + "hist_import_failed": "Importen misslyckades.", + "imported_history_readonly": "Detekterad i importerad effekthistorik. Den påverkar programmatchningen men räknas inte in i din statistik och kan inte beskäras eller delas. Märk den för att namnge programmet." }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Diskmaskin: tysta sekunder efter förväntad varaktighet innan väntan på tömning vid cykelslut frisläpps", + "smart_termination_duration_ratio": "Andel av det matchade programmets förväntade varaktighet som en cykel måste nå innan smart avslutning får avsluta den i förtid; sänk den för last- eller temperaturberoende maskiner", "completion_min_seconds": "Kortaste körning som räknas som en riktig cykel", "end_repeat_count": "Antal låga avläsningar i rad innan avslut", "interrupted_min_seconds": "Korta cykler markeras som avbrutna", @@ -1421,6 +1479,10 @@ "doc": "Lagra spårningen av full effekt och matchande felsökningsdata för varje cykel. Användbar för felsökning men ökar lagringsstorleken.", "label": "Spara felsökningsspår" }, + "smart_termination_duration_ratio": { + "doc": "Hur långt in i det matchade programmets förväntade varaktighet en cykel måste vara innan smart avslutning får avsluta den i förtid när effekten sjunker. Den förväntade varaktigheten är programmets genomsnitt, så på apparater vars körtid varierar mycket - tvättmaskiner med kallt vinter- kontra varmt sommarinloppsvatten, torktumlare med fuktsensor, lastberoende program - avslutas ungefär hälften av alla körningar kortare än det genomsnittet och får aldrig den snabba avslutningen, utan avslutas först minuter senare via reserv-timeouten. Sänk detta värde (t.ex. 0,85) på sådana maskiner så att den tidiga avslutningen ändå utlöses; höj det mot 1,0 för att vara mer försiktig. Lämna tomt för standardvärdet (0,98, eller 0,99 för diskmaskiner). Den kan bara avsluta en cykel tidigare, aldrig senare, och utlöses aldrig vid en tvetydig eller osäker matchning.", + "label": "Kvot för smart avslutning" + }, "smoothing_window": { "doc": "Hur mycket råeffektsignalen utjämnas. Låg (2) är lyhörd men bullrig; hög (5) jämnar ut spikar men lägger till eftersläpning.", "label": "Utjämningsfönster" @@ -1551,6 +1613,10 @@ "door_end_dwell_seconds": { "label": "Dörröppningens uppehållstid vid avslut", "doc": "Hur länge dörren måste förbli öppen innan WashData avslutar cykeln, när \"Dörren öppnas automatiskt vid avslut\" är aktiverat. Tillräckligt länge för att ignorera snabb tilläggning av ett fat (standard 60 s), tillräckligt kort för att avsluta snabbt när maskinen poppar upp dörren." + }, + "profile_evidence_sources": { + "label": "Cykler som formar ett program", + "doc": "Vilka cykler som används för att bygga upp effektkurvan för varje program och för att matcha en avslutad cykel mot den. Avmarkerar du en typ formar den inte längre dina program, utan att något tas bort - cyklerna ligger kvar i din Cykler-lista och kan fortfarande märkas eller tas bort. Praktiskt om du inte litar på importerade data. Statistiken påverkas inte: den räknar alltid bara de cykler som den här maskinen verkligen har kört. Att avmarkera allt ignoreras, eftersom ett program utan cykler bakom sig aldrig skulle kunna matcha." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "Grundkonfiguration" + }, + "profile_evidence": { + "label": "Profilunderlag" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "Beskär…", "splitting": "Delar upp…", "deleting": "Raderar…", - "imported": "Importerad" + "imported": "Importerad", + "preparing": "Förbereder…" }, "suggestion": { "both_agree": "WashData rekommenderar", @@ -1754,7 +1824,7 @@ "thr_batch": "Hålls precis över den lägsta aktiva effekten vid p05 över {cycles} cykler ({p05}W) så att en start fångas så tidigt som möjligt och stopptröskeln stannar under maskinens lägsta driftseffekt.", "tol_per_profile": "p75 för varaktighetsvariansen per profil över {profiles} profiler ({cycles} cykler); snäva profiler bestraffas inte.", "tol_pooled": "Baserat på sammanslagen varaktighetsvarians för {cycles} senaste märkta cykler (p95 avvikelse={dev}).", - "watchdog": "Hålls så lågt som är säkert (strax över p95-uppdateringsmellanrummet på {p95}s, min 30s) så att stillestånd upptäcks snabbt utan falska stopp." + "watchdog": "Hålls så lågt som är säkert (strax över p95-uppdateringsmellanrummet på {p95}s och minst 2x samplingsintervallet på {median}s, min. 30s) så att stillestånd upptäcks snabbt utan falska stopp." }, "exclusions": { "summary": "Uteslöt {total} feldetekterade cykler: {parts}.", @@ -1801,6 +1871,7 @@ "wrong_profile": "Fel profil" }, "toast": { + "catalog_refreshed": "Gemenskapskatalogen har uppdaterats", "access_saved": "Åtkomstkontroll sparad", "all_wiped": "All data rensad", "analysis_complete_none": "Analys klar: inga nya förslag", @@ -1812,7 +1883,7 @@ "cycle_labelled": "Cykel märkt", "cycle_paused": "Cykel pausad", "cycle_resumed": "Cykel återupptagen", - "cycle_trimmed": "Cykel trimmad", + "cycle_trimmed": "Cykel beskuren", "cycles_merged": "Cykler sammanslagna", "envelope_rebuilt": "Kuvert ombyggt", "envelopes_rebuilt": "Kuvert ombyggda", @@ -1906,7 +1977,9 @@ "store_download_failed": "Nedladdning misslyckades: {error}", "store_download_nothing": "Inget nytt att ladda ner -- den här inställningen finns redan på din apparat.", "export_selective_done": "Export nedladdad", - "import_selective_done": "Importerade {profiles} profil(er) och {cycles} cykel/cykler" + "import_selective_done": "Importerade {profiles} profil(er) och {cycles} cykel/cykler", + "hist_csv_required": "Ladda in en CSV-fil eller klistra in dess innehåll först", + "file_read_failed": "Kunde inte läsa den filen" }, "trend": { "down": "Nedåtgående trend", @@ -1955,6 +2028,10 @@ "finished": "Cykeln nådde ett sluttillstånd och avslutades." }, "store": { + "your_model_tip": "Det här är apparaten du har angett i Inställningar", + "your_model": "Din apparat", + "search_brand_ph": "Sök efter märke…", + "programs_count": "Program: {n}", "browse": "Bläddra", "device": "Enhet", "favorites": "Favoriter", diff --git a/custom_components/ha_washdata/translations/panel/tr.json b/custom_components/ha_washdata/translations/panel/tr.json index b72034a1..aa3574e6 100644 --- a/custom_components/ha_washdata/translations/panel/tr.json +++ b/custom_components/ha_washdata/translations/panel/tr.json @@ -78,9 +78,12 @@ "awaiting": "Onay bekleniyor", "imported_tip": "Topluluk mağazasından içe aktarıldı. Yalnızca eşleştirme için kullanılır, istatistiklere sayılmaz.", "not_importable": "burada geçersiz", - "exists": "mevcut" + "exists": "mevcut", + "backfilled_tip": "İçe aktarılan güç geçmişinde algılandı. Yalnızca program eşleştirmesini etkiler, istatistiklere sayılmaz." }, "btn": { + "set_brand_model": "Marka ve modeli ayarla", + "refresh_catalog": "Kataloğu yenile", "add_device": "+ Cihaz ekle", "add_device_tip": "Başka bir WashData cihazı ekleyin", "add_maintenance": "Bakım kaydı ekle", @@ -241,7 +244,12 @@ "import_selected": "Seçileni içe aktar", "back": "Geri", "mute_suggestion": "Bu ayarı önerme", - "reset_muted": "Susturulanları sıfırla" + "reset_muted": "Susturulanları sıfırla", + "import_power_history": "Güç geçmişini içe aktar", + "hist_read_recorder": "Home Assistant'tan oku", + "hist_scan": "Döngüleri tara", + "hist_import_n": "{n} döngüyü içe aktar", + "hist_goto_cycles": "Döngüleri göster" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "Kırışıklık Önleme Maks. Gücü'nün altında olmalıdır ({max} W)" }, "attn_sub": "Kaydetmeden önce çakışmaları düzeltin", - "attn_title": "{n} ayar çakışması{s}", - "settings_banner": "{n} ayar çakışması{s} – vurgulanan bölümleri kontrol edin ve kaydetmeden önce düzeltin.", + "attn_title": "Ayar çakışmaları: {n}", + "settings_banner": "Ayar çakışmaları: {n}. Vurgulanan bölümleri kontrol edin ve kaydetmeden önce düzeltin.", "settings_banner_btn": "İlkine git", "confidence": { "auto": "Eşleşme Eşiğinde veya üzerinde olmalıdır ({match})", - "learning": "Eşleşme Eşiğinde veya altında olmalıdır ({match})", + "learning": "Eşleşme Eşiğinde veya üzerinde olmalıdır ({match})", "match_for_auto": "Otomatik Etiket Güveni'nde veya altında olmalıdır ({alc})", - "match_for_learning": "Öğrenme Güveni'nde veya üzerinde olmalıdır ({lc})" + "match_for_learning": "Öğrenme Güveni'nde veya altında olmalıdır ({lc})" }, "duration_ratio": { "max": "Min. Süre Oranından büyük olmalıdır ({min})", @@ -298,7 +306,7 @@ "match": "Eşleşmeme Eşiğinin üzerinde olmalıdır ({un})", "unmatch": "Eşleşme Eşiğinin altında olmalıdır ({match}); aksi takdirde onaylanan bir eşleşme anında iptal olur" }, - "cascade_toast": "Tutarlılık için {n} ayar{s} daha otomatik olarak düzenlendi.", + "cascade_toast": "Tutarlılık için düzenlenen diğer ayarlar: {n}", "suggestion_resolves": "Bunu düzeltmek için aşağıdaki bekleyen öneriyi ({val}) etkinleştirin", "use_fix": "{val} kullan", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Simülasyon sonucu", "pg_across_cycles": "Tüm döngülerinizde", "community_store": "Topluluk Mağazası", - "online_account": "Topluluk Mağazası ve çevrimiçi özellikler" + "online_account": "Topluluk Mağazası ve çevrimiçi özellikler", + "import_power_history": "Güç geçmişini içe aktar" }, "health": { "fair": "Kabul edilebilir profil kalitesi", @@ -364,6 +373,7 @@ "poor": "⚠ Düşük profil kalitesi" }, "lbl": { + "drag_to_resize": "Yeniden boyutlandırmak için sürükleyin", "actions": "Eylemler", "activity": "Aktivite", "administrators": "Yöneticiler", @@ -434,7 +444,7 @@ "from": "Başlangıç", "gap_s": "Boşluk (s)", "group_name": "Grup adı", - "head_trim": "Baş Kırpma (s)", + "head_trim": "Baştan Kırpma (s)", "health": "Sağlık", "hide_tabs": "Yönetici olmayanlar için sekmeleri gizle", "in_use": "Kullanımda", @@ -450,7 +460,7 @@ "metric": "Metrik", "mode_existing_profile": "Mevcut Profile Ekle", "mode_new_profile": "Yeni Profil Oluştur", - "models_fine_tuned": "({count} model{plural} ince ayarlı)", + "models_fine_tuned": "(ince ayarlı modeller: {count})", "n_classic_suggestions": "{n} klasik", "n_ml_suggestions": "{n} ML", "n_selected": "{n} seçili", @@ -532,7 +542,7 @@ "stage3": "Aşama 3 – DTW", "stage4": "Aşama 4 – uyum", "status": "Durum", - "tail_trim": "Kuyruk Kırpma (s)", + "tail_trim": "Sondan Kırpma (s)", "timer_auto_pause": "Otomatik duraklatma", "timer_min": "dk.", "timer_msg_placeholder": "Mesaj (isteğe bağlı, {device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_import_copy": "Kopya olarak içe aktar", "conflict_keep_mine": "Benimkini koru", "conflict_overwrite": "Üzerine yaz", - "font_size": "Panel yazı tipi boyutu" + "font_size": "Panel yazı tipi boyutu", + "hist_csv_data": "CSV verisi", + "hist_from_recorder": "Veya Home Assistant'tan okuyun", + "hist_since": "Başlangıç tarihi", + "days": "gün", + "hist_keep": "Bu döngüyü sakla", + "hist_looks_complete": "tamamlanmış", + "peak_power_short": "Tepe", + "shape": "Şekil", + "hist_skip_idle": "hiçbir şey çalışmıyor", + "hist_skip_sparse": "okumalar birbirinden çok uzak", + "hist_skip_short": "çok az okuma", + "hist_skip_long": "bölmeye yetecek kadar uzun bir ara yok", + "hist_reason_short": "bu cihazın en kısa gerçek döngüsünden daha kısa", + "hist_reason_no_end": "hiç düzgün şekilde bitmedi", + "task_history_import": "Güç geçmişi taraması", + "task_history_import_apply": "Döngüleri içe aktarma", + "evidence_real_cycles": "Bu makinenin çalıştırdığı döngüler", + "evidence_reference_cycles": "Topluluk mağazasından indirilenler", + "evidence_backfill_cycles": "İçe aktarılan güç geçmişinde bulunanlar" }, "log": { "all_levels": "Tüm seviyeler", @@ -756,9 +785,15 @@ "store_share": "Topluluk mağazasında paylaş", "store_share_device": "Bu cihazı paylaş", "export_select": "Dışa aktarma - veri seçin", - "import_wizard": "İçe aktarma - veri seçin" + "import_wizard": "İçe aktarma - veri seçin", + "history_import": "Güç geçmişini içe aktar" }, "msg": { + "tail_trim_hint": "Sondan kaldırılacak saniye sayısı", + "store_sibling_hint": "Tam olarak sizin modeliniz için paylaşılmış bir şey yok mu? Aynı markanın yakın bir modeli genellikle iyi bir başlangıç noktasıdır.", + "store_declare_appliance": "WashData'ya hangi cihaza sahip olduğunuzu bildirin; bu sekme, başkalarının o cihaz için paylaştığı programları ve kayıtları gösterir. Ayrıca yukarıya bir marka yazıp göz atabilirsiniz.", + "refresh_catalog_hint": "Topluluk marka ve cihaz listeleri, paylaşılan mağazanın günlük okuma kotası içinde kalması için önbelleğe alınır. Başkalarının eklediği veya onayladığı kayıtları almak için yenileyin.", + "head_trim_hint": "Baştan kaldırılacak saniye sayısı", "appliance_monitor": "Cihaz monitörü", "artifact_dip_detail": "Normal güç bandının altında yaklaşık ~{n} saniye sürdü.", "artifact_footer": "Yukarıdaki grafikte vurgulanmıştır. Bunlar geçici eserlerdir (örn. döngünün ortasında kapının açılması), mutlaka sorun teşkil etmez.", @@ -769,7 +804,7 @@ "automations_intro": "WashData, {start} / {end} olaylarını tetikler ve varlıkları gösterir; bu nedenle bildirimler ve eylemler en iyi şekilde normal Home Assistant otomasyonları olarak oluşturulur. Bu cihazı kullanan otomasyonlar aşağıda görünür.", "cleanup_intro": "Tüm etiketli döngüler üst üste bindirilmiş. Aykırı değerleri işaretleyip silerek profili temizleyin.", "clear_debug_hint": "Saklanan hata ayıklama verilerini kaldırarak boş alan açın.", - "collecting_data": "Veri toplanıyor: ince ayar başlamadan önce {need} döngü{plural} daha gerekiyor ({current}/{min}).", + "collecting_data": "Veri toplanıyor. İnce ayarın başlaması için gereken döngü sayısı: {need} ({current}/{min}).", "compare_overlay_profiles": "Profilleri üst üste bindirin (soluk)", "compare_profiles_tip": "Hangisinin bu döngüye en uygun olduğunu görmek için diğer profil zarflarını yukarıdaki tabloya yerleştirin.", "compare_selected_cycles": "Seçili döngüler (katı) – göster / gizle", @@ -779,7 +814,7 @@ "cycles_deleted": "{count} döngü silindi", "enough_data": "Öğrenmek için yeterli veri ({current}/{min} döngü).", "export_description": "JSON'a dışa aktarılacak profilleri, döngüleri, ayarları ve daha fazlasını tam olarak seçin ya da bir dosyayı analiz edip yalnızca istediğiniz kısımları içe aktarın.", - "feedback_cycles_pending": "İncelenecek {n} döngü{s}", + "feedback_cycles_pending": "İncelenecek: {n}", "feedback_prompt": "Doğru olduğunu onaylayın, programı düzeltin veya yoksayın.", "feedback_relabel_hint": "Bu döngüyü yeniden etiketlemek de bunu çözer.", "filter_by_profile": "Profile göre filtrele…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "bekleme simülasyonu burada başlar", "pg_sweep_intro": "{param} farklı olsaydı ne olurdu? En çok döngünün doğru şekilde eşleştirildiği ayarı bulmak için son {cycles} döngünüzde {steps} değeri test edin.", "pg_sweep_step": "Adım {done} / {total}", - "pg_undetected": "{n} döngü{s} algılanmadı", "pg_verdict_bad": "Dikkat gerekiyor: birçok döngü algılanmadan geçiyor.", "pg_verdict_good": "İyi ayarlanmış: çoğu döngü doğru şekilde tanımlanıp eşleştiriliyor.", "pg_verdict_ok": "Kabul edilebilir: bazı döngüler kaçırıldı. Başlangıç eşiğini düşürmeyi deneyin.", @@ -887,6 +921,7 @@ "review_recorded_tip": "Bunu, programı için özenle seçilmiş bir referans döngüsü olarak işaretleyin; manuel olarak kaydedilen bir döngüyle aynı rol. Referans döngüleri her zaman tutulur, eşleşen şablonun çekirdeği oluşturulur ve hiçbir zaman temizleme işlemi nedeniyle bırakılmaz. (Bu \"altın\"/kaydedilmiş bayraktır; ikisi de aynı şeydir.)", "review_tags_tip": "Bu döngüde neyin yanlış gittiğini açıklayan isteğe bağlı bayraklar, böylece eğitim ve temizlik bunu açıklayabilir.", "review_to_cycles": "Döngü inceleme kuyruğunu aç", + "samples_decimated": "{total} örnekten {shown} tanesi gösteriliyor (görüntü için seyreltildi; tepe noktaları korundu). Buradaki geniş bir boşluk seyreltmedir, eksik veri değildir.", "saving_triggers_reload": "Kaydetme bir entegrasyon yeniden yüklemesini tetikler. HA varlıkları kısa süreliğine kullanılamaz olarak görünebilir.", "search_placeholder": "Ayar ara…", "see_recorder": "Aşağıdaki kaydedici widget'ına bakın", @@ -1006,7 +1041,29 @@ "sug_mute_failed": "Öneri susturulamadı", "sug_unmuted_all": "Susturulan öneriler sıfırlandı", "n_suggestions_muted": "{count} susturuldu; öneri motoru bunları önermeyecek.", - "font_size_hint": "Bu paneldeki her şeyi büyütün veya küçültün. Bu cihazdaki hesabınıza uygulanır." + "font_size_hint": "Bu paneldeki her şeyi büyütün veya küçültün. Bu cihazdaki hesabınıza uygulanır.", + "import_history_description": "WashData'dan önce de akıllı bir priziniz var mıydı? Güç sensörünün geçmiş dışa aktarımını yükleyin veya doğrudan Home Assistant'tan okutun; normal algılama bunun üzerinde çalışır ve geçmiş döngüler adlandırılmaya hazır şekilde Döngüler listenizde görünür.", + "hist_input_hint": "Geçmiş panelinden indirdiğiniz bir CSV dosyasını yükleyin (entity, state, last changed) veya WashData'nın sensörün geçmişini doğrudan okumasına izin verin. Ardından algılama, tıpkı canlı çalışırken olduğu gibi bunun üzerinde çalışır ve bulunan döngülerden hangilerini saklayacağınızı siz seçersiniz.", + "hist_recorder_hint": "Seçtiğiniz tarihten şimdiye kadar okur. Home Assistant varsayılan olarak ayrıntılı geçmişi 10 gün tutar, sonrasında yalnızca saatlik ortalamaları saklar; bunlar döngü algılamak için fazla kabadır - yalnızca recorder daha uzun süre saklayacak şekilde ayarlıysa daha eski bir tarih seçin.", + "hist_scanning": "Geçmişiniz algılayıcı üzerinden yeniden oynatılıyor. Bu işlem arka planda çalışır - bu pencereyi kapatıp daha sonra geri dönebilirsiniz.", + "hist_imported_count": "{n} döngü içe aktarıldı.", + "hist_duplicates": "{n} tanesi daha önce içe aktarılmıştı ve atlandı.", + "hist_capped": "İçe aktarılan döngüler için cihaz başına sınıra ulaşıldı; kalanlar kaydedilmedi.", + "hist_next_step": "Döngüler listenizde, içe aktarılmış geçmiş olarak işaretli halde duruyorlar. Birini açın ve ait olduğu programı adlandırmak için Etiketle'yi kullanın.", + "hist_rows_read": "{n} okuma alındı", + "hist_entity_substituted": "{used} okundu (bu cihaz {wanted} için yapılandırılmış)", + "hist_breaks": "sensörün kullanılamadığı {n} boşluk", + "hist_other_entity": "diğer varlıklara ait {n} okuma yoksayıldı", + "hist_skipped_spans": "Atlanan aralıklar", + "hist_settings_used": "Bu cihazın mevcut ayarlarıyla algılandı (minimum güç {w} W, kapatma gecikmesi {s} s).", + "hist_none_found": "Bu geçmişte hiçbir döngü algılanamadı.", + "hist_found": "{n} döngü bulundu. Gerçek bir çalışma gibi görünmeyenlerin işaretini kaldırın - içe aktarana kadar hiçbir şey kaydedilmez.", + "hist_scan_capped": "Yalnızca ilk adaylar gösteriliyor (toplam {n} bulundu).", + "hist_recorder_empty": "Home Assistant'ta bu sensör için o aralıkta ayrıntılı geçmiş yok.", + "hist_scan_failed": "Tarama başarısız oldu.", + "hist_scan_expired": "Bu tarama artık kullanılamıyor. Lütfen yeniden tarayın.", + "hist_import_failed": "İçe aktarma başarısız oldu.", + "imported_history_readonly": "İçe aktarılan güç geçmişinde algılandı. Program eşleştirmesini etkiler ancak istatistiklerinize sayılmaz, kırpılamaz veya bölünemez. Programını adlandırmak için etiketleyin." }, "pg_desc": { "completion_min_seconds": "Gerçek döngü sayılan en kısa çalışma", @@ -1035,7 +1092,8 @@ "dtw_refine_top_n": "Aşama 3: DTW'nin yeniden puanladığı aday sayısı; doğru profil 4-5. sıraya düşüyorsa 7-9'a yükseltin (varsayılan 5)", "duration_scale": "Aşama 4: süre uyumunun yarılandığı logaritmik oran; küçük = daha sert ceza (varsayılan 0.175)", "energy_scale": "Aşama 4: enerji uyumunun yarılandığı logaritmik oran; küçük = daha sert ceza (varsayılan 0.25)", - "dishwasher_end_spike_quiet_release": "Bulaşık makinesi: beklenen süreden sonra döngü sonu tahliye beklemesi bırakılmadan önceki sessiz saniyeler" + "dishwasher_end_spike_quiet_release": "Bulaşık makinesi: beklenen süreden sonra döngü sonu tahliye beklemesi bırakılmadan önceki sessiz saniyeler", + "smart_termination_duration_ratio": "Eşleşen programın beklenen süresinin, Akıllı Sonlandırma'nın döngüyü erken bitirebilmesi için döngünün ulaşması gereken kesri; yüke veya sıcaklığa bağlı makinelerde düşürün" }, "phase_desc": { "anti_crease": "Kırışıklıkları azaltmak için tamamlandıktan sonra ara sıra kısa yuvarlanmalar.", @@ -1390,7 +1448,7 @@ "label": "Min. Süre Oranı" }, "profile_match_threshold": { - "doc": "Bir program tanımlamasını kabul etmek için döngü sonunda gereken minimum benzerlik puanı (0-1). Yanlış tanımlamaları azaltmak için kaldırın; makinenizin programları eşleşmiyorsa düşürün. Varsayılan 0,4 ihtiyatlı bir başlangıç ​​noktasıdır.", + "doc": "Bir program tanımlamasını kabul etmek için döngü sonunda gereken minimum benzerlik puanı (0-1). Yanlış tanımlamaları azaltmak için kaldırın; makinenizin programları eşleşmiyorsa düşürün. Varsayılan 0,4 ihtiyatlı bir başlangıç noktasıdır.", "label": "Eşleşme Eşiği" }, "profile_unmatch_threshold": { @@ -1417,6 +1475,10 @@ "doc": "Her döngü için tam güç takibini ve eşleşen hata ayıklama verilerini saklayın. Sorun giderme için kullanışlıdır ancak depolama boyutunu artırır.", "label": "Hata Ayıklama İzlerini Kaydet" }, + "smart_termination_duration_ratio": { + "doc": "Güç düştüğünde Akıllı Sonlandırma'nın bir döngüyü erken bitirebilmesi için, döngünün eşleşen programın beklenen süresinin ne kadarına ilerlemiş olması gerektiğini belirler. Beklenen süre, programın ortalamasıdır; bu nedenle çalışma süresi çok değişen cihazlarda (kışın soğuk, yazın sıcak şebeke suyuyla çalışan çamaşır makineleri, sensörle kurutan kurutucular, yüke bağlı programlar) tüm çalışmaların yaklaşık yarısı bu ortalamadan daha kısa biter ve hızlı bitişi hiç alamaz, yalnızca yedek zaman aşımıyla dakikalar sonra sona erer. Bu makinelerde erken bitişin yine de devreye girmesi için bu değeri düşürün (örn. 0,85); daha ihtiyatlı olmak için 1,0'a doğru yükseltin. Varsayılan (0,98, bulaşık makineleri için 0,99) için boş bırakın. Bir döngüyü yalnızca daha erken bitirebilir, asla daha geç bitiremez ve belirsiz veya düşük güvenilirlikli bir eşleşmede asla devreye girmez.", + "label": "Akıllı Sonlandırma Oranı" + }, "smoothing_window": { "doc": "Ham güç sinyalinin ne kadar yumuşatıldığı. Düşük (2) duyarlıdır ancak gürültülüdür; yüksek (5) ani artışları yumuşatır ancak gecikme ekler.", "label": "Yumuşatma Penceresi" @@ -1434,7 +1496,7 @@ "label": "Başlangıç Eşiği" }, "stop_threshold_w": { - "doc": "Düşmede gecikme geri sayımı başlamadan önce güç bu seviyenin altına düşmelidir. Bunu Başlangıç ​​Eşiğinin altına ayarlayın; aralarındaki boşluk, titremeyi önleyen histerezis bandıdır. Çok yükseğe ayarlanırsa, düşük güçlü fazlar (durulamada bekletme, kırışık önleme) hatalı şekilde bitiş sırasını tetikler.", + "doc": "Düşmede gecikme geri sayımı başlamadan önce güç bu seviyenin altına düşmelidir. Bunu Başlangıç Eşiğinin altına ayarlayın; aralarındaki boşluk, titremeyi önleyen histerezis bandıdır. Çok yükseğe ayarlanırsa, düşük güçlü fazlar (durulamada bekletme, kırışık önleme) hatalı şekilde bitiş sırasını tetikler.", "label": "Durdurma Eşiği" }, "switch_entity": { @@ -1551,6 +1613,10 @@ "dishwasher_end_spike_quiet_release": { "label": "Pasif Kurutma Sessiz Serbest Bırakma", "doc": "Döngü beklenen süresini aştıktan sonra, WashData bir son tahliye beklemeyi bırakıp döngüyü sonlandırmadan önce bulaşık makinesinin ne kadar süre sessiz (Durdurma Eşiği'nin altında) kalması gerektiğidir. Makineniz, gecikmeli bir son tahliyeden önce uzun ve sessiz bir kurutma aşamasına sahipse ve bu tahliye kaçırılıyorsa değeri artırın - daha geniş bir pencere, öğrenilen sürenin eski ortalamaya kilitlenmek yerine mevsimsel kaymayı (daha soğuk şebeke suyu = daha uzun döngüler) izlemesini sağlar. Bu ayar, dahili 30 dakikalık son tahliye tepesi sınırına göre beklemeyi yalnızca kısaltabilir, asla uzatamaz." + }, + "profile_evidence_sources": { + "label": "Bir programı şekillendiren döngüler", + "doc": "Her programın güç eğrisini oluşturmak ve tamamlanmış bir döngüyü onunla eşleştirmek için hangi döngülerin kullanılacağı. Bir türün işaretini kaldırmak, hiçbir şey silmeden onun programlarınızı şekillendirmesini durdurur - döngüler Döngüler listenizde kalır ve yine etiketlenebilir veya kaldırılabilir. İçe aktarılan verilere güvenmiyorsanız kullanışlıdır. İstatistikler etkilenmez: her zaman yalnızca bu makinenin gerçekten çalıştırdığı döngüleri sayarlar. Her şeyin işaretini kaldırmak yok sayılır, çünkü arkasında hiç döngü olmayan bir program asla eşleşemez." } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "Temel yapılandırma" + }, + "profile_evidence": { + "label": "Profil Kaynakları" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "Kırpılıyor…", "splitting": "Bölünüyor…", "deleting": "Siliniyor…", - "imported": "İçe aktarıldı" + "imported": "İçe aktarıldı", + "preparing": "Hazırlanıyor…" }, "tab": { "advanced": "Gelişmiş", @@ -1688,6 +1758,7 @@ "wrong_profile": "Yanlış profil" }, "toast": { + "catalog_refreshed": "Topluluk kataloğu yenilendi", "access_saved": "Erişim denetimi kaydedildi", "all_wiped": "Tüm veriler silindi", "analysis_complete_none": "Analiz tamamlandı: yeni öneri yok", @@ -1793,7 +1864,9 @@ "store_download_failed": "İndirme başarısız: {error}", "store_download_nothing": "İndirilecek yeni bir şey yok - bu kurulum zaten cihazınızda.", "export_selective_done": "Dışa aktarma indirildi", - "import_selective_done": "{profiles} profil ve {cycles} döngü içe aktarıldı" + "import_selective_done": "{profiles} profil ve {cycles} döngü içe aktarıldı", + "hist_csv_required": "Önce bir CSV dosyası yükleyin veya içeriğini yapıştırın", + "file_read_failed": "Bu dosya okunamadı" }, "suggestion": { "both_agree": "WashData öneriyor", @@ -1889,7 +1962,7 @@ "thr_batch": "{cycles} çevrim boyunca p05 en düşük aktif gücün ({p05}W) hemen üzerinde tutuldu; böylece başlangıç olabildiğince erken yakalanır ve durdurma eşiği makinenin en düşük çalışma gücünün altında kalır.", "tol_per_profile": "{profiles} profil boyunca ({cycles} çevrim) profil başına süre varyansının p75'i; tutarlı profiller cezalandırılmaz.", "tol_pooled": "{cycles} son etiketli çevrimin birleştirilmiş süre varyansına dayalı (p95 sapma={dev}).", - "watchdog": "Güvenli olduğu kadar düşük tutuldu ({p95}s'lik p95 güncelleme boşluğunun hemen üzerinde, en az 30s); böylece takılmalar yanlış durdurmalar olmadan hızlıca yakalanır." + "watchdog": "Güvenli olduğu kadar düşük tutuldu (p95 güncelleme boşluğu {p95}s değerinin hemen üzerinde ve örnekleme aralığı {median}s değerinin en az 2 katı, en az 30s); böylece takılmalar yanlış durdurmalar olmadan hızlıca yakalanır." }, "exclusions": { "summary": "{total} yanlış algılanan döngü hariç tutuldu: {parts}.", @@ -1955,6 +2028,10 @@ "finished": "Döngü, son duruma ulaştı ve sona erdi." }, "store": { + "your_model_tip": "Bu, Ayarlar'da belirttiğiniz cihazdır", + "your_model": "Sizinki", + "search_brand_ph": "Markaya göre ara…", + "programs_count": "Programlar: {n}", "browse": "Göz at", "device": "Cihaz", "favorites": "Favoriler", diff --git a/custom_components/ha_washdata/translations/panel/uk.json b/custom_components/ha_washdata/translations/panel/uk.json index 37a83e69..25b4f19e 100644 --- a/custom_components/ha_washdata/translations/panel/uk.json +++ b/custom_components/ha_washdata/translations/panel/uk.json @@ -78,9 +78,12 @@ "awaiting": "Очікує схвалення", "imported_tip": "Імпортовано з магазину спільноти. Використовується лише для зіставлення, не враховується у статистиці.", "not_importable": "недоступно", - "exists": "існує" + "exists": "існує", + "backfilled_tip": "Виявлено в імпортованій історії потужності. Впливає лише на зіставлення програм, не враховується у статистиці." }, "btn": { + "set_brand_model": "Вказати бренд і модель", + "refresh_catalog": "Оновити каталог", "add_device": "+ Додати пристрій", "add_device_tip": "Додати інший пристрій WashData", "add_maintenance": "Додати запис обслуговування", @@ -234,14 +237,19 @@ "download_device": "Завантажити конфігурацію пристрою", "share_device": "Поділитися конфігурацією пристрою", "share_device_tip": "Поділитися програмами та налаштуваннями цього пристрою зі спільнотою", - "share_n": "Поділитися: {n} програм{s}", + "share_n": "Поділитися циклами: {n}", "export_selected": "Експорт (вибрати дані)", "export_all": "Швидкий експорт усього", "import_raw": "Розширено: замінити все з JSON", "download_export": "Завантажити експорт", "analyze_import": "Проаналізувати файл", "import_selected": "Імпортувати вибране", - "back": "Назад" + "back": "Назад", + "import_power_history": "Імпорт історії потужності", + "hist_read_recorder": "Зчитати з Home Assistant", + "hist_scan": "Знайти цикли", + "hist_import_n": "Імпортувати цикли ({n})", + "hist_goto_cycles": "Показати цикли" }, "conflict": { "anti_wrinkle_exit": { @@ -253,14 +261,14 @@ "start": "Має бути нижчим за Макс. Потужність Захисту від Зминання ({max} Вт)" }, "attn_sub": "Виправте конфлікти перед збереженням", - "attn_title": "{n} конфлікт{s} налаштувань", - "settings_banner": "{n} конфлікт{s} налаштувань – перевірте виділені розділи й виправте перед збереженням.", + "attn_title": "Конфлікти налаштувань: {n}", + "settings_banner": "Конфлікти налаштувань: {n}. Перевірте виділені розділи та виправте їх перед збереженням.", "settings_banner_btn": "До першого", "confidence": { "auto": "Має бути не нижчим за Поріг Зіставлення ({match})", - "learning": "Має бути не вищим за Поріг Зіставлення ({match})", + "learning": "Має бути не нижчим за Поріг Зіставлення ({match})", "match_for_auto": "Має бути не вищим за Впевненість Автоматичного Маркування ({alc})", - "match_for_learning": "Має бути не нижчим за Впевненість Навчання ({lc})" + "match_for_learning": "Має бути не вищим за Впевненість Навчання ({lc})" }, "duration_ratio": { "max": "Має бути більшим за Мін. Коефіцієнт Тривалості ({min})", @@ -298,7 +306,7 @@ "match": "Має бути вищим за Поріг Незіставлення ({un})", "unmatch": "Має бути нижчим за Поріг Зіставлення ({match}); інакше підтверджений збіг одразу скасовується" }, - "cascade_toast": "Для узгодженості також автоматично скориговано ще {n} параметр{s}.", + "cascade_toast": "Інші налаштування змінено для узгодженості: {n}", "suggestion_resolves": "Активуйте очікувану пропозицію ({val}) нижче, щоб виправити це", "use_fix": "Використати {val}", "watchdog": { @@ -356,7 +364,8 @@ "pg_outcome": "Результат симуляції", "pg_across_cycles": "За всіма вашими циклами", "community_store": "Магазин спільноти", - "online_account": "Магазин спільноти та онлайн-функції" + "online_account": "Магазин спільноти та онлайн-функції", + "import_power_history": "Імпорт історії потужності" }, "health": { "fair": "Прийнятна якість профілю", @@ -364,6 +373,7 @@ "poor": "⚠ Погана якість профілю" }, "lbl": { + "drag_to_resize": "Перетягніть, щоб змінити розмір", "actions": "Дії", "activity": "Активність", "administrators": "Адміністратори", @@ -451,7 +461,7 @@ "metric": "Метрика", "mode_existing_profile": "Додати до наявного профілю", "mode_new_profile": "Створити новий профіль", - "models_fine_tuned": "({count} модель{plural} дотреновано)", + "models_fine_tuned": "(дотреновані моделі: {count})", "n_classic_suggestions": "{n} класичне", "n_ml_suggestions": "{n} ML", "n_selected": "Вибрано {n}", @@ -677,7 +687,26 @@ "conflict_resolution": "Конфлікти імен", "conflict_import_copy": "Імпортувати як копію", "conflict_keep_mine": "Зберегти мої", - "conflict_overwrite": "Перезаписати" + "conflict_overwrite": "Перезаписати", + "hist_csv_data": "Дані CSV", + "hist_from_recorder": "Або зчитати з Home Assistant", + "days": "дн.", + "hist_keep": "Зберегти цей цикл", + "hist_looks_complete": "завершений", + "peak_power_short": "Пік", + "shape": "Форма", + "hist_skip_idle": "нічого не працювало", + "hist_skip_sparse": "показання надто рідкі", + "hist_skip_short": "надто мало показань", + "hist_skip_long": "немає достатньо довгої паузи для розділення", + "hist_reason_short": "коротший за найкоротший справжній цикл цього приладу", + "hist_reason_no_end": "так і не завершився коректно", + "hist_since": "Починаючи з", + "task_history_import": "Сканування історії потужності", + "task_history_import_apply": "Імпорт циклів", + "evidence_real_cycles": "Цикли, виконані цим приладом", + "evidence_reference_cycles": "Завантажені з магазину спільноти", + "evidence_backfill_cycles": "Знайдені в імпортованій історії потужності" }, "log": { "all_levels": "Усі рівні", @@ -756,9 +785,15 @@ "store_share": "Поділитися в магазині спільноти", "store_share_device": "Поділитися конфігурацією пристрою", "export_select": "Експорт - вибір даних", - "import_wizard": "Імпорт - вибір даних" + "import_wizard": "Імпорт - вибір даних", + "history_import": "Імпорт історії потужності" }, "msg": { + "tail_trim_hint": "Скільки секунд обрізати з кінця", + "store_sibling_hint": "Для вашої точної моделі нічого не опубліковано? Дуже схожа модель того самого бренду зазвичай є доброю відправною точкою.", + "store_declare_appliance": "Вкажіть WashData, який у вас пристрій, і на цій вкладці з'являться конфігурації, опубліковані для нього іншими користувачами. Можна також ввести бренд вище і просто подивитися, що є.", + "refresh_catalog_hint": "Списки брендів і пристроїв спільноти кешуються, щоб спільний магазин не перевищував свій денний ліміт запитів. Оновіть, щоб отримати записи, додані або схвалені іншими.", + "head_trim_hint": "Скільки секунд обрізати з початку", "appliance_monitor": "Монітор приладу", "artifact_dip_detail": "Опустився нижче звичайної смуги потужності приблизно на ~{n} с.", "artifact_footer": "Виділено на графіку вище. Це тимчасові артефакти (наприклад, двері відчинені посередині циклу), не обов'язково проблеми.", @@ -769,7 +804,7 @@ "automations_intro": "WashData запускає події {start} / {end} та надає об'єкти, тому сповіщення й дії найкраще будувати як звичайні автоматизації Home Assistant. Автоматизації, що використовують цей пристрій, відображаються нижче.", "cleanup_intro": "Усі позначені цикли накладено. Позначте аномалії і видаліть їх для очищення профілю.", "clear_debug_hint": "Видаліть збережені дані налагодження, щоб звільнити місце.", - "collecting_data": "Збір даних – потрібно ще {need} цикл{plural} до початку точного налаштування ({current}/{min}).", + "collecting_data": "Збір даних. Залишилося циклів до початку точного налаштування: {need} ({current}/{min}).", "compare_overlay_profiles": "Накласти профілі (блідо)", "compare_profiles_tip": "Накладіть інші конверти профілів на таблицю вище, щоб побачити, який з них найкраще підходить для цього циклу.", "compare_selected_cycles": "Вибрані цикли (суцільно) – показати / приховати", @@ -779,7 +814,7 @@ "cycles_deleted": "Видалено циклів: {count}", "enough_data": "Достатньо даних для навчання ({current}/{min} циклів).", "export_description": "Виберіть, які саме профілі, цикли, налаштування та інше експортувати в JSON, або проаналізуйте файл та імпортуйте лише потрібні частини.", - "feedback_cycles_pending": "{n} цикл{s} для перегляду", + "feedback_cycles_pending": "На перегляд: {n}", "feedback_prompt": "Підтвердьте правильність, виправте програму або проігноруйте.", "feedback_relabel_hint": "Повторне маркування цього циклу також вирішує його.", "filter_by_profile": "Фільтр за профілем…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "тут починається симуляція режиму очікування", "pg_sweep_intro": "Що якби {param} було іншим? Перевірте {steps} значень на ваших останніх {cycles} циклах, щоб знайти налаштування, за якого правильно зіставляється найбільше циклів.", "pg_sweep_step": "Крок {done} / {total}", - "pg_undetected": "{n} цикл{s} не виявлено", "pg_verdict_bad": "Потребує уваги: багато циклів залишаються невиявленими.", "pg_verdict_good": "Добре налаштовано: більшість циклів правильно визначаються та зіставляються.", "pg_verdict_ok": "Прийнятно: деякі цикли пропущено. Спробуйте знизити поріг запуску.", @@ -888,6 +922,7 @@ "review_recorded_tip": "Позначте це як підібраний вручну еталонний цикл для своєї програми – така ж роль, як цикл, записаний вручну. Довідкові цикли завжди зберігаються, заповнюють відповідний шаблон і ніколи не відкидаються під час очищення. (Це «золотий»/записаний прапор; обидва – одне й те саме.)", "review_tags_tip": "Необов'язкові прапорці, що описують, що пішло не так у цьому циклі, щоб навчання та очищення могли це врахувати.", "review_to_cycles": "Відкрити чергу огляду циклів", + "samples_decimated": "Показано {shown} із {total} відліків (проріджено для відображення; піки збережено). Широкий розрив тут - це проріджування, а не пропуск даних.", "saving_triggers_reload": "Збереження запускає перезавантаження інтеграції. Об'єкти HA можуть короткочасно відображатися як недоступні.", "search_placeholder": "Пошук налаштувань…", "see_recorder": "Дивіться віджет записувача нижче", @@ -991,12 +1026,12 @@ "share_consent": "Ви ділитеся реальними даними вашого приладу. Не діліться, якщо ваші шаблони використання є приватними.", "share_device_none": "Пристрої ще не налаштовані. Спочатку додайте пристрій.", "share_guideline_naming": "Використовуйте зрозумілі назви програм (наприклад, 'Cotton 40', 'Eco 60'), щоб інші могли їх ідентифікувати", - "share_guideline_quality": "Діліться тільки профілями з ⭐ еталонними циклами або щонайменше {n} підтвердженими запусками", + "share_guideline_quality": "Діліться лише циклами, які завершилися нормально: без перерв посеред циклу, відкривання дверцят або короткочасних збоїв живлення.", "share_guideline_review": "Перегляньте профілі перед публікацією -- видаліть ті, що виглядають неправильно", "share_guidelines_title": "Перед публікацією", "store_download_device_intro": "Завантажити конфігурацію пристрою зі спільноти та застосувати її до нового або наявного пристрою", - "store_share_device_intro": "Поділитися програмами вашого пристрою (профілі + еталонні цикли) зі спільнотою. Налаштування необов'язкові.", - "share_profile_no_cycles": "Профіль '{p}' не має ⭐ еталонних циклів -- його буде пропущено, якщо у вас немає {n}+ підтверджених запусків", + "store_share_device_intro": "Завантажте {brand} {model} з вибраними вами еталонними циклами. Інші користувачі з таким самим пристроєм зможуть прийняти ваші програми. Записи перевіряються перед публікацією.", + "share_profile_no_cycles": "Немає еталонних циклів. Щоб включити цей профіль, позначте цикл як ⭐ на вкладці Цикли", "advisory_phase_inconsistent": "Схоже, що '{name}' поєднує різні програми або температури - його цикли нагріваються протягом дуже різного часу. Розділення на окремі профілі (напр. за температурою) покращить зіставлення та оцінки часу.", "advisory_phase_inconsistent_title": "⚠ Можливо, змішані програми", "export_select_intro": "Позначте, що саме включити. Вибір профілів без їхніх циклів усе одно експортує розпізнавану програму (її вивчена форма передається разом із нею).", @@ -1006,7 +1041,29 @@ "merge_hint": "Імпортовані елементи додаються; нічого локального не втрачається. Конфлікти імен вирішуються нижче.", "replace_warn": "Кожна позначена категорія очищується та замінюється даними з файлу. Непозначені категорії залишаються без змін.", "dest_reference_hint": "Імпортовані цикли лише покращують розпізнавання програм і ніколи не впливають на статистику використання/енергії.", - "dest_real_history_hint": "Імпортовані цикли зараховуються як власна історія цього пристрою та враховуються в статистиці енергії/використання. Використовуйте для перенесення одного приладу на нове встановлення." + "dest_real_history_hint": "Імпортовані цикли зараховуються як власна історія цього пристрою та враховуються в статистиці енергії/використання. Використовуйте для перенесення одного приладу на нове встановлення.", + "import_history_description": "Розумна розетка була у вас ще до WashData? Завантажте експорт історії її датчика потужності або зчитайте її прямо з Home Assistant, і звичайне виявлення пройде цими даними, тож минулі цикли з'являться у списку «Цикли», готові до позначення.", + "hist_input_hint": "Завантажте CSV, отриманий на панелі «Історія» (сутність, стан, час зміни), або дозвольте WashData зчитати історію датчика напряму. Далі виявлення пройде цими даними так само, як у реальному часі, а ви виберете, які зі знайдених циклів зберегти.", + "hist_recorder_hint": "Читає дані від вибраної дати до цього моменту. Типово Home Assistant зберігає докладну історію 10 днів, а після цього лише погодинні середні значення, які надто грубі для виявлення циклів - вибирайте давнішу дату лише тоді, коли в налаштуваннях recorder задано довший термін зберігання.", + "hist_scanning": "Ваша історія відтворюється через детектор. Це виконується у фоні - можна закрити це вікно та повернутися пізніше.", + "hist_imported_count": "Імпортовано циклів: {n}.", + "hist_duplicates": "Уже імпортовано раніше та пропущено: {n}.", + "hist_capped": "Досягнуто ліміту імпортованих циклів для пристрою; решту не збережено.", + "hist_next_step": "Вони у списку «Цикли» з позначкою імпортованої історії. Відкрийте цикл і натисніть «Позначити», щоб вказати його програму.", + "hist_rows_read": "Прочитано показань: {n}", + "hist_breaks": "Пропусків, де датчик був недоступний: {n}", + "hist_other_entity": "Показань інших сутностей пропущено: {n}", + "hist_entity_substituted": "Прочитано {used} (для цього пристрою налаштовано {wanted})", + "hist_skipped_spans": "Пропущені ділянки", + "hist_settings_used": "Виявлено з поточними налаштуваннями цього пристрою (Мінімальна потужність {w} Вт, Затримка вимкнення {s} с).", + "hist_none_found": "У цій історії не вдалося виявити жодного циклу.", + "hist_found": "Знайдено циклів: {n}. Зніміть позначки з того, що не схоже на справжній запуск - до імпорту нічого не зберігається.", + "hist_scan_capped": "Показано лише перших кандидатів (знайдено: {n}).", + "hist_recorder_empty": "У Home Assistant немає докладної історії для цього датчика за цей період.", + "hist_scan_failed": "Сканування не вдалося.", + "hist_scan_expired": "Це сканування вже недоступне. Виконайте сканування ще раз.", + "hist_import_failed": "Імпорт не вдався.", + "imported_history_readonly": "Виявлено в імпортованій історії потужності. Впливає на зіставлення програм, але не враховується у вашій статистиці; такий цикл не можна обрізати чи розділити. Натисніть «Позначити», щоб вказати програму." }, "phase_desc": { "anti_crease": "Час від часу короткі оберти після завершення для зменшення зморшок.", @@ -1471,6 +1528,10 @@ "show_contributor": { "doc": "Показати ім'я автора на профілях, завантажених з магазину спільноти" }, + "smart_termination_duration_ratio": { + "doc": "Наскільки глибоко в очікувану тривалість зіставленої програми має зайти цикл, перш ніж Розумне завершення зможе завершити його достроково після падіння потужності. Очікувана тривалість - це середнє значення програми, тому на приладах із дуже мінливим часом роботи - пральні машини за холодної зимової та теплої літньої води на вході, сушильні машини з датчиком вологості, програми, що залежать від завантаження - близько половини всіх запусків завершуються коротше за це середнє й ніколи не отримують швидкого завершення, закінчуючись лише за резервним тайм-аутом із запізненням на кілька хвилин. Зменшіть це значення (напр., 0.85) на таких машинах, щоб дострокове завершення все ж спрацьовувало; підвищуйте ближче до 1.0 для більшої обережності. Залиште порожнім для значення за замовчуванням (0.98 або 0.99 для посудомийних машин). Воно може лише завершити цикл раніше, але ніколи пізніше, і ніколи не спрацьовує за неоднозначного або недостатньо впевненого зіставлення.", + "label": "Коефіцієнт розумного завершення" + }, "enable_phase_matching": { "label": "Залишок часу з урахуванням фаз", "doc": "Розбиває кожен активний цикл на фази (нагрівання, прання, віджим) і розподіляє залишок часу за фазами, поєднуючи це з класичною оцінкою - спираючись на розподіл за фазами на початку циклу та на класичну оцінку ближче до кінця. Це персоналізує відлік під те, скільки насправді нагрівається та працює ваша машина, що найпомітніше в першій половині циклу. Вимкнено = лише класична оцінка. Впливає лише на відображення залишку часу; зіставлення програм і виявлення циклу залишаються незмінними." @@ -1522,6 +1583,10 @@ "door_end_dwell_seconds": { "label": "Витримка при відкритих дверцятах", "doc": "Як довго дверцята повинні залишатися відкритими до завершення циклу, коли увімкнено \"Дверцята відкриваються автоматично наприкінці\". Достатньо довго, щоб ігнорувати швидке додавання посуду (за замовчуванням 60 с), достатньо коротко для швидкого завершення після відкриття дверцят машиною." + }, + "profile_evidence_sources": { + "label": "Цикли, що формують програму", + "doc": "Які цикли беруть участь у побудові кривої потужності кожної програми та в зіставленні з нею завершеного циклу. Якщо зняти позначку з певного виду, він більше не формує ваші програми, але нічого не видаляється: цикли залишаються у списку Цикли, їх так само можна позначити або вилучити. Корисно, якщо ви не довіряєте імпортованим даним. На статистику це не впливає: у ній завжди враховуються лише цикли, які цей прилад справді виконав. Якщо зняти всі позначки, це буде проігноровано, адже програма без жодного циклу ніколи не могла б зіставитися." } }, "setting_group": { @@ -1605,6 +1670,9 @@ }, "basic_configuration": { "label": "Основні налаштування" + }, + "profile_evidence": { + "label": "Дані для профілю" } }, "status": { @@ -1629,7 +1697,8 @@ "trimming": "Обрізання…", "splitting": "Розділення…", "deleting": "Видалення…", - "imported": "Імпортовано" + "imported": "Імпортовано", + "preparing": "Підготовка…" }, "tab": { "advanced": "Розширені", @@ -1659,6 +1728,7 @@ "wrong_profile": "Неправильний профіль" }, "toast": { + "catalog_refreshed": "Каталог спільноти оновлено", "access_saved": "Контроль доступу збережено", "all_wiped": "Усі дані видалено", "analysis_complete_none": "Аналіз завершено: нових пропозицій немає", @@ -1752,19 +1822,21 @@ "rating_saved": "Оцінку якості збережено", "brand_added": "Бренд додано, очікує схвалення", "profile_added": "Профіль додано, очікує схвалення", - "saved_except_conflicts": "Налаштування збережені -- {n} налаштування{s} пропущено через конфлікти", + "saved_except_conflicts": "Збережено. Виправте виділені конфлікти, щоб зберегти решту.", "share_device_none_sel": "Виберіть принаймні одну програму для публікації", - "store_device_downloaded": "Конфігурацію пристрою завантажено: {created} профіль{c} створено, {dup} вже існувало", - "store_device_downloaded_phases": "Конфігурацію пристрою завантажено: {created} профіль{c} створено, {dup} вже існувало, карту фаз застосовано", - "store_device_downloaded_settings": "Конфігурацію пристрою завантажено: {created} профіль{c} створено, {dup} вже існувало, налаштування застосовано", - "store_device_shared": "Конфігурацію пристрою опубліковано: {n} програм{s} завантажено", - "store_device_shared_all_dup": "Нема нічого нового для публікації -- всі програми вже є в магазині", - "store_device_shared_partial": "Часткова публікація: {n} програм{s} завантажено, {failed} пропущено", - "store_device_shared_some_dup": "Конфігурацію пристрою опубліковано: {n} програм{s} завантажено ({dup} вже існувало)", + "store_device_downloaded": "Додано програм: {p}, записів: {c}", + "store_device_downloaded_phases": "Додано програм: {p}, записів: {c}, карт фаз: {ph}", + "store_device_downloaded_settings": "Додано програм: {p}, записів: {c}, карт фаз: {ph}, налаштувань: {s}", + "store_device_shared": "Опубліковано в магазині спільноти (циклів: {n}), очікує перевірки.", + "store_device_shared_all_dup": "Усі вибрані цикли ({n}) уже були в магазині спільноти.", + "store_device_shared_partial": "Опубліковано циклів: {n}; не вдалося завантажити: {failed}.", + "store_device_shared_some_dup": "Опубліковано циклів: {created}; уже в магазині: {dup}.", "store_download_failed": "Помилка завантаження: {error}", "store_download_nothing": "Нема що завантажувати -- всі профілі вже існують на цьому пристрої", "export_selective_done": "Експорт завантажено", - "import_selective_done": "Імпортовано профілів: {profiles}, циклів: {cycles}" + "import_selective_done": "Імпортовано профілів: {profiles}, циклів: {cycles}", + "hist_csv_required": "Спочатку завантажте файл CSV або вставте його вміст", + "file_read_failed": "Не вдалося прочитати цей файл" }, "suggestion": { "both_agree": "WashData рекомендує", @@ -1860,7 +1932,7 @@ "thr_batch": "Залишено трохи вище найменшої активної потужності p05 за {cycles} циклами ({p05}W), щоб запуск фіксувався якомога раніше, а поріг зупинки залишався нижчим за мінімальну робочу потужність машини.", "tol_per_profile": "p75 розкиду тривалості за профілями серед {profiles} профілів ({cycles} циклів); стабільні профілі не штрафуються.", "tol_pooled": "На основі об'єднаного розкиду тривалості {cycles} нещодавніх розмічених циклів (відхилення p95={dev}).", - "watchdog": "Залишено настільки низьким, наскільки це безпечно (трохи вище інтервалу оновлення p95, що дорівнює {p95}s, мін. 30s), щоб зависання виявлялися швидко без хибних зупинок." + "watchdog": "Залишено настільки низьким, наскільки це безпечно (трохи вище інтервалу оновлення p95, що дорівнює {p95}s, і щонайменше 2x інтервалу вибірки {median}s, мін. 30s), щоб зависання виявлялися швидко без хибних зупинок." }, "exclusions": { "summary": "Виключено {total} помилково виявлених циклів: {parts}.", @@ -1892,6 +1964,7 @@ }, "pg_desc": { "dishwasher_end_spike_quiet_release": "Посудомийна машина: секунди неактивності після очікуваної тривалості перед зняттям очікування фінального зливу в кінці циклу", + "smart_termination_duration_ratio": "Частка очікуваної тривалості зіставленої програми, якої має досягти цикл, перш ніж Розумне завершення зможе завершити його достроково; зменшіть її для машин, що залежать від завантаження або температури", "completion_min_seconds": "Найкоротший запуск, що вважається справжнім циклом", "end_repeat_count": "Скільки низьких показань поспіль до завершення", "interrupted_min_seconds": "Короткі цикли позначаються як перервані", @@ -1955,6 +2028,10 @@ "finished": "Цикл досяг кінцевого стану й завершився." }, "store": { + "your_model_tip": "Це пристрій, який ви вказали в налаштуваннях", + "your_model": "Ваш", + "search_brand_ph": "Пошук за брендом…", + "programs_count": "Програми: {n}", "browse": "Огляд", "device": "Пристрій", "favorites": "Обране", diff --git a/custom_components/ha_washdata/translations/panel/zh-Hans.json b/custom_components/ha_washdata/translations/panel/zh-Hans.json index 17b45603..23b9ab0d 100644 --- a/custom_components/ha_washdata/translations/panel/zh-Hans.json +++ b/custom_components/ha_washdata/translations/panel/zh-Hans.json @@ -78,9 +78,12 @@ "awaiting": "等待批准", "imported_tip": "从社区商店导入。仅用于匹配,不计入统计。", "not_importable": "此处不适用", - "exists": "已存在" + "exists": "已存在", + "backfilled_tip": "在导入的功率历史中检测到。仅影响程序匹配,不计入统计。" }, "btn": { + "set_brand_model": "设置品牌和型号", + "refresh_catalog": "刷新目录", "add_device": "+ 添加设备", "add_device_tip": "添加另一个 WashData 设备", "add_maintenance": "添加维护事件", @@ -241,7 +244,12 @@ "import_selected": "导入所选", "back": "返回", "mute_suggestion": "停止建议此设置", - "reset_muted": "重置已屏蔽" + "reset_muted": "重置已屏蔽", + "import_power_history": "导入功率历史", + "hist_read_recorder": "从 Home Assistant 读取", + "hist_scan": "扫描周期", + "hist_import_n": "导入 {n} 个周期", + "hist_goto_cycles": "查看这些周期" }, "conflict": { "anti_wrinkle_exit": { @@ -253,12 +261,12 @@ "start": "必须低于防皱最大功率 ({max} W)" }, "attn_sub": "保存前请解决冲突", - "attn_title": "{n} 个设置冲突{s}", + "attn_title": "设置冲突:{n}", "confidence": { "auto": "必须不低于匹配阈值 ({match})", - "learning": "必须不超过匹配阈值 ({match})", + "learning": "必须不低于匹配阈值 ({match})", "match_for_auto": "必须不超过自动标记置信度 ({alc})", - "match_for_learning": "必须不低于学习置信度 ({lc})" + "match_for_learning": "必须不超过学习置信度 ({lc})" }, "duration_ratio": { "max": "必须大于最小时长比例 ({min})", @@ -296,14 +304,14 @@ "match": "必须高于不匹配阈值 ({un})", "unmatch": "必须低于匹配阈值 ({match});否则已确认的匹配会立即取消" }, - "cascade_toast": "为保持一致性,还自动调整了 {n} 项设置{s}。", + "cascade_toast": "为保持一致性而调整的其他设置:{n}", "suggestion_resolves": "应用下方的待处理建议({val})以修复此问题", "use_fix": "使用 {val}", "watchdog": { "interval": "应至少为采样间隔 ({si} 秒) 的 2 倍", "sampling": "采样间隔应最多为看门狗间隔 ({wi} 秒) 的一半" }, - "settings_banner": "{n} 个设置冲突{s} – 请检查高亮显示的部分,并在保存前修复。", + "settings_banner": "设置冲突:{n}。请检查高亮显示的部分,并在保存前修复。", "settings_banner_btn": "前往第一处" }, "hdr": { @@ -356,7 +364,8 @@ "pg_outcome": "模拟结果", "pg_across_cycles": "所有周期综合", "community_store": "社区商店", - "online_account": "社区商店与在线功能" + "online_account": "社区商店与在线功能", + "import_power_history": "导入功率历史" }, "health": { "fair": "配置文件质量尚可", @@ -364,6 +373,7 @@ "poor": "⚠ 配置文件质量差" }, "lbl": { + "drag_to_resize": "拖动以调整大小", "actions": "操作", "activity": "活动", "administrators": "管理员", @@ -434,7 +444,7 @@ "from": "起始", "gap_s": "间隔(秒)", "group_name": "组名称", - "head_trim": "头部裁剪(秒)", + "head_trim": "开头裁剪(秒)", "health": "健康", "hide_tabs": "对非管理员隐藏标签", "in_use": "使用中", @@ -450,7 +460,7 @@ "metric": "指标", "mode_existing_profile": "添加到现有配置文件", "mode_new_profile": "创建新配置文件", - "models_fine_tuned": "({count}个模型{plural}已微调)", + "models_fine_tuned": "(已微调的模型:{count})", "n_classic_suggestions": "{n} 经典", "n_ml_suggestions": "{n} ML", "n_selected": "已选 {n} 个", @@ -532,7 +542,7 @@ "stage3": "阶段 3 – DTW", "stage4": "阶段 4 – 一致度", "status": "状态", - "tail_trim": "尾部裁剪(秒)", + "tail_trim": "末尾裁剪(秒)", "timer_auto_pause": "自动暂停", "timer_min": "分钟", "timer_msg_placeholder": "消息(可选,{device}/{program}/{minutes})", @@ -677,7 +687,26 @@ "conflict_import_copy": "作为副本导入", "conflict_keep_mine": "保留我的", "conflict_overwrite": "覆盖", - "font_size": "面板字体大小" + "font_size": "面板字体大小", + "hist_csv_data": "CSV 数据", + "hist_from_recorder": "或从 Home Assistant 读取", + "hist_since": "起始日期", + "days": "天", + "hist_keep": "保留此周期", + "hist_looks_complete": "完整", + "peak_power_short": "峰值", + "shape": "形状", + "hist_skip_idle": "没有运行", + "hist_skip_sparse": "读数间隔过大", + "hist_skip_short": "读数太少", + "hist_skip_long": "没有足够长的间断可用于切分", + "hist_reason_short": "比该设备最短的真实周期还短", + "hist_reason_no_end": "从未正常结束", + "task_history_import": "扫描功率历史", + "task_history_import_apply": "导入周期", + "evidence_real_cycles": "本机实际运行的周期", + "evidence_reference_cycles": "从社区商店下载", + "evidence_backfill_cycles": "在导入的功率历史中发现" }, "log": { "all_levels": "所有级别", @@ -756,9 +785,15 @@ "store_share": "分享到社区商店", "store_share_device": "分享此设备", "export_select": "导出 - 选择数据", - "import_wizard": "导入 - 选择数据" + "import_wizard": "导入 - 选择数据", + "history_import": "导入功率历史" }, "msg": { + "tail_trim_hint": "从末尾移除的秒数", + "store_sibling_hint": "没有与您型号完全一致的分享内容?同品牌中相近的型号通常是不错的起点。", + "store_declare_appliance": "告知 WashData 您拥有哪台家电,此标签页便会显示其他用户为它分享的程序和录制。您也可以在上方输入品牌名称随意浏览。", + "refresh_catalog_hint": "社区品牌和家电列表已缓存,以便共享商店保持在每日读取额度内。刷新可获取他人新增或已批准的条目。", + "head_trim_hint": "从开头移除的秒数", "appliance_monitor": "设备监控", "artifact_dip_detail": "跌破通常功率范围约 {n} 秒。", "artifact_footer": "在上图中高亮显示。这些是瞬态异常(例如门在周期中打开),不一定是问题。", @@ -769,7 +804,7 @@ "automations_intro": "WashData 触发 {start} / {end} 事件并公开实体,因此通知和操作最好构建为常规 Home Assistant 自动化。使用此设备的自动化显示在下方。", "cleanup_intro": "显示所有已标记周期的叠加。勾选离群值并删除以清理配置文件。", "clear_debug_hint": "删除存储的调试数据以释放空间。", - "collecting_data": "正在收集数据,还需要 {need} 个周期{plural}才能开始微调({current}/{min})。", + "collecting_data": "正在收集数据。开始微调前还需要的周期数:{need}({current}/{min})。", "compare_overlay_profiles": "叠加配置文件(浅色)", "compare_profiles_tip": "将其他配置文件包络叠加在上方图表上,看哪个最适合此周期。", "compare_selected_cycles": "选中的周期(实线)– 显示/隐藏", @@ -779,7 +814,7 @@ "cycles_deleted": "已删除 {count} 个周期", "enough_data": "有足够数据可供学习({current}/{min} 个循环)。", "export_description": "精确选择要导出到 JSON 的配置文件、周期、设置等,或分析文件并仅导入所需的部分。", - "feedback_cycles_pending": "{n} 个周期{s}待审查", + "feedback_cycles_pending": "待审查:{n}", "feedback_prompt": "确认正确、修正程序或忽略。", "feedback_relabel_hint": "重新标记此周期也会解决它。", "filter_by_profile": "按配置文件筛选…", @@ -856,7 +891,6 @@ "pg_stress_synthetic": "待机模拟从此处开始", "pg_sweep_intro": "如果 {param} 不同会怎样?在最近 {cycles} 个周期上测试 {steps} 个值,找到能正确匹配最多周期的设置。", "pg_sweep_step": "步骤 {done} / {total}", - "pg_undetected": "{n} 个周期{s}未检测到", "pg_verdict_bad": "需要注意: 许多周期未被检测到。", "pg_verdict_good": "调校良好: 大多数周期都被正确识别和匹配。", "pg_verdict_ok": "尚可: 漏掉了一些周期。可尝试降低启动阈值。", @@ -887,6 +921,7 @@ "review_recorded_tip": "将此标记为其程序的精选参考周期 – 与手动录制周期的作用相同。参考周期始终保留,为匹配模板提供种子,且不会被清理删除。(这是“黄金”/录制标志;两者是同一件事。)", "review_tags_tip": "描述此周期出现问题的可选标志,以便训练和清理可以考虑这些问题。", "review_to_cycles": "打开周期审查队列", + "samples_decimated": "正在显示 {total} 个样本中的 {shown} 个(为显示而抽稀;保留峰值)。此处的较大间隔是抽稀,而非缺失数据。", "saving_triggers_reload": "保存会触发集成重载。HA 实体可能短暂显示为不可用。", "search_placeholder": "搜索设置…", "see_recorder": "查看下方的录制小部件", @@ -1006,7 +1041,29 @@ "sug_mute_failed": "无法屏蔽建议", "sug_unmuted_all": "已重置屏蔽的建议", "n_suggestions_muted": "已屏蔽 {count} 项,自动调整器将不再提议这些设置。", - "font_size_hint": "调整此面板所有内容的字体大小,仅对您在此设备上的账户生效。" + "font_size_hint": "调整此面板所有内容的字体大小,仅对您在此设备上的账户生效。", + "import_history_description": "在使用 WashData 之前就已经有智能插座了?上传其功率传感器的历史导出文件,或直接从 Home Assistant 读取,常规检测会在这些数据上运行,过去的周期便会出现在您的周期列表中,等待命名。", + "hist_input_hint": "上传从历史面板下载的 CSV(entity、state、last changed),或让 WashData 直接读取该传感器的历史。随后检测会像实时运行时一样在这些数据上运行,并由您选择保留其中哪些周期。", + "hist_recorder_hint": "从您选择的日期读取到现在。Home Assistant 默认保留 10 天的详细历史,之后仅保留每小时平均值,过于粗略,无法用于检测周期;只有当 recorder 配置为保留更长时间时,才选择更早的日期。", + "hist_scanning": "正在通过检测器重放您的历史数据。该任务在后台运行 - 您可以关闭此对话框,稍后再回来查看。", + "hist_imported_count": "已导入 {n} 个周期。", + "hist_duplicates": "其中 {n} 个此前已导入,已跳过。", + "hist_capped": "已达到每台设备可导入周期的上限,其余未保存。", + "hist_next_step": "它们已在您的周期列表中,标记为导入的历史。打开其中一个,用“标记”指定它所属的程序。", + "hist_rows_read": "已读取 {n} 条读数", + "hist_entity_substituted": "已读取 {used}(此设备配置的是 {wanted})", + "hist_breaks": "{n} 处传感器不可用的空缺", + "hist_other_entity": "已忽略其他实体的 {n} 条读数", + "hist_skipped_spans": "跳过的时段", + "hist_settings_used": "使用该设备的当前设置检测(最小功率 {w} W,关闭延迟 {s} 秒)。", + "hist_none_found": "在该历史数据中未能检测到任何周期。", + "hist_found": "找到 {n} 个周期。取消勾选看起来不像真实运行的项 - 在导入之前不会保存任何内容。", + "hist_scan_capped": "仅显示前若干个候选项(共找到 {n} 个)。", + "hist_recorder_empty": "Home Assistant 中没有该传感器在该时间段的详细历史。", + "hist_scan_failed": "扫描失败。", + "hist_scan_expired": "该次扫描结果已不可用,请重新扫描。", + "hist_import_failed": "导入失败。", + "imported_history_readonly": "在导入的功率历史中检测到。它会影响程序匹配,但不计入您的统计,且无法裁剪或拆分。请标记以指定其程序。" }, "pg_desc": { "completion_min_seconds": "计为真实周期的最短运行时间", @@ -1035,7 +1092,8 @@ "dtw_refine_top_n": "Stage 3: DTW 重新评分的候选数量; 若正确档案排在第 4-5 位则调高至 7-9(默认 5)", "duration_scale": "Stage 4: 时长一致性得分减半时的对数比率; 越小惩罚越严格(默认 0.175)", "energy_scale": "Stage 4: 能耗一致性得分减半时的对数比率; 越小惩罚越严格(默认 0.25)", - "dishwasher_end_spike_quiet_release": "洗碗机:预期时长过后、解除周期结束排水等待之前保持静止的秒数" + "dishwasher_end_spike_quiet_release": "洗碗机:预期时长过后、解除周期结束排水等待之前保持静止的秒数", + "smart_termination_duration_ratio": "匹配程序预期时长中,周期在智能终止可以提前结束之前必须达到的比例;对于取决于负载或温度的机器请调低" }, "phase_desc": { "anti_crease": "完成后偶尔进行短暂的翻滚以减少皱纹。", @@ -1290,7 +1348,7 @@ "label": "结束服务" }, "notify_fire_events": { - "doc": "还在循环开始/结束时触发 ha_​​washdata_* 事件,以便您可以构建自己的自动化。", + "doc": "还在循环开始/结束时触发 ha_washdata_* 事件,以便您可以构建自己的自动化。", "label": "为通知触发 HA 事件" }, "notify_icon": { @@ -1417,6 +1475,10 @@ "doc": "存储每个周期的完整功率跟踪和匹配调试数据。对于故障排除很有用,但会增加存储大小。", "label": "保存调试追踪" }, + "smart_termination_duration_ratio": { + "doc": "指定在功率下降后、智能终止可以提前结束一个周期之前,该周期必须进行到匹配程序预期时长的多大比例。预期时长是该程序的平均值,因此对于运行时间波动很大的家电(冬季冷进水与夏季暖进水的洗衣机、传感器烘干的烘干机、取决于负载的程序),大约一半的运行会比该平均值更早结束,从而无法获得快速结束,只能通过后备超时晚几分钟才结束。对于这些机器,请调低此值(例如 0.85),使提前结束仍然生效;若要更保守,则将其调高至接近 1.0。留空则使用默认值(0.98,洗碗机为 0.99)。它只能让周期更早结束,绝不会更晚,并且在匹配结果不明确或置信度较低时绝不会触发。", + "label": "智能终止比率" + }, "smoothing_window": { "doc": "原始功率信号被平滑了多少。低 (2) 响应灵敏,但噪音较大;高 (5) 可平滑尖峰,但会增加滞后。", "label": "平滑窗口" @@ -1551,6 +1613,10 @@ "dishwasher_end_spike_quiet_release": { "label": "自然干燥静止释放", "doc": "当周期超过其预期时长后,洗碗机必须保持静止(低于停止阈值)多长时间,WashData 才会停止等待最终排水并结束该周期。如果您的机器在较晚的最终排水之前有一个较长的近乎无声的干燥阶段,导致该次排水被漏检,请调高此值 - 更宽的窗口可让学习到的时长跟随季节性漂移(进水更冷 = 周期更长),而不是锁定在旧的平均值上。它只能相对于内部 30 分钟的结束尖峰上限缩短等待时间,绝不会延长。" + }, + "profile_evidence_sources": { + "label": "用于构建程序的周期", + "doc": "用于构建每个程序的功率曲线,并将已完成的周期与其匹配的周期种类。取消勾选某一种类会使其不再影响您的程序,但不会删除任何内容 - 这些周期仍留在周期列表中,仍可标记或删除。如果您不信任导入的数据,此选项很有用。统计不受影响:统计始终只计入本机实际运行过的周期。取消勾选全部会被忽略,因为没有任何周期作为依据的程序永远无法匹配。" } }, "setting_group": { @@ -1634,6 +1700,9 @@ }, "basic_configuration": { "label": "基本配置" + }, + "profile_evidence": { + "label": "配置文件依据" } }, "status": { @@ -1658,7 +1727,8 @@ "trimming": "裁剪中…", "splitting": "拆分中…", "deleting": "删除中…", - "imported": "已导入" + "imported": "已导入", + "preparing": "正在准备…" }, "tab": { "advanced": "高级", @@ -1688,6 +1758,7 @@ "wrong_profile": "错误配置文件" }, "toast": { + "catalog_refreshed": "社区目录已刷新", "access_saved": "已保存访问控制", "all_wiped": "所有数据已清除", "analysis_complete_none": "分析完成:没有新建议", @@ -1793,7 +1864,9 @@ "store_download_failed": "下载失败:{error}", "store_download_nothing": "没有新内容可下载 - 此配置已在您的设备上。", "export_selective_done": "导出已下载", - "import_selective_done": "已导入 {profiles} 个配置文件和 {cycles} 个周期" + "import_selective_done": "已导入 {profiles} 个配置文件和 {cycles} 个周期", + "hist_csv_required": "请先加载 CSV 文件或粘贴其内容", + "file_read_failed": "无法读取该文件" }, "suggestion": { "both_agree": "WashData建议", @@ -1889,7 +1962,7 @@ "thr_batch": "保持在 {cycles} 个周期中 p05 最低工作功率({p05}W)略上方,以便尽早捕捉到启动,同时使停止阈值保持在设备最低运行功率之下。", "tol_per_profile": "{profiles} 个配置文件({cycles} 个周期)中每个配置文件时长方差的 p75;波动小的配置文件不受惩罚。", "tol_pooled": "基于 {cycles} 个近期已标注周期的合并时长方差(p95 偏差={dev})。", - "watchdog": "在安全范围内保持尽可能低(略高于 p95 更新间隔 {p95}s,最小 30s),以便快速捕捉到停滞而不产生误停止。" + "watchdog": "在安全范围内保持尽可能低(略高于 p95 更新间隔 {p95}s,且至少为采样间隔 {median}s 的 2 倍,最小 30s),以便快速捕捉到停滞而不产生误停止。" }, "exclusions": { "summary": "已排除 {total} 个误检测周期:{parts}。", @@ -1955,6 +2028,10 @@ "finished": "循环到达终止状态并已结束。" }, "store": { + "your_model_tip": "这是您在设置中指定的家电", + "your_model": "您的型号", + "search_brand_ph": "按品牌搜索…", + "programs_count": "程序:{n}", "browse": "浏览", "device": "设备", "favorites": "收藏", diff --git a/custom_components/ha_washdata/translations/uk.json b/custom_components/ha_washdata/translations/uk.json index ba959bbd..94cf35f8 100644 --- a/custom_components/ha_washdata/translations/uk.json +++ b/custom_components/ha_washdata/translations/uk.json @@ -193,15 +193,15 @@ }, "user_confirmed": { "name": "Підтвердити виявлену програму", - "description": "Установіть true, якщо виявлена ​​програма правильна." + "description": "Установіть true, якщо виявлена програма правильна." }, "corrected_profile": { "name": "Виправлений профіль", "description": "Якщо не підтверджено, укажіть правильну назву профілю/програми." }, "corrected_duration": { - "name": "Виправлена ​​тривалість (секунди)", - "description": "Додаткова виправлена ​​тривалість у секундах." + "name": "Виправлена тривалість (секунди)", + "description": "Додаткова виправлена тривалість у секундах." }, "notes": { "name": "Примітки", diff --git a/custom_components/ha_washdata/ws_api.py b/custom_components/ha_washdata/ws_api.py index c398948c..7b23e6df 100644 --- a/custom_components/ha_washdata/ws_api.py +++ b/custom_components/ha_washdata/ws_api.py @@ -27,6 +27,7 @@ import os import re import time import uuid +from datetime import timedelta from typing import Any import voluptuous as vol @@ -41,6 +42,7 @@ from .const import ( CONF_COMPLETION_MIN_SECONDS, CONF_DEVICE_TYPE, CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE, + CONF_SMART_TERMINATION_DURATION_RATIO, CONF_DOOR_SENSOR_ENTITY, CONF_ANTI_WRINKLE_EXIT_POWER, CONF_ANTI_WRINKLE_MAX_POWER, @@ -74,23 +76,40 @@ from .const import ( CONF_WATCHDOG_INTERVAL, DEFAULT_DEVICE_TYPE, DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS, + DEFAULT_SMART_TERMINATION_DURATION_RATIO, + DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE, + resolve_sampling_interval_default, + resolve_watchdog_interval_default, + resolve_start_duration_default, + resolve_smart_termination_duration_ratio_default, DEFAULT_MAINTENANCE_REMINDER_CYCLES, DEFAULT_MIN_POWER, DEFAULT_OFF_DELAY, DEFAULT_OFF_DELAY_BY_DEVICE, + DEFAULT_PROFILE_MATCH_THRESHOLD, DEVICE_TYPE_PUMP, MAINTENANCE_EVENT_TYPES, DEVICE_TYPES, DOMAIN, ENABLE_ML_SUGGESTIONS, ENABLE_ML_TRAINING, + HISTORY_IMPORT_CHUNK_BYTES, + HISTORY_IMPORT_CHUNK_SAMPLES, + HISTORY_IMPORT_MAX_BYTES, + HISTORY_IMPORT_MAX_ROWS, + HISTORY_IMPORT_MAX_SEGMENTS, + HISTORY_IMPORT_MAX_TOTAL_CYCLES, + HISTORY_IMPORT_RECORDER_EMPTY_DAY_STOP, + HISTORY_IMPORT_RECORDER_MAX_DAYS, PLAYGROUND_PRESET_MAX, SHOW_ML_LAB, STATE_COLORS, ) +from . import history_import from . import playground from . import task_registry from .cycle_detector import CycleDetectorConfig +from .options_utils import strip_null_options from .setup_advisor import compute_setup_phase from .ws_schema import WS_OPEN_RESPONSES, WS_RESPONSE_TYPES @@ -212,6 +231,22 @@ _SUGGESTION_INT_KEYS: frozenset[str] = frozenset({ }) +def _coerce_suggested(key: str, val: Any) -> Any: + """Round a raw suggestion value the way the UI would apply it. + + Int keys are floored to int, everything else rounded to 4 dp - the same + coercion ``ws_get_suggestions`` does before the equivalence test. The device + pill's badge filter must use it too, or a value like 30.4 for an int key with + a current 30 is hidden in the Settings list (30 == 30) but still counted on + the pill, leaving a badge the user cannot clear. Returns the raw value + unchanged if it is not numeric. + """ + try: + return int(float(val)) if key in _SUGGESTION_INT_KEYS else round(float(val), 4) + except (TypeError, ValueError): + return val + + def _suggestion_equivalent(suggested: Any, current: Any) -> bool: """True when a suggested value is effectively the same as the current one. @@ -242,11 +277,17 @@ _ML_COMPARE_SETTINGS: tuple[tuple[str, str, str], ...] = ( def _downsample(samples: Any, max_points: int = 240) -> list[list[float]]: - """Reduce a [(offset_s, watts), ...] series to <= max_points via striding. + """Reduce a [(offset_s, watts), ...] series to ~max_points, preserving extrema. - Keeps the first and last samples so the time axis is preserved. Power curves - can hold thousands of points; the panel only needs enough to draw a faithful - line, and WebSocket payloads should stay lean. + Splits the series into contiguous buckets and keeps each bucket's MIN and MAX + power sample (in time order), plus the global first and last. Two points per + bucket, so ~max_points/2 buckets keeps the payload at the same budget striding + used. Unlike striding this never drops a single-sample load peak (it is its + bucket's max) or a lone 0 W self-shutdown sample between two non-zero readings + (a zero is always its bucket's min) - the signals that carry the meaning. + + Power curves can hold thousands of points; the panel only needs enough to draw + a faithful line, and WebSocket payloads should stay lean. """ try: pairs = list(samples or []) @@ -262,24 +303,39 @@ def _downsample(samples: Any, max_points: int = 240) -> list[list[float]]: if n <= max_points: return [_pt(it) for it in pairs] - step = n / float(max_points) - out: list[list[float]] = [] - last_i = -1 - idx = 0.0 - while int(idx) < n: - i = int(idx) - if i != last_i: - out.append(_pt(pairs[i])) - last_i = i - idx += step - last_pt = _pt(pairs[-1]) - if not out or out[-1][0] != last_pt[0]: - out.append(last_pt) - return out + nbuckets = max(1, max_points // 2) + keep: set[int] = {0, n - 1} + for b in range(nbuckets): + lo = (b * n) // nbuckets + hi = ((b + 1) * n) // nbuckets + if hi <= lo: + continue + min_i = max_i = lo + min_v = max_v = float(pairs[lo][1]) + for i in range(lo + 1, hi): + v = float(pairs[i][1]) + if v < min_v: + min_v, min_i = v, i + elif v > max_v: + max_v, max_i = v, i + keep.add(min_i) + keep.add(max_i) + return [_pt(pairs[i]) for i in sorted(keep)] -async def _recorder_power(hass: HomeAssistant, entity_id: str, start_dt: Any) -> list[tuple[float, float]]: - """Raw (unix_ts, watts) readings for entity_id from start_dt to now, via the recorder.""" +async def _recorder_power( + hass: HomeAssistant, + entity_id: str, + start_dt: Any, + *, + end_dt: Any = None, +) -> list[tuple[float, float]]: + """Raw (unix_ts, watts) readings for entity_id over a window, via the recorder. + + ``end_dt`` defaults to now (the live chart overlay's use). Passing it lets a caller + read the history in bounded windows instead of one unbounded query - a month of + 5-second data is millions of rows in a single recorder-executor job. + """ try: from homeassistant.components.recorder import ( # pylint: disable=import-outside-toplevel get_instance, @@ -287,16 +343,25 @@ async def _recorder_power(hass: HomeAssistant, entity_id: str, start_dt: Any) -> ) except Exception: # pylint: disable=broad-exception-caught return [] - end_dt = dt_util.now() # tz-aware; use dt_util.now() per the datetime convention + # tz-aware; use dt_util.now() per the datetime convention + window_end = end_dt if end_dt is not None else dt_util.now() def _query() -> list[tuple[float, float]]: res = history.state_changes_during_period( - hass, start_dt, end_dt, entity_id, include_start_time_state=True + hass, start_dt, window_end, entity_id, include_start_time_state=True ) rows: list[tuple[float, float]] = [] + start_ts = start_dt.timestamp() if hasattr(start_dt, "timestamp") else None for s in res.get(entity_id, []) or []: try: - rows.append((s.last_changed.timestamp(), round(float(s.state), 1))) + ts = s.last_changed.timestamp() + # include_start_time_state yields the state in force at the window + # start, whose last_changed can predate it by days. Clamp it to the + # window so a caller reading day-by-day windows does not see a huge + # synthetic leading gap (or the same reading twice). + if start_ts is not None and ts < start_ts: + ts = start_ts + rows.append((ts, round(float(s.state), 1))) except (ValueError, TypeError): continue return rows @@ -366,6 +431,36 @@ def _strip_cycle(c: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in c.items() if k not in _CYCLE_STRIP_KEYS} +def _cycle_capabilities(cycle: dict[str, Any], origin: str) -> dict[str, Any]: + """What the panel may offer for one cycle, given which list it lives in. + + Three separate answers, because "not a real cycle" is not one capability: + + * ``is_reference`` - it lives outside ``past_cycles``, so it feeds envelopes and the + matcher but never usage statistics. Always derived from list membership, never from + a ``meta.source`` string: the cycle list and the inspector both answer through here, + and they must not be able to disagree. + * ``labelable`` - a profile can be assigned to it. True everywhere: naming the + programs found in imported history (#344) is the entire point of that feature, and + the store labels a non-real cycle in place. + * ``editable`` - trim / split / review apply. Real cycles only; those store functions + operate on ``past_cycles`` and would silently no-op elsewhere. + + ``origin`` is the list name from :meth:`ProfileStore.find_stored_cycle` + (``past`` / ``reference`` / ``backfill``) and is passed through so the UI can say + where a cycle came from - a curated community-store template and a segment the + importer detected in the user's own history warrant different wording. + """ + if origin == "past": + return {"is_reference": False, "labelable": True, "editable": True} + return { + "is_reference": True, + "labelable": True, + "editable": False, + "cycle_origin": origin or "reference", + } + + # Option keys that are identity/transient churn and are never recorded in the # settings changelog (D7): name/title edits flow through the separate `title` # kwarg, and suggestion application uses its own apply_suggestions command. @@ -445,6 +540,9 @@ _FULL_COMMANDS = frozenset({ "get_export_inventory", "analyze_import", "export_config_selective", "import_config_selective", # Reverting on-device models / matcher tuning discards learned state -> full access. "revert_matching_config", "revert_ml_models", + # Historical power-data import: ingests a whole power history and writes cycles. + "history_import_begin", "history_import_chunk", "history_import_recorder", + "start_history_import_scan", "apply_history_import", }) # Commands allowed for any authenticated user regardless of device permissions. _OPEN_COMMANDS = frozenset({ @@ -474,6 +572,14 @@ _ADMIN_COMMANDS = frozenset({ "store_disconnect", "store_set_online", "store_set_prefs", + # Historical power-data import: reads a whole recorder history (or an uploaded + # export of one) and writes cycles into the store, so it is admin-only even with + # RBAC disabled, exactly like the selective import wizard. + "history_import_begin", + "history_import_chunk", + "history_import_recorder", + "start_history_import_scan", + "apply_history_import", }) # Mutating commands intentionally allowed at the 'read' level. Picking the live # program is a benign runtime action (it changes detection, not stored data), so @@ -502,6 +608,11 @@ _READ_WRITE_COMMANDS = frozenset({ "store_get_cycles", "store_get_device_quality", "store_get_device_profiles", + "store_get_catalog_entry", + # NB: store_refresh_catalog is deliberately NOT here. It drops the install-wide + # catalog cache in the shared StoreClient, so a read-level user could bust the + # 1-hour TTL that protects the free-tier read budget on repeat. It defaults to + # 'edit', like the other install-wide store actions above read level. }) _LOG_BUFFER_KEY = "ha_washdata_log_buffer" @@ -806,6 +917,46 @@ async def ws_store_get_device_quality(hass, connection, msg): _send_result(connection, msg["id"], "store_get_device_quality", await manager.store_bridge.get_device_quality(msg["device_id"])) +@websocket_api.websocket_command({ + vol.Required("type"): "ha_washdata/store_get_catalog_entry", vol.Required("entry_id"): str, + vol.Required("brand"): str, vol.Required("model"): str, vol.Required("appliance_type"): str, +}) +@websocket_api.async_response +async def ws_store_get_catalog_entry(hass, connection, msg): + """Resolve just this appliance's catalog brand + device documents, by id. + + Two point reads, replacing the brand list + device list the settings form used to + download purely to locate these two rows (measured: 128 documents, 119 KB). The + pickers still fetch the full lists, but only when the user opens one. + """ + ctx = _store_ctx(hass, msg["entry_id"]) + if ctx is None: + _send_result(connection, msg["id"], "store_get_catalog_entry", {"disabled": True}) + return + manager, _ = ctx + res = await manager.store_bridge.catalog_entry(msg["brand"], msg["model"], msg["appliance_type"]) + _send_result(connection, msg["id"], "store_get_catalog_entry", res) + + +@websocket_api.websocket_command({ + vol.Required("type"): "ha_washdata/store_refresh_catalog", vol.Required("entry_id"): str, +}) +@websocket_api.async_response +async def ws_store_refresh_catalog(hass, connection, msg): + """Drop the cached catalog so the next browse re-reads the store. + + The cache is deliberately long-lived (the catalog is near-static and every read is + charged against a shared free-tier budget), so this is the escape hatch for "someone + told me my brand was just approved". + """ + ctx = _store_ctx(hass, msg["entry_id"]) + if ctx is None: + _send_result(connection, msg["id"], "store_refresh_catalog", {"disabled": True}) + return + manager, _ = ctx + _send_result(connection, msg["id"], "store_refresh_catalog", manager.store_bridge.refresh_catalog()) + + @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_get_device_profiles", vol.Required("entry_id"): str, vol.Required("brand"): str, vol.Required("model"): str, vol.Required("appliance_type"): str, @@ -1138,6 +1289,11 @@ def async_register_commands(hass: HomeAssistant) -> None: ws_store_list_brands, ws_store_get_device_quality, ws_store_get_device_profiles, ws_store_confirm_device, ws_store_rate_device, ws_store_set_online, ws_store_set_prefs, + # Catalog identity badges (point reads) + manual cache refresh + ws_store_get_catalog_entry, ws_store_refresh_catalog, + # Historical power-data import: staged ingest, background scan, apply + ws_history_import_begin, ws_history_import_chunk, ws_history_import_recorder, + ws_start_history_import_scan, ws_apply_history_import, ] for handler in handlers: websocket_api.async_register_command(hass, _guard(handler)) @@ -1176,11 +1332,21 @@ def ws_get_devices( "current_power_w": None, "cycle_progress_pct": None, "suggestions_count": 0, + # Which keys those are, so the panel can merge them with the + # Calibrated (ML) recommendations it computes client-side without + # double-counting a key both engines suggest. + "suggestion_keys": [], "feedback_count": 0, "recording": False, "is_user_paused": False, "manual_program": False, "options": dict(entry.options), + # Device-resolved defaults for the cadence/ratio fields (#396/#393) so the + # device-list conflict/suggestion badges score an unset field against the + # value the integration would actually use, matching the Settings tab. + "option_defaults": _resolved_option_defaults( + {**entry.data, **entry.options}.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE) + ), } if manager is not None: @@ -1209,11 +1375,28 @@ def ws_get_devices( store = getattr(manager, "profile_store", None) if store is not None: try: + # Same filters as ws_get_suggestions (muted keys and + # no-op values dropped) so the device-pill badge can + # never disagree with the Settings tab banner. raw = store.get_suggestions() or {} - info["suggestions_count"] = sum( - 1 for k in _SUGGESTION_KEYS - if isinstance(raw.get(k), dict) and raw[k].get("value") is not None - ) + merged = {**entry.data, **entry.options} + try: + muted = set(store.get_locked_suggestions() or []) + except Exception: # pylint: disable=broad-exception-caught + muted = set() + keys = [ + k for k in _SUGGESTION_KEYS + if k not in muted + and isinstance(raw.get(k), dict) + and raw[k].get("value") is not None + # Coerce first, exactly like ws_get_suggestions, so the pill + # and the Settings list agree on int-key rounding. + and not _suggestion_equivalent( + _coerce_suggested(k, raw[k]["value"]), merged.get(k) + ) + ] + info["suggestion_keys"] = keys + info["suggestions_count"] = len(keys) except Exception: # pylint: disable=broad-exception-caught pass try: @@ -1269,6 +1452,7 @@ def ws_get_device_cycles( cycles: list[dict[str, Any]] = [] reference_cycles: list[dict[str, Any]] = [] + backfill_cycles: list[dict[str, Any]] = [] total = 0 try: store = getattr(manager, "profile_store", None) @@ -1282,15 +1466,22 @@ def ws_get_device_cycles( window = ordered[offset:offset + limit] for c in window: cycles.append(_strip_cycle(c)) - # Imported store recordings are a small, bounded set kept out of the - # paginated `cycles`/`total` (they never enter usage stats). Return - # them once, on the first page, tagged so the panel can badge them - # and route edits/deletes correctly. + # Imported store recordings and cycles recovered from raw history are + # bounded sets kept out of the paginated `cycles`/`total` (they never enter + # usage stats). Return them once, on the first page, tagged so the panel can + # badge them and route edits/deletes correctly. They travel in separate + # arrays because they are separate categories: a curated community template + # and an auto-detected segment from the user's own past are not the same + # claim about a cycle. if offset == 0: for c in reversed(store.get_reference_cycles()): ref = _strip_cycle(c) - ref["is_reference"] = True + ref.update(_cycle_capabilities(c, "reference")) reference_cycles.append(ref) + for c in reversed(store.get_backfill_cycles()): + item = _strip_cycle(c) + item.update(_cycle_capabilities(c, "backfill")) + backfill_cycles.append(item) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error fetching cycles for entry %s: %s", entry_id, exc) @@ -1299,6 +1490,7 @@ def ws_get_device_cycles( "entry_id": entry_id, "cycles": cycles, "reference_cycles": reference_cycles, + "backfill_cycles": backfill_cycles, "total": total, "has_more": has_more, }, @@ -1307,6 +1499,28 @@ def ws_get_device_cycles( # ─── Settings ───────────────────────────────────────────────────────────────── + +def _resolved_option_defaults(device_type: str) -> dict[str, Any]: + """Device-resolved defaults for the cadence/ratio settings whose default varies + by device type (#396/#393). + + The panel uses these as the render/conflict-check/suggestion-comparison fallback + for an unset field, so it shows - and validates against - the value the + integration would actually use, not a static schema literal that would spuriously + trip (or silently miss) the watchdog>=2*sampling / start_duration>=sampling rules + on a coarse-sampling device type. Shared by ws_get_options (current device) and + ws_get_devices (per device) so the two never diverge. + """ + return { + CONF_SAMPLING_INTERVAL: resolve_sampling_interval_default(device_type), + CONF_WATCHDOG_INTERVAL: resolve_watchdog_interval_default(device_type), + CONF_START_DURATION_THRESHOLD: resolve_start_duration_default(device_type), + CONF_SMART_TERMINATION_DURATION_RATIO: ( + resolve_smart_termination_duration_ratio_default(device_type) + ), + } + + @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/get_options", vol.Required("entry_id"): str} ) @@ -1322,7 +1536,19 @@ def ws_get_options( connection.send_error(msg["id"], "not_found", f"Entry {msg['entry_id']!r} not found") return options = {**entry.data, **entry.options} - _send_result(connection, msg["id"], "get_options", {"options": options}) + # Device-resolved defaults for the cadence settings whose defaults vary by + # device type (#396). The panel uses these as the render/conflict-check + # fallback for an unset field so it shows (and validates against) the value the + # integration would actually use - not a static schema literal that would + # spuriously trip the panel's own watchdog>=2*sampling / start_duration>=sampling + # rules on a coarse-sampling device type. + device_type = options.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE) + _send_result( + connection, + msg["id"], + "get_options", + {"options": options, "defaults": _resolved_option_defaults(device_type)}, + ) @websocket_api.websocket_command( @@ -1393,6 +1619,35 @@ async def ws_set_options( DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS ) + # Smart-Termination duration ratio (#393): fraction of expected duration, so it + # is meaningless outside [0.50, 1.00] - clamp valid submissions to the range. + # An empty or non-numeric value drops the key so the device-type default + # (resolved in the config builder, 0.99 dishwasher / 0.98 other) applies again; + # coercing to a single scalar default here would be wrong for dishwashers. + if CONF_SMART_TERMINATION_DURATION_RATIO in new_options: + _raw_str = new_options[CONF_SMART_TERMINATION_DURATION_RATIO] + if _raw_str in (None, ""): + new_options.pop(CONF_SMART_TERMINATION_DURATION_RATIO, None) + else: + try: + _str = float(_raw_str) + if not math.isfinite(_str): + raise ValueError("non-finite") + new_options[CONF_SMART_TERMINATION_DURATION_RATIO] = min( + 1.0, max(0.5, _str) + ) + except (TypeError, ValueError): + new_options.pop(CONF_SMART_TERMINATION_DURATION_RATIO, None) + + # A None outside the clearable selectors means "not set", not a value: the + # per-setting Revert sends the changelog's `old`, which is null for a setting + # never saved before. Stored, it would survive options.get(key, DEFAULT) and + # break the float()/int() casts at setup, so drop the key and let the default + # apply again; this also cleans nulls persisted by earlier builds. + _pre_strip_keys = set(new_options) + new_options = strip_null_options(new_options) + dropped_null_keys = _pre_strip_keys - set(new_options) + # Partition identity out of options: the display name is carried by the # entry title, never persisted in options (matches the config-flow invariant # that CONF_NAME is absent from options). @@ -1409,8 +1664,13 @@ async def ws_set_options( # that rebuilds the store). A changelog failure must never block the save. try: old_effective = {**entry.data, **entry.options} + # Keys dropped by the null-strip above are recorded as a change to None + # ("reverted to unset") so the history still shows what happened; a + # None -> None no-op is skipped by _diff_option_changes. submitted_post = { - k: new_options[k] for k in msg["options"] if k in new_options + k: new_options.get(k) + for k in msg["options"] + if k in new_options or k in dropped_null_keys } changes = _diff_option_changes(old_effective, submitted_post) if changes: @@ -2993,8 +3253,14 @@ async def ws_import_config( entry_options_updates.pop(key, None) if entry_options_updates: # Apply the imported tunables on top of the current options; - # never spread entry.data into options. - new_options = {**entry.options, **entry_options_updates} + # never spread entry.data into options. An import payload can + # carry a null (an export taken from an entry that still held + # one), and a persisted null survives options.get(key, DEFAULT) + # and breaks setup (#389), so the same write-boundary strip as + # ws_set_options applies here. + new_options = strip_null_options( + {**entry.options, **entry_options_updates} + ) hass.config_entries.async_update_entry(entry, options=new_options) # NB: config_updates["entry_data"] is intentionally NOT written to # entry.data. export_data ships the raw, un-redacted entry.data of @@ -3319,9 +3585,7 @@ def ws_get_suggestions( if not isinstance(item, dict) or item.get("value") is None: continue val = item["value"] - suggested = ( - int(float(val)) if key in _SUGGESTION_INT_KEYS else round(float(val), 4) - ) + suggested = _coerce_suggested(key, val) current = merged.get(key) # Hide suggestions that would not change the current value. if _suggestion_equivalent(suggested, current): @@ -3527,15 +3791,7 @@ async def ws_get_cycle_power_data( try: store = manager.profile_store samples = store.get_cycle_power_data(cycle_id) - cycle = next( - (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None - ) - if cycle is None: - # Imported store recordings live in a separate list. - cycle = next( - (c for c in store.get_reference_cycles() if c.get("id") == cycle_id), - None, - ) + cycle, origin = store.find_stored_cycle(cycle_id) if cycle: meta = { "start_time": cycle.get("start_time"), @@ -3544,9 +3800,7 @@ async def ws_get_cycle_power_data( "profile_name": cycle.get("profile_name"), "status": cycle.get("status"), "energy_kwh": _cycle_kwh(cycle), - # Imported store recordings are read-only in the inspector (no - # trim/relabel/review -- they never enter usage stats). - "is_reference": str(cycle.get("meta", {}).get("source", "")).startswith("store"), + **_cycle_capabilities(cycle, origin), } # Transient artifacts (door-open pauses, out-of-band dips/spikes) for # graph markers. Prefer the value frozen at cycle end; compute on the @@ -3563,9 +3817,15 @@ async def ws_get_cycle_power_data( except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error getting cycle power data %s: %s", cycle_id, exc) + _ds = _downsample(samples) _send_result(connection, msg["id"], "get_cycle_power_data", { "cycle_id": cycle_id, - "samples": _downsample(samples), + "samples": _ds, + # Declare thinning (#395) so a gap from decimation is not mistaken for + # a gap from a sensor that stopped reporting: the panel can show + # "N of M samples" and disambiguate the two. + "sample_count": len(samples), + "decimated": len(_ds) < len(samples), "full_duration_s": round(float(samples[-1][0]), 1) if samples else 0.0, **meta, }, @@ -3690,12 +3950,16 @@ async def ws_analyze_split( split_offsets = ( [round(float(s[1]), 1) for s in segs[:-1]] if segs and len(segs) > 1 else [] ) + _ds = _downsample(samples) _send_result(connection, msg["id"], "analyze_split", { "segments": [ [round(float(a), 1), round(float(b), 1)] for a, b in (segs or []) ], "split_offsets": split_offsets, - "samples": _downsample(samples), + "samples": _ds, + # Declare thinning (#395); see ws_get_cycle_power_data. + "sample_count": len(samples), + "decimated": len(_ds) < len(samples), "full_duration_s": round(float(samples[-1][0]), 1) if samples else 0.0, }, ) @@ -5269,6 +5533,17 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig: opts.get(CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE), DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS, ), + smart_termination_duration_ratio=_safe_float_finite( + opts.get(CONF_SMART_TERMINATION_DURATION_RATIO), + resolve_smart_termination_duration_ratio_default( + str(opts.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)) + ), + ), + # Match the live detector's tuned gate, not the dataclass 0.4, so the sim's + # Smart-Termination / anti-crease confidence checks reproduce production. + match_confidence_threshold=_safe_float_finite( + opts.get(CONF_PROFILE_MATCH_THRESHOLD), DEFAULT_PROFILE_MATCH_THRESHOLD + ), ) @@ -5505,6 +5780,7 @@ def _playground_preset_list(store: Any) -> list[dict[str, Any]]: { vol.Required("type"): "ha_washdata/get_playground_settings", vol.Required("entry_id"): str, + vol.Optional("include_suggestions", default=True): bool, } ) @websocket_api.async_response @@ -5519,6 +5795,13 @@ async def ws_get_playground_settings( simulation uses, so the control panel always opens on what the integration is really running - never on a stale schema default. ``publishable`` lists the keys the panel may write back to the config entry. + + ``include_suggestions=False`` skips the auto-tuner/ML suggestion blocks. They exist + only to label the two "Load suggested" buttons, and the ML one runs statistics across + every clean cycle - real work, on the critical path of opening the tab. The panel + therefore opens without them and fetches them in the background; the buttons already + render only once their count is non-zero, so they simply appear when the data lands. + Defaults to True so every other caller is unaffected. """ entry_id: str = msg["entry_id"] ctx = _playground_context(hass, entry_id) @@ -5531,11 +5814,14 @@ async def ws_get_playground_settings( except Exception: # pylint: disable=broad-exception-caught match_config = {} + include_suggestions = bool(msg.get("include_suggestions", True)) + # Classic suggestions from the store (periodic analysis results), filtered to # keys the Playground actually exposes — cheap dict read, no executor needed. raw_sugg: dict[str, Any] = {} try: - raw_sugg = store.get_suggestions() or {} + raw_sugg = store.get_suggestions() if include_suggestions else {} + raw_sugg = raw_sugg or {} except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug( "Could not read Playground classic suggestions for %s: %s", entry_id, exc @@ -5553,7 +5839,7 @@ async def ws_get_playground_settings( # ML suggestions — computed on-demand; executor-offloaded because it runs # statistics across all clean cycles. None when the feature is disabled. ml_sugg: dict[str, Any] | None = None - if ENABLE_ML_SUGGESTIONS: + if ENABLE_ML_SUGGESTIONS and include_suggestions: ml_sugg = {} try: learning = getattr(_manager, "learning_manager", None) @@ -6064,3 +6350,532 @@ def ws_start_playground_cycle_detail( if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "start_playground_cycle_detail", {"task_id": task.id}) + + +# ─── Historical power-data import (issue #344) ───────────────────────────────── +# +# Four steps, because the data is large and the answer is the user's to approve: +# +# 1. ingest - `history_import_begin` + `history_import_chunk` stage CSV text, or +# `history_import_recorder` fills the same buffer from the recorder. +# Chunked because Home Assistant builds its WebSocket with aiohttp's +# default 4 MiB frame cap, and ten days of 5-second data is 5-8 MB of +# text; an over-cap frame is not rejected, it closes the connection. +# 2. scan - `start_history_import_scan` replays the stream through fresh detectors +# as a detached registry task, chunk by chunk (see history_import.py for +# why a raw stream cannot be fed to one detector). +# 3. review - the panel shows one row per candidate; the whole traces never cross +# the wire (they would blow the same frame cap). +# 4. apply - `apply_history_import` persists the accepted rows into +# `backfill_cycles`. +# +# Parsing lives in Python, not in the panel, so one implementation is under test. + +_HISTORY_IMPORT_KEY = f"{DOMAIN}_history_import" + + +def _history_staging(hass: HomeAssistant) -> dict[str, dict[str, Any]]: + """Per-entry staging area for an in-flight import, keyed by entry_id. + + One slot per entry: a new upload replaces the previous one, so an abandoned 8 MB + paste cannot accumulate. Cleared by :func:`async_clear_history_import` on unload. + """ + return hass.data.setdefault(_HISTORY_IMPORT_KEY, {}) + + +def async_clear_history_import(hass: HomeAssistant, entry_id: str) -> None: + """Drop any staged upload and scan result for an entry (called on unload).""" + _history_staging(hass).pop(entry_id, None) + + +def _history_slot(hass: HomeAssistant, entry_id: str, token: str) -> dict[str, Any] | None: + slot = _history_staging(hass).get(entry_id) + if not isinstance(slot, dict) or slot.get("token") != token: + return None + return slot + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ha_washdata/history_import_begin", + vol.Required("entry_id"): str, + } +) +@callback +def ws_history_import_begin( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Open a staging slot for a CSV upload and return the token chunks must carry.""" + entry_id: str = msg["entry_id"] + if _get_manager(hass, entry_id) is None: + _err_not_found(connection, msg["id"], entry_id) + return + token = uuid.uuid4().hex + _history_staging(hass)[entry_id] = { + "token": token, + "chunks": [], + "bytes": 0, + "next_seq": 0, + "source": "csv", + } + _send_result(connection, msg["id"], "history_import_begin", { + "token": token, + "max_bytes": HISTORY_IMPORT_MAX_BYTES, + "chunk_bytes": HISTORY_IMPORT_CHUNK_BYTES, + }) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ha_washdata/history_import_chunk", + vol.Required("entry_id"): str, + vol.Required("token"): str, + vol.Required("seq"): int, + vol.Required("text"): str, + } +) +@callback +def ws_history_import_chunk( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Append one chunk of CSV text to the staging slot. + + Sequence-checked: a dropped or re-ordered chunk would splice the file silently, so a + mismatch is an error the panel can restart from rather than a corrupt import. + """ + entry_id: str = msg["entry_id"] + slot = _history_slot(hass, entry_id, msg["token"]) + if slot is None: + connection.send_error(msg["id"], "not_found", "No upload in progress; start again") + return + if int(msg["seq"]) != slot["next_seq"]: + connection.send_error( + msg["id"], "invalid_format", + f"Out-of-order chunk {msg['seq']}, expected {slot['next_seq']}", + ) + return + text: str = msg["text"] + size = len(text.encode("utf-8", "ignore")) + if slot["bytes"] + size > HISTORY_IMPORT_MAX_BYTES: + _history_staging(hass).pop(entry_id, None) + connection.send_error(msg["id"], "invalid_format", "Upload is too large") + return + slot["chunks"].append(text) + slot["bytes"] += size + slot["next_seq"] += 1 + _send_result(connection, msg["id"], "history_import_chunk", { + "received_bytes": slot["bytes"], + "next_seq": slot["next_seq"], + }) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ha_washdata/history_import_recorder", + vol.Required("entry_id"): str, + # Either a start date (what the panel sends: "import since ") or a plain + # day count. `start_date` wins when both are present. + vol.Optional("start_date"): vol.Any(None, str), + vol.Optional("days", default=10): vol.All( + vol.Coerce(int), vol.Range(min=1, max=HISTORY_IMPORT_RECORDER_MAX_DAYS) + ), + } +) +@websocket_api.async_response +async def ws_history_import_recorder( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Fill the staging slot from Home Assistant's own recorder. + + The entity is always the device's configured power sensor, never client-supplied: + this command reads arbitrary history, and letting the caller name the entity would + make it an information-disclosure hole. + + Read one day at a time. `state_changes_during_period` has no row cap, and a single + query over a long window materialises the whole result in one recorder-executor job. + Home Assistant purges states after `purge_keep_days` (10 by default), so a request + reaching further back simply returns fewer rows - that is reported, not an error. + """ + entry_id: str = msg["entry_id"] + manager = _get_manager(hass, entry_id) + if manager is None: + _err_not_found(connection, msg["id"], entry_id) + return + entity_id = getattr(manager, "power_sensor_entity_id", None) + if not entity_id: + connection.send_error(msg["id"], "not_found", "This device has no power sensor configured") + return + + now = dt_util.now() + days = int(msg.get("days") or 10) + start_date = msg.get("start_date") + if start_date: + # "Import since " - resolve to a day count so the windowed read below is + # unchanged. The date is read as a LOCAL calendar day (it comes from a date + # picker, where the user means their own midnight), and clamped to the supported + # range: a future date reads today, an older one is capped at the maximum. + try: + parsed_date = dt_util.parse_date(str(start_date)) + except Exception: # pylint: disable=broad-exception-caught + parsed_date = None + if parsed_date is None: + connection.send_error(msg["id"], "invalid_format", "Not a valid start date") + return + span = (now.date() - parsed_date).days + 1 + days = max(1, min(HISTORY_IMPORT_RECORDER_MAX_DAYS, span)) + rows: list[tuple[float, float]] = [] + # Walk BACKWARDS from today, not forwards from the oldest requested day: the window can + # now be years (a recorder configured to keep full-resolution states that long), and + # starting at the far end would issue thousands of empty queries before reaching any + # data - and, on truncation, would keep the OLDEST rows rather than the most recent. + # `samples_from_readings` sorts, so the accumulation order does not matter downstream. + empty_run = 0 + # The loop can stop early (empty-day run, or the row cap), so the requested `days` is + # not what was read. Track the oldest window actually queried and report THAT, or the + # panel would name a range it never looked at. + oldest_queried = now + queried_days = 0 + for day in range(1, days + 1): + window_start = now - timedelta(days=day) + window_end = now - timedelta(days=day - 1) + day_rows = await _recorder_power(hass, entity_id, window_start, end_dt=window_end) + oldest_queried = window_start + queried_days += 1 + if day_rows: + empty_run = 0 + rows.extend(day_rows) + else: + # Inside the retention window a day always yields at least the carried + # start-time state, so a run of empty days means the recorder is purged past + # here and every further query would be wasted. + empty_run += 1 + if empty_run >= HISTORY_IMPORT_RECORDER_EMPTY_DAY_STOP: + break + if len(rows) > HISTORY_IMPORT_MAX_ROWS: + break + + token = uuid.uuid4().hex + _history_staging(hass)[entry_id] = { + "token": token, + "chunks": [], + "bytes": 0, + "next_seq": 0, + "source": "recorder", + "rows": rows[:HISTORY_IMPORT_MAX_ROWS], + "entity_id": entity_id, + } + _send_result(connection, msg["id"], "history_import_recorder", { + "token": token, + "rows": len(rows[:HISTORY_IMPORT_MAX_ROWS]), + "entity_id": entity_id, + # Both describe the window actually read, not the one asked for: counted from + # the loop itself, so an early stop cannot report days that were never queried. + "days": queried_days, + "start_date": oldest_queried.date().isoformat(), + "truncated": len(rows) > HISTORY_IMPORT_MAX_ROWS, + }) + + +def _history_samples(slot: dict[str, Any], entity_id: str | None) -> Any: + """Turn a staging slot into samples, whichever way it was filled. + + Executor-side: CSV parsing is pure Python over megabytes of text. Returns either a + ``(samples, report)`` pair or an ``{"error": ...}`` marker. + """ + if slot.get("source") == "recorder": + samples = history_import.samples_from_readings(slot.get("rows") or []) + if len(samples) < 2: + return {"error": "no_readings"} + return samples, { + "rows_total": len(samples), + "rows_parsed": len(samples), + "entity_id": slot.get("entity_id"), + "source": "recorder", + } + parsed = history_import.parse_history_csv( + "".join(slot.get("chunks") or []), entity_id=entity_id + ) + if isinstance(parsed, dict): + return parsed + report = parsed.report() + report["source"] = "csv" + return parsed.samples, report + + +async def _history_import_scan_task( + hass: HomeAssistant, task: Any, entry_id: str, token: str +) -> None: + """Replay a staged history stream, chunk by chunk, into candidate cycles. + + The full traces are held in the staging slot rather than in the task result: the + result is served verbatim by `get_task_result`, and a few hundred traces would exceed + the WebSocket frame cap and take the connection down. The result carries only preview + rows, which is also all the review UI needs. + """ + reg = task_registry.get_registry(hass) + ctx = _playground_context(hass, entry_id) + if ctx is None: + reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") + return + manager, _store, base_config, options, _price = ctx + slot = _history_slot(hass, entry_id, token) + if slot is None: + reg.finish(task, state=task_registry.STATE_ERROR, error="upload expired") + return + # Segmentation is only as good as the thresholds it runs with, and + # `_playground_base_config`'s fallback uses the *scalar* defaults (min_off_gap 60, + # off_delay 180) rather than the per-device ones - which on a dishwasher would cut + # the stream at every drying pause and produce nothing but fragments. Refuse rather + # than scan against the wrong thresholds. + if not isinstance(getattr(getattr(manager, "detector", None), "config", None), CycleDetectorConfig): + reg.finish(task, state=task_registry.STATE_ERROR, error="detector_unavailable") + return + try: + entity_id = getattr(manager, "power_sensor_entity_id", None) + parsed = await hass.async_add_executor_job(_history_samples, slot, entity_id) + if isinstance(parsed, dict): + reg.finish(task, state=task_registry.STATE_ERROR, error=str(parsed.get("error"))) + return + samples, report = parsed + sampling_interval = options.get(CONF_SAMPLING_INTERVAL) + runner = await hass.async_add_executor_job( + functools.partial( + history_import.build_scan, + samples, + base_config, + sampling_interval_s=sampling_interval, + parse_report=report, + ) + ) + if isinstance(runner, dict): + # A stream with nothing usable in it is a *result*, not a failure: the panel + # explains which spans were skipped and why (six months of hourly averages + # is the common case), so the user is not left staring at "0 cycles". + reg.finish(task, state=task_registry.STATE_DONE, result={ + "segments": [], + "skipped": runner.get("skipped") or [], + "parse": runner.get("parse") or report, + "found": 0, + "error": runner.get("error"), + }) + return + reg.update(task, total=runner.total) + while not runner.finished: + if task.cancel_requested: + break + await hass.async_add_executor_job(runner.step, HISTORY_IMPORT_CHUNK_SAMPLES) + reg.update(task, done=min(runner.total, runner.done)) + payload = await hass.async_add_executor_job( + functools.partial(runner.finalize, partial=task.cancel_requested) + ) + # Split the payload: traces stay server-side, keyed by this task so a reconnect + # can still apply them; only the preview rows travel. + cycles = payload.pop("cycles", []) + current = _history_slot(hass, entry_id, token) + if current is not None: + current["scan_task_id"] = task.id + current["cycles"] = cycles + current.pop("chunks", None) # the raw text is no longer needed + current.pop("rows", None) + payload["token"] = token + payload["settings"] = { + "min_power": base_config.min_power, + "off_delay": base_config.off_delay, + "min_off_gap": base_config.min_off_gap, + "device_type": base_config.device_type, + } + reg.finish( + task, + state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, + result=payload, + ) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise + except Exception as exc: # pylint: disable=broad-exception-caught + # Task-level failure at WARNING like the other task runners, so it lands in the + # default HA log and the panel Logs view (sub-step failures stay at debug). + _LOGGER.warning("History-import scan failed for %s: %s", entry_id, exc) + reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ha_washdata/start_history_import_scan", + vol.Required("entry_id"): str, + vol.Required("token"): str, + } +) +@callback +def ws_start_history_import_scan( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Kick off the replay as a detached, registry-tracked task.""" + entry_id: str = msg["entry_id"] + if _get_manager(hass, entry_id) is None: + _err_not_found(connection, msg["id"], entry_id) + return + if _history_slot(hass, entry_id, msg["token"]) is None: + connection.send_error(msg["id"], "not_found", "No upload in progress; start again") + return + reg = task_registry.get_registry(hass) + task = reg.create( + entry_id, "history_import", "Scanning power history", + label_key="task.history_import.scanning", + ) + _raw = hass.async_create_task( + _history_import_scan_task(hass, task, entry_id, msg["token"]) + ) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) + _send_result(connection, msg["id"], "start_history_import_scan", {"task_id": task.id}) + + +async def _history_import_apply_task( + hass: HomeAssistant, task: Any, entry_id: str, scan_task_id: str, accept: list[int] +) -> None: + """Persist the accepted candidates into ``backfill_cycles``.""" + reg = task_registry.get_registry(hass) + manager = _get_manager(hass, entry_id) + if manager is None: + reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") + return + scan = reg.get(scan_task_id) + # Ownership check: get_task_result is not entry-scoped, so without this a stale or + # foreign task id could write another device's cycles into this store. + if scan is None or scan.entry_id != entry_id or scan.kind != "history_import": + reg.finish(task, state=task_registry.STATE_ERROR, error="scan_expired") + return + try: + async with _entry_write_lock(hass, entry_id): + if _get_manager(hass, entry_id) is not manager: + reg.finish(task, state=task_registry.STATE_ERROR, error="device reloaded") + return + # Validate AND read the staging slot under the lock, not before it: two + # concurrent applies must not both pass a pre-lock check and each persist the + # same candidate (one whose dedup_key is None bypasses existing_dedup_keys), + # and the slot must not be swapped between the check and the read. + slot = _history_staging(hass).get(entry_id) + if not isinstance(slot, dict) or slot.get("scan_task_id") != scan_task_id: + # The registry keeps only the last 30 finished tasks per entry, and an + # entry reload clears the staging area, so a scan can legitimately be + # gone by now. + reg.finish(task, state=task_registry.STATE_ERROR, error="scan_expired") + return + cycles: list[dict[str, Any]] = list(slot.get("cycles") or []) + # Dedupe the client-supplied indices (order-preserving): a repeated index + # would visit the same candidate twice, and one whose dedup_key is None is + # not caught by the in-loop dedup set, so it would store a second copy. + wanted = list(dict.fromkeys(i for i in accept if 0 <= i < len(cycles))) + if not wanted: + reg.finish( + task, state=task_registry.STATE_DONE, + result={"imported": 0, "duplicates": 0}, + ) + return + store = manager.profile_store + target = store.get_backfill_cycles() + room = max(0, HISTORY_IMPORT_MAX_TOTAL_CYCLES - len(target)) + existing = history_import.existing_dedup_keys(store.iter_stored_cycles()) + id_pool = {c.get("id") for c in target if isinstance(c, dict)} + imported = 0 + duplicates = 0 + reg.update(task, total=len(wanted)) + for done, index in enumerate(wanted, start=1): + if task.cancel_requested: + break + raw = cycles[index] + key = history_import.dedup_key(raw.get("start_time"), raw.get("duration")) + if key is not None and key in existing: + duplicates += 1 + reg.update(task, done=done) + continue + if imported >= room: + break + store._add_cycle_data( # noqa: SLF001 - the bulk insert primitive + history_import.build_backfill_cycle(raw), + target=target, + id_pool=id_pool, + ) + if key is not None: + existing.add(key) + imported += 1 + reg.update(task, done=done) + if imported: + await store.async_save() + # "capped" means the cap decided where we stopped - not the user cancelling, + # and not the duplicates that were legitimately skipped. + capped = not task.cancel_requested and imported < (len(wanted) - duplicates) + # Read the total inside the lock: another task could append to the same + # backfill list after the lock releases, inflating a count read outside it. + total_backfill = len(target) + # Consume the slot we applied - but only if it is still that same object. + # A fresh upload (which does not take this lock) can replace the slot during + # the async_save() await above; clearing unconditionally would discard it. + if _history_staging(hass).get(entry_id) is slot: + async_clear_history_import(hass, entry_id) + manager.notify_update() + reg.finish( + task, + state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, + result={ + "imported": imported, + "duplicates": duplicates, + "capped": capped, + "total_backfill": total_backfill, + }, + ) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise + except Exception as exc: # pylint: disable=broad-exception-caught + # A failed apply is a failed data write; log at WARNING like the other task + # runners so it is visible in the default HA log and the panel Logs view. + _LOGGER.warning("History-import apply failed for %s: %s", entry_id, exc) + reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ha_washdata/apply_history_import", + vol.Required("entry_id"): str, + vol.Required("scan_task_id"): str, + vol.Required("accept"): list, + } +) +@callback +def ws_apply_history_import( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Persist the candidates the user kept, as a detached, registry-tracked task.""" + entry_id: str = msg["entry_id"] + if _get_manager(hass, entry_id) is None: + _err_not_found(connection, msg["id"], entry_id) + return + accept: list[int] = [] + for item in msg["accept"][:HISTORY_IMPORT_MAX_SEGMENTS]: + try: + accept.append(int(item)) + except (TypeError, ValueError): + continue + reg = task_registry.get_registry(hass) + task = reg.create( + entry_id, "history_import_apply", "Importing cycles", + label_key="task.history_import.importing", + ) + _raw = hass.async_create_task( + _history_import_apply_task(hass, task, entry_id, msg["scan_task_id"], accept) + ) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) + _send_result(connection, msg["id"], "apply_history_import", {"task_id": task.id}) diff --git a/custom_components/ha_washdata/ws_schema.py b/custom_components/ha_washdata/ws_schema.py index b86ecc02..4307d810 100644 --- a/custom_components/ha_washdata/ws_schema.py +++ b/custom_components/ha_washdata/ws_schema.py @@ -80,11 +80,16 @@ class DeviceInfo(TypedDict): current_power_w: float | None cycle_progress_pct: float | None suggestions_count: int + suggestion_keys: list[str] feedback_count: int recording: bool is_user_paused: bool manual_program: bool options: dict[str, Any] + # Device-resolved defaults for the cadence/ratio fields whose default varies by + # device type (#396/#393), so the device-list conflict/suggestion badges can score + # an unset field against the value the integration would use (matches the Settings tab). + option_defaults: dict[str, Any] class GetDevicesResponse(TypedDict): @@ -95,6 +100,7 @@ class GetDeviceCyclesResponse(TypedDict): entry_id: str cycles: list[dict[str, Any]] reference_cycles: list[dict[str, Any]] + backfill_cycles: list[dict[str, Any]] total: int has_more: bool @@ -103,6 +109,10 @@ class GetDeviceCyclesResponse(TypedDict): class GetOptionsResponse(TypedDict): options: dict[str, Any] + # Device-resolved defaults for the cadence settings whose default varies by + # device type (sampling_interval / watchdog_interval / start_duration_threshold), + # used by the panel as the render + conflict-check fallback for an unset field (#396). + defaults: dict[str, Any] class GetSettingsChangelogResponse(TypedDict): @@ -258,11 +268,15 @@ class RunSuggestionAnalysisResponse(TypedDict, total=False): # ─── Cycle curve / interactive editing ───────────────────────────────────────── class GetCyclePowerDataResponse(TypedDict, total=False): - """``cycle_id`` / ``samples`` / ``full_duration_s`` are always present; the - metadata keys are present only when the cycle is found.""" + """``cycle_id`` / ``samples`` / ``sample_count`` / ``decimated`` / + ``full_duration_s`` are always present; the metadata keys are present only + when the cycle is found. ``sample_count`` is the stored point count and + ``decimated`` is True when ``samples`` was thinned below it (#395).""" cycle_id: str samples: list[list[float]] + sample_count: int + decimated: bool full_duration_s: float start_time: str | None end_time: str | None @@ -272,13 +286,22 @@ class GetCyclePowerDataResponse(TypedDict, total=False): energy_kwh: float | None artifacts: list[dict[str, Any]] restart_gaps: list[Any] + # Capability keys spread from _cycle_capabilities(cycle, origin). ``is_reference`` + # is present for every cycle; the other three ride along for a non-``past`` cycle + # (reference/backfill), so declare them or the WS contract check flags them and the + # generated ws-types.d.ts omits the fields the panel needs to type. is_reference: bool + labelable: bool + editable: bool + cycle_origin: str class AnalyzeSplitResponse(TypedDict): segments: list[list[float]] split_offsets: list[float] samples: list[list[float]] + sample_count: int + decimated: bool full_duration_s: float @@ -513,6 +536,31 @@ class StartTaskResponse(TypedDict): task_id: str +class HistoryImportBeginResponse(TypedDict): + """Staging slot opened for a CSV upload (issue #344).""" + + token: str + max_bytes: int + chunk_bytes: int + + +class HistoryImportChunkResponse(TypedDict): + received_bytes: int + next_seq: int + + +class HistoryImportRecorderResponse(TypedDict): + """Staging slot filled from the recorder instead of an upload.""" + + token: str + rows: int + entity_id: str + days: int + # Oldest day actually queried (ISO date), for "read since ". + start_date: str + truncated: bool + + class SubscribeTasksResponse(TypedDict, total=False): """Empty ack for the ``subscribe_tasks`` subscription; the live data arrives as ``{"type": "task", "task": TaskSnapshot}`` event messages, not in this @@ -606,6 +654,25 @@ class StoreDeviceProfilesResponse(TypedDict, total=False): disabled: bool +class StoreCatalogEntryResponse(TypedDict, total=False): + """One appliance's catalog identity: its brand + device documents, resolved by id. + + ``brand`` / ``device`` are None when that entry is not in the catalog yet (not an + error -- it just means nobody has contributed it). Backs the settings form's status + badges without downloading the brand/device lists. + """ + device_id: str + brand: dict | None + device: dict | None + disabled: bool + + +class StoreRefreshCatalogResponse(TypedDict, total=False): + """Acknowledgement that the cached catalog was dropped.""" + ok: bool + disabled: bool + + class StoreUploadDeviceResponse(TypedDict, total=False): """Result of sharing a whole-device bundle (multi-profile, multi-cycle).""" ok: bool @@ -737,6 +804,11 @@ WS_RESPONSE_TYPES: dict[str, type] = { "start_playground_history": StartTaskResponse, "start_playground_sweep": StartTaskResponse, "start_playground_cycle_detail": StartTaskResponse, + "history_import_begin": HistoryImportBeginResponse, + "history_import_chunk": HistoryImportChunkResponse, + "history_import_recorder": HistoryImportRecorderResponse, + "start_history_import_scan": StartTaskResponse, + "apply_history_import": StartTaskResponse, "store_status": StoreStatusResponse, "store_connect": StoreSimpleResponse, "store_disconnect": StoreSimpleResponse, @@ -752,6 +824,8 @@ WS_RESPONSE_TYPES: dict[str, type] = { "store_set_online": StoreOnlineResponse, "store_set_prefs": StorePrefsResponse, "store_get_device_profiles": StoreDeviceProfilesResponse, + "store_get_catalog_entry": StoreCatalogEntryResponse, + "store_refresh_catalog": StoreRefreshCatalogResponse, "store_upload_device": StoreUploadDeviceResponse, "store_download_device": StoreDownloadDeviceResponse, "get_shareable_cycles": GetShareableCyclesResponse, @@ -1006,7 +1080,10 @@ WS_COMMANDS: dict[str, dict] = { _p("cycle_id", "str"), _p("profile_name", "str|null", False), ]}, - "get_playground_settings": {"params": [_entry()]}, + "get_playground_settings": {"params": [ + _entry(), + _p("include_suggestions", "bool", False), + ]}, "save_playground_preset": {"params": [ _entry(), _p("name", "str"), @@ -1037,6 +1114,26 @@ WS_COMMANDS: dict[str, dict] = { _p("stress_tail", "bool", False), _p("stress_idle_w", "float|null", False), ]}, + "history_import_begin": {"params": [_entry()]}, + "history_import_chunk": {"params": [ + _entry(), + _p("token", "str"), + _p("seq", "int"), + _p("text", "str"), + ]}, + "history_import_recorder": {"params": [ + _entry(), + # Either bound: `start_date` (an ISO local calendar day, what the panel's date + # picker sends) wins over the legacy `days` count when both are present. + _p("start_date", "str|null", False), + _p("days", "int", False), + ]}, + "start_history_import_scan": {"params": [_entry(), _p("token", "str")]}, + "apply_history_import": {"params": [ + _entry(), + _p("scan_task_id", "str"), + _p("accept", "list"), + ]}, # Community store (online features) "store_status": {"params": [_entry()]}, "store_connect": {"params": [ @@ -1054,6 +1151,8 @@ WS_COMMANDS: dict[str, dict] = { "store_get_cycles": {"params": [_entry(), _p("profile_id", "str")]}, "store_get_device_quality": {"params": [_entry(), _p("device_id", "str")]}, "store_get_device_profiles": {"params": [_entry(), _p("brand", "str"), _p("model", "str"), _p("appliance_type", "str")]}, + "store_get_catalog_entry": {"params": [_entry(), _p("brand", "str"), _p("model", "str"), _p("appliance_type", "str")]}, + "store_refresh_catalog": {"params": [_entry()]}, "store_confirm_device": {"params": [_entry(), _p("device_id", "str")]}, "store_rate_device": {"params": [_entry(), _p("device_id", "str"), _p("rating", "int")]}, "store_set_online": {"params": [_entry(), _p("enabled", "bool")]}, diff --git a/custom_components/ha_washdata/www/build-manifest.json b/custom_components/ha_washdata/www/build-manifest.json new file mode 100644 index 00000000..2f70ada9 --- /dev/null +++ b/custom_components/ha_washdata/www/build-manifest.json @@ -0,0 +1,19 @@ +{ + "generator": "esbuild 0.25.12", + "assets": { + "ha-washdata-panel.js": { + "artifact": "ha-washdata-panel.min.js", + "source_sha256": "b7065b51086421552eecaa395f8dfd743b2f0b646f9ad80a5e3e955dc54b5926", + "artifact_sha256": "92d975bc6c11acff7060c2bd11d1ad09b1c43b0575e1655d7bec67a9438cd327", + "source_bytes": 843545, + "artifact_bytes": 564506 + }, + "ha-washdata-card.js": { + "artifact": "ha-washdata-card.min.js", + "source_sha256": "71a6d762af2ab78525a3bc169d6bfa682ce5f79ab3bd23a808059102a1d223e3", + "artifact_sha256": "f7e8ab6492010f5ad6875e0c2969a5fe43ff3040ac96ada665cd61016963ea16", + "source_bytes": 50627, + "artifact_bytes": 29435 + } + } +} diff --git a/custom_components/ha_washdata/www/ha-washdata-card.min.js b/custom_components/ha_washdata/www/ha-washdata-card.min.js new file mode 100644 index 00000000..9709c649 --- /dev/null +++ b/custom_components/ha_washdata/www/ha-washdata-card.min.js @@ -0,0 +1,11 @@ +/*! + * 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 file is a MINIFIED BUILD. The corresponding readable source ships in the + * same directory and is the licensed, preferred form for modification. + * This program comes with ABSOLUTELY NO WARRANTY. See the GNU Affero General + * Public License for details: . + */ +const I="ha-washdata-card",B="ha-washdata-card-editor",S="ha_washdata",V=500,Y=250,K=10,D={state:"washer_state",program:"washer_program",time:"time_remaining",progress:"cycle_progress",power:"current_power",energy:"energy_total",phase:"current_phase"},j={pause:"pause_cycle",resume:"resume_cycle",terminate:"force_end_cycle",record_start:"record_start",record_stop:"record_stop"},N={pause:"mdi:pause",resume:"mdi:play",terminate:"mdi:stop",record_start:"mdi:record-circle-outline",record_stop:"mdi:stop-circle-outline",open_panel:"mdi:open-in-new"},O=["pause","resume","terminate","record_start","record_stop","program","open_panel"],H=["running","paused","user_paused","ending","starting","anti_wrinkle","rinse"],x=["off","unknown","unavailable","idle"],z=60,C=new WeakMap;function W(u){const t=u&&u.connection;if(!t)return Promise.resolve({stateColors:{}});if(C.has(t))return C.get(t);const e=u.callWS({type:S+"/get_constants"}).then(i=>({stateColors:i&&i.state_colors||{}})).catch(()=>({stateColors:{}}));return C.set(t,e),e}const y={},k={};function U(u){return"/"+S+"/panel-translations/"+encodeURIComponent(u)+".json"}async function G(u){if(!u)return null;const t=[u],e=u.indexOf("-");e>0&&t.push(u.slice(0,e));for(const i of t)try{const s=await fetch(U(i));if(s.ok){const n=await s.json();if(n&&typeof n=="object")return n}}catch{}return null}async function v(u){if(!u||y[u])return;if(k[u]){await k[u];return}const t=G(u).then(e=>{e&&(y[u]=e)});k[u]=t,await t}class F extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"}),this._cfg=null,this._hass=null,this._builtSig=null,this._constantsLoaded=!1,this._langEnsured="",this._constants=null,this._spark=[],this._sparkState=null,this._holdTimer=null,this._holdTriggered=!1,this._tapTimer=null,this._lastTapTime=0,this._pointerStart=null,this._pointerCanceled=!1,this._onPointerDown=this._onPointerDown.bind(this),this._onPointerMove=this._onPointerMove.bind(this),this._onPointerUp=this._onPointerUp.bind(this),this._onPointerCancel=this._onPointerCancel.bind(this)}static getStubConfig(){return{entity:"sensor.washing_machine_state",layout:"tile",tap_action:{action:"more-info"},hold_action:{action:"none"},double_tap_action:{action:"none"}}}static getConfigElement(){return document.createElement(B)}setConfig(t){if(!t)throw new Error("Invalid configuration");const e=t.layout||"tile";if(e!=="glance"&&!t.entity)throw new Error("Please define an entity");if(e==="glance"&&!(Array.isArray(t.entities)&&t.entities.length)&&!t.entity)throw new Error("Please define entities for the glance layout");this._cfg={...t,layout:e},this._builtSig!==this._structureSig()&&(this._builtSig=null,this.shadowRoot&&(this.shadowRoot.innerHTML="")),this._render()}_structureSig(){const t=this._cfg||{},e=this._flags(),i=t.layout||"tile";return[i,e.showBar?"bar":"",e.showSparkline?"spark":"",e.buttons.join("+"),i==="glance"?String(this._glanceEntities().length):""].join("|")}set hass(t){this._hass=t,this._ensureResources(),this._render()}_detailRows(){const t=this._flags();let e=2;return t.buttons.length&&(e+=1),t.showSparkline&&(e+=1),e}getCardSize(){const t=this._cfg&&this._cfg.layout;return t==="detail"?this._detailRows():t==="glance"&&this._glanceEntities().length||1}getGridOptions(){const t=this._cfg&&this._cfg.layout;if(t==="detail"){const e=this._detailRows();return{rows:e,min_rows:e,columns:12,min_columns:6}}return t==="glance"?{rows:Math.max(1,this._glanceEntities().length),min_rows:1,columns:12,min_columns:6}:{rows:1,min_rows:1,columns:6,min_columns:3}}disconnectedCallback(){this._clearHoldTimer(),this._tapTimer&&(window.clearTimeout(this._tapTimer),this._tapTimer=null)}_lang(){const t=this._hass&&this._hass.locale&&this._hass.locale.language||this._hass&&this._hass.language||"en";return typeof t=="string"&&t?t:"en"}_tLookup(t,e){const i=y[e];if(!i)return null;const s=t.split(".").reduce((n,o)=>n&&n[o]!==void 0?n[o]:null,i);return s&&typeof s=="string"?s:null}_t(t,e,i){const s=this._lang();let n=s&&this._tLookup(t,s)||this._tLookup(t,"en")||i||t;if(e)for(const[o,a]of Object.entries(e))n=n.replace(new RegExp("\\{"+o+"\\}","g"),String(a));return n}_ensureResources(){if(!this._hass)return;const t=this._lang(),e=!this._constantsLoaded,i=t!==this._langEnsured;if(!e&&!i)return;const s=[];e&&(this._constantsLoaded=!0,s.push(W(this._hass).then(n=>{this._constants=n}),v("en"))),i&&(this._langEnsured=t,t&&t!=="en"&&s.push(v(t))),Promise.all(s).then(()=>this._update()).catch(()=>{})}_resolveRoles(t){const e=this._hass,i=this._cfg||{},s=e&&e.entities||{},n=t||i.entity,o=s[n],a=i.device_id||o&&o.device_id||null,r={state:n},c={};let d=null;if(a)for(const[l,h]of Object.entries(s)){if(!h||h.device_id!==a||h.platform&&h.platform!==S)continue;const p=h.translation_key;if(l.startsWith("sensor."))for(const[f,g]of Object.entries(D))p===g&&!r[f]&&(r[f]=l);for(const[f,g]of Object.entries(j))p===g&&!c[f]&&(c[f]=l);!d&&l.startsWith("select.")&&(d=l)}if(i.program_entity&&(r.program=i.program_entity),i.time_entity&&(r.time=i.time_entity),i.pct_entity&&(r.progress=i.pct_entity),i.power_entity&&(r.power=i.power_entity),i.energy_entity&&(r.energy=i.energy_entity),i.phase_entity&&(r.phase=i.phase_entity),!r.time||!r.progress||!r.program){const l=/^sensor\.(.+)_state$/.exec(n);if(l){const h="sensor."+l[1]+"_",p=e&&e.states,f=g=>p&&p[h+g]?h+g:null;r.time=r.time||f("time_remaining"),r.progress=r.progress||f("cycle_progress"),r.program=r.program||f("program")}}return r._buttons=c,r._select=d,r._deviceId=a,r}_fmtState(t){if(!t)return"";const e=this._hass;if(e&&typeof e.formatEntityState=="function")try{return e.formatEntityState(t)}catch{}const i=t.state||"";return i&&i.charAt(0).toUpperCase()+i.slice(1)}_vm(t){const e=this._hass,i=this._cfg,s=e.states,n=s[t.state];if(!n)return{missing:!0,missingEntity:t.state};const o=String(n.state||"").toLowerCase(),a=x.includes(o),r=H.includes(o),c=n.attributes||{};let d="";const l=t.program?s[t.program]:null;if(l){const _=String(l.state||"").toLowerCase();["unknown","none","off","unavailable",""].includes(_)||(d=this._fmtState(l))}if(!d&&c.current_program_guess){const _=String(c.current_program_guess).toLowerCase();["unknown","none","off","unavailable"].includes(_)||(d=c.current_program_guess)}let h="";const p=t.phase?s[t.phase]:null;if(p){const _=String(p.state||"").toLowerCase();["unknown","none","off","unavailable",""].includes(_)||(h=this._fmtState(p))}!h&&l&&l.attributes&&l.attributes.active_phase&&(h=l.attributes.active_phase);let f="";if(o==="running"&&c.sub_state){const _=String(c.sub_state).match(/Running \((.*)\)/);f=_&&_[1]?_[1]:c.sub_state}let g=null;const m=t.progress?s[t.progress]:null;m&&!isNaN(parseFloat(m.state))&&(g=Math.max(0,Math.min(100,Math.round(parseFloat(m.state)))));let b="";const w=t.time?s[t.time]:null;w&&!x.includes(String(w.state).toLowerCase())&&(e&&typeof e.formatEntityState=="function"?b=this._fmtState(w):isNaN(parseFloat(w.state))?b=w.state:b=w.state+" "+this._t("card.min",null,"min"));let R="",A="";if(m&&m.attributes){const _=m.attributes.projected_energy_kwh;_!=null&&(R=_+" kWh");const T=m.attributes.projected_cost;T!=null&&(A=this._fmtCost(T))}let L=null;const E=t.power?s[t.power]:null;E&&!isNaN(parseFloat(E.state))&&(L=parseFloat(E.state));let P=null;c.cycle_anomaly&&c.cycle_anomaly!=="none"&&(P={kind:c.cycle_anomaly,ratio:c.overrun_ratio});const M=this._stateColor(o,r,i.active_color);return{missing:!1,sk:o,stateObj:n,isInactive:a,isActive:r,isRunning:o==="running",stateLabel:this._fmtState(n),color:M,program:d,phase:h,subState:f,pct:g,timeText:b,energyText:R,costText:A,powerW:L,anomaly:P,buttons:t._buttons||{},select:t._select||null}}_fmtCost(t){const e=this._hass,i=e&&e.config&&e.config.currency;if(i)try{return new Intl.NumberFormat(this._lang(),{style:"currency",currency:i}).format(Number(t))}catch{}return String(t)}_stateColor(t,e,i){if(e&&i){if(Array.isArray(i)){const[o,a,r]=i;return{fg:"rgb("+o+","+a+","+r+")",bg:"rgba("+o+","+a+","+r+",0.2)"}}return{fg:i,bg:"rgba(128,128,128,0.15)"}}const n=(this._constants&&this._constants.stateColors||{})[t];return n?{fg:n,bg:this._alpha(n,t)}:x.includes(t)?{fg:"var(--disabled-text-color, grey)",bg:"rgba(128,128,128,0.1)"}:{fg:"var(--primary-color)",bg:"rgba(var(--rgb-primary-color, 33,150,243),0.2)"}}_alpha(t,e){const i=/#([0-9a-fA-F]{6})/.exec(t);if(i){const s=parseInt(i[1],16),n=s>>16&255,o=s>>8&255,a=s&255;return"rgba("+n+","+o+","+a+",0.18)"}return x.includes(e)?"rgba(128,128,128,0.1)":"rgba(128,128,128,0.15)"}_deviceTypeIcon(){const t=this._hass,e=this._cfg;if(e.icon)return e.icon;const i=t&&t.states[e.entity];return i&&i.attributes&&i.attributes.icon?i.attributes.icon:"mdi:washing-machine"}_titleText(){const t=this._cfg;if(t.title)return t.title;const e=this._hass,i=e&&e.entities||{},s=e&&e.devices||{},n=i[t.entity];if(n&&n.device_id&&s[n.device_id]){const a=s[n.device_id],r=a.name_by_user||a.name;if(r)return r}const o=e&&e.states[t.entity];return o&&o.attributes&&o.attributes.friendly_name?String(o.attributes.friendly_name).replace(/ (State|Status)$/i,""):"WashData"}_flags(){const t=this._cfg||{};return{showState:t.show_state!==!1,showProgram:t.show_program!==!1,showDetails:t.show_details!==!1,showBar:t.show_progress_bar!==!1,showPhase:t.show_phase!==!1,showEnergy:t.show_energy!==!1,showAnomaly:t.show_anomaly!==!1,showSparkline:!!t.show_sparkline,displayMode:t.display_mode||"time",buttons:Array.isArray(t.buttons)?t.buttons.filter(e=>O.includes(e)):[]}}_render(){if(!this.shadowRoot||!this._cfg)return;const t=this._structureSig();this._builtSig!==t&&(this._build(this._cfg.layout||"tile"),this._builtSig=t),this._update()}_baseStyle(){return":host{display:block;height:100%}ha-card{padding:0;background:var(--ha-card-background,var(--card-background-color,white));border-radius:var(--ha-card-border-radius,12px);box-shadow:var(--ha-card-box-shadow,none);overflow:hidden;cursor:pointer;height:100%;box-sizing:border-box;border:var(--ha-card-border-width,1px) solid var(--ha-card-border-color,var(--divider-color))}.icon-container{border-radius:12px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background-color .3s,color .3s;position:relative}ha-icon{--mdc-icon-size:24px}.primary{font-weight:500;color:var(--primary-text-color);white-space:nowrap;text-overflow:ellipsis;overflow:hidden;line-height:1.2}.secondary{color:var(--secondary-text-color);white-space:nowrap;text-overflow:ellipsis;overflow:hidden;line-height:1.2}.bar{height:4px;border-radius:2px;background:var(--divider-color,rgba(128,128,128,.25));overflow:hidden}.bar>i{display:block;height:100%;width:0;border-radius:2px;transition:width .5s ease,background-color .3s}.chip{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:10px;background:rgba(128,128,128,.14);color:var(--secondary-text-color);white-space:nowrap}.chip.warn{background:rgba(255,152,0,.16);color:var(--warning-color,#ff9800)}.acts{display:flex;gap:4px;align-items:center}.wd-act{border:none;background:rgba(128,128,128,.12);color:var(--primary-text-color);border-radius:10px;width:34px;height:34px;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}.wd-act:hover{background:rgba(128,128,128,.22)}.wd-act[disabled]{opacity:.35;pointer-events:none}.wd-act ha-icon{--mdc-icon-size:20px}select.wd-prog{max-width:150px;font:inherit;color:var(--primary-text-color);background:var(--secondary-background-color);border:1px solid var(--divider-color);border-radius:8px;padding:4px 6px}"}_build(t){return t==="detail"?this._buildDetail():t==="glance"?this._buildGlance():this._buildTile()}_attachGestures(t){t.addEventListener("pointerdown",this._onPointerDown),t.addEventListener("pointermove",this._onPointerMove),t.addEventListener("pointerup",this._onPointerUp),t.addEventListener("pointercancel",this._onPointerCancel),t.addEventListener("pointerleave",this._onPointerCancel)}_buildTile(){const t=this._flags(),e=this._baseStyle()+".tile{display:flex;flex-direction:row;align-items:center;padding:8px 12px;gap:12px;width:100%;box-sizing:border-box}.tile .icon-container{width:40px;height:40px}.tile .info{display:flex;flex-direction:column;justify-content:center;overflow:hidden;flex:1;min-width:0}.tile .primary{font-size:14px}.tile .secondary{font-size:12px;margin-top:2px}.tile .bar{margin-top:6px}";this.shadowRoot.innerHTML="
'+(t.showBar?'
':"")+"
",this._attachGestures(this.shadowRoot.getElementById("card"))}_buildDetail(){const t=this._flags(),e=this._baseStyle()+"ha-card.wd-detail{height:auto}.detail{display:flex;flex-direction:column;gap:8px;padding:12px 14px;width:100%;box-sizing:border-box}.detail .top{display:flex;align-items:center;gap:12px}.detail .icon-container{width:48px;height:48px}.detail .icon-container ha-icon{--mdc-icon-size:30px}.detail .info{flex:1;min-width:0}.detail .primary{font-size:16px}.detail .secondary{font-size:13px;margin-top:2px}.detail .eta{text-align:right;flex-shrink:0}.detail .eta .big{font-size:22px;font-weight:600;color:var(--primary-text-color);line-height:1.1}.detail .eta .lbl{font-size:11px;color:var(--secondary-text-color);text-transform:uppercase;letter-spacing:.04em}.detail .bar{height:6px}.detail .meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center}.detail .meta:empty{display:none}.detail canvas{width:100%;height:34px;display:block}";this.shadowRoot.innerHTML="
'+(t.showBar?'
':"")+'
'+(t.showSparkline?'':"")+(t.buttons.length?'
':"")+"
",this._attachGestures(this.shadowRoot.getElementById("card")),t.buttons.length&&this._buildButtons(this.shadowRoot.getElementById("acts"),t.buttons)}_buildButtons(t,e){if(t){t.innerHTML="";for(const i of e){if(i==="program"){const o=document.createElement("select");o.className="wd-prog",o.id="prog-select",o.addEventListener("click",a=>a.stopPropagation()),o.addEventListener("pointerdown",a=>a.stopPropagation()),o.addEventListener("change",a=>{a.stopPropagation(),this._onProgramChange(a.target.value)}),t.appendChild(o);continue}const s=document.createElement("button");s.className="wd-act",s.dataset.btn=i,s.title=this._t("card.btn."+i,null,this._defaultBtnLabel(i)),s.addEventListener("pointerdown",o=>o.stopPropagation()),s.addEventListener("pointerup",o=>o.stopPropagation()),s.addEventListener("click",o=>{o.stopPropagation(),this._onActionButton(i)});const n=document.createElement("ha-icon");n.setAttribute("icon",N[i]||"mdi:gesture-tap-button"),s.appendChild(n),t.appendChild(s)}}}_defaultBtnLabel(t){return{pause:"Pause",resume:"Resume",terminate:"End cycle",record_start:"Start recording",record_stop:"Stop recording",open_panel:"Open WashData"}[t]||t}_buildGlance(){const t=this._baseStyle()+".glance{display:flex;flex-direction:column;padding:6px 0}.row{display:flex;align-items:center;gap:12px;padding:8px 14px;cursor:pointer}.row:hover{background:rgba(128,128,128,.06)}.dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}.row .info{flex:1;min-width:0}.row .primary{font-size:14px}.row .secondary{font-size:12px;margin-top:1px}.row .rt{font-size:13px;color:var(--secondary-text-color);flex-shrink:0;text-align:right}",e=this._glanceEntities().map((i,s)=>'
').join("");this.shadowRoot.innerHTML="
'+e+"
",this.shadowRoot.querySelectorAll(".row").forEach(i=>{i.addEventListener("click",()=>{const s=parseInt(i.dataset.idx,10),n=this._glanceEntities()[s];n&&this._moreInfo(n)})})}_glanceEntities(){const t=this._cfg||{};return Array.isArray(t.entities)&&t.entities.length?t.entities.map(e=>typeof e=="string"?e:e&&e.entity).filter(Boolean):t.entity?[t.entity]:[]}_update(){if(!this.shadowRoot||!this._hass||!this._cfg)return;const t=this._cfg.layout||"tile";if(t==="glance")return this._updateGlance();const e=this._resolveRoles(),i=this._vm(e);return t==="detail"?this._updateDetail(i):this._updateTile(i)}_applyIcon(t){const e=this.shadowRoot.getElementById("icon"),i=this.shadowRoot.getElementById("iconc");!e||!i||(e.setAttribute("icon",this._deviceTypeIcon()),i.style.color=t.color.fg,i.style.background=t.color.bg)}_applyBar(t){const e=this.shadowRoot.getElementById("barfill"),i=this.shadowRoot.getElementById("bar");if(!(!e||!i)){if(!t.isActive||t.pct===null){i.style.visibility="hidden",e.style.width="0%";return}i.style.visibility="visible",e.style.width=t.pct+"%",e.style.background=t.color.fg}}_updateTile(t){const e=this.shadowRoot.getElementById("title"),i=this.shadowRoot.getElementById("state");if(t.missing){e&&(e.textContent=this._t("card.entity_not_found",null,"Entity not found")),i&&(i.textContent=t.missingEntity||"");return}e.textContent=this._titleText(),this._applyIcon(t),this._applyBar(t);const s=this._flags(),n=[];s.showState&&(t.isRunning?t.subState&&n.push(t.subState):n.push(t.stateLabel)),s.showProgram&&t.program&&n.push(t.program),!t.isInactive&&s.showDetails&&(s.displayMode==="percentage"&&t.pct!==null?n.push(t.pct+"%"):t.timeText?n.push(t.timeText):t.pct!==null&&n.push(t.pct+"%")),s.showAnomaly&&t.anomaly&&n.push(this._t("card.running_long",null,"running long")),i.textContent=n.join(" \u2022 ")}_updateDetail(t){const e=this.shadowRoot.getElementById("title"),i=this.shadowRoot.getElementById("state"),s=this.shadowRoot.getElementById("eta"),n=this.shadowRoot.getElementById("etalbl"),o=this.shadowRoot.getElementById("etawrap"),a=this.shadowRoot.getElementById("meta");if(t.missing){e&&(e.textContent=this._t("card.entity_not_found",null,"Entity not found")),i&&(i.textContent=t.missingEntity||"");return}e.textContent=this._titleText(),this._applyIcon(t),this._applyBar(t);const r=this._flags(),c=[];if(r.showState&&c.push(t.isRunning&&t.subState?t.subState:t.stateLabel),r.showProgram&&t.program&&c.push(t.program),i.textContent=c.join(" \u2022 "),o&&s&&n&&(t.isActive&&t.timeText?(s.textContent=t.timeText,n.textContent=this._t("card.remaining",null,"remaining"),o.style.visibility="visible"):t.isActive&&t.pct!==null?(s.textContent=t.pct+"%",n.textContent=this._t("card.progress",null,"progress"),o.style.visibility="visible"):o.style.visibility="hidden"),a){a.innerHTML="";const d=(l,h)=>{if(!l)return;const p=document.createElement("span");p.className="chip"+(h?" warn":""),p.textContent=l,a.appendChild(p)};if(r.showPhase&&t.phase&&t.isActive&&d(t.phase),r.showEnergy&&t.energyText&&d(t.energyText),r.showEnergy&&t.costText&&d(t.costText),t.isRunning&&t.powerW!==null&&d(Math.round(t.powerW)+" W"),r.showAnomaly&&t.anomaly){const l=t.anomaly.ratio?" ("+Math.round((t.anomaly.ratio-1)*100)+"%)":"";d(this._t("card.running_long",null,"Running long")+l,!0)}}this._updateButtons(t),r.showSparkline&&this._updateSparkline(t)}_updateButtons(t){const e=this.shadowRoot.getElementById("acts");if(!e)return;const i=t.sk,s={pause:i==="running",resume:i==="paused"||i==="user_paused",terminate:t.isActive,record_start:!t.isActive,record_stop:!0,open_panel:!0};e.querySelectorAll(".wd-act").forEach(o=>{const a=o.dataset.btn,r=a==="open_panel"||a==="program"?!0:t.buttons[a];s[a]!==!1&&(a==="open_panel"||r)?o.removeAttribute("disabled"):o.setAttribute("disabled","")});const n=this.shadowRoot.getElementById("prog-select");if(n&&t.select){const o=this._hass.states[t.select],a=o&&o.attributes&&o.attributes.options||[],r=o&&o.state,c=a.join("|");if(n._sig!==c){n._sig=c,n.innerHTML="";for(const d of a){const l=document.createElement("option");l.value=d,l.textContent=d,n.appendChild(l)}}r!==void 0&&(n.value=r)}else n&&n.setAttribute("disabled","")}_updateSparkline(t){const e=this.shadowRoot.getElementById("spark");if(!e)return;if(t.sk!==this._sparkState&&(t.isActive||(this._spark=[]),this._sparkState=t.sk),t.isActive&&t.powerW!==null){const d=this._spark[this._spark.length-1],l=Date.now();(!d||l-d.t>2e3)&&(this._spark.push({t:l,w:t.powerW}),this._spark.length>z&&this._spark.shift())}const i=this._spark;if(i.length<2){e.style.display="none";return}e.style.display="block";const s=e.clientWidth||300,n=34;e.width!==s&&(e.width=s),e.height!==n&&(e.height=n);const o=e.getContext("2d");o.clearRect(0,0,s,n);const a=Math.max.apply(null,i.map(d=>d.w))||1,r=3,c=(s-r*2)/(i.length-1);o.beginPath(),i.forEach((d,l)=>{const h=r+l*c,p=n-r-d.w/a*(n-r*2);l===0?o.moveTo(h,p):o.lineTo(h,p)}),o.strokeStyle=t.color.fg,o.lineWidth=2,o.lineJoin="round",o.stroke()}_updateGlance(){const t=this._glanceEntities(),e=this.shadowRoot.querySelectorAll(".row");t.forEach((i,s)=>{const n=e[s];if(!n)return;const o=this._resolveRoles(i),a=this._vm(o),r=n.querySelector("[data-dot]"),c=n.querySelector("[data-title]"),d=n.querySelector("[data-sub]"),l=n.querySelector("[data-rt]");if(a.missing){c&&(c.textContent=i),d&&(d.textContent=this._t("card.entity_not_found",null,"Entity not found")),r&&(r.style.background="var(--disabled-text-color, grey)"),l&&(l.textContent="");return}r&&(r.style.background=a.color.fg),c&&(c.textContent=this._deviceNameFor(i));const h=[];a.isRunning&&a.subState?h.push(a.subState):h.push(a.stateLabel),a.program&&h.push(a.program),d&&(d.textContent=h.join(" \u2022 ")),l&&(a.isActive&&a.timeText?l.textContent=a.timeText:a.isActive&&a.pct!==null?l.textContent=a.pct+"%":l.textContent="")})}_deviceNameFor(t){const e=this._hass,i=e&&e.entities||{},s=e&&e.devices||{},n=i[t];if(n&&n.device_id&&s[n.device_id]){const a=s[n.device_id],r=a.name_by_user||a.name;if(r)return r}const o=e&&e.states[t];return o&&o.attributes&&o.attributes.friendly_name?String(o.attributes.friendly_name).replace(/ (State|Status)$/i,""):t}_onActionButton(t){const e=this._hass;if(!e)return;if(t==="open_panel"){this._navigate("/ha-washdata");return}const i=this._resolveRoles(),s=i._buttons&&i._buttons[t];s&&(this._fireHaptic("light"),e.callService("button","press",{entity_id:s}))}_onProgramChange(t){const e=this._hass;if(!e||!t)return;const s=this._resolveRoles()._select;s&&e.callService("select","select_option",{entity_id:s,option:t})}_moreInfo(t){t&&this.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:t},bubbles:!0,composed:!0}))}_navigate(t){window.history.pushState(null,"",t),window.dispatchEvent(new CustomEvent("location-changed",{detail:{replace:!1}}))}_clearHoldTimer(){this._holdTimer&&(window.clearTimeout(this._holdTimer),this._holdTimer=null)}_onPointerDown(t){if(t.button!==void 0&&t.button!==0)return;this._holdTriggered=!1,this._pointerCanceled=!1,this._pointerStart={x:t.clientX,y:t.clientY};const e=this._cfg&&this._cfg.hold_action;e&&e.action&&e.action!=="none"&&(this._clearHoldTimer(),this._holdTimer=window.setTimeout(()=>{this._holdTimer=null,this._holdTriggered=!0,this._fireHaptic("success"),this._executeAction(e)},500))}_onPointerMove(t){if(!this._pointerStart)return;const e=t.clientX-this._pointerStart.x,i=t.clientY-this._pointerStart.y;e*e+i*i>100&&(this._clearHoldTimer(),this._pointerStart=null,this._pointerCanceled=!0)}_onPointerCancel(){this._clearHoldTimer()}_onPointerUp(){if(this._clearHoldTimer(),this._pointerCanceled){this._pointerCanceled=!1;return}if(this._holdTriggered){this._holdTriggered=!1;return}const t=this._cfg&&this._cfg.tap_action||{action:"more-info"},e=this._cfg&&this._cfg.double_tap_action;if(!(e&&e.action&&e.action!=="none")){this._executeAction(t);return}const s=Date.now();if(this._tapTimer&&s-this._lastTapTime<250){window.clearTimeout(this._tapTimer),this._tapTimer=null,this._lastTapTime=0,this._executeAction(e);return}this._lastTapTime=s,this._tapTimer=window.setTimeout(()=>{this._tapTimer=null,this._executeAction(t)},250)}_fireHaptic(t){this.dispatchEvent(new CustomEvent("haptic",{detail:t,bubbles:!0,composed:!0}))}_executeAction(t){if(!t)return;const e=t.action||"more-info",i=t.entity||this._cfg&&this._cfg.entity;switch(e){case"none":return;case"more-info":this._moreInfo(i);return;case"toggle":if(!this._hass||!i)return;this._hass.callService("homeassistant","toggle",{entity_id:i});return;case"call-service":case"perform-action":{const s=t.perform_action||t.service;if(!s||!this._hass)return;const[n,o]=s.split(".");if(!n||!o)return;const a={...t.data||t.service_data||{}};this._hass.callService(n,o,a,t.target);return}case"navigate":{const s=t.navigation_path;if(!s)return;t.navigation_replace?(window.history.replaceState(window.history.state,"",s),window.dispatchEvent(new CustomEvent("location-changed",{detail:{replace:!0}}))):this._navigate(s);return}case"url":{const s=t.url_path;if(!s)return;window.open(s,"_blank","noopener,noreferrer");return}default:return}}}class q extends HTMLElement{_lang(){const t=this._hass&&this._hass.locale&&this._hass.locale.language||this._hass&&this._hass.language||"en";return typeof t=="string"&&t?t:"en"}_t(t,e){const i=this._lang(),s=y[i]||y.en;if(s){const n=t.split(".").reduce((o,a)=>o&&o[a]!==void 0?o[a]:null,s);if(n&&typeof n=="string")return n}return e||t}setConfig(t){this._cfg={layout:"tile",...t},this._render()}set hass(t){this._hass=t;const e=this._lang(),i=[];y.en||i.push(v("en")),e&&e!=="en"&&!y[e]&&i.push(v(e)),i.length&&Promise.all(i).then(()=>this._render()),this._form&&(this._form.hass=t)}_render(){this.shadowRoot||this.attachShadow({mode:"open"}),this._form||(this.shadowRoot.innerHTML='
',this._form=document.createElement("ha-form"),this.shadowRoot.getElementById("editor-container").appendChild(this._form),this._form.addEventListener("value-changed",t=>this._valueChanged(t)),this._form.computeLabel=t=>this._t("card.editor."+t.name,this._humanize(t.name))),this._form.schema=this._schema(),this._form.data=this._cfg,this._hass&&(this._form.hass=this._hass)}_humanize(t){return t.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase())}_schema(){const t=this._cfg&&this._cfg.layout||"tile",e={name:"layout",selector:{select:{mode:"dropdown",options:[{value:"tile",label:this._t("card.layout.tile","Tile (compact)")},{value:"detail",label:this._t("card.layout.detail","Detail (rich)")},{value:"glance",label:this._t("card.layout.glance","Glance (multiple devices)")}]}}};if(t==="glance")return[e,{name:"entities",selector:{entity:{domain:"sensor",multiple:!0}}},{name:"title",selector:{text:{}}}];const i=[e,{name:"entity",selector:{entity:{domain:"sensor"}}},{name:"title",selector:{text:{}}},{name:"icon",selector:{icon:{}}},{name:"show_state",selector:{boolean:{}}},{name:"show_program",selector:{boolean:{}}},{name:"show_progress_bar",selector:{boolean:{}}},{name:"display_mode",selector:{select:{mode:"dropdown",options:[{value:"time",label:this._t("card.show_time_remaining","Show Time Remaining")},{value:"percentage",label:this._t("card.show_percentage","Show Percentage")}]}}},{name:"active_color",selector:{color_rgb:{}}}];return t==="detail"&&i.push({name:"show_phase",selector:{boolean:{}}},{name:"show_energy",selector:{boolean:{}}},{name:"show_anomaly",selector:{boolean:{}}},{name:"show_sparkline",selector:{boolean:{}}},{name:"buttons",selector:{select:{multiple:!0,mode:"list",options:O.map(s=>({value:s,label:this._t("card.btn."+s,this._humanize(s))}))}}}),i.push({name:"program_entity",selector:{entity:{domain:["sensor","select","input_select","input_text"]}}},{name:"time_entity",selector:{entity:{domain:"sensor"}}},{name:"pct_entity",selector:{entity:{domain:"sensor"}}},{name:"power_entity",selector:{entity:{domain:"sensor"}}},{name:"tap_action",selector:{ui_action:{}}},{name:"hold_action",selector:{ui_action:{}}},{name:"double_tap_action",selector:{ui_action:{}}}),i}_valueChanged(t){if(!this._cfg)return;const e=t.detail.value,i=e.layout&&e.layout!==this._cfg.layout;this._cfg={...this._cfg,...e},this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._cfg},bubbles:!0,composed:!0})),i&&this._render()}}customElements.define(I,F),customElements.define(B,q),window.customCards=window.customCards||[],window.customCards.push({type:I,name:"WashData Card",preview:!0,description:"Adaptive card for WashData appliances: compact tile, rich detail, or multi-device glance.",documentationURL:"https://github.com/3dg1luk43/ha_washdata"}); diff --git a/custom_components/ha_washdata/www/ha-washdata-panel.js b/custom_components/ha_washdata/www/ha-washdata-panel.js index 0c167c3f..bda61679 100644 --- a/custom_components/ha_washdata/www/ha-washdata-panel.js +++ b/custom_components/ha_washdata/www/ha-washdata-panel.js @@ -115,6 +115,8 @@ const _SETTINGS_SECTIONS = [ doc: 'During the off-delay countdown, accumulated energy (watts x time) is compared to this threshold. If exceeded, the countdown resets - keeping anti-crease tumbles and dishwasher drying tails attached to the cycle instead of cutting them short. Raise it if cycles end too early during cool-down; lower it if detection is sluggish.' }, { key: 'end_repeat_count', label: 'End Repeat Count', type: 'number', min: 1, def: 1, doc: 'Number of consecutive below-stop-threshold readings required before the cycle ends. 1 is fine for most plugs. Raise to 2-3 if your smart plug occasionally reports a false-zero sample mid-cycle and your cycles are ending prematurely.' }, + { key: 'smart_termination_duration_ratio', label: 'Smart Termination Ratio', type: 'number', step: 0.01, min: 0.5, max: 1.0, + doc: 'How far into the matched program\'s expected duration a cycle must be before Smart Termination may end it early once power drops. The expected duration is the program\'s average, so on appliances whose runtime varies a lot - washers on cold winter vs warm summer inlet water, sensor-dry dryers, load-dependent programs - about half of all runs finish shorter than that average and never get the fast finish, ending only via the fallback timeout minutes late. Lower this (e.g. 0.85) on those machines so the early finish still fires; raise it toward 1.0 to be more conservative. Leave empty for the default (0.98, or 0.99 for dishwashers). It can only ever end a cycle earlier, never later, and never fires on an ambiguous or low-confidence match.' }, ] }, { sub: 'Power Off', fields: [ { key: 'power_off_threshold_w', label: 'Power Off Threshold', unit: 'W', type: 'number', step: 0.1, min: 0, def: 0, @@ -148,6 +150,11 @@ const _SETTINGS_SECTIONS = [ { key: 'duration_tolerance', label: 'Estimate Tolerance', type: 'number', step: 0.01, min: 0, max: 1, def: 0.1, doc: 'Tolerance for time-remaining estimates (learning feedback, not matching). If the actual duration is within +/-X% of the estimate it counts as a good match.' }, ] }, + { sub: 'Profile Evidence', fields: [ + { key: 'profile_evidence_sources', label: 'Cycles that shape a program', type: 'checkboxlist', def: ['real_cycles', 'reference_cycles', 'backfill_cycles'], + choices: [['real_cycles', 'Cycles this machine ran'], ['reference_cycles', 'Downloaded from the community store'], ['backfill_cycles', 'Found in imported power history']], + doc: 'Which cycles are used to build each program\'s power curve, and to match a finished cycle against it. Unticking a kind stops it shaping your programs without deleting anything - the cycles stay in your Cycles list and can still be labelled or removed. Useful if you do not trust imported data. Statistics are unaffected: they always count only the cycles this machine actually ran. Unticking everything is ignored, since a program with no cycles behind it could never match.' }, + ] }, { sub: 'Auto-Labeling', fields: [ { key: 'auto_label_confidence', label: 'Auto-Label Confidence', type: 'number', step: 0.01, min: 0, max: 1, def: 0.9, doc: 'If the match score at cycle end is at or above this, the program is labeled automatically without any confirmation prompt. Raise it to require higher certainty before auto-labeling; lower it to automate more. Works in conjunction with Learning Confidence below it.' }, @@ -422,12 +429,15 @@ const _SETTING_CONFLICTS = [ }), }, { - // learning_confidence <= profile_match_threshold + // learning_confidence >= profile_match_threshold (#396): the verify-band floor + // must sit at or above the live match-trust gate. The correct confidence ladder + // is unmatch < match < learning < auto_label; flagging learning BELOW match + // (the old rule) was backwards and tripped the shipped defaults (0.6 vs 0.4). keys: ['learning_confidence', 'profile_match_threshold'], - check: v => v.learning_confidence != null && v.profile_match_threshold != null && v.learning_confidence > v.profile_match_threshold, + check: v => v.learning_confidence != null && v.profile_match_threshold != null && v.learning_confidence < v.profile_match_threshold, fieldErrors: v => ({ - learning_confidence: { msgKey: 'conflict.confidence.learning', msgVars: {match: v.profile_match_threshold}, msgFb: `Must be at or below Match Threshold (${v.profile_match_threshold})`, fixVal: +(v.profile_match_threshold).toFixed(2) }, - profile_match_threshold: { msgKey: 'conflict.confidence.match_for_learning', msgVars: {lc: v.learning_confidence}, msgFb: `Must be at or above Learning Confidence (${v.learning_confidence})`, fixVal: +(v.learning_confidence).toFixed(2) }, + learning_confidence: { msgKey: 'conflict.confidence.learning', msgVars: {match: v.profile_match_threshold}, msgFb: `Must be at or above Match Threshold (${v.profile_match_threshold})`, fixVal: +(v.profile_match_threshold).toFixed(2) }, + profile_match_threshold: { msgKey: 'conflict.confidence.match_for_learning', msgVars: {lc: v.learning_confidence}, msgFb: `Must be at or below Learning Confidence (${v.learning_confidence})`, fixVal: +(v.learning_confidence).toFixed(2) }, }), }, { @@ -1222,6 +1232,25 @@ function _fmtEnergy(kwh) { // user's persisted "Cycle date display" preference by _render() on each paint. let _datePref = 'relative'; +// ─── History-import date picker helpers ────────────────────────────────────── +// The recorder read is bounded by a start DATE ("import since ..."), which is what a +// user actually knows, rather than a day count they have to work out. These produce +// LOCAL calendar days in the `yyyy-mm-dd` form requires (toISOString +// would shift across the UTC boundary and offer "tomorrow" or skip today). +const _HIST_MAX_DAYS = 3700; // mirrors HISTORY_IMPORT_RECORDER_MAX_DAYS (~10 years) +function _histDayStr(d) { + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} +function _histShiftDays(n) { + const d = new Date(); + d.setDate(d.getDate() - n); + return _histDayStr(d); +} +function _histToday() { return _histDayStr(new Date()); } +function _histDefaultSince() { return _histShiftDays(10); } // HA's default purge_keep_days +function _histMinSince() { return _histShiftDays(_HIST_MAX_DAYS - 1); } + // Locale-aware "3 hours ago" / "in 2 days" formatting. Intl handles localization, // so this needs no translation strings; falls back to absolute if unsupported. function _relTime(ms) { @@ -1259,6 +1288,27 @@ function _fmtDate(ts, mode) { if (isNaN(ms)) return '-'; return (mode || _datePref) === 'relative' ? _relTime(ms) : _fmtAbsDate(ms); } +// Home Assistant rejects a WebSocket command with a plain {code, message} object, +// which every browser console renders as a collapsed "Object". That made a panel +// fetch failure unreportable: the user sees `fetch error: Object` and has to expand +// it by hand to learn anything. Render the identity inline instead. +// +// The common case worth recognising is `unknown_command` right after a Home Assistant +// restart: the sidebar panel loads and starts polling before the integration has +// finished setting up and registering its commands, so the first few polls fail and +// then it self-heals. Saying so beats leaving the user to guess. +function _wsErrText(err) { + if (err == null) return 'unknown error'; + if (err instanceof Error) return `${err.name}: ${err.message}`; + const code = err.code != null ? String(err.code) : ''; + const msg = err.message != null ? String(err.message) : ''; + if (!code && !msg) { try { return JSON.stringify(err); } catch (_) { return String(err); } } + const hint = code === 'unknown_command' + ? ' (the integration is probably still starting up; this should stop on its own)' + : ''; + return `${code || 'error'}${msg ? ': ' + msg : ''}${hint}`; +} + function _esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } @@ -1414,6 +1464,22 @@ function _field(f, value, extra) { // Structured value (list/object) edited as JSON text; round-trips on save. const jt = (v === '' || v == null) ? '' : (typeof v === 'string' ? v : JSON.stringify(v, null, 2)); input = ``; + } else if (f.type === 'checkboxlist') { + // Several checkboxes writing ONE option as a list of the ticked values. The inner + // boxes carry data-choice, never data-opt, so the collectors below see one field. + // An empty stored value is not a valid "none" state - the backend treats an empty + // evidence selection as all-three (a profile with no cycles could never match), so + // render the field's default set rather than all-unchecked, which would tell the + // user matching is starved while it is actually using every source. + const chosen = (Array.isArray(value) && value.length) + ? value.map(String) + : (Array.isArray(f.def) ? f.def.map(String) : []); + input = `
` + + (f.choices || []).map(([val, lbl]) => + `` + ).join('') + `
`; } else if (f.type === 'entitylist') { // Chip/pill multi-picker: existing values as removable pills + a combobox // add-input. Managed by DOM (no re-render) and collected on save. @@ -1568,6 +1634,22 @@ const _DIAGRAM_BY_KEY = { sampling_interval: 'sampling', }; +// Rect that really clips an absolutely-positioned popover: the viewport +// intersected with every ancestor that scrolls or hides its overflow. `.wd-modal` +// only declares `overflow-y: auto`, but CSS promotes the unset axis to `auto` as +// well, so it clips horizontally too, which is what cut the help bubbles in half +// (#385). +function _clipRectFor(el) { + let left = 0, right = window.innerWidth; + for (let a = el.parentElement; a; a = a.parentElement) { + if (getComputedStyle(a).overflowX === 'visible') continue; + const r = a.getBoundingClientRect(); + if (r.left > left) left = r.left; + if (r.right < right) right = r.right; + } + return { left, right }; +} + // Tooltip popover with an optional JS-drawn SVG diagram above the text. function _tip(text, diagram) { const dg = diagram ? _diagram(diagram) : ''; @@ -1771,11 +1853,18 @@ class HaWashdataPanel extends HTMLElement { this._phases = []; this._recState = null; this._opts = {}; + this._optDefaults = {}; // device-resolved cadence defaults from get_options (#396) this._mlComparison = null; this._mlById = {}; this._mlLoading = false; this._mlSettings = {}; // conf key -> {classic_value, ml_value, ml_reason, ...} this._mlSettingsLoading = false; + // Last-seen Calibrated (ML) comparison + muted keys per entry_id. The ML + // comparison is expensive and only fetched for the device being viewed, so + // these keep the device-pill badges of already-visited devices from blanking + // out when the selection moves on. Keyed by entry_id, never cleared on switch. + this._mlSettingsByEntry = {}; + this._lockedByEntry = {}; this._mlTrainingStatus = null; // {enabled, running, last_trained, cycle_count, min_cycles, ...} this._setupStatus = null; // result of ws_get_setup_status // UI state @@ -1810,7 +1899,13 @@ class HaWashdataPanel extends HTMLElement { this._panelSubtab = 'maintenance'; this._gearTab = 'prefs'; // Store-backed brand/model picker cache (Basic > Device info). - this._catalog = { brands: undefined, devices: undefined, forBrand: null, approvedOnly: false }; + // brandsFull: the whole brand collection is local, so every search is in-memory. + // brandPrefixes: prefixes already resolved server-side (a completed "bo" covers "bos"). + this._catalog = { brands: undefined, devices: undefined, forBrand: null, approvedOnly: false, + brandsFull: false, brandPrefixes: [] }; + // Resolved catalog identity for the saved brand/model (two point reads), which is + // all the status badges need. Keyed on brand|model|type so it self-invalidates. + this._catalogEntry = null; this._maintenance = null; // cached maintenance log/reminders (Advanced → Maintenance) this._logs = []; this._logLevel = ''; @@ -1845,6 +1940,8 @@ class HaWashdataPanel extends HTMLElement { this._undoSeq = 0; // Bound modal keydown handler (Escape / Tab-trap); attached once in _boot. this._kbdHandler = null; + // Bound help-bubble edge nudge (#385); attached once in _boot. + this._tipHandler = null; // D7: settings changelog cache this._settingsChangelog = null; this._settingsChangeByKey = {}; @@ -1945,6 +2042,14 @@ class HaWashdataPanel extends HTMLElement { this._kbdHandler = (e) => this._onKeydown(e); this.shadowRoot.addEventListener('keydown', this._kbdHandler); } + if (this.shadowRoot && !this._tipHandler) { + this._tipHandler = (e) => { + const t = e.target; + const anchor = t && t.closest ? t.closest('.wd-tip') : null; + if (anchor) this._positionTip(anchor); + }; + this.shadowRoot.addEventListener('pointerover', this._tipHandler); + } } this._onResize = () => this._resizeLogsPage(); window.addEventListener('resize', this._onResize); @@ -1960,6 +2065,7 @@ class HaWashdataPanel extends HTMLElement { this._flushPendingDeletes(); // Remove the modal keydown listener. if (this._kbdHandler && this.shadowRoot) { this.shadowRoot.removeEventListener('keydown', this._kbdHandler); this._kbdHandler = null; } + if (this._tipHandler && this.shadowRoot) { this.shadowRoot.removeEventListener('pointerover', this._tipHandler); this._tipHandler = null; } // Remove the community-store OAuth message listener. if (this._storeConnectListener) { window.removeEventListener('message', this._storeConnectListener); this._storeConnectListener = null; } } @@ -1980,6 +2086,14 @@ class HaWashdataPanel extends HTMLElement { // survives every _render() innerHTML swap. Removed on disconnect. this._kbdHandler = (e) => this._onKeydown(e); shadow.addEventListener('keydown', this._kbdHandler); + // Same deal for the help-bubble edge nudge (#385): delegated on the shadow + // root so it survives every innerHTML swap. + this._tipHandler = (e) => { + const t = e.target; + const anchor = t && t.closest ? t.closest('.wd-tip') : null; + if (anchor) this._positionTip(anchor); + }; + shadow.addEventListener('pointerover', this._tipHandler); // Load per-user-language panel translations before first render. // Falls back to JS-embedded strings if the fetch fails. this._loadPanelTranslations().catch(() => {}).finally(() => { @@ -2040,6 +2154,12 @@ class HaWashdataPanel extends HTMLElement { this._updateTaskPills(); this._pgAdoptTask(t); this._onTrackedTaskProgress(t); + // The history-import wizard reads its own progress and results off this stream, so + // a closed dialog or a dropped socket cannot lose a run. + if (t.kind === 'history_import' || t.kind === 'history_import_apply') { + if (t.state === 'running') { if (this._modal && this._modal.type === 'history-import') this._render(); } + else this._histTaskFinished(t); + } this._settleTaskCallback(t); } @@ -2238,6 +2358,8 @@ class HaWashdataPanel extends HTMLElement { rebuild: this._t('lbl.task_rebuild', {}, 'Rebuilding envelopes'), reprocess: this._t('lbl.task_reprocess', {}, 'Reprocessing'), ml_training: this._t('lbl.task_ml_training', {}, 'Learning'), + history_import: this._t('lbl.task_history_import', {}, 'Scanning power history'), + history_import_apply: this._t('lbl.task_history_import_apply', {}, 'Importing cycles'), }; return m[kind] || kind; } @@ -2436,6 +2558,7 @@ class HaWashdataPanel extends HTMLElement { } const res = await this._ws({ type: `${_DOMAIN}/get_devices` }); + this._lastFetchErr = null; // recovered: report the next failure even if identical this._devices = res.devices || []; this._lastRefresh = new Date(); // Restore the last-used device on the first paint (selIdx is still 0). @@ -2509,7 +2632,13 @@ class HaWashdataPanel extends HTMLElement { this._fetchLogs().then(() => this._refreshLogDrawer()).catch(() => {}); } } catch (err) { - console.warn('[WashData panel] fetch error:', err); + // Collapse repeats: a poll failure is usually transient and identical every + // 5 s, and six copies of the same line buries whatever else is in the console. + const text = _wsErrText(err); + if (text !== this._lastFetchErr) { + this._lastFetchErr = text; + console.warn('[WashData panel] fetch error -', text, err); + } } finally { this._loading = false; // The 5s poll must never clobber editing on another tab or inside a modal. @@ -2539,9 +2668,11 @@ class HaWashdataPanel extends HTMLElement { try { const res = await this._ws({ type: `${_DOMAIN}/get_device_cycles`, entry_id: entryId, limit: _CYCLE_PAGE_SIZE, offset: 0 }); this._cycles = res.cycles || []; - // Imported store recordings are returned once (first page) and kept out of - // the paginated `cycles`/offset math so "Load more" stays correct. - this._refCycles = res.reference_cycles || []; + // Imported store recordings and cycles recovered from raw power history are + // returned once (first page) and kept out of the paginated `cycles`/offset math + // so "Load more" stays correct. They share one panel array because they share the + // table; each row carries `cycle_origin` so badges and wording can differ. + this._refCycles = [...(res.reference_cycles || []), ...(res.backfill_cycles || [])]; this._cycleOffset = this._cycles.length; this._cyclesTotal = (res.total != null) ? res.total : this._cycles.length; this._cyclesHasMore = (res.has_more != null) ? !!res.has_more : false; @@ -2795,6 +2926,7 @@ class HaWashdataPanel extends HTMLElement { for (const c of (d && d.cycles) || []) idx[c.id] = c; this._mlById = idx; this._mlSettings = (d && d.settings_comparison) || this._mlSettings; + this._mlSettingsByEntry[entryId] = this._mlSettings; } catch (_) { /* leave prior index */ } } @@ -2808,6 +2940,7 @@ class HaWashdataPanel extends HTMLElement { if (!this._isActiveEntry(entryId)) return; // device switched mid-flight — drop stale response this._mlComparison = d; this._mlSettings = (d && d.settings_comparison) || {}; + this._mlSettingsByEntry[entryId] = this._mlSettings; } catch (_) { /* leave prior */ } } @@ -2849,6 +2982,7 @@ class HaWashdataPanel extends HTMLElement { if (!this._isActiveEntry(entryId)) return; // device switched mid-flight this._suggestions = res.suggestions || []; this._lockedSuggestions = res.locked_suggestions || []; + this._lockedByEntry[entryId] = this._lockedSuggestions; } catch (_) { if (this._isActiveEntry(entryId)) { this._suggestionsError = true; this._suggestions = []; } } @@ -2913,7 +3047,7 @@ class HaWashdataPanel extends HTMLElement { this._settingsChangelog = null; this._settingsChangeByKey = {}; this._powerData = { live: [], raw: [], cycle_active: false, cycle_elapsed_s: 0 }; this._matchDebug = null; - this._profiles = []; this._profileHealth = {}; this._profileTrends = {}; this._coverageGaps = {}; this._profileAdvisories = []; this._opts = {}; this._suggestions = []; this._lockedSuggestions = []; + this._profiles = []; this._profileHealth = {}; this._profileTrends = {}; this._coverageGaps = {}; this._profileAdvisories = []; this._opts = {}; this._optDefaults = {}; this._suggestions = []; this._lockedSuggestions = []; this._cycles = []; this._refCycles = []; this._recState = null; this._diag = null; this._maintenance = null; this._phases = []; this._mlTrainingStatus = null; // per-device; re-fetched by _fetchTabData this._setupStatus = null; // per-device; re-fetched by _fetchTabData @@ -2947,7 +3081,13 @@ class HaWashdataPanel extends HTMLElement { // previous device's appliance type, so reusing them could save an invalid // brand/model combo. Brands are type-agnostic, so keep them loaded (reloading // them without a re-render is what left the brand dropdown empty). - this._catalog = { brands: this._catalog.brands, devices: undefined, forBrand: null, approvedOnly: this._catalog.approvedOnly }; + clearTimeout(this._brandSearchTimer); this._brandSearchTimer = null; + this._catalog = { + brands: this._catalog.brands, devices: undefined, forBrand: null, + approvedOnly: this._catalog.approvedOnly, + // Which brand prefixes have already been resolved travels with the rows. + brandsFull: this._catalog.brandsFull, brandPrefixes: this._catalog.brandPrefixes, + }; if (this._entityListCache) delete this._entityListCache.store_model; const dev = this._devices[this._selIdx]; if (dev) await this._fetchSuggestions(dev.entry_id); @@ -3072,6 +3212,7 @@ class HaWashdataPanel extends HTMLElement { const r = await this._ws({ type: `${_DOMAIN}/get_options`, entry_id: eid }); if (!this._isActiveEntry(eid)) return; // device switched mid-flight — drop stale response this._opts = r.options || {}; + this._optDefaults = r.defaults || {}; // device-resolved cadence defaults (#396) await this._fetchSuggestions(eid); // D7: "What changed" — load the settings changelog (best-effort; older // backends without this command simply show no change markers). @@ -3105,22 +3246,38 @@ class HaWashdataPanel extends HTMLElement { await this._loadStoreStatus(eid); if (!this._isActiveEntry(eid)) return; this._ensureStoreConnectListener(); - // Kick off the initial browse in the background (renders its own spinner). - if (this._onlineEnabled()) this._storeSearch(this._storeQuery); + // Kick off the initial browse in the background (renders its own spinner). It + // opens on the declared brand -- the useful, and cheapest, default: the query + // then shares a cache key with the Settings model picker. + if (this._onlineEnabled()) this._storeSearch(this._storeBrandScope()); } else if (this._tab === 'advanced' && this._panelSubtab === 'ml') { const r = await this._ws({ type: `${_DOMAIN}/get_options`, entry_id: eid }); if (!this._isActiveEntry(eid)) return; // device switched mid-flight — drop stale response this._opts = r.options || {}; this._loadMlTrainingStatus(eid).finally(() => { if (this._tab === 'advanced' && this._panelSubtab === 'ml') this._renderPreservingFormEdits(); }); } else if (this._tab === 'playground') { - try { const r = await this._ws({ type: `${_DOMAIN}/get_options`, entry_id: eid }); if (!this._isActiveEntry(eid)) return; this._opts = r.options || {}; } catch (_) {} - // Always open on the integration's CURRENT settings: the backend reads them - // back off the live detector/matcher config, so no stale schema default can - // leak into the sandbox. - await this._pgFetchSettings(eid); + // These four are independent of each other, so they go out together rather + // than in series: on a slow host the tab used to wait out four sequential + // round-trips before rendering anything. + // + // Settings are fetched WITHOUT suggestions (see _pgFetchSuggestions): they only + // label two buttons, and computing them runs statistics over every clean cycle. + await Promise.all([ + (async () => { + try { + const r = await this._ws({ type: `${_DOMAIN}/get_options`, entry_id: eid }); + if (this._isActiveEntry(eid)) this._opts = r.options || {}; + } catch (_) {} + })(), + // Always open on the integration's CURRENT settings: the backend reads them + // back off the live detector/matcher config, so no stale schema default can + // leak into the sandbox. + this._pgFetchSettings(eid, false), + this._fetchCycles(eid), + this._profiles.length ? Promise.resolve() : this._fetchProfiles(eid), + ]); if (!this._isActiveEntry(eid)) return; - await this._fetchCycles(eid); - if (!this._profiles.length) await this._fetchProfiles(eid); + this._pgFetchSuggestions(eid); // Auto-select most recent cycle on first load. Profile defaults to // auto-detect ('') so the sim shows what the matcher WOULD pick, not the // cycle's stored label. @@ -3145,7 +3302,7 @@ class HaWashdataPanel extends HTMLElement { } } } catch (err) { - console.warn('[WashData panel] tab data fetch error:', err); + console.warn('[WashData panel] tab data fetch error -', _wsErrText(err), err); } finally { this._tabLoading = false; this._render(); @@ -3158,7 +3315,7 @@ class HaWashdataPanel extends HTMLElement { if (!this._isActiveEntry(eid)) return; // device switched mid-flight — drop stale result this._diag = r.stats || {}; } catch (err) { - console.warn('[WashData panel] tools fetch error:', err); + console.warn('[WashData panel] tools fetch error -', _wsErrText(err), err); this._diag = { _error: String(err && err.message || err) }; } } @@ -3168,7 +3325,7 @@ class HaWashdataPanel extends HTMLElement { const r = await this._ws({ type: `${_DOMAIN}/get_maintenance_log`, entry_id: eid }); this._maintenance = r || {}; } catch (err) { - console.warn('[WashData panel] maintenance fetch error:', err); + console.warn('[WashData panel] maintenance fetch error -', _wsErrText(err), err); this._maintenance = { _error: String(err && err.message || err) }; } } @@ -3180,7 +3337,7 @@ class HaWashdataPanel extends HTMLElement { const r = await this._ws({ type: `${_DOMAIN}/get_logs`, level: null, limit: 500 }); this._logs = r.logs || []; } catch (err) { - console.warn('[WashData panel] logs fetch error:', err); + console.warn('[WashData panel] logs fetch error -', _wsErrText(err), err); } } @@ -3451,6 +3608,7 @@ class HaWashdataPanel extends HTMLElement { this._drawStatusCurve(); this._drawModalCanvas(); this._drawProfileSparklines(); // D2 + this._drawHistorySparklines(); // #344 import review this._drawPlaygroundCanvases(); // F3 ['wd-status-canvas', 'wd-cyc-canvas', 'wd-compare-canvas', 'wd-env-canvas', 'wd-phase-canvas', 'wd-spag-canvas', 'wd-pgroup-canvas'] .forEach(id => this._attachHover(id)); @@ -3641,9 +3799,10 @@ class HaWashdataPanel extends HTMLElement { const dotColor = rec ? 'var(--error-color, #f44336)' : this._stateColor(st); const label = rec ? this._t('status.recording', {}, 'Recording') : this._stateLabel(st); const badges = []; - const confN = this._conflictCountForOpts(d.options || {}); + const confN = this._conflictCountForOpts(d.options || {}, d.option_defaults || {}); if (confN) badges.push(`⚠ ${confN}`); - if (d.suggestions_count) badges.push(`💡 ${d.suggestions_count}`); + const sugN = this._sugCountsForDevice(d).total; + if (sugN) badges.push(`💡 ${sugN}`); if (d.feedback_count) badges.push(`💬 ${d.feedback_count}`); return ``); + if (dev.feedback_count && this._canEdit()) attn.push(``); const _confKeys = this._conflictKeysFromOpts(); if (_confKeys.size && this._canEdit()) { const n = _confKeys.size, s = n > 1 ? 's' : ''; - attn.push(``); + attn.push(``); } - const _mlSugCount = this._mlSugKeys().size; - if ((dev.suggestions_count || _mlSugCount) && this._canEdit()) { - const total = (dev.suggestions_count || 0) + _mlSugCount; + const _sugC = this._sugCountsForDevice(dev); + if (_sugC.total && this._canEdit()) { + const total = _sugC.total; const parts = []; - if (dev.suggestions_count) parts.push(this._t('lbl.n_classic_suggestions', {n: dev.suggestions_count}, `${dev.suggestions_count} classic`)); - if (_mlSugCount) parts.push(this._t('lbl.n_ml_suggestions', {n: _mlSugCount}, `${_mlSugCount} ML`)); + if (_sugC.classic) parts.push(this._t('lbl.n_classic_suggestions', {n: _sugC.classic}, `${_sugC.classic} classic`)); + if (_sugC.ml) parts.push(this._t('lbl.n_ml_suggestions', {n: _sugC.ml}, `${_sugC.ml} ML`)); attn.push(``); } const attnHtml = attn.length ? `
${attn.join('')}
` : ''; @@ -4053,9 +4212,14 @@ class HaWashdataPanel extends HTMLElement { : s === 'interrupted' ? 'var(--error-color, #f44336)' : s === 'force_stopped' ? 'var(--warning-color, #ff9800)' : 'var(--secondary-text-color)'; - const importedBadge = c => c.is_reference - ? ` 📥` - : ''; + const importedBadge = c => { + if (!c.is_reference) return ''; + const fromHistory = c.cycle_origin === 'backfill'; + const tip = fromHistory + ? this._t('badge.backfilled_tip', {}, 'Detected in imported power history. Shapes program matching only, not counted in stats.') + : this._t('badge.imported_tip', {}, 'Imported from the community store. Used for matching only, not counted in stats.'); + return ` ${fromHistory ? '🕗' : '📥'}`; + }; const reviewBadge = c => { if (isGolden(c)) return ' '; // Pending feedback wins over the reviewed check, matching needsReview (#355): @@ -4270,32 +4434,37 @@ class HaWashdataPanel extends HTMLElement { } + // Paint one power curve into a small canvas. Shared by the profile-card + // signature sparklines (D2) and the import-review candidate rows (#344), so the two + // cannot drift apart. + _paintSparkline(cv, curve) { + if (!cv || !Array.isArray(curve) || curve.length < 3) return; + const primary = (getComputedStyle(this).getPropertyValue('--primary-color') || '#03a9f4').trim() || '#03a9f4'; + const dpr = window.devicePixelRatio || 1; + const rect = cv.getBoundingClientRect(); + const w = cv.width = Math.max(1, Math.round((rect.width || 64) * dpr)); + const h = cv.height = Math.max(1, Math.round((rect.height || 20) * dpr)); + const ctx = cv.getContext('2d'); + ctx.clearRect(0, 0, w, h); + const max = Math.max(...curve, 1), pad = 2 * dpr; + const X = i => pad + (curve.length === 1 ? 0 : (i / (curve.length - 1)) * (w - 2 * pad)); + const Y = v => h - pad - (Math.max(0, v) / max) * (h - 2 * pad); + ctx.beginPath(); + curve.forEach((v, i) => { const x = X(i), y = Y(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); + ctx.strokeStyle = primary; ctx.lineWidth = 1.5 * dpr; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); + } + // D2: paint every profile-card sparkline after a render. _drawProfileSparklines() { const sr = this.shadowRoot; if (!sr) return; const canvases = sr.querySelectorAll('canvas[data-spark-prof]'); if (!canvases.length) return; - const primary = (getComputedStyle(this).getPropertyValue('--primary-color') || '#03a9f4').trim() || '#03a9f4'; const byName = {}; for (const p of (this._profiles || [])) byName[p.name] = p; canvases.forEach(cv => { - const name = cv.dataset.sparkProf; - const curve = (byName[name] && byName[name].signature_curve) || []; - if (!Array.isArray(curve) || curve.length < 3) return; - const dpr = window.devicePixelRatio || 1; - const rect = cv.getBoundingClientRect(); - const w = cv.width = Math.max(1, Math.round((rect.width || 64) * dpr)); - const h = cv.height = Math.max(1, Math.round((rect.height || 20) * dpr)); - const ctx = cv.getContext('2d'); - ctx.clearRect(0, 0, w, h); - const max = Math.max(...curve, 1), pad = 2 * dpr; - const X = i => pad + (curve.length === 1 ? 0 : (i / (curve.length - 1)) * (w - 2 * pad)); - const Y = v => h - pad - (Math.max(0, v) / max) * (h - 2 * pad); - // Filled area + line, matching the appliance's power signature. - ctx.beginPath(); - curve.forEach((v, i) => { const x = X(i), y = Y(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); - ctx.strokeStyle = primary; ctx.lineWidth = 1.5 * dpr; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); + const prof = byName[cv.dataset.sparkProf]; + this._paintSparkline(cv, (prof && prof.signature_curve) || []); }); } @@ -4509,7 +4678,7 @@ class HaWashdataPanel extends HTMLElement { const s = confCount !== 1 ? 's' : ''; const confBanner = confCount ? `
- ⚠ ${this._t('conflict.settings_banner', {n: confCount, s}, `${confCount} setting conflict${s} — check the highlighted sections and fix before saving.`)} + ⚠ ${this._t('conflict.settings_banner', {n: confCount}, `Setting conflicts: ${confCount}. Check the highlighted sections and fix them before saving.`)}
` : ''; // Combined tuning-suggestion count: classic (observed) + Calibrated (ML) keys @@ -4614,7 +4783,14 @@ class HaWashdataPanel extends HTMLElement { if (f.onlyDeviceType && (o.device_type || 'washing_machine') !== f.onlyDeviceType) return ''; if (f.type === 'storebrand' || f.type === 'storemodel') return this._renderStorePicker(f, o); let value = o[f.key]; - if (value === undefined) value = f.def; + // Prefer a device-resolved default from the backend (#396: cadence settings + // whose default varies by device type) over the static schema literal, so an + // unset field shows - and the conflict validator checks - the value the + // integration would actually use. + if (value === undefined) { + const od = this._optDefaults; + value = (od && od[f.key] != null) ? od[f.key] : f.def; + } const extra = {}; if (f.type === 'devicetype') extra.opts = this._deviceTypeOpts(value || o.device_type); @@ -4709,14 +4885,16 @@ class HaWashdataPanel extends HTMLElement { } _renderBrandPicker(key, val, label, doc, ph) { - if (this._catalog.brands === undefined) { this._catalog.brands = null; this._loadCatalogBrands(); } + // The badge comes from the resolved catalog ENTRY (one point read), not from the + // brand list: fetching all ~84 brands to find one row was the store's single + // largest read source. The list is loaded only when the user opens the combo. + this._ensureCatalogEntry(); const brands = Array.isArray(this._catalog.brands) ? this._catalog.brands : []; // Feed the shared custom combobox (works in the shadow DOM, unlike a native // ). The combo reads this cache live, so async loads appear. this._entityListCache = this._entityListCache || {}; this._entityListCache[key] = brands.map(b => b.brand).filter(Boolean); - const match = brands.find(b => String(b.brand || '').toLowerCase() === val.toLowerCase()); - const tag = this._statusTag(match); + const tag = this._statusTag(this._catalogEntryFor(val, 'brand')); const loading = this._catalog.brands === null ? ` ${this._t('msg.loading', {}, 'Loading…')}` : ''; return `
@@ -4735,11 +4913,15 @@ class HaWashdataPanel extends HTMLElement {
${_esc(this._t('msg.pick_brand_first', {}, 'Pick an appliance brand first.'))}
`; } - if (this._catalog.forBrand !== brand) { this._catalog.forBrand = brand; this._catalog.devices = null; this._loadCatalogDevices(brand); } + // As in the brand picker: the badge + community actions come from the resolved + // catalog entry (one point read), so simply opening Settings no longer downloads + // this brand's whole device list. That list is fetched on combo focus. + this._ensureCatalogEntry(); + if (this._catalog.forBrand !== brand) { this._catalog.forBrand = brand; this._catalog.devices = undefined; } const devices = Array.isArray(this._catalog.devices) ? this._catalog.devices : []; this._entityListCache = this._entityListCache || {}; this._entityListCache[key] = devices.map(d => d.model).filter(Boolean); - const match = devices.find(d => String(d.model || '').toLowerCase() === val.toLowerCase()); + const match = this._catalogEntryFor(val, 'device'); const tag = this._statusTag(match); const loading = this._catalog.devices === null ? ` ${this._t('msg.loading', {}, 'Loading…')}` : ''; // Details + community actions for the resolved device. @@ -4817,21 +4999,176 @@ class HaWashdataPanel extends HTMLElement { return groups; } + // ── Catalog identity (badges) vs catalog lists (pickers) ──────────────────── + // These are two different reads and it matters which one runs. Resolving the + // appliance the user already saved needs exactly two documents, both of which have + // deterministic ids, so it is a point read. Offering every alternative to pick from + // needs whole collections. Rendering the Settings tab only ever needed the former, + // but used to trigger the latter -- measured at 128 documents / 119 KB per open, + // against a daily read budget the whole community shares. + + // Which appliance the currently-rendered entry describes. Included in the key so a + // device switch (or an edit to brand/model/type) re-resolves instead of showing the + // previous appliance's badge. + _catalogEntryKey() { + const o = this._opts || {}; + return [ + (o.store_brand || '').trim().toLowerCase(), + (o.store_model || '').trim().toLowerCase(), + o.device_type || '', + ].join('|'); + } + + // Kick off the two point reads if this appliance's identity is not already resolved. + // Safe to call from render (it self-dedupes on the key and never re-enters). + _ensureCatalogEntry() { + const want = this._catalogEntryKey(); + const cur = this._catalogEntry; + if (cur && cur.key === want) return; + this._catalogEntry = { key: want, brand: null, device: null, deviceId: null, loading: true }; + this._loadCatalogEntry(want); + } + + // The resolved brand/device doc, but only when it actually describes the value being + // rendered -- a half-typed model must not keep showing the saved model's badge. + _catalogEntryFor(val, which) { + const e = this._catalogEntry; + if (!e || e.key !== this._catalogEntryKey()) return null; + const rec = which === 'brand' ? e.brand : e.device; + if (!rec) return null; + const field = which === 'brand' ? rec.brand : rec.model; + return String(field || '').toLowerCase() === String(val || '').trim().toLowerCase() ? rec : null; + } + + async _loadCatalogEntry(wantKey) { + const dev = this._devices[this._selIdx]; + const o = this._opts || {}; + const brand = (o.store_brand || '').trim(); + const model = (o.store_model || '').trim(); + if (!dev || !this._onlineEnabled() || !brand || !model) { + if (this._catalogEntry && this._catalogEntry.key === wantKey) this._catalogEntry.loading = false; + return; + } + let res = null; + try { + res = await this._ws({ + type: `${_DOMAIN}/store_get_catalog_entry`, entry_id: dev.entry_id, + brand, model, appliance_type: o.device_type || '', + }); + } catch (_) { /* leave unresolved: the badge is decoration, not state */ } + // Drop a response that a device switch or a further edit has already superseded. + if (!this._catalogEntry || this._catalogEntry.key !== wantKey) return; + this._catalogEntry = { + key: wantKey, loading: false, + brand: (res && res.brand) || null, + device: (res && res.device) || null, + deviceId: (res && res.device_id) || null, + }; + if (this._isActiveEntry(dev.entry_id)) this._renderPreservingFormEdits(); + } + + // Surface a just-loaded candidate list without a re-render when the user is typing in + // that picker. Dispatching `input` runs the combobox's own showDrop handler (which reads + // _entityListCache live), so the options appear while focus and caret stay put; a + // re-render here would rebuild the input mid-keystroke. Falls back to a normal render + // when the field is not focused, which is what clears the "Loading..." hint. + _refreshComboAfterLoad(inputId, entryId, mayReopen = true) { + const inp = this.shadowRoot && this.shadowRoot.getElementById(inputId); + if (inp && this.shadowRoot.activeElement === inp) { + // mayReopen=false after a failed load: re-dispatching would re-arm the search + // debounce and turn a persistent failure into a request loop. + if (mayReopen) inp.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + if (this._isActiveEntry(entryId)) this._render(); + } + + // Load the picker's candidate list on first interaction with that picker. Called from + // the combobox's focus/input path rather than from render, so a user who opens Settings + // to change an unrelated setting never pays for the catalog at all. + _ensureCatalogList(optKey, q) { + if (!this._onlineEnabled()) return; + if (optKey === 'store_brand') { this._ensureBrandCandidates(q); return; } + if (optKey === 'store_model') { + const brand = String((this._opts || {}).store_brand || '').trim(); + if (!brand) return; + if (this._catalog.forBrand !== brand || this._catalog.devices === undefined) { + this._catalog.forBrand = brand; + this._catalog.devices = null; + this._loadCatalogDevices(brand); + } + } + } + + // Resolve brand candidates for what is currently typed, as cheaply as possible. + // + // The field opens pre-filled with the saved brand, so the very first focus already has + // a search term -- which the backend answers with a `brand_lc` range query reading only + // the matching documents (3 for "bo" against 84 for the whole collection). Clearing the + // field is the explicit "show me everything" gesture and is the only path that fetches + // the full list. Typing is debounced, and a prefix whose matches are already covered by + // a completed broader query never queries at all. + _ensureBrandCandidates(q) { + const prefix = String(q || '').trim().toLowerCase(); + if (this._catalog.brandsFull) return; // whole collection is already local + if (!prefix) { + // Clearing the field supersedes any prefix search still sitting in the debounce: + // the full list answers it, so letting it fire would spend a read for nothing. + clearTimeout(this._brandSearchTimer); this._brandSearchTimer = null; + if (this._catalog.brands === undefined) { this._catalog.brands = null; this._loadCatalogBrands(''); } + return; + } + const done = this._catalog.brandPrefixes || (this._catalog.brandPrefixes = []); + // A completed query for "bo" already returned every brand starting with "bos". + if (done.some(p => prefix.startsWith(p))) return; + clearTimeout(this._brandSearchTimer); + this._brandSearchTimer = setTimeout(() => this._loadCatalogBrands(prefix), 250); + } + + // Prefix results are PARTIAL, so they are unioned into the candidate list rather than + // replacing it: typing "bo" and then backspacing to "b" must not drop the rows the + // broader query already produced. + _mergeBrandCandidates(rows) { + const have = new Map( + (Array.isArray(this._catalog.brands) ? this._catalog.brands : []) + .map(b => [String(b.id != null ? b.id : b.brand), b])); + for (const r of rows || []) have.set(String(r.id != null ? r.id : r.brand), r); + this._catalog.brands = Array.from(have.values()) + .sort((a, b) => String(a.brand || '').localeCompare(String(b.brand || ''))); + } + // Feed the combobox candidate cache directly (no re-render): the combo reads it // live, so options appear without rebuilding the input the user is typing in. - async _loadCatalogBrands() { + async _loadCatalogBrands(query = '') { this._entityListCache = this._entityListCache || {}; + let failed = false; const dev = this._devices[this._selIdx]; if (!dev || !this._onlineEnabled()) { this._catalog.brands = []; this._entityListCache.store_brand = []; return; } + if (query && this._catalog.brandsFull) return; // the full list already covers it + if (this._catalog.brands === undefined) this._catalog.brands = null; // show the loading hint try { - const r = await this._ws({ type: `${_DOMAIN}/store_list_brands`, entry_id: dev.entry_id, include_pending: !this._catalog.approvedOnly }); - this._catalog.brands = (r && r.items) || []; - } catch (_) { this._catalog.brands = []; } + const r = await this._ws({ + type: `${_DOMAIN}/store_list_brands`, entry_id: dev.entry_id, + query: query || null, include_pending: !this._catalog.approvedOnly, + }); + const rows = (r && r.items) || []; + if (query) { + this._mergeBrandCandidates(rows); + (this._catalog.brandPrefixes || (this._catalog.brandPrefixes = [])).push(query); + } else { + this._catalog.brands = rows; + this._catalog.brandsFull = true; // every later search is answered in memory + } + } catch (_) { + if (!Array.isArray(this._catalog.brands)) this._catalog.brands = []; + failed = true; + } this._entityListCache.store_brand = (this._catalog.brands || []).map(b => b.brand).filter(Boolean); - // Re-render so the picker shows the loaded brands + clears the "Loading…" hint - // (the combo also reads the cache live on focus, but a stale device switch left - // it looking empty until the next paint). - if (this._isActiveEntry(dev.entry_id)) this._render(); + // On failure do NOT re-dispatch `input`: that is what re-arms the debounce, and a + // query that keeps failing would then retry ~4x/second for as long as the field has + // focus. Backing off means the dropdown simply does not update until the user types + // again (which is itself the retry) or the field loses focus. + this._refreshComboAfterLoad('wd-store-brand', dev.entry_id, !failed); } async _loadCatalogDevices(brand) { @@ -4843,7 +5180,7 @@ class HaWashdataPanel extends HTMLElement { if (this._catalog.forBrand === brand) this._catalog.devices = (r && r.items) || []; } catch (_) { if (this._catalog.forBrand === brand) this._catalog.devices = []; } this._entityListCache.store_model = (this._catalog.devices || []).map(d => d.model).filter(Boolean); - if (this._isActiveEntry(dev.entry_id)) this._render(); + this._refreshComboAfterLoad('wd-store-model', dev.entry_id); } // Load the appliance's profiles into the open Share dialog (dropdown + resolved @@ -5118,19 +5455,59 @@ class HaWashdataPanel extends HTMLElement { // by the settings banner, section dots, tab bulb, and the "Show only" filter so // Calibrated suggestions surface everywhere classic (observed) suggestions do. _mlSugKeys(eff) { - const cur = eff || Object.assign({}, this._opts, this._pendingSettings || {}); + // Fall back to the device-resolved defaults for an unset field (#396) so an ML + // value equal to that default is not counted as differing from "current" - which + // would badge a phantom suggestion whose "Use" value already equals what runs. + const cur = eff || Object.assign({}, this._optDefaults, this._opts, this._pendingSettings || {}); // Muted keys (#343) are excluded so the banner count, section dots, tab bulb // and the "Show only" filter all follow the mute state the same way the // classic suggestions do (which are dropped from this._suggestions on mute). - const locked = new Set(this._lockedSuggestions || []); + return this._mlSugKeysFrom(this._mlSettings, cur, this._lockedSuggestions); + } + + // Same rule for an arbitrary (comparison, current values, muted keys) triple, + // so the device-pill badges can score a device that is not the selected one + // from the per-entry caches. + _mlSugKeysFrom(mlSettings, cur, locked) { + const muted = new Set(locked || []); + const vals = cur || {}; const keys = new Set(); - for (const [key, mlc] of Object.entries(this._mlSettings || {})) { - if (locked.has(key)) continue; - if (mlc && mlc.ml_value != null && !_sugSame(mlc.ml_value, cur[key])) keys.add(key); + for (const [key, mlc] of Object.entries(mlSettings || {})) { + if (muted.has(key)) continue; + if (mlc && mlc.ml_value != null && !_sugSame(mlc.ml_value, vals[key])) keys.add(key); } return keys; } + // Tuning-suggestion counts for one entry of the device list: classic (observed, + // counted by the backend) plus the Calibrated (ML) recommendations that no + // classic suggestion already covers. Same arithmetic as the Settings tab + // banner, so the pill badge, the Overview attention card and the banner agree. + _sugCountsForDevice(dev) { + if (!dev) return { classic: 0, ml: 0, total: 0 }; + const sel = this._devices[this._selIdx]; + const isSel = !!(sel && sel.entry_id === dev.entry_id); + // Keys let us drop an ML recommendation for a key that already has a classic + // suggestion; a payload carrying only the count (older backend, test mocks) + // falls back to adding the two. + const cKeys = Array.isArray(dev.suggestion_keys) ? dev.suggestion_keys : null; + const classic = cKeys ? cKeys.length : (dev.suggestions_count || 0); + // Staged (unsaved) edits count for the selected device only; every other + // device is scored against its saved options, which the poll keeps fresh. + // Device-resolved defaults (#396) sit under the saved options so an unset field + // scores against the value the integration would actually use, not `undefined`. + const cur = isSel + ? Object.assign({}, dev.option_defaults || {}, dev.options || {}, this._opts, this._pendingSettings || {}) + : Object.assign({}, dev.option_defaults || {}, dev.options || {}); + const mlKeys = this._mlSugKeysFrom( + isSel ? this._mlSettings : this._mlSettingsByEntry[dev.entry_id], + cur, + isSel ? this._lockedSuggestions : this._lockedByEntry[dev.entry_id], + ); + const ml = cKeys ? [...mlKeys].filter(k => !cKeys.includes(k)).length : mlKeys.size; + return { classic, ml, total: classic + ml }; + } + _htmlSettingsSugOnly(o) { const sugKeys = new Set((this._suggestions || []).map(s => s.key)); // Include Calibrated (ML) recommendations so the "Show only" filter surfaces @@ -5202,7 +5579,7 @@ class HaWashdataPanel extends HTMLElement { } const nModels = Object.keys(st.on_device_models || {}).length; const source = nModels - ? `${this._t('ml.personalized', {}, '● Personalized to this machine')} ${this._t('lbl.models_fine_tuned', {count: nModels, plural: nModels > 1 ? 's' : ''}, '(' + nModels + ' model' + (nModels > 1 ? 's' : '') + ' fine-tuned)')}` + ? `${this._t('ml.personalized', {}, '● Personalized to this machine')} ${this._t('lbl.models_fine_tuned', {count: nModels}, '(fine-tuned models: ' + nModels + ')')}` : `${this._t('ml.builtin_models', {}, '● Using built-in models')}`; const cyc = st.cycle_count || 0, min = st.min_cycles || 0; const enough = cyc >= min; @@ -5211,7 +5588,7 @@ class HaWashdataPanel extends HTMLElement { const need = Math.max(0, min - cyc); const dataLine = enough ? this._t('msg.enough_data', {current: cyc, min: min}, `Enough data to learn from (${cyc}/${min} cycles).`) - : this._t('msg.collecting_data', {need: need, current: cyc, min: min, plural: need === 1 ? '' : 's'}, `Collecting data — ${need} more cycle${need === 1 ? '' : 's'} before fine-tuning can start (${cyc}/${min}).`); + : this._t('msg.collecting_data', {need: need, current: cyc, min: min}, `Collecting data. Cycles still needed before fine-tuning can start: ${need} (${cyc}/${min}).`); const bar = `
`; const last = st.last_trained ? _fmtDate(st.last_trained) : 'never'; const state = running @@ -5373,6 +5750,7 @@ class HaWashdataPanel extends HTMLElement { ['anti_wrinkle_exit_power', 'Anti-Wrinkle Exit Power','W', 'Power must fall below this between pulses for anti-wrinkle to stay active', 'advanced'], ['anti_wrinkle_idle_timeout','Max Pulse Gap', 's', 'Quiet time allowed between two tumble pulses before anti-wrinkle ends', 'advanced'], ['dishwasher_end_spike_quiet_release','Passive-Dry Quiet Release','s', 'Dishwasher: quiet seconds after expected duration before the end-of-cycle drain wait is released', 'advanced'], + ['smart_termination_duration_ratio', 'Smart Termination Ratio', '', 'Fraction of the matched program\'s expected duration a cycle must reach before Smart Termination may end it early; lower it for load- or temperature-dependent machines', 'advanced'], ['profile_match_min_duration_ratio', 'Min Duration Ratio', '', 'Stage 1: shortest run (vs the profile) still allowed to match', 'matching'], ['profile_match_max_duration_ratio', 'Max Duration Ratio', '', 'Stage 1: longest run (vs the profile) still allowed to match', 'matching'], ['corr_weight', 'Correlation Weight', '', 'Stage 2: balance between curve shape (correlation) and power level (MAE); default 0.45', 'matching'], @@ -5423,23 +5801,47 @@ class HaWashdataPanel extends HTMLElement { // Fetch the device's live effective settings + its saved presets. Tolerant of an // older backend (command unknown): the field pre-fill silently falls back to the // stored-option chain in _pgFieldVal. - async _pgFetchSettings(entryId) { + async _pgFetchSettings(entryId, includeSuggestions = true) { try { - const r = await this._ws({ type: `${_DOMAIN}/get_playground_settings`, entry_id: entryId }); + const r = await this._ws({ + type: `${_DOMAIN}/get_playground_settings`, entry_id: entryId, + include_suggestions: includeSuggestions, + }); if (!this._isActiveEntry(entryId)) return; this._pgEffective = r.effective || {}; this._pgPublishable = Array.isArray(r.publishable) ? r.publishable : null; this._pgPresets = Array.isArray(r.presets) ? r.presets : []; this._pgPresetLimit = r.preset_limit || 0; if (this._pgPresetSel && !this._pgPresets.some(p => p.name === this._pgPresetSel)) this._pgPresetSel = ''; - this._pgSuggClassic = (r.classic_suggestions && typeof r.classic_suggestions === 'object') ? r.classic_suggestions : {}; - this._pgSuggMl = (r.ml_suggestions && typeof r.ml_suggestions === 'object') ? r.ml_suggestions : null; - this._pgMlSuggEnabled = !!r.ml_suggestions_enabled; + if (includeSuggestions) this._pgApplySuggestions(r); } catch (e) { if (this._pgIsUnknownCmd(e)) this._pgNeedsRestart = true; } } + _pgApplySuggestions(r) { + this._pgSuggClassic = (r.classic_suggestions && typeof r.classic_suggestions === 'object') ? r.classic_suggestions : {}; + this._pgSuggMl = (r.ml_suggestions && typeof r.ml_suggestions === 'object') ? r.ml_suggestions : null; + this._pgMlSuggEnabled = !!r.ml_suggestions_enabled; + } + + // Suggestions label the two "Load suggested" buttons and nothing else, and the ML set + // runs statistics over every clean cycle - real work that used to sit on the critical + // path of opening the tab, ahead of two more round-trips. Fetched in the background + // instead; the buttons render only once their count is non-zero, so they appear when + // the data lands rather than holding up the whole tab. + async _pgFetchSuggestions(entryId) { + try { + const r = await this._ws({ + type: `${_DOMAIN}/get_playground_settings`, entry_id: entryId, + include_suggestions: true, + }); + if (!this._isActiveEntry(entryId)) return; + this._pgApplySuggestions(r); + if (this._tab === 'playground') this._render(); + } catch (_) { /* the buttons simply stay hidden */ } + } + // Every value the control panel currently shows (live baseline + staged edits). // This is what a "save preset" snapshots, so a preset is a complete setup rather // than a sparse diff that would mean something different on another baseline. @@ -7062,6 +7464,13 @@ class HaWashdataPanel extends HTMLElement {
+ +
+
${this._t('hdr.import_power_history', {}, 'Import power history')}
+

${this._t('msg.import_history_description', {}, 'Already had a smart plug before WashData? Upload a history export of its power sensor, or read it straight from Home Assistant, and the normal detection runs over it so past cycles turn up in your Cycles list ready to name.')}

+
+ +
` : `

${this._t('msg.maintenance_requires_access', {}, 'Maintenance and export/import require full access.')}

`}`; } @@ -7324,24 +7733,54 @@ class HaWashdataPanel extends HTMLElement { _htmlStoreBrands() { const items = this._storeDevices || []; + const mine = String((this._opts || {}).store_model || '').trim().toLowerCase(); const rows = items.map(d => { const title = `${_esc(d.brand || '')} ${_esc(d.model || '')}`.trim() || this._t('store.device', {}, 'Device'); const type = d.applianceType ? `${_esc(this._deviceTypeLabel(d.applianceType))}` : ''; + const isMine = mine && String(d.model || '').toLowerCase() === mine; + const yours = isMine ? `${this._t('store.your_model', {}, 'Yours')}` : ''; + // Only ever claim content, never absence: these counters under-report (see + // _storeItemHasContent), so a missing chip means "unknown", not "empty". + const nProg = Number(d.profileCount) || 0; + const progChip = nProg > 0 + ? `${this._t('store.programs_count', {n: nProg}, `Programs: ${nProg}`)}` + : ''; return ``; }).join(''); + + // Nothing to scope the catalog to yet. Declaring the appliance is what makes this tab + // useful AND is itself the catalog search (the Settings pickers query the same data), + // so point there rather than showing an unscoped list the user cannot act on. + if (!this._storeBrandScope()) { + return ` + +

${this._t('msg.store_declare_appliance', {}, 'Tell WashData which appliance you own and this tab shows the setups other people have shared for it. You can also type a brand above to look around.')}

+ `; + } + + const empty = `

${this._t('store.no_results', {}, 'No matching appliances found. Try a different search.')}

`; const list = this._storeLoading ? this._htmlStoreLoading() - : (items.length ? `
${rows}
` : `

${this._t('store.no_results', {}, 'No matching appliances found. Try a different search.')}

`); + : (items.length ? `
${rows}
` : empty); + // The exact model is empty far more often than not, so say plainly that the other + // rows are worth a look rather than letting the user conclude the store is empty. + const siblingHint = (!this._storeLoading && items.length > 1) + ? `

${this._t('msg.store_sibling_hint', {}, 'Nothing shared for your exact model? A closely-related model from the same brand is usually a good starting point.')}

` + : ''; return ` + ${siblingHint} ${list}`; } @@ -7465,10 +7904,19 @@ class HaWashdataPanel extends HTMLElement { // one-line list entry + a store_account default; no bespoke handler needed. _htmlStorePrefs(busy) { const prefs = (this._constants && this._constants.storePrefs) || {}; - return _STORE_PREFS.map(p => { + const rows = _STORE_PREFS.map(p => { const checked = prefs[p.key] !== false; // defaults on; get_constants sends the full set return _switchRow(`data-action="store-toggle-pref" data-pref="${_esc(p.key)}" ${checked ? 'checked' : ''} ${busy ? 'disabled' : ''}`, this._t(p.labelKey, {}, p.labelFb), _tip(this._t(p.docKey, {}, p.docFb))); }).join(''); + // The catalog is cached for an hour because every read is charged against a quota the + // whole community shares. Your own contributions clear it immediately; this is the + // escape hatch for someone else's (e.g. a brand you were told was just approved). + const refreshing = this._busy.has('store-refresh-catalog'); + return rows + ` +
+ + ${this._t('msg.refresh_catalog_hint', {}, 'The community brand and appliance lists are cached to keep the shared store within its daily budget. Refresh to pick up entries added or approved by others.')} +
`; } // ── Community Store data ───────────────────────────────────────────────────── @@ -7483,6 +7931,15 @@ class HaWashdataPanel extends HTMLElement { } catch (_) { /* leave prior status */ } } + // The brand the browse list is scoped to: whatever the user typed, else the appliance + // they declared. Browsing is deliberately brand-scoped -- an unscoped list of one + // appliance type is ~140 catalog entries, and a washing machine has no use for + // dishwashers. Scoping also lands on the SAME cache key the Settings model picker + // uses, so opening one after the other costs nothing. + _storeBrandScope() { + return String(this._storeQuery || (this._opts || {}).store_brand || '').trim(); + } + async _storeSearch(query) { const dev = this._devices[this._selIdx]; if (!dev) return; @@ -7490,12 +7947,23 @@ class HaWashdataPanel extends HTMLElement { this._storeQuery = query || ''; this._storeView = 'brands'; this._storeDevice = null; this._storeProfile = null; this._storeProfiles = []; this._storeCycles = []; + const brand = this._storeBrandScope(); + // Nothing to scope to yet: show the "tell us what you own" state rather than spend a + // read on a list the user cannot act on (see _htmlStoreBrands). + if (!brand) { this._storeDevices = []; this._storeLoading = false; this._render(); return; } this._storeLoading = true; this._render(); try { - const r = await this._ws({ type: `${_DOMAIN}/store_search_devices`, entry_id: eid, query: this._storeQuery, appliance_type: this._storeApplianceType() }); + const r = await this._ws({ + type: `${_DOMAIN}/store_search_devices`, entry_id: eid, + query: brand, appliance_type: this._storeApplianceType(), + // Pending entries are 93% of the catalog and are publicly readable (shown with + // an "awaiting approval" tag), so approved-only made this tab show 15 of 564 + // devices -- about 6 per appliance type. + include_pending: true, + }); if (!this._isActiveEntry(eid)) return; if (r && r.disabled) { this._storeStatus = { enabled: false }; this._storeDevices = []; } - else this._storeDevices = (r && r.items) || []; + else this._storeDevices = this._sortStoreDevices((r && r.items) || []); } catch (e) { if (this._isActiveEntry(eid)) { this._storeDevices = []; this._showToast(this._t('toast.store_search_failed', {error: e.message || e}, 'Search failed: ' + (e.message || e)), 'error'); } } finally { @@ -7503,6 +7971,29 @@ class HaWashdataPanel extends HTMLElement { } } + // Own model first, then entries that carry shared programs, then the rest (each group + // keeping the server's favourite-count order). The user's exact model has nothing + // shared about 70% of the time, so the sibling models have to be visible -- but their + // own machine still has to be easy to find in a 44-row list. + _sortStoreDevices(items) { + const mine = String((this._opts || {}).store_model || '').trim().toLowerCase(); + const rank = (d) => { + if (mine && String(d.model || '').toLowerCase() === mine) return 0; + return this._storeItemHasContent(d) ? 1 : 2; + }; + return items + .map((d, i) => ({ d, i })) + .sort((a, b) => (rank(a.d) - rank(b.d)) || (a.i - b.i)) + .map((x) => x.d); + } + + // True only when a positive count says so. These counters are contributor-maintained + // and under-report where an increment was denied, so absent/zero means "unknown", not + // "empty" -- the UI must never tell a user an entry is empty on a stale zero. + _storeItemHasContent(d) { + return (Number(d && d.profileCount) || 0) > 0 || (Number(d && d.cycleCount) || 0) > 0; + } + // Attach the GitHub-connect popup message listener exactly once. The popup // (served from the store web origin) posts {type:'washdata-connect', ...}; we // validate the origin strictly against the configured store web origin. @@ -7526,6 +8017,8 @@ class HaWashdataPanel extends HTMLElement { if (d.model) patch.store_model = d.model; this._opts = { ...this._opts, ...patch }; this._catalog.brands = undefined; this._catalog.devices = undefined; this._catalog.forBrand = null; + this._catalog.brandsFull = false; this._catalog.brandPrefixes = []; + this._catalogEntry = null; this._showToast(this._t('toast.appliance_added', {}, 'Appliance added - awaiting approval')); this._render(); return; @@ -7533,6 +8026,8 @@ class HaWashdataPanel extends HTMLElement { if (d.type === 'washdata-brand-created') { if (d.brand) this._opts = { ...this._opts, store_brand: d.brand }; this._catalog.brands = undefined; // reload the brand catalog so it is pickable + this._catalog.brandsFull = false; this._catalog.brandPrefixes = []; + this._catalogEntry = null; // and re-resolve the badge for the new brand this._showToast(this._t('toast.brand_added', {}, 'Brand added - awaiting approval')); this._render(); return; @@ -7901,6 +8396,33 @@ class HaWashdataPanel extends HTMLElement { _hideGraphTip() { if (this._hoverRafId) { cancelAnimationFrame(this._hoverRafId); this._hoverRafId = null; this._hoverPending = null; } if (this._gtip) this._gtip.style.display = 'none'; this._syncSpagRowHighlight(null); } + // #385: keep an "i" help popover inside whatever clips it. The CSS centres the + // bubble on its 15px anchor (`left:50%` + `translateX(-50%)`), so an anchor near + // a container edge (every label in the cycle modal's Review tab) pushed half + // of it outside the modal, where the overflow cut the text off mid-word. Nudge + // it back along the x axis on hover, keeping the centred position preferred. + _positionTip(anchor) { + const pop = anchor.querySelector('.wd-tip-pop'); + if (!pop) return; + // Always measure from the unshifted position so repeat hovers can't stack up. + pop.style.transform = ''; + // On the first hover the :hover rule may not have been applied yet, so lay + // the bubble out invisibly just to measure it. + const hidden = !pop.offsetWidth; + const prev = hidden ? pop.style.cssText : ''; + if (hidden) { pop.style.visibility = 'hidden'; pop.style.display = 'block'; } + const r = pop.getBoundingClientRect(); + if (hidden) pop.style.cssText = prev; + if (!r.width) return; + const clip = _clipRectFor(anchor); + const gap = 6; // breathing room at the edge + const min = clip.left + gap; + const max = clip.right - gap - r.width; + const left = max < min ? min : Math.min(Math.max(r.left, min), max); + const shift = Math.round(left - r.left); + if (shift) pop.style.transform = `translateX(calc(-50% + ${shift}px))`; + } + _syncSpagRowHighlight(cid) { if (cid === this._spagHoverCid) return; this._spagHoverCid = cid; @@ -7938,6 +8460,7 @@ class HaWashdataPanel extends HTMLElement { if (m.type === 'gear-settings') return `
`; if (m.type === 'export-select') return `
`; if (m.type === 'import-wizard') return `
`; + if (m.type === 'history-import') return `
`; let body = ''; if (m.type === 'confirm') { @@ -8392,6 +8915,187 @@ class HaWashdataPanel extends HTMLElement { `; } + // ── Import power history (#344) ─────────────────────────────────────────────── + // + // Four steps: stage the data, scan it in the background, review what was found, + // then write only the rows the user kept. Nothing is stored before the review step. + // Parsing lives in Python so one implementation is under test; the panel only ships + // the text up in frame-sized chunks. + + _histSkipReason(reason) { + const map = { + idle: this._t('lbl.hist_skip_idle', {}, 'nothing running'), + sparse: this._t('lbl.hist_skip_sparse', {}, 'readings too far apart'), + too_few_samples: this._t('lbl.hist_skip_short', {}, 'too few readings'), + too_long: this._t('lbl.hist_skip_long', {}, 'no break long enough to split on'), + }; + return map[reason] || reason || ''; + } + + _histSegReason(reason) { + const map = { + shorter_than_minimum: this._t('lbl.hist_reason_short', {}, 'shorter than this appliance\'s shortest real cycle'), + no_clean_end: this._t('lbl.hist_reason_no_end', {}, 'never ended cleanly'), + }; + return map[reason] || ''; + } + + _htmlHistoryImportModal(m) { + const title = `

${this._t('modal.history_import', {}, 'Import power history')}

`; + const err = m.error ? `

${_esc(m.error)}

` : ''; + + if (m.step === 'input') { + const busy = this._busy.has('hist-import'); + const dis = busy ? 'disabled' : ''; + return `${title} +

${this._t('msg.hist_input_hint', {}, 'Upload a CSV downloaded from the History panel (entity, state, last changed), or let WashData read the sensor\'s history directly. Detection then runs over it exactly as it does live, and you choose which of the cycles it finds to keep.')}

+
+
+
+ +
+ ${this._t('lbl.hist_since', {}, 'Since')} + + +
+
${this._t('msg.hist_recorder_hint', {}, 'Reads from the date you pick up to now. Home Assistant keeps detailed history for 10 days by default and only hourly averages after that, which are too coarse to detect cycles from - pick a date further back only if your recorder is set to keep more.')}
+
+ ${err} +
+ + +
`; + } + + if (m.step === 'scan') { + const t = m.scanTaskId ? (this._tasks || {})[m.scanTaskId] : null; + const pct = (t && t.total > 0) ? Math.round((t.done / t.total) * 100) : null; + return `${title} +

${this._t('msg.hist_scanning', {}, 'Replaying your history through the detector. This runs in the background - you can close this dialog and come back to it.')}

+
+

${pct == null ? this._t('status.preparing', {}, 'Preparing…') : `${pct}%`}

+ ${err} +
+ +
`; + } + + if (m.step === 'done') { + const d = m.done || {}; + const lines = [ + this._t('msg.hist_imported_count', { n: d.imported || 0 }, `${d.imported || 0} cycles imported.`), + d.duplicates ? this._t('msg.hist_duplicates', { n: d.duplicates }, `${d.duplicates} were already imported and were skipped.`) : '', + d.capped ? this._t('msg.hist_capped', {}, 'The per-device limit for imported cycles was reached; the rest were not stored.') : '', + ].filter(Boolean); + return `${title} + ${lines.map(l => `

${_esc(l)}

`).join('')} +

${this._t('msg.hist_next_step', {}, 'They are in your Cycles list, tagged as imported history. Open one and use Label to name the program it belongs to.')}

+
+ + +
`; + } + + // step === 'review' + const res = m.result || {}; + const segs = res.segments || []; + const parse = res.parse || {}; + const busy = this._busy.has('hist-apply'); + const accept = m.accept || new Set(); + + // Account for every row the file contained, so "nothing found" is explained + // rather than just reported. + const facts = []; + if (parse.rows_total) facts.push(this._t('msg.hist_rows_read', { n: parse.rows_total }, `${parse.rows_total} readings read`)); + if (parse.first && parse.last) facts.push(`${_fmtDate(parse.first)} – ${_fmtDate(parse.last)}`); + if (parse.breaks) facts.push(this._t('msg.hist_breaks', { n: parse.breaks }, `${parse.breaks} gaps where the sensor was unavailable`)); + if (parse.rows_other_entity) facts.push(this._t('msg.hist_other_entity', { n: parse.rows_other_entity }, `${parse.rows_other_entity} readings for other entities ignored`)); + // The file held one sensor and it was not this device's: it was read anyway, but say + // so plainly - it is the difference between "my export" and "the wrong export". + if (parse.entity_substituted_from) { + facts.push(this._t( + 'msg.hist_entity_substituted', + { used: parse.entity_id || '?', wanted: parse.entity_substituted_from }, + `read ${parse.entity_id || '?'} (this device is configured for ${parse.entity_substituted_from})`, + )); + } + const skipped = res.skipped || []; + const skippedByReason = {}; + skipped.forEach(sk => { skippedByReason[sk.reason] = (skippedByReason[sk.reason] || 0) + 1; }); + const skipLine = Object.entries(skippedByReason) + .map(([reason, n]) => `${n} × ${this._histSkipReason(reason)}`).join(', '); + + const settings = res.settings || {}; + const settingsLine = settings.min_power != null + ? this._t('msg.hist_settings_used', { w: settings.min_power, s: settings.off_delay }, + `Detected using this device's current settings (minimum power ${settings.min_power} W, off delay ${settings.off_delay} s).`) + : ''; + + if (!segs.length) { + return `${title} +

${this._t('msg.hist_none_found', {}, 'No cycles could be detected in that history.')}

+ ${facts.length ? `

${_esc(facts.join(' · '))}

` : ''} + ${skipLine ? `

${this._t('msg.hist_skipped_spans', {}, 'Skipped stretches')}: ${_esc(skipLine)}

` : ''} + ${settingsLine ? `

${_esc(settingsLine)}

` : ''} +
+ + +
`; + } + + const rows = segs.map(seg => { + const on = accept.has(seg.index); + const reason = this._histSegReason(seg.reason); + return ` + + ${_esc(_fmtDate(seg.start_time))} + ${_esc(_fmtDuration(seg.duration_s))} + ${seg.energy_wh != null ? _esc((seg.energy_wh / 1000).toFixed(2)) + ' kWh' : '–'} + ${_esc(String(Math.round(seg.peak_w)))} W + + ${reason ? ` ${_esc(reason)}` : `${_esc(this._t('lbl.hist_looks_complete', {}, 'complete'))}`} + `; + }).join(''); + + const allOn = segs.every(seg => accept.has(seg.index)); + return `${title} +

${this._t('msg.hist_found', { n: segs.length }, `Found ${segs.length} cycles. Untick anything that does not look like a real run - nothing is stored until you import.`)}

+ ${facts.length ? `

${_esc(facts.join(' · '))}

` : ''} + ${skipLine ? `

${this._t('msg.hist_skipped_spans', {}, 'Skipped stretches')}: ${_esc(skipLine)}

` : ''} + ${settingsLine ? `

${_esc(settingsLine)}

` : ''} +
+
+ + + + + + + + + ${rows}
${this._t('lbl.date', {}, 'Date')}${this._t('lbl.duration', {}, 'Duration')}${this._t('lbl.energy', {}, 'Energy')}${this._t('lbl.peak_power_short', {}, 'Peak')}${this._t('lbl.shape', {}, 'Shape')}${this._t('lbl.notes', {}, 'Notes')}
+
+ ${res.capped ? `

${this._t('msg.hist_scan_capped', { n: res.found }, `Only the first candidates are shown (${res.found} were found).`)}

` : ''} + ${err} +
+ + +
`; + } + + // Paint the candidate sparklines after a render (same painter as profile cards). + _drawHistorySparklines() { + const sr = this.shadowRoot; + if (!sr) return; + const m = this._modal; + const segs = (m && m.result && m.result.segments) || []; + const byIndex = {}; + segs.forEach(seg => { byIndex[String(seg.index)] = seg.curve || []; }); + sr.querySelectorAll('canvas[data-hist-spark]').forEach(cv => { + this._paintSparkline(cv, byIndex[cv.dataset.histSpark] || []); + }); + } + // Interactive cycle inspector: view / trim / split. _htmlCycleModal(m) { if (!m.loaded) { @@ -8399,7 +9103,14 @@ class HaWashdataPanel extends HTMLElement {
`; } const cur = m.curve || {}; - const isRef = !!cur.is_reference; // imported store recording: read-only except delete + const isRef = !!cur.is_reference; // lives in reference_cycles: outside usage stats + // Capabilities come from the backend (`_reference_capabilities`), which derives them + // from which list the cycle lives in. An imported cycle can be labelled - that is how + // a program gets named from imported history (#344) - but not trimmed, split or + // reviewed, because those store functions only operate on past_cycles. + const canLabel = cur.labelable !== false; + const canEditCycle = cur.editable !== false; + const fromHistory = cur.cycle_origin === 'backfill'; const full = cur.full_duration_s || cur.duration || 0; const kwh = cur.energy_kwh != null ? cur.energy_kwh : null; // ML health chip (higher = better) shown when an ML assessment is attached. @@ -8433,12 +9144,14 @@ class HaWashdataPanel extends HTMLElement { : ''; // Imported recordings are read-only (they seed matching templates only), so // the edit mode-bar is hidden and a short note explains why. - const modeBar = (this._canEdit() && !isRef) ? `
+ const modeBar = (this._canEdit() && canEditCycle) ? `
-
` : (isRef ? `
📥 ${this._t('msg.imported_readonly', {}, 'Imported from the community store. Shown for reference and matching. It is not counted in your stats and cannot be edited.')}
` : ''); +
` : (isRef ? `
📥 ${fromHistory + ? this._t('msg.imported_history_readonly', {}, 'Detected in imported power history. It shapes program matching but is not counted in your statistics, and cannot be trimmed or split. Label it to name the program.') + : this._t('msg.imported_readonly', {}, 'Imported from the community store. Shown for reference and matching. It is not counted in your stats and cannot be edited.')}
` : ''); // Pending-detection-feedback banner (Confirm / Correct… / Ignore). Built once // and shown in BOTH Inspect and Review modes, so a cycle in the "needs review" @@ -8466,12 +9179,14 @@ class HaWashdataPanel extends HTMLElement { const shareBtn = canShare ? `` : ''; - // Imported recordings support Delete (remove a bad import) but not Label - // (relabelling only applies to real cycles that feed usage stats). + // Delete removes a bad import; Label is offered whenever the backend says the + // cycle can carry one, which includes imported cycles - naming the programs found + // in imported history is the point of that import (#344), and the store handles + // labelling a reference cycle in place. const editBtns = !this._canEdit() ? '' - : isRef ? `` - : ` - `; + : `${canLabel + ? `\n ` + : ''}`; controls = `${fbBanner}
${shareBtn} @@ -8605,9 +9320,19 @@ class HaWashdataPanel extends HTMLElement {
${this._t('msg.restart_gap_footer', {}, 'Highlighted on the graph. Power data is missing for these intervals — matching used only real readings.')}
`; } + // Thinning caption (#395): the graph draws a decimated copy of the stored + // trace, so a wide gap between drawn points can look like missing sensor data. + // Declare it when the server thinned the curve so the two are not confused. + let decNote = ''; + if (cur.decimated) { + const shownN = (cur.samples || []).length; + const totalN = cur.sample_count || shownN; + decNote = `
${this._t('msg.samples_decimated', {shown: shownN, total: totalN}, `Showing ${shownN} of ${totalN} samples (thinned for display; peaks kept). A wide gap here is thinning, not missing data.`)}
`; + } return `

${this._t('lbl.cycle', {}, 'Cycle')} · ${_esc(_fmtDate(cur.start_time))}

${meta}${modeBar}
+ ${decNote} ${artifactBox} ${restartGapBox} ${controls}`; @@ -9014,9 +9739,8 @@ class HaWashdataPanel extends HTMLElement { // _snapshotFormToPending call; it would override _opts in the render since // Object.assign merges pending last. Clear it so _opts wins. delete this._pendingSettings.store_brand; - this._catalog.forBrand = v; this._catalog.devices = null; + this._catalog.forBrand = v; this._catalog.devices = undefined; this._render(); // enable + reset the model field (input has blurred) - this._loadCatalogDevices(v); // patches #wd-model-dl in place, no re-render }); const modelInput = sr.getElementById('wd-store-model'); if (modelInput) modelInput.addEventListener('change', () => { @@ -9345,6 +10069,11 @@ class HaWashdataPanel extends HTMLElement { const optKey = inp.dataset.opt || combo.closest('[data-opt]')?.dataset.opt; const showDrop = (q) => { + // Opening the store brand/model combo is the moment the user actually needs the + // catalog, so that is when it is fetched -- rendering the form no longer does it. + // This costs nothing here: the fetch fills _entityListCache, which is read live + // below, so the options appear on the next keystroke or focus without a re-render. + this._ensureCatalogList(optKey, q); // Read the candidate list live so async-loaded options (e.g. the store // brand/model catalog) appear without re-wiring the combobox. const entities = (this._entityListCache || {})[optKey] || []; @@ -9610,6 +10339,30 @@ class HaWashdataPanel extends HTMLElement { }, { once: true }); }); } + const histFile = sr.getElementById('wd-hist-file'); + if (histFile) histFile.addEventListener('change', () => { + const f = histFile.files && histFile.files[0]; + if (!f) return; + const m = this._modal; + const reader = new FileReader(); + reader.onload = () => { + const ta = sr.getElementById('wd-hist-csv'); + const text = String(reader.result || ''); + if (ta) ta.value = text; + if (m && m.type === 'history-import') m.csvText = text; + }; + reader.onerror = () => this._showToast( + this._t('toast.file_read_failed', {}, 'Could not read that file'), 'error'); + reader.readAsText(f); + }); + sr.querySelectorAll('[data-hist-pick]').forEach(box => box.addEventListener('change', () => { + const m = this._modal; + if (!m || m.type !== 'history-import') return; + const index = parseInt(box.dataset.histPick, 10); + if (box.checked) m.accept.add(index); else m.accept.delete(index); + this._render(); + requestAnimationFrame(() => this._drawHistorySparklines()); + })); const impFile = sr.getElementById('wd-import-file'); if (impFile) impFile.addEventListener('change', () => { const f = impFile.files && impFile.files[0]; @@ -9687,6 +10440,7 @@ class HaWashdataPanel extends HTMLElement { this._pendingSettings = {}; const r = await this._ws({ type: `${_DOMAIN}/get_options`, entry_id: dev.entry_id }); this._opts = r.options || {}; + this._optDefaults = r.defaults || {}; // #396 await this._fetchSuggestions(dev.entry_id); this._render(); } @@ -10058,6 +10812,19 @@ class HaWashdataPanel extends HTMLElement { if (!dev) return; const eid = dev.entry_id; + // Prefix-grouped dispatch: these action families live in dedicated sub-methods. + // dev / eid / sr stay derived here and are passed in, never re-derived. + if (a.startsWith('sug-')) return this._onActSuggestions(a, btn, dev, eid, sr); + if (a.startsWith('ml-')) return this._onActMl(a, btn, dev, eid); + if (a.startsWith('store-')) return this._onActStore(a, btn, dev, eid, sr); + if (a.startsWith('auto-')) return this._onActAuto(a, btn, dev, eid, sr); + if (a.startsWith('maint-')) return this._onActMaintenance(a, btn, dev, eid, sr); + // 'pg-new' / 'pg-edit' / 'pg-suggest' are profile-GROUP actions handled in the + // chain below, not Playground ones, so they are excluded from the pg- prefix. + if (a.startsWith('pg-') && a !== 'pg-new' && a !== 'pg-edit' && a !== 'pg-suggest') { + return this._onActPlayground(a, btn, dev, eid); + } + if (a === 'open-cycle') { const cid = btn.dataset.cid; // Cycles opened from the "needs review" queue jump straight to Review mode. @@ -10098,7 +10865,397 @@ class HaWashdataPanel extends HTMLElement { this._render(); }); - } else if (a === 'sug-apply-all') { + } else if (a === 'create-profile') { + this._modal = { type: 'create-profile' }; this._render(); + + } else if (a === 'setup-cta') { + // Setup card primary / secondary CTA — navigate to the relevant panel section. + const ctaAction = btn.dataset.ctaAction || ''; + let params = {}; + try { params = JSON.parse(btn.dataset.ctaParams || '{}'); } catch (_) {} + this._dispatchSetupCta(ctaAction, params); + + } else if (a === 'setup-skip') { + // Setup card step skip (snooze 14 days or never). + const stepKey = btn.dataset.step; + const snooze = btn.dataset.snooze; // "never" or "14d" + if (stepKey) { + let val; + if (snooze === 'never') { + val = 'never'; + } else { + const until = new Date(); + until.setDate(until.getDate() + 14); + val = until.toISOString(); + } + this._setPref(stepKey, val); + this._reloadSetupStatus(); // async fire-and-forget; calls _render() when done + } + + } else if (a === 'hide-setup-card') { + // Setup card permanent hide (only offered when dismissible, i.e. phase 3/4). + // Keep _setupStatus so _htmlSetupCard can collapse to the phase-3 chip + // immediately (nulling it here would hide the card entirely instead — the + // sibling setup-skip / expand-setup handlers also leave the status intact). + this._setPref('setup_card_dismissed', true); + this._render(); + + } else if (a === 'expand-setup') { + // Phase 3/4 chip tapped — restore full guidance card by clearing the pref. + this._setPref('setup_card_dismissed', false); + this._render(); + + } else if (a === 'set-settings-level') { + // F2: switch the Settings tab between Basic and Advanced disclosure. + const lvl = (btn.type === 'checkbox' ? btn.checked : btn.dataset.slevel === 'advanced') ? 'advanced' : 'basic'; + if (lvl !== this._pref('settings_level', 'basic')) { + this._snapshotFormToPending(sr); // keep in-progress edits across re-render + this._setPref('settings_level', lvl); + this._render(); + } + + } else if (a === 'pg-new' || a === 'pg-edit' || a === 'pg-suggest') { + if (a === 'pg-new') { + this._modal = { type: 'profile-group', orig: null, name: '', members: [] }; + } else if (a === 'pg-edit') { + const gname = btn.dataset.gname; + const g = ((this._profileGroups || {}).groups || []).find(x => x.name === gname); + this._modal = { type: 'profile-group', orig: gname, name: gname, members: g ? [...(g.members || [])] : [] }; + } else { + const s = ((this._profileGroups || {}).suggestions || [])[parseInt(btn.dataset.idx, 10)] || null; + if (!s) return; + this._modal = { type: 'profile-group', orig: s.existing_group || null, name: s.existing_group || '', members: [...(s.members || [])] }; + } + this._render(); + // Fetch every profile's envelope so ticked members render on the overlay. + this._ensureProfileEnvs(eid, (this._profiles || []).map(p => p.name)).then(() => { + if (this._modal && this._modal.type === 'profile-group') this._render(); + }); + + } else if (a === 'rebuild-envelopes') { + // Backgrounded task (issue #311): rebuilding every profile serially can + // stall a low-power host, so run it via the registry with a header pill. + this._kickAndTrack( + { type: `${_DOMAIN}/rebuild_envelopes`, entry_id: eid }, + 'rebuild-envelopes', + async () => { this._showToast(this._t('toast.envelopes_rebuilt', {}, 'Envelopes rebuilt')); await this._fetchProfiles(eid); }, + ); + + } else if (a === 'rec-start') { + this._ws({ type: `${_DOMAIN}/start_recording`, entry_id: eid }).then(() => { this._showToast(this._t('toast.recording_started', {}, 'Recording started')); return this._fetchRecState(eid); }).then(() => this._render()).catch(e => this._showToast(this._t('toast.start_failed', {error: e.message || e}, 'Start failed: ' + (e.message || e)), 'error')); + } else if (a === 'rec-stop') { + this._ws({ type: `${_DOMAIN}/stop_recording`, entry_id: eid }).then(() => { this._showToast(this._t('toast.recording_stopped', {}, 'Recording stopped')); return this._fetchRecState(eid); }).then(() => this._render()).catch(e => this._showToast(this._t('toast.stop_failed', {error: e.message || e}, 'Stop failed: ' + (e.message || e)), 'error')); + } else if (a === 'rec-process-open') { + this._fetchProfiles(eid).then(() => { this._modal = { type: 'process-recording' }; this._render(); }); + } else if (a === 'rec-discard') { + this._modal = { type: 'confirm', title: this._t('modal.discard_recording_title', {}, 'Discard Recording'), message: this._t('modal.discard_recording_msg', {}, 'Discard the saved recording? This cannot be undone.'), okLabel: this._t('btn.discard', {}, 'Discard'), + onOk: async () => { try { await this._ws({ type: `${_DOMAIN}/discard_recording`, entry_id: eid }); this._showToast(this._t('toast.recording_discarded', {}, 'Recording discarded')); await this._fetchRecState(eid); } catch (e) { this._showToast(this._t('toast.discard_failed', {error: e.message || e}, 'Discard failed: ' + (e.message || e)), 'error'); } } }; + this._render(); + + } else if (a === 'fb-confirm') { + this._ws({ type: `${_DOMAIN}/resolve_feedback`, entry_id: eid, cycle_id: btn.dataset.cid, action: 'confirm' }).then(() => { this._showToast(this._t('toast.feedback_confirmed', {}, 'Feedback confirmed')); return this._fetchFeedbacks(eid); }).then(() => this._render()).catch(e => this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error')); + } else if (a === 'fb-ignore') { + this._ws({ type: `${_DOMAIN}/resolve_feedback`, entry_id: eid, cycle_id: btn.dataset.cid, action: 'ignore' }).then(() => { this._showToast(this._t('toast.feedback_dismissed', {}, 'Feedback dismissed')); return this._fetchFeedbacks(eid); }).then(() => this._render()).catch(e => this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error')); + } else if (a === 'fb-correct') { + this._fetchProfiles(eid).then(() => { this._modal = { type: 'correct-feedback', cycleId: btn.dataset.cid, detectedProfile: btn.dataset.prof }; this._render(); }); + } else if (a === 'fb-dismiss-all') { + this._modal = { type: 'confirm', title: this._t('modal.dismiss_all_title', {}, 'Dismiss All Feedbacks'), message: this._t('modal.dismiss_all_msg', {count: this._feedbacks.length}, `Dismiss all ${this._feedbacks.length} pending feedback requests?`), okLabel: this._t('modal.dismiss_all_ok', {}, 'Dismiss All'), + onOk: async () => { try { await this._ws({ type: `${_DOMAIN}/dismiss_all_feedbacks`, entry_id: eid }); this._showToast(this._t('toast.feedback_all_dismissed', {}, 'All feedbacks dismissed')); await this._fetchFeedbacks(eid); } catch (e) { this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error'); } } }; + this._render(); + + } else if (a === 'create-phase') { + this._modal = { type: 'create-phase', deviceType: btn.dataset.dtype }; this._render(); + } else if (a === 'edit-phase') { + this._modal = { type: 'edit-phase', phaseId: btn.dataset.pid, phaseName: btn.dataset.pname, phaseDesc: btn.dataset.pdesc, isDefault: btn.dataset.pisdefault === 'true' }; this._render(); + } else if (a === 'del-phase') { + const pname = btn.dataset.pname, pid = btn.dataset.pid; + this._modal = { type: 'confirm', title: this._t('modal.delete_phase_title', {}, 'Delete Phase'), message: this._t('modal.delete_phase_msg', {name: pname}, `Delete phase "${pname}"?`), okLabel: this._t('btn.delete', {}, 'Delete'), + onOk: async () => { try { await this._ws({ type: `${_DOMAIN}/delete_phase`, entry_id: eid, phase_id: pid }); this._showToast(this._t('toast.phase_deleted', {name: pname}, `Phase "${pname}" deleted`)); await this._fetchPhases(eid); } catch (e) { this._showToast(this._t('msg.toast_delete_failed', {error: e.message || e}, 'Delete failed: ' + (e.message || e)), 'error'); } } }; + this._render(); + + } else if (a === 'diag-refresh') { + this._fetchToolsData(eid).then(() => this._render()); + + } else if (a === 'reprocess-history') { + this._modal = { type: 'confirm', title: this._t('modal.process_history_title', {}, 'Process History'), message: this._t('modal.process_history_msg', {}, 'Re-run matching, refresh suggestions, retrain ML (if enabled) and recompute cycle health across all stored cycles. This may take a while.'), okLabel: this._t('modal.process_history_ok', {}, 'Process'), + onOk: () => this._kickAndTrack({ type: `${_DOMAIN}/reprocess_history`, entry_id: eid }, 'reprocess', async (r) => { + const nc = r.count || 0; + const bits = [this._t('toast.processed_cycles', {n: nc}, nc + ' cycles')]; + if (r.suggestions != null) bits.push(this._t('toast.processed_suggestions', {n: r.suggestions}, r.suggestions + ' suggestion(s)')); + const np = (r.ml_training && r.ml_training.ok && (r.ml_training.promoted || []).length) || 0; + if (np) bits.push(this._t('toast.processed_models', {n: np}, np + ' model(s) promoted')); + this._showToast(this._t('toast.processed', {bits: bits.join(', ')}, 'Processed ' + bits.join(', '))); + await this._fetchToolsData(eid); + }) }; + this._render(); + } else if (a === 'clear-debug') { + this._modal = { type: 'confirm', title: this._t('modal.clear_debug_title', {}, 'Clear Debug Data'), message: this._t('modal.clear_debug_msg', {}, 'Delete all stored debug traces?'), okLabel: this._t('status.clear', {}, 'Clear'), + onOk: () => this._busyRun('clear-debug', async () => { try { const r = await this._ws({ type: `${_DOMAIN}/clear_debug_data`, entry_id: eid }); this._showToast(this._t('toast.debug_cleared', {count: r.count || 0}, `Cleared ${r.count || 0} debug traces`)); await this._fetchToolsData(eid); } catch (e) { this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error'); } }) }; + this._render(); + } else if (a === 'wipe-history') { + this._modal = { type: 'confirm', title: this._t('modal.wipe_all_title', {}, 'Wipe All Data'), message: this._t('modal.wipe_all_msg', {}, '⚠️ This permanently deletes ALL cycles and profiles. This cannot be undone.'), okLabel: this._t('modal.wipe_all_ok', {}, 'Wipe Everything'), + onOk: () => this._busyRun('wipe', async () => { try { await this._ws({ type: `${_DOMAIN}/wipe_history`, entry_id: eid }); this._showToast(this._t('toast.all_wiped', {}, 'All data wiped')); this._cycles = []; this._profiles = []; await this._fetchToolsData(eid); } catch (e) { this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error'); } }) }; + this._render(); + + } else if (a === 'export-config') { + this._ws({ type: `${_DOMAIN}/export_config`, entry_id: eid }).then(r => { + const blob = new Blob([r.json_data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a2 = document.createElement('a'); + a2.href = url; a2.download = `washdata_export_${eid.slice(0, 8)}.json`; + document.body.appendChild(a2); a2.click(); document.body.removeChild(a2); URL.revokeObjectURL(url); + this._showToast(this._t('toast.export_downloaded', {}, 'Export downloaded')); + }).catch(e => this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error')); + } else if (a === 'export-select-open') { + // Open the export wizard: fetch this device's inventory, default everything on. + this._modal = { type: 'export-select', inventory: null, loading: true, sel: { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() }, expanded: new Set() }; + this._render(); + (async () => { + let inv = null; + try { const r = await this._ws({ type: `${_DOMAIN}/get_export_inventory`, entry_id: eid }); inv = (r && r.manifest) || null; } + catch (e) { this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'); } + if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'export-select') return; + if (!inv) { this._modal = null; this._render(); return; } + this._modal.inventory = inv; + this._modal.sel = this._wizInitSel({ categories: inv }, false); + this._modal.loading = false; + this._render(); + })(); + } else if (a === 'cyc-select-toggle') { + this._selectMode = !this._selectMode; + if (!this._selectMode) this._cycleSel.clear(); + this._render(); + } else if (a === 'cyc-auto-open') { + this._modal = { type: 'auto-label' }; this._render(); + } else if (a === 'cyc-merge') { + const ids = Array.from(this._cycleSel); + if (ids.length < 2) return; + this._fetchProfiles(eid).then(() => { this._modal = { type: 'merge-cycles', ids }; this._render(); }); + } else if (a === 'cyc-relabel') { + // D6: bulk relabel — reuse the existing profile picker. + const ids = Array.from(this._cycleSel); + if (!ids.length) return; + this._fetchProfiles(eid).then(() => { this._modal = { type: 'bulk-relabel', ids }; this._render(); }); + } else if (a === 'cyc-load-more') { + // D3: append the next page, preserving current sort/filter. + this._busyRun('cyc-load-more', async () => { + try { await this._loadMoreCycles(eid); } + catch (e) { this._showToast(this._t('toast.load_more_failed', { error: e.message || e }, 'Could not load more: ' + (e.message || e)), 'error'); } + }); + } else if (a === 'task-cancel') { + const tid = btn.dataset.taskId; + if (tid) { + this._cancellingTasks.add(tid); + this._updateTaskPills(); + this._ws({ type: `${_DOMAIN}/cancel_task`, task_id: tid }).catch(() => { + // Cancel request itself failed (dropped socket, etc.) — re-enable the + // ✕ so the user can retry instead of leaving the pill stuck "Cancelling…". + this._cancellingTasks.delete(tid); + this._updateTaskPills(); + }); + } + } else if (a === 'cyc-compare') { + const ids = Array.from(this._cycleSel); + if (ids.length < 2) return; + // Open the overlay modal immediately (loading state), then fetch each + // selected cycle's trace in parallel and fill it in as they arrive. + this._modal = { type: 'compare-cycles', ids, cycles: {}, hidden: new Set(), overlays: [], loaded: false }; + if (!this._profiles.length) this._fetchProfiles(eid); + this._render(); + Promise.all(ids.map(cid => + this._ws({ type: `${_DOMAIN}/get_cycle_power_data`, entry_id: eid, cycle_id: cid }) + .then(r => ({ cid, r })).catch(() => ({ cid, r: null })) + )).then(results => { + if (!this._modal || this._modal.type !== 'compare-cycles') return; + results.forEach(({ cid, r }) => { if (r) this._modal.cycles[cid] = r; }); + this._modal.loaded = true; + this._render(); + }); + } else if (a === 'cyc-bulk-del') { + // D4: optimistic delete with a 10s Undo window (no confirm dialog). + const ids = Array.from(this._cycleSel); + if (!ids.length) return; + this._deleteCyclesWithUndo(eid, ids); + } else if (a === 'retry-cycles') { + this._fetchCycles(eid).then(() => this._render()); + } else if (a === 'retry-profiles') { + Promise.all([this._fetchProfiles(eid), this._fetchProfileGroups(eid)]).then(() => this._render()); + } else if (a === 'retry-suggestions') { + this._fetchSuggestions(eid).then(() => this._render()); + } else if (a === 'goto-suggestions') { + this._settingsSugOnly = true; this._tab = 'settings'; this._fetchTabData(); + } else if (a === 'goto-conflicts') { + this._tab = 'settings'; this._fetchTabData(); + } else if (a === 'conf-goto-section') { + const confKeys = this._conflictKeysFromOpts(); + for (const sec of _SETTINGS_SECTIONS) { + const fields = sec.fields || (sec.groups || []).flatMap(g => g.fields || []); + if (fields.some(f => confKeys.has(f.key))) { this._settingsSec = sec.id; this._render(); break; } + } + } else if (a === 'toggle-settings-history') { + this._settingsHistoryOpen = !this._settingsHistoryOpen; + this._render(); + + } else if (a === 'settings-revert-key') { + const key = btn.dataset.key; + const val = JSON.parse(btn.dataset.val); + if (!key) return; + const eid = dev.entry_id; + this._ws({ type: `${_DOMAIN}/set_options`, entry_id: eid, options: { [key]: val } }) + .then(() => this._ws({ type: `${_DOMAIN}/get_options`, entry_id: eid })) + .then(r => { this._opts = r.options || {}; this._optDefaults = r.defaults || {}; return this._fetchSettingsChangelog(eid); }) + .then(() => { + this._showToast(this._t('msg.toast_reverted', { key: this._t('setting.' + key + '.label', {}, key) }, '{key} reverted'), 'success'); + this._render(); + }) + .catch(e => this._showToast(this._t('msg.toast_error', { error: e.message || e }, 'Error: ' + (e.message || e)), 'error')); + + } else if (a === 'toggle-log-drawer') { + this._logOpen = !this._logOpen; + try { localStorage.setItem('wd-log-open', this._logOpen ? '1' : '0'); } catch (_) {} + this._render(); + if (this._logOpen) this._fetchLogs().then(() => { if (this._logOpen) this._render(); }); + } else if (a === 'open-advanced') { + // Overview action cards navigate to the Advanced tab at a given subtab. + const sub = btn.dataset.sub; + if (sub) this._panelSubtab = sub; + this._tab = 'advanced'; + this._render(); + if (this._panelSubtab === 'diagnostics' && !this._diag) this._fetchToolsData(eid).then(() => { if (this._tab === 'advanced') this._render(); }); + else if (this._panelSubtab === 'logs') this._fetchLogs().then(() => { if (this._tab === 'advanced') this._render(); }); + else if (this._panelSubtab === 'maintenance') this._fetchMaintenance(eid).then(() => { if (this._tab === 'advanced') this._render(); }); + else if (this._panelSubtab === 'ml') this._fetchTabData(); + } else if (a === 'add-device') { + this._navigate(`/config/integrations/integration/${_DOMAIN}`); + } else if (a === 'goto-feedbacks') { + this._tab = 'history'; this._cycleFilter = { ...this._cycleFilter, status: 'needs_review' }; this._fetchTabData(); + } else if (a === 'goto-recording') { + this._tab = 'status'; this._fetchTabData(); + } else if (a === 'logs-refresh') { + this._fetchLogs().then(() => this._render()); + } else if (a === 'logs-export') { + this._ws({ type: `${_DOMAIN}/get_logs`, limit: 500 }).then(r => { + const lines = (r.logs || []).map(x => `${new Date(x.ts * 1000).toISOString()} ${x.level} ${x.msg}`).join('\n'); + const blob = new Blob([lines], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a2 = document.createElement('a'); + a2.href = url; a2.download = `washdata_logs_${Date.now()}.txt`; + document.body.appendChild(a2); a2.click(); document.body.removeChild(a2); URL.revokeObjectURL(url); + this._showToast(this._t('toast.logs_exported', {}, 'Logs exported')); + }).catch(e => this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error')); + } else if (a === 'import-config-open') { + // Open the import wizard at the paste/upload step. + this._modal = { type: 'import-wizard', step: 'input', jsonText: '', manifest: null, error: null, + sel: { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() }, + expanded: new Set(), mode: 'merge', cycleDest: 'reference', conflicts: {} }; + this._render(); + } else if (a === 'hist-import-open') { + // Open the power-history import wizard at the upload step. Every field is + // initialised here so the render-smoke harness (which calls every modal + // renderer) never meets a half-built state. + this._modal = { + type: 'history-import', step: 'input', csvText: '', since: _histDefaultSince(), token: null, + scanTaskId: null, applyTaskId: null, result: null, accept: new Set(), + done: null, error: null, + }; + this._render(); + } else if (a === 'import-config-raw') { + // Advanced fallback: the legacy raw-JSON whole-store replace. + this._modal = { type: 'import-config' }; this._render(); + + } else if (a === 'save-prefs') { + const dt = sr.getElementById('wd-pref-tab')?.value || ''; + const dbg = !!sr.getElementById('wd-pref-debug')?.checked; + const showExpected = sr.getElementById('wd-pref-expected') ? !!sr.getElementById('wd-pref-expected').checked : true; + const showRaw = !!sr.getElementById('wd-pref-raw')?.checked; + const dateFmt = sr.getElementById('wd-pref-datefmt')?.value || 'relative'; + const langOverrideSave = sr.getElementById('wd-pref-lang')?.value || ''; + const fontScale = parseFloat(sr.getElementById('wd-pref-fontscale')?.value) || 1; + const prefs = { default_tab: dt, show_debug: dbg, show_expected: showExpected, show_raw: showRaw, date_format: dateFmt, lang_override: langOverrideSave, font_scale: fontScale }; + this._busyRun('save-prefs', async () => { + try { + await this._ws({ type: `${_DOMAIN}/set_user_prefs`, prefs }); + if (this._panelCfg) this._panelCfg.prefs = { ...(this._panelCfg.prefs || {}), ...prefs }; + // Language may have changed: ensure the (now effective) language file is + // loaded, then re-render so the new strings take effect immediately. + const effLang = langOverrideSave || (this._hass && this._hass.locale && this._hass.locale.language); + await this._loadPanelLang(effLang); + this._render(); + this._showToast(this._t('toast.preferences_saved', {}, 'Preferences saved')); + } catch (e) { this._showToast(this._t('toast.save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } + }); + + } else if (a === 'save-panel') { + const panel = { + default_tab: sr.getElementById('wd-ps-deftab')?.value || 'status', + hidden_tabs: Array.from(sr.querySelectorAll('[data-hidetab]')).filter(c => c.checked).map(c => c.dataset.hidetab), + }; + this._busyRun('save-panel', async () => { + try { + await this._ws({ type: `${_DOMAIN}/set_panel_config`, panel }); + this._panelCfg = await this._ws({ type: `${_DOMAIN}/get_panel_config` }); + this._tabInitialized = true; // keep the user on the current tab + this._applyPanelConfig(); + this._showToast(this._t('toast.panel_settings_saved', {}, 'Panel settings saved')); + } catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } + }); + + } else if (a === 'pause-cycle') { + this._ws({ type: `${_DOMAIN}/pause_cycle`, entry_id: eid }) + .then(r => { + if (r && r.ok === false) { this._showToast(this._t('toast.pause_no_cycle', {}, 'No active cycle to pause'), 'error'); return; } + this._showToast(this._t('toast.cycle_paused', {}, 'Cycle paused')); + return this._fetchAll(); + }) + .catch(e => this._showToast(this._t('toast.pause_failed', {error: e.message || e}, 'Pause failed: ' + (e.message || e)), 'error')); + + } else if (a === 'resume-cycle') { + this._ws({ type: `${_DOMAIN}/resume_cycle`, entry_id: eid }) + .then(r => { + if (r && r.ok === false) { this._showToast(this._t('toast.resume_no_cycle', {}, 'No paused cycle to resume'), 'error'); return; } + this._showToast(this._t('toast.cycle_resumed', {}, 'Cycle resumed')); + return this._fetchAll(); + }) + .catch(e => this._showToast(this._t('msg.toast_resume_failed', {error: e.message || e}, 'Resume failed: ' + (e.message || e)), 'error')); + + } else if (a === 'terminate-cycle') { + this._modal = { + type: 'confirm', + title: this._t('modal.force_stop_title', {}, 'Force Stop Cycle'), + message: this._t('modal.force_stop_msg', {}, 'Force-stop the active cycle now? The cycle will be saved as interrupted.'), + okLabel: this._t('btn.force_stop', {}, 'Force Stop'), + onOk: async () => { + try { + await this._ws({ type: `${_DOMAIN}/terminate_cycle`, entry_id: eid }); + this._showToast(this._t('toast.cycle_force_stopped', {}, 'Cycle force-stopped')); + await this._fetchAll(); + } catch (e) { this._showToast(this._t('msg.toast_force_stop_failed', {error: e.message || e}, 'Force stop failed: ' + (e.message || e)), 'error'); } + }, + }; + this._render(); + + } else if (a === 'save-rbac') { + const enabled = !!sr.getElementById('wd-rbac-enabled')?.checked; + const default_level = sr.getElementById('wd-rbac-default')?.value || 'none'; + const usersMap = {}; + sr.querySelectorAll('[data-rbacuser]').forEach(el => { + const uid = el.dataset.rbacuser, dev = el.dataset.rbacdev, val = el.value; + if (!usersMap[uid]) usersMap[uid] = { default: 'none', devices: {} }; + if (dev === '__default__') usersMap[uid].default = val; + else if (val && val !== 'inherit') usersMap[uid].devices[dev] = val; + }); + this._busyRun('save-rbac', async () => { + try { + await this._ws({ type: `${_DOMAIN}/set_panel_config`, rbac: { enabled, default_level, users: usersMap } }); + this._panelCfg = await this._ws({ type: `${_DOMAIN}/get_panel_config` }); + this._showToast(this._t('toast.access_saved', {}, 'Access control saved')); + } catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } + }); + } + } + + _onActSuggestions(a, btn, dev, eid, sr) { + if (a === 'sug-apply-all') { const keys = this._suggestions.map(s => s.key); this._busyRun('save-settings', async () => { try { @@ -10107,6 +11264,7 @@ class HaWashdataPanel extends HTMLElement { await this._fetchSuggestions(eid); const r = await this._ws({ type: `${_DOMAIN}/get_options`, entry_id: eid }); this._opts = r.options || {}; + this._optDefaults = r.defaults || {}; // #396 this._prevOpts = null; this._cascadePending = {}; this._preCascadeOpts = null; @@ -10153,8 +11311,11 @@ class HaWashdataPanel extends HTMLElement { await this._fetchSuggestions(eid); } catch (e) { this._showToast(this._t('toast.analysis_failed', {error: e.message || e}, 'Analysis failed: ' + (e.message || e)), 'error'); } }); + } + } - } else if (a === 'ml-train-now') { + _onActMl(a, btn, dev, eid) { + if (a === 'ml-train-now') { // Detached, registry-tracked task: a header pill shows progress and it // survives a dropped socket; the result loads when it settles. this._kickAndTrack({ type: `${_DOMAIN}/trigger_ml_training`, entry_id: eid }, 'ml-train-now:' + eid, async (r) => { @@ -10184,9 +11345,12 @@ class HaWashdataPanel extends HTMLElement { await this._loadMlTrainingStatus(eid); } catch (e) { this._showToast(this._t('msg.toast_revert_failed', {error: e.message || e}, 'Revert failed: ' + (e.message || e)), 'error'); } }); + } + } + _onActStore(a, btn, dev, eid, sr) { // ── Community Store ────────────────────────────────────────────────────── - } else if (a === 'store-toggle-online') { + if (a === 'store-toggle-online') { // Online features are integration-wide: persist via the global store_set_online. const on = !!btn.checked; this._busyRun('store-account', async () => { @@ -10214,6 +11378,38 @@ class HaWashdataPanel extends HTMLElement { } }); + } else if (a === 'store-goto-identity') { + this._tab = 'settings'; + this._settingsSec = 'basic'; // the Device info group lives in Basic + this._fetchTabData(); + // Focus the brand picker once the Settings form has rendered. Focusing it also + // opens its dropdown, which is what loads the brand catalog (_ensureCatalogList). + requestAnimationFrame(() => requestAnimationFrame(() => { + const el = this.shadowRoot && this.shadowRoot.getElementById('wd-store-brand'); + if (el) { el.focus(); el.scrollIntoView({ block: 'center' }); } + })); + + } else if (a === 'store-refresh-catalog') { + // Drop the backend's cached catalog AND every local copy of it, so the next time a + // picker is opened it genuinely re-reads the store. + this._busyRun('store-refresh-catalog', async () => { + try { + await this._ws({ type: `${_DOMAIN}/store_refresh_catalog`, entry_id: eid }); + clearTimeout(this._brandSearchTimer); this._brandSearchTimer = null; + this._catalog.brands = undefined; this._catalog.devices = undefined; + this._catalog.forBrand = null; + this._catalog.brandsFull = false; this._catalog.brandPrefixes = []; + this._catalogEntry = null; + if (this._entityListCache) { + delete this._entityListCache.store_brand; + delete this._entityListCache.store_model; + } + this._showToast(this._t('toast.catalog_refreshed', {}, 'Community catalog refreshed')); + } catch (e) { + this._showToast(this._t('toast.store_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error'); + } + }); + } else if (a === 'store-connect') { const origin = this._constants.storeWebOrigin; if (!origin) { this._showToast(this._t('toast.store_unavailable', {}, 'The community store is not available.'), 'error'); return; } @@ -10258,8 +11454,16 @@ class HaWashdataPanel extends HTMLElement { try { const r = await this._ws({ type: `${_DOMAIN}/store_confirm_device`, entry_id: eid, device_id: did }); if (r && r.error) { this._showToast(this._t('toast.store_error', {error: r.error}, 'Error: ' + r.error), 'error'); return; } - const d = (this._catalog.devices || []).find(x => String(x.id) === String(did)); - if (d && r) { d.confirmCount = r.confirmCount; d.status = r.status; } + // Patch every copy of the row the UI may be showing: the picker's badge now + // reads the resolved catalog entry, while the Store tab reads the browse list. + const rows = [ + ...(this._catalog.devices || []), + ...(this._storeDevices || []), + (this._catalogEntry && this._catalogEntry.device) || null, + ]; + for (const d of rows) { + if (r && d && String(d.id) === String(did)) { d.confirmCount = r.confirmCount; d.status = r.status; } + } this._showToast(r && r.status === 'approved' ? this._t('toast.device_approved', {}, 'Approved by the community') : this._t('toast.thanks_confirming', {}, 'Thanks for confirming')); this._render(); } catch (e2) { this._showToast(this._t('toast.store_error', {error: e2.message || e2}, 'Error: ' + (e2.message || e2)), 'error'); } @@ -10412,8 +11616,11 @@ class HaWashdataPanel extends HTMLElement { brand: this._opts.store_brand || '', model: this._opts.store_model || '', origin: location.origin, }).toString(); window.open(origin + '/create.html?' + q, 'washdata_create', 'width=560,height=760'); + } + } - } else if (a === 'auto-new') { + _onActAuto(a, btn, dev, eid, sr) { + if (a === 'auto-new') { this._navigate('/config/automation/edit/new'); } else if (a === 'auto-new-started') { @@ -10454,119 +11661,11 @@ class HaWashdataPanel extends HTMLElement { try { await this._ws({ type: `${_DOMAIN}/auto_label_cycles`, entry_id: eid, confidence_threshold: thr }); this._showToast(this._t('toast.auto_label_complete', {}, 'Auto-label complete')); await this._fetchCycles(eid); } catch (e) { this._showToast(this._t('toast.auto_label_failed', {error: e.message || e}, 'Auto-label failed: ' + (e.message || e)), 'error'); } }); + } + } - } else if (a === 'create-profile') { - this._modal = { type: 'create-profile' }; this._render(); - - } else if (a === 'setup-cta') { - // Setup card primary / secondary CTA — navigate to the relevant panel section. - const ctaAction = btn.dataset.ctaAction || ''; - let params = {}; - try { params = JSON.parse(btn.dataset.ctaParams || '{}'); } catch (_) {} - this._dispatchSetupCta(ctaAction, params); - - } else if (a === 'setup-skip') { - // Setup card step skip (snooze 14 days or never). - const stepKey = btn.dataset.step; - const snooze = btn.dataset.snooze; // "never" or "14d" - if (stepKey) { - let val; - if (snooze === 'never') { - val = 'never'; - } else { - const until = new Date(); - until.setDate(until.getDate() + 14); - val = until.toISOString(); - } - this._setPref(stepKey, val); - this._reloadSetupStatus(); // async fire-and-forget; calls _render() when done - } - - } else if (a === 'hide-setup-card') { - // Setup card permanent hide (only offered when dismissible, i.e. phase 3/4). - // Keep _setupStatus so _htmlSetupCard can collapse to the phase-3 chip - // immediately (nulling it here would hide the card entirely instead — the - // sibling setup-skip / expand-setup handlers also leave the status intact). - this._setPref('setup_card_dismissed', true); - this._render(); - - } else if (a === 'expand-setup') { - // Phase 3/4 chip tapped — restore full guidance card by clearing the pref. - this._setPref('setup_card_dismissed', false); - this._render(); - - } else if (a === 'set-settings-level') { - // F2: switch the Settings tab between Basic and Advanced disclosure. - const lvl = (btn.type === 'checkbox' ? btn.checked : btn.dataset.slevel === 'advanced') ? 'advanced' : 'basic'; - if (lvl !== this._pref('settings_level', 'basic')) { - this._snapshotFormToPending(sr); // keep in-progress edits across re-render - this._setPref('settings_level', lvl); - this._render(); - } - - } else if (a === 'pg-new' || a === 'pg-edit' || a === 'pg-suggest') { - if (a === 'pg-new') { - this._modal = { type: 'profile-group', orig: null, name: '', members: [] }; - } else if (a === 'pg-edit') { - const gname = btn.dataset.gname; - const g = ((this._profileGroups || {}).groups || []).find(x => x.name === gname); - this._modal = { type: 'profile-group', orig: gname, name: gname, members: g ? [...(g.members || [])] : [] }; - } else { - const s = ((this._profileGroups || {}).suggestions || [])[parseInt(btn.dataset.idx, 10)] || null; - if (!s) return; - this._modal = { type: 'profile-group', orig: s.existing_group || null, name: s.existing_group || '', members: [...(s.members || [])] }; - } - this._render(); - // Fetch every profile's envelope so ticked members render on the overlay. - this._ensureProfileEnvs(eid, (this._profiles || []).map(p => p.name)).then(() => { - if (this._modal && this._modal.type === 'profile-group') this._render(); - }); - - } else if (a === 'rebuild-envelopes') { - // Backgrounded task (issue #311): rebuilding every profile serially can - // stall a low-power host, so run it via the registry with a header pill. - this._kickAndTrack( - { type: `${_DOMAIN}/rebuild_envelopes`, entry_id: eid }, - 'rebuild-envelopes', - async () => { this._showToast(this._t('toast.envelopes_rebuilt', {}, 'Envelopes rebuilt')); await this._fetchProfiles(eid); }, - ); - - } else if (a === 'rec-start') { - this._ws({ type: `${_DOMAIN}/start_recording`, entry_id: eid }).then(() => { this._showToast(this._t('toast.recording_started', {}, 'Recording started')); return this._fetchRecState(eid); }).then(() => this._render()).catch(e => this._showToast(this._t('toast.start_failed', {error: e.message || e}, 'Start failed: ' + (e.message || e)), 'error')); - } else if (a === 'rec-stop') { - this._ws({ type: `${_DOMAIN}/stop_recording`, entry_id: eid }).then(() => { this._showToast(this._t('toast.recording_stopped', {}, 'Recording stopped')); return this._fetchRecState(eid); }).then(() => this._render()).catch(e => this._showToast(this._t('toast.stop_failed', {error: e.message || e}, 'Stop failed: ' + (e.message || e)), 'error')); - } else if (a === 'rec-process-open') { - this._fetchProfiles(eid).then(() => { this._modal = { type: 'process-recording' }; this._render(); }); - } else if (a === 'rec-discard') { - this._modal = { type: 'confirm', title: this._t('modal.discard_recording_title', {}, 'Discard Recording'), message: this._t('modal.discard_recording_msg', {}, 'Discard the saved recording? This cannot be undone.'), okLabel: this._t('btn.discard', {}, 'Discard'), - onOk: async () => { try { await this._ws({ type: `${_DOMAIN}/discard_recording`, entry_id: eid }); this._showToast(this._t('toast.recording_discarded', {}, 'Recording discarded')); await this._fetchRecState(eid); } catch (e) { this._showToast(this._t('toast.discard_failed', {error: e.message || e}, 'Discard failed: ' + (e.message || e)), 'error'); } } }; - this._render(); - - } else if (a === 'fb-confirm') { - this._ws({ type: `${_DOMAIN}/resolve_feedback`, entry_id: eid, cycle_id: btn.dataset.cid, action: 'confirm' }).then(() => { this._showToast(this._t('toast.feedback_confirmed', {}, 'Feedback confirmed')); return this._fetchFeedbacks(eid); }).then(() => this._render()).catch(e => this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error')); - } else if (a === 'fb-ignore') { - this._ws({ type: `${_DOMAIN}/resolve_feedback`, entry_id: eid, cycle_id: btn.dataset.cid, action: 'ignore' }).then(() => { this._showToast(this._t('toast.feedback_dismissed', {}, 'Feedback dismissed')); return this._fetchFeedbacks(eid); }).then(() => this._render()).catch(e => this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error')); - } else if (a === 'fb-correct') { - this._fetchProfiles(eid).then(() => { this._modal = { type: 'correct-feedback', cycleId: btn.dataset.cid, detectedProfile: btn.dataset.prof }; this._render(); }); - } else if (a === 'fb-dismiss-all') { - this._modal = { type: 'confirm', title: this._t('modal.dismiss_all_title', {}, 'Dismiss All Feedbacks'), message: this._t('modal.dismiss_all_msg', {count: this._feedbacks.length}, `Dismiss all ${this._feedbacks.length} pending feedback requests?`), okLabel: this._t('modal.dismiss_all_ok', {}, 'Dismiss All'), - onOk: async () => { try { await this._ws({ type: `${_DOMAIN}/dismiss_all_feedbacks`, entry_id: eid }); this._showToast(this._t('toast.feedback_all_dismissed', {}, 'All feedbacks dismissed')); await this._fetchFeedbacks(eid); } catch (e) { this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error'); } } }; - this._render(); - - } else if (a === 'create-phase') { - this._modal = { type: 'create-phase', deviceType: btn.dataset.dtype }; this._render(); - } else if (a === 'edit-phase') { - this._modal = { type: 'edit-phase', phaseId: btn.dataset.pid, phaseName: btn.dataset.pname, phaseDesc: btn.dataset.pdesc, isDefault: btn.dataset.pisdefault === 'true' }; this._render(); - } else if (a === 'del-phase') { - const pname = btn.dataset.pname, pid = btn.dataset.pid; - this._modal = { type: 'confirm', title: this._t('modal.delete_phase_title', {}, 'Delete Phase'), message: this._t('modal.delete_phase_msg', {name: pname}, `Delete phase "${pname}"?`), okLabel: this._t('btn.delete', {}, 'Delete'), - onOk: async () => { try { await this._ws({ type: `${_DOMAIN}/delete_phase`, entry_id: eid, phase_id: pid }); this._showToast(this._t('toast.phase_deleted', {name: pname}, `Phase "${pname}" deleted`)); await this._fetchPhases(eid); } catch (e) { this._showToast(this._t('msg.toast_delete_failed', {error: e.message || e}, 'Delete failed: ' + (e.message || e)), 'error'); } } }; - this._render(); - - } else if (a === 'diag-refresh') { - this._fetchToolsData(eid).then(() => this._render()); - - } else if (a === 'maint-add') { + _onActMaintenance(a, btn, dev, eid, sr) { + if (a === 'maint-add') { const eventType = sr.getElementById('wd-maint-type')?.value || ''; const date = sr.getElementById('wd-maint-date')?.value || ''; const notes = (sr.getElementById('wd-maint-notes')?.value || '').trim(); @@ -10608,74 +11707,11 @@ class HaWashdataPanel extends HTMLElement { this._render(); } catch (e) { this._showToast(this._t('toast.reminders_save_failed', { error: e.message || e }, 'Could not save reminders: ' + (e.message || e)), 'error'); } }); + } + } - } else if (a === 'reprocess-history') { - this._modal = { type: 'confirm', title: this._t('modal.process_history_title', {}, 'Process History'), message: this._t('modal.process_history_msg', {}, 'Re-run matching, refresh suggestions, retrain ML (if enabled) and recompute cycle health across all stored cycles. This may take a while.'), okLabel: this._t('modal.process_history_ok', {}, 'Process'), - onOk: () => this._kickAndTrack({ type: `${_DOMAIN}/reprocess_history`, entry_id: eid }, 'reprocess', async (r) => { - const nc = r.count || 0; - const bits = [this._t('toast.processed_cycles', {n: nc}, nc + ' cycles')]; - if (r.suggestions != null) bits.push(this._t('toast.processed_suggestions', {n: r.suggestions}, r.suggestions + ' suggestion(s)')); - const np = (r.ml_training && r.ml_training.ok && (r.ml_training.promoted || []).length) || 0; - if (np) bits.push(this._t('toast.processed_models', {n: np}, np + ' model(s) promoted')); - this._showToast(this._t('toast.processed', {bits: bits.join(', ')}, 'Processed ' + bits.join(', '))); - await this._fetchToolsData(eid); - }) }; - this._render(); - } else if (a === 'clear-debug') { - this._modal = { type: 'confirm', title: this._t('modal.clear_debug_title', {}, 'Clear Debug Data'), message: this._t('modal.clear_debug_msg', {}, 'Delete all stored debug traces?'), okLabel: this._t('status.clear', {}, 'Clear'), - onOk: () => this._busyRun('clear-debug', async () => { try { const r = await this._ws({ type: `${_DOMAIN}/clear_debug_data`, entry_id: eid }); this._showToast(this._t('toast.debug_cleared', {count: r.count || 0}, `Cleared ${r.count || 0} debug traces`)); await this._fetchToolsData(eid); } catch (e) { this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error'); } }) }; - this._render(); - } else if (a === 'wipe-history') { - this._modal = { type: 'confirm', title: this._t('modal.wipe_all_title', {}, 'Wipe All Data'), message: this._t('modal.wipe_all_msg', {}, '⚠️ This permanently deletes ALL cycles and profiles. This cannot be undone.'), okLabel: this._t('modal.wipe_all_ok', {}, 'Wipe Everything'), - onOk: () => this._busyRun('wipe', async () => { try { await this._ws({ type: `${_DOMAIN}/wipe_history`, entry_id: eid }); this._showToast(this._t('toast.all_wiped', {}, 'All data wiped')); this._cycles = []; this._profiles = []; await this._fetchToolsData(eid); } catch (e) { this._showToast(this._t('msg.toast_error', {error: e.message || e}, 'Error: ' + (e.message || e)), 'error'); } }) }; - this._render(); - - } else if (a === 'export-config') { - this._ws({ type: `${_DOMAIN}/export_config`, entry_id: eid }).then(r => { - const blob = new Blob([r.json_data], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a2 = document.createElement('a'); - a2.href = url; a2.download = `washdata_export_${eid.slice(0, 8)}.json`; - document.body.appendChild(a2); a2.click(); document.body.removeChild(a2); URL.revokeObjectURL(url); - this._showToast(this._t('toast.export_downloaded', {}, 'Export downloaded')); - }).catch(e => this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error')); - } else if (a === 'export-select-open') { - // Open the export wizard: fetch this device's inventory, default everything on. - this._modal = { type: 'export-select', inventory: null, loading: true, sel: { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() }, expanded: new Set() }; - this._render(); - (async () => { - let inv = null; - try { const r = await this._ws({ type: `${_DOMAIN}/get_export_inventory`, entry_id: eid }); inv = (r && r.manifest) || null; } - catch (e) { this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'); } - if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'export-select') return; - if (!inv) { this._modal = null; this._render(); return; } - this._modal.inventory = inv; - this._modal.sel = this._wizInitSel({ categories: inv }, false); - this._modal.loading = false; - this._render(); - })(); - } else if (a === 'cyc-select-toggle') { - this._selectMode = !this._selectMode; - if (!this._selectMode) this._cycleSel.clear(); - this._render(); - } else if (a === 'cyc-auto-open') { - this._modal = { type: 'auto-label' }; this._render(); - } else if (a === 'cyc-merge') { - const ids = Array.from(this._cycleSel); - if (ids.length < 2) return; - this._fetchProfiles(eid).then(() => { this._modal = { type: 'merge-cycles', ids }; this._render(); }); - } else if (a === 'cyc-relabel') { - // D6: bulk relabel — reuse the existing profile picker. - const ids = Array.from(this._cycleSel); - if (!ids.length) return; - this._fetchProfiles(eid).then(() => { this._modal = { type: 'bulk-relabel', ids }; this._render(); }); - } else if (a === 'cyc-load-more') { - // D3: append the next page, preserving current sort/filter. - this._busyRun('cyc-load-more', async () => { - try { await this._loadMoreCycles(eid); } - catch (e) { this._showToast(this._t('toast.load_more_failed', { error: e.message || e }, 'Could not load more: ' + (e.message || e)), 'error'); } - }); - } else if (a === 'pg-analysis-tab') { + _onActPlayground(a, btn, dev, eid) { + if (a === 'pg-analysis-tab') { const tab = btn.dataset.subtab || 'history'; if (tab !== this._pgAnalysisTab) { this._pgAnalysisTab = tab; this._render(); requestAnimationFrame(() => this._drawPlaygroundCanvases()); } } else if (a === 'pg-run-history') { @@ -10684,18 +11720,6 @@ class HaWashdataPanel extends HTMLElement { this._pgBatchCancel = true; const tid = this._pgHistoryTaskId || this._pgSweepTaskId; if (tid) this._ws({ type: `${_DOMAIN}/cancel_task`, task_id: tid }).catch(() => {}); - } else if (a === 'task-cancel') { - const tid = btn.dataset.taskId; - if (tid) { - this._cancellingTasks.add(tid); - this._updateTaskPills(); - this._ws({ type: `${_DOMAIN}/cancel_task`, task_id: tid }).catch(() => { - // Cancel request itself failed (dropped socket, etc.) — re-enable the - // ✕ so the user can retry instead of leaving the pill stuck "Cancelling…". - this._cancellingTasks.delete(tid); - this._updateTaskPills(); - }); - } } else if (a === 'pg-load-run') { const tid = btn.dataset.taskId; if (!tid) return; @@ -10751,193 +11775,6 @@ class HaWashdataPanel extends HTMLElement { this._pgDeletePreset(); } else if (a === 'pg-publish-one') { this._pgPublishOne(btn.dataset.pgkey); - } else if (a === 'cyc-compare') { - const ids = Array.from(this._cycleSel); - if (ids.length < 2) return; - // Open the overlay modal immediately (loading state), then fetch each - // selected cycle's trace in parallel and fill it in as they arrive. - this._modal = { type: 'compare-cycles', ids, cycles: {}, hidden: new Set(), overlays: [], loaded: false }; - if (!this._profiles.length) this._fetchProfiles(eid); - this._render(); - Promise.all(ids.map(cid => - this._ws({ type: `${_DOMAIN}/get_cycle_power_data`, entry_id: eid, cycle_id: cid }) - .then(r => ({ cid, r })).catch(() => ({ cid, r: null })) - )).then(results => { - if (!this._modal || this._modal.type !== 'compare-cycles') return; - results.forEach(({ cid, r }) => { if (r) this._modal.cycles[cid] = r; }); - this._modal.loaded = true; - this._render(); - }); - } else if (a === 'cyc-bulk-del') { - // D4: optimistic delete with a 10s Undo window (no confirm dialog). - const ids = Array.from(this._cycleSel); - if (!ids.length) return; - this._deleteCyclesWithUndo(eid, ids); - } else if (a === 'retry-cycles') { - this._fetchCycles(eid).then(() => this._render()); - } else if (a === 'retry-profiles') { - Promise.all([this._fetchProfiles(eid), this._fetchProfileGroups(eid)]).then(() => this._render()); - } else if (a === 'retry-suggestions') { - this._fetchSuggestions(eid).then(() => this._render()); - } else if (a === 'goto-suggestions') { - this._settingsSugOnly = true; this._tab = 'settings'; this._fetchTabData(); - } else if (a === 'goto-conflicts') { - this._tab = 'settings'; this._fetchTabData(); - } else if (a === 'conf-goto-section') { - const confKeys = this._conflictKeysFromOpts(); - for (const sec of _SETTINGS_SECTIONS) { - const fields = sec.fields || (sec.groups || []).flatMap(g => g.fields || []); - if (fields.some(f => confKeys.has(f.key))) { this._settingsSec = sec.id; this._render(); break; } - } - } else if (a === 'toggle-settings-history') { - this._settingsHistoryOpen = !this._settingsHistoryOpen; - this._render(); - - } else if (a === 'settings-revert-key') { - const key = btn.dataset.key; - const val = JSON.parse(btn.dataset.val); - if (!key) return; - const eid = dev.entry_id; - this._ws({ type: `${_DOMAIN}/set_options`, entry_id: eid, options: { [key]: val } }) - .then(() => this._ws({ type: `${_DOMAIN}/get_options`, entry_id: eid })) - .then(r => { this._opts = r.options || {}; return this._fetchSettingsChangelog(eid); }) - .then(() => { - this._showToast(this._t('msg.toast_reverted', { key: this._t('setting.' + key + '.label', {}, key) }, '{key} reverted'), 'success'); - this._render(); - }) - .catch(e => this._showToast(this._t('msg.toast_error', { error: e.message || e }, 'Error: ' + (e.message || e)), 'error')); - - } else if (a === 'toggle-log-drawer') { - this._logOpen = !this._logOpen; - try { localStorage.setItem('wd-log-open', this._logOpen ? '1' : '0'); } catch (_) {} - this._render(); - if (this._logOpen) this._fetchLogs().then(() => { if (this._logOpen) this._render(); }); - } else if (a === 'open-advanced') { - // Overview action cards navigate to the Advanced tab at a given subtab. - const sub = btn.dataset.sub; - if (sub) this._panelSubtab = sub; - this._tab = 'advanced'; - this._render(); - if (this._panelSubtab === 'diagnostics' && !this._diag) this._fetchToolsData(eid).then(() => { if (this._tab === 'advanced') this._render(); }); - else if (this._panelSubtab === 'logs') this._fetchLogs().then(() => { if (this._tab === 'advanced') this._render(); }); - else if (this._panelSubtab === 'maintenance') this._fetchMaintenance(eid).then(() => { if (this._tab === 'advanced') this._render(); }); - else if (this._panelSubtab === 'ml') this._fetchTabData(); - } else if (a === 'add-device') { - this._navigate(`/config/integrations/integration/${_DOMAIN}`); - } else if (a === 'goto-feedbacks') { - this._tab = 'history'; this._cycleFilter = { ...this._cycleFilter, status: 'needs_review' }; this._fetchTabData(); - } else if (a === 'goto-recording') { - this._tab = 'status'; this._fetchTabData(); - } else if (a === 'logs-refresh') { - this._fetchLogs().then(() => this._render()); - } else if (a === 'logs-export') { - this._ws({ type: `${_DOMAIN}/get_logs`, limit: 500 }).then(r => { - const lines = (r.logs || []).map(x => `${new Date(x.ts * 1000).toISOString()} ${x.level} ${x.msg}`).join('\n'); - const blob = new Blob([lines], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a2 = document.createElement('a'); - a2.href = url; a2.download = `washdata_logs_${Date.now()}.txt`; - document.body.appendChild(a2); a2.click(); document.body.removeChild(a2); URL.revokeObjectURL(url); - this._showToast(this._t('toast.logs_exported', {}, 'Logs exported')); - }).catch(e => this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error')); - } else if (a === 'import-config-open') { - // Open the import wizard at the paste/upload step. - this._modal = { type: 'import-wizard', step: 'input', jsonText: '', manifest: null, error: null, - sel: { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() }, - expanded: new Set(), mode: 'merge', cycleDest: 'reference', conflicts: {} }; - this._render(); - } else if (a === 'import-config-raw') { - // Advanced fallback: the legacy raw-JSON whole-store replace. - this._modal = { type: 'import-config' }; this._render(); - - } else if (a === 'save-prefs') { - const dt = sr.getElementById('wd-pref-tab')?.value || ''; - const dbg = !!sr.getElementById('wd-pref-debug')?.checked; - const showExpected = sr.getElementById('wd-pref-expected') ? !!sr.getElementById('wd-pref-expected').checked : true; - const showRaw = !!sr.getElementById('wd-pref-raw')?.checked; - const dateFmt = sr.getElementById('wd-pref-datefmt')?.value || 'relative'; - const langOverrideSave = sr.getElementById('wd-pref-lang')?.value || ''; - const fontScale = parseFloat(sr.getElementById('wd-pref-fontscale')?.value) || 1; - const prefs = { default_tab: dt, show_debug: dbg, show_expected: showExpected, show_raw: showRaw, date_format: dateFmt, lang_override: langOverrideSave, font_scale: fontScale }; - this._busyRun('save-prefs', async () => { - try { - await this._ws({ type: `${_DOMAIN}/set_user_prefs`, prefs }); - if (this._panelCfg) this._panelCfg.prefs = { ...(this._panelCfg.prefs || {}), ...prefs }; - // Language may have changed: ensure the (now effective) language file is - // loaded, then re-render so the new strings take effect immediately. - const effLang = langOverrideSave || (this._hass && this._hass.locale && this._hass.locale.language); - await this._loadPanelLang(effLang); - this._render(); - this._showToast(this._t('toast.preferences_saved', {}, 'Preferences saved')); - } catch (e) { this._showToast(this._t('toast.save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } - }); - - } else if (a === 'save-panel') { - const panel = { - default_tab: sr.getElementById('wd-ps-deftab')?.value || 'status', - hidden_tabs: Array.from(sr.querySelectorAll('[data-hidetab]')).filter(c => c.checked).map(c => c.dataset.hidetab), - }; - this._busyRun('save-panel', async () => { - try { - await this._ws({ type: `${_DOMAIN}/set_panel_config`, panel }); - this._panelCfg = await this._ws({ type: `${_DOMAIN}/get_panel_config` }); - this._tabInitialized = true; // keep the user on the current tab - this._applyPanelConfig(); - this._showToast(this._t('toast.panel_settings_saved', {}, 'Panel settings saved')); - } catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } - }); - - } else if (a === 'pause-cycle') { - this._ws({ type: `${_DOMAIN}/pause_cycle`, entry_id: eid }) - .then(r => { - if (r && r.ok === false) { this._showToast(this._t('toast.pause_no_cycle', {}, 'No active cycle to pause'), 'error'); return; } - this._showToast(this._t('toast.cycle_paused', {}, 'Cycle paused')); - return this._fetchAll(); - }) - .catch(e => this._showToast(this._t('toast.pause_failed', {error: e.message || e}, 'Pause failed: ' + (e.message || e)), 'error')); - - } else if (a === 'resume-cycle') { - this._ws({ type: `${_DOMAIN}/resume_cycle`, entry_id: eid }) - .then(r => { - if (r && r.ok === false) { this._showToast(this._t('toast.resume_no_cycle', {}, 'No paused cycle to resume'), 'error'); return; } - this._showToast(this._t('toast.cycle_resumed', {}, 'Cycle resumed')); - return this._fetchAll(); - }) - .catch(e => this._showToast(this._t('msg.toast_resume_failed', {error: e.message || e}, 'Resume failed: ' + (e.message || e)), 'error')); - - } else if (a === 'terminate-cycle') { - this._modal = { - type: 'confirm', - title: this._t('modal.force_stop_title', {}, 'Force Stop Cycle'), - message: this._t('modal.force_stop_msg', {}, 'Force-stop the active cycle now? The cycle will be saved as interrupted.'), - okLabel: this._t('btn.force_stop', {}, 'Force Stop'), - onOk: async () => { - try { - await this._ws({ type: `${_DOMAIN}/terminate_cycle`, entry_id: eid }); - this._showToast(this._t('toast.cycle_force_stopped', {}, 'Cycle force-stopped')); - await this._fetchAll(); - } catch (e) { this._showToast(this._t('msg.toast_force_stop_failed', {error: e.message || e}, 'Force stop failed: ' + (e.message || e)), 'error'); } - }, - }; - this._render(); - - } else if (a === 'save-rbac') { - const enabled = !!sr.getElementById('wd-rbac-enabled')?.checked; - const default_level = sr.getElementById('wd-rbac-default')?.value || 'none'; - const usersMap = {}; - sr.querySelectorAll('[data-rbacuser]').forEach(el => { - const uid = el.dataset.rbacuser, dev = el.dataset.rbacdev, val = el.value; - if (!usersMap[uid]) usersMap[uid] = { default: 'none', devices: {} }; - if (dev === '__default__') usersMap[uid].default = val; - else if (val && val !== 'inherit') usersMap[uid].devices[dev] = val; - }); - this._busyRun('save-rbac', async () => { - try { - await this._ws({ type: `${_DOMAIN}/set_panel_config`, rbac: { enabled, default_level, users: usersMap } }); - this._panelCfg = await this._ws({ type: `${_DOMAIN}/get_panel_config` }); - this._showToast(this._t('toast.access_saved', {}, 'Access control saved')); - } catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } - }); } } @@ -10989,190 +11826,17 @@ class HaWashdataPanel extends HTMLElement { } } - // ---- Community Store: import a reference cycle ---- - if (m && m.type === 'store-import') { - if (action === 'store-import-mode-new') { m.mode = 'new'; this._render(); return; } - if (action === 'store-import-mode-merge') { m.mode = 'merge'; this._render(); return; } - if (action === 'store-import-ok') { - const msg = { type: `${_DOMAIN}/store_import_cycle`, entry_id: eid, cycle_id: m.cycleId }; - if (m.mode === 'merge') { - const target = sr.getElementById('wd-store-import-target')?.value || ''; - if (!target) { this._showToast(this._t('toast.store_pick_profile', {}, 'Pick a profile to merge into'), 'error'); return; } - msg.target_profile = target; - } else { - const name = (sr.getElementById('wd-store-import-name')?.value || '').trim() || m.program; - if (!name) { this._showToast(this._t('toast.store_name_required', {}, 'Enter a profile name'), 'error'); return; } - msg.new_profile_name = name; - } - await this._busyRun('store-import', async () => { - try { - const r = await this._ws(msg); - if (r && r.error) { this._showToast(this._t('toast.store_import_failed', {error: r.error}, 'Import failed: ' + r.error), 'error'); return; } - this._modal = null; - this._showToast(this._t('toast.store_imported', {profile: (r && r.profile) || ''}, `Imported into ${(r && r.profile) || 'profile'}`)); - await this._fetchProfiles(eid); - } catch (e) { this._showToast(this._t('toast.store_import_failed', {error: e.message || e}, 'Import failed: ' + (e.message || e)), 'error'); } - }); - return; - } + // NB: 'import-ok' (the legacy raw-JSON import modal) is deliberately NOT in this + // list, so the 'import-' names are matched one by one rather than by prefix. + if (action.startsWith('store-import-') || action === 'store-share-ok' + || action.startsWith('wiz-') || action.startsWith('imp-') + || action === 'import-back' || action === 'import-analyze' || action === 'import-apply-ok') { + return this._onMActImport(action, btn, m, eid, sr); } - // ---- Community Store: share a golden cycle ---- - if (m && m.type === 'store-share') { - if (action === 'store-share-ok') { - const program = (sr.getElementById('wd-store-share-prog')?.value || '').trim(); - const description = (sr.getElementById('wd-store-share-desc')?.value || '').trim(); - if (!program) { this._showToast(this._t('toast.store_pick_profile', {}, 'Pick a profile to share into'), 'error'); return; } - await this._busyRun('store-share', async () => { - try { - const r = await this._ws({ type: `${_DOMAIN}/store_upload_cycle`, entry_id: eid, local_cycle_id: m.cycleId, program, description }); - if (r && r.error) { - if (r.error === 'no_appliance_declared') this._showToast(this._t('toast.store_no_appliance', {}, 'Set your appliance brand and model in Settings first.'), 'error'); - else { const why = r.detail ? `${r.error} - ${r.detail}` : r.error; this._showToast(this._t('toast.store_share_failed', {error: why}, 'Share failed: ' + why), 'error'); } - return; - } - this._modal = null; - this._showToast(this._t('toast.store_shared', {}, 'Shared to the community store - pending review.')); - } catch (e) { this._showToast(this._t('toast.store_share_failed', {error: e.message || e}, 'Share failed: ' + (e.message || e)), 'error'); } - }); - return; - } - } + if (action.startsWith('hist-')) return this._onMActHistoryImport(action, btn, m, eid, sr); - // ---- Community Store: share a whole device bundle ---- - if (m && m.type === 'store-share-device') { - if (action === 'sd-toggle-cyc') { - const cid = btn.dataset.cid; - if (m.selected.has(cid)) m.selected.delete(cid); else m.selected.add(cid); - this._render(); - return; - } - if (action === 'sd-toggle-prof') { - const prog = btn.dataset.prog; - const grp = this._shareableByProgram().find(g => g.program === prog); - if (grp) { - const all = grp.cycles.every(c => m.selected.has(c.id)); - grp.cycles.forEach(c => { if (all) m.selected.delete(c.id); else m.selected.add(c.id); }); - } - this._render(); - return; - } - if (action === 'sd-toggle-phases') { - const prog = btn.dataset.prog; - if (!m.includePhases) m.includePhases = new Set(); - if (m.includePhases.has(prog)) m.includePhases.delete(prog); else m.includePhases.add(prog); - this._render(); - return; - } - if (action === 'sd-toggle-settings') { m.includeSettings = !m.includeSettings; this._render(); return; } - if (action === 'sd-toggle-consent') { m.consented = !m.consented; this._render(); return; } - if (action === 'sd-toggle-guide') { m.guideOpen = !m.guideOpen; this._render(); return; } - if (action === 'store-share-device-ok') { - // Build the {local_cycle_id, program} items from the model selection, - // resolving each cycle's program from the fetched shareable list. - const progById = new Map(); - (this._shareableCycles || []).forEach(c => progById.set(c.id, (c.profile_name || '').trim())); - const items = Array.from(m.selected) - .map(cid => ({ local_cycle_id: cid, program: progById.get(cid) || '' })) - .filter(it => it.program); - if (!items.length) { this._showToast(this._t('toast.share_device_none_sel', {}, 'Select at least one cycle to share'), 'error'); return; } - // Only send phases for programs that both opted in AND have a selected cycle. - const selectedProgs = new Set(items.map(it => it.program)); - const includePhases = Array.from(m.includePhases || []).filter(p => selectedProgs.has(p)); - await this._busyRun('store-share-device', async () => { - try { - const r = await this._ws({ type: `${_DOMAIN}/store_upload_device`, entry_id: eid, items, include_phases: includePhases, include_settings: !!m.includeSettings }); - // Pre-flight gate error (not connected / no appliance): keep the modal open. - if (r && r.error) { - if (r.error === 'no_appliance_declared') this._showToast(this._t('toast.store_no_appliance', {}, 'Set your appliance brand and model in Settings first.'), 'error'); - else { const why = r.detail ? `${r.error} - ${r.detail}` : r.error; this._showToast(this._t('toast.store_share_failed', {error: why}, 'Share failed: ' + why), 'error'); } - return; - } - const n = (r && r.cycle_ids && r.cycle_ids.length) || 0; - const failed = (r && r.errors && r.errors.length) || 0; - const dup = (r && r.duplicates) || 0; - const created = (r && r.created != null) ? r.created : n; - if (!n) { - // Nothing uploaded: surface the first error and keep the modal for retry. - const why = (r && r.errors && r.errors[0]) || (r && r.detail) || 'upload_failed'; - this._showToast(this._t('toast.store_share_failed', {error: why}, 'Share failed: ' + why), 'error'); - return; - } - this._modal = null; - if (failed) this._showToast(this._t('toast.store_device_shared_partial', {n, failed}, `Shared ${n} cycle(s); ${failed} could not be uploaded.`), 'info'); - else if (dup && !created) this._showToast(this._t('toast.store_device_shared_all_dup', {n: dup}, `All ${dup} cycle(s) were already in the community store.`), 'info'); - else if (dup) this._showToast(this._t('toast.store_device_shared_some_dup', {created, dup}, `Shared ${created} cycle(s); ${dup} were already in the store.`)); - else this._showToast(this._t('toast.store_device_shared', {n: created}, `Shared ${created} cycle(s) to the community store - pending review.`)); - } catch (e) { this._showToast(this._t('toast.store_share_failed', {error: e.message || e}, 'Share failed: ' + (e.message || e)), 'error'); } - }); - return; - } - } - - // ---- Selective export / import wizard ---- - if (m && (m.type === 'export-select' || m.type === 'import-wizard')) { - // The manifest that backs the tree: inventory for export, analyze result for import. - const man = m.type === 'export-select' ? { categories: m.inventory || {} } : (m.manifest || { categories: {} }); - if (action === 'wiz-toggle-all') { - const cats = man.categories || {}; - const importableOnly = m.type === 'import-wizard'; - // If everything is already selected, clear; otherwise select all selectable. - let allSel = true; - this._wizCatOrder().filter(cid => cats[cid] && cats[cid].present).forEach(cid => { - if (importableOnly && cats[cid].importable === false) return; - if (this._wizCatState(m, cid, man).state !== 'all') allSel = false; - }); - m.sel = allSel - ? { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() } - : this._wizInitSel(man, importableOnly); - this._render(); - return; - } - if (action === 'wiz-toggle-cat') { - const cid = btn.dataset.cat; - const st = this._wizCatState(m, cid, man); - const turnOn = st.state !== 'all'; - if (cid === 'profiles') { - const items = (man.categories.profiles && man.categories.profiles.items) || []; - m.sel.profiles = new Set(turnOn ? items.map(i => i.name) : []); - } else if (cid === 'real_cycles' || cid === 'reference_cycles') { - const set = new Set(); - if (turnOn) ((man.categories[cid] && man.categories[cid].groups) || []).forEach(g => g.cycles.forEach(cy => { if (cy.id != null) set.add(String(cy.id)); })); - if (cid === 'real_cycles') m.sel.realIds = set; else m.sel.refIds = set; - } else if (turnOn) { m.sel.cats.add(cid); } else { m.sel.cats.delete(cid); } - this._render(); - return; - } - if (action === 'wiz-toggle-profile') { - const name = btn.dataset.name; - if (m.sel.profiles.has(name)) m.sel.profiles.delete(name); else m.sel.profiles.add(name); - this._render(); - return; - } - if (action === 'wiz-toggle-cycgroup') { - const cid = btn.dataset.cat; const prof = btn.dataset.prof; - const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds; - const ids = this._wizGroupIds(man, cid, prof); - const all = ids.length > 0 && ids.every(id => set.has(id)); - ids.forEach(id => { if (all) set.delete(id); else set.add(id); }); - this._render(); - return; - } - if (action === 'wiz-toggle-cyc') { - const cid = btn.dataset.cat; const id = btn.dataset.cid; - const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds; - if (set.has(id)) set.delete(id); else set.add(id); - this._render(); - return; - } - if (action === 'wiz-expand') { - const key = btn.dataset.key; - if (!m.expanded) m.expanded = new Set(); - if (m.expanded.has(key)) m.expanded.delete(key); else m.expanded.add(key); - this._render(); - return; - } - } + if (action.startsWith('sd-') || action === 'store-share-device-ok') return this._onMActStoreShare(action, btn, m, eid); // Export wizard: generate + download the filtered JSON. if (m && m.type === 'export-select' && action === 'export-generate' && eid) { @@ -11192,153 +11856,7 @@ class HaWashdataPanel extends HTMLElement { return; } - // Import wizard: step machine + toggles + apply. - if (m && m.type === 'import-wizard') { - if (action === 'import-back') { - m.step = 'input'; m.error = null; this._render(); - return; - } - if (action === 'imp-mode-merge') { m.mode = 'merge'; this._render(); return; } - if (action === 'imp-mode-replace') { m.mode = 'replace'; this._render(); return; } - if (action === 'imp-dest-reference') { m.cycleDest = 'reference'; this._render(); return; } - if (action === 'imp-dest-real') { if ((m.manifest || {}).real_history_allowed !== false) { m.cycleDest = 'real_history'; this._render(); } return; } - if (action === 'imp-conflict') { m.conflicts[btn.dataset.prof] = btn.value; return; } - if (action === 'import-analyze' && eid) { - const ta = sr.getElementById('wd-import-json'); - const jsonText = ta ? ta.value : (m.jsonText || ''); - m.jsonText = jsonText; - if (!jsonText.trim()) { this._showToast(this._t('toast.json_required', {}, 'JSON data is required'), 'error'); return; } - m.step = 'analyze'; m.error = null; this._render(); - try { - const r = await this._ws({ type: `${_DOMAIN}/analyze_import`, entry_id: eid, json_data: jsonText }); - if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'import-wizard') return; - const manifest = (r && r.manifest) || {}; - if (manifest.error) { m.step = 'input'; m.error = manifest.error; this._render(); return; } - m.manifest = manifest; - m.sel = this._wizInitSel(manifest, true); - // Default every conflicting profile to the safest resolution. - m.conflicts = {}; - ((manifest.categories && manifest.categories.profiles && manifest.categories.profiles.items) || []) - .forEach(i => { if (i.conflict) m.conflicts[i.name] = 'import_as_copy'; }); - m.step = 'select'; - this._render(); - } catch (e) { - if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'import-wizard') return; - m.step = 'input'; m.error = (e && e.message) || String(e); this._render(); - } - return; - } - if (action === 'import-apply-ok' && eid) { - const selection = this._wizSelectionPayload(m); - await this._busyRun('import-wizard', async () => { - try { - const r = await this._ws({ type: `${_DOMAIN}/import_config_selective`, entry_id: eid, - json_data: m.jsonText, selection, mode: m.mode, - conflict_resolutions: m.conflicts, cycle_destination: m.cycleDest, apply_settings: true }); - const s = (r && r.summary) || {}; - this._modal = null; - this._showToast(this._t('toast.import_selective_done', { - profiles: s.profiles_imported || 0, - cycles: (s.real_cycles_imported || 0) + (s.reference_cycles_imported || 0), - }, `Imported ${s.profiles_imported || 0} profile(s) and ${(s.real_cycles_imported || 0) + (s.reference_cycles_imported || 0)} cycle(s)`)); - await this._fetchCycles(eid); - await this._fetchProfiles(eid); - } catch (e) { this._showToast(this._t('toast.import_failed', {error: e.message || e}, 'Import failed: ' + (e.message || e)), 'error'); } - }); - return; - } - } - - - // ---- Cycle inspector ---- - if (m && m.type === 'cycle-detail') { - if (action === 'cyc-view') { m.mode = 'view'; this._render(); return; } - if (action === 'cyc-trim') { m.mode = 'trim'; if (!m.trim || m.trim.end <= 0) m.trim = { start: 0, end: (m.curve && m.curve.full_duration_s) || 0 }; this._render(); return; } - if (action === 'cyc-split') { m.mode = 'split'; this._render(); return; } - if (action === 'cyc-review') { m.mode = 'review'; this._render(); return; } - if (action === 'cyc-review-save') { - const cid = m.cycleId; - const quality = sr.getElementById('wd-cyc-rev-quality')?.value || ''; - const golden = !!sr.getElementById('wd-cyc-rev-golden')?.checked; - const notes = sr.getElementById('wd-cyc-rev-notes')?.value || ''; - const tags = Array.from(sr.querySelectorAll('.wd-cyc-rev-tag')).filter(cb => cb.checked).map(cb => cb.value); - const newLabel = sr.getElementById('wd-cyc-rev-label')?.value ?? ''; - const curLabel = (m.curve && m.curve.profile_name) || ''; - await this._busyRun('cyc-review-save', async () => { - try { - await this._ws({ type: `${_DOMAIN}/set_ml_review`, entry_id: eid, cycle_id: cid, quality, golden, tags, notes }); - if (newLabel !== curLabel) { - await this._ws({ type: `${_DOMAIN}/label_cycle`, entry_id: eid, cycle_id: cid, profile_name: newLabel || null }); - } - this._showToast(this._t('toast.review_saved', {}, 'Review saved')); - await this._fetchCycles(eid); - // A label change in review now resolves the pending feedback backend-side - // (#331), so refresh the queue rather than leaving a stale entry. - if (newLabel !== curLabel) await this._fetchFeedbacks(eid); - await this._loadMlIndex(eid); - if (this._modal && this._modal.cycleId === cid) this._modal.ml = (this._mlById || {})[cid] || this._modal.ml; - } catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } - }); - return; - } - if (action === 'trim-mode-s') { m.timeMode = 's'; this._render(); return; } - if (action === 'trim-mode-clock') { m.timeMode = 'clock'; this._render(); return; } - if (action === 'cyc-reset-trim') { m.trim = { start: 0, end: (m.curve && m.curve.full_duration_s) || 0 }; this._render(); return; } - if (action === 'cyc-clear-split') { m.split = { offsets: [], profiles: [] }; this._render(); return; } - if (action === 'cyc-label') { if (!this._profiles.length) await this._fetchProfiles(eid); this._modal = { type: 'label-cycle', cycleId: m.cycleId }; this._render(); return; } - if (action === 'cyc-delete') { - // D4: optimistic delete with Undo (close the inspector first). - const cid = m.cycleId; - this._modal = null; this._render(); - this._deleteCyclesWithUndo(eid, [cid]); - return; - } - if (action === 'cyc-auto-split') { - const gap = parseInt(sr.getElementById('wd-split-gap')?.value || '900', 10); - await this._busyRun('cyc-auto', async () => { - try { const r = await this._ws({ type: `${_DOMAIN}/analyze_split`, entry_id: eid, cycle_id: m.cycleId, gap_seconds: gap }); m.split.offsets = (r.split_offsets || []).slice(); m.split.profiles = []; if (!m.split.offsets.length) this._showToast(this._t('toast.no_split_found', {}, 'No idle gaps found to split on'), 'info'); } - catch (e) { this._showToast(this._t('toast.auto_detect_failed', {error: e.message || e}, 'Auto-detect failed: ' + (e.message || e)), 'error'); } - }); - return; - } - if (action === 'cyc-apply-trim') { - // Backgrounded task (issue #311): recompute + envelope rebuild can stall a - // low-power host, so run it via the registry with a header pill. - const cid = m.cycleId, s = m.trim.start, e2 = m.trim.end; - // The trim is irreversible (no undo). Confirm before discarding the - // majority of the trace so an accidental collapse can't slip through on - // a single click (#373). - const full = (m.curve && m.curve.full_duration_s) || 0; - const keptPct = full > 0 ? Math.max(0, Math.round(((e2 - s) / full) * 100)) : 100; - if (keptPct < 50 && !confirm(this._t('msg.trim_destructive_confirm', {pct: keptPct}, `This keeps only ${keptPct}% of the cycle and cannot be undone. Continue?`))) return; - this._kickAndTrack( - { type: `${_DOMAIN}/trim_cycle`, entry_id: eid, cycle_id: cid, start_s: s, end_s: e2 }, - 'cyc-trim-apply', - async () => { - this._showToast(this._t('toast.cycle_trimmed', {}, 'Cycle trimmed')); - await this._closeCycleDetail(eid); - await this._fetchCycles(eid); - }, - ); - return; - } - if (action === 'cyc-apply-split') { - // Backgrounded task (issue #311): per-segment extraction + affected - // envelope rebuilds can stall a low-power host, so run it via the registry. - const cid = m.cycleId, offs = m.split.offsets.slice(), profs = m.split.profiles.slice(); - this._kickAndTrack( - { type: `${_DOMAIN}/apply_split`, entry_id: eid, cycle_id: cid, split_offsets: offs, segment_profiles: profs }, - 'cyc-split-apply', - async (result) => { - this._showToast(this._t('toast.split_complete', {count: (result.new_ids || []).length}, `Split into ${(result.new_ids || []).length} cycles`)); - await this._closeCycleDetail(eid); - await this._fetchCycles(eid); - await this._fetchProfiles(eid); - }, - ); - return; - } - } + if (action.startsWith('cyc-') || action.startsWith('trim-mode-')) return this._onMActCycleDetail(action, m, eid, sr); // ---- Profile control panel ---- if (m && m.type === 'profile-panel') { @@ -11351,68 +11869,7 @@ class HaWashdataPanel extends HTMLElement { } return; } - if (action === 'pp-phase-add') { - const full = (m.env && m.env.target_duration) || (m.env && m.env.avg && m.env.avg.length ? m.env.avg[m.env.avg.length - 1][0] : 600); - const last = m.phases.length ? m.phases[m.phases.length - 1].end : 0; - const st = Math.min(last, full); - m.phases.push({ name: m.catalog[0] || '', start: st, end: Math.min(st + Math.max(60, full * 0.1), full) }); - this._render(); return; - } - if (action === 'pp-phase-rm') { const i = +((btn && btn.dataset.idx) || -1); if (i >= 0) { m.phases.splice(i, 1); this._render(); } return; } - if (action === 'pp-phase-save') { - const phases = m.phases.filter(p => p.name).map(p => ({ name: p.name, start: p.start, end: p.end })); - await this._busyRun('pp-phase-save', async () => { - try { await this._ws({ type: `${_DOMAIN}/set_profile_phases`, entry_id: eid, profile_name: m.name, phases }); this._showToast(this._t('toast.phases_saved', {}, 'Phases saved')); } - catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } - }); - return; - } - if (action === 'pp-cleanup-del') { - const sel = m.cleanup ? Array.from(m.cleanup.selected) : []; - if (!sel.length) return; - await this._busyRun('pp-cleanup-del', async () => { - try { - for (const cid of sel) await this._ws({ type: `${_DOMAIN}/delete_cycle`, entry_id: eid, cycle_id: cid }); - this._showToast(this._t('toast.cycles_deleted', {count: sel.length}, `Deleted ${sel.length} cycle(s)`)); - const r = await this._ws({ type: `${_DOMAIN}/get_profile_cycles`, entry_id: eid, profile_name: m.name }); - if (this._modal) this._modal.cleanup = { cycles: r.cycles || [], selected: new Set() }; - await this._fetchProfiles(eid); - } catch (e) { this._showToast(this._t('msg.toast_delete_failed', {error: e.message || e}, 'Delete failed: ' + (e.message || e)), 'error'); } - }); - return; - } - if (action === 'pp-rename') { - const nn = sr.getElementById('wd-pp-rename')?.value?.trim(); - const dur = parseFloat(sr.getElementById('wd-pp-dur')?.value || '0'); - if (!nn) { this._showToast(this._t('msg.toast_name_required', {}, 'Name required'), 'error'); return; } - try { - await this._ws({ type: `${_DOMAIN}/rename_profile`, entry_id: eid, profile_name: m.name, new_name: nn, manual_duration_min: dur > 0 ? dur : null }); - this._showToast(this._t('toast.profile_renamed', {}, 'Profile renamed')); m.name = nn; await this._fetchProfiles(eid); - m.stats = (this._profiles || []).find(p => p.name === nn) || m.stats; this._render(); - } catch (e) { this._showToast(this._t('toast.rename_failed', {error: e.message || e}, 'Rename failed: ' + (e.message || e)), 'error'); } - return; - } - if (action === 'pp-rebuild') { - // Backgrounded task (issue #311): rebuild runs via the registry; the - // profile's fresh envelope is fetched only once the task has settled. - this._kickAndTrack( - { type: `${_DOMAIN}/rebuild_envelopes`, entry_id: eid }, - 'pp-rebuild', - async () => { - try { - const r = await this._ws({ type: `${_DOMAIN}/get_profile_envelope`, entry_id: eid, profile_name: m.name }); - if (this._modal) this._modal.env = r.envelope; - } catch (_) { /* modal may have closed */ } - this._showToast(this._t('toast.envelope_rebuilt', {}, 'Envelope rebuilt')); - }, - ); - return; - } - if (action === 'pp-delete') { - // D4: optimistic delete with Undo (close the profile panel first). - this._deleteProfileWithUndo(eid, m.name); - return; - } + return this._onMActProfilePanel(action, btn, m, eid, sr); } // ---- Simple form modals ---- @@ -11526,6 +11983,630 @@ class HaWashdataPanel extends HTMLElement { } } + // ── Import power history (#344): upload, scan, review, apply ───────────────── + + // Ship the staged text in frame-sized chunks. Home Assistant builds its WebSocket + // with aiohttp's default 4 MiB frame cap, and ten days of 5-second data is 5-8 MB of + // text - an over-cap frame is not rejected, it closes the connection and takes every + // subscription with it. Chunks are split on line boundaries so the server never has to + // reassemble a partial row. + async _histUpload(eid, text) { + const begun = await this._ws({ type: `${_DOMAIN}/history_import_begin`, entry_id: eid }); + const limit = Math.max(4096, begun.chunk_bytes || 512 * 1024); + let seq = 0; + let from = 0; + while (from < text.length) { + let to = Math.min(text.length, from + limit); + if (to < text.length) { + const nl = text.lastIndexOf('\n', to); + if (nl > from) to = nl + 1; + } + await this._ws({ + type: `${_DOMAIN}/history_import_chunk`, entry_id: eid, + token: begun.token, seq, text: text.slice(from, to), + }); + seq += 1; + from = to; + } + return begun.token; + } + + async _histStartScan(eid, m, token) { + m.token = token; + m.step = 'scan'; + m.error = null; + this._render(); + const started = await this._ws({ + type: `${_DOMAIN}/start_history_import_scan`, entry_id: eid, token, + }); + m.scanTaskId = started.task_id; + this._addProvisionalTask(started.task_id, 'history_import', eid, 0); + if (!this._tasksSubscribed) this._pollTaskGeneric(started.task_id); + // A short history can finish before this reply arrives, in which case the task + // event fired while we still had no id to match it against. Adopt the current + // snapshot so a fast scan is never lost. + this._histAdopt(started.task_id); + } + + // Pick up a task that may already have settled. Safe to call for a running task. + async _histAdopt(taskId) { + if (!taskId) return; + let snap = (this._tasks || {})[taskId]; + if (!snap || snap.state === 'running') { + try { + snap = await this._ws({ type: `${_DOMAIN}/get_task_result`, task_id: taskId }); + } catch (_) { return; } + } + if (snap && snap.state && snap.state !== 'running') this._histTaskFinished(snap); + } + + // Called from the task-registry snapshot handler when one of our tasks finishes, so a + // dropped socket or a closed dialog cannot lose the run. + async _histTaskFinished(task) { + const m = this._modal; + if (!m || m.type !== 'history-import') return; + // Both the task-event stream and the post-start adoption can deliver the same + // terminal snapshot; settle each task once. The id is only marked once it is known + // to be one of ours - an event that arrives before the start reply recorded the id + // must not burn it, or the adoption that follows would be a no-op and the wizard + // would sit on the progress step forever. + if (task.id !== m.scanTaskId && task.id !== m.applyTaskId) return; + this._histSettled = this._histSettled || new Set(); + if (this._histSettled.has(task.id)) return; + this._histSettled.add(task.id); + if (task.id === m.scanTaskId) { + if (task.state === 'error') { + m.step = 'input'; + m.error = task.error || this._t('msg.hist_scan_failed', {}, 'Scanning failed.'); + this._render(); + return; + } + try { + const res = task.result ? task : await this._ws({ type: `${_DOMAIN}/get_task_result`, task_id: task.id }); + m.result = res.result || {}; + m.accept = new Set((m.result.segments || []).filter(seg => seg.accept).map(seg => seg.index)); + m.step = 'review'; + this._render(); + requestAnimationFrame(() => this._drawHistorySparklines()); + } catch (e) { + m.step = 'input'; + m.error = String((e && e.message) || e); + this._render(); + } + return; + } + if (task.id === m.applyTaskId) { + if (task.state === 'error') { + m.error = task.error === 'scan_expired' + ? this._t('msg.hist_scan_expired', {}, 'That scan is no longer available. Please scan again.') + : (task.error || this._t('msg.hist_import_failed', {}, 'Import failed.')); + m.step = 'review'; + this._render(); + return; + } + try { + const res = task.result ? task : await this._ws({ type: `${_DOMAIN}/get_task_result`, task_id: task.id }); + m.done = res.result || {}; + } catch (_) { m.done = {}; } + m.step = 'done'; + this._render(); + const dev = this._devices[this._selIdx]; + if (dev) { this._fetchCycles(dev.entry_id); this._fetchProfiles(dev.entry_id); } + } + } + + async _onMActHistoryImport(action, btn, m, eid, sr) { + if (!m || m.type !== 'history-import' || !eid) return; + + if (action === 'hist-scan') { + const ta = sr.getElementById('wd-hist-csv'); + const text = ta ? ta.value : (m.csvText || ''); + if (!text.trim()) { + this._showToast(this._t('toast.hist_csv_required', {}, 'Load a CSV file or paste its contents first'), 'error'); + return; + } + m.csvText = text; + await this._busyRun('hist-import', async () => { + try { + const token = await this._histUpload(eid, text); + await this._histStartScan(eid, m, token); + } catch (e) { + m.error = String((e && e.message) || e); + this._render(); + } + }); + return; + } + + if (action === 'hist-recorder') { + const sinceInput = sr.getElementById('wd-hist-since'); + const since = (sinceInput && sinceInput.value) || _histDefaultSince(); + m.since = since; + await this._busyRun('hist-import', async () => { + try { + const res = await this._ws({ + type: `${_DOMAIN}/history_import_recorder`, entry_id: eid, start_date: since, + }); + if (!res.rows) { + m.error = this._t('msg.hist_recorder_empty', {}, 'Home Assistant has no detailed history for this sensor in that window.'); + this._render(); + return; + } + await this._histStartScan(eid, m, res.token); + } catch (e) { + m.error = String((e && e.message) || e); + this._render(); + } + }); + return; + } + + if (action === 'hist-cancel-scan') { + if (m.scanTaskId) { + try { await this._ws({ type: `${_DOMAIN}/cancel_task`, task_id: m.scanTaskId }); } catch (_) {} + } + m.step = 'input'; + m.scanTaskId = null; + this._render(); + return; + } + + if (action === 'hist-back') { + m.step = 'input'; + m.result = null; + m.error = null; + this._render(); + return; + } + + if (action === 'hist-toggle-all') { + const segs = (m.result && m.result.segments) || []; + const allOn = segs.every(seg => m.accept.has(seg.index)); + m.accept = allOn ? new Set() : new Set(segs.map(seg => seg.index)); + this._render(); + requestAnimationFrame(() => this._drawHistorySparklines()); + return; + } + + if (action === 'hist-apply') { + if (!m.accept || !m.accept.size) return; + await this._busyRun('hist-apply', async () => { + try { + const res = await this._ws({ + type: `${_DOMAIN}/apply_history_import`, entry_id: eid, + scan_task_id: m.scanTaskId, accept: [...m.accept].sort((a, b) => a - b), + }); + m.applyTaskId = res.task_id; + this._addProvisionalTask(res.task_id, 'history_import_apply', eid, m.accept.size); + if (!this._tasksSubscribed) this._pollTaskGeneric(res.task_id); + this._histAdopt(res.task_id); + } catch (e) { + m.error = String((e && e.message) || e); + this._render(); + } + }); + return; + } + + if (action === 'hist-goto-cycles') { + this._modal = null; + this._tab = 'history'; + this._cycleFilter = { ...(this._cycleFilter || {}), status: 'imported' }; + this._fetchTabData(); + return; + } + } + + async _onMActImport(action, btn, m, eid, sr) { + // ---- Community Store: import a reference cycle ---- + if (m && m.type === 'store-import') { + if (action === 'store-import-mode-new') { m.mode = 'new'; this._render(); return; } + if (action === 'store-import-mode-merge') { m.mode = 'merge'; this._render(); return; } + if (action === 'store-import-ok') { + const msg = { type: `${_DOMAIN}/store_import_cycle`, entry_id: eid, cycle_id: m.cycleId }; + if (m.mode === 'merge') { + const target = sr.getElementById('wd-store-import-target')?.value || ''; + if (!target) { this._showToast(this._t('toast.store_pick_profile', {}, 'Pick a profile to merge into'), 'error'); return; } + msg.target_profile = target; + } else { + const name = (sr.getElementById('wd-store-import-name')?.value || '').trim() || m.program; + if (!name) { this._showToast(this._t('toast.store_name_required', {}, 'Enter a profile name'), 'error'); return; } + msg.new_profile_name = name; + } + await this._busyRun('store-import', async () => { + try { + const r = await this._ws(msg); + if (r && r.error) { this._showToast(this._t('toast.store_import_failed', {error: r.error}, 'Import failed: ' + r.error), 'error'); return; } + this._modal = null; + this._showToast(this._t('toast.store_imported', {profile: (r && r.profile) || ''}, `Imported into ${(r && r.profile) || 'profile'}`)); + await this._fetchProfiles(eid); + } catch (e) { this._showToast(this._t('toast.store_import_failed', {error: e.message || e}, 'Import failed: ' + (e.message || e)), 'error'); } + }); + return; + } + } + + // ---- Community Store: share a golden cycle ---- + if (m && m.type === 'store-share') { + if (action === 'store-share-ok') { + const program = (sr.getElementById('wd-store-share-prog')?.value || '').trim(); + const description = (sr.getElementById('wd-store-share-desc')?.value || '').trim(); + if (!program) { this._showToast(this._t('toast.store_pick_profile', {}, 'Pick a profile to share into'), 'error'); return; } + await this._busyRun('store-share', async () => { + try { + const r = await this._ws({ type: `${_DOMAIN}/store_upload_cycle`, entry_id: eid, local_cycle_id: m.cycleId, program, description }); + if (r && r.error) { + if (r.error === 'no_appliance_declared') this._showToast(this._t('toast.store_no_appliance', {}, 'Set your appliance brand and model in Settings first.'), 'error'); + else { const why = r.detail ? `${r.error} - ${r.detail}` : r.error; this._showToast(this._t('toast.store_share_failed', {error: why}, 'Share failed: ' + why), 'error'); } + return; + } + this._modal = null; + this._showToast(this._t('toast.store_shared', {}, 'Shared to the community store - pending review.')); + } catch (e) { this._showToast(this._t('toast.store_share_failed', {error: e.message || e}, 'Share failed: ' + (e.message || e)), 'error'); } + }); + return; + } + } + + // ---- Selective export / import wizard ---- + if (m && (m.type === 'export-select' || m.type === 'import-wizard')) { + // The manifest that backs the tree: inventory for export, analyze result for import. + const man = m.type === 'export-select' ? { categories: m.inventory || {} } : (m.manifest || { categories: {} }); + if (action === 'wiz-toggle-all') { + const cats = man.categories || {}; + const importableOnly = m.type === 'import-wizard'; + // If everything is already selected, clear; otherwise select all selectable. + let allSel = true; + this._wizCatOrder().filter(cid => cats[cid] && cats[cid].present).forEach(cid => { + if (importableOnly && cats[cid].importable === false) return; + if (this._wizCatState(m, cid, man).state !== 'all') allSel = false; + }); + m.sel = allSel + ? { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() } + : this._wizInitSel(man, importableOnly); + this._render(); + return; + } + if (action === 'wiz-toggle-cat') { + const cid = btn.dataset.cat; + const st = this._wizCatState(m, cid, man); + const turnOn = st.state !== 'all'; + if (cid === 'profiles') { + const items = (man.categories.profiles && man.categories.profiles.items) || []; + m.sel.profiles = new Set(turnOn ? items.map(i => i.name) : []); + } else if (cid === 'real_cycles' || cid === 'reference_cycles') { + const set = new Set(); + if (turnOn) ((man.categories[cid] && man.categories[cid].groups) || []).forEach(g => g.cycles.forEach(cy => { if (cy.id != null) set.add(String(cy.id)); })); + if (cid === 'real_cycles') m.sel.realIds = set; else m.sel.refIds = set; + } else if (turnOn) { m.sel.cats.add(cid); } else { m.sel.cats.delete(cid); } + this._render(); + return; + } + if (action === 'wiz-toggle-profile') { + const name = btn.dataset.name; + if (m.sel.profiles.has(name)) m.sel.profiles.delete(name); else m.sel.profiles.add(name); + this._render(); + return; + } + if (action === 'wiz-toggle-cycgroup') { + const cid = btn.dataset.cat; const prof = btn.dataset.prof; + const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds; + const ids = this._wizGroupIds(man, cid, prof); + const all = ids.length > 0 && ids.every(id => set.has(id)); + ids.forEach(id => { if (all) set.delete(id); else set.add(id); }); + this._render(); + return; + } + if (action === 'wiz-toggle-cyc') { + const cid = btn.dataset.cat; const id = btn.dataset.cid; + const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds; + if (set.has(id)) set.delete(id); else set.add(id); + this._render(); + return; + } + if (action === 'wiz-expand') { + const key = btn.dataset.key; + if (!m.expanded) m.expanded = new Set(); + if (m.expanded.has(key)) m.expanded.delete(key); else m.expanded.add(key); + this._render(); + return; + } + } + + // Import wizard: step machine + toggles + apply. + if (m && m.type === 'import-wizard') { + if (action === 'import-back') { + m.step = 'input'; m.error = null; this._render(); + return; + } + if (action === 'imp-mode-merge') { m.mode = 'merge'; this._render(); return; } + if (action === 'imp-mode-replace') { m.mode = 'replace'; this._render(); return; } + if (action === 'imp-dest-reference') { m.cycleDest = 'reference'; this._render(); return; } + if (action === 'imp-dest-real') { if ((m.manifest || {}).real_history_allowed !== false) { m.cycleDest = 'real_history'; this._render(); } return; } + if (action === 'imp-conflict') { m.conflicts[btn.dataset.prof] = btn.value; return; } + if (action === 'import-analyze' && eid) { + const ta = sr.getElementById('wd-import-json'); + const jsonText = ta ? ta.value : (m.jsonText || ''); + m.jsonText = jsonText; + if (!jsonText.trim()) { this._showToast(this._t('toast.json_required', {}, 'JSON data is required'), 'error'); return; } + m.step = 'analyze'; m.error = null; this._render(); + try { + const r = await this._ws({ type: `${_DOMAIN}/analyze_import`, entry_id: eid, json_data: jsonText }); + if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'import-wizard') return; + const manifest = (r && r.manifest) || {}; + if (manifest.error) { m.step = 'input'; m.error = manifest.error; this._render(); return; } + m.manifest = manifest; + m.sel = this._wizInitSel(manifest, true); + // Default every conflicting profile to the safest resolution. + m.conflicts = {}; + ((manifest.categories && manifest.categories.profiles && manifest.categories.profiles.items) || []) + .forEach(i => { if (i.conflict) m.conflicts[i.name] = 'import_as_copy'; }); + m.step = 'select'; + this._render(); + } catch (e) { + if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'import-wizard') return; + m.step = 'input'; m.error = (e && e.message) || String(e); this._render(); + } + return; + } + if (action === 'import-apply-ok' && eid) { + const selection = this._wizSelectionPayload(m); + await this._busyRun('import-wizard', async () => { + try { + const r = await this._ws({ type: `${_DOMAIN}/import_config_selective`, entry_id: eid, + json_data: m.jsonText, selection, mode: m.mode, + conflict_resolutions: m.conflicts, cycle_destination: m.cycleDest, apply_settings: true }); + const s = (r && r.summary) || {}; + this._modal = null; + this._showToast(this._t('toast.import_selective_done', { + profiles: s.profiles_imported || 0, + cycles: (s.real_cycles_imported || 0) + (s.reference_cycles_imported || 0), + }, `Imported ${s.profiles_imported || 0} profile(s) and ${(s.real_cycles_imported || 0) + (s.reference_cycles_imported || 0)} cycle(s)`)); + await this._fetchCycles(eid); + await this._fetchProfiles(eid); + } catch (e) { this._showToast(this._t('toast.import_failed', {error: e.message || e}, 'Import failed: ' + (e.message || e)), 'error'); } + }); + return; + } + } + } + + async _onMActStoreShare(action, btn, m, eid) { + // ---- Community Store: share a whole device bundle ---- + if (m && m.type === 'store-share-device') { + if (action === 'sd-toggle-cyc') { + const cid = btn.dataset.cid; + if (m.selected.has(cid)) m.selected.delete(cid); else m.selected.add(cid); + this._render(); + return; + } + if (action === 'sd-toggle-prof') { + const prog = btn.dataset.prog; + const grp = this._shareableByProgram().find(g => g.program === prog); + if (grp) { + const all = grp.cycles.every(c => m.selected.has(c.id)); + grp.cycles.forEach(c => { if (all) m.selected.delete(c.id); else m.selected.add(c.id); }); + } + this._render(); + return; + } + if (action === 'sd-toggle-phases') { + const prog = btn.dataset.prog; + if (!m.includePhases) m.includePhases = new Set(); + if (m.includePhases.has(prog)) m.includePhases.delete(prog); else m.includePhases.add(prog); + this._render(); + return; + } + if (action === 'sd-toggle-settings') { m.includeSettings = !m.includeSettings; this._render(); return; } + if (action === 'sd-toggle-consent') { m.consented = !m.consented; this._render(); return; } + if (action === 'sd-toggle-guide') { m.guideOpen = !m.guideOpen; this._render(); return; } + if (action === 'store-share-device-ok') { + // Build the {local_cycle_id, program} items from the model selection, + // resolving each cycle's program from the fetched shareable list. + const progById = new Map(); + (this._shareableCycles || []).forEach(c => progById.set(c.id, (c.profile_name || '').trim())); + const items = Array.from(m.selected) + .map(cid => ({ local_cycle_id: cid, program: progById.get(cid) || '' })) + .filter(it => it.program); + if (!items.length) { this._showToast(this._t('toast.share_device_none_sel', {}, 'Select at least one cycle to share'), 'error'); return; } + // Only send phases for programs that both opted in AND have a selected cycle. + const selectedProgs = new Set(items.map(it => it.program)); + const includePhases = Array.from(m.includePhases || []).filter(p => selectedProgs.has(p)); + await this._busyRun('store-share-device', async () => { + try { + const r = await this._ws({ type: `${_DOMAIN}/store_upload_device`, entry_id: eid, items, include_phases: includePhases, include_settings: !!m.includeSettings }); + // Pre-flight gate error (not connected / no appliance): keep the modal open. + if (r && r.error) { + if (r.error === 'no_appliance_declared') this._showToast(this._t('toast.store_no_appliance', {}, 'Set your appliance brand and model in Settings first.'), 'error'); + else { const why = r.detail ? `${r.error} - ${r.detail}` : r.error; this._showToast(this._t('toast.store_share_failed', {error: why}, 'Share failed: ' + why), 'error'); } + return; + } + const n = (r && r.cycle_ids && r.cycle_ids.length) || 0; + const failed = (r && r.errors && r.errors.length) || 0; + const dup = (r && r.duplicates) || 0; + const created = (r && r.created != null) ? r.created : n; + if (!n) { + // Nothing uploaded: surface the first error and keep the modal for retry. + const why = (r && r.errors && r.errors[0]) || (r && r.detail) || 'upload_failed'; + this._showToast(this._t('toast.store_share_failed', {error: why}, 'Share failed: ' + why), 'error'); + return; + } + this._modal = null; + if (failed) this._showToast(this._t('toast.store_device_shared_partial', {n, failed}, `Shared ${n} cycle(s); ${failed} could not be uploaded.`), 'info'); + else if (dup && !created) this._showToast(this._t('toast.store_device_shared_all_dup', {n: dup}, `All ${dup} cycle(s) were already in the community store.`), 'info'); + else if (dup) this._showToast(this._t('toast.store_device_shared_some_dup', {created, dup}, `Shared ${created} cycle(s); ${dup} were already in the store.`)); + else this._showToast(this._t('toast.store_device_shared', {n: created}, `Shared ${created} cycle(s) to the community store - pending review.`)); + } catch (e) { this._showToast(this._t('toast.store_share_failed', {error: e.message || e}, 'Share failed: ' + (e.message || e)), 'error'); } + }); + return; + } + } + } + + async _onMActCycleDetail(action, m, eid, sr) { + // ---- Cycle inspector ---- + if (m && m.type === 'cycle-detail') { + if (action === 'cyc-view') { m.mode = 'view'; this._render(); return; } + if (action === 'cyc-trim') { m.mode = 'trim'; if (!m.trim || m.trim.end <= 0) m.trim = { start: 0, end: (m.curve && m.curve.full_duration_s) || 0 }; this._render(); return; } + if (action === 'cyc-split') { m.mode = 'split'; this._render(); return; } + if (action === 'cyc-review') { m.mode = 'review'; this._render(); return; } + if (action === 'cyc-review-save') { + const cid = m.cycleId; + const quality = sr.getElementById('wd-cyc-rev-quality')?.value || ''; + const golden = !!sr.getElementById('wd-cyc-rev-golden')?.checked; + const notes = sr.getElementById('wd-cyc-rev-notes')?.value || ''; + const tags = Array.from(sr.querySelectorAll('.wd-cyc-rev-tag')).filter(cb => cb.checked).map(cb => cb.value); + const newLabel = sr.getElementById('wd-cyc-rev-label')?.value ?? ''; + const curLabel = (m.curve && m.curve.profile_name) || ''; + await this._busyRun('cyc-review-save', async () => { + try { + await this._ws({ type: `${_DOMAIN}/set_ml_review`, entry_id: eid, cycle_id: cid, quality, golden, tags, notes }); + if (newLabel !== curLabel) { + await this._ws({ type: `${_DOMAIN}/label_cycle`, entry_id: eid, cycle_id: cid, profile_name: newLabel || null }); + } + this._showToast(this._t('toast.review_saved', {}, 'Review saved')); + await this._fetchCycles(eid); + // A label change in review now resolves the pending feedback backend-side + // (#331), so refresh the queue rather than leaving a stale entry. + if (newLabel !== curLabel) await this._fetchFeedbacks(eid); + await this._loadMlIndex(eid); + if (this._modal && this._modal.cycleId === cid) this._modal.ml = (this._mlById || {})[cid] || this._modal.ml; + } catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } + }); + return; + } + if (action === 'trim-mode-s') { m.timeMode = 's'; this._render(); return; } + if (action === 'trim-mode-clock') { m.timeMode = 'clock'; this._render(); return; } + if (action === 'cyc-reset-trim') { m.trim = { start: 0, end: (m.curve && m.curve.full_duration_s) || 0 }; this._render(); return; } + if (action === 'cyc-clear-split') { m.split = { offsets: [], profiles: [] }; this._render(); return; } + if (action === 'cyc-label') { if (!this._profiles.length) await this._fetchProfiles(eid); this._modal = { type: 'label-cycle', cycleId: m.cycleId }; this._render(); return; } + if (action === 'cyc-delete') { + // D4: optimistic delete with Undo (close the inspector first). + const cid = m.cycleId; + this._modal = null; this._render(); + this._deleteCyclesWithUndo(eid, [cid]); + return; + } + if (action === 'cyc-auto-split') { + const gap = parseInt(sr.getElementById('wd-split-gap')?.value || '900', 10); + await this._busyRun('cyc-auto', async () => { + try { const r = await this._ws({ type: `${_DOMAIN}/analyze_split`, entry_id: eid, cycle_id: m.cycleId, gap_seconds: gap }); m.split.offsets = (r.split_offsets || []).slice(); m.split.profiles = []; if (!m.split.offsets.length) this._showToast(this._t('toast.no_split_found', {}, 'No idle gaps found to split on'), 'info'); } + catch (e) { this._showToast(this._t('toast.auto_detect_failed', {error: e.message || e}, 'Auto-detect failed: ' + (e.message || e)), 'error'); } + }); + return; + } + if (action === 'cyc-apply-trim') { + // Backgrounded task (issue #311): recompute + envelope rebuild can stall a + // low-power host, so run it via the registry with a header pill. + const cid = m.cycleId, s = m.trim.start, e2 = m.trim.end; + // The trim is irreversible (no undo). Confirm before discarding the + // majority of the trace so an accidental collapse can't slip through on + // a single click (#373). + const full = (m.curve && m.curve.full_duration_s) || 0; + const keptPct = full > 0 ? Math.max(0, Math.round(((e2 - s) / full) * 100)) : 100; + if (keptPct < 50 && !confirm(this._t('msg.trim_destructive_confirm', {pct: keptPct}, `This keeps only ${keptPct}% of the cycle and cannot be undone. Continue?`))) return; + this._kickAndTrack( + { type: `${_DOMAIN}/trim_cycle`, entry_id: eid, cycle_id: cid, start_s: s, end_s: e2 }, + 'cyc-trim-apply', + async () => { + this._showToast(this._t('toast.cycle_trimmed', {}, 'Cycle trimmed')); + await this._closeCycleDetail(eid); + await this._fetchCycles(eid); + }, + ); + return; + } + if (action === 'cyc-apply-split') { + // Backgrounded task (issue #311): per-segment extraction + affected + // envelope rebuilds can stall a low-power host, so run it via the registry. + const cid = m.cycleId, offs = m.split.offsets.slice(), profs = m.split.profiles.slice(); + this._kickAndTrack( + { type: `${_DOMAIN}/apply_split`, entry_id: eid, cycle_id: cid, split_offsets: offs, segment_profiles: profs }, + 'cyc-split-apply', + async (result) => { + this._showToast(this._t('toast.split_complete', {count: (result.new_ids || []).length}, `Split into ${(result.new_ids || []).length} cycles`)); + await this._closeCycleDetail(eid); + await this._fetchCycles(eid); + await this._fetchProfiles(eid); + }, + ); + return; + } + } + } + + async _onMActProfilePanel(action, btn, m, eid, sr) { + if (m && m.type === 'profile-panel') { + if (action === 'pp-phase-add') { + const full = (m.env && m.env.target_duration) || (m.env && m.env.avg && m.env.avg.length ? m.env.avg[m.env.avg.length - 1][0] : 600); + const last = m.phases.length ? m.phases[m.phases.length - 1].end : 0; + const st = Math.min(last, full); + m.phases.push({ name: m.catalog[0] || '', start: st, end: Math.min(st + Math.max(60, full * 0.1), full) }); + this._render(); return; + } + if (action === 'pp-phase-rm') { const i = +((btn && btn.dataset.idx) || -1); if (i >= 0) { m.phases.splice(i, 1); this._render(); } return; } + if (action === 'pp-phase-save') { + const phases = m.phases.filter(p => p.name).map(p => ({ name: p.name, start: p.start, end: p.end })); + await this._busyRun('pp-phase-save', async () => { + try { await this._ws({ type: `${_DOMAIN}/set_profile_phases`, entry_id: eid, profile_name: m.name, phases }); this._showToast(this._t('toast.phases_saved', {}, 'Phases saved')); } + catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); } + }); + return; + } + if (action === 'pp-cleanup-del') { + const sel = m.cleanup ? Array.from(m.cleanup.selected) : []; + if (!sel.length) return; + await this._busyRun('pp-cleanup-del', async () => { + try { + for (const cid of sel) await this._ws({ type: `${_DOMAIN}/delete_cycle`, entry_id: eid, cycle_id: cid }); + this._showToast(this._t('toast.cycles_deleted', {count: sel.length}, `Deleted ${sel.length} cycle(s)`)); + const r = await this._ws({ type: `${_DOMAIN}/get_profile_cycles`, entry_id: eid, profile_name: m.name }); + if (this._modal) this._modal.cleanup = { cycles: r.cycles || [], selected: new Set() }; + await this._fetchProfiles(eid); + } catch (e) { this._showToast(this._t('msg.toast_delete_failed', {error: e.message || e}, 'Delete failed: ' + (e.message || e)), 'error'); } + }); + return; + } + if (action === 'pp-rename') { + const nn = sr.getElementById('wd-pp-rename')?.value?.trim(); + const dur = parseFloat(sr.getElementById('wd-pp-dur')?.value || '0'); + if (!nn) { this._showToast(this._t('msg.toast_name_required', {}, 'Name required'), 'error'); return; } + try { + await this._ws({ type: `${_DOMAIN}/rename_profile`, entry_id: eid, profile_name: m.name, new_name: nn, manual_duration_min: dur > 0 ? dur : null }); + this._showToast(this._t('toast.profile_renamed', {}, 'Profile renamed')); m.name = nn; + // rename_profile also rewrites the member name inside any profile group + // (profile_store.update_profile step 4); re-fetch groups too or the stale + // old name leaves the renamed profile looking removed from its group, and + // resaving that stale modal would drop it for real. + await Promise.all([this._fetchProfiles(eid), this._fetchProfileGroups(eid)]); + m.stats = (this._profiles || []).find(p => p.name === nn) || m.stats; this._render(); + } catch (e) { this._showToast(this._t('toast.rename_failed', {error: e.message || e}, 'Rename failed: ' + (e.message || e)), 'error'); } + return; + } + if (action === 'pp-rebuild') { + // Backgrounded task (issue #311): rebuild runs via the registry; the + // profile's fresh envelope is fetched only once the task has settled. + this._kickAndTrack( + { type: `${_DOMAIN}/rebuild_envelopes`, entry_id: eid }, + 'pp-rebuild', + async () => { + try { + const r = await this._ws({ type: `${_DOMAIN}/get_profile_envelope`, entry_id: eid, profile_name: m.name }); + if (this._modal) this._modal.env = r.envelope; + } catch (_) { /* modal may have closed */ } + this._showToast(this._t('toast.envelope_rebuilt', {}, 'Envelope rebuilt')); + }, + ); + return; + } + if (action === 'pp-delete') { + // D4: optimistic delete with Undo (close the profile panel first). + this._deleteProfileWithUndo(eid, m.name); + return; + } + } + } + // ── Settings save ───────────────────────────────────────────────────────── // Runs all conflict rules against this._opts (no DOM required). @@ -11543,6 +12624,7 @@ class HaWashdataPanel extends HTMLElement { const f = _FIELD_BY_KEY[key]; const ftype = (f && f.type) || el.dataset.ftype || 'text'; if (el.type === 'checkbox') { this._pendingSettings[key] = el.checked; return; } + if (ftype === 'checkboxlist') { this._pendingSettings[key] = this._collectCheckboxlist(el, key); return; } if (ftype === 'entitylist') { this._pendingSettings[key] = Array.from(el.querySelectorAll('.wd-pill')).map(p => p.dataset.val).filter(Boolean); return; @@ -11576,31 +12658,59 @@ class HaWashdataPanel extends HTMLElement { }); } + // Collect a checkboxlist field's ticked choices. An empty result on a field whose + // default is a non-empty set (profile_evidence_sources) is normalised to that default: + // an empty evidence selection is not a valid "none" - the backend silently uses all + // three - so persisting/staging [] would leave the UI showing all-unchecked while + // matching used every source (#2). The default set is the honest, stored value. + _collectCheckboxlist(el, key) { + const chosen = Array.from(el.querySelectorAll('[data-choice]')) + .filter(c => c.checked).map(c => c.dataset.choice); + if (chosen.length) return chosen; + const f = _FIELD_BY_KEY[key]; + return (f && Array.isArray(f.def) && f.def.length) ? f.def.slice() : chosen; + } + // Compute conflicting field keys from any options dict (used by device cards and section dots). - _conflictKeysForOpts(opts) { + // `defaults` supplies the device-resolved value for a field the user never set + // (#396: sampling/watchdog/start_duration/smart-term ratio resolve per device type + // and are sent by the backend as `defaults`/`option_defaults`, never stored in + // options). Merging them UNDER `opts` means a cross-section rule (watchdog>=2*sampling) + // sees the value the integration would actually use even when the partner field's + // section was never opened this session - otherwise the unset partner reads as + // `undefined`, the rule's `!= null` guard short-circuits, and the conflict is missed. + _conflictKeysForOpts(opts, defaults) { + const v = Object.assign({}, defaults || {}, opts); const keys = new Set(); for (const rule of _SETTING_CONFLICTS) { - if (!rule.check(opts)) continue; - for (const key of Object.keys(rule.fieldErrors(opts))) keys.add(key); + if (!rule.check(v)) continue; + for (const key of Object.keys(rule.fieldErrors(v))) keys.add(key); } return keys; } - _conflictCountForOpts(opts) { return this._conflictKeysForOpts(opts).size; } + _conflictCountForOpts(opts, defaults) { return this._conflictKeysForOpts(opts, defaults).size; } // section-pill dots to surface saved-settings conflicts without needing the form. _conflictKeysFromOpts() { - return this._conflictKeysForOpts(Object.assign({}, this._opts, this._pendingSettings)); + return this._conflictKeysForOpts( + Object.assign({}, this._opts, this._pendingSettings), this._optDefaults + ); } - // Collect current numeric form values from DOM, falling back to saved opts for - // fields not rendered in the current section (cross-section conflicts). + // Collect current numeric form values from DOM, falling back to saved opts (and + // the device-resolved defaults for unset fields, #396) for fields not rendered in + // the current section (cross-section conflicts). _readSettingsFormValues(sr) { - const vals = Object.assign({}, this._opts, this._pendingSettings); + const vals = Object.assign({}, this._optDefaults, this._opts, this._pendingSettings); if (!sr) return vals; sr.querySelectorAll('#wd-settings-form [data-opt]').forEach(el => { const key = el.dataset.opt; if (el.type === 'checkbox') { vals[key] = el.checked; return; } + if (el.dataset.ftype === 'checkboxlist') { + vals[key] = this._collectCheckboxlist(el, key); + return; + } const n = parseFloat(el.value); if (!isNaN(n)) vals[key] = n; else if (el.value !== '') vals[key] = el.value; @@ -11710,7 +12820,7 @@ class HaWashdataPanel extends HTMLElement { this._snapshotFormToPending(sr); if (autoChanged.size > 0) { const n = autoChanged.size, s = n > 1 ? 's' : ''; - this._showToast(this._t('conflict.cascade_toast', {n, s}, `Also adjusted ${n} setting${s} for consistency.`), 'success'); + this._showToast(this._t('conflict.cascade_toast', {n}, `Other settings adjusted for consistency: ${n}`), 'success'); } } @@ -11728,6 +12838,7 @@ class HaWashdataPanel extends HTMLElement { const f = _FIELD_BY_KEY[key]; const ftype = (f && f.type) || el.dataset.ftype || 'text'; if (el.type === 'checkbox') { updates[key] = el.checked; return; } + if (ftype === 'checkboxlist') { updates[key] = this._collectCheckboxlist(el, key); return; } if (ftype === 'entitylist') { updates[key] = Array.from(el.querySelectorAll('.wd-pill')).map(p => p.dataset.val).filter(Boolean); return; } if (ftype === 'timerlist') { updates[key] = Array.from(el.querySelectorAll('.wd-timer-row')).map(row => ({ @@ -11762,6 +12873,21 @@ class HaWashdataPanel extends HTMLElement { updates[key] = val; // text, textarea, select, devicetype }); + // #396/#393: the cadence/ratio fields render their runtime-resolved default when + // unset (from _optDefaults). Persisting that default back as an explicit option + // pins the value against future default changes and defeats "unset -> resolve per + // device type", so drop a field that is (a) not already an explicit option and + // (b) still equal to the resolved default - the backend resolves the identical + // value, so runtime behaviour is unchanged. A value the user actually changed away + // from the default (or a field they had already set) is untouched and still saves. + const _od = this._optDefaults || {}; + for (const k of Object.keys(_od)) { + if (!(k in this._opts) && (k in updates) + && JSON.stringify(updates[k]) === JSON.stringify(_od[k])) { + delete updates[k]; + } + } + if (this._invalidJson) { this._showToast(this._t('toast.invalid_json', {key: this._invalidJson}, `"${this._invalidJson}" is not valid JSON - fix it or clear the field before saving.`), 'error'); return; diff --git a/custom_components/ha_washdata/www/ha-washdata-panel.min.js b/custom_components/ha_washdata/www/ha-washdata-panel.min.js new file mode 100644 index 00000000..488383a4 --- /dev/null +++ b/custom_components/ha_washdata/www/ha-washdata-panel.min.js @@ -0,0 +1,1996 @@ +/*! + * 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 file is a MINIFIED BUILD. The corresponding readable source ships in the + * same directory and is the licensed, preferred form for modification. + * This program comes with ABSOLUTELY NO WARRANTY. See the GNU Affero General + * Public License for details: . + */ +const y="ha_washdata",Ht=(()=>{try{return new URL(import.meta.url).searchParams.get("v")||""}catch{return""}})(),ae=2e4,ne=6e3,Rt=25,re=[{key:"show_contributor",labelKey:"lbl.show_contributor",labelFb:"Show contributor names",docKey:"setting.show_contributor.doc",docFb:'Show the "by " attribution on community appliances and reference cycles.'}],Bt=34,et=["#e6194B","#3cb44b","#4363d8","#f58231","#911eb4","#42d4f4","#f032e6","#bfef45","#fabed4","#469990","#dcbeff","#9A6324","#800000","#aaffc3","#808000","#ffd8b1","#000075","#a9a9a9"],$t="{device}, {duration}, {minutes}, {program}, {energy_kwh}, {cost}, {time_finished}, {vs_typical}, {cycle_count}",mt=[{id:"basic",label:"Basic",intro:"Core identity and the essentials most setups need.",groups:[{sub:"Device info",fields:[{key:"name",label:"Device Name",type:"text",doc:"Display name shown in the HA integrations list and device registry."},{key:"device_type",label:"Device Type",type:"devicetype",doc:"Appliance class. Sets sensible detection defaults (thresholds, off-delay, end handling) tuned for that appliance type; change it only if the device was originally set up as the wrong type."},{key:"store_brand",label:"Appliance Brand",type:"storebrand",optional:!0,doc:"Optional. The appliance brand, picked from the community catalog. Used to find and share matching reference recordings. Leave blank if you are not using online features."},{key:"store_model",label:"Appliance Model",type:"storemodel",optional:!0,doc:"Optional. The appliance model, picked from the community catalog once a brand is set. If your model is not listed you can add it to the catalog."}]},{sub:"Basic configuration",fields:[{key:"power_sensor",label:"Power Sensor",type:"entity",domain:"sensor",doc:"The sensor entity reporting live power in watts for this appliance (e.g. sensor.washer_power). All cycle detection is based on this signal."},{key:"min_power",label:"Minimum Power",unit:"W",type:"number",step:.1,min:0,def:2,basic:!0,doc:"Absolute minimum power considered active. Readings below this are treated as 0 W (standby), filtering out the phantom load of smart plugs and standby LEDs."},{key:"off_delay",label:"Off Delay",unit:"s",type:"number",min:0,def:180,basic:!0,doc:"Time to wait after power drops before declaring the cycle finished. If power resumes within this window the cycle continues seamlessly - this bridges pauses between wash stages. Dishwashers have long drying phases (power off for 20-60 min) so the off-delay must exceed that to keep the whole wash+dry as one cycle."},{key:"linked_device",label:"Group Under Device",type:"device",doc:'Optionally nest this WashData device under another device (e.g. the smart plug) in the HA device registry, shown as "Connected via ...".'}]}]},{id:"detection",label:"Detection",intro:"How a cycle is detected as starting, running and finishing.",groups:[{sub:"Thresholds & Gap",fields:[{key:"start_threshold_w",label:"Start Threshold",unit:"W",type:"number",step:1,min:0,basic:!0,doc:"Power must rise above this level to confirm a cycle has started. Setting it too low causes false starts from standby power; too high and slow-starting programs (cold fill) are missed. The suggestion engine sets this just above the machine's observed lowest active power."},{key:"stop_threshold_w",label:"Stop Threshold",unit:"W",type:"number",step:.1,min:0,basic:!0,doc:"Power must fall below this level before the off-delay countdown begins. Set it below the Start Threshold - the gap between them is the hysteresis band that prevents flicker. If set too high, low-power phases (rinse holds, anti-crease) falsely trigger the end sequence."},{key:"min_off_gap",label:"Min Off Gap",unit:"s",type:"number",min:0,basic:!0,doc:"If the machine powers off for less than this time, the on/off/on sequence is treated as one continuous cycle. Prevents soak programs (machine powers off for several minutes mid-wash) from being split into two separate cycles. Set it shorter than the gap between your back-to-back loads if you want those counted as separate cycles. Device-type defaults protect the typical intra-cycle pause for each appliance."}]},{sub:"Cycle Start",fields:[{key:"start_duration_threshold",label:"Start Duration",unit:"s",type:"number",min:0,def:5,doc:"Power must stay above the start threshold this long to confirm a real start, preventing split-second on/off toggles from starting a cycle."},{key:"start_energy_threshold",label:"Start Energy",unit:"Wh",type:"number",step:.01,min:0,def:.2,doc:"Energy (power x time) the appliance must consume before RUNNING. A brief high-power spike has very low energy and is ignored, preventing false starts."},{key:"completion_min_seconds",label:"Min Cycle Duration",unit:"s",type:"number",min:0,def:600,basic:!0,doc:"Cycles shorter than this are discarded as ghost cycles (test runs, opening the door to add a sock)."}]},{sub:"Cycle End",fields:[{key:"end_energy_threshold",label:"End Energy",unit:"Wh",type:"number",step:.001,min:0,def:.05,doc:"During the off-delay countdown, accumulated energy (watts x time) is compared to this threshold. If exceeded, the countdown resets - keeping anti-crease tumbles and dishwasher drying tails attached to the cycle instead of cutting them short. Raise it if cycles end too early during cool-down; lower it if detection is sluggish."},{key:"end_repeat_count",label:"End Repeat Count",type:"number",min:1,def:1,doc:"Number of consecutive below-stop-threshold readings required before the cycle ends. 1 is fine for most plugs. Raise to 2-3 if your smart plug occasionally reports a false-zero sample mid-cycle and your cycles are ending prematurely."},{key:"smart_termination_duration_ratio",label:"Smart Termination Ratio",type:"number",step:.01,min:.5,max:1,doc:"How far into the matched program's expected duration a cycle must be before Smart Termination may end it early once power drops. The expected duration is the program's average, so on appliances whose runtime varies a lot - washers on cold winter vs warm summer inlet water, sensor-dry dryers, load-dependent programs - about half of all runs finish shorter than that average and never get the fast finish, ending only via the fallback timeout minutes late. Lower this (e.g. 0.85) on those machines so the early finish still fires; raise it toward 1.0 to be more conservative. Leave empty for the default (0.98, or 0.99 for dishwashers). It can only ever end a cycle earlier, never later, and never fires on an ambiguous or low-confidence match."}]},{sub:"Power Off",fields:[{key:"power_off_threshold_w",label:"Power Off Threshold",unit:"W",type:"number",step:.1,min:0,def:0,doc:"Optional power-based Off detection. When above 0, once a cycle has finished and power stays below this level for the Power Off Delay, the machine is treated as switched off and the state returns to Off. Leave at 0 to disable (the default). Set it above the true switched-off floor and below the Stop Threshold and your machine's finished-but-on standby draw; if it is not below the Stop Threshold it is ignored. When enabled it replaces the Progress Reset Delay for returning to Off, so a finished machine stays in Finished/Clean until it is actually powered off."},{key:"power_off_delay",label:"Power Off Delay",unit:"s",type:"number",min:0,def:30,doc:"How long power must stay below the Power Off Threshold after a cycle finishes before the state returns to Off. Only used when the Power Off Threshold is above 0. Checked on the background cadence, so the effective delay rounds up to the next state-expiry tick."}]},{sub:"Signal Processing",fields:[{key:"sampling_interval",label:"Sampling Interval",unit:"s",type:"number",min:1,def:30,doc:"Expected time between sensor readings - used to size the smoothing window and start debounce correctly. Every sensor update is captured regardless of this value; it only calibrates the downstream calculations. The suggestion engine measures your sensor's actual cadence from past cycles and sets this automatically."},{key:"smoothing_window",label:"Smoothing Window",type:"number",min:1,def:2,doc:"How much the raw power signal is smoothed. Low (2) is responsive but noisy; high (5) smooths spikes but adds lag."}]}]},{id:"matching",label:"Matching",intro:"How finished cycles are matched to learned profiles and labelled.",notDeviceTypes:["other"],groups:[{sub:"Match Scoring",fields:[{key:"profile_match_threshold",label:"Match Threshold",type:"number",step:.01,min:0,max:1,def:.4,doc:"Minimum similarity score (0-1) required at cycle end to accept a program identification. Raise it to reduce wrong identifications; lower it if your machine's programs are not being matched. Default 0.4 is a conservative starting point."},{key:"profile_unmatch_threshold",label:"Unmatch Threshold",type:"number",step:.01,min:0,max:1,def:.35,doc:"If a live mid-cycle match drops below this score, the tentative identification is cleared. Keep it a little below the Match Threshold so a brief dip in similarity does not flip the display back to unmatched."},{key:"profile_match_interval",label:"Match Interval",unit:"s",type:"number",min:0,doc:"How often to attempt profile matching during a running cycle. Default 300 s (5 minutes) balances detection speed and CPU."}]},{sub:"Duration Gates",fields:[{key:"profile_match_min_duration_ratio",label:"Min Duration Ratio",type:"number",step:.01,min:0,max:1,def:.1,doc:"Minimum cycle length relative to the profile. 0.9 means a cycle must be at least 90% of the profile duration to match."},{key:"profile_match_max_duration_ratio",label:"Max Duration Ratio",type:"number",step:.01,min:0,def:1.5,doc:"Maximum cycle length relative to the profile. 1.5 means a cycle must be under 150% of the profile duration to match."},{key:"profile_duration_tolerance",label:"Profile Duration Tolerance",type:"number",step:.01,min:0,max:1,def:.25,doc:"The +/- band around a profile average duration used during matching. 0.25 means a 60 min profile matches 45-75 min cycles."},{key:"duration_tolerance",label:"Estimate Tolerance",type:"number",step:.01,min:0,max:1,def:.1,doc:"Tolerance for time-remaining estimates (learning feedback, not matching). If the actual duration is within +/-X% of the estimate it counts as a good match."}]},{sub:"Profile Evidence",fields:[{key:"profile_evidence_sources",label:"Cycles that shape a program",type:"checkboxlist",def:["real_cycles","reference_cycles","backfill_cycles"],choices:[["real_cycles","Cycles this machine ran"],["reference_cycles","Downloaded from the community store"],["backfill_cycles","Found in imported power history"]],doc:"Which cycles are used to build each program's power curve, and to match a finished cycle against it. Unticking a kind stops it shaping your programs without deleting anything - the cycles stay in your Cycles list and can still be labelled or removed. Useful if you do not trust imported data. Statistics are unaffected: they always count only the cycles this machine actually ran. Unticking everything is ignored, since a program with no cycles behind it could never match."}]},{sub:"Auto-Labeling",fields:[{key:"auto_label_confidence",label:"Auto-Label Confidence",type:"number",step:.01,min:0,max:1,def:.9,doc:"If the match score at cycle end is at or above this, the program is labeled automatically without any confirmation prompt. Raise it to require higher certainty before auto-labeling; lower it to automate more. Works in conjunction with Learning Confidence below it."},{key:"learning_confidence",label:"Learning Confidence",type:"number",step:.01,min:0,max:1,def:.6,doc:"If the match score falls between this and Auto-Label Confidence, WashData flags the finished cycle for review in the Cycles queue so you can verify the identified program. Below this score the match is too uncertain to surface. Must be kept below Auto-Label Confidence."}]}]},{id:"phase_eta",label:"Time Remaining",intro:"Phase-aware time-remaining, for machines whose cycle length depends on temperature or spin.",onlyDeviceTypes:["washing_machine","washer_dryer"],fields:[{key:"enable_phase_matching",label:"Phase-aware time remaining",type:"checkbox",def:!1,doc:"Break each running cycle into phases (heating, wash, spin) and budget the time remaining per phase, blended with the classic estimate - leaning on the phase budget early in the cycle and the classic estimate near the end. This personalises the countdown to how long your machine actually heats and runs, which is most noticeable in the first half of a cycle. Off = the classic estimate only. Only the time-remaining display is affected; program matching and cycle detection are unchanged."}]},{id:"timing",label:"Timing & Watchdog",intro:"Background cadence, the offline watchdog and housekeeping.",groups:[{sub:"Watchdog",fields:[{key:"watchdog_interval",label:"Watchdog Interval",unit:"s",type:"number",min:1,def:30,doc:"How often the background watchdog checks for stalled sensors and elapsed timeouts. Default 30 s."},{key:"no_update_active_timeout",label:"No-Update Timeout",unit:"s",type:"number",min:0,def:600,doc:"If no power updates arrive for this long while running, assume the plug dropped offline and force-stop to avoid a zombie cycle. Default 600 s allows for cloud or mesh lag."}]},{sub:"Housekeeping",fields:[{key:"progress_reset_delay",label:"Progress Reset Delay",unit:"s",type:"number",min:0,def:1800,doc:"After finishing, hold progress at 100% for this long so Completed is visible on dashboards before resetting to Idle."},{key:"auto_maintenance",label:"Auto Maintenance (nightly cleanup)",type:"checkbox",def:!0,doc:"Run nightly housekeeping: rebuild profile envelopes, recompute cycle health, prune debug traces and retain the most recent cycles."},{key:"power_profile_interval_min",label:"Power Profile Interval",unit:"min",type:"number",min:1,def:15,doc:"Bucket size for the per-profile power_profile sensor attribute (the flat per-slot average-watts array consumed by external planners such as EMHASS and tibber_prices). Smaller buckets keep short power spikes sharp; larger buckets smooth the shape. Default 15 min. Read-time only; does not affect detection."}]},{sub:"Debug",fields:[{key:"expose_debug_entities",label:"Expose Debug Entities",type:"checkbox",doc:"Publish extra diagnostic HA entities (match confidence, ambiguity, state internals). Off keeps the entity list clean for normal use."},{key:"save_debug_traces",label:"Save Debug Traces",type:"checkbox",doc:"Store the full power trace and matching debug data for each cycle. Useful for troubleshooting but increases storage size."}]}]},{id:"anti_wrinkle",label:"Anti-Wrinkle",intro:"Anti-wrinkle / anti-crease mode detects low-power tumble pulses after the main phase and keeps them attached to the finished cycle instead of reading them as new cycles.",onlyDeviceTypes:["washing_machine","dryer","washer_dryer"],fields:[{key:"anti_wrinkle_enabled",label:"Enable Anti-Wrinkle Detection",type:"checkbox",doc:"Recognise the short low-power tumble pulses a dryer emits after the main heat phase and keep them attached to the finished cycle instead of reading them as new cycles."},{key:"anti_wrinkle_max_power",label:"Max Anti-Wrinkle Power",unit:"W",type:"number",step:10,min:0,def:400,doc:"A pulse above this power is treated as a real new cycle, not an anti-wrinkle tumble. Set just above the tumble-pulse power."},{key:"anti_wrinkle_max_duration",label:"Max Duration",unit:"s",type:"number",min:0,def:60,doc:"Pulses longer than this are treated as a real cycle rather than an anti-wrinkle tumble."},{key:"anti_wrinkle_exit_power",label:"Exit Power Threshold",unit:"W",type:"number",step:.1,min:0,def:.8,doc:"Power must fall below this between pulses for anti-wrinkle mode to stay active."},{key:"anti_wrinkle_idle_timeout",label:"Max Pulse Gap",unit:"s",type:"number",step:30,min:0,def:120,doc:"How long the machine may stay quiet between two tumble pulses before anti-wrinkle mode ends. Set it above the longest gap your dryer leaves between pulses, otherwise every later pulse is read as a false start."}]},{id:"dishwasher",label:"Dishwasher",intro:"End-of-cycle handling for dishwashers, which typically finish with a long near-silent drying phase before a short final drain.",onlyDeviceTypes:["dishwasher"],fields:[{key:"dishwasher_end_spike_quiet_release",label:"Passive-Dry Quiet Release",unit:"s",type:"number",step:60,min:0,def:600,doc:"Once the cycle passes its expected duration, how long the dishwasher must stay quiet (below the Stop Threshold) before WashData stops waiting for a final drain and ends the cycle. Raise it if your machine has a long silent drying phase before a late final drain that is being missed - a wider window lets the learned duration follow seasonal drift (colder inlet water = longer cycles) instead of locking to the old average. It only ever shortens the wait relative to the internal 30-minute end-spike cap, never extends it."}]},{id:"delay",label:"Delay Start",intro:"Delayed-start detection identifies when an appliance is powered but has not yet begun its cycle.",fields:[{key:"delay_start_detect_enabled",label:"Enable Delay-Start Detection",type:"checkbox",doc:"Detect when the appliance is powered on and waiting (delayed start / standby) but has not begun its cycle, so standby draw is not mistaken for a running cycle."},{key:"delay_confirm_seconds",label:"Confirm Seconds",unit:"s",type:"number",min:0,def:60,doc:"Power must stay in the standby band for this long before the appliance is treated as waiting-to-start rather than running."},{key:"delay_timeout_hours",label:"Timeout Hours",unit:"h",type:"number",step:.5,min:0,def:8,doc:"Stop waiting in delayed-start mode after this many hours and return to idle, so a machine left powered but never started does not wait forever."}]},{id:"triggers",label:"Triggers & Door",intro:"Optional external signals: an end trigger, a door sensor, a pause switch, and the unload reminder.",groups:[{sub:"External End Trigger",fields:[{key:"external_end_trigger_enabled",label:"Enable External End Trigger",type:"checkbox",doc:"Let an external binary sensor signal the end of a cycle, in addition to the built-in power-based detection."},{key:"external_end_trigger",label:"External Trigger Entity",type:"entity",domain:"binary_sensor",doc:'Binary sensor whose state change marks the cycle end (e.g. an appliance "finished" contact or a companion integration).'},{key:"external_end_trigger_inverted",label:"Invert External Trigger (trigger on OFF)",type:"checkbox",doc:"Treat the trigger sensor turning OFF (rather than ON) as the end-of-cycle signal."}]},{sub:"Door & Pause",fields:[{key:"door_sensor_entity",label:"Door Sensor Entity",type:"entity",domain:"binary_sensor",doc:"Optional door binary sensor. Used to detect when the appliance has been opened/unloaded after a cycle."},{key:"door_opens_at_end",label:"Door Opens Automatically At End",type:"checkbox",doc:"For dishwashers that pop the door open at the end of the cycle to dry (AirDry and similar). With this on, a door-open on a running cycle no longer pauses it forever; instead, if the door stays open for the dwell below, WashData treats the cycle as finished. A brief open (adding an item) is ignored. Requires a Door Sensor Entity."},{key:"door_end_dwell_seconds",label:"Door-Open End Dwell",unit:"s",type:"number",min:1,def:60,doc:'How long the door must stay open before WashData ends the cycle, when "Door Opens Automatically At End" is on. Long enough to ignore quickly adding a dish (default 60 s), short enough to end promptly once the machine pops the door.'},{key:"pause_cuts_power",label:"Pause Also Cuts Power (via switch)",type:"checkbox",doc:"When a cycle is paused, also switch off the Switch Entity below. Only for appliances whose plug can safely be cut mid-cycle."},{key:"switch_entity",label:"Switch Entity",type:"entity",domain:"switch",doc:'Optional switch toggled off on pause and back on when resuming, used together with "Pause also cuts power".'}]},{sub:"Unload Reminder",fields:[{key:"notify_unload_delay_minutes",label:"Unload Nag Delay",unit:"min",type:"number",min:0,def:60,basic:!0,doc:'Minutes after a cycle ends before sending the still-waiting "unload the machine" reminder. Set 0 to disable the reminder.'},{key:"notify_unload_repeat",label:"Repeat Until Door Opens",type:"checkbox",doc:'Keep re-sending the unload reminder every "Unload Nag Delay" minutes until you open the door or tap "Stop reminding" on the notification. Requires a Door Sensor Entity (the reminder itself does). The dismiss button works on Home Assistant companion-app (mobile) notifications.'},{key:"pump_stuck_duration",label:"Pump Stuck Duration",unit:"s",type:"number",min:0,def:1800,onlyDeviceType:"pump",doc:"Seconds a pump may run continuously before it is flagged as possibly stuck (fires the stuck-pump event)."}]}]},{id:"notifications",label:"Notifications",groups:[{sub:"Services",fields:[{key:"notify_start_services",label:"Start Services",type:"entitylist",domain:"notify",placeholder:"add a notify service\u2026",basic:!0,doc:"notify.* services called when a cycle starts. Add one per target (phone, dashboard, etc.); leave empty for no start notification."},{key:"notify_finish_services",label:"Finish Services",type:"entitylist",domain:"notify",placeholder:"add a notify service\u2026",basic:!0,doc:"notify.* services called when a cycle finishes. Add one per target; leave empty for no finish notification."},{key:"notify_live_services",label:"Live Progress Services",type:"entitylist",domain:"notify",placeholder:"add a notify service\u2026",doc:"notify.* services called for live progress updates while a cycle runs. Leave empty to disable live-progress notifications."},{key:"notify_people",label:"People (for Only When Home)",type:"entitylist",domain:"person",placeholder:"add a person\u2026",doc:'person.* entities used by "Notify Only When Home" to decide whether anyone is home.'},{key:"notify_only_when_home",label:"Notify Only When Home",type:"checkbox",doc:"Only send notifications when at least one of the linked people (above) is home."},{key:"notify_fire_events",label:"Fire HA Events for Notifications",type:"checkbox",def:!0,doc:"Also fire ha_washdata_* events on cycle start/finish so you can build your own automations."}]},{sub:"Timing",fields:[{key:"notify_before_end_minutes",label:"Pre-End Alert",unit:"min",type:"number",min:0,def:0,doc:"Send an Almost Done alert when estimated time remaining drops below this. 0 disables it."},{key:"notify_live_interval_seconds",label:"Live Update Interval",unit:"s",type:"number",min:30,def:300,doc:"How often live-progress notifications are refreshed while a cycle runs."},{key:"notify_live_overrun_percent",label:"Live Overrun % Before Alert",unit:"%",type:"number",min:0,def:20,doc:"If a cycle runs past its estimate by more than this percentage, send an overrun alert."},{key:"notify_live_chronometer",label:"Use Live Chronometer",type:"checkbox",doc:"Show a live-updating countdown timer in the notification (on platforms that support it) instead of a static estimate."},{key:"notify_live_sticky",label:"Keep Live Notification On Tap",type:"checkbox",doc:"Android only. Make the live-progress notification persistent (sticky) so tapping it does not dismiss the ongoing thread. Off keeps the default behaviour where a tap dismisses it."},{key:"notify_live_click_action",label:"Live Notification Tap Target",type:"text",optional:!0,doc:"Android only. Where a tap on the live-progress notification opens (e.g. /lovelace/laundry, or a full URL) instead of the app landing page. Leave blank for the default."},{key:"notify_timeout_seconds",label:"Auto-Dismiss After",unit:"s",type:"number",min:0,def:0,doc:"Automatically dismiss the notification after this many seconds (on platforms that support it). 0 keeps it until dismissed manually."}]},{sub:"Messages",fields:[{key:"notify_title",label:"Notification Title",type:"text",def:"WashData: {device}",doc:`Notification title. Template variables: ${$t}.`},{key:"notify_icon",label:"Notification Icon",type:"text",def:"",doc:"Optional mdi icon for the notification (e.g. mdi:washing-machine). Leave blank for the platform default."},{key:"notify_start_message",label:"Start Message",type:"textarea",def:"{device} started.",doc:`Body sent when a cycle starts. Template variables: ${$t}.`},{key:"notify_finish_message",label:"Finish Message",type:"textarea",def:"{device} finished. Duration: {duration}m.",basic:!0,doc:`Body sent when a cycle finishes. Template variables: ${$t}. {time_finished} and {vs_typical} are most useful here.`},{key:"notify_pre_complete_message",label:"Pre-Complete Message",type:"textarea",def:"{device}: Less than {minutes} minutes remaining.",doc:`Body of the pre-end / almost-done alert. Template variables: ${$t}.`},{key:"notify_reminder_message",label:"Reminder Message",type:"textarea",def:"",doc:`Body of the still-waiting unload reminder. Blank uses the built-in default. Template variables: ${$t}.`},{key:"notify_channel",label:"Android Channel (start/live)",type:"text",def:"",placeholder:"e.g. WashData",suggestions:["WashData","WashData Status","Appliance Status"],doc:"Android notification channel name for start/live messages (controls per-channel sound and priority on the mobile app). Blank uses the companion app default."},{key:"notify_finish_channel",label:"Android Channel (finish)",type:"text",def:"",placeholder:"e.g. WashData Finished",suggestions:["WashData Finished","WashData Alerts","Appliance Finished"],doc:"Android notification channel name for the finish message. Blank reuses the start/live channel."}]},{sub:"Energy",fields:[{key:"energy_sensor",label:"Energy Meter Entity",type:"entity",domain:"sensor",optional:!0,doc:"Optional cumulative energy counter (total_increasing kWh/Wh, e.g. the plug's own lifetime meter). When set, each cycle's reported energy is taken from this counter's start-to-end delta, which avoids the under-counting you get from integrating a slow-reporting power sensor. Falls back to the integrated value if the reading is missing, its unit is unknown, or the delta is not positive. Leave blank to keep integrating the power sensor."},{key:"energy_price_entity",label:"Energy Price Entity",type:"entity",domain:"sensor",basic:!0,doc:"Sensor with the current electricity price per kWh (e.g. a dynamic tariff). Takes precedence over the static price below. Each cycle freezes the price in effect when it finished."},{key:"energy_price_static",label:"Static Energy Price (per kWh)",type:"number",step:.001,min:0,basic:!0,doc:"Fixed price per kWh used for cost figures when no live price entity is set above."},{key:"peak_rate_threshold",label:"Peak-Rate Threshold (per kWh)",type:"number",step:.001,min:0,def:0,clearable:!0,doc:"When a cycle starts and the current price per kWh is at or above this value, append a peak-rate tip to the start notification. 0 or blank disables the tip."},{key:"peak_rate_message",label:"Peak-Rate Message",type:"text",def:"",placeholder:"Running at peak rate ({price}/kWh).",doc:"Optional custom text for the peak-rate tip appended to the start notification. Template variables: {device}, {price}. Blank uses the built-in default."}]},{sub:"Cycle Timers",fields:[{key:"notify_cycle_timers",label:"Cycle Timers",type:"timerlist",doc:"Notifications at specific minutes into a cycle (e.g. to add softener). Message supports {device}, {program}, {minutes}. Enable Auto-pause to pause at that point and receive an interactive notification with a Resume button; resume via the panel, the pause/resume service, or the notification action."}]},{sub:"Quiet Hours & Milestones",fields:[{key:"notify_quiet_start_hour",label:"Quiet Hours Start",unit:"h",type:"number",min:0,max:23,clearable:!0,doc:"Start of a do-not-disturb window (0-23). Finish, reminder and clean-laundry notifications that would fire during quiet hours are held and delivered when the window ends. Leave blank to disable. Supports windows that cross midnight (e.g. start 22, end 7)."},{key:"notify_quiet_end_hour",label:"Quiet Hours End",unit:"h",type:"number",min:0,max:23,clearable:!0,doc:"End of the do-not-disturb window (0-23). Held notifications are delivered at this hour. Leave blank to disable."},{key:"notify_milestones",label:"Cycle Milestones",type:"intlist",def:"50, 100, 500, 1000",placeholder:"50, 100, 500, 1000",doc:"Comma-separated cycle counts that trigger a one-off celebration notification when reached (e.g. 50, 100, 500, 1000). Blank disables milestone notifications."},{key:"notify_milestone_message",label:"Milestone Message",type:"textarea",def:"{device} has completed {cycle_count} cycles!",doc:"Message for the milestone notification. Template variables: {device}, {cycle_count}."}]}]},{id:"ml_training",label:"ML Training",fields:[{key:"enable_ml_models",label:"Apply smart models during a cycle",type:"checkbox",def:!1,doc:"While a cycle runs, let the models refine the live results: a steadier time-remaining and energy/cost estimate, and an anti-premature-stop guard on end detection (it can only ever delay a finish, never end one early, and is bounded). Uses your fine-tuned models when available, otherwise the built-in ones. Off = the classic power-based logic only (still reliable)."},{key:"ml_training_enabled",label:"Learn from this machine",type:"checkbox",def:!1,doc:"Periodically study your reviewed cycles overnight and fine-tune the models to this specific machine. A change is only kept when it genuinely scores better on held-out cycles, so this can only help or stay the same \u2014 never regress."},{key:"ml_training_hour",label:"Learn at hour",unit:"h",type:"number",min:0,max:23,def:2,doc:"Local hour of day (0-23) to do the overnight fine-tuning. Pick a quiet hour such as 2 (02:00)."},{key:"ml_training_min_cycles",label:"Cycles needed first",type:"number",min:5,def:30,doc:"Wait until at least this many cycles have been recorded before fine-tuning, so there is enough to learn from."},{key:"ml_training_interval_days",label:"Check at most every",unit:"days",type:"number",min:1,def:7,doc:"Re-check for improvements at most once per this many days."}]}],kt={};for(const h of mt){const t=h.groups||[{fields:h.fields}];for(const e of t)for(const s of e.fields||[])kt[s.key]=s}const qt={profile_match_min_duration_ratio:.1,profile_match_max_duration_ratio:1.5,corr_weight:.45,keep_min_score:.1,dtw_bandwidth:.2,dtw_blend:.5,dtw_ensemble_w:.7,dtw_ddtw_scale:30,dtw_refine_top_n:5,duration_weight:.22,energy_weight:.22,duration_scale:.175,energy_scale:.25},Vt=[{keys:["start_threshold_w","stop_threshold_w"],check:h=>h.start_threshold_w!=null&&h.stop_threshold_w!=null&&h.start_threshold_w<=h.stop_threshold_w,fieldErrors:h=>({start_threshold_w:{msgKey:"conflict.hysteresis.start",msgVars:{stop:h.stop_threshold_w},msgFb:`Must be above Stop Threshold (${h.stop_threshold_w} W)`,fixVal:+Math.max(h.stop_threshold_w+.5,h.stop_threshold_w*1.25).toFixed(1)},stop_threshold_w:{msgKey:"conflict.hysteresis.stop",msgVars:{start:h.start_threshold_w},msgFb:`Must be below Start Threshold (${h.start_threshold_w} W)`,fixVal:+Math.min(h.start_threshold_w-.5,h.start_threshold_w*.8).toFixed(1)}})},{keys:["min_power","stop_threshold_w"],check:h=>h.min_power!=null&&h.stop_threshold_w!=null&&h.min_power>h.stop_threshold_w,fieldErrors:h=>({min_power:{msgKey:"conflict.min_power.min_power",msgVars:{stop:h.stop_threshold_w},msgFb:`Must be at or below Stop Threshold (${h.stop_threshold_w} W)`,fixVal:+(h.stop_threshold_w*.8).toFixed(1)},stop_threshold_w:{msgKey:"conflict.min_power.stop",msgVars:{min:h.min_power},msgFb:`Must be at or above Min Power (${h.min_power} W)`,fixVal:+(h.min_power*1.25).toFixed(1)}})},{keys:["power_off_threshold_w","stop_threshold_w"],check:h=>h.power_off_threshold_w!=null&&h.power_off_threshold_w>0&&h.stop_threshold_w!=null&&h.power_off_threshold_w>=h.stop_threshold_w,fieldErrors:h=>({power_off_threshold_w:{msgKey:"conflict.power_off.threshold",msgVars:{stop:h.stop_threshold_w},msgFb:`Must be below Stop Threshold (${h.stop_threshold_w} W) to take effect`,fixVal:+(h.stop_threshold_w*.6).toFixed(1)},stop_threshold_w:{msgKey:"conflict.power_off.stop",msgVars:{pot:h.power_off_threshold_w},msgFb:`Must be above Power Off Threshold (${h.power_off_threshold_w} W)`,fixVal:+(h.power_off_threshold_w*1.67).toFixed(1)}})},{keys:["off_delay","min_off_gap"],check:h=>h.off_delay!=null&&h.min_off_gap!=null&&h.off_delay>h.min_off_gap,fieldErrors:h=>({off_delay:{msgKey:"conflict.off_delay.off_delay",msgVars:{gap:h.min_off_gap},msgFb:`Off Delay (${h.off_delay} s) overrides Min Off Gap (${h.min_off_gap} s); cycles within the gap may merge`,fixVal:h.min_off_gap},min_off_gap:{msgKey:"conflict.off_delay.gap",msgVars:{delay:h.off_delay},msgFb:`Min Off Gap should be at least Off Delay (${h.off_delay} s)`,fixVal:h.off_delay}})},{keys:["watchdog_interval","sampling_interval"],check:h=>h.watchdog_interval!=null&&h.sampling_interval!=null&&h.watchdog_interval<2*h.sampling_interval,fieldErrors:h=>({watchdog_interval:{msgKey:"conflict.watchdog.interval",msgVars:{si:h.sampling_interval},msgFb:`Should be at least 2\xD7 Sampling Interval (${h.sampling_interval} s)`,fixVal:+(2*h.sampling_interval+1)},sampling_interval:{msgKey:"conflict.watchdog.sampling",msgVars:{wi:h.watchdog_interval},msgFb:`Sampling Interval should be at most half of Watchdog Interval (${h.watchdog_interval} s)`,fixVal:+Math.floor(h.watchdog_interval/2)}})},{keys:["no_update_active_timeout","watchdog_interval"],check:h=>h.no_update_active_timeout!=null&&h.watchdog_interval!=null&&h.no_update_active_timeout<=h.watchdog_interval,fieldErrors:h=>({no_update_active_timeout:{msgKey:"conflict.no_update_timeout.timeout",msgVars:{wi:h.watchdog_interval},msgFb:`Must be greater than Watchdog Interval (${h.watchdog_interval} s)`,fixVal:h.watchdog_interval*2},watchdog_interval:{msgKey:"conflict.no_update_timeout.watchdog",msgVars:{to:h.no_update_active_timeout},msgFb:`Must be less than No-Update Timeout (${h.no_update_active_timeout} s)`,fixVal:+Math.floor(h.no_update_active_timeout/2)}})},{keys:["start_duration_threshold","sampling_interval"],check:h=>h.start_duration_threshold!=null&&h.sampling_interval!=null&&h.start_duration_threshold({start_duration_threshold:{msgKey:"conflict.start_dur.threshold",msgVars:{si:h.sampling_interval},msgFb:`Should be at least one Sampling Interval (${h.sampling_interval} s) to prevent single-sample false starts`,fixVal:h.sampling_interval},sampling_interval:{msgKey:"conflict.start_dur.sampling",msgVars:{sdt:h.start_duration_threshold},msgFb:`Sampling Interval exceeds Start Duration (${h.start_duration_threshold} s); single-sample spikes can open a cycle`,fixVal:h.start_duration_threshold}})},{keys:["learning_confidence","profile_match_threshold"],check:h=>h.learning_confidence!=null&&h.profile_match_threshold!=null&&h.learning_confidence({learning_confidence:{msgKey:"conflict.confidence.learning",msgVars:{match:h.profile_match_threshold},msgFb:`Must be at or above Match Threshold (${h.profile_match_threshold})`,fixVal:+h.profile_match_threshold.toFixed(2)},profile_match_threshold:{msgKey:"conflict.confidence.match_for_learning",msgVars:{lc:h.learning_confidence},msgFb:`Must be at or below Learning Confidence (${h.learning_confidence})`,fixVal:+h.learning_confidence.toFixed(2)}})},{keys:["profile_match_threshold","auto_label_confidence"],check:h=>h.profile_match_threshold!=null&&h.auto_label_confidence!=null&&h.profile_match_threshold>h.auto_label_confidence,fieldErrors:h=>({profile_match_threshold:{msgKey:"conflict.confidence.match_for_auto",msgVars:{alc:h.auto_label_confidence},msgFb:`Must be at or below Auto-Label Confidence (${h.auto_label_confidence})`,fixVal:+h.auto_label_confidence.toFixed(2)},auto_label_confidence:{msgKey:"conflict.confidence.auto",msgVars:{match:h.profile_match_threshold},msgFb:`Must be at or above Match Threshold (${h.profile_match_threshold})`,fixVal:+h.profile_match_threshold.toFixed(2)}})},{keys:["profile_unmatch_threshold","profile_match_threshold"],check:h=>h.profile_unmatch_threshold!=null&&h.profile_match_threshold!=null&&h.profile_unmatch_threshold>=h.profile_match_threshold,fieldErrors:h=>({profile_unmatch_threshold:{msgKey:"conflict.unmatch.unmatch",msgVars:{match:h.profile_match_threshold},msgFb:`Must be below Match Threshold (${h.profile_match_threshold}); otherwise a committed match un-matches instantly`,fixVal:+(h.profile_match_threshold-.05).toFixed(2)},profile_match_threshold:{msgKey:"conflict.unmatch.match",msgVars:{un:h.profile_unmatch_threshold},msgFb:`Must be above Unmatch Threshold (${h.profile_unmatch_threshold})`,fixVal:+(h.profile_unmatch_threshold+.05).toFixed(2)}})},{keys:["anti_wrinkle_exit_power","stop_threshold_w"],check:h=>["washing_machine","dryer","washer_dryer"].includes(h.device_type)&&h.anti_wrinkle_exit_power!=null&&h.stop_threshold_w!=null&&h.anti_wrinkle_exit_power>=h.stop_threshold_w,fieldErrors:h=>({anti_wrinkle_exit_power:{msgKey:"conflict.anti_wrinkle_exit.exit",msgVars:{stop:h.stop_threshold_w},msgFb:`Must be below Stop Threshold (${h.stop_threshold_w} W); otherwise the anti-wrinkle exit power is ignored`,fixVal:+(h.stop_threshold_w*.4).toFixed(1)},stop_threshold_w:{msgKey:"conflict.anti_wrinkle_exit.stop",msgVars:{exit:h.anti_wrinkle_exit_power},msgFb:`Must be above Anti-Wrinkle Exit Power (${h.anti_wrinkle_exit_power} W)`,fixVal:+(h.anti_wrinkle_exit_power*2.5).toFixed(1)}})},{keys:["anti_wrinkle_max_power","start_threshold_w"],check:h=>["washing_machine","dryer","washer_dryer"].includes(h.device_type)&&h.anti_wrinkle_max_power!=null&&h.start_threshold_w!=null&&h.anti_wrinkle_max_power<=h.start_threshold_w,fieldErrors:h=>({anti_wrinkle_max_power:{msgKey:"conflict.anti_wrinkle_max.max",msgVars:{start:h.start_threshold_w},msgFb:`Must be above Start Threshold (${h.start_threshold_w} W); otherwise anti-wrinkle duration limit is bypassed`,fixVal:+(h.start_threshold_w*2).toFixed(0)},start_threshold_w:{msgKey:"conflict.anti_wrinkle_max.start",msgVars:{max:h.anti_wrinkle_max_power},msgFb:`Must be below Anti-Wrinkle Max Power (${h.anti_wrinkle_max_power} W)`,fixVal:+(h.anti_wrinkle_max_power*.5).toFixed(1)}})},{keys:["pump_stuck_duration","no_update_active_timeout"],check:h=>h.device_type==="pump"&&h.pump_stuck_duration!=null&&h.no_update_active_timeout!=null&&h.no_update_active_timeout<=h.pump_stuck_duration,fieldErrors:h=>({pump_stuck_duration:{msgKey:"conflict.pump_stuck.duration",msgVars:{to:h.no_update_active_timeout},msgFb:`Must be less than No-Update Timeout (${h.no_update_active_timeout} s) so the stuck alarm fires before the watchdog kills the cycle`,fixVal:h.no_update_active_timeout-60},no_update_active_timeout:{msgKey:"conflict.pump_stuck.timeout",msgVars:{ps:h.pump_stuck_duration},msgFb:`Must exceed Pump Stuck Duration (${h.pump_stuck_duration} s) so the stuck alarm fires before the cycle is force-stopped`,fixVal:h.pump_stuck_duration+60}})},{keys:["profile_match_min_duration_ratio","profile_match_max_duration_ratio"],check:h=>h.profile_match_min_duration_ratio!=null&&h.profile_match_max_duration_ratio!=null&&h.profile_match_min_duration_ratio>=h.profile_match_max_duration_ratio,fieldErrors:h=>({profile_match_min_duration_ratio:{msgKey:"conflict.duration_ratio.min",msgVars:{max:h.profile_match_max_duration_ratio},msgFb:`Must be less than Max Duration Ratio (${h.profile_match_max_duration_ratio})`,fixVal:+(h.profile_match_max_duration_ratio*.5).toFixed(2)},profile_match_max_duration_ratio:{msgKey:"conflict.duration_ratio.max",msgVars:{min:h.profile_match_min_duration_ratio},msgFb:`Must be greater than Min Duration Ratio (${h.profile_match_min_duration_ratio})`,fixVal:+(h.profile_match_min_duration_ratio*2).toFixed(2)}})},{keys:["end_energy_threshold","stop_threshold_w","off_delay"],check:h=>h.end_energy_threshold!=null&&h.stop_threshold_w!=null&&h.off_delay!=null&&h.off_delay>0&&h.end_energy_threshold({end_energy_threshold:{msgKey:"conflict.end_energy.energy",msgVars:{w:h.stop_threshold_w,d:h.off_delay},msgFb:`Too strict for Stop Threshold (${h.stop_threshold_w} W) over Off Delay (${h.off_delay} s); the cycle can only end through a fallback path`,fixVal:Math.ceil(h.stop_threshold_w*h.off_delay/3600*1e3)/1e3},stop_threshold_w:{msgKey:"conflict.end_energy.stop",msgVars:{e:h.end_energy_threshold,d:h.off_delay},msgFb:`End Energy Threshold (${h.end_energy_threshold} Wh over ${h.off_delay} s) only permits ${+(h.end_energy_threshold*3600/h.off_delay).toFixed(2)} W`,fixVal:Math.floor(h.end_energy_threshold*3600/h.off_delay*10)/10}})}],oe=` +:host { + display: block; + background: var(--primary-background-color); + color: var(--primary-text-color); + min-height: 100%; + font-family: var(--paper-font-body1_-_font-family, Roboto, sans-serif); + --wd-radius-sm: 4px; + --wd-radius-md: 8px; + --wd-radius-lg: 12px; + --wd-space-xs: 4px; + --wd-space-sm: 6px; + --wd-space-md: 10px; + --wd-space-lg: 16px; + --wd-space-xl: 24px; + --wd-font-sm: 0.75em; + --wd-font-xs: 0.7em; + --wd-white: #fff; + --wd-tint-xs: rgba(0,0,0,0.04); + --wd-tint-sm: rgba(0,0,0,0.08); + --wd-tint-md: rgba(0,0,0,0.12); +} +.wd-header { + display: flex; align-items: center; gap: 12px; + padding: 14px 24px; + background: var(--app-header-background-color, var(--primary-color)); + color: var(--app-header-text-color, #fff); + position: sticky; top: 0; z-index: 20; + box-shadow: 0 2px 6px rgba(0,0,0,.25); +} +.wd-header h1 { margin: 0; font-size: 1.25em; font-weight: 600; letter-spacing: .01em; } +.wd-logo { flex-shrink: 0; opacity: .95; } +.wd-burger { display: none; align-items: center; justify-content: center; background: transparent; border: none; color: inherit; cursor: pointer; padding: 5px; margin: -2px 2px -2px -4px; border-radius: var(--wd-radius-md); flex-shrink: 0; } +.wd-burger:hover { background: rgba(255,255,255,.16); } +.wd-gear-btn { background: transparent; border: none; color: inherit; cursor: pointer; padding: 5px; margin-left: 4px; border-radius: var(--wd-radius-md); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; opacity: .8; } +.wd-gear-btn:hover { background: rgba(255,255,255,.16); opacity: 1; } +@media (max-width: 870px) { .wd-burger { display: inline-flex; } } +.wd-burger.wd-burger--force { display: inline-flex; } +.wd-header .wd-sub { font-size: .72em; opacity: .75; margin-top: 2px; } +.wd-header .wd-ts { margin-left: auto; font-size: .7em; opacity: .65; white-space: nowrap; } +.wd-body { max-width: 1160px; margin: 0 auto; padding: 20px 16px 60px; } +.wd-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; } +.wd-chip { + padding: 5px 16px; border-radius: 16px; + border: 1px solid var(--divider-color, rgba(0,0,0,.12)); + background: var(--card-background-color); color: var(--primary-text-color); + cursor: pointer; font-size: .85em; transition: background .15s, color .15s; +} +.wd-chip:hover { background: var(--secondary-background-color); } +.wd-chip.active { background: var(--primary-color); color: var(--wd-white); border-color: var(--primary-color); } +.wd-tabs { + display: flex; gap: 2px; + border-bottom: 1px solid var(--divider-color, rgba(0,0,0,.1)); + margin-bottom: 20px; overflow-x: auto; +} +.wd-tab { + padding: 10px 22px; border: none; background: transparent; + color: var(--secondary-text-color); font-size: .8em; font-weight: 600; + letter-spacing: .07em; text-transform: uppercase; cursor: pointer; + border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; + white-space: nowrap; +} +.wd-tab:hover { color: var(--primary-text-color); } +.wd-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); } +.wd-pane { display: none; } +.wd-pane.active { display: block; } +.wd-card { + background: var(--card-background-color); border-radius: var(--wd-radius-lg); + padding: 20px 22px; margin-bottom: 16px; + box-shadow: var(--ha-card-box-shadow, 0 2px 6px rgba(0,0,0,.08)); +} +.wd-card-title { + margin: 0 0 14px; font-size: .72em; font-weight: 600; + letter-spacing: .09em; text-transform: uppercase; + color: var(--secondary-text-color); + display: flex; align-items: center; gap: 8px; +} +.wd-card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; align-items: center; } +.wd-badge { + display: inline-flex; align-items: center; gap: 7px; + padding: 5px 14px; border-radius: 20px; font-size: .85em; font-weight: 500; + margin-bottom: 18px; +} +.wd-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex-shrink: 0; } +.wd-running .wd-dot { animation: wd-pulse 1.4s ease-in-out infinite; } +@keyframes wd-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } } +.wd-stats { + display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; margin-bottom: 18px; +} +.wd-stat { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 14px 10px; text-align: center; } +.wd-stat-val { font-size: 1.5em; font-weight: 600; line-height: 1.1; } +.wd-stat-lbl { margin-top: 5px; font-size: .72em; color: var(--secondary-text-color); } +.wd-prog-bg { background: var(--secondary-background-color); border-radius: 6px; height: 10px; overflow: hidden; } +.wd-prog-fill { height: 100%; background: var(--primary-color); border-radius: 6px; transition: width .6s ease; } +.wd-prog-row { display: flex; justify-content: space-between; margin-top: 6px; font-size: .78em; color: var(--secondary-text-color); } +/* D1: compact phase timeline below the progress bar */ +.wd-ptl-wrap { margin-top: 8px; } +.wd-ptl { position: relative; height: 12px; border-radius: 6px; overflow: hidden; background: var(--secondary-background-color); } +.wd-ptl-seg { position: absolute; top: 0; bottom: 0; } +.wd-ptl-seg-lbl { position: absolute; left: 4px; top: 50%; transform: translateY(-50%); font-size: 8px; line-height: 1; color: var(--wd-white); white-space: nowrap; overflow: hidden; max-width: calc(100% - 6px); text-shadow: 0 0 2px rgba(0,0,0,.55); pointer-events: none; } +.wd-ptl-cursor { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--primary-text-color, #111); box-shadow: 0 0 0 1px rgba(255,255,255,.6); } +.wd-ptl-cur { margin-top: 5px; font-size: .74em; color: var(--secondary-text-color); } +.wd-cycle-ctrl { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; } +.wd-table { width: 100%; border-collapse: collapse; font-size: .875em; } +.wd-table th { + text-align: left; padding: 8px 12px; + color: var(--secondary-text-color); font-weight: 600; font-size: .72em; + letter-spacing: .07em; text-transform: uppercase; + border-bottom: 1px solid var(--divider-color); +} +.wd-table td { padding: 10px 12px; border-bottom: 1px solid var(--divider-color, rgba(0,0,0,.05)); vertical-align: middle; } +.wd-table tr:last-child td { border-bottom: none; } +.wd-table tbody tr:hover td { background: var(--secondary-background-color); } +.wd-table-wrap { overflow-x: auto; } +.wd-th-sort { cursor: pointer; user-select: none; white-space: nowrap; } +.wd-th-sort:hover { color: var(--primary-color); } +.wd-tc-date { white-space: nowrap; color: var(--secondary-text-color); font-size: .82em; } +.wd-tc-num { white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; } +/* Dedicated flags/icons column: keep every badge on one line so review/anomaly/source + icons never overwrite each other or spill into the profile name. */ +.wd-tc-flags { white-space: nowrap; font-size: .9em; } +th.wd-tc-flags { color: var(--secondary-text-color); font-weight: 500; } +.wd-filter-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } +.wd-filter-input { flex: 1; min-width: 120px; padding: 5px 10px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size: .84em; } +.wd-filter-select { padding: 5px 8px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size: .84em; } +.wd-row-link { cursor: pointer; } +.wd-pill { display: inline-block; padding: 2px 9px; border-radius: var(--wd-radius-sm); background: var(--secondary-background-color); color: var(--secondary-text-color); font-size: .78em; } +.wd-tag { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 10px; font-size: .72em; font-weight: 600; vertical-align: middle; background: var(--secondary-background-color); color: var(--secondary-text-color); margin-left: 4px; } +.wd-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; + font-size: .85em; font-weight: 500; transition: opacity .15s; + white-space: nowrap; +} +.wd-btn:hover { opacity: .85; } +.wd-btn:disabled { opacity: .55; cursor: default; } +.wd-btn-primary { background: var(--primary-color); color: var(--wd-white); } +.wd-btn-secondary { background: var(--secondary-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); } +.wd-btn-danger { background: var(--error-color, #f44336); color: var(--wd-white); } +.wd-btn-sm { padding: 4px 10px; font-size: .78em; } +.wd-btn-xs { padding: 2px 8px; font-size: .72em; } +.wd-spin { + display: inline-block; width: 13px; height: 13px; + border: 2px solid currentColor; border-right-color: transparent; + border-radius: 50%; animation: wd-rot .7s linear infinite; vertical-align: -2px; +} +@keyframes wd-rot { to { transform: rotate(360deg); } } +.wd-field { margin-bottom: 16px; } +.wd-field label { display: block; font-size: .82em; font-weight: 600; margin-bottom: 5px; color: var(--secondary-text-color); letter-spacing: .04em; text-transform: uppercase; } +.wd-field input[type=text], .wd-field input[type=number], .wd-field select, .wd-field textarea { + width: 100%; box-sizing: border-box; padding: 8px 10px; border-radius: 6px; + border: 1px solid var(--divider-color, rgba(0,0,0,.2)); + background: var(--secondary-background-color); + color: var(--primary-text-color); font-size: .9em; font-family: inherit; +} +.wd-field textarea { min-height: 64px; resize: vertical; } +.wd-field input[type=checkbox] { width: auto; margin-right: 8px; } +/* Switch-style boolean settings (replaces the old plain checkbox). Scoped under + .wd-field-switch so the switch label wins over the generic ".wd-field label" + (display:block, higher specificity) rule and stays a centered flex row. */ +.wd-field-switch label { margin: 0; } +.wd-field-switch .wd-switch-row { display: flex; align-items: center; gap: 10px; min-height: 22px; } +/* Switch label: a flex row [toggle][text], vertically centred. The .wd-field + .wd-switch-lbl selector is needed to beat .wd-field label (display:block + + .82em + uppercase, specificity 0,1,1) which otherwise leaks in and both breaks + the vertical centring (align-items is a no-op on a block) and shrinks the label. + The bare .wd-switch-lbl covers inline use outside a .wd-field (e.g. adopt). */ +.wd-switch-lbl, +.wd-field .wd-switch-lbl { + display: inline-flex; align-items: center; gap: 10px; cursor: pointer; + min-width: 0; margin: 0; font-size: 1rem; font-weight: 400; + letter-spacing: normal; text-transform: none; +} +/* Match the switch label to every other setting name (see .wd-field label). */ +.wd-switch-text { font-size: .82em; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--secondary-text-color); } +#wd-settings-form .wd-switch-text, #wd-ml-form .wd-switch-text { color: var(--primary-text-color); } +/* Normal-case, readable label for switches outside the Settings form (store / + panel prefs / access control), whose labels are descriptive sentences. */ +.wd-switch-text--plain { font-size: .9em; font-weight: 600; line-height: 1.3; letter-spacing: normal; text-transform: none; color: var(--primary-text-color); } +.wd-switch { position: relative; display: inline-flex; flex: 0 0 auto; width: 40px; height: 22px; } +.wd-switch input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; } +.wd-switch-slider { position: absolute; inset: 0; border-radius: 22px; background: var(--switch-unchecked-track-color, rgba(120,120,120,.5)); transition: background .2s; } +.wd-switch-slider::before { content: ""; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px; border-radius: 50%; background: var(--switch-unchecked-button-color, #fafafa); box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .2s; } +.wd-switch input:checked + .wd-switch-slider { background: var(--switch-checked-track-color, var(--primary-color, #03a9f4)); } +/* Force a light thumb: some themes set --switch-checked-button-color to the accent, + which made the knob vanish into the (also-accent) track. A white thumb reads on + any track colour. */ +.wd-switch input:checked + .wd-switch-slider::before { transform: translateX(18px); background: #fff; } +.wd-switch input:focus-visible + .wd-switch-slider { outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; } +/* A11y: a shared keyboard focus ring for all interactive controls (many HA themes + suppress the UA default outline). */ +.wd-tab:focus-visible, .wd-btn:focus-visible, .wd-chip:focus-visible, +.wd-sec-btn:focus-visible, .wd-subtab:focus-visible, .wd-mini-tab:focus-visible, +.wd-devcard:focus-visible, [tabindex]:focus-visible, a:focus-visible, select:focus-visible { + outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; border-radius: var(--wd-radius-sm); +} +/* A11y: honor the user's reduced-motion preference \u2014 drop non-essential animation. */ +@media (prefers-reduced-motion: reduce) { + .wd-dot, .wd-devdot, .wd-rec-active, .wd-spin, .wd-toast { animation: none !important; } + * { scroll-behavior: auto !important; } +} +/* Notifications > Automations: split "New" dropdown + pills. */ +.wd-auto-dd summary { cursor: pointer; list-style: none; } +.wd-auto-dd summary::-webkit-details-marker { display: none; } +.wd-auto-dd summary::marker { content: ''; } +.wd-auto-pill { display: inline-flex; align-items: center; gap: 2px; max-width: 100%; background: var(--secondary-background-color); border: 1px solid var(--divider-color); border-radius: 16px; padding: 3px 4px 3px 12px; } +.wd-auto-pill-link { text-decoration: none; color: var(--primary-text-color); font-size: .92em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wd-auto-pill-link:hover { text-decoration: underline; } +.wd-auto-pill-x { flex: 0 0 auto; border: none; background: transparent; color: var(--secondary-text-color); cursor: pointer; font-size: 1.15em; line-height: 1; padding: 0 5px; border-radius: 50%; } +.wd-auto-pill-x:hover { background: var(--error-color, #f44336); color: var(--wd-white); } +.wd-field-hint { font-size: .78em; color: var(--secondary-text-color); margin-top: 4px; } +/* Entity-pill multi-picker (compact chips + inline add input) */ +.wd-pillbox { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; padding: 5px 6px; min-height: 34px; + border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--card-background-color); } +.wd-pillbox:focus-within { border-color: var(--primary-color); } +.wd-pillbox .wd-pill { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; padding: 2px 4px 2px 9px; + font-size: .82em; line-height: 1.4; border-radius: var(--wd-radius-lg); background: var(--primary-color); color: var(--wd-white); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wd-pill-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; + border: 0; border-radius: 50%; background: rgba(255,255,255,.25); color: var(--wd-white); font-size: 13px; line-height: 1; + cursor: pointer; flex: none; } +.wd-pill-x:hover { background: rgba(255,255,255,.45); } +.wd-pill-add { flex: 1; min-width: 90px; border: 0 !important; background: transparent !important; padding: 3px 4px !important; + font-size: .88em; color: var(--primary-text-color); outline: none; } +.wd-form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0 20px; } +/* Cycle timer list */ +.wd-timerlist { display: flex; flex-direction: column; gap: 8px; } +.wd-timer-row { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; + border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--card-background-color); } +.wd-timer-top { display: flex; align-items: center; gap: 8px; } +.wd-timer-top input[type="number"] { width: 70px; flex: 0 0 auto; } +.wd-timer-top textarea { flex: 1 1 auto; min-width: 0; box-sizing: border-box; resize: vertical; min-height: 32px; height: 34px; } +.wd-timer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.wd-timer-footer .wd-switch-lbl { display: flex; align-items: center; gap: 8px; cursor: pointer; } +.wd-timer-add { align-self: flex-start; margin-top: 4px; } +/* Roomier Settings layout (scoped so modals keep their compact spacing) */ +#wd-settings-form .wd-form-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 18px 28px; align-items: start; } +#wd-settings-form .wd-field { margin-bottom: 0; background: var(--secondary-background-color); border-radius: 10px; padding: 12px 14px; } +#wd-settings-form .wd-field label { color: var(--primary-text-color); } +#wd-settings-form .wd-field input[type=text], #wd-settings-form .wd-field input[type=number], #wd-settings-form .wd-field select, #wd-settings-form .wd-field textarea { background: var(--card-background-color); padding: 9px 11px; } +#wd-settings-form .wd-subhead { margin: 22px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--divider-color); } +.wd-sec-intro { font-size: .85em; color: var(--secondary-text-color); margin: 0 0 16px; line-height: 1.5; } +.wd-label-row { display: flex; align-items: center; } +.wd-tip { + display: inline-flex; width: 15px; height: 15px; border-radius: 50%; + align-items: center; justify-content: center; font-size: 10px; font-style: italic; + background: var(--secondary-background-color); color: var(--secondary-text-color); + cursor: help; margin-left: 6px; position: relative; border: 1px solid var(--divider-color); + font-weight: 700; text-transform: none; letter-spacing: normal; +} +.wd-tip-pop { + display: none; position: absolute; bottom: 150%; left: 50%; transform: translateX(-50%); + width: 264px; background: var(--card-background-color); color: var(--primary-text-color); + border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 10px 12px; + box-shadow: 0 4px 18px rgba(0,0,0,.35); z-index: 60; + text-align: left; font-weight: 400; text-transform: none; letter-spacing: normal; +} +.wd-tip:hover .wd-tip-pop { display: block; } +.wd-tip-txt { font-size: 12px; line-height: 1.5; display: block; } +.wd-dg { display: block; width: 100%; height: auto; margin-bottom: 8px; background: var(--secondary-background-color); border-radius: 6px; } +.wd-dg .ln { fill: none; stroke: var(--primary-color); stroke-width: 2.5; } +.wd-dg .ln2 { fill: none; stroke: var(--secondary-text-color); stroke-width: 1.5; opacity: .7; } +.wd-dg .ok { fill: none; stroke: var(--success-color, #4caf50); stroke-width: 2; } +.wd-dg .bad { fill: none; stroke: var(--error-color, #f44336); stroke-width: 2; } +.wd-dg .dash { stroke-dasharray: 4 3; } +.wd-dg .fz { fill: var(--primary-color); opacity: .18; } +.wd-dg .fw { fill: var(--warning-color, #ff9800); opacity: .2; } +.wd-dg .fb { fill: var(--error-color, #f44336); opacity: .14; } +.wd-dg text { fill: var(--secondary-text-color); font-size: 9px; } +.wd-dg .ax { stroke: var(--divider-color); stroke-width: 1; } +.wd-sug { + display: flex; align-items: center; gap: 8px; margin-top: 6px; + padding: 6px 10px; border-radius: var(--wd-radius-md); font-size: .82em; + background: rgba(255,152,0,.10); border: 1px solid rgba(255,152,0,.40); + box-sizing: border-box; flex-wrap: wrap; +} +.wd-sug.wd-sug-split { flex-direction: column; align-items: stretch; gap: 0; padding: 0; overflow: hidden; } +.wd-sug-opt { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 10px; } +.wd-sug-opt:not(:last-child) { border-bottom: 1px solid rgba(255,152,0,.28); } +.wd-sug-chip { + display: inline-flex; align-items: center; gap: 3px; flex-shrink: 0; + font-size: .75em; font-weight: 700; letter-spacing: .04em; + padding: 2px 7px; border-radius: 10px; white-space: nowrap; +} +.wd-sug-chip-obs { background: rgba(255,152,0,.22); } +.wd-sug-chip-cal { background: rgba(33,150,243,.18); } +.wd-sug-val { font-weight: 700; flex-shrink: 0; } +.wd-sug-impact-line { flex-basis: 100%; font-size: .86em; opacity: .70; font-style: italic; margin-top: 2px; } +.wd-sug-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.wd-sug-sep { display: none; } +.wd-sug-impact { display: none; } +.wd-sug-use { border: none; background: var(--warning-color, #ff9800); color: var(--wd-white); border-radius: var(--wd-radius-sm); padding: 2px 8px; font-size: .92em; cursor: pointer; flex-shrink: 0; } +.wd-sug-lock { border: none; background: transparent; color: var(--secondary-text-color); border-radius: var(--wd-radius-sm); padding: 2px 6px; font-size: .92em; cursor: pointer; flex-shrink: 0; opacity: .65; } +.wd-sug-lock:hover { opacity: 1; background: rgba(255,152,0,.15); } +.wd-conflict-err { display: flex; flex-direction: column; gap: 4px; margin-top: 5px; } +.wd-conflict-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: .8em; color: var(--error-color, #b71c1c); padding: 5px 9px; border-left: 3px solid var(--error-color, #b71c1c); background: rgba(183,28,28,.07); border-radius: 0 5px 5px 0; } +.wd-conflict-fix { border: 1px solid var(--error-color, #b71c1c); background: none; color: var(--error-color, #b71c1c); border-radius: var(--wd-radius-sm); padding: 1px 7px; font-size: .92em; cursor: pointer; white-space: nowrap; flex: none; } +.wd-conflict-fix:hover { background: var(--error-color, #b71c1c); color: var(--wd-white); } +.wd-conflict-sug-note { font-style: italic; opacity: 0.85; flex: none; } +#wd-settings-form .wd-field.wd-has-conflict { outline: 2px solid var(--error-color, #b71c1c); outline-offset: -1px; } +.wd-rev-sub { display: flex; align-items: center; gap: 6px; margin: 14px 0 6px; font-size: .85em; font-weight: 600; color: var(--primary-text-color); } +.wd-rev-tags { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; } +.wd-rev-tag { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--divider-color); font-size: .85em; cursor: pointer; } +.wd-rev-tag input { margin: 0; } +.wd-rev-notes { width: 100%; box-sizing: border-box; background: var(--card-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 9px 11px; font: inherit; resize: vertical; } +.wd-sug-banner { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 12px 16px; border-radius: 10px; margin-bottom: 16px; + background: rgba(255,152,0,.12); border: 1px solid rgba(255,152,0,.4); +} +.wd-subhead { font-size: .76em; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--primary-color); margin: 8px 0 10px; grid-column: 1 / -1; } +.wd-section-nav { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 0; } +.wd-sec-btn { + padding: 5px 14px; border-radius: 14px; border: 1px solid var(--divider-color); + background: transparent; color: var(--secondary-text-color); font-size: .8em; cursor: pointer; transition: background .15s; +} +.wd-sec-btn.active { background: var(--primary-color); color: var(--wd-white); border-color: var(--primary-color); } +.wd-level-toggle { display: inline-flex; gap: 4px; } +.wd-sec-btn { position: relative; } +/* Basic/Advanced slide toggle */ +.wd-mode-switch { display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font-size: .82em; user-select: none; white-space: nowrap; } +.wd-mode-switch-label { color: var(--secondary-text-color); transition: color .15s; } +.wd-mode-switch-label.active { color: var(--primary-color); font-weight: 600; } +.wd-toggle-track { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; } +.wd-toggle-track input { opacity: 0; width: 0; height: 0; position: absolute; } +.wd-toggle-knob { position: absolute; inset: 0; border-radius: 20px; background: var(--divider-color); transition: background .2s; } +.wd-toggle-knob::after { content: ''; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .2s; } +.wd-toggle-track input:checked + .wd-toggle-knob { background: var(--primary-color); } +.wd-toggle-track input:checked + .wd-toggle-knob::after { transform: translateX(16px); } +.wd-sec-sug-dot { position: absolute; top: 2px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--warning-color, #ff9800); display: inline-block; pointer-events: none; } +.wd-sec-conf-dot { position: absolute; top: 2px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--error-color, #b71c1c); display: inline-block; pointer-events: none; } +.wd-subtabs { display: flex; gap: 2px; border-bottom: 1px solid var(--divider-color); margin-bottom: 18px; flex-wrap: wrap; } +.wd-subtab { padding: 8px 18px; border: none; background: transparent; color: var(--secondary-text-color); font-size: .8em; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; transition: color .15s; } +.wd-subtab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); } +.wd-profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } +.wd-profile-card { + background: var(--card-background-color); border-radius: 10px; padding: 16px; + border: 1px solid var(--divider-color, rgba(0,0,0,.08)); cursor: pointer; transition: border-color .15s, transform .1s; +} +.wd-profile-card:hover { border-color: var(--primary-color); transform: translateY(-1px); } +button.wd-attn-card, button.wd-profile-card { appearance: none; font: inherit; text-align: left; width: 100%; } +button.wd-profile-card { display: block; } +.wd-prof-wrap { position: relative; } +.wd-profile-name { font-weight: 600; font-size: 1em; margin-bottom: 6px; } +.wd-profile-meta { font-size: .8em; color: var(--secondary-text-color); } +/* Profile-card header: name on the left, mini power-signature sparkline on the + right, both on one line and vertically aligned regardless of badge count. */ +.wd-profile-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.wd-profile-head .wd-profile-name { margin: 0; flex: 1 1 auto; min-width: 0; } +/* Status-pill row: wraps cleanly on its own line below the name, and every pill + (health / trend / warm-up / imported) shares one uniform compact pill shape. */ +.wd-profile-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px; } +.wd-profile-badges .wd-badge { margin: 0; padding: 2px 9px; border-radius: 999px; font-size: .72em; font-weight: 600; gap: 4px; } +/* D2: mini duration sparkline on profile cards */ +.wd-prof-spark { width: 64px; height: 20px; display: block; flex-shrink: 0; } +/* Community Store */ +.wd-store-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; font-size: .85em; } +.wd-crumb { background: none; border: none; color: var(--primary-color); cursor: pointer; padding: 2px 4px; font: inherit; } +.wd-crumb:hover { text-decoration: underline; } +.wd-crumb.active { color: var(--primary-text-color); font-weight: 700; cursor: default; } +.wd-crumb-sep { color: var(--secondary-text-color); } +.wd-store-search { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; } +.wd-store-search input { flex: 1; min-width: 180px; padding: 8px 11px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); font-size: .9em; } +.wd-store-list { display: flex; flex-direction: column; gap: 8px; } +/* Browse rows (appliances / programs): tappable list rows with a hover affordance + and a chevron, instead of flat cards. */ +.wd-store-rows { display: flex; flex-direction: column; gap: 6px; } +.wd-store-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; text-align: left; width: 100%; cursor: pointer; padding: 11px 14px; background: var(--secondary-background-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); color: var(--primary-text-color); transition: border-color .12s ease, background .12s ease; } +.wd-store-row:hover { border-color: var(--primary-color); background: var(--card-background-color); } +.wd-store-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.wd-store-row-title { font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.wd-store-row-sub { display: flex; align-items: center; gap: 10px; font-size: .8em; color: var(--secondary-text-color); } +.wd-store-chip { background: var(--accent-dim, rgba(0,180,216,.14)); color: var(--primary-color); border-radius: 999px; padding: 1px 8px; font-size: .92em; text-transform: capitalize; } +.wd-store-fav { color: var(--warning-color, #f0b429); } +.wd-store-row-arrow { color: var(--secondary-text-color); font-size: 1.3em; flex-shrink: 0; } +.wd-store-cycle-top { display: flex; align-items: center; gap: 12px; } +.wd-store-cycle-stats { flex: 1; min-width: 0; } +.wd-store-spark { width: 120px; height: 36px; flex-shrink: 0; display: block; background: var(--secondary-background-color); border-radius: 6px; } +.wd-store-conn { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; } +.wd-store-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; } +.wd-tag-pending { background: rgba(56,139,253,.18); color: var(--info-color, #58a6ff); } +.wd-tag-approved { background: rgba(63,185,80,.18); color: var(--success-color, #3fb950); } +.wd-store-picker-detail { margin-top: 6px; font-size: .85em; color: var(--secondary-text-color); display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; } +.wd-store-picker-actions { display: flex; align-items: center; gap: 8px; flex-basis: 100%; margin-top: 4px; flex-wrap: wrap; } +.wd-star-row { display: inline-flex; gap: 2px; } +.wd-star-btn { background: none; border: none; cursor: pointer; color: var(--warning-color, #f0b429); font-size: 1.1em; padding: 0 1px; line-height: 1; } +.wd-star-btn:hover { transform: scale(1.15); } +/* Share-device selection tree (profile -> its reference cycles). */ +.wd-sd-tree { display: flex; flex-direction: column; gap: 8px; max-height: 44vh; overflow-y: auto; margin-bottom: 16px; } +.wd-sd-group { border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--secondary-background-color); overflow: hidden; } +.wd-sd-prof { display: flex; align-items: center; gap: 8px; padding: 9px 12px; cursor: pointer; font-weight: 600; } +.wd-sd-prof-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wd-sd-count { font-size: .8em; font-weight: 400; color: var(--secondary-text-color); } +.wd-sd-cycles { display: flex; flex-direction: column; border-top: 1px solid var(--divider-color); } +.wd-sd-cyc { display: flex; align-items: center; gap: 8px; padding: 6px 12px 6px 28px; cursor: pointer; font-size: .9em; } +.wd-sd-cyc:hover, .wd-sd-prof:hover { background: var(--card-background-color); } +.wd-sd-cyc-meta { color: var(--secondary-text-color); } +.wd-sd-phase { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-top: 1px solid var(--divider-color); cursor: pointer; font-size: .85em; color: var(--secondary-text-color); } +.wd-sd-phase:hover { background: var(--card-background-color); } +.wd-sd-settings { display: flex; align-items: center; gap: 8px; padding: 10px 2px 2px; cursor: pointer; font-size: .9em; } +.wd-sd-consent { display: flex; align-items: flex-start; gap: 8px; padding: 10px 2px 2px; cursor: pointer; font-size: .9em; } +.wd-sd-group-nocyc { opacity: .65; } +.wd-sd-prof-disabled { display: flex; align-items: baseline; gap: 8px; padding: 9px 12px; font-weight: 600; flex-wrap: wrap; } +.wd-sd-nocyc-note { font-size: .8em; font-weight: 400; color: var(--secondary-text-color); } +.wd-share-guide { margin-bottom: 10px; border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); overflow: hidden; } +.wd-share-guide > summary { padding: 8px 12px; cursor: pointer; font-size: .85em; font-weight: 600; color: var(--secondary-text-color); list-style: none; } +.wd-share-guide > summary::-webkit-details-marker { display: none; } +.wd-share-guide > summary::before { content: '\u25B6 '; font-size: .7em; } +.wd-share-guide[open] > summary::before { content: '\u25BC '; } +.wd-share-guide-list { margin: 0; padding: 4px 12px 10px 28px; font-size: .85em; color: var(--secondary-text-color); line-height: 1.5; } +.wd-share-guide-list li { margin-bottom: 4px; } +.wd-linkbtn { background: none; border: none; padding: 0; color: var(--primary-color); cursor: pointer; font: inherit; text-decoration: underline; } +.wd-gear-body { margin-top: 12px; } +.wd-empty { text-align: center; padding: 48px 24px; color: var(--secondary-text-color); } +.wd-empty .wd-icon { font-size: 3em; margin-bottom: 10px; } +.wd-error-state { display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin-bottom: 10px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--divider-color); color: var(--error-color, #b71c1c); font-size: .9em; } +.wd-info { font-size: .9em; color: var(--secondary-text-color); line-height: 1.6; margin: 0; } +.wd-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 100; display: flex; align-items: center; justify-content: center; } +.wd-modal { background: var(--card-background-color); border-radius: var(--wd-radius-lg); padding: 24px; max-width: 480px; width: calc(100% - 32px); max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.3); } +.wd-modal-lg { max-width: 880px; } +.wd-modal h2 { margin: 0 0 16px; font-size: 1.1em; display: flex; align-items: center; gap: 10px; } +.wd-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; flex-wrap: wrap; } +.wd-canvas-wrap { margin: 10px 0; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 6px; } +.wd-canvas-wrap canvas { width: 100%; height: 240px; display: block; touch-action: none; cursor: crosshair; } +.wd-mode-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } +.wd-mini-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; flex-wrap: wrap; } +.wd-mini-tab { padding: 7px 16px; border: none; background: transparent; color: var(--secondary-text-color); font-size: .82em; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; } +.wd-mini-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); } +.wd-kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin: 4px 0 14px; } +.wd-kv-item { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 10px; text-align: center; } +.wd-kv-val { font-size: 1.25em; font-weight: 700; } +.wd-kv-lbl { font-size: .7em; color: var(--secondary-text-color); margin-top: 3px; } +.wd-seg-row, .wd-phase-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; } +.wd-swatch { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; display: inline-block; } +.wd-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 200; padding: 10px 20px; border-radius: var(--wd-radius-md); font-size: .9em; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,.25); animation: wd-toast-in .2s ease; } +@keyframes wd-toast-in { from { opacity: 0; transform: translateX(-50%) translateY(10px); } } +.wd-toast-success { background: var(--success-color, #4caf50); color: var(--wd-white); } +.wd-toast-error { background: var(--error-color, #f44336); color: var(--wd-white); } +.wd-toast-info { background: var(--info-color, #2196f3); color: var(--wd-white); } +/* D4: undo toast \u2014 action button + row layout */ +.wd-toast { display: flex; align-items: center; gap: 14px; } +.wd-toast-action { background: rgba(255,255,255,.22); color: inherit; border: none; border-radius: 6px; padding: 5px 12px; font: inherit; font-weight: 700; cursor: pointer; text-transform: uppercase; letter-spacing: .04em; font-size: .85em; } +.wd-toast-action:hover { background: rgba(255,255,255,.34); } +/* D7: "changed since last save" marker beside a settings field label */ +.wd-chg-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--info-color, #2196f3); margin-left: 6px; flex-shrink: 0; cursor: help; } +.wd-diag-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 16px; } +.wd-diag-stat { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 12px; text-align: center; } +.wd-diag-val { font-size: 1.6em; font-weight: 700; } +.wd-diag-lbl { font-size: .72em; color: var(--secondary-text-color); margin-top: 4px; } +.wd-feedback-item { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--divider-color); } +.wd-feedback-item:last-child { border-bottom: none; } +.wd-feedback-body { flex: 1; } +.wd-feedback-profile { font-weight: 600; } +.wd-feedback-meta { font-size: .78em; color: var(--secondary-text-color); } +.wd-rec-status { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; } +.wd-rec-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } +.wd-rec-active { background: var(--error-color, #f44336); animation: wd-pulse 1s ease-in-out infinite; } +.wd-rec-ready { background: var(--success-color, #4caf50); } +.wd-rec-idle { background: var(--disabled-color, #bdbdbd); } +/* Graph hover tooltip (follows the cursor) */ +.wd-gtip { position: fixed; z-index: 300; display: none; pointer-events: none; background: var(--card-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 7px 10px; font-size: 12px; line-height: 1.5; box-shadow: 0 4px 16px rgba(0,0,0,.4); white-space: nowrap; } +.wd-gtip b { font-weight: 700; } +/* Status chart legend + toggles */ +.wd-leg { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; font-size: .8em; color: var(--secondary-text-color); } +.wd-leg-i { display: inline-flex; align-items: center; gap: 6px; } +.wd-leg-i input { margin: 0 2px 0 0; width: auto; } +.wd-leg-sw { width: 16px; height: 3px; border-radius: 2px; display: inline-block; } +/* Status program selector */ +.wd-prog-ctl { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } +.wd-prog-ctl label { font-size: .72em; text-transform: uppercase; letter-spacing: .08em; color: var(--secondary-text-color); margin: 0; } +.wd-prog-ctl select { padding: 8px 11px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); min-width: 200px; font-size: .9em; } +.wd-prog-tag { font-size: .78em; padding: 3px 9px; border-radius: 10px; } +.wd-prog-tag.auto { background: rgba(76,175,80,.18); color: var(--success-color, #4caf50); } +.wd-prog-tag.manual { background: rgba(255,152,0,.2); color: var(--warning-color, #ff9800); } +/* Status-rich device selector */ +.wd-devbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; } +.wd-devcard { display: flex; align-items: center; gap: 9px; padding: 9px 13px; border-radius: var(--wd-radius-lg); border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); cursor: pointer; font-size: .9em; } +.wd-devcard.active { border-color: var(--primary-color); box-shadow: 0 0 0 1px var(--primary-color); } +.wd-devadd { border-style: dashed; color: var(--secondary-text-color); } +.wd-devadd:hover { border-color: var(--primary-color); color: var(--primary-color); } +.wd-devdot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.wd-devdot.run { animation: wd-pulse 1.4s ease-in-out infinite; } +.wd-devname { font-weight: 600; } +.wd-devsub { font-size: .72em; color: var(--secondary-text-color); } +.wd-dbadge { font-size: .72em; padding: 1px 7px; border-radius: 10px; background: var(--secondary-background-color); } +.wd-dbadge.rec { background: var(--error-color, #f44336); color: var(--wd-white); } +.wd-dbadge.sug { background: rgba(255,152,0,.22); } +.wd-dbadge.fb { background: rgba(33,150,243,.22); } +.wd-dbadge.conf { background: rgba(183,28,28,.18); color: var(--error-color, #b71c1c); } +/* Attention cards (status dashboard) */ +.wd-attn { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; margin-bottom: 16px; } +.wd-attn-card { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; background: var(--card-background-color); border: 1px solid var(--divider-color); cursor: pointer; transition: border-color .15s; } +.wd-attn-card:hover { border-color: var(--primary-color); } +.wd-attn-icon { font-size: 1.5em; line-height: 1; } +.wd-attn-body { flex: 1; min-width: 0; } +.wd-attn-title { font-weight: 600; } +.wd-attn-sub { font-size: .76em; color: var(--secondary-text-color); } +/* F1 first-run onboarding card (Status power-chart area) */ +.wd-onboard { margin-top: 12px; padding: 16px; border-radius: 10px; border: 1px dashed var(--divider-color); background: var(--secondary-background-color); } +.wd-onboard .wd-card-title { margin-top: 0; } +.wd-onboard-skip { font-size: .8em; color: var(--secondary-text-color); text-decoration: underline; cursor: pointer; } +.wd-onboard-skip:hover { color: var(--primary-color); } +/* Setup card (replaces getting-started card, phases 0-3) and phase-4 chip */ +.wd-setup-card { margin-top: 12px; padding: 16px; border-radius: 10px; border: 1px solid var(--primary-color, #03a9f4); background: color-mix(in srgb, var(--primary-color, #03a9f4) 8%, var(--card-background-color, var(--ha-card-background))); } +.wd-setup-card .wd-card-title { margin-top: 0; } +.wd-setup-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 20px; font-size: .82em; font-weight: 600; cursor: pointer; border: 1px solid var(--divider-color); background: var(--secondary-background-color); margin-top: 12px; } +.wd-setup-chip--healthy { border-color: var(--success-color, #4caf50); background: color-mix(in srgb, var(--success-color, #4caf50) 10%, var(--card-background-color, transparent)); } +.wd-setup-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.wd-setup-dot--green { background: var(--success-color, #4caf50); } +.wd-link { background: none; border: none; padding: 0; color: var(--primary-color); cursor: pointer; font: inherit; text-decoration: underline; display: inline; } +.wd-link:hover { opacity: .8; } +/* Logs page */ +.wd-logbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; } +.wd-logs { font-family: monospace; font-size: .76em; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 10px; height: 56vh; min-height: 140px; overflow: auto; resize: vertical; } +#wd-log-lines-page { height: auto; min-height: 200px; resize: none; } +/* Grouped stat blocks (profile overview) */ +.wd-sg-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin: 4px 0 16px; } +.wd-sg { background: var(--secondary-background-color); border-radius: 10px; padding: 14px; } +.wd-sg-h { font-size: .7em; text-transform: uppercase; letter-spacing: .08em; color: var(--secondary-text-color); margin-bottom: 6px; } +.wd-sg-main { font-size: 1.55em; font-weight: 700; line-height: 1.1; } +.wd-sg-main span { font-size: .5em; font-weight: 400; color: var(--secondary-text-color); margin-left: 4px; } +.wd-sg-sub { font-size: .78em; color: var(--secondary-text-color); margin-top: 5px; line-height: 1.5; } +.wd-logline { padding: 2px 0; border-bottom: 1px solid var(--divider-color); white-space: pre-wrap; word-break: break-word; } +.wd-logline:last-child { border-bottom: none; } +/* Entity combobox */ +.wd-combo { position: relative; width: 100%; } +.wd-combo-drop { position: absolute; top: 100%; left: 0; right: 0; z-index: 60; + background: var(--card-background-color,#fff); border: 1px solid var(--divider-color); + border-radius: 6px; box-shadow: 0 4px 14px rgba(0,0,0,.18); + max-height: 220px; overflow-y: auto; margin-top: 3px; } +.wd-combo-item { padding: 7px 12px; cursor: pointer; font-size: .86em; white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; } +.wd-combo-item:hover, .wd-combo-item.kbd { background: var(--secondary-background-color); } +.wd-combo-row { display: flex; gap: 6px; align-items: center; } +.wd-combo-row .wd-combo { flex: 1 1 auto; } +.wd-addbtn { flex: 0 0 auto; width: 34px; height: 34px; border-radius: var(--wd-radius-md); border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); font-size: 1.25em; line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } +.wd-addbtn:hover { background: var(--primary-color); color: #fff; border-color: var(--primary-color); } +.wd-loglvl { font-weight: 700; margin-right: 6px; } +.wd-logcomp { display: inline-block; font-size: .72em; color: var(--secondary-text-color); background: var(--secondary-background-color); border-radius: 4px; padding: 0 5px; margin-right: 6px; } +.wd-logdev { display: inline-block; font-size: .72em; color: var(--primary-color); margin-right: 6px; } +.wd-logts { color: var(--secondary-text-color); margin-right: 6px; } +.wd-lvl-ERROR, .wd-lvl-CRITICAL { color: var(--error-color, #f44336); } +.wd-lvl-WARNING { color: var(--warning-color, #ff9800); } +.wd-lvl-INFO { color: var(--info-color, #2196f3); } +.wd-lvl-DEBUG { color: var(--secondary-text-color); } +/* Compact cycle list */ +.wd-clist { display: flex; flex-direction: column; } +.wd-crow { display: flex; align-items: center; gap: 10px; padding: 9px 6px; border-bottom: 1px solid var(--divider-color); cursor: pointer; } +.wd-crow:hover { background: var(--secondary-background-color); } +.wd-crow:last-child { border-bottom: none; } +.wd-cmain { flex: 1; min-width: 0; overflow: hidden; } +.wd-cprog { font-weight: 600; } +.wd-cdate { font-size: .74em; color: var(--secondary-text-color); } +.wd-cmeta { text-align: right; font-size: .76em; color: var(--secondary-text-color); white-space: nowrap; } +/* Responsive / touch (portrait, phones, side panel) */ +@media (max-width: 680px) { + .wd-body { padding: 12px 10px 64px; } + .wd-card { padding: 14px; margin-bottom: 12px; } + .wd-form-grid { grid-template-columns: 1fr; } + .wd-stats { grid-template-columns: repeat(2, 1fr); } + .wd-kv { grid-template-columns: repeat(2, 1fr); } + .wd-tab { padding: 9px 13px; } + .wd-modal { padding: 16px; width: calc(100% - 18px); } + .wd-modal-lg { max-width: 100%; } + .wd-canvas-wrap canvas { height: 200px; } + .wd-header { padding: 12px 14px; } + .wd-btn { padding: 9px 15px; } /* larger touch targets */ + .wd-tip-pop { width: 210px; } + .wd-pg-lane-lbl { flex: 0 1 120px; max-width: 120px; } + #wd-settings-form .wd-form-grid { grid-template-columns: 1fr; gap: 12px 0; } +} +/* Log drawer */ +.wd-shell { display: flex; flex-direction: column; min-height: 100%; } +.wd-content-row { display: flex; flex: 1; overflow: hidden; min-height: 0; } +.wd-main { flex: 1; overflow-y: auto; min-width: 0; } +.wd-log-drawer { + position: relative; width: 0; overflow: hidden; + transition: width .28s cubic-bezier(.4,0,.2,1); + border-left: 1px solid var(--divider-color); + display: flex; flex-direction: column; + background: var(--primary-background-color); +} +.wd-log-drawer.open { width: 380px; } +.wd-log-resize { + position: absolute; left: 0; top: 0; bottom: 0; width: 6px; cursor: ew-resize; z-index: 2; + transition: background .15s; +} +.wd-log-resize:hover, .wd-log-resize.dragging { background: var(--primary-color, #03a9f4); opacity: .35; } +.wd-log-drawer-head { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; border-bottom: 1px solid var(--divider-color); + font-weight: 600; font-size: .9em; flex-shrink: 0; white-space: nowrap; +} +.wd-log-drawer-body { flex: 1; overflow-y: auto; padding: 10px 14px; min-width: 0; } +.wd-log-close-btn { + background: none; border: none; cursor: pointer; color: inherit; opacity: .65; + padding: 3px 6px; border-radius: var(--wd-radius-sm); font-size: 1.1em; line-height: 1; +} +.wd-log-close-btn:hover { opacity: 1; background: var(--secondary-background-color); } +.wd-gear-btn.log-active { background: rgba(255,255,255,.22); } +@media (max-width: 680px) { + .wd-log-drawer.open { width: 100vw !important; position: fixed; top: 0; right: 0; bottom: 0; z-index: 30; border-left: none; } + .wd-log-resize { display: none; } +} +.wd-pg-delta-up { color: var(--success-color, #4caf50); font-weight: 700; } +.wd-pg-delta-down { color: var(--error-color, #f44336); font-weight: 700; } +.wd-pg-delta-flat { color: var(--secondary-text-color); } +/* F3: Unified Playground */ +.wd-pg-canvas-wrap { position: relative; width: 100%; } +#wd-pg-canvas { display: block; width: 100%; height: 330px; cursor: crosshair; border-radius: 6px; background: var(--secondary-background-color); margin: 10px 0 0; } +.wd-pg-strip { display: flex; align-items: center; gap: 10px; padding: 8px 2px; font-size: .88em; font-variant-numeric: tabular-nums; flex-wrap: wrap; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.2)); margin-bottom: 12px; } +.wd-pg-strip-state { padding: 2px 10px; border-radius: 20px; font-weight: 700; font-size: .83em; white-space: nowrap; } +.wd-pg-strip-pbar { display: inline-flex; align-items: center; gap: 5px; } +.wd-pg-strip-track { width: 60px; height: 6px; background: var(--secondary-background-color); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; } +.wd-pg-strip-fill { height: 100%; background: var(--primary-color); border-radius: 3px; transition: width .15s; } +.wd-pg-params { display: flex; flex-direction: column; gap: 2px; } +.wd-pg-param-row { display: flex; align-items: center; gap: 6px; } +.wd-pg-param-lbl { flex: 1; font-size: .83em; color: var(--secondary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wd-pg-param-inp { width: 76px; flex: 0 0 76px; } +.wd-pg-param-drag { font-size: .75em; color: var(--primary-color); cursor: default; flex: 0 0 12px; } +.wd-pg-score-bar-row { display: flex; align-items: center; gap: 6px; font-size: .82em; margin: 2px 0; } +.wd-pg-score-bar-lbl { flex: 0 0 80px; color: var(--secondary-text-color); } +.wd-pg-score-bar-track { flex: 1; height: 6px; background: var(--secondary-background-color); border-radius: 3px; overflow: hidden; } +.wd-pg-score-bar-fill { height: 100%; border-radius: 3px; } +.wd-pg-score-bar-val { flex: 0 0 42px; text-align: right; font-variant-numeric: tabular-nums; color: var(--secondary-text-color); } +.wd-pg-cand-row { display: flex; align-items: center; gap: 6px; font-size: .82em; margin: 3px 0; } +.wd-pg-cand-name { flex: 0 0 110px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wd-pg-cand-track { flex: 1; height: 7px; background: var(--secondary-background-color); border-radius: var(--wd-radius-sm); overflow: hidden; } +.wd-pg-cand-fill { height: 100%; border-radius: var(--wd-radius-sm); } +.wd-pg-cand-pct { flex: 0 0 34px; text-align: right; color: var(--secondary-text-color); } +/* Playground: settings control panel (live-settings source + named presets) */ +.wd-pg-ctrl { border: 1px solid var(--divider-color, rgba(127,127,127,.25)); border-radius: var(--wd-radius-md, 10px); padding: 8px 10px; margin: 0 0 10px; background: var(--secondary-background-color); } +.wd-pg-ctrl .wd-btn { white-space: nowrap; } +.wd-pg-preset-sel { min-width: 150px; max-width: 220px; font-size: .82em; } +.wd-pg-preset-name { flex: 1 1 150px; min-width: 120px; max-width: 220px; font-size: .82em; } +/* Per-setting publish arrow. The empty slot keeps every row's value column aligned + whether or not that row currently has a publishable change. */ +.wd-pg-pub { width: 20px; height: 20px; flex: 0 0 20px; padding: 0; border: none; border-radius: 5px; cursor: pointer; background: var(--primary-color); color: var(--text-primary-color, #fff); font-size: .8em; line-height: 1; } +.wd-pg-pub:hover { filter: brightness(1.15); } +.wd-pg-pub-slot { width: 20px; flex: 0 0 20px; display: inline-block; } +/* Playground: unified workbench (graph+settings always on top) + "Across your + cycles" drawer with History/Optimize sub-tabs. */ +.wd-pg-drawer { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--divider-color, rgba(127,127,127,.25)); } +.wd-pg-drawer-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; } +.wd-pg-subtabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: var(--secondary-background-color); } +.wd-pg-subtab { border: none; background: transparent; color: var(--secondary-text-color); font: inherit; font-size: .84em; font-weight: 600; padding: 5px 13px; border-radius: 8px; cursor: pointer; } +.wd-pg-subtab:hover { color: var(--primary-text-color); } +.wd-pg-subtab.active { background: var(--card-background-color, var(--primary-background-color)); color: var(--primary-color); box-shadow: 0 1px 3px rgba(0,0,0,.12); } +.wd-pg-hrow { cursor: pointer; } +.wd-pg-hrow:hover td { background: var(--secondary-background-color); } +.wd-pg-hrow.selected td { background: color-mix(in srgb, var(--primary-color) 14%, transparent); box-shadow: inset 2px 0 0 var(--primary-color); } +.wd-pg-sim-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; margin-top: 4px; } +.wd-pg-sim-main, .wd-pg-sim-side { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.wd-pg-simbar { height: 6px; border-radius: 3px; background: var(--secondary-background-color); overflow: hidden; margin: 6px 0 0; } +.wd-pg-simbar-fill { height: 100%; width: 40%; border-radius: 3px; background: var(--primary-color); animation: wd-pg-indeterminate 1.1s ease-in-out infinite; } +@keyframes wd-pg-indeterminate { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } +.wd-pg-batchbar-fill { height: 100%; width: 0%; border-radius: 3px; background: var(--primary-color); transition: width .18s ease; } +/* Header activity pills (background-task registry) */ +.wd-task-pills { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 0 0 0 12px; } +.wd-task-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 4px 3px 9px; border-radius: 12px; background: rgba(255,255,255,.16); color: var(--app-header-text-color, #fff); font-size: .78em; line-height: 1; } +.wd-task-pill-lbl { font-weight: 600; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wd-task-pill-pct { font-variant-numeric: tabular-nums; opacity: .95; } +.wd-task-pill-eta { opacity: .75; } +.wd-task-pill-x { border: none; background: rgba(0,0,0,.18); color: inherit; width: 16px; height: 16px; border-radius: 50%; cursor: pointer; font-size: .9em; line-height: 1; display: inline-flex; align-items: center; justify-content: center; padding: 0; } +.wd-task-pill-x:hover { background: rgba(0,0,0,.32); } +.wd-task-pill--cancelling { background: rgba(255,160,0,.28); } +.wd-task-pill-cancelling { opacity: .9; font-style: italic; } +.wd-task-pill-x--cancelling { opacity: .4; cursor: default; pointer-events: none; } +.wd-task-spin { width: 10px; height: 10px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: wd-spin-kf .8s linear infinite; opacity: .9; } +@keyframes wd-spin-kf { to { transform: rotate(360deg); } } +.wd-pg-batchrow { display: flex; align-items: center; gap: 10px; margin: 6px 0 8px; } +.wd-pg-batchrow .wd-pg-simbar { flex: 1; margin: 0; } +#wd-pg-canvas.wd-pg-panning { cursor: grabbing; } +.wd-pg-alerts-card { background: var(--secondary-background-color); border-radius: 10px; padding: 12px; } +.wd-pg-outcome-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.wd-pg-outcome-item { text-align: center; background: var(--card-background-color, var(--primary-background-color)); border-radius: 8px; padding: 8px 4px; } +.wd-pg-outcome-val { font-size: 1.05em; font-weight: 700; } +.wd-pg-outcome-lbl { font-size: .68em; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: .04em; margin-top: 2px; } +.wd-pg-alert { border-left: 3px solid var(--info-color, #2196f3); padding: 4px 0 4px 10px; } +/* History table */ +.wd-pg-htable { width: 100%; border-collapse: collapse; font-size: .84em; } +.wd-pg-htable th { text-align: left; font-weight: 600; color: var(--secondary-text-color); padding: 6px 8px; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.2)); font-size: .82em; } +.wd-pg-htable td { padding: 6px 8px; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.12)); } +.wd-pg-htable tr[data-action] { cursor: pointer; } +.wd-pg-htable tr[data-action]:hover { background: var(--secondary-background-color); } +.wd-pg-diffbadge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: .82em; font-weight: 600; margin: 0 6px 6px 0; } +/* Sweep heatmap */ +@media (max-width: 720px) { + .wd-pg-sim-grid { grid-template-columns: 1fr; } +} +@media (max-width: 640px) { + .wd-pg-strip { gap: 7px; font-size: .82em; } +} +`;function ot(h){if(h==null||h<0)return"-";const t=Math.floor(h/3600),e=Math.floor(h%3600/60),s=Math.floor(h%60);return t>0?`${t}h ${e}m`:e>0?`${e}m ${s}s`:`${s}s`}function Ut(h){return h==null?"-":h>=100?`${Math.round(h)} W`:`${h.toFixed(1)} W`}function gt(h){return h==null?"-":`${h.toFixed(2)} kWh`}let Gt="relative";const le=3700;function Kt(h){const t=e=>String(e).padStart(2,"0");return`${h.getFullYear()}-${t(h.getMonth()+1)}-${t(h.getDate())}`}function Xt(h){const t=new Date;return t.setDate(t.getDate()-h),Kt(t)}function de(){return Kt(new Date)}function zt(){return Xt(10)}function ce(){return Xt(le-1)}function he(h){const t=Math.round((h-Date.now())/1e3);let e;try{e=new Intl.RelativeTimeFormat(void 0,{numeric:"auto"})}catch{return Yt(h)}const s=Math.abs(t),r=[["year",31536e3],["month",2592e3],["week",604800],["day",86400],["hour",3600],["minute",60]];for(const[n,a]of r)if(s>=a)return e.format(Math.round(t/a),n);return e.format(t,"second")}function Yt(h){return new Date(h).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}function st(h,t){if(!h)return"-";let e;if(typeof h=="number")e=h>=1e12?h:h*1e3;else{const s=String(h),r=/^(\d{4})-(\d{2})-(\d{2})$/.exec(s);e=r?new Date(+r[1],+r[2]-1,+r[3]).getTime():new Date(s).getTime()}return isNaN(e)?"-":(t||Gt)==="relative"?he(e):Yt(e)}function Pt(h){if(h==null)return"unknown error";if(h instanceof Error)return`${h.name}: ${h.message}`;const t=h.code!=null?String(h.code):"",e=h.message!=null?String(h.message):"";if(!t&&!e)try{return JSON.stringify(h)}catch{return String(h)}const s=t==="unknown_command"?" (the integration is probably still starting up; this should stop on its own)":"";return`${t||"error"}${e?": "+e:""}${s}`}function d(h){return String(h??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function pe(h){const t=String(h??"").trim();return/^https?:\/\//i.test(t)?t:""}function St(h,t){const e=String(h??"").trim();let s=e.match(/^#([0-9a-fA-F]{3,8})$/);if(s){let r=s[1];if((r.length===3||r.length===4)&&(r=r.split("").map(n=>n+n).join("")),r.length===6||r.length===8){const n=parseInt(r.slice(0,2),16),a=parseInt(r.slice(2,4),16),o=parseInt(r.slice(4,6),16);return`rgba(${n}, ${a}, ${o}, ${t})`}}if(s=e.match(/^rgba?\(([^)]+)\)$/i),s){const r=s[1].split(",").map(n=>n.trim());if(r.length>=3)return`rgba(${r[0]}, ${r[1]}, ${r[2]}, ${t})`}return e}function Jt(h,t){const e=parseFloat(h);return isNaN(e)?t:e}function Ft(h){return h?Array.from(h.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')).filter(e=>e.getClientRects().length>0):[]}function Tt(h){return h==null||h===""?"(none)":h===!0?"on":h===!1?"off":Array.isArray(h)?h.join(", ")||"(none)":String(h)}function Qt(h,t,e){return h.slice().sort((s,r)=>{const n=t(s),a=t(r);return n==null&&a==null?0:n==null?1:a==null?-1:(na?1:0)*e})}function ft(h,t,e,s,r,n,a){const o=e?s===1?" \u25B2":" \u25BC":' \u2195',i=n==="right"?"text-align:right;":"",l=a?` title="${d(a)}"`:"";return`${h}${o}`}function Mt(h){h=Math.max(0,Math.round(h));const t=Math.floor(h/3600),e=Math.floor(h%3600/60),s=h%60;return t>0?`${t}:${String(e).padStart(2,"0")}:${String(s).padStart(2,"0")}`:`${e}:${String(s).padStart(2,"0")}`}function Zt(h,t){const e={pause:["lbl.artifact_interruption","Interruption"],dip:["lbl.artifact_low_power","Low power"],spike:["lbl.artifact_high_power","High power"]},[s,r]=e[h]||["lbl.artifact_anomaly","Anomaly"];return t?t(s,{},r):r}function te(h){return h.toLowerCase().replace(/[\s&/\-]+/g,"_").replace(/^_+|_+$/g,"").replace(/_+/g,"_")}function ee(h){const t=new Set;return String(h??"").split(",").forEach(e=>{const s=parseInt(e.trim(),10);Number.isFinite(s)&&s>0&&t.add(s)}),Array.from(t).sort((e,s)=>e-s)}function Dt(h,t){if(!h||!h.length)return null;if(t<=h[0][0])return h[0][1];if(t>=h[h.length-1][0])return h[h.length-1][1];for(let e=1;e=t){const s=h[e-1],r=h[e],n=r[0]-s[0]||1;return s[1]+(r[1]-s[1])*((t-s[0])/n)}return h[h.length-1][1]}function _e(h,t,e){e=e||{};const s=h.key,r=h.unit?`${h.label} (${h.unit})`:h.label,n=h.unit?` ${h.unit}`:"",a=h.doc?it(h.doc,h.diagram||ge[s]):"",o=e.changed?``:"";if(h.type==="checkbox")return`
${o}${a}
${h.hint?`
${d(h.hint)}
`:""}
`;let i="";const l=t??"";if(h.type==="select"||h.type==="devicetype"||h.type==="device"){const C=(e.opts||[]).map(([R,B])=>``).join("");i=``}else if(h.type==="textarea")i=``;else if(h.type==="json"){const S=l===""||l==null?"":typeof l=="string"?l:JSON.stringify(l,null,2);i=``}else if(h.type==="checkboxlist"){const S=Array.isArray(t)&&t.length?t.map(String):Array.isArray(h.def)?h.def.map(String):[];i=`
`+(h.choices||[]).map(([C,R])=>``).join("")+"
"}else if(h.type==="entitylist"){const C=(Array.isArray(t)?t:t?[t]:[]).map(R=>`${d(R)}`).join("");i=`
${C}
`}else if(h.type==="timerlist"){const S=Array.isArray(l)?l:[],C=e.t?e.t("lbl.timer_min",{},"min"):"min",R=e.t?e.t("lbl.timer_msg_placeholder",{},"Message (optional, {device}/{program}/{minutes})"):"Message (optional, {device}/{program}/{minutes})",B=e.t?e.t("lbl.timer_auto_pause",{},"Auto-pause"):"Auto-pause",W=e.t?e.t("btn.remove_timer",{},"Delete"):"Delete",O=e.t?e.t("btn.add_timer",{},"+ Add timer"):"+ Add timer",D=(E,M)=>{const H=E&&E.offset_minutes?String(E.offset_minutes):"",q=E&&E.message?d(E.message):"",Q=E&&E.auto_pause?" checked":"";return`
`},A=S.map((E,M)=>D(E,M)).join("");i=`
${A}
`}else if(h.type==="entity"){const S=h.placeholder?` placeholder="${d(h.placeholder)}"`:"";i=`
`}else if(h.type==="list"){const S=Array.isArray(l)?l.join(", "):d(l);i=``}else if(h.type==="intlist"){const S=Array.isArray(l)?l.join(", "):String(l??""),C=h.placeholder?` placeholder="${d(h.placeholder)}"`:"";i=``}else{const S=h.type==="number"?"number":"text",C=e.datalistId?` list="${e.datalistId}"`:"",R=h.step!=null?` step="${h.step}"`:"",B=h.min!=null?` min="${h.min}"`:"",W=h.max!=null?` max="${h.max}"`:"",O=h.placeholder?` placeholder="${d(h.placeholder)}"`:"";i=`${e.datalist||""}`}const c=e.suggestion,_=e.mlSuggestion,p=c&&c.suggested!=null&&!Ot(c.suggested,t)?c.suggested:null,g=_&&_.value!=null&&!Ot(_.value,t)?_.value:null,u=e.t,v=c?c.reason_key?u(c.reason_key,c.reason_params||{},c.reason||""):c.reason||"":"",b=_?_.reason_key?u(_.reason_key,_.reason_params||{},_.reason||""):_.reason||"":"",$=S=>``;let k="";if(p!=null&&g!=null){const S=parseFloat(p),C=parseFloat(g);if((!isNaN(S)&&!isNaN(C)?Math.abs(S-C)/Math.max(Math.abs(S),Math.abs(C),1e-9):1)<.05){const B=u("suggestion.calibrated_label",{},"Calibrated"),W=it([v,b?`${B}: ${b}`:""].filter(Boolean).join(` + +`));k=`
\u{1F4A1} ${d(u("suggestion.both_agree",{},"WashData recommends"))}${d(p)}${n}${$(p)}${W}
`}else{const B=v?it(v):"",W=b?it(b):"",O=u("suggestion.observed_label",{},"Observed"),D=u("suggestion.calibrated_label",{},"Calibrated");let A="",E="";if(!isNaN(S)&&!isNaN(C)){const q=C>S;E=u(`suggestion.impact.${s}.${q?"higher":"lower"}`,{},""),A=u(`suggestion.impact.${s}.${q?"lower":"higher"}`,{},"")}const M=A?`
${d(A)}
`:"",H=E?`
${d(E)}
`:"";k=`
\u{1F4A1} ${d(O)}${d(p)}${n}${$(p)}${B}${M}
\u{1F916} ${d(D)}${d(g)}${n}${$(g)}${W}${H}
`}}else if(p!=null){const S=v?it(v):"",C=t!=null&&t!==""?` (now ${d(t)}${n})`:"";k=`
\u{1F4A1} ${d(u("suggestion.observed_label",{},"Observed"))}${d(p)}${n}${C}${$(p)}${S}
`}else if(g!=null){const S=b?it(b):"";k=`
\u{1F916} ${d(u("suggestion.calibrated_label",{},"Calibrated"))}${d(g)}${n}${$(g)}${S}
`}if(k&&s){const S=d(u("btn.mute_suggestion",{},"Stop suggesting this setting")),C=``;k=k.replace(/<\/div>\s*$/,C+"")}return`
${o}${a}
${i}${h.hint?`
${d(h.hint)}
`:""}${k}
`}function Ot(h,t){if(h==null||t==null)return!1;const e=parseFloat(h),s=parseFloat(t);return!isNaN(e)&&!isNaN(s)?Math.abs(e-s)<1e-6:String(h)===String(t)}const ge={min_power:"min_power",off_delay:"off_delay",smoothing_window:"smoothing",start_threshold_w:"hysteresis",stop_threshold_w:"hysteresis",start_energy_threshold:"start_energy",running_dead_zone:"dead_zone",profile_duration_tolerance:"duration_tolerance",profile_match_min_duration_ratio:"match_ratios",profile_match_max_duration_ratio:"match_ratios",progress_reset_delay:"progress_reset",completion_min_seconds:"min_duration",min_off_gap:"min_off_gap",start_duration_threshold:"start_duration",end_energy_threshold:"end_energy_thresh",end_repeat_count:"end_repeat",profile_match_threshold:"confidence",profile_unmatch_threshold:"confidence",auto_label_confidence:"confidence",learning_confidence:"confidence",no_update_active_timeout:"watchdog_timeout",anti_wrinkle_enabled:"anti_wrinkle",anti_wrinkle_max_power:"anti_wrinkle",anti_wrinkle_max_duration:"anti_wrinkle",anti_wrinkle_exit_power:"anti_wrinkle",anti_wrinkle_idle_timeout:"anti_wrinkle",sampling_interval:"sampling"};function ue(h){let t=0,e=window.innerWidth;for(let s=h.parentElement;s;s=s.parentElement){if(getComputedStyle(s).overflowX==="visible")continue;const r=s.getBoundingClientRect();r.left>t&&(t=r.left),r.righti${t?fe(t):""}${d(h)}`}function Nt(h,t,e=""){return`${e||""}`}function Et(h,t,e="",s=""){return`
${Nt(h,t,e)}
${s?`
${d(s)}
`:""}
`}function fe(h){const t=s=>`${s}`,e='';switch(h){case"smoothing":return t(`${e} + + + raw vs smoothed`);case"min_power":return t(`${e} + + + + minbelow = off`);case"hysteresis":return t(`${e} + + + + + startstop`);case"start_energy":return t(`${e} + + spike ignored + + + energy counts`);case"off_delay":return t(`${e} + + + off-delay wait`);case"duration_tolerance":return t(`${e} + + + + -tol+tolprofile`);case"match_ratios":return t(`${e} + + + + + + minmax`);case"dead_zone":return t(`${e} + + + ignored`);case"progress_reset":return t(`${e} + + + held at 100%`);case"min_duration":return t(`${e} + + too short + + kept`);case"min_off_gap":return t(`${e} + + + off-gap + gap below min = one cycle`);case"start_duration":return t(`${e} + + + + + spike: ignored + confirmed`);case"end_energy_thresh":return t(`${e} + + + + tail energy above thresh: timer resets + accum + thr`);case"end_repeat":return t(`${e} + + + + + + + R1 R2 R3 + N reads below stop = end`);case"confidence":return t(` + + + + + + no match + feedback + auto + 0.0 + 1.0`);case"watchdog_timeout":return t(`${e} + + + + + no updates + sensor offline: force-stop`);case"anti_wrinkle":return t(`${e} + + + heat phase + tumble pulses kept`);case"sampling":return t(`${e} + + + + + + + SI + SI + SI + typical reading interval`);default:return""}}class me extends HTMLElement{constructor(){super(),this._hass=null,this._initialized=!1,this._pollTimer=null,this._toastTimer=null,this._hassUpdateThrottle=null,this._evtUnsubs=[],this._hoverRafId=null,this._hoverPending=null,this._constants={stateColors:{},deviceTypes:[],mlLabEnabled:!1,mlSuggestionsEnabled:!1,mlTrainingAvailable:!1,storeOnlineAvailable:!1,storeOnlineEnabled:!1,storeWebOrigin:"",storePrefs:{},pgMatchDefaults:{},version:"",iconUrl:""},this._constantsLoaded=!1,this._devices=[],this._cycles=[],this._refCycles=[],this._shareableCycles=[],this._sharePhasePrograms=[],this._shareAllPrograms=[],this._dlSettings=!1,this._selectMode=!1,this._cycleSel=new Set,this._profiles=[],this._profileGroups={groups:[],suggestions:[],min_cohesion:.85},this._profileEnvCache={},this._suggestions=[],this._lockedSuggestions=[],this._feedbacks=[],this._diag=null,this._phases=[],this._recState=null,this._opts={},this._optDefaults={},this._mlComparison=null,this._mlById={},this._mlLoading=!1,this._mlSettings={},this._mlSettingsLoading=!1,this._mlSettingsByEntry={},this._lockedByEntry={},this._mlTrainingStatus=null,this._setupStatus=null,this._selIdx=0,this._tab="status",this._settingsSec="basic",this._settingsSearch="",this._settingsSugOnly=!1,this._settingsHistoryOpen=!1,this._canvasZoom={},this._toolsSubtab="recording",this._loading=!0,this._tabLoading=!1,this._lastRefresh=null,this._powerHistory=[],this._powerT0=null,this._statusEnv=null,this._statusEnvName=null,this._powerData={live:[],raw:[],cycle_active:!1,cycle_elapsed_s:0},this._stagedSuggestions=!1,this._pendingSettings={},this._busy=new Set,this._tasks={},this._cancellingTasks=new Set,this._taskCallbacks={},this._tasksSubscribed=!1,this._pgHistoryTaskId=null,this._pgSweepTaskId=null,this._panelCfg=null,this._panelTrans=null,this._pollMs=ae,this._panelSubtab="maintenance",this._gearTab="prefs",this._catalog={brands:void 0,devices:void 0,forBrand:null,approvedOnly:!1,brandsFull:!1,brandPrefixes:[]},this._catalogEntry=null,this._maintenance=null,this._logs=[],this._logLevel="",this._logDevice="",this._logComponent="",this._logSearch="";try{this._logOpen=localStorage.getItem("wd-log-open")==="1",this._logDrawerWidth=Math.max(280,parseInt(localStorage.getItem("wd-log-width")||"380",10)||380)}catch{this._logOpen=!1,this._logDrawerWidth=380}this._tabInitialized=!1,this._modal=null,this._prevModal=null,this._toast=null,this._cycleSort={col:"date",dir:-1},this._cycleFilter={text:"",status:""},this._cleanupSort={col:"date",dir:-1},this._profSubtab="profiles",this._statusPhases=[],this._statusPhasesName=null,this._cycleOffset=0,this._cyclesTotal=0,this._cyclesHasMore=!1,this._undoBuffer=new Map,this._undoSeq=0,this._kbdHandler=null,this._tipHandler=null,this._settingsChangelog=null,this._settingsChangeByKey={},this._pgCycleId="",this._pgProfileName="",this._pgPowerPts=null,this._pgDtwData=null,this._pgEnvData=null,this._pgAnalysisTab="history",this._pgDetail=null,this._pgDetailBusy=!1,this._pgHistory=null,this._pgSweepObjective="match_accuracy",this._pgSweepNew=null,this._pgDetailDebounceTimer=null,this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={},this._pgEffective=null,this._pgPublishable=null,this._pgPresets=[],this._pgPresetLimit=0,this._pgPresetSel="",this._pgPresetName="",this._pgSuggClassic={},this._pgSuggMl=null,this._pgMlSuggEnabled=!1,this._pgView=null,this._pgHoverT=null,this._pgMap=null,this._pgStressTail=!1,this._pgStressIdleW=null,this._pgPanStart=null,this._pgHoverEvent=null,this._pgBatchProgress=null,this._pgBatchCancel=!1,this._pgLoadSeq=0,this._pgDragging=null,this._pgNeedsRestart=!1,this._pgSimCycles=20,this._pgSweepParam="off_delay",this._pgSweepFrom="",this._pgSweepTo="",this._pgSweepSteps=5,this._pgLoading=!1,this._storeView="brands",this._storeQuery="",this._storeDevices=[],this._storeDevice=null,this._storeProfiles=[],this._storeProfile=null,this._storeCycles=[],this._storeStatus=null,this._storeConnected=!1,this._storeLoading=!1,this._storeConnectListener=null}set hass(t){const e=this._hass;if(this._hass=t,!this._initialized&&t){this._initialized=!0,this._boot();return}e!==t&&this._initialized&&!this._loading&&!this._hassUpdateThrottle&&(this._hassUpdateThrottle=setTimeout(()=>{this._hassUpdateThrottle=null,this._fetchAll()},ne))}set panel(t){this._panel=t}set narrow(t){this._narrow=t}connectedCallback(){this._initialized&&(this._startPoll(),this._setupSubscriptions(),this._fetchAll(),this.shadowRoot&&!this._kbdHandler&&(this._kbdHandler=t=>this._onKeydown(t),this.shadowRoot.addEventListener("keydown",this._kbdHandler)),this.shadowRoot&&!this._tipHandler&&(this._tipHandler=t=>{const e=t.target,s=e&&e.closest?e.closest(".wd-tip"):null;s&&this._positionTip(s)},this.shadowRoot.addEventListener("pointerover",this._tipHandler))),this._onResize=()=>this._resizeLogsPage(),window.addEventListener("resize",this._onResize)}disconnectedCallback(){this._onResize&&(window.removeEventListener("resize",this._onResize),this._onResize=null),this._stopPoll(),this._hassUpdateThrottle&&(clearTimeout(this._hassUpdateThrottle),this._hassUpdateThrottle=null),this._pgRestartRetryTimer&&(clearTimeout(this._pgRestartRetryTimer),this._pgRestartRetryTimer=null),this._evtUnsubs.forEach(t=>{try{t()}catch{}}),this._evtUnsubs=[],this._flushPendingDeletes(),this._kbdHandler&&this.shadowRoot&&(this.shadowRoot.removeEventListener("keydown",this._kbdHandler),this._kbdHandler=null),this._tipHandler&&this.shadowRoot&&(this.shadowRoot.removeEventListener("pointerover",this._tipHandler),this._tipHandler=null),this._storeConnectListener&&(window.removeEventListener("message",this._storeConnectListener),this._storeConnectListener=null)}_boot(){const t=this.attachShadow({mode:"open"}),e=document.createElement("style");e.textContent=oe,t.appendChild(e),this._container=document.createElement("div"),t.appendChild(this._container),this._gtip=document.createElement("div"),this._gtip.className="wd-gtip",t.appendChild(this._gtip),this._kbdHandler=s=>this._onKeydown(s),t.addEventListener("keydown",this._kbdHandler),this._tipHandler=s=>{const r=s.target,n=r&&r.closest?r.closest(".wd-tip"):null;n&&this._positionTip(n)},t.addEventListener("pointerover",this._tipHandler),this._loadPanelTranslations().catch(()=>{}).finally(()=>{this._fetchAll(),this._startPoll()}),this._setupSubscriptions()}_setupSubscriptions(){this._evtUnsubs.forEach(e=>{try{e()}catch{}}),this._evtUnsubs=[],this._tasksSubscribed=!1;const t=this._hass&&this._hass.connection;if(t&&t.subscribeMessage){const e=s=>{const r=s.data||{};if(r.entry_id&&(this._fetchAll(),s.event_type==="ha_washdata_cycle_ended")){const n=this._devices[this._selIdx];n&&n.entry_id===r.entry_id&&this._fetchCycles(r.entry_id).then(()=>{this._tab==="history"&&this._render()})}};for(const s of["ha_washdata_cycle_started","ha_washdata_cycle_ended"])t.subscribeMessage(e,{type:"subscribe_events",event_type:s}).then(r=>{this._evtUnsubs.push(r)}).catch(()=>{});t.subscribeMessage(s=>this._onTaskEvent(s),{type:`${y}/subscribe_tasks`}).then(s=>{this._tasksSubscribed=!0,this._evtUnsubs.push(s)}).catch(()=>{this._tasksSubscribed=!1})}}_onTaskEvent(t){const e=t&&t.task;if(!e||!e.id)return;const s=this._tasks[e.id];s&&(s.updated_at||0)>(e.updated_at||0)||(this._tasks[e.id]=e,e.state!=="running"&&this._cancellingTasks.delete(e.id),this._updateTaskPills(),this._pgAdoptTask(e),this._onTrackedTaskProgress(e),(e.kind==="history_import"||e.kind==="history_import_apply")&&(e.state==="running"?this._modal&&this._modal.type==="history-import"&&this._render():this._histTaskFinished(e)),this._settleTaskCallback(e))}_pgAdoptTask(t){if(t.kind!=="pg_sweep"&&t.kind!=="pg_history")return;const e=this._devices[this._selIdx];if(!e||t.entry_id!==e.entry_id)return;const s=t.kind==="pg_sweep";if(this._pgSweepTaskId||this._pgHistoryTaskId)return;const r=t.state!=="running"&&t.finished_at&&Date.now()/1e3-t.finished_at<30;t.state!=="running"&&!r||(s?this._pgSweepTaskId=t.id:this._pgHistoryTaskId=t.id,this._pgAnalysisTab=s?"sweep":"history",this._busy.add(s?"pg-sweep":"pg-history"),t.state==="running"&&(this._pgBatchProgress={done:t.done||0,total:t.total||0}),this._render())}_pgAdoptExisting(){Object.values(this._tasks||{}).forEach(t=>{this._pgAdoptTask(t),this._onTrackedTaskProgress(t)})}_htmlPgRecentRuns(t){const e=this._devices[this._selIdx];if(!e)return"";const s=Object.values(this._tasks||{}).filter(n=>n.kind===t&&n.entry_id===e.entry_id&&n.state!=="running"&&n.has_result).sort((n,a)=>(a.finished_at||0)-(n.finished_at||0)).slice(0,8);if(!s.length)return"";const r=s.map(n=>{const a=n.finished_at?st(n.finished_at*1e3):"",o=n.state==="cancelled"?" \u26A0":"";return``}).join("");return`
+ ${this._t("lbl.pg_recent_runs",{},"Recent runs")} +
${r}
+
`}_settleTaskCallback(t){if(t.state==="running")return;const e=this._taskCallbacks[t.id];if(e){delete this._taskCallbacks[t.id],e(t);return}this._autoSettleAdopted(t)}_autoSettleAdopted(t){if(t.state!=="done"||t.kind!=="ml_training"&&t.kind!=="reprocess"||!t.finished_at||Date.now()/1e3-t.finished_at>60||(this._autoSettled=this._autoSettled||new Set,this._autoSettled.has(t.id)))return;const e=this._devices[this._selIdx];if(!e||t.entry_id!==e.entry_id)return;this._autoSettled.add(t.id);const s=e.entry_id,r=()=>{this._isActiveEntry(s)&&this._render()};t.kind==="ml_training"?this._loadMlTrainingStatus(s).finally(r):this._fetchToolsData(s).finally(r)}async _kickAndTrack(t,e,s){if(this._busy.has(e))return;this._busy.add(e),this._render();let r;try{const a=await this._ws(t);if(r=a&&a.task_id,!r)throw new Error("no task id");const o=String(t.type||"").endsWith("reprocess_history")?"reprocess":String(t.type||"").endsWith("trigger_ml_training")?"ml_training":String(t.type||"").endsWith("apply_split")?"split":String(t.type||"").endsWith("trim_cycle")?"trim":String(t.type||"").endsWith("apply_merge")?"merge":String(t.type||"").endsWith("rebuild_envelopes")?"rebuild":"task";this._addProvisionalTask(r,o,t.entry_id,0)}catch(a){this._busy.delete(e),this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error"),this._render();return}this._taskCallbacks[r]=async a=>{if(this._busy.delete(e),a.state==="error"){this._showToast(this._t("msg.toast_error",{error:a.error||""},"Error: "+(a.error||"")),"error"),this._render();return}if(a.state==="cancelled"){this._showToast(this._t("toast.task_cancelled",{},"Cancelled."),"info"),this._render();return}let o=null;try{const i=await this._ws({type:`${y}/get_task_result`,task_id:a.id});o=i&&i.result}catch{}if(o==null){this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info"),this._render();return}try{await s(o,a.state)}catch{}this._render()};const n=this._tasks[r];if(n&&n.state!=="running"){this._settleTaskCallback(n);return}this._tasksSubscribed||this._pollTaskGeneric(r)}_finalizeTaskError(t,e){const s=Object.assign({},this._tasks[t]||{id:t},{id:t,state:"error",error:e,finished_at:Date.now()/1e3});this._tasks[t]=s,this._updateTaskPills(),this._settleTaskCallback(s)}async _pollTaskGeneric(t){let e=0;for(let s=0;s<3600&&this._taskCallbacks[t];s++){let r;try{r=await this._ws({type:`${y}/get_task_result`,task_id:t}),e=0}catch{if(++e>=5){this._finalizeTaskError(t,"lost connection");return}await new Promise(a=>setTimeout(a,1200));continue}if(!r){this._finalizeTaskError(t,"no result");return}if(this._tasks[t]=r,this._updateTaskPills(),r.state!=="running"){this._settleTaskCallback(r);return}await new Promise(n=>setTimeout(n,1200))}this._taskCallbacks[t]&&this._finalizeTaskError(t,"timed out")}_deviceName(t){const e=(this._devices||[]).find(s=>s.entry_id===t);return e&&(e.name||e.title)||""}_taskActionLabel(t){return{pg_history:this._t("lbl.task_pg_history",{},"Test on history"),pg_sweep:this._t("lbl.task_pg_sweep",{},"Optimize"),pg_detail:this._t("lbl.task_pg_detail",{},"Simulate cycle"),split:this._t("lbl.task_split",{},"Splitting cycle"),trim:this._t("lbl.task_trim",{},"Trimming cycle"),merge:this._t("lbl.task_merge",{},"Merging cycles"),rebuild:this._t("lbl.task_rebuild",{},"Rebuilding envelopes"),reprocess:this._t("lbl.task_reprocess",{},"Reprocessing"),ml_training:this._t("lbl.task_ml_training",{},"Learning"),history_import:this._t("lbl.task_history_import",{},"Scanning power history"),history_import_apply:this._t("lbl.task_history_import_apply",{},"Importing cycles")}[t]||t}_fmtEta(t){return t=Math.round(t),t<60?this._t("lbl.eta_secs",{n:t},`~${t}s left`):this._t("lbl.eta_mins",{n:Math.round(t/60)},`~${Math.round(t/60)}m left`)}_exclNote(t){if(!t||!t.total)return"";const e=(t.items||[]).map(([s,r])=>`${r} ${this._t("suggestion.exclusions.reason."+s,{},String(s).replace(/_/g," "))}`).join(", ");return" "+this._t("suggestion.exclusions.summary",{total:t.total,parts:e},`Excluded ${t.total} mis-detected cycle(s): ${e}.`)}_htmlTaskPills(){const t=Object.values(this._tasks||{}).filter(s=>s.state==="running"),e=new Set(t.map(s=>s.id));return this._cancellingTasks.forEach(s=>{e.has(s)||this._cancellingTasks.delete(s)}),t.length?t.map(s=>{const r=this._cancellingTasks.has(s.id),n=this._deviceName(s.entry_id),a=s.label_key?this._t(s.label_key,s.label_params||{},s.label||this._taskActionLabel(s.kind)):this._taskActionLabel(s.kind),o=(n?n+" \xB7 ":"")+a,i=s.progress!=null?Math.round(s.progress*100)+"%":"",l=s.eta_s!=null&&s.eta_s>0?this._fmtEta(s.eta_s):"",c=this._t("task.cancelling",{},"Cancelling\u2026"),_="wd-task-pill"+(r?" wd-task-pill--cancelling":""),p=r?" \xB7 "+c:i?" "+i:"";return`${d(o)}`+(r?`${d(c)}`:(i?`${i}`:"")+(l?`${d(l)}`:""))+``}).join(""):""}_updateTaskPills(){const t=this.shadowRoot;if(!t)return;const e=t.getElementById("wd-task-pills");e&&(e.innerHTML=this._htmlTaskPills())}_addProvisionalTask(t,e,s,r){!t||this._tasks[t]||(this._tasks[t]={id:t,entry_id:s,kind:e,label:this._taskActionLabel(e),state:"running",done:0,total:r||0,progress:r?0:null,eta_s:null,updated_at:0,has_result:!1},this._updateTaskPills())}_onTrackedTaskProgress(t){if(!(t.id!==this._pgHistoryTaskId&&t.id!==this._pgSweepTaskId)){if(t.state==="running"){this._pgBatchProgress={done:t.done||0,total:t.total||0},this._pgUpdateBatchBar(t.done||0,t.total||0);return}this._pgFinishTask(t,t.id===this._pgHistoryTaskId)}}async _pgFinishTask(t,e){let s=null;try{if(t.state==="done"||t.state==="cancelled"){const r=await this._ws({type:`${y}/get_task_result`,task_id:t.id});s=r&&r.result}}catch{}this._isActiveEntry(t.entry_id)&&(t.state==="error"?this._showToast(this._t("msg.toast_error",{error:t.error||""},"Error: "+(t.error||"")),"error"):s&&(e?this._pgHistory=s:this._pgSweepNew=s&&!s.error?s:null),e?(this._busy.delete("pg-history"),this._pgHistoryTaskId=null):(this._busy.delete("pg-sweep"),this._pgSweepTaskId=null),this._pgBatchProgress=null,this._render())}async _pgPollTask(t){for(let e=0;e<3600&&(t===this._pgHistoryTaskId||t===this._pgSweepTaskId);e++){let s;try{s=await this._ws({type:`${y}/get_task_result`,task_id:t})}catch{break}if(!s)break;if(this._tasks[t]=s,this._updateTaskPills(),s.state!=="running"){this._onTrackedTaskProgress(s);return}this._pgBatchProgress={done:s.done||0,total:s.total||0},this._pgUpdateBatchBar(s.done||0,s.total||0),await new Promise(r=>setTimeout(r,1200))}}_panelTransUrl(t){const e=`/ha_washdata/panel-translations/${encodeURIComponent(t)}.json`;return Ht?`${e}?v=${encodeURIComponent(Ht)}`:e}async _fetchPanelLang(t){if(!t)return null;const e=[t],s=t.indexOf("-");s>0&&e.push(t.slice(0,s));for(const r of e)try{const n=await fetch(this._panelTransUrl(r));if(n.ok){const a=await n.json();if(a&&typeof a=="object")return a}}catch{}return null}async _loadPanelLang(t){if(!t||this._panelTrans&&this._panelTrans[t])return;const e=await this._fetchPanelLang(t);e&&(this._panelTrans||(this._panelTrans={}),this._panelTrans[t]=e)}async _loadPanelTranslations(){const t=this._hass&&this._hass.locale&&this._hass.locale.language;await Promise.all([this._loadPanelLang("en"),t&&t!=="en"?this._loadPanelLang(t):Promise.resolve()])}_startPoll(){this._stopPoll(),this._pollTimer=setInterval(()=>this._fetchAll(),this._pollMs)}_stopPoll(){this._pollTimer&&(clearInterval(this._pollTimer),this._pollTimer=null)}async _ws(t){return this._hass.connection.sendMessagePromise(t)}async _fetchAll(){if(!this._hass)return;const t=this._loading;try{if(!this._constantsLoaded){try{const r=await this._ws({type:`${y}/get_constants`});this._constants={stateColors:r.state_colors||{},deviceTypes:r.device_types||[],mlLabEnabled:!!r.ml_lab_enabled,mlSuggestionsEnabled:!!r.ml_suggestions_enabled,mlTrainingAvailable:!!r.ml_training_available,storeOnlineAvailable:!!r.store_online_available,storeOnlineEnabled:!!r.store_online_enabled,storeWebOrigin:r.store_web_origin||"",storePrefs:r.store_prefs||{},pgMatchDefaults:r.pg_match_defaults||{},PROFILE_MIN_WARMUP_CYCLES:r.PROFILE_MIN_WARMUP_CYCLES,version:r.version||"",iconUrl:r.icon_url||""}}catch{}try{this._panelCfg=await this._ws({type:`${y}/get_panel_config`}),this._applyPanelConfig()}catch{}this._constantsLoaded=!0}const e=await this._ws({type:`${y}/get_devices`});if(this._lastFetchErr=null,this._devices=e.devices||[],this._lastRefresh=new Date,this._selIdx===0&&this._devices.length>1){const r=localStorage.getItem("wd-last-device");if(r){const n=this._devices.findIndex(a=>a.entry_id===r);n>0&&(this._selIdx=n)}}const s=this._devices[this._selIdx];if(s&&this._tab==="status"){try{this._powerData=await this._ws({type:`${y}/get_power_history`,entry_id:s.entry_id,with_raw:this._pref("show_raw_active",!1)})}catch{}if(this._pref("show_debug",!1))try{this._matchDebug=await this._ws({type:`${y}/get_match_debug`,entry_id:s.entry_id})}catch{}if(this._canEdit()&&s.recording)try{this._recState=await this._ws({type:`${y}/get_recording_state`,entry_id:s.entry_id})}catch{}}if(s&&s.current_program){if(this._statusEnvName!==s.current_program){this._statusEnvName=s.current_program;try{const r=await this._ws({type:`${y}/get_profile_envelope`,entry_id:s.entry_id,profile_name:s.current_program});this._statusEnv=r.envelope||null}catch{this._statusEnv=null}}await this._ensureStatusPhases(s.entry_id,s.current_program)}else this._statusEnv=null,this._statusEnvName=null,this._statusPhases=[],this._statusPhasesName=null;if(t&&s){if(await this._fetchCycles(s.entry_id),await this._fetchSuggestions(s.entry_id),await this._fetchProfiles(s.entry_id),this._tab==="status")try{this._setupStatus=await this._ws({type:`${y}/get_setup_status`,entry_id:s.entry_id})}catch{this._setupStatus=null}if(this._constants.storeOnlineAvailable){try{const r=await this._ws({type:`${y}/get_options`,entry_id:s.entry_id});this._isActiveEntry(s.entry_id)&&(this._opts=r.options||{})}catch{}this._onlineEnabled()&&await this._loadStoreStatus(s.entry_id)}}this._logOpen&&this._isAdmin()&&this._fetchLogs().then(()=>this._refreshLogDrawer()).catch(()=>{})}catch(e){const s=Pt(e);s!==this._lastFetchErr&&(this._lastFetchErr=s,console.warn("[WashData panel] fetch error -",s,e))}finally{this._loading=!1;const e=this.shadowRoot,s=e&&e.activeElement,r=!!(s&&["SELECT","INPUT","TEXTAREA","OPTION"].includes(s.tagName));t?this._render():this._tab==="status"&&!this._modal&&!r?this._render():this._tab==="status"&&!this._modal&&r?(this._drawStatusCurve(),this._refreshDeviceBar()):this._refreshDeviceBar()}}async _fetchCycles(t){this._cyclesError=!1;try{const e=await this._ws({type:`${y}/get_device_cycles`,entry_id:t,limit:Rt,offset:0});this._cycles=e.cycles||[],this._refCycles=[...e.reference_cycles||[],...e.backfill_cycles||[]],this._cycleOffset=this._cycles.length,this._cyclesTotal=e.total!=null?e.total:this._cycles.length,this._cyclesHasMore=e.has_more!=null?!!e.has_more:!1}catch{this._cyclesError=!0,this._cycles=[],this._refCycles=[],this._cycleOffset=0,this._cyclesTotal=0,this._cyclesHasMore=!1}}async _loadMoreCycles(t){const e=await this._ws({type:`${y}/get_device_cycles`,entry_id:t,limit:Rt,offset:this._cycleOffset}),s=e.cycles||[],r=new Set(this._cycles.map(n=>n.id));for(const n of s)r.has(n.id)||this._cycles.push(n);this._cycleOffset+=s.length,this._cyclesTotal=e.total!=null?e.total:this._cyclesTotal,this._cyclesHasMore=e.has_more!=null?!!e.has_more:s.length>=Rt}async _ensureStatusPhases(t,e){if(!e){this._statusPhases=[],this._statusPhasesName=null;return}if(this._statusPhasesName!==e){this._statusPhasesName=e;try{const s=await this._ws({type:`${y}/get_profile_phases`,entry_id:t,profile_name:e});this._statusPhases=(s.phases||[]).map(r=>({name:r.name,start:r.start,end:r.end}))}catch{this._statusPhases=[]}}}async _fetchSettingsChangelog(t){try{const s=await this._ws({type:`${y}/get_settings_changelog`,entry_id:t});this._settingsChangelog=s.changelog||[]}catch{this._settingsChangelog=this._settingsChangelog||[]}const e={};for(const s of this._settingsChangelog||[])s&&s.key!=null&&!(s.key in e)&&(e[s.key]=s);this._settingsChangeByKey=e}_registerUndo(t){const e="u"+ ++this._undoSeq;return t.timer=setTimeout(()=>this._commitDelete(e),1e4),this._undoBuffer.set(e,t),e}_undoDelete(t){const e=this._undoBuffer.get(t);if(e){this._undoBuffer.delete(t),e.timer&&clearTimeout(e.timer);try{e.restore()}catch{}this._toastTimer&&clearTimeout(this._toastTimer),this._toast=null,this._render()}}async _commitDelete(t){const e=this._undoBuffer.get(t);if(!e)return;this._undoBuffer.delete(t),e.timer&&clearTimeout(e.timer);const s=(r,n)=>{const a=this._devices[this._selIdx],o=a&&a.entry_id;if(e.eid===o){try{e.restore(r)}catch{}this._showToast(n,"error"),this._render()}};try{const r=await e.commit();r&&r.length&&s(r,this._t("toast.delete_partial_failed",{},"Some items could not be deleted and were restored"))}catch(r){s(null,this._t("toast.delete_failed",{error:r&&r.message||r},"Delete failed: "+(r&&r.message||r)))}}_isActiveEntry(t){const e=this._devices[this._selIdx];return!!e&&e.entry_id===t}_flushPendingDeletes(){return!this._undoBuffer||!this._undoBuffer.size?Promise.resolve():Promise.all(Array.from(this._undoBuffer.keys()).map(t=>this._commitDelete(t)))}_deleteCyclesWithUndo(t,e){const s=new Set(e),r=[];if(this._cycles=(this._cycles||[]).filter((i,l)=>s.has(i.id)?(r.push({idx:l,rec:i,ref:!1}),!1):!0),this._refCycles=(this._refCycles||[]).filter((i,l)=>s.has(i.id)?(r.push({idx:l,rec:i,ref:!0}),!1):!0),!r.length)return;this._cycleSel.clear(),this._selectMode=!1,this._render();const n=i=>{const l=i&&i.length?i:r,c=this._cycles.slice(),_=this._refCycles.slice();l.slice().sort((p,g)=>p.idx-g.idx).forEach(({idx:p,rec:g,ref:u})=>{const v=u?_:c;v.splice(Math.min(p,v.length),0,g)}),this._cycles=c,this._refCycles=_},a=async()=>{const i=[];for(const l of r)try{await this._ws({type:`${y}/delete_cycle`,entry_id:t,cycle_id:l.rec.id})}catch{i.push(l)}if(i.lengthi.name===e),r=s>=0?this._profiles[s]:{name:e};s>=0&&(this._profiles=this._profiles.filter(i=>i.name!==e)),this._modal=null,this._render();const n=i=>{const l=this._profiles.slice();l.splice(Math.min(s<0?l.length:s,l.length),0,r),this._profiles=l},a=async()=>{try{await this._ws({type:`${y}/delete_profile`,entry_id:t,profile_name:e,unlabel_cycles:!0})}catch{return[{idx:s,rec:r}]}try{await this._fetchProfiles(t),this._tab==="profiles"&&this._render()}catch{}return[]},o=this._registerUndo({eid:t,restore:n,commit:a});this._showToast(this._t("msg.profile_deleted",{name:e},"Profile deleted"),"success",{actionLabel:this._t("btn.undo",{},"Undo"),actionToken:o,duration:1e4})}_onKeydown(t){if(!t.defaultPrevented){if(t.key==="Escape"){this._modal&&(t.preventDefault(),this._onModalAction("cancel",null));return}if(this._modal&&t.key==="Tab"){const e=this.shadowRoot,s=e&&e.querySelector('.wd-modal[role="dialog"]');if(s){const r=Ft(s);if(r.length){const n=e.activeElement,a=r.indexOf(n);t.shiftKey?a<=0&&(t.preventDefault(),r[r.length-1].focus()):(a===-1||a===r.length-1)&&(t.preventDefault(),r[0].focus())}}return}if(["ArrowLeft","ArrowRight","Home","End"].includes(t.key)){const e=this.shadowRoot,s=e&&e.activeElement;if(s&&s.classList.contains("wd-tab")){const r=[...e.querySelectorAll("button.wd-tab")],n=r.indexOf(s);if(n===-1)return;let a=n;t.key==="ArrowRight"?a=(n+1)%r.length:t.key==="ArrowLeft"?a=(n-1+r.length)%r.length:t.key==="Home"?a=0:t.key==="End"&&(a=r.length-1),t.preventDefault(),r[a].click(),r[a].focus()}}}}async _loadMlIndex(t){if(this._mlById=this._mlById||{},!!this._constants.mlLabEnabled)try{const e=await this._ws({type:`${y}/get_ml_comparison`,entry_id:t});if(!this._isActiveEntry(t))return;this._mlComparison=e;const s={};for(const r of e&&e.cycles||[])s[r.id]=r;this._mlById=s,this._mlSettings=e&&e.settings_comparison||this._mlSettings,this._mlSettingsByEntry[t]=this._mlSettings}catch{}}async _loadMlSettings(t){if(this._mlSettings=this._mlSettings||{},!!this._constants.mlSuggestionsEnabled)try{const e=this._mlComparison||await this._ws({type:`${y}/get_ml_comparison`,entry_id:t});if(!this._isActiveEntry(t))return;this._mlComparison=e,this._mlSettings=e&&e.settings_comparison||{},this._mlSettingsByEntry[t]=this._mlSettings}catch{}}async _loadMlTrainingStatus(t){if(this._constants.mlTrainingAvailable)try{const e=await this._ws({type:`${y}/get_ml_training_status`,entry_id:t});if(!this._isActiveEntry(t))return;this._mlTrainingStatus=e}catch{}}async _fetchCycleProfileEnv(t,e){if(e)try{const s=await this._ws({type:`${y}/get_profile_envelope`,entry_id:t,profile_name:e}),r=this._modal;r&&r.type==="cycle-detail"&&r.entryId===t&&r.curve&&(r.curve.profile_name||"")===e&&(r.profileEnv=s.envelope||null,this._render())}catch{}}async _fetchSuggestions(t){this._suggestionsError=!1;try{const e=await this._ws({type:`${y}/get_suggestions`,entry_id:t});if(!this._isActiveEntry(t))return;this._suggestions=e.suggestions||[],this._lockedSuggestions=e.locked_suggestions||[],this._lockedByEntry[t]=this._lockedSuggestions}catch{this._isActiveEntry(t)&&(this._suggestionsError=!0,this._suggestions=[])}}async _fetchProfiles(t){this._profilesError=!1;try{const e=await this._ws({type:`${y}/get_profiles`,entry_id:t});this._profiles=e.profiles||[],this._profileHealth=e.profile_health||{},this._profileTrends=e.profile_trends||{},this._coverageGaps=e.coverage_gaps||{},this._profileAdvisories=e.profile_advisories||[]}catch{this._profilesError=!0}return this._profiles}async _ensureProfileEnvs(t,e){this._profileEnvCache=this._profileEnvCache||{};const s=[...new Set(e)].filter(r=>r&&!(r in this._profileEnvCache));return s.length?(await Promise.all(s.map(async r=>{try{const n=await this._ws({type:`${y}/get_profile_envelope`,entry_id:t,profile_name:r});this._profileEnvCache[r]=n&&n.envelope||null}catch{this._profileEnvCache[r]=null}})),this._profileEnvCache):this._profileEnvCache}async _fetchProfileGroups(t){this._profileGroupsError=!1;try{const e=await this._ws({type:`${y}/get_profile_groups`,entry_id:t});this._profileGroups={groups:e.groups||[],suggestions:e.suggestions||[],min_cohesion:e.min_cohesion||.85}}catch{this._profileGroupsError=!0,this._profileGroups={groups:[],suggestions:[],min_cohesion:.85}}return this._profileGroups}async _selectDevice(t){if(t===this._selIdx)return;await this._flushPendingDeletes(),this._selIdx=t;const e=this._devices[t];e&&localStorage.setItem("wd-last-device",e.entry_id),this._pendingSettings={},this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null,this._stagedSuggestions=!1,this._mlComparison=null,this._mlById={},this._mlSettings={},this._profileEnvCache={},this._powerHistory=[],this._powerT0=null,this._statusEnv=null,this._statusEnvName=null,this._statusPhases=[],this._statusPhasesName=null,this._cycleOffset=0,this._cyclesTotal=0,this._cyclesHasMore=!1,this._settingsChangelog=null,this._settingsChangeByKey={},this._powerData={live:[],raw:[],cycle_active:!1,cycle_elapsed_s:0},this._matchDebug=null,this._profiles=[],this._profileHealth={},this._profileTrends={},this._coverageGaps={},this._profileAdvisories=[],this._opts={},this._optDefaults={},this._suggestions=[],this._lockedSuggestions=[],this._cycles=[],this._refCycles=[],this._recState=null,this._diag=null,this._maintenance=null,this._phases=[],this._mlTrainingStatus=null,this._setupStatus=null,this._deviceAutomations=[],this._selectMode=!1,this._cycleSel=new Set,this._cycleFilter={text:"",status:""},this._profSubtab="profiles",this._pgCycleId="",this._pgProfileName="",this._pgPowerPts=null,this._pgDtwData=null,this._pgEnvData=null,this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={},this._pgEffective=null,this._pgPublishable=null,this._pgPresets=[],this._pgPresetSel="",this._pgPresetName="",this._pgPresetLimit=0,this._pgSuggClassic={},this._pgSuggMl=null,this._pgMlSuggEnabled=!1,this._pgView=null,this._pgHoverT=null,this._pgLoadSeq++,this._pgNeedsRestart=!1,this._pgLoading=!1,this._pgDetail=null,this._pgHistory=null,this._pgSweepNew=null,this._pgHistoryTaskId=null,this._pgSweepTaskId=null,this._busy.delete("pg-history"),this._busy.delete("pg-sweep"),this._pgBatchProgress=null,this._pgDetailDebounceTimer&&(clearTimeout(this._pgDetailDebounceTimer),this._pgDetailDebounceTimer=null),this._pgStressTail=!1,this._pgStressIdleW=null,this._storeView="brands",this._storeDevice=null,this._storeProfile=null,this._storeQuery="",this._storeDevices=[],this._storeProfiles=[],this._storeCycles=[],this._storeStatus=null,this._storeConnected=!1,this._storeLoading=!1,clearTimeout(this._brandSearchTimer),this._brandSearchTimer=null,this._catalog={brands:this._catalog.brands,devices:void 0,forBrand:null,approvedOnly:this._catalog.approvedOnly,brandsFull:this._catalog.brandsFull,brandPrefixes:this._catalog.brandPrefixes},this._entityListCache&&delete this._entityListCache.store_model;const s=this._devices[this._selIdx];s&&await this._fetchSuggestions(s.entry_id),this._fetchTabData()}_refreshDeviceBar(){const t=this.shadowRoot;if(!t)return;const e=t.querySelector(".wd-devbar"),s=this._htmlDeviceBar();if(e&&s){const r=document.createElement("div");r.innerHTML=s;const n=r.firstElementChild;n&&(e.replaceWith(n),n.querySelectorAll(".wd-devcard[data-idx]").forEach(a=>a.addEventListener("click",()=>this._selectDevice(parseInt(a.dataset.idx,10)))))}}_refreshLogDrawer(){this._logOpen&&(this._refreshLogViews(),this._refreshLogFilterOptions())}_refreshLogFilterOptions(){const t=this.shadowRoot;if(!t)return;const e=(n,a,o)=>``+a.map(i=>``).join(""),s=e(this._t("log.all_devices",{},"All devices"),this._logDevices(),this._logDevice),r=e(this._t("log.all_components",{},"All components"),this._logComponents(),this._logComponent);t.querySelectorAll('.wd-log-filter[data-logfilter="device"]').forEach(n=>{n.innerHTML=s,n.value=this._logDevice||""}),t.querySelectorAll('.wd-log-filter[data-logfilter="component"]').forEach(n=>{n.innerHTML=r,n.value=this._logComponent||""})}async _fetchTabData(){const t=this._devices[this._selIdx];if(!t)return;const e=t.entry_id;this._tabLoading=!0,this._render();try{if(this._tab==="status"){if(this._powerData=await this._ws({type:`${y}/get_power_history`,entry_id:e,with_raw:this._pref("show_raw_active",!1)}),this._profiles.length||await this._fetchProfiles(e),!this._profiles.length&&!this._cycles.length&&await this._fetchCycles(e),await this._ensureStatusPhases(e,t.current_program),this._pref("show_debug",!1))try{this._matchDebug=await this._ws({type:`${y}/get_match_debug`,entry_id:e})}catch{}if(this._canEdit())try{this._recState=await this._ws({type:`${y}/get_recording_state`,entry_id:e})}catch{}try{this._setupStatus=await this._ws({type:`${y}/get_setup_status`,entry_id:e})}catch{this._setupStatus=null}}else if(this._tab==="history"){await this._fetchCycles(e),this._profiles.length||await this._fetchProfiles(e);try{const s=await this._ws({type:`${y}/get_feedbacks`,entry_id:e});this._feedbacks=s.feedbacks||[]}catch{}this._constants.storeOnlineAvailable&&this._onlineEnabled()&&this._loadStoreStatus(e).then(()=>{this._tab==="history"&&this._render()}),this._constants.mlLabEnabled&&(this._mlLoading=!0,this._loadMlIndex(e).finally(()=>{this._mlLoading=!1;const s=this._modal;s&&s.type==="cycle-detail"&&!s.ml&&this._mlById[s.cycleId]&&(s.ml=this._mlById[s.cycleId]),(this._tab==="history"||s&&s.type==="cycle-detail")&&this._render()}))}else if(this._tab==="profiles"){if(await this._fetchProfiles(e),this._fetchProfileGroups(e).then(()=>{this._tab==="profiles"&&this._render()}),this._cycles.length||this._fetchCycles(e).then(()=>{this._tab==="profiles"&&this._render()}),this._profSubtab==="phase-catalog")try{const s=await this._ws({type:`${y}/get_phase_catalog`,entry_id:e});this._phases=s.phases||[]}catch{}}else if(this._tab==="settings"){const s=await this._ws({type:`${y}/get_options`,entry_id:e});if(!this._isActiveEntry(e))return;this._opts=s.options||{},this._optDefaults=s.defaults||{},await this._fetchSuggestions(e),await this._fetchSettingsChangelog(e),this._constants.mlSuggestionsEnabled&&(this._mlSettingsLoading=!0,this._loadMlSettings(e).finally(()=>{this._mlSettingsLoading=!1,this._tab==="settings"&&this._renderPreservingFormEdits()})),this._constants.mlTrainingAvailable&&this._loadMlTrainingStatus(e).finally(()=>{this._tab==="settings"&&this._renderPreservingFormEdits()}),this._autoLoading=!0,this._loadDeviceAutomations(e).finally(()=>{this._autoLoading=!1,this._tab==="settings"&&this._renderPreservingFormEdits()}),this._constants.storeOnlineAvailable&&this._loadStoreStatus(e).finally(()=>{this._tab==="settings"&&this._renderPreservingFormEdits()})}else if(this._tab==="store"){try{const s=await this._ws({type:`${y}/get_options`,entry_id:e});if(!this._isActiveEntry(e))return;this._opts=s.options||{}}catch{}if(this._profiles.length||this._fetchProfiles(e),await this._loadStoreStatus(e),!this._isActiveEntry(e))return;this._ensureStoreConnectListener(),this._onlineEnabled()&&this._storeSearch(this._storeBrandScope())}else if(this._tab==="advanced"&&this._panelSubtab==="ml"){const s=await this._ws({type:`${y}/get_options`,entry_id:e});if(!this._isActiveEntry(e))return;this._opts=s.options||{},this._loadMlTrainingStatus(e).finally(()=>{this._tab==="advanced"&&this._panelSubtab==="ml"&&this._renderPreservingFormEdits()})}else if(this._tab==="playground"){if(await Promise.all([(async()=>{try{const s=await this._ws({type:`${y}/get_options`,entry_id:e});this._isActiveEntry(e)&&(this._opts=s.options||{})}catch{}})(),this._pgFetchSettings(e,!1),this._fetchCycles(e),this._profiles.length?Promise.resolve():this._fetchProfiles(e)]),!this._isActiveEntry(e))return;this._pgFetchSuggestions(e),!this._pgCycleId&&this._cycles?.length&&(this._pgCycleId=this._cycles[0].id,this._pgProfileName=""),this._pgNeedsRestart=!1,this._pgRestartRetries=0,this._pgAdoptExisting()}else this._tab==="advanced"&&this._panelSubtab==="maintenance"&&!this._maintenance&&this._fetchMaintenance(e).then(()=>{this._tab==="advanced"&&this._render()})}catch(s){console.warn("[WashData panel] tab data fetch error -",Pt(s),s)}finally{this._tabLoading=!1,this._render()}}async _fetchToolsData(t){try{const e=await this._ws({type:`${y}/get_diagnostics`,entry_id:t});if(!this._isActiveEntry(t))return;this._diag=e.stats||{}}catch(e){console.warn("[WashData panel] tools fetch error -",Pt(e),e),this._diag={_error:String(e&&e.message||e)}}}async _fetchMaintenance(t){try{const e=await this._ws({type:`${y}/get_maintenance_log`,entry_id:t});this._maintenance=e||{}}catch(e){console.warn("[WashData panel] maintenance fetch error -",Pt(e),e),this._maintenance={_error:String(e&&e.message||e)}}}async _fetchLogs(){try{const t=await this._ws({type:`${y}/get_logs`,level:null,limit:500});this._logs=t.logs||[]}catch(t){console.warn("[WashData panel] logs fetch error -",Pt(t),t)}}_logComponents(){return[...new Set((this._logs||[]).map(t=>t.logger).filter(Boolean))].sort()}_logDevices(){return[...new Set((this._logs||[]).map(t=>t.device).filter(Boolean))].sort()}_filteredLogRecords(){const t={DEBUG:10,INFO:20,WARNING:30,ERROR:40,CRITICAL:50},e=this._logLevel&&t[this._logLevel]||0,s=this._logDevice,r=this._logComponent,n=(this._logSearch||"").trim().toLowerCase();return(this._logs||[]).filter(a=>!(e&&(t[a.level]||0){const s=new Date(e.ts*1e3).toLocaleTimeString(),r=e.device?`${d(e.device)}`:"";return`
${s}${d(e.level)}${d(e.logger||"")}${r}${d(e.msg)}
`}).join(""):(this._logs||[]).length>0?`

${this._t("msg.no_logs_match",{},"No log records match these filters.")}

`:`

${this._t("msg.no_logs",{},"No log records buffered yet.")}

`}_htmlLogFilters(t){const s=["","DEBUG","INFO","WARNING","ERROR"].map(a=>``).join(""),r=``+this._logDevices().map(a=>``).join(""),n=``+this._logComponents().map(a=>``).join("");return` + + + `}_refreshLogViews(){const t=this.shadowRoot;if(!t)return;const e=this._logLinesHtml();["wd-log-lines-drawer","wd-log-lines-page"].forEach(s=>{const r=t.getElementById(s);r&&(r.innerHTML=e)})}_syncLogFilters(t){const e=this.shadowRoot;e&&e.querySelectorAll(`.wd-log-filter[data-logfilter="${t.dataset.logfilter}"]`).forEach(s=>{s!==t&&s.value!==t.value&&(s.value=t.value)})}async _fetchRecState(t){try{this._recState=await this._ws({type:`${y}/get_recording_state`,entry_id:t})}catch{}}async _fetchFeedbacks(t){try{const e=await this._ws({type:`${y}/get_feedbacks`,entry_id:t});this._feedbacks=e.feedbacks||[]}catch{}}async _fetchPhases(t){try{const e=await this._ws({type:`${y}/get_phase_catalog`,entry_id:t});this._phases=e.phases||[]}catch{}}_localize(t,e){try{const s=this._hass&&this._hass.localize?this._hass.localize(t):"";return s&&s!==t?s:e}catch{return e}}_tLookup(t,e){const s=this._panelTrans&&(this._panelTrans[e]||this._panelTrans.en);if(!s)return null;const r=t.split(".").reduce((n,a)=>n&&n[a]!==void 0?n[a]:null,s);return r&&typeof r=="string"?r:null}_t(t,e={},s=""){let r;const a=this._panelCfg&&this._panelCfg.prefs&&this._panelCfg.prefs.lang_override||this._hass&&this._hass.locale&&this._hass.locale.language;this._panelTrans?r=a&&this._tLookup(t,a)||this._tLookup(t,"en")||s:r=this._localize(`component.${y}.panel.${t}`,s);for(const[o,i]of Object.entries(e))r=r.replace(new RegExp(`\\{${o}\\}`,"g"),String(i));return r}_stateColor(t){const e=this._constants.stateColors||{};return e[t]||e.unknown||"var(--disabled-color, #bdbdbd)"}_stateLabel(t){const e=(t||"unknown").replace(/_/g," ").replace(/\b\w/g,s=>s.toUpperCase());return this._localize(`component.${y}.entity.sensor.washer_state.state.${t}`,e)}_deviceTypeLabel(t){const e=(this._constants.deviceTypes||[]).find(r=>r.id===t),s=e?e.label:(t||"").replace(/_/g," ");return this._localize(`component.${y}.selector.device_type.options.${t}`,s)}_deviceTypeOpts(t){return(this._constants.deviceTypes||[]).map(e=>[e.id,this._deviceTypeLabel(e.id)])}_deviceOpts(){const t=[["","- None -"]],e=this._hass&&this._hass.devices?this._hass.devices:{};return Object.values(e).forEach(s=>{const r=s.name_by_user||s.name||s.id;t.push([s.id,r])}),t}_applyFontScale(t){let e=parseFloat(t);isFinite(e)||(e=1),e=Math.max(.7,Math.min(2,e)),this.style.fontSize=e===1?"":Math.round(e*1e3)/10+"%"}_applyPanelConfig(){const t=this._panelCfg;if(!t)return;this._applyFontScale(t.prefs&&t.prefs.font_scale||1);const e=t.prefs&&t.prefs.lang_override;e&&!(this._panelTrans&&this._panelTrans[e])&&this._loadPanelLang(e).then(()=>this._render()).catch(()=>{});const s=t.panel||{};if(!this._tabInitialized){const r=t.prefs&&t.prefs.default_tab||s.default_tab;r&&["status","history","profiles","settings","playground"].includes(r)&&(this._tab=r),this._tabInitialized=!0}}_isAdmin(){return!!(this._panelCfg&&this._panelCfg.is_admin)}_curPerm(){const t=this._devices[this._selIdx];return t&&t.perm||"full"}_canEdit(){const t=this._curPerm();return this._isAdmin()||t==="edit"||t==="full"}_canFull(){const t=this._curPerm();return this._isAdmin()||t==="full"}_onlineEnabled(){return!!(this._constants&&this._constants.storeOnlineAvailable&&this._constants.storeOnlineEnabled)}_visibleTabIds(){const t=this._isAdmin(),e=!t&&this._panelCfg&&this._panelCfg.panel&&this._panelCfg.panel.hidden_tabs||[],s=["status","history","profiles"];return this._canEdit()&&s.push("settings"),this._canEdit()&&s.push("playground"),this._canEdit()&&this._onlineEnabled()&&s.push("store"),s.push("advanced"),s.filter(r=>t||!e.includes(r))}async _busyRun(t,e){this._busy.add(t),this._render();try{return await e()}finally{this._busy.delete(t),this._render()}}async _closeCycleDetail(t){const e=this._prevModal;if(this._prevModal=null,e&&e.type==="profile-panel"){if(this._modal=e,this._render(),e.tab==="cleanup")try{const s=await this._ws({type:`${y}/get_profile_cycles`,entry_id:t,profile_name:e.name});this._modal&&this._modal.type==="profile-panel"&&this._modal.name===e.name&&(this._modal.cleanup={cycles:s.cycles||[],selected:new Set},this._render(),this._drawSpaghetti())}catch{}}else this._modal=null,this._render()}_render(){if(!this._container)return;Gt=this._pref("date_format","relative");const t=this.shadowRoot,e=t&&(t.activeElement||this.getRootNode()&&this.getRootNode().activeElement)||null;this._container.innerHTML=this._buildHtml(),this._wire(),this._drawStatusCurve(),this._drawModalCanvas(),this._drawProfileSparklines(),this._drawHistorySparklines(),this._drawPlaygroundCanvases(),["wd-status-canvas","wd-cyc-canvas","wd-compare-canvas","wd-env-canvas","wd-phase-canvas","wd-spag-canvas","wd-pgroup-canvas"].forEach(s=>this._attachHover(s)),this._syncModalFocus(e),requestAnimationFrame(()=>this._resizeLogsPage())}_resizeLogsPage(){const t=this.shadowRoot&&this.shadowRoot.getElementById("wd-log-lines-page");if(!t)return;const e=t.getBoundingClientRect();e.top<=0||(t.style.height=window.innerHeight-e.top-24+"px")}_syncModalFocus(t=null){const e=this.shadowRoot;if(!e)return;const s=e.querySelector('.wd-modal[role="dialog"]');if(s){const r=s.querySelector("h2");if(r&&!r.id&&(r.id="wd-modal-title"),this._modalFocusActive){const n=e.activeElement||this.getRootNode()&&this.getRootNode().activeElement||null;if(!n||!s.contains(n)){const a=Ft(s);try{(a[0]||s).focus()}catch{}}}else{this._modalFocusActive=!0;const n=t||null;n&&!s.contains(n)&&(this._modalReturnFocus=n);const a=Ft(s);try{(a[0]||s).focus()}catch{}}}else if(this._modalFocusActive){this._modalFocusActive=!1;const r=this._modalReturnFocus;if(this._modalReturnFocus=null,r&&r.isConnected&&typeof r.focus=="function")try{r.focus()}catch{}}}_renderPreservingFormEdits(){this._snapshotFormToPending(this.shadowRoot),this._render()}_snapshotCycleReviewForm(t){const e=this._modal;if(!t||!e||e.type!=="cycle-detail"||e.mode!=="review")return;const s=t.getElementById("wd-cyc-rev-quality"),r=t.getElementById("wd-cyc-rev-golden"),n=t.getElementById("wd-cyc-rev-notes"),a=t.getElementById("wd-cyc-rev-label");if(!s&&!r&&!n&&!a)return;e.ml||(e.ml={}),e.ml.ml_review||(e.ml.ml_review={});const o=e.ml.ml_review;s&&(o.quality=s.value||""),r&&(o.golden=!!r.checked),n&&(o.notes=n.value||""),o.tags=Array.from(t.querySelectorAll(".wd-cyc-rev-tag")).filter(i=>i.checked).map(i=>i.value),a&&e.curve&&(e.curve.profile_name=a.value||"")}_buildHtml(){const t=this._toast?`
${d(this._toast.msg)}${this._toast.actionLabel?``:""}
`:"";return` +
+ ${this._htmlHeader()} +
+
+
+ ${this._loading?`
\u23F3
${this._t("msg.loading",{},"Loading\u2026")}
`:this._htmlBody()} +
+
+ ${this._logOpen&&this._isAdmin()?this._htmlLogDrawer():'
'} +
+
+ ${this._modal?this._htmlModal():""} + ${t} + `}_htmlHeader(){const e=Array.from(this._busy).some(i=>!(i==="pg-sweep"||i==="pg-history"||i==="reprocess"||i.startsWith("ml-train-now")))?`${this._t("status.working",{},"Working\u2026")}`:"",s=this._constants.iconUrl,r=s?``:``,n=this._constants.version;return` +
+ ${``} + ${r} +

WashData

${n?`v${d(n)} · `:""}${this._t("msg.appliance_monitor",{},"Appliance monitor")}
+ ${e} + ${this._htmlTaskPills()} + + + ${this._isAdmin()?``:""} +
+ `}_htmlBody(){if(!this._devices.length)return`
\u{1F9FA}
${this._t("msg.no_devices",{},"No WashData devices configured yet.")}
`;const t=this._mlSugKeys().size,e=this._suggestions.length||t?" \u{1F4A1}":"",s=this._conflictKeysFromOpts().size>0?" \u26A0":"",n=this._busy.has("pg-sim")||this._busy.has("pg-sweep")?'':"",a={status:this._t("tab.status",{},"Overview"),history:this._t("tab.history",{},"Cycles"),profiles:this._t("tab.profiles",{},"Profiles"),settings:this._t("tab.settings",{},"Settings")+s+e,playground:this._t("tab.playground",{},"Playground")+n,store:this._t("tab.store",{},"Store"),advanced:this._t("tab.advanced",{},"Advanced")},o=this._visibleTabIds();o.includes(this._tab)||(this._tab="status");const i=o.map(c=>``).join(""),l=(c,_)=>o.includes(c)?`
${_}
`:"";return` + ${this._htmlDeviceBar()} +
${i}
+ ${this._tabLoading?`
\u23F3
${this._t("msg.loading",{},"Loading\u2026")}
`:""} + ${l("status",this._htmlStatus())} + ${l("history",this._htmlHistory())} + ${l("profiles",this._htmlProfiles())} + ${l("settings",this._htmlSettings())} + ${l("playground",this._htmlPlayground())} + ${l("store",this._htmlStore())} + ${l("advanced",this._htmlPanel())} + `}_htmlDeviceBar(){const t=this._isAdmin()?``:"";return this._devices.length<=1?t?`
${t}
`:"":`
${this._devices.map((e,s)=>{const r=e.is_user_paused?"user_paused":e.detector_state||"unknown",n=["running","starting","paused","user_paused","ending","anti_wrinkle","rinse"].includes(r),a=!!e.recording,o=a?"var(--error-color, #f44336)":this._stateColor(r),i=a?this._t("status.recording",{},"Recording"):this._stateLabel(r),l=[],c=this._conflictCountForOpts(e.options||{},e.option_defaults||{});c&&l.push(`\u26A0 ${c}`);const _=this._sugCountsForDevice(e).total;return _&&l.push(`\u{1F4A1} ${_}`),e.feedback_count&&l.push(`\u{1F4AC} ${e.feedback_count}`),``}).join("")}${t}
`}_htmlStatus(){const t=this._devices[this._selIdx];if(!t)return`
${this._t("msg.no_device_selected",{},"No device selected.")}
`;const e=!!t.is_user_paused,s=e?"user_paused":t.detector_state||"unknown",r=!!t.recording,n=r?"var(--error-color, #f44336)":this._stateColor(s),a=r?this._t("status.recording",{},"Recording"):this._stateLabel(s),o=r||["running","starting","paused","user_paused","ending","anti_wrinkle","rinse"].includes(s),i=t.cycle_progress_pct,l=t.time_remaining_s,c=t.current_program,_=!!t.manual_program,p=c||"auto_detect",g=(this._profiles||[]).map(x=>x.name);c&&!g.includes(c)&&g.unshift(c);const u=g.map(x=>``).join(""),v=c?_?this._t("badge.manual",{},"(manually selected)"):this._t("badge.auto",{},"(auto-detected)"):"",b=v?`${v}`:"",$=`
${it(this._t("lbl.program_tip",{},"Override which profile is matched to the current cycle. Auto-detect lets the integration pick the best match automatically. Pin a specific program to force-match it when auto-detect is wrong or you know what is running."))} + ${b}
`,k=[];t.recording&&this._canEdit()&&k.push(`
\u25CF
${this._t("msg.recording_in_progress",{},"Recording in progress")}
${this._t("msg.see_recorder",{},"See recorder widget below")}
`),t.feedback_count&&this._canEdit()&&k.push(``);const S=this._conflictKeysFromOpts();if(S.size&&this._canEdit()){const x=S.size,L=x>1?"s":"";k.push(``)}const C=this._sugCountsForDevice(t);if(C.total&&this._canEdit()){const x=C.total,L=[];C.classic&&L.push(this._t("lbl.n_classic_suggestions",{n:C.classic},`${C.classic} classic`)),C.ml&&L.push(this._t("lbl.n_ml_suggestions",{n:C.ml},`${C.ml} ML`)),k.push(``)}const R=k.length?`
${k.join("")}
`:"",B=o&&i!=null?` +
+
${i.toFixed(1)}%${l!=null?`${this._t("lbl.time_remaining",{v:ot(l)},`~${ot(l)} remaining`)}`:""}
+ `:"",O=((this._powerData||{}).live||[]).length>1,D=this._pref("show_expected",!0),A=this._pref("show_raw_active",!1),E=`
+ ${this._t("lbl.power",{},"Power")} + ${this._statusEnv?``:""} + ${this._pref("show_raw",!1)?``:""} +
`,M=this._cyclesTotal||0,H=(this._profiles||[]).length,q=this._pref("setup_card_dismissed",!1),Q=this._setupStatus,J=Q&&!O,G=J?this._htmlSetupCard(Q,q):"",X=O?`
${E}`:J?G:`

${this._t("msg.live_chart_loading",{},"Live power chart appears as readings arrive.")}

`,ct=this._pref("show_debug",!1);let U="";if(ct){const x=this._matchDebug||{},L=x.confidence!=null?`${(x.confidence*100).toFixed(1)}%`:"-",j=(x.candidates||[]).map(I=>`${d(I.profile_name)}${I.confidence_pct}%${I.mae}${I.correlation}${I.duration_ratio>=0?"+":""}${I.duration_ratio}%`).join("");U=`
+
Live Match Debug ${it("Confidence: how closely the current power curve matches the top candidate profile (0-100%). Ambiguous: the two best candidates score within 5% of each other - the label is uncertain until the cycle finishes.")}
+
+
${L}
${this._t("lbl.confidence",{},"Confidence")}
+
${x.ambiguous?this._t("status.ambiguous",{},"Ambiguous"):this._t("status.clear",{},"Clear")}
${this._t("lbl.label",{},"Match")}
+
+ ${j?`${j}
ProfileConfMAECorrDuration
`:`

${this._t("msg.no_match_yet",{},"No match attempt yet - this populates during a running cycle.")}

`} +
`}const at=[];this._canEdit()&&at.push(``),at.push(``);const f=`
${this._t("hdr.tools_and_data",{},"Tools & Data")}
${at.join("")}
`,m=(()=>{if(!this._canEdit())return"";const L=["running","starting","ending","anti_wrinkle","rinse"].includes(s),j=L&&!e,I=e,w=L||e;return!j&&!I&&!w?"":`
+ ${I?``:""} + ${j?``:""} + ${w?``:""} +
`})();return` + ${R} +
+
+
${d(t.title)}
+ ${m} +
+
+ ${d(a)} + ${!r&&t.sub_state&&t.sub_state.toLowerCase()!==s?`(${d(t.sub_state)})`:""} +
+ ${$} +
+
${Ut(t.current_power_w)}
${this._t("lbl.power",{},"Power")}
+
${i!=null?i.toFixed(0)+"%":"-"}
${this._t("lbl.progress",{},"Progress")}
+
${ot(l)}
${this._t("lbl.remaining",{},"Remaining")}
+
+ ${B} + ${this._htmlPhaseTimeline(t,i,o)} + ${J?"":`
${this._t("hdr.live_power",{},"Live Power")}
`} + ${X} +
+ ${this._canEdit()?this._htmlRecordingWidget():""} + ${U} + ${f} + `}_htmlSetupCard(t,e=!1){if(!t)return"";const{phase:s,message_key:r,message_params:n,cta_label_key:a,cta_action:o,secondary_label_key:i,secondary_action:l,skippable:c,dismissible:_,step_key:p}=t;if(s==="phase4")return` +
+ + ${this._t("setup.hdr.healthy_chip",{},"Setup complete")} +
`;if(s==="phase3"&&e)return` +
+ + ${this._t("setup.hdr.card",{},"Device Setup")} +
`;const g=this._t(r,n||{},""),u=this._t(a,{},"Continue"),v=i?this._t(i,{},""):null,b=c?` + + ${this._t("setup.cta.skip_step",{},"Skip this step")} + ${this._t("setup.cta.skip_forever",{},"Don't show again")} + `:"",$=_?` + + ${this._t("setup.cta.hide_guidance",{},"Hide guidance")} + `:"";return` +
+
${this._t("setup.hdr.card",{},"Device Setup")}
+

${d(g)}

+
+ + ${v?` + ${d(v)} + `:""} +
+ ${b} + ${$} +
`}_dispatchSetupCta(t,e){if(t){if(t==="open_recorder"){this.shadowRoot.querySelector(".wd-rec-dot")?.closest(".wd-card")?.scrollIntoView({behavior:"smooth"});return}if(t==="open_cycles"||t==="open_cycles_unlabeled"){this._tab="history",t==="open_cycles_unlabeled"&&(this._cycleFilter={...this._cycleFilter||{},status:"unlabeled"}),this._fetchTabData();return}if(t==="open_profiles"||t==="open_profiles_groups"){this._tab="profiles",this._fetchTabData();return}if(t==="open_suggestions"){this._tab="settings",this._settingsSugOnly=!0,this._fetchTabData();return}if(t==="create_profile_from_cluster"){this._tab="profiles",this._fetchTabData();return}if(t&&t.startsWith("open_cycle:")){this._tab="history",this._fetchTabData();return}}}async _reloadSetupStatus(){const t=this._devices[this._selIdx];if(t){try{this._setupStatus=await this._ws({type:`${y}/get_setup_status`,entry_id:t.entry_id})}catch{this._setupStatus=null}this._render()}}_htmlPhaseTimeline(t,e,s){const r=this._statusPhases||[];if(!s||!r.length||!t.current_program)return"";let n=this._statusEnv&&this._statusEnv.target_duration||0;if(!n){const _=(this._profiles||[]).find(p=>p.name===t.current_program);n=_&&_.avg_duration||0}if(n||(n=Math.max(1,...r.map(_=>_.end||0))),n<=0)return"";const a=e!=null?Math.min(1,Math.max(0,e/100)):null;let o="";const i=r.map((_,p)=>{const g=Math.max(0,Math.min(1,(_.start||0)/n)),u=Math.max(0,Math.min(1,(_.end||0)/n)),v=Math.max(0,(u-g)*100),b=et[p%et.length],$=a==null?!0:g<=a;a!=null&&a>=g&&a12?`${d(_.name)}`:"";return`
${k}
`}).join(""),l=a!=null?`
`:"",c=o?`
${this._t("lbl.current_phase",{},"Current phase")}: ${d(o)}
`:"";return`
+ + ${c} +
`}_htmlRecordingWidget(){const t=this._recState,e=this._devices[this._selIdx],r=!!(e&&e.recording)?"recording":t?t.state:"idle",n=r==="recording"?"wd-rec-active":r==="stopped"?"wd-rec-ready":"wd-rec-idle",a=r==="recording"?this._t("status.recording",{},"Recording\u2026"):r==="stopped"?this._t("status.ready",{},"Ready to process"):this._t("status.idle",{},"Idle");let o="";r==="recording"?o=t?`${ot(t.duration_s)} \xB7 ${t.sample_count||0} samples`:"":r==="stopped"&&(o=`${t.sample_count||0} samples \xB7 ${ot(t.duration_s)}`);const i=r==="recording"?``:r==="stopped"?` + `:``;return`
+
+
+
+
${this._t("hdr.manual_recording",{},"Manual Recording")}${it(this._t("hdr.manual_recording_tip",{},"Run a cycle intentionally while WashData records the power trace. Start just before the appliance starts, Stop when it finishes, then Process to save it as a named profile."))}${o?`${o}`:""}
+
+
${i}
+
+
`}_htmlHistory(){const t=this._cycles||[],e=this._refCycles||[],s=e.concat(t),r=this._canEdit(),n=this._selectMode&&r,a=this._cycleSel,{col:o,dir:i}=this._cycleSort,{text:l,status:c}=this._cycleFilter,_=this._mlById||{},p=new Set((this._feedbacks||[]).map(w=>w.cycle_id)),g=w=>_[w.id],u=w=>{const P=g(w);return!!(P&&P.ml_review&&P.ml_review.reviewed_at)},v=w=>{const P=g(w);return!!(P&&P.ml_review&&P.ml_review.golden)},b=w=>{if(p.has(w.id))return!0;if(u(w))return!1;const P=g(w),z=P&&P.ml_quality_label;return["uncertain","review"].includes(z)||["force_stopped","interrupted"].includes(w.status)},$=s.filter(b).length;let k=s;if(l){const w=l.toLowerCase();k=k.filter(P=>(P.profile_name||P.matched_profile||"").toLowerCase().includes(w))}c==="unlabelled"?k=k.filter(w=>!w.profile_name&&!w.matched_profile):c==="needs_review"?k=k.filter(b):c==="imported"?k=k.filter(w=>w.is_reference):c&&(k=k.filter(w=>(w.status||"completed")===c));const S={date:w=>w.start_time?new Date(w.start_time).getTime():0,confidence:w=>w.match_confidence,duration:w=>w.duration,energy:w=>w.energy_kwh!=null?w.energy_kwh:w.energy_wh!=null?w.energy_wh/1e3:null,cost:w=>w.cost!=null?w.cost:-1,status:w=>w.status||"completed",profile:w=>(w.profile_name||w.matched_profile||"\uFFFF").toLowerCase()};k=Qt(k,S[o]||S.date,i);const C=w=>w==="completed"?"var(--success-color, #4caf50)":w==="interrupted"?"var(--error-color, #f44336)":w==="force_stopped"?"var(--warning-color, #ff9800)":"var(--secondary-text-color)",R=w=>{if(!w.is_reference)return"";const P=w.cycle_origin==="backfill",z=P?this._t("badge.backfilled_tip",{},"Detected in imported power history. Shapes program matching only, not counted in stats."):this._t("badge.imported_tip",{},"Imported from the community store. Used for matching only, not counted in stats.");return` ${P?"\u{1F557}":"\u{1F4E5}"}`},B=w=>v(w)?' \u2B50':p.has(w.id)?' \u{1F4AC}':u(w)?' \u2713':b(w)?' \u25CF':"",W=w=>{if(w.anomaly!=="overrun")return"";const P=w.overrun_ratio?" "+this._t("badge.overrun_ratio",{x:Number(w.overrun_ratio).toFixed(1)},`(${Number(w.overrun_ratio).toFixed(1)}x expected)`):"";return` \u23F1`},O=w=>{if(w.anomaly!=="underrun")return"";const P=w.underrun_ratio?" "+this._t("badge.underrun_ratio",{pct:Math.round(w.underrun_ratio*100)},`(${Math.round(w.underrun_ratio*100)}% of expected)`):"";return` \u26A1`},D=w=>{if(!w.energy_anomaly||w.energy_anomaly==="none")return"";const P=w.energy_anomaly==="energy_spike",z=w.energy_z_score!=null?` (${w.energy_z_score>0?"+":""}${Number(w.energy_z_score).toFixed(1)}\u03C3)`:"",F=P?"badge.energy_spike":"badge.energy_low",K=P?"Higher energy than usual":"Lower energy than usual",Z=P?"\u{1F53A}":"\u{1F53B}",rt=P?"var(--error-color,#f44336)":"var(--info-color,#2196f3)";return` ${Z}`},A=w=>{const P=Array.isArray(w.artifacts)?w.artifacts.length:0;return P?` 1?"ies":"y"} detected (e.g. door opened mid-cycle) \u2014 open to see them on the graph`))}" style="color:var(--warning-color,#ff9800)">\u26A0`:""},E=w=>{const P=Array.isArray(w.restart_gaps)?w.restart_gaps.length:0;return P?` 1?"s":""} during this cycle \u2014 power trace has a hole`))}" style="color:var(--info-color,#2196f3)">\u21BB`:""},M=this._hass&&this._hass.config&&this._hass.config.currency||"",H=w=>w.cost!=null?`${w.cost.toFixed(2)}${M?" "+M:""}`:"-",q=k.map(w=>{const P=w.profile_name||w.matched_profile,z=w.match_confidence!=null?w.match_confidence*100:null,F=w.status||"completed",K=w.energy_kwh!=null?w.energy_kwh:w.energy_wh!=null?w.energy_wh/1e3:null,Z=n,rt=Z?``:``,ht={completed:this._t("status.completed",{},"Completed"),interrupted:this._t("status.interrupted",{},"Interrupted"),force_stopped:this._t("status.force_stopped",{},"Force stopped"),active:this._t("status.active",{},"Active")}[F]||F,nt=`${R(w)}${B(w)}${W(w)}${O(w)}${D(w)}${A(w)}${E(w)}`.trim();return` + ${rt} + ${P?d(P):`${this._t("lbl.unlabelled",{},"Unlabelled")}`} + ${nt} + ${d(ht)} + ${st(w.start_time)} + ${ot(w.duration)} + ${K!=null?gt(K):"-"} + ${H(w)} + ${z!=null?z.toFixed(0)+"%":"-"} + `}).join(""),Q=` + + ${ft(this._t("lbl.profile",{},"Profile"),"profile",o==="profile",i,"cycsort","",this._t("col.profile_tip",{},"Matched program name. Unlabelled means no profile matched at end of cycle."))} + ${this._t("lbl.flags",{},"Flags")} + ${ft(this._t("lbl.status",{},"Status"),"status",o==="status",i,"cycsort","",this._t("col.status_tip",{},"Cycle outcome: Completed (natural end), Interrupted (abrupt power drop), Force Stopped (manual), or Needs Review (feedback pending)."))} + ${ft(this._t("lbl.date",{},"Date"),"date",o==="date",i,"cycsort","",this._t("col.date_tip",{},"Date and time the cycle started."))} + ${ft(this._t("lbl.duration",{},"Duration"),"duration",o==="duration",i,"cycsort","right",this._t("col.duration_tip",{},"Total cycle run time from start to end."))} + ${ft(this._t("lbl.energy",{},"Energy"),"energy",o==="energy",i,"cycsort","right",this._t("col.energy_tip",{},"Total energy consumed (kWh). Computed by integrating power over time."))} + ${ft(this._t("lbl.cost",{},"Cost"),"cost",o==="cost",i,"cycsort","right",this._t("col.cost_tip",{},"Energy cost for this cycle, frozen at completion using the price in effect then (energy x price per kWh). Set a price under Settings to populate it."))} + ${ft(this._t("lbl.confidence",{},"Confidence"),"confidence",o==="confidence",i,"cycsort","right",this._t("col.confidence_tip",{},"Profile match confidence (0-100%). How closely the cycle power curve matched the identified program."))} + `,J=`
+ + +
`,G=k.length!==s.length?this._t("lbl.n_shown",{n:k.length},`, ${k.length} shown`):"",X=e.length?this._t("lbl.n_imported_note",{n:e.length},`, ${e.length} imported`):"",ct=this._t("lbl.cycles_title",{n:`${t.length}${X}${G}`},`Cycles (${t.length}${X}${G})`),U=r?`
+ + +
`:"",at=new Set(e.map(w=>w.id)),f=[...a].some(w=>at.has(w)),m=n?`
+ ${this._t("lbl.n_selected",{n:a.size},`${a.size} selected`)} + + + + +
`:"",x=this._busy.has("cyc-load-more"),L=this._cyclesHasMore?`
+ +
`:"",j=` +
+
${ct}
+ ${J} + ${U}${m} + ${k.length===0?`
\u{1F4CB}
${s.length?this._t("msg.no_cycles_match",{},"No cycles match the current filter."):this._t("msg.no_cycles_yet",{},"No cycles recorded yet.")}
`:`
${Q}${q}
`} + ${L} +
`;return(this._cyclesError?`
${this._t("msg.fetch_error",{},"Failed to load data.")}
`:"")+j}_trendIcon(t){return t==="up"?`\u2191`:t==="down"?`\u2193`:""}_profileCardHtml(t){const e=t.avg_duration?this._t("lbl.duration_avg",{v:Math.round(t.avg_duration/60)},`~${Math.round(t.avg_duration/60)}m avg`):this._t("lbl.no_duration",{},"no duration"),s=t.avg_energy!=null?` \xB7 ${gt(t.avg_energy)}/cycle`:"",r=t.avg_energy!=null&&t.cycle_count?` \xB7 ${gt(t.avg_energy*t.cycle_count)} total`:"",n=this._hass&&this._hass.config&&this._hass.config.currency||"",a=t.avg_cost!=null?` \xB7 ${this._t("lbl.avg_cost",{},"Avg")} ${t.avg_cost.toFixed(2)}${n?" "+n:""}/${this._t("lbl.per_cycle_short",{},"cycle")}`:"",o=(this._profileHealth||{})[t.name],i=(this._profileTrends||{})[t.name];let l="";o&&o.health_status==="poor"?l=`\u26A0 ${this._t("badge.poor_fit",{},"poor fit")}`:o&&o.health_status==="fair"&&(l=`${this._t("badge.fair_fit",{},"fair fit")}`);let c="";if(i){const k=this._trendIcon(i.duration_trend),S=i.energy_trend?this._trendIcon(i.energy_trend):"";if(i.duration_trend!=="stable"||i.energy_trend==="up"){const C=[];if(i.duration_trend!=="stable"){const B=`${i.duration_slope_pct>0?"+":""}${i.duration_slope_pct}`;C.push(i.duration_trend==="up"?this._t("msg.duration_trend_up_tip",{pct:B},`Duration up (${B}%/cycle)`):this._t("msg.duration_trend_down_tip",{pct:B},`Duration down (${B}%/cycle)`))}if(i.energy_trend&&i.energy_trend!=="stable"){const B=`${i.energy_slope_pct>0?"+":""}${i.energy_slope_pct}`;C.push(i.energy_trend==="up"?this._t("msg.energy_trend_up_tip",{pct:B},`Energy up (${B}%/cycle)`):this._t("msg.energy_trend_down_tip",{pct:B},`Energy down (${B}%/cycle)`))}const R=C.join(", ")||this._t("msg.performance_trending",{},"Performance trending");c=`${k}${S||""}`}}const _=this._constants&&this._constants.PROFILE_MIN_WARMUP_CYCLES||5,p=o&&o.cycle_count||0,u=p<_&&!t.is_imported?`${this._t("msg.warmup_badge",{done:p,needed:_},`Still learning (${p}/${_} cycles)`)}`:"",v=t.is_imported?`\u{1F4E5} ${this._t("status.imported",{},"Imported")}`:"",b=[l,c,u,v].filter(Boolean).join(" "),$=Array.isArray(t.signature_curve)&&t.signature_curve.length>=3?``:"";return` +
+ +
`}_paintSparkline(t,e){if(!t||!Array.isArray(e)||e.length<3)return;const s=(getComputedStyle(this).getPropertyValue("--primary-color")||"#03a9f4").trim()||"#03a9f4",r=window.devicePixelRatio||1,n=t.getBoundingClientRect(),a=t.width=Math.max(1,Math.round((n.width||64)*r)),o=t.height=Math.max(1,Math.round((n.height||20)*r)),i=t.getContext("2d");i.clearRect(0,0,a,o);const l=Math.max(...e,1),c=2*r,_=g=>c+(e.length===1?0:g/(e.length-1)*(a-2*c)),p=g=>o-c-Math.max(0,g)/l*(o-2*c);i.beginPath(),e.forEach((g,u)=>{const v=_(u),b=p(g);u===0?i.moveTo(v,b):i.lineTo(v,b)}),i.strokeStyle=s,i.lineWidth=1.5*r,i.lineJoin="round",i.lineCap="round",i.stroke()}_drawProfileSparklines(){const t=this.shadowRoot;if(!t)return;const e=t.querySelectorAll("canvas[data-spark-prof]");if(!e.length)return;const s={};for(const r of this._profiles||[])s[r.name]=r;e.forEach(r=>{const n=s[r.dataset.sparkProf];this._paintSparkline(r,n&&n.signature_curve||[])})}_htmlProfiles(){const t=this._busy.has("rebuild-envelopes"),e=this._canEdit(),s={};this._profiles.forEach(u=>{s[u.name]=u});const r=this._profileGroups||{groups:[],suggestions:[]},n=new Set;r.groups.forEach(u=>(u.members||[]).forEach(v=>n.add(v)));const a=r.groups.map(u=>{const v=(u.members||[]).map(C=>s[C]?this._profileCardHtml(s[C]):"").join(""),b=Math.round((u.cohesion!=null?u.cohesion:1)*100),$=u.cohesive?`${this._t("lbl.cohesion_good",{pct:b},"cohesion "+b+"%")}`:`${this._t("lbl.cohesion_low",{pct:b},"\u26A0 low cohesion "+b+"%")}`,k=u.cohesive?"":`

${this._t("msg.group_not_cohesive",{},"These profiles aren't similar enough to group reliably, so matching treats them individually until you remove the outlier or split the group.")}

`;return`
+
+ ${e?``:`\u{1F517} ${d(u.name)}`} + ${$} + ${e?``:""} +
+ ${k} +
${v}
+
`}).join(""),o=this._profiles.filter(u=>!n.has(u.name)),i=o.map(u=>this._profileCardHtml(u)).join(""),l=this._profiles.length===0&&!(this._cycles||[]).length&&!(this._refCycles||[]).length,_=(e&&this._onlineEnabled()&&l?` +
+ \u{1F4E5} ${this._t("msg.onboard_download",{},"New device? Adopt a ready-made setup (programs, reference cycles and phases) from another WashData user with the same appliance.")} + +
`:"")+` +
+
${this._t("tab.profiles",{},"Profiles")} (${this._profiles.length})
+

${this._t("msg.profiles_intro",{},"Click a profile for stats, phases and cleanup. Group near-identical programs (same shape/duration, different temperature or spin) so matching reliably picks between them.")}

+ ${e?`
+ + + +
`:""} +
+ ${a} + ${this._profiles.length===0?`
\u{1F4CA}
${this._t("msg.no_profiles_yet",{},"No profiles yet. Create one from a labelled cycle.")}
`:o.length?`${a?`
${this._t("lbl.ungrouped",{},"Ungrouped")}
`:""}
${i}
`:""}`,p=[["profiles",this._t("tab.subtab_profiles",{},"Profiles")],["phase-catalog",this._t("tab.subtab_phase_catalog",{},"Phase Catalog")]].map(([u,v])=>``).join(""),g=this._profilesError||this._profileGroupsError?`
${this._t("msg.fetch_error",{},"Failed to load data.")}
`:"";return` +
${p}
+ ${this._profSubtab==="phase-catalog"?this._htmlPhases():g+_} + `}_htmlProfileGroupModal(t){const e=this._busy.has("pg-save"),s=this._profileEnvCache||{},r=p=>et[Math.max(0,this._profiles.findIndex(g=>g.name===p))%et.length],n=t.members||[],a=this._profiles.map(p=>{const g=n.includes(p.name),u=p.avg_duration?`~${Math.round(p.avg_duration/60)}m`:"",v=p.avg_energy!=null?` \xB7 ${gt(p.avg_energy)}`:"",b=g?``:"";return``}).join(""),o=n.filter(p=>s[p]&&(s[p].avg||[]).length),i=o.length?`
${o.map(p=>` ${d(p)}`).join("")}
`:"",l=o.length?`
${i}`:`

${this._t("msg.group_preview_hint",{},"Tick 2+ members to preview and compare their power curves.")}

`,c=((this._profileGroups||{}).groups||[]).find(p=>p.name===t.orig),_=c&&c.cohesion!=null?`${c.cohesive?this._t("lbl.cohesion_good",{pct:Math.round(c.cohesion*100)},"cohesion "+Math.round(c.cohesion*100)+"%"):this._t("lbl.cohesion_low",{pct:Math.round(c.cohesion*100)},"\u26A0 low cohesion "+Math.round(c.cohesion*100)+"%")}`:"";return`

${t.orig?this._t("modal.edit_group",{},"Edit profile group"):this._t("modal.new_group",{},"New profile group")}

+
${_}
+ ${l} +
${this._t("lbl.members",{},"Members")}${n.length?` (${n.length})`:""}
+
${a||`${this._t("msg.no_profiles_yet_short",{},"No profiles yet.")}`}
+

${this._t("msg.group_modal_help",{},"Group programs with the same shape that differ in temperature/spin (durations may vary). Matching scores the group as one candidate, then picks the best-fitting member. Pick at least 2; the overlay shows how alike they are.")}

+
+ + ${t.orig?``:""} + +
`}_drawGroupCanvas(){const t=this._modal;if(!t||t.type!=="profile-group")return;const e=this._profileEnvCache||{},s=a=>et[Math.max(0,this._profiles.findIndex(o=>o.name===a))%et.length];let r=0;const n=(t.members||[]).filter(a=>e[a]&&(e[a].avg||[]).length).map(a=>{const o=e[a],i=o.avg[o.avg.length-1];return r=Math.max(r,o.target_duration||(i?i[0]:0)),{points:o.avg,stroke:s(a),width:2,alpha:.9,name:a}});n.length&&this._drawCurves("wd-pgroup-canvas",{series:n,xMax:r})}_settingsLevel(){return this._pref("settings_level","basic")==="advanced"?"advanced":"basic"}_settingFieldVisible(t){return this._settingsLevel()==="advanced"||!!t.basic}_secHasBasicFields(t){return(t.fields||(t.groups||[]).flatMap(s=>s.fields||[])).some(s=>s.basic)}_htmlSettings(){const t=Object.assign({},this._opts,this._pendingSettings);if(!Object.keys(t).length)return`
\u2699\uFE0F
${this._t("msg.loading_settings",{},"Loading settings\u2026")}
`;const e=this._suggestionsError?`
${this._t("msg.fetch_error",{},"Failed to load data.")}
`:"",r=this._settingsLevel()==="basic",n=new Set((this._suggestions||[]).map(U=>U.key)),a=this._mlSugKeys(t),o=new Set([...n,...a]),i=U=>(U.fields||(U.groups||[]).flatMap(f=>f.fields||[])).some(f=>o.has(f.key)),l=this._conflictKeysFromOpts(),c=U=>(U.fields||(U.groups||[]).flatMap(f=>f.fields||[])).some(f=>l.has(f.key)),_=this._opts&&this._opts.device_type||"",p=mt.filter(U=>!(U.id==="ml_training"||_&&U.notDeviceTypes&&U.notDeviceTypes.includes(_)||_&&U.onlyDeviceTypes&&!U.onlyDeviceTypes.includes(_)||r&&!this._secHasBasicFields(U))),g=(p.find(U=>U.id===this._settingsSec)||p[0]||{}).id,u=p.map(U=>{const at=i(U),f=c(U);return``}).join(""),v=``,b=r?`

${this._t("msg.settings_basic_note",{},"Showing essential settings. Switch to Advanced for the full list.")}

`:"",$=this._busy.has("save-settings"),k=l.size,S=k!==1?"s":"",C=k?` +
+ \u26A0 ${this._t("conflict.settings_banner",{n:k},`Setting conflicts: ${k}. Check the highlighted sections and fix them before saving.`)} + +
`:"",R=this._suggestions.length,B=[...a].filter(U=>!n.has(U)).length,W=R+B,O=this._settingsSugOnly&&!this._settingsSearch,D=R?``:"",A=R?``:"",E=W?O?` +
+ \u{1F4A1} ${this._t("msg.showing_suggestions",{count:W},`Showing ${W} setting${W>1?"s":""} with suggestions.`)} ${this._t("msg.show_all_settings",{},"Show all settings")}. + ${D} +
`:` +
+ \u{1F4A1} ${this._t("msg.tuning_suggestions_available",{count:W},`${W} tuning suggestion${W>1?"s":""} available from observed cycles. They appear beside the relevant fields.`)} + + ${D} + ${A} +
`:"",M=this._busy.has("sug-analyze"),H=``,q=this._settingsSearch||"",Q=q.trim().toLowerCase(),J=``,G=Q?this._htmlSettingsSearch(t,Q):O?this._htmlSettingsSugOnly(t):this._htmlSettingsSection(t),X=(this._lockedSuggestions||[]).length,ct=X?` +
+ \u{1F515} ${this._t("msg.n_suggestions_muted",{count:X},`${X} suggestion${X>1?"s":""} muted; the auto-tuner will not propose these.`)} + +
`:"";return` + ${e} +
+
${this._t("tab.settings",{},"Settings")}${this._mlSettingsLoading?` ${this._t("msg.ml_loading",{},"loading ML\u2026")}`:""}
+ ${H} +
+ ${C}${E}${ct}${b} +
+ ${J} +
${u}
+ ${v} +
+
+
${G}
+
+ + + +
+

${this._t("msg.saving_triggers_reload",{},"Saving triggers an integration reload. HA entities may briefly show as unavailable.")}

+
+ ${this._htmlSettingsHistory()} + `}_htmlSettingsHistory(){const t=this._settingsChangelog||[];if(!t.length)return"";const e=this._settingsHistoryOpen,s=this._canEdit(),r=t.slice(0,100).map(n=>{const a=s?``:"";return` + ${d(this._t("setting."+n.key+".label",{},n.key))} + ${d(Tt(n.old))} \u2192 ${d(Tt(n.new))} + ${st(n.timestamp)} + ${a} + `}).join("");return`
+
+
${this._t("hdr.settings_history",{},"Settings history")} (${t.length})
+ ${e?"\u25B2":"\u25BC"} +
+ ${e?`
+ + + + + ${r}
${this._t("lbl.setting",{},"Setting")}${this._t("lbl.change",{},"Change")}${this._t("lbl.date",{},"Date")}
`:""} +
`}_renderField(t,e){if(t.onlyDeviceType&&(e.device_type||"washing_machine")!==t.onlyDeviceType)return"";if(t.type==="storebrand"||t.type==="storemodel")return this._renderStorePicker(t,e);let s=e[t.key];if(s===void 0){const l=this._optDefaults;s=l&&l[t.key]!=null?l[t.key]:t.def}const r={};if(t.type==="devicetype")r.opts=this._deviceTypeOpts(s||e.device_type);else if(t.type==="device")r.opts=this._deviceOpts();else if(t.type==="select")r.opts=t.opts||[];else if(t.type==="entity"){const l=this._hass&&this._hass.states?this._hass.states:{},c=t.domain==="binary_sensor"?["binary_sensor","sensor"]:t.domain?[t.domain]:null,_=Object.keys(l).filter(p=>!c||c.some(g=>p.startsWith(g+"."))).sort().slice(0,500);this._entityListCache||(this._entityListCache={}),this._entityListCache[t.key]=_}else if(t.type==="entitylist"){const l=this._hass&&this._hass.states?this._hass.states:{},c=Object.keys(l).filter(_=>!t.domain||_.startsWith(t.domain+".")).sort();if(t.domain==="notify"&&this._hass&&this._hass.services&&this._hass.services.notify){const _=Object.keys(this._hass.services.notify).map(p=>`notify.${p}`);r.entities=[...new Set([...c,..._])].sort().slice(0,500)}else r.entities=c.slice(0,500);this._entityListCache||(this._entityListCache={}),this._entityListCache[t.key]=r.entities}else if(Array.isArray(t.suggestions)&&t.suggestions.length){const l=`wd-dl-${t.key}`;r.datalistId=l,r.datalist=`${t.suggestions.map(c=>``}const n=this._suggestions.find(l=>l.key===t.key);if(n){const l=Object.assign({},n.reason_params||{});n.exclusions&&n.exclusions.total&&(l.excl=this._exclNote(n.exclusions)),r.suggestion={suggested:n.suggested,current:n.current,reason:n.reason,reason_key:n.reason_key,reason_params:l}}const a=(this._mlSettings||{})[t.key];a&&a.ml_value!=null&&!(this._lockedSuggestions||[]).includes(t.key)&&(r.mlSuggestion={value:a.ml_value,reason:a.ml_reason,reason_key:a.ml_reason_key,reason_params:a.ml_reason_params}),r.useBtnLabel=this._t("btn.use",{},"Use"),r.t=this._t.bind(this);const o=(this._settingsChangeByKey||{})[t.key];o&&(r.changed=this._t("msg.setting_changed",{old:Tt(o.old),new:Tt(o.new),date:st(o.timestamp)},`Changed from ${Tt(o.old)} to ${Tt(o.new)} on ${st(o.timestamp)}`));const i=Object.assign({},t,{label:this._t("setting."+t.key+".label",{},t.label||""),doc:t.doc!=null?this._t("setting."+t.key+".doc",{},t.doc):t.doc});return _e(i,s,r)}_renderStorePicker(t,e){const s=t.type==="storebrand",r=t.key,n=String(e[r]==null?"":e[r]),a=this._t("setting."+r+".label",{},t.label||""),o=this._t("setting."+r+".doc",{},t.doc||""),i=d(this._t("placeholder."+r,{},s?"e.g. Bosch":"e.g. WAT28660"));return this._onlineEnabled()?s?this._renderBrandPicker(r,n,a,o,i):this._renderModelPicker(r,e,n,a,o,i):`
+ +
${d(this._t("msg.store_picker_offline",{},"Enable online features in the settings gear to pick from the community catalog."))}
`}_statusTag(t){if(t&&t.status==="pending"){const e=d(this._t("badge.awaiting_tip",{},"Awaiting community approval")),s=typeof t.confirmCount=="number"?this._t("badge.awaiting_n",{n:t.confirmCount},`Awaiting approval \xB7 ${t.confirmCount} confirmed`):this._t("badge.awaiting",{},"Awaiting approval");return`${s}`}return t&&t.status==="approved"?`${this._t("badge.approved",{},"Approved")}`:""}_renderBrandPicker(t,e,s,r,n){this._ensureCatalogEntry();const a=Array.isArray(this._catalog.brands)?this._catalog.brands:[];this._entityListCache=this._entityListCache||{},this._entityListCache[t]=a.map(l=>l.brand).filter(Boolean);const o=this._statusTag(this._catalogEntryFor(e,"brand")),i=this._catalog.brands===null?` ${this._t("msg.loading",{},"Loading\u2026")}`:"";return`
+
+
+ + +
+ +
`}_renderModelPicker(t,e,s,r,n,a){const o=String(e.store_brand||"");if(!o)return`
+ +
${d(this._t("msg.pick_brand_first",{},"Pick an appliance brand first."))}
`;this._ensureCatalogEntry(),this._catalog.forBrand!==o&&(this._catalog.forBrand=o,this._catalog.devices=void 0);const i=Array.isArray(this._catalog.devices)?this._catalog.devices:[];this._entityListCache=this._entityListCache||{},this._entityListCache[t]=i.map(u=>u.model).filter(Boolean);const l=this._catalogEntryFor(s,"device"),c=this._statusTag(l),_=this._catalog.devices===null?` ${this._t("msg.loading",{},"Loading\u2026")}`:"";let p="";if(l){const u=[],v=pe(l.manualUrl);v&&u.push(`${this._t("link.manual",{},"Manual \u2197")}`),(this._constants&&this._constants.storePrefs||{}).show_contributor!==!1&&u.push(this._t("store.contributed_by",{name:d(l.createdByName||this._t("lbl.anonymous",{},"Anonymous"))},`by ${d(l.createdByName||"Anonymous")}`));const b=!!(this._storeStatus&&this._storeStatus.connected);let $="";if(b){const k=[1,2,3,4,5].map(S=>``).join("");$=` + ${k}`}else $=`${this._t("msg.connect_to_confirm",{},"Connect in the settings gear to confirm or rate.")}`;p=`
${u.join(" \xB7 ")}
${$}
`}const g=this._onlineEnabled()&&this._storeDeviceDeclared()?`
+ + +
`:"";return`
+
+
+ + +
+ +
+ ${p}${g}
`}_storeApplianceType(){return{washing_machine:"washer",washer:"washer",dryer:"dryer",dishwasher:"dishwasher",washer_dryer:"washer_dryer"}[this._opts.device_type]||""}_storeDeviceDeclared(){return!!((this._opts.store_brand||"").trim()&&(this._opts.store_model||"").trim())}_shareableByProgram(){const t=new Map;for(const s of this._shareableCycles||[]){const r=(s.profile_name||"").trim();r&&(t.has(r)||t.set(r,[]),t.get(r).push(s))}const e=Array.from(t.entries()).map(([s,r])=>({program:s,cycles:r})).sort((s,r)=>s.program.localeCompare(r.program));for(const s of this._shareAllPrograms||[])t.has(s)||e.push({program:s,cycles:[],noCycles:!0});return e}_catalogEntryKey(){const t=this._opts||{};return[(t.store_brand||"").trim().toLowerCase(),(t.store_model||"").trim().toLowerCase(),t.device_type||""].join("|")}_ensureCatalogEntry(){const t=this._catalogEntryKey(),e=this._catalogEntry;e&&e.key===t||(this._catalogEntry={key:t,brand:null,device:null,deviceId:null,loading:!0},this._loadCatalogEntry(t))}_catalogEntryFor(t,e){const s=this._catalogEntry;if(!s||s.key!==this._catalogEntryKey())return null;const r=e==="brand"?s.brand:s.device;if(!r)return null;const n=e==="brand"?r.brand:r.model;return String(n||"").toLowerCase()===String(t||"").trim().toLowerCase()?r:null}async _loadCatalogEntry(t){const e=this._devices[this._selIdx],s=this._opts||{},r=(s.store_brand||"").trim(),n=(s.store_model||"").trim();if(!e||!this._onlineEnabled()||!r||!n){this._catalogEntry&&this._catalogEntry.key===t&&(this._catalogEntry.loading=!1);return}let a=null;try{a=await this._ws({type:`${y}/store_get_catalog_entry`,entry_id:e.entry_id,brand:r,model:n,appliance_type:s.device_type||""})}catch{}!this._catalogEntry||this._catalogEntry.key!==t||(this._catalogEntry={key:t,loading:!1,brand:a&&a.brand||null,device:a&&a.device||null,deviceId:a&&a.device_id||null},this._isActiveEntry(e.entry_id)&&this._renderPreservingFormEdits())}_refreshComboAfterLoad(t,e,s=!0){const r=this.shadowRoot&&this.shadowRoot.getElementById(t);if(r&&this.shadowRoot.activeElement===r){s&&r.dispatchEvent(new Event("input",{bubbles:!0}));return}this._isActiveEntry(e)&&this._render()}_ensureCatalogList(t,e){if(this._onlineEnabled()){if(t==="store_brand"){this._ensureBrandCandidates(e);return}if(t==="store_model"){const s=String((this._opts||{}).store_brand||"").trim();if(!s)return;(this._catalog.forBrand!==s||this._catalog.devices===void 0)&&(this._catalog.forBrand=s,this._catalog.devices=null,this._loadCatalogDevices(s))}}}_ensureBrandCandidates(t){const e=String(t||"").trim().toLowerCase();if(this._catalog.brandsFull)return;if(!e){clearTimeout(this._brandSearchTimer),this._brandSearchTimer=null,this._catalog.brands===void 0&&(this._catalog.brands=null,this._loadCatalogBrands(""));return}(this._catalog.brandPrefixes||(this._catalog.brandPrefixes=[])).some(r=>e.startsWith(r))||(clearTimeout(this._brandSearchTimer),this._brandSearchTimer=setTimeout(()=>this._loadCatalogBrands(e),250))}_mergeBrandCandidates(t){const e=new Map((Array.isArray(this._catalog.brands)?this._catalog.brands:[]).map(s=>[String(s.id!=null?s.id:s.brand),s]));for(const s of t||[])e.set(String(s.id!=null?s.id:s.brand),s);this._catalog.brands=Array.from(e.values()).sort((s,r)=>String(s.brand||"").localeCompare(String(r.brand||"")))}async _loadCatalogBrands(t=""){this._entityListCache=this._entityListCache||{};let e=!1;const s=this._devices[this._selIdx];if(!s||!this._onlineEnabled()){this._catalog.brands=[],this._entityListCache.store_brand=[];return}if(!(t&&this._catalog.brandsFull)){this._catalog.brands===void 0&&(this._catalog.brands=null);try{const r=await this._ws({type:`${y}/store_list_brands`,entry_id:s.entry_id,query:t||null,include_pending:!this._catalog.approvedOnly}),n=r&&r.items||[];t?(this._mergeBrandCandidates(n),(this._catalog.brandPrefixes||(this._catalog.brandPrefixes=[])).push(t)):(this._catalog.brands=n,this._catalog.brandsFull=!0)}catch{Array.isArray(this._catalog.brands)||(this._catalog.brands=[]),e=!0}this._entityListCache.store_brand=(this._catalog.brands||[]).map(r=>r.brand).filter(Boolean),this._refreshComboAfterLoad("wd-store-brand",s.entry_id,!e)}}async _loadCatalogDevices(t){this._entityListCache=this._entityListCache||{};const e=this._devices[this._selIdx];if(!e||!this._onlineEnabled()||!t){this._catalog.devices=[],this._entityListCache.store_model=[];return}try{const s=await this._ws({type:`${y}/store_search_devices`,entry_id:e.entry_id,query:t,appliance_type:this._storeApplianceType(),include_pending:!this._catalog.approvedOnly});this._catalog.forBrand===t&&(this._catalog.devices=s&&s.items||[])}catch{this._catalog.forBrand===t&&(this._catalog.devices=[])}this._entityListCache.store_model=(this._catalog.devices||[]).map(s=>s.model).filter(Boolean),this._refreshComboAfterLoad("wd-store-model",e.entry_id)}async _loadShareProfiles(){const t=this._devices[this._selIdx],e=this._modal;if(!t||!e||e.type!=="store-share")return;const s=(this._opts.store_brand||"").trim(),r=(this._opts.store_model||"").trim();if(!s||!r){e.profiles=[],this._modal===e&&this._render();return}try{const n=await this._ws({type:`${y}/store_get_device_profiles`,entry_id:t.entry_id,brand:s,model:r,appliance_type:this._opts.device_type||""});if(this._modal!==e)return;e.profiles=n&&n.items||[],e.deviceId=n&&n.device_id||null}catch{this._modal===e&&(e.profiles=[])}this._modal===e&&this._render()}_htmlAutomations(){const t=this._deviceAutomations||[],e=t.length?t.map(n=>`\u{1F517} ${d(n.name)}${n.enabled?"":' (off)'}`).join(""):`${this._autoLoading?this._t("msg.loading",{},"Loading\u2026"):this._t("hdr.no_automations",{},"No automations reference this device yet.")}`,s=Array.isArray(this._opts.notify_actions)?this._opts.notify_actions:[],r=s.length?` +
+
${this._t("msg.legacy_actions_title",{count:s.length},`${s.length} legacy custom action${s.length>1?"s":""} still running`)}
+

${this._t("msg.old_actions_warning",{},"Configured with the old actions editor (now removed). They still fire on cycle events but can no longer be edited here. Convert them into a normal automation, or remove them.")}

+
+ + +
+
`:"";return` +
${this._t("hdr.automations",{},"Automations")}
+

${this._t("msg.automations_intro",{start:"ha_washdata_cycle_started",end:"ha_washdata_cycle_ended"},"WashData fires {start} / {end} events and exposes entities, so notifications and actions are best built as normal Home Assistant automations. Automations that use this device appear below.")}

+ ${r} +
${e}
+
+ +
+ ${this._t("btn.from_template",{},"From template \u25BE")} +
+ + +
+
+
`}async _loadDeviceAutomations(t){this._deviceAutomations=[];const e=this._hass;if(!(!e||!e.callWS))try{let s=null;const r=e.devices||{};for(const i of Object.values(r))if((i.config_entries||[]).includes(t)){s=i.id;break}const n=s?await e.callWS({type:"search/related",item_type:"device",item_id:s}):await e.callWS({type:"search/related",item_type:"config_entry",item_id:t});if(!this._isActiveEntry(t))return;const a=n&&n.automation||[],o=e.states||{};this._deviceAutomations=a.map(i=>{const l=o[i]&&o[i].attributes||{};return{entity_id:i,id:l.id,name:l.friendly_name||i,enabled:o[i]?o[i].state==="on":!0}}).filter(i=>i.id)}catch{this._isActiveEntry(t)&&(this._deviceAutomations=[])}}_navigate(t){try{history.pushState(null,"",t),this.dispatchEvent(new CustomEvent("location-changed",{bubbles:!0,composed:!0,detail:{replace:!1}}))}catch{try{window.location.assign(t)}catch{}}}async _newAutomationFromEvent(t){const e=this._devices[this._selIdx];if(!e)return;const s=this._hass,r=t==="started"?"ha_washdata_cycle_started":"ha_washdata_cycle_ended",n=t==="started"?"started":"finished",a={alias:`${e.title||"WashData"}: cycle ${n}`,description:`Runs when the WashData ${e.title||""} cycle ${n}. Add your actions (notify, lights, ...).`,mode:"single",trigger:[{platform:"event",event_type:r,event_data:{entry_id:e.entry_id}}],condition:[],action:[]},o="washdata_"+Date.now().toString(36);try{s&&s.callApi?(await s.callApi("POST","config/automation/config/"+o,a),this._navigate("/config/automation/edit/"+o)):this._navigate("/config/automation/edit/new")}catch(i){this._showToast(this._t("msg.toast_automation_failed",{error:i.message||i},"Could not create automation: "+(i.message||i)),"error")}}async _convertLegacyActions(){const t=this._devices[this._selIdx],e=this._hass,s=Array.isArray(this._opts.notify_actions)?this._opts.notify_actions:[];if(!t||!s.length)return;const r={alias:`${t.title||"WashData"}: migrated custom actions`,description:"Migrated from WashData legacy custom actions (which ran on cycle start, finish and live). Trim the triggers as needed. Note: old {device}/{duration}-style placeholders are NOT templated here - replace them with Jinja templates such as {{ trigger.event.data.device_name }}.",mode:"single",trigger:[{platform:"event",event_type:"ha_washdata_cycle_started",event_data:{entry_id:t.entry_id}},{platform:"event",event_type:"ha_washdata_cycle_ended",event_data:{entry_id:t.entry_id}}],condition:[],action:s},n="washdata_"+Date.now().toString(36);if(!e||!e.callApi){this._showToast(this._t("msg.toast_no_automation",{},"Cannot create automation here"),"error");return}let a=!1;try{await e.callApi("POST","config/automation/config/"+n,r),a=!0,await this._ws({type:`${y}/set_options`,entry_id:t.entry_id,options:{notify_actions:[]}}),this._isActiveEntry(t.entry_id)&&(this._opts={...this._opts,notify_actions:[]}),this._showToast(this._t("msg.toast_automation_migrated",{},"Actions migrated to an automation; opening editor")),this._navigate("/config/automation/edit/"+n)}catch(o){const i=o.message||o;if(!a){this._showToast(this._t("msg.toast_convert_failed",{error:i},"Convert failed: "+i),"error");return}let l=null;try{const c=await this._ws({type:`${y}/get_options`,entry_id:t.entry_id}),_=c&&c.options||{},p=Array.isArray(_.notify_actions)?_.notify_actions:[];l=p.length>0,this._isActiveEntry(t.entry_id)&&(this._opts={...this._opts,notify_actions:p})}catch{l=null}if(l===!1){this._showToast(this._t("msg.toast_automation_migrated",{},"Actions migrated to an automation; opening editor")),this._navigate("/config/automation/edit/"+n);return}if(l===!0){try{await e.callApi("DELETE","config/automation/config/"+n),this._showToast(this._t("msg.toast_convert_rolled_back",{error:i},"Migration failed and was rolled back (no automation left behind): "+i),"error")}catch{this._showToast(this._t("msg.toast_convert_orphan",{},"The automation was created, but clearing the old actions failed. Do not retry: remove the legacy actions manually to avoid a duplicate automation."),"error")}return}this._showToast(this._t("msg.toast_convert_orphan",{},"The automation was created, but clearing the old actions failed. Do not retry: remove the legacy actions manually to avoid a duplicate automation."),"error")}}_htmlSettingsSection(t){const e=this._opts&&this._opts.device_type||"",s=this._settingsLevel()==="basic",r=l=>!(l.id==="ml_training"||e&&l.notDeviceTypes&&l.notDeviceTypes.includes(e)||e&&l.onlyDeviceTypes&&!l.onlyDeviceTypes.includes(e)||s&&!this._secHasBasicFields(l)),n=mt.find(l=>l.id===this._settingsSec&&r(l))||mt.find(l=>r(l))||mt[0],a=n.intro||this._t("section."+n.id+".intro",{},"")?`

${d(this._t("section."+n.id+".intro",{},n.intro||""))}

`:"",o="";if(n.id==="notifications"){const l=`

${this._t("msg.notify_services_hint",{entity:"notify.<name>",vars:""+d($t)+""},"Use {entity} service IDs (comma-separated for multiple). Template variables: {vars}.")}

`,c=n.groups.map(p=>{const g=(p.fields||[]).filter(u=>this._settingFieldVisible(u)).map(u=>this._renderField(u,t)).filter(Boolean).join("");return g?`
${d(this._t("setting_group."+te(p.sub)+".label",{},p.sub))}
${g}
`:""}).join("");return`${s?"":this._htmlAutomations()}${l}${c}`}if(n.groups){const l=n.groups.map(c=>{const _=c.sub?`
${d(this._t("setting_group."+te(c.sub)+".label",{},c.sub))}
`:"",p=(c.fields||[]).filter(g=>this._settingFieldVisible(g)).map(g=>this._renderField(g,t)).filter(Boolean).join("");return p?`${_}
${p}
`:""}).join("");return`${a}${o}${l}`}const i=(n.fields||[]).filter(l=>this._settingFieldVisible(l)).map(l=>this._renderField(l,t)).filter(Boolean).join("");return`${a}${o}
${i}
`}_htmlSettingsSearch(t,e){const s=this._opts&&this._opts.device_type||"",r=mt.filter(i=>!(i.id==="ml_training"||s&&i.notDeviceTypes&&i.notDeviceTypes.includes(s)||s&&i.onlyDeviceTypes&&!i.onlyDeviceTypes.includes(s))),n=i=>`${i.label||""} ${i.key||""} ${i.doc||""} ${i.hint||""}`.toLowerCase().includes(e);let a="",o=0;for(const i of r){const c=(i.fields||(i.groups||[]).flatMap(p=>p.fields||[])).filter(n);if(!c.length)continue;const _=c.map(p=>this._renderField(p,t)).filter(Boolean).join("");_&&(o+=c.length,a+=`
${d(this._t("section."+i.id+".label",{},i.label))}
${_}
`)}return o?a:`

${this._t("msg.no_settings_match",{q:e},`No settings match "${d(e)}"`)}

`}_mlSugKeys(t){const e=t||Object.assign({},this._optDefaults,this._opts,this._pendingSettings||{});return this._mlSugKeysFrom(this._mlSettings,e,this._lockedSuggestions)}_mlSugKeysFrom(t,e,s){const r=new Set(s||[]),n=e||{},a=new Set;for(const[o,i]of Object.entries(t||{}))r.has(o)||i&&i.ml_value!=null&&!Ot(i.ml_value,n[o])&&a.add(o);return a}_sugCountsForDevice(t){if(!t)return{classic:0,ml:0,total:0};const e=this._devices[this._selIdx],s=!!(e&&e.entry_id===t.entry_id),r=Array.isArray(t.suggestion_keys)?t.suggestion_keys:null,n=r?r.length:t.suggestions_count||0,a=s?Object.assign({},t.option_defaults||{},t.options||{},this._opts,this._pendingSettings||{}):Object.assign({},t.option_defaults||{},t.options||{}),o=this._mlSugKeysFrom(s?this._mlSettings:this._mlSettingsByEntry[t.entry_id],a,s?this._lockedSuggestions:this._lockedByEntry[t.entry_id]),i=r?[...o].filter(l=>!r.includes(l)).length:o.size;return{classic:n,ml:i,total:n+i}}_htmlSettingsSugOnly(t){const e=new Set((this._suggestions||[]).map(a=>a.key));for(const a of this._mlSugKeys(t))e.add(a);if(!e.size)return`

${this._t("msg.no_suggestions",{},"No active suggestions.")}

`;const s=this._opts&&this._opts.device_type||"",r=mt.filter(a=>!(a.id==="ml_training"||s&&a.notDeviceTypes&&a.notDeviceTypes.includes(s)||s&&a.onlyDeviceTypes&&!a.onlyDeviceTypes.includes(s)));let n="";for(const a of r){const i=(a.fields||(a.groups||[]).flatMap(c=>c.fields||[])).filter(c=>e.has(c.key));if(!i.length)continue;const l=i.map(c=>this._renderField(c,t)).filter(Boolean).join("");l&&(n+=`
${d(this._t("section."+a.id+".label",{},a.label))}
${l}
`)}return n||`

${this._t("msg.no_suggestions",{},"No active suggestions.")}

`}_htmlMlTab(){const t=this._opts;if(!Object.keys(t).length)return`
\u{1F916}
${this._t("msg.loading",{},"Loading\u2026")}
`;const e=this._mlTrainingStatus,s=this._devices[this._selIdx],r=s&&s.entry_id,n=mt.find(i=>i.id==="ml_training"),a=n?(n.fields||[]).map(i=>this._renderField(i,t)).filter(Boolean).join(""):"",o=this._busy.has("save-settings");return` +
${this._t("hdr.ml_smart_learning",{},"Smart Learning")}
+

${this._t("msg.ml_intro",{},"WashData ships with smart models that work out of the box.")}

+ + ${this._htmlMlStatusSection(e,r)} + +
+
${this._t("hdr.ml_settings_card",{},"Settings")}
+

${this._t("msg.ml_settings_intro",{},"Two independent switches: one applies the models while a cycle runs, the other lets WashData fine-tune them to your machine over time.")}

+
${a}
+
+ +
+

${this._t("msg.saving_triggers_reload",{},"Saving triggers an integration reload.")}

+
+ + ${this._htmlMlLearnedSection(e)} + ${this._htmlMatchingTuningCard()} + `}_htmlMlStatusSection(t,e){const s=e&&this._busy.has("ml-train-now:"+e)||t&&t.running,r=this._canEdit()?``:"";if(!t)return`
${this._t("hdr.status",{},"Status")}

${this._t("msg.loading",{},"Loading\u2026")}

`;const n=Object.keys(t.on_device_models||{}).length,a=n?`${this._t("ml.personalized",{},"\u25CF Personalized to this machine")} ${this._t("lbl.models_fine_tuned",{count:n},"(fine-tuned models: "+n+")")}`:`${this._t("ml.builtin_models",{},"\u25CF Using built-in models")}`,o=t.cycle_count||0,i=t.min_cycles||0,l=o>=i,c=i>0?Math.min(100,Math.round(o/i*100)):100,_=l?"var(--success-color,#4caf50)":"var(--warning-color,#ff9800)",p=Math.max(0,i-o),g=l?this._t("msg.enough_data",{current:o,min:i},`Enough data to learn from (${o}/${i} cycles).`):this._t("msg.collecting_data",{need:p,current:o,min:i},`Collecting data. Cycles still needed before fine-tuning can start: ${p} (${o}/${i}).`),u=`
`,v=t.last_trained?st(t.last_trained):"never",b=s?` ${this._t("status.fine_tuning",{},"fine-tuning now\u2026")}`:t.enabled?this._t("lbl.auto_fine_tune_on",{hour:String(t.hour).padStart(2,"0")},`auto fine-tune on (around ${String(t.hour).padStart(2,"0")}:00)`):this._t("lbl.auto_fine_tune_off",{},"auto fine-tune off");return`
+
+
${this._t("hdr.status",{},"Status")}
${r} +
+
${a}
+

${g}

+ ${u} +

${this._t("lbl.last_checked",{},"Last checked:")} ${d(v)} \xB7 ${b}

+
`}_htmlMlLearnedSection(t){if(!t)return"";const e=t.on_device_models||{},s=Object.keys(e),r=this._busy.has("ml-revert-models");let n;if(!s.length)n=`

${this._t("msg.no_fine_tuned",{},"Nothing fine-tuned yet \u2014 WashData is using its built-in models.")}

`;else{const a=s.map(i=>{const l=e[i]||{},c=l.trained_at?st(l.trained_at):"unknown";return`
+
+
${d(l.label_key?this._t(l.label_key,{},l.label||i):l.label||i)}${this._mlTrendBadge(l.trend)}
+
${d(l.blurb_key?this._t(l.blurb_key,{},l.blurb||""):l.blurb||"")} \xB7 ${this._t("ml.fine_tuned_at",{when:d(c)},"fine-tuned "+d(c))}
+
+ ${this._mlQualityChip(l)} +
`}).join(""),o=this._canEdit()?``:"";n=`
${a}
${o}`}return`
+
${this._t("hdr.ml_learned",{},"What WashData has learned")}
+

${this._t("msg.ml_learned_intro",{},"Models fine-tuned to this machine.")}

+ ${n} +
`}_mlQualityChip(t){let e=0,s="",r=t.metric_key?this._t(t.metric_key,t.metric_params||{},t.metric||""):t.metric||"";if(t.auc!=null)e=Math.max(0,Math.min(1,(t.auc-.5)/.5))*100,s=t.auc>=.85?this._t("ml.fit_strong",{},"Strong"):t.auc>=.75?this._t("ml.fit_good",{},"Good"):t.auc>=.65?this._t("ml.fit_fair",{},"Fair"):this._t("ml.fit_weak",{},"Weak");else if(t.model_mae!=null&&t.naive_mae!=null&&t.naive_mae>0){const a=Math.max(0,(t.naive_mae-t.model_mae)/t.naive_mae);e=Math.min(1,a)*100,s=a>=.5?this._t("ml.fit_strong",{},"Strong"):a>=.2?this._t("ml.fit_good",{},"Good"):this._t("ml.fit_slight",{},"Slight"),r=this._t("ml.better_than_baseline",{pct:(a*100).toFixed(0),metric:r},`${(a*100).toFixed(0)}% better than the baseline estimate (${r})`)}else return"";const n=e>=70?"var(--success-color,#4caf50)":e>=40?"var(--warning-color,#ff9800)":"var(--secondary-text-color)";return`
+
${s} ${this._t("ml.fit_word",{},"fit")}
+
+
`}_mlTrendBadge(t){if(!t)return"";const s={improving:[this._t("badge.improving",{},"\u2197 improving"),"var(--success-color,#4caf50)",this._t("ml.trend_improving_tip",{},"This model's fit has improved across recent re-checks.")],declining:[this._t("badge.declining",{},"\u2198 declining"),"var(--warning-color,#ff9800)",this._t("ml.trend_declining_tip",{},"This model's fit has slipped across recent re-checks \u2014 reviewing more cycles may help it re-learn.")],steady:[this._t("badge.steady",{},"\u2192 steady"),"var(--secondary-text-color)",this._t("ml.trend_steady_tip",{},"This model's fit has held roughly steady across recent re-checks.")]}[t];return s?` ${s[0]}`:""}_htmlMatchingTuningCard(){const t=this._mlTrainingStatus,e=t&&t.matching;if(!e)return"";const s=e.defaults||{},r=e.tuned||null,n=r&&r.config||null,a=e.active==="tuned"&&n,o=this._busy.has("ml-revert-match"),i=g=>g==null||isNaN(g)?"-":Number(g).toFixed(2),l=[["corr_weight","Shape (correlation)"],["duration_weight","Duration agreement"],["energy_weight","Energy agreement"],["dtw_ensemble_w","DTW derivative blend (DDTW)"]].map(([g,u])=>{const v=s[g],b=a?n[g]:s[g],$=a&&v!=null&&b!=null&&Math.abs(v-b)>1e-9;return` + ${u} + ${i(v)} + ${i(b)} + `}).join(""),c=a?`${this._t("badge.using_tuned",{},"Using tuned weights")}`:`${this._t("badge.using_defaults",{},"Using shipped defaults")}`;let _="";if(a){const g=r.trained_at?st(r.trained_at):"unknown",u=r.baseline_test_top1,v=r.tuned_test_top1,b=u!=null&&v!=null?` \xB7 held-out top-1 ${(u*100).toFixed(0)}% \u2192 ${(v*100).toFixed(0)}%`:"";_=`

Tuned ${d(g)} from ${r.cycle_count||0} cycles${b}.

`}const p=a?``:"";return`
+
+
${this._t("hdr.ml_matching_tuning",{},"Program-matching fine-tuning")}
${p} +
+

${this._t("msg.matching_tuning_intro",{},"When learning, WashData also adjusts how much program matching weighs shape versus duration and energy.")}

+
${c}
+ + + ${l} +
${this._t("lbl.emphasis",{},"Emphasis")}${this._t("lbl.default",{},"Default")}${this._t("lbl.in_use",{},"In use")}
+ ${_} +
`}_pgOverrideFields(){return[["start_threshold_w","Start Threshold","W","Minimum watts to count as started","detection"],["stop_threshold_w","Stop Threshold","W","Below this, machine counts as off","detection"],["off_delay","Off Delay","s","Seconds of low power before cycle ends","timing"],["min_off_gap","Min Off Gap","s","Gap required to separate two cycles","timing"],["completion_min_seconds","Min Cycle Duration","s","Shortest run that counts as a real cycle","timing"],["start_duration_threshold","Start Duration","s","Seconds above threshold to confirm start","timing"],["end_repeat_count","End Repeat Count","","Low readings in a row before ending","advanced"],["interrupted_min_seconds","Interrupted Min","s","Short cycles flagged as interrupted","advanced"],["anti_wrinkle_enabled","Enable Anti-Wrinkle Detection","","Absorb the tumble pulses after the main phase instead of reading them as new cycles","advanced","bool"],["anti_wrinkle_max_power","Max Anti-Wrinkle Power","W","A pulse above this ends anti-wrinkle and opens a new cycle","advanced"],["anti_wrinkle_max_duration","Max Anti-Wrinkle Duration","s","A pulse longer than this ends anti-wrinkle and opens a new cycle","advanced"],["anti_wrinkle_exit_power","Anti-Wrinkle Exit Power","W","Power must fall below this between pulses for anti-wrinkle to stay active","advanced"],["anti_wrinkle_idle_timeout","Max Pulse Gap","s","Quiet time allowed between two tumble pulses before anti-wrinkle ends","advanced"],["dishwasher_end_spike_quiet_release","Passive-Dry Quiet Release","s","Dishwasher: quiet seconds after expected duration before the end-of-cycle drain wait is released","advanced"],["smart_termination_duration_ratio","Smart Termination Ratio","","Fraction of the matched program's expected duration a cycle must reach before Smart Termination may end it early; lower it for load- or temperature-dependent machines","advanced"],["profile_match_min_duration_ratio","Min Duration Ratio","","Stage 1: shortest run (vs the profile) still allowed to match","matching"],["profile_match_max_duration_ratio","Max Duration Ratio","","Stage 1: longest run (vs the profile) still allowed to match","matching"],["corr_weight","Correlation Weight","","Stage 2: balance between curve shape (correlation) and power level (MAE); default 0.45","matching"],["keep_min_score","Keep Min Score","","Stage 2: floor score to stay in the race; default 0.1 admits weak matches, later stages pick the best","matching"],["dtw_bandwidth","DTW Bandwidth","","Stage 3: Sakoe-Chiba warp band (0 = DTW off; default 0.2 = 20% of cycle length)","matching"],["dtw_blend","DTW Blend","","Stage 3: 0 = core score only, 1 = DTW score only, 0.5 = equal blend (default)","matching"],["dtw_ensemble_w","DTW Ensemble Weight","","Stage 3 (ensemble mode): weight on scaled-L1 vs derivative DTW; default 0.7 favours level-aware","matching"],["dtw_ddtw_scale","DDTW Scale","","Stage 3: DDTW half-saturation distance; smaller = more shape-sensitive (default 30)","matching"],["dtw_refine_top_n","DTW Refine Top-N","","Stage 3: candidates DTW re-scores; raise to 7-9 if correct profile ranks 4th-5th (default 5)","matching"],["duration_weight","Duration Weight","","Stage 4: how strongly run-length agreement affects the final score (default 0.22)","matching"],["energy_weight","Energy Weight","","Stage 4: how strongly energy agreement affects the final score (default 0.22)","matching"],["duration_scale","Duration Scale","","Stage 4: log-ratio where duration agreement halves; smaller = stricter penalty (default 0.175)","matching"],["energy_scale","Energy Scale","","Stage 4: log-ratio where energy agreement halves; smaller = stricter penalty (default 0.25)","matching"]]}_pgFieldVal(t,e){const s=e||{};if(s[t]!==void 0)return s[t];const r=this._pgEffective||{};if(r[t]!==void 0&&r[t]!==null)return r[t];const n=this._opts||{};if(n[t]!==void 0&&n[t]!==null)return n[t];const a=kt[t]||{};if(a.def!==void 0)return a.def;const o=this._constants&&this._constants.pgMatchDefaults||{};return o[t]!==void 0?o[t]:qt[t]!==void 0?qt[t]:""}async _pgFetchSettings(t,e=!0){try{const s=await this._ws({type:`${y}/get_playground_settings`,entry_id:t,include_suggestions:e});if(!this._isActiveEntry(t))return;this._pgEffective=s.effective||{},this._pgPublishable=Array.isArray(s.publishable)?s.publishable:null,this._pgPresets=Array.isArray(s.presets)?s.presets:[],this._pgPresetLimit=s.preset_limit||0,this._pgPresetSel&&!this._pgPresets.some(r=>r.name===this._pgPresetSel)&&(this._pgPresetSel=""),e&&this._pgApplySuggestions(s)}catch(s){this._pgIsUnknownCmd(s)&&(this._pgNeedsRestart=!0)}}_pgApplySuggestions(t){this._pgSuggClassic=t.classic_suggestions&&typeof t.classic_suggestions=="object"?t.classic_suggestions:{},this._pgSuggMl=t.ml_suggestions&&typeof t.ml_suggestions=="object"?t.ml_suggestions:null,this._pgMlSuggEnabled=!!t.ml_suggestions_enabled}async _pgFetchSuggestions(t){try{const e=await this._ws({type:`${y}/get_playground_settings`,entry_id:t,include_suggestions:!0});if(!this._isActiveEntry(t))return;this._pgApplySuggestions(e),this._tab==="playground"&&this._render()}catch{}}_pgCurrentValues(){const t={};for(const[e]of this._pgOverrideFields()){const s=this._pgFieldVal(e,{});s!==""&&s!==null&&s!==void 0&&(t[e]=s)}this._pgThreshStart!=null&&(t.start_threshold_w=this._pgThreshStart),this._pgThreshStop!=null&&(t.stop_threshold_w=this._pgThreshStop);for(const[e,s]of Object.entries(this._pgParamOverrides||{}))t[e]=s;return t}_pgStagedVal(t){return t==="start_threshold_w"?this._pgThreshStart??void 0:t==="stop_threshold_w"?this._pgThreshStop??void 0:this._pgParamOverrides[t]}_pgSetStaged(t,e){t==="start_threshold_w"?this._pgThreshStart=e:t==="stop_threshold_w"?this._pgThreshStop=e:this._pgParamOverrides[t]=e}_pgClearStaged(t){t==="start_threshold_w"?this._pgThreshStart=null:t==="stop_threshold_w"?this._pgThreshStop=null:delete this._pgParamOverrides[t]}_pgChangedKeys(){const t=this._pgEffective||{},e=[];for(const[s]of this._pgOverrideFields()){const r=this._pgStagedVal(s);if(r==null)continue;const n=t[s];n!=null&&this._pgSameVal(r,n)||e.push(s)}return e}_pgSameVal(t,e){if(typeof t=="boolean"||typeof e=="boolean")return!!t==!!e;const s=parseFloat(t),r=parseFloat(e);return!isNaN(s)&&!isNaN(r)?Math.abs(s-r)<1e-9:String(t)===String(e)}_pgIsPublishable(t){return Array.isArray(this._pgPublishable)?this._pgPublishable.includes(t):!!kt[t]}_htmlPgControlPanel(){const t=this._pgChangedKeys(),e=this._canEdit(),s=!!this._pgEffective,r=t.filter(b=>this._pgIsPublishable(b)),n=s?t.length?`${this._t("msg.pg_n_changed",{n:t.length},t.length+" changed vs live settings")}`:`\u2713 ${this._t("msg.pg_matches_live",{},"Matches live settings")}`:`${this._t("msg.pg_live_unavailable",{},"Live settings unavailable \u2014 showing defaults.")}`,a=``+(this._pgPresets||[]).map(b=>``).join(""),o=!!this._pgPresetSel,i=this._pgPresetLimit>0&&(this._pgPresets||[]).length>=this._pgPresetLimit&&!(this._pgPresets||[]).some(b=>b.name===(this._pgPresetName||"").trim()),l=`
+ + + ${e?``:""} +
`,c=e?`
+ + + ${this._t("msg.pg_preset_limit",{n:this._pgPresetLimit},"Preset limit reached ("+this._pgPresetLimit+")")} +
`:"",_=e&&r.length?``:"",p=Object.keys(this._pgSuggClassic||{}).length,g=Object.keys(this._pgSuggMl||{}).length,u=p>0?``:"",v=this._pgMlSuggEnabled&&g>0?``:"";return`
+
+ ${this._t("hdr.pg_settings_source",{},"Settings source")} + ${n} +
+

${this._t("msg.pg_ctrl_intro",{},"Values start from this device's live integration settings. Edits stay in the Playground until you publish them.")}

+
+ + ${u}${v} + ${_} +
+ ${l} + ${c} +
`}_pgApplyPresetValues(t){this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={};const e=this._pgEffective||{};for(const[s,r]of Object.entries(t||{})){if(r==null)continue;const n=e[s];n!=null&&this._pgSameVal(r,n)||this._pgSetStaged(s,r)}}async _pgSavePreset(){const t=this._devices[this._selIdx],e=(this._pgPresetName||"").trim();!t||!this._canEdit()||!e||(this._pgPresets||[]).some(s=>s.name===e)&&!confirm(this._t("msg.pg_preset_overwrite",{name:e},`Overwrite the preset "${e}"?`))||(await this._busyRun("pg-preset-save",async()=>{try{const s=await this._ws({type:`${y}/save_playground_preset`,entry_id:t.entry_id,name:e,values:this._pgCurrentValues()});if(!this._isActiveEntry(t.entry_id))return;this._pgPresets=Array.isArray(s.presets)?s.presets:this._pgPresets,this._pgPresetSel=e,this._pgPresetName="",this._showToast(this._t("toast.pg_preset_saved",{name:e},`Preset "${e}" saved`))}catch(s){this._showToast(this._t("msg.toast_save_failed",{error:s.message||s},"Save failed: "+(s.message||s)),"error")}}),this._render())}async _pgDeletePreset(){const t=this._devices[this._selIdx],e=this._pgPresetSel;!t||!this._canEdit()||!e||confirm(this._t("msg.pg_preset_delete_confirm",{name:e},`Delete the preset "${e}"?`))&&(await this._busyRun("pg-preset-delete",async()=>{try{const s=await this._ws({type:`${y}/delete_playground_preset`,entry_id:t.entry_id,name:e});if(!this._isActiveEntry(t.entry_id))return;this._pgPresets=Array.isArray(s.presets)?s.presets:(this._pgPresets||[]).filter(r=>r.name!==e),this._pgPresetSel=""}catch(s){this._showToast(this._t("msg.toast_error",{error:s.message||s},"Error: "+(s.message||s)),"error")}}),this._render())}async _pgLoadLive(){const t=this._devices[this._selIdx];if(!t)return;const e=this._pgChangedKeys();e.length&&!confirm(this._t("msg.pg_load_live_confirm",{n:e.length},`Discard ${e.length} Playground edit(s) and reload the integration's current settings?`))||(await this._busyRun("pg-load-live",async()=>{this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={},await this._pgFetchSettings(t.entry_id)}),this._render(),requestAnimationFrame(()=>this._pgDrawCanvas()))}_pgLoadSuggested(t){const e=t==="ml"?this._pgSuggMl||{}:this._pgSuggClassic||{},s=this._pgEffective||{};let r=0;for(const[n,a]of Object.entries(e)){if(a==null)continue;const o=s[n];o!=null&&this._pgSameVal(a,o)||(this._pgSetStaged(n,a),r++)}if(r===0)this._showToast(this._t("msg.pg_sugg_none",{},"All suggestions already match the current settings"));else{const n=t==="ml"?"toast.pg_sugg_ml_loaded":"toast.pg_sugg_loaded",a=t==="ml"?`Staged ${r} ML-calibrated value(s) - run the Playground to compare`:`Staged ${r} suggested value(s) - run the Playground to compare`;this._showToast(this._t(n,{n:r},a))}this._render(),requestAnimationFrame(()=>this._pgDrawCanvas())}async _pgPublishOne(t){const e=this._devices[this._selIdx];if(!e||!this._canEdit()||!t||!this._pgIsPublishable(t))return;const s=this._pgStagedVal(t);if(s==null)return;const r=this._t("setting."+t+".label",{},t);confirm(this._t("msg.pg_publish_one_confirm",{label:r,value:s},`Save ${r} = ${s} to this device's settings?`))&&(await this._busyRun("pg-publish-"+t,async()=>{try{if(await this._ws({type:`${y}/set_options`,entry_id:e.entry_id,options:{[t]:s}}),!this._isActiveEntry(e.entry_id))return;this._opts={...this._opts,[t]:s},this._pgEffective&&(this._pgEffective={...this._pgEffective,[t]:s}),this._pgClearStaged(t),this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading"))}catch(n){this._showToast(this._t("msg.toast_save_failed",{error:n.message||n},"Save failed: "+(n.message||n)),"error")}}),this._render())}_htmlPlayground(){if(!this._devices[this._selIdx])return`
${this._t("msg.no_device_selected",{},"No device selected.")}
`;const e=this._cycles||[],s=this._profiles||[],r=e.map(u=>{const v=u.profile_name||u.matched_profile||this._t("lbl.unlabelled",{},"Unlabelled"),b=u.duration?` \xB7 ${Math.round(u.duration/60)} min`:"",$=u.start_time?` \xB7 ${st(u.start_time)}`:"";return``}).join(""),n=``+s.map(u=>``).join(""),a=this._pgLoading,o=`
+
+
+
+ + ${a?``:""} +
+
`,i=a?`
+
${this._t("msg.pg_simulating",{},"Simulating cycle\u2026")}
`:"",l=!this._pgPowerPts&&!a?`
+
+
${this._t("msg.pg_canvas_empty2",{},"Pick a cycle above and press Run to simulate it. Then hover to read values, scroll to zoom, and drag to pan.")}
+
`:"",c=`${i}
${l}
`,_=this._htmlPgStrip(),p=this._pgNeedsRestart?`

\u26A0 ${this._t("msg.pg_restart_note",{},"Restart Home Assistant to enable simulation tools.")}

`:"",g=`${o}${c}${_} +
+
${this._htmlPgParamRows()}
+
${this._htmlPgAlerts()}${this._htmlPgAnalysis()}
+
`;return`
+
${this._t("hdr.playground",{},"Playground")}
+

${this._t("msg.playground_intro",{},"Explore how settings affect detection on your real cycle data. Nothing here changes live configuration until you explicitly apply it.")}

+ ${p} + ${g} + ${this._htmlPgDrawer()} +
`}_htmlPgDrawer(){const t=this._pgAnalysisTab||"history",s=`
+ ${[["history",this._t("lbl.pg_mode_history",{},"Test on history")],["sweep",this._t("lbl.pg_mode_optimize",{},"Optimize")]].map(([n,a])=>``).join("")} +
`,r=t==="sweep"?this._htmlPgSweepMode():this._htmlPgHistoryMode();return`
+
+ ${this._t("hdr.pg_across_cycles",{},"Across your cycles")} + ${s} +
+ ${r} +
`}_htmlPgParamRows(){const t=this._pgOverrideFields(),e=new Set(["start_threshold_w","stop_threshold_w"]),s={detection:"#2a78d6",timing:"#1baf7a",advanced:"#eda100",matching:"#a05cd6"},r={detection:this._t("lbl.pg_group_detection",{},"Detection triggers"),timing:this._t("lbl.pg_group_timing",{},"Timing rules"),advanced:this._t("lbl.pg_group_advanced",{},"Edge cases"),matching:this._t("lbl.pg_group_matching",{},"Program matching")};let n="";const a=t.map(([p,g,u,v,b,$])=>{const k=this._t("setting."+p+".label",{},g),S=this._pgFieldVal(p,{});let C;p==="start_threshold_w"?C=this._pgThreshStart??S:p==="stop_threshold_w"?C=this._pgThreshStop??S:C=this._pgParamOverrides[p]??S;const R=$==="bool",B=e.has(p),W=u||"",O=s[b]||"#2a78d6",D=r[b]||"";let A="";b&&b!==n&&(A=`
+
+ ${d(D)} +
`,n=b);const E=this._pgStagedVal(p),M=E!=null&&!(S!==""&&S!==null&&S!==void 0&&this._pgSameVal(E,S)),H=this._canEdit()&&M&&this._pgIsPublishable(p)?``:'';return`${A}
+
+
${d(k)}${B?` \u2195`:""}
+ ${v?`
${d(this._t("pg_desc."+p,{},v))}
`:""} +
+
+ ${R?``:``} + ${W?`${d(W)}`:""} + ${H} +
+
`}).join(""),i=`
+
+ ${d(this._t("lbl.pg_stress_group",{},"Idle termination test"))} +
`,l=`
+
+
${d(this._t("lbl.pg_stress_toggle",{},"Test idle termination"))}
+
${d(this._t("lbl.pg_stress_toggle_desc",{},"Simulates the appliance staying at its standby draw after recording ends \u2014 shows if and when WashData stops the cycle."))}
+
+ +
`,c=this._pgStressTail?`
+
+
${d(this._t("lbl.pg_stress_idle_w",{},"Idle level (W)"))}
+
${d(this._t("lbl.pg_stress_idle_w_desc",{},"Override the auto-detected standby floor (leave blank for auto)."))}
+
+
+ + W +
+
`:"",_=`${i}${l}${c}`;return`
+
${this._t("hdr.pg_detection_params",{},"Detection settings")}
+ ${this._htmlPgControlPanel()} + ${a}${_} +
+ + ${this._pgDetailBusy?'':""} +
+
`}_htmlPgAlerts(){const t=this._pgDetail;if(!t)return"";const e=t.outcome||{},s={error:"var(--error-color,#f44336)",warn:"var(--warning-color,#ff9800)",info:"var(--info-color,#2196f3)"},r=Array.isArray(t.alerts)?t.alerts:[],n=r.length?r.map(c=>`
+ ${d(this._pgAlertLabel(c.code))} +
${d(c.detail_key?this._t(c.detail_key,c.detail_params||{},c.detail||""):c.detail||"")}
+
`).join(""):`
\u2713 ${this._t("msg.pg_no_alerts",{},"No issues detected in this run.")}
`,a=e.termination_reason?String(e.termination_reason):"\u2014",o=e.final_duration_s?Math.round(e.final_duration_s/60)+" min":"\u2014",i=e.projected_energy_wh!=null?e.projected_energy_wh>=1e3?(e.projected_energy_wh/1e3).toFixed(2)+" kWh":Math.round(e.projected_energy_wh)+" Wh":"\u2014",l=(c,_)=>`
${d(_)}
${d(c)}
`;return`
+
${this._t("hdr.pg_outcome",{},"Simulation outcome")}
+
+ ${l(this._t("lbl.pg_ended",{},"Ended"),a)} + ${l(this._t("lbl.duration",{},"Duration"),o)} + ${l(this._t("lbl.pg_proj_energy",{},"Proj. energy"),i)} +
+
${n}
+
`}_pgAlertLabel(t){return{overrun:this._t("lbl.pg_alert_overrun",{},"Overrun"),underrun:this._t("lbl.pg_alert_underrun",{},"Underrun"),did_not_finish:this._t("lbl.pg_alert_did_not_finish",{},"Did not finish"),false_end:this._t("lbl.pg_alert_false_end",{},"Split into multiple cycles"),unmatched:this._t("lbl.pg_alert_unmatched",{},"Unmatched"),ambiguous:this._t("lbl.pg_alert_ambiguous",{},"Ambiguous match"),energy_anomaly:this._t("lbl.pg_alert_energy",{},"Energy anomaly"),timeout_end:this._t("lbl.pg_alert_timeout_end",{},"Ended by timeout, not prediction"),would_run_indefinitely:this._t("lbl.pg_alert_indefinite",{},"Would run indefinitely"),stress_terminated:this._t("lbl.pg_alert_stress_ok",{},"Idle termination: cycle stopped"),stress_above_threshold:this._t("lbl.pg_alert_stress_warn",{},"Idle draw above stop threshold"),stress_hit_cap:this._t("lbl.pg_alert_stress_cap",{},"Hit safety cap")}[t]||t}_htmlPgHistoryMode(){const t=this._busy.has("pg-history"),e=this._pgHistory,s=Object.keys(this._pgParamOverrides||{}).length>0||this._pgThreshStart!=null||this._pgThreshStop!=null,r=`
+ ${this._t("lbl.last",{},"Last")} + + ${this._t("lbl.cycles_lc",{},"cycles")} + + ${s?`\u2699 ${this._t("msg.pg_override_active",{},"Using your edited settings vs. current")}`:""} +
`,n=t?this._htmlPgBatchBar():"",a=`

${this._t("msg.pg_history_intro2",{},"Replay your recent cycles through the real detector and matcher with the settings above. Click any row to load that cycle in the graph; edit a setting to see a before/after comparison.")}

`;if(!e||!Array.isArray(e.rows))return`${a}${r}${this._htmlPgRecentRuns("pg_history")}${n}${t?"":`
${this._t("msg.pg_history_empty",{},"Press Run to replay your cycles.")}
`}`;let o="";if(e.diff){const u=(v,b,$)=>`${v} ${d($)}`;o=`
+ ${u((e.diff.newly_correct||[]).length,"var(--success-color,#4caf50)",this._t("lbl.pg_newly_correct",{},"newly correct"))} + ${u((e.diff.regressed||[]).length,"var(--error-color,#f44336)",this._t("lbl.pg_regressed",{},"regressed"))} + ${u((e.diff.end_timing_changed||[]).length,"var(--warning-color,#ff9800)",this._t("lbl.pg_end_timing_changed",{},"end-timing changed"))} +
`}const i=e.summary||{},l=`
${this._t("msg.pg_history_summary",{detected:i.detected,correct:i.match_correct,total:i.cycles},`${i.detected}/${i.cycles} detected \xB7 ${i.match_correct} matched correctly`)}
`,c=u=>(this._cycles||[]).find(v=>v.id===u),_={};(e.baseline_rows||[]).forEach(u=>{_[u.cycle_id]=u});const p=e.rows.map(u=>{const v=c(u.cycle_id),b=v&&v.start_time?st(v.start_time):(u.cycle_id||"").slice(0,8),$=u.match_correct===!0?"\u2713":u.match_correct===!1?"\u2717":"\u2014",k=u.match_correct===!0?"var(--success-color,#4caf50)":u.match_correct===!1?"var(--error-color,#f44336)":"var(--secondary-text-color)",S=u.matched_profile||this._t("lbl.unlabelled",{},"Unlabelled"),C=u.duration_s?Math.round(u.duration_s/60)+"m":"\u2014",R=u.overrun_ratio!=null?` (${Math.round(u.overrun_ratio*100)}%)`:"",B=u.alerts&&u.alerts.length?` \u26A0`:"",W=_[u.cycle_id];let O="";return W&&(W.match_correct!==!0&&u.match_correct===!0?O=`\u25B2 ${this._t("lbl.pg_fixed",{},"fixed")}`:W.match_correct===!0&&u.match_correct!==!0?O=`\u25BC ${this._t("lbl.pg_broke",{},"broke")}`:String(W.termination_reason)!==String(u.termination_reason)&&(O=`${d(String(W.termination_reason||"\u2014"))}\u2192${d(String(u.termination_reason||"\u2014"))}`)),` + ${d(b)} + ${$} ${d(S)} + ${d(String(u.termination_reason||"\u2014"))} + ${C}${R}${B} + ${e.diff?`${O}`:""} + `}).join(""),g=` + + + + + ${e.diff?``:""} + ${p}
${this._t("lbl.cycle",{},"Cycle")}${this._t("lbl.match",{},"Match")}${this._t("lbl.pg_ended",{},"Ended")}${this._t("lbl.duration",{},"Duration")}${this._t("lbl.pg_vs_current",{},"vs current")}
`;return`${a}${r}${this._htmlPgRecentRuns("pg_history")}${o}${l}${g}`}_htmlPgBatchBar(){const t=this._pgBatchProgress;return t?`
+
+ ${t.done}/${t.total} + +
`:""}_pgUpdateBatchBar(t,e){const s=this.shadowRoot;if(!s)return;const r=s.getElementById("wd-pg-batch-fill"),n=s.getElementById("wd-pg-batch-count"),a=e?Math.round(100*t/e):0;r&&(r.style.width=a+"%"),n&&(n.textContent=String(t))}async _pgRunHistory(){const t=this._devices[this._selIdx];if(!t)return;const e=(this._cycles||[]).slice(0,Math.max(1,this._pgSimCycles||20)).map(r=>r.id);if(!e.length){this._showToast(this._t("msg.no_cycles_selected",{},"No cycles available."),"error");return}const s={...this._pgParamOverrides};this._pgThreshStart!=null&&(s.start_threshold_w=this._pgThreshStart),this._pgThreshStop!=null&&(s.stop_threshold_w=this._pgThreshStop),this._pgBatchProgress={done:0,total:e.length},this._busy.add("pg-history"),this._render();try{const r=await this._ws({type:`${y}/start_playground_history`,entry_id:t.entry_id,cycle_ids:e,settings_override:s});if(this._pgHistoryTaskId=r&&r.task_id,this._pgNeedsRestart=!1,!this._pgHistoryTaskId)throw new Error("no task id");this._addProvisionalTask(this._pgHistoryTaskId,"pg_history",t.entry_id,e.length),this._tasksSubscribed||this._pgPollTask(this._pgHistoryTaskId)}catch(r){this._busy.delete("pg-history"),this._pgBatchProgress=null,this._pgIsUnknownCmd(r)?this._pgNeedsRestart=!0:this._showToast(this._t("msg.toast_error",{error:r.message||r},"Error: "+(r.message||r)),"error"),this._render()}}_pgSweepObjectives(){return[["match_accuracy",this._t("lbl.pg_obj_match",{},"Match accuracy"),!1],["end_timing_accuracy",this._t("lbl.pg_obj_endtiming",{},"End-timing accuracy"),!1],["false_end_rate",this._t("lbl.pg_obj_falseend",{},"False-end rate"),!0],["median_overrun",this._t("lbl.pg_obj_overrun",{},"Duration off-target"),!0],["ambiguity_rate",this._t("lbl.pg_obj_ambiguity",{},"Ambiguity rate"),!0]]}_htmlPgSweepMode(){const t=this._busy.has("pg-sweep"),s=this._pgOverrideFields().filter(([,,,,,i])=>i!=="bool").map(([i,l])=>``).join(""),r=this._pgSweepObjectives().map(([i,l])=>``).join(""),n=`

${this._t("msg.pg_sweep_intro2",{},"Find the setting that best meets an objective across your recent cycles. Nothing changes until you apply it.")}

`,a=`
+
+
+
+
+
+ +
`,o=t?this._htmlPgBatchBar():"";return`${n}${a}${this._htmlPgRecentRuns("pg_sweep")}${o}${this._htmlPgSweepResult()}`}_htmlPgSweepResult(){const t=this._pgSweepNew;if(!t||!Array.isArray(t.points)||!t.points.length)return"";const e=this._pgSweepObjectives().find(p=>p[0]===t.objective),s=e?e[2]:!1,r=t.points.filter(p=>p.metric!=null).map(p=>p.metric);if(!r.length)return`
${this._t("msg.pg_sweep_no_metric",{},"Not enough data to score this objective.")}
`;const n=Math.min(...r),a=Math.max(...r),o=s?Math.min(...r):Math.max(...r),i=(t.points.find(p=>p.metric===o)||{}).value,l=p=>t.objective==="median_overrun"?Math.round(p*100)+"% off":Math.round(p*100)+"%",c=t.points.map(p=>{const g=a>n&&p.metric!=null?(p.metric-n)/(a-n):p.metric!=null?1:0,u=p.metric===o,v=t.current_value!=null&&Math.abs(p.value-t.current_value)<1e-6,b=u?"var(--success-color,#4caf50)":"var(--primary-color)";return`
+ ${d(String(p.value))}${v?" \u25C0":""} +
+ ${p.metric!=null?l(p.metric):"\u2014"} +
`}).join(""),_=this._canEdit()&&i!=null?``:"";return`
${this._t("lbl.pg_best_value",{},"Best value found")}: ${d(String(i))} \xB7 ${l(o)} \xB7 \u25C0 ${this._t("lbl.pg_current_value",{},"Current value:")}
+ ${c} +
${_}
`}async _pgRunSweep2(){const t=this._devices[this._selIdx];if(!t)return;const e=parseFloat(this._pgSweepFrom),s=parseFloat(this._pgSweepTo);if(isNaN(e)||isNaN(s)||e===s){this._showToast(this._t("msg.toast_name_required",{},"Set a valid From/To range."),"error");return}const r=Math.max(2,Math.min(12,this._pgSweepSteps||5)),n=Array.from({length:r},(l,c)=>+(e+(s-e)*c/(r-1)).toFixed(3)),a=this._pgSweepParam||"off_delay",o=this._pgSweepObjective||"match_accuracy",i={type:`${y}/start_playground_sweep`,entry_id:t.entry_id,param:a,values:n,objective:o};this._pgBatchProgress={done:0,total:n.length},this._busy.add("pg-sweep"),this._render();try{const l=await this._ws(i);if(this._pgSweepTaskId=l&&l.task_id,this._pgNeedsRestart=!1,!this._pgSweepTaskId)throw new Error("no task id");this._addProvisionalTask(this._pgSweepTaskId,"pg_sweep",t.entry_id,n.length),this._tasksSubscribed||this._pgPollTask(this._pgSweepTaskId)}catch(l){this._busy.delete("pg-sweep"),this._pgBatchProgress=null,this._pgIsUnknownCmd(l)?this._pgNeedsRestart=!0:this._showToast(this._t("msg.toast_error",{error:l.message||l},"Error: "+(l.message||l)),"error"),this._render()}}async _pgApplyToSettings(){const t=this._devices[this._selIdx];if(!t||!this._canEdit())return;const e={};for(const n of this._pgChangedKeys())this._pgIsPublishable(n)&&(e[n]=this._pgStagedVal(n));const s=Object.keys(e);if(!s.length)return;const r=s.map(n=>this._t("setting."+n+".label",{},n)).join(", ");confirm(this._t("msg.pg_apply_settings_confirm",{n:s.length,list:r},`Save these ${s.length} setting(s) to this device? ${r}`))&&(await this._busyRun("pg-apply-settings",async()=>{try{if(await this._ws({type:`${y}/set_options`,entry_id:t.entry_id,options:e}),!this._isActiveEntry(t.entry_id))return;this._opts={...this._opts,...e},this._pgEffective&&(this._pgEffective={...this._pgEffective,...e});for(const n of s)this._pgClearStaged(n);this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading"))}catch(n){this._showToast(this._t("msg.toast_save_failed",{error:n.message||n},"Save failed: "+(n.message||n)),"error")}}),this._render())}async _pgApplySweepValue(t){const e=this._devices[this._selIdx];if(!e||!this._canEdit()||t==null)return;const s=this._pgSweepParam,r=this._t("setting."+s+".label",{},s);confirm(this._t("msg.pg_apply_confirm",{label:r,value:t},"Apply best value: "+r+" = "+t+"?"))&&await this._busyRun("pg-sweep-apply",async()=>{try{await this._ws({type:`${y}/set_options`,entry_id:e.entry_id,options:{[s]:+t}}),this._opts={...this._opts,[s]:+t},s==="start_threshold_w"?this._pgThreshStart=+t:s==="stop_threshold_w"?this._pgThreshStop=+t:this._pgParamOverrides[s]=+t,this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading")),this._render()}catch(n){this._showToast(this._t("msg.toast_save_failed",{error:n.message||n},"Save failed: "+(n.message||n)),"error")}})}_htmlPgStrip(){return`
+ ${this._t("lbl.pg_idle",{},"Idle")} + ${this._t("lbl.power",{},"Power")} \u2014 + + + \u2014% + + ${this._t("lbl.pg_time_left_model",{},"Time left (model)")} \u2014 + ${this._t("lbl.energy",{},"Energy")} \u2014 + ${this._t("lbl.match",{},"Match")} \u2014 + ${this._t("lbl.phase",{},"Phase")} \u2014 +
`}_htmlPgAnalysis(){const t=this._pgDtwData,e=(i,l,c,_,p)=>{const g=c&&l!=null?Math.max(0,Math.min(1,l/c)):l!=null?Math.max(0,Math.min(1,l)):0;return`
+ ${d(i)} +
+ ${p!=null?d(String(p)):"\u2014"} +
`};let s="";if(t&&(t.stage2||t.stage4)){const i=t.stage2||{},l=t.stage4||{},c=t.dtw||{},_=l.final_score??c.blended_score??i.score,p=this._pgDetail&&this._pgDetail.outcome&&this._pgDetail.outcome.confidence,g=p??_;let u="\u2014",v="var(--secondary-text-color)";g!=null&&(g>=.7?(u="\u2705 "+this._t("lbl.pg_strong_match",{},"Strong match"),v="var(--success-color, #4caf50)"):g>=.4?(u="\u26A0 "+this._t("lbl.pg_weak_match",{},"Weak match"),v="var(--warning-color, #ff9800)"):(u="\u274C "+this._t("lbl.pg_poor_match",{},"Poor match"),v="var(--error-color, #f44336)"));const b=t.profile_name||this._pgProfileName||"\u2014";s+=`
${u}
`,b!=="\u2014"&&(s+=`
${d(b)} \xB7 ${d(this._t("lbl.score",{},"score"))} ${_!=null?_.toFixed(3):"\u2014"}
`),s+=e(this._t("lbl.correlation",{},"Correlation"),i.correlation,1,"#42a5f5",i.correlation!=null?i.correlation.toFixed(2):null),c.blended_score!=null&&(s+=e(this._t("lbl.pg_dtw",{},"DTW"),c.blended_score,1,"#ab47bc",c.blended_score.toFixed(2))),l.duration_agreement!=null&&(s+=e(this._t("lbl.duration",{},"Duration"),l.duration_agreement,1,"#66bb6a",l.duration_agreement.toFixed(2))),l.energy_agreement!=null&&(s+=e(this._t("lbl.energy",{},"Energy"),l.energy_agreement,1,"#ffa726",l.energy_agreement.toFixed(2))),s+='
'}else t||(s+=`

${this._t("msg.pg_analysis_empty2",{},"Press Run to see match analysis.")}

`);const r=this._pgDetail&&this._pgDetail.outcome,n=r&&r.matched_profile||t&&t.profile_name||this._pgProfileName||(this._cycles||[]).find(i=>i.id===this._pgCycleId)?.profile_name||"",a=r&&r.confidence;if(n&&a!=null){const i=Math.round(Math.max(0,Math.min(1,a))*100);s+=`
${d(this._t("lbl.pg_match_confidence",{},"Match confidence"))}
`,s+=`
+ ${d(n)} +
+ ${i}% +
`}const o=t&&t.stage4&&t.stage4.final_score;if(o!=null){const i=Math.round(Math.max(0,Math.min(1,o))*100);s+=`
${d(this._t("lbl.pg_envelope_fit",{},"Envelope fit"))}
`,s+=`
+
+ ${i}% +
`}return`
${s||`

${this._t("msg.pg_analysis_hint2",{},"Pick a cycle and press Run to load match analysis.")}

`}
`}async _pgLoad(){const t=this._devices[this._selIdx];if(!t||this._pgLoading)return;const e=this._pgCycleId||this._cycles?.[0]?.id||"";if(!e)return;this._pgCycleId=e,this._pgLoading=!0,this._pgView=null,this._pgHoverT=null,this._pgPowerPts=null,this._pgDtwData=null,this._pgEnvData=null,this._pgDetail=null;const s=++this._pgLoadSeq;this._render();try{const r=await this._ws({type:`${y}/get_cycle_power_data`,entry_id:t.entry_id,cycle_id:e}),n=r.samples||[],a=[];for(const l of n){if(!Array.isArray(l)||l.length<2)continue;const c=+l[0],_=+l[1];!isNaN(c)&&!isNaN(_)&&a.push({t:c,w:_})}if(this._pgPowerPts=a.length?a:null,typeof r.full_duration_s=="number"&&r.full_duration_s>0){const l=(this._cycles||[]).find(c=>c.id===e);l&&(l._pg_duration=r.full_duration_s)}const o=this._pgProfileName||(this._cycles||[]).find(l=>l.id===e)?.profile_name||"";if(a.length)try{const l={type:`${y}/get_dtw_debug`,entry_id:t.entry_id,cycle_id:e};o&&(l.profile_name=o),this._pgDtwData=await this._ws(l),this._pgNeedsRestart=!1}catch(l){this._pgIsUnknownCmd(l)&&(this._pgNeedsRestart=!0),this._pgDtwData=null}const i=this._pgDtwData?.profile_name||o;if(i)try{const l=await this._ws({type:`${y}/get_profile_envelope`,entry_id:t.entry_id,profile_name:i});this._pgEnvData=l.envelope||null}catch{this._pgEnvData=null}if(s!==this._pgLoadSeq)return;await this._pgLoadDetail(t.entry_id,e)}catch(r){this._showToast(this._t("msg.toast_error",{error:r.message||r},"Error: "+(r.message||r)),"error")}s===this._pgLoadSeq&&(this._pgLoading=!1,this._render(),requestAnimationFrame(()=>{this._pgDrawCanvas(),this._pgUpdateStripAt(null)}),this._pgNeedsRestart&&this._tab==="playground"?(this._pgRestartRetries||0)<5&&!this._pgRestartRetryTimer&&(this._pgRestartRetries=(this._pgRestartRetries||0)+1,this._pgRestartRetryTimer=setTimeout(()=>{this._pgRestartRetryTimer=null,this._pgNeedsRestart&&this._tab==="playground"&&this._pgLoad()},3e3)):this._pgRestartRetries=0)}_pgCancelRun(){this._pgLoadSeq++,this._pgLoading=!1,this._render()}_pgSelectCycle(t){t&&(this._pgCycleId=t,this._pgProfileName="",this._pgView=null,this._pgHoverT=null,this._pgLoading=!1,this._pgLoad(),requestAnimationFrame(()=>{const e=this.shadowRoot&&this.shadowRoot.getElementById("wd-pg-canvas");e&&typeof e.scrollIntoView=="function"&&e.scrollIntoView({behavior:"smooth",block:"center"})}))}async _pgLoadDetail(t,e){const s={...this._pgParamOverrides};this._pgThreshStart!=null&&(s.start_threshold_w=this._pgThreshStart),this._pgThreshStop!=null&&(s.stop_threshold_w=this._pgThreshStop);try{const r=await this._ws({type:`${y}/start_playground_cycle_detail`,entry_id:t,cycle_id:e,settings_override:s,stress_tail:this._pgStressTail,stress_idle_w:this._pgStressIdleW!=null?parseFloat(this._pgStressIdleW):null}),n=r&&r.task_id;if(!n)throw new Error("no task id");this._addProvisionalTask(n,"pg_detail",t,0);const a=await new Promise(o=>{this._taskCallbacks[n]=async l=>{if(l.state==="done"||l.state==="cancelled")try{const c=await this._ws({type:`${y}/get_task_result`,task_id:l.id});o(c&&c.result)}catch{o(null)}else o(null)};const i=this._tasks[n];i&&i.state!=="running"?this._settleTaskCallback(i):this._tasksSubscribed||this._pollTaskGeneric(n)});if(!this._isActiveEntry(t))return;this._pgDetail=a&&!a.error?a:null,this._pgNeedsRestart=!1}catch(r){this._pgIsUnknownCmd(r)&&(this._pgNeedsRestart=!0),this._pgDetail=null}}_pgRerunDetail(){const t=this._devices[this._selIdx];!t||!this._pgCycleId||(clearTimeout(this._pgDetailDebounceTimer),this._pgDetailDebounceTimer=setTimeout(async()=>{this._pgDetailBusy=!0,this._render(),await this._pgLoadDetail(t.entry_id,this._pgCycleId),this._pgDetailBusy=!1,this._render(),requestAnimationFrame(()=>this._pgDrawCanvas())},220))}_pgMapState(t){return t==="running"||t==="paused"?"running":t==="ending"?"ending":t==="starting"?"detecting":t==="anti_wrinkle"?"anti_wrinkle":"idle"}_pgSeriesAt(t){const e=this._pgDetail&&this._pgDetail.series;if(!e||!e.length)return null;let s=e[0];for(const r of e)if(r.t<=t)s=r;else break;return s}_pgStateSegsFromSeries(t){const e=this._pgDetail&&this._pgDetail.series;if(!e||!e.length)return[];const s=[];let r=null;for(const n of e){const a=this._pgMapState(n.state);!r||r.state!==a?(r={start:n.t,end:n.t,state:a},s.push(r)):r.end=n.t}return s.length&&(s[s.length-1].end=t),s}_pgDrawCanvas(){if(this._tab!=="playground")return;const t=this.shadowRoot,e=t&&t.getElementById("wd-pg-canvas");if(!e)return;const s=this._pgPowerPts,r=e.getBoundingClientRect(),n=window.devicePixelRatio||1,a=Math.max(1,Math.round(r.width*n)),o=Math.max(1,Math.round((r.height||280)*n));(e.width!==a||e.height!==o)&&(e.width=a,e.height=o);const i=e.getContext("2d"),l=getComputedStyle(this),c=(l.getPropertyValue("--primary-color")||"#03a9f4").trim(),_=(l.getPropertyValue("--divider-color")||"rgba(127,127,127,.2)").trim(),p=(l.getPropertyValue("--secondary-text-color")||"#888").trim(),g=(l.getPropertyValue("--secondary-background-color")||"#1a1a1a").trim();i.clearRect(0,0,a,o);const u=34*n,v=14*n,b=Bt*n,$=44*n,k=8*n,S=b+8*n,C=u+v+4*n,R=o-S-C;if(!s||!s.length){this._pgLoading&&(i.fillStyle=p,i.font=`${12*n}px sans-serif`,i.textAlign="center",i.textBaseline="middle",i.fillText(this._t("msg.loading",{},"Loading\u2026"),a/2,o/2));return}const B=this._pgDetail&&this._pgDetail.outcome&&this._pgDetail.outcome.stress,W=B&&B.enabled?B.synthetic_from_s:null,O=W!=null&&this._pgDetail&&this._pgDetail.series?this._pgDetail.series.filter(T=>T.t>=W):[];let D=(this._cycles||[]).find(T=>T.id===this._pgCycleId)?._pg_duration||s[s.length-1].t||1;B&&B.terminated&&B.terminated_after_s!=null&&W!=null?D=Math.max(D,W+B.terminated_after_s):O.length&&(D=Math.max(D,O[O.length-1].t));const A=Math.max(...s.map(T=>T.w),...O.map(T=>T.power||0),1),E=this._pgThreshStart??this._pgFieldVal("start_threshold_w",{})??50,M=this._pgThreshStop??this._pgFieldVal("stop_threshold_w",{})??5;let H=0,q=D;this._pgView&&this._pgView.max-this._pgView.min>1&&(H=Math.max(0,this._pgView.min),q=Math.min(D,this._pgView.max),q-H<=1&&(H=0,q=D));const Q=Math.max(1e-6,q-H),J=a-$-k,G=T=>$+(T-H)/Q*J,X=T=>S+(1-Math.max(0,T)/A)*R;this._pgMap={vMin:H,vMax:q,totalDur:D,padLpx:$/n,plotWpx:J/n},i.strokeStyle="rgba(127,127,127,0.12)",i.lineWidth=n,i.setLineDash([]),[.25,.5,.75,1].map(T=>Math.round(T*A/100)*100||Math.round(T*A)).forEach(T=>{const N=X(T);NS+R||(i.beginPath(),i.moveTo($,N),i.lineTo(a-k,N),i.stroke(),i.fillStyle=p,i.font=`${9*n}px sans-serif`,i.textAlign="right",i.textBaseline="middle",i.fillText(T+"W",$-4*n,N))}),i.save(),i.beginPath(),i.rect($,S,J,R),i.clip();const U=this._pgEnvData,at=U&&Array.isArray(U.avg)?U.avg.filter(T=>Array.isArray(T)&&T.length>=2):[];if(at.length){const T=Math.max(...at.map(V=>V[0]))||1,N=V=>G(V/T*D),tt=Array.isArray(U.min)?U.min.filter(V=>Array.isArray(V)&&V.length>=2):[],Y=Array.isArray(U.max)?U.max.filter(V=>Array.isArray(V)&&V.length>=2):[];if(tt.length&&Y.length){i.beginPath(),Y.forEach((V,lt)=>lt?i.lineTo(N(V[0]),X(V[1])):i.moveTo(N(V[0]),X(V[1])));for(let V=tt.length-1;V>=0;V--)i.lineTo(N(tt[V][0]),X(tt[V][1]));i.closePath(),i.fillStyle="#eda10012",i.fill()}else i.beginPath(),i.strokeStyle="#eda100",i.lineWidth=2*n,i.setLineDash([5*n,4*n]),at.forEach((V,lt)=>lt?i.lineTo(N(V[0]),X(V[1])):i.moveTo(N(V[0]),X(V[1]))),i.stroke(),i.setLineDash([])}const f=this._pgDtwData,m=f&&Array.isArray(f.profile_trace)?f.profile_trace.filter(T=>Array.isArray(T)&&T.length>=2):[],x=f&&Array.isArray(f.cycle_trace)?f.cycle_trace.filter(T=>Array.isArray(T)&&T.length>=2):[],L=f&&Array.isArray(f.warp_path)?f.warp_path:[];if(x.length&&m.length&&L.length){const T=Math.max(...m.map(Y=>Y[0]))||1,N=Math.max(...x.map(Y=>Y[0]))||1,tt=Math.max(1,Math.floor(L.length/25));i.save(),i.globalAlpha=.13,i.strokeStyle="#fff",i.lineWidth=n;for(let Y=0;YN[0]))||1;i.beginPath(),i.strokeStyle="#eda100",i.lineWidth=2*n,i.setLineDash([6*n,4*n]),m.forEach((N,tt)=>{const Y=G(N[0]/T*D),V=X(N[1]);tt?i.lineTo(Y,V):i.moveTo(Y,V)}),i.stroke(),i.setLineDash([])}if(i.beginPath(),i.moveTo(G(0),X(0)),s.forEach(T=>i.lineTo(G(T.t),X(T.w))),i.lineTo(G(s[s.length-1].t),X(0)),i.closePath(),i.fillStyle=St(c,.1),i.fill(),i.beginPath(),i.strokeStyle=c,i.lineWidth=2*n,s.forEach((T,N)=>N?i.lineTo(G(T.t),X(T.w)):i.moveTo(G(T.t),X(T.w))),i.stroke(),O.length){const T=s[s.length-1];i.beginPath(),i.moveTo(G(T.t),X(T.w)),O.forEach(N=>i.lineTo(G(N.t),X(N.power))),i.lineTo(G(O[O.length-1].t),X(0)),i.lineTo(G(T.t),X(0)),i.closePath(),i.fillStyle=St(c,.06),i.fill(),i.beginPath(),i.strokeStyle=c,i.lineWidth=1.5*n,i.setLineDash([4*n,4*n]),i.moveTo(G(T.t),X(T.w)),O.forEach(N=>i.lineTo(G(N.t),X(N.power))),i.stroke(),i.setLineDash([])}if(W!=null&&W{const Y=X(T);YS+R+2||(i.save(),i.strokeStyle=N,i.lineWidth=2*n,i.setLineDash([8*n,4*n]),i.beginPath(),i.moveTo($,Y),i.lineTo(a-k,Y),i.stroke(),i.setLineDash([]),i.fillStyle=N,i.beginPath(),i.arc($+(a-$-k)*.06,Y,5*n,0,Math.PI*2),i.fill(),i.fillStyle=N,i.font=`bold ${9*n}px sans-serif`,i.textAlign="left",i.textBaseline="bottom",i.fillText(tt+" "+Math.round(T)+"W",$+14*n,Y-2*n),i.restore())};j(+E,"#2a78d6",this._t("lbl.start",{},"Start")),j(+M,"#e34948",this._t("btn.stop",{},"Stop"));const I={idle:g,detecting:"#42a5f566",running:"#66bb6a66",ending:"#ef535066",anti_wrinkle:"#ab47bc66"},w={idle:this._t("lbl.pg_idle",{},"Idle"),detecting:this._t("lbl.pg_detecting",{},"Detecting"),running:this._t("lbl.pg_ev_running",{},"Running"),ending:this._t("lbl.pg_ev_ending",{},"Ending"),anti_wrinkle:this._t("lbl.pg_anti_wrinkle",{},"Anti-wrinkle")},P=o-u-v;i.fillStyle=g,i.fillRect($,P,a-$-k,u),i.save(),i.beginPath(),i.rect($,P,J,u),i.clip(),this._pgStateSegsFromSeries(D).forEach(T=>{const N=G(T.start),tt=G(T.end);i.fillStyle=I[T.state]||_,i.fillRect(N,P,Math.max(1,tt-N),u),tt-N>50*n&&(i.fillStyle=p,i.font=`${8*n}px sans-serif`,i.textAlign="center",i.textBaseline="middle",i.fillText((w[T.state]||T.state).toUpperCase(),(N+tt)/2,P+u/2))}),i.restore();const F=(this._cycles||[]).find(T=>T.id===this._pgCycleId),K=this._pgDtwData?.profile_name||this._pgProfileName||F?.profile_name,Z=K?(this._profiles||[]).find(T=>T.name===K):null,rt=o-v;Z&&Array.isArray(Z.phases)&&Z.phases.length&&(i.save(),i.beginPath(),i.rect($,rt,J,v),i.clip(),Z.phases.forEach((T,N)=>{const tt=G((T.start||0)*D),Y=G((T.end||1)*D),V=N*47%360;i.fillStyle=`hsla(${V},60%,55%,0.55)`,i.fillRect(tt,rt,Math.max(1,Y-tt),v),Y-tt>40*n&&(i.fillStyle="#fff",i.font=`${7*n}px sans-serif`,i.textAlign="center",i.textBaseline="middle",i.fillText(T.name,(tt+Y)/2,rt+v/2))}),i.restore()),this._pgEventHits=[];const ht=this._pgDetail&&Array.isArray(this._pgDetail.events)?this._pgDetail.events.filter(T=>T.type!=="state"&&T.t>=H&&T.t<=q).slice().sort((T,N)=>T.t-N.t):[],nt=11*n,Ct=S-b/2-2*n,se=nt*2+3*n;let jt=-1/0;const xt=this._pgHoverEvent;if(ht.forEach(T=>{const N=this._pgEventMeta(T.type),tt=G(T.t);let Y=Math.max(tt,jt+se);Y=Math.max($+nt,Math.min(a-k-nt,Y)),jt=Y;const V=xt&&xt.t===T.t&&xt.type===T.type;i.save(),i.strokeStyle=N.color,i.globalAlpha=V?.9:.5,i.lineWidth=(V?2:1)*n,i.beginPath(),i.moveTo(Y,Ct+nt),i.lineTo(tt,S),i.stroke(),V&&(i.globalAlpha=.3,i.setLineDash([2*n,3*n]),i.beginPath(),i.moveTo(tt,S),i.lineTo(tt,o-v),i.stroke(),i.setLineDash([])),i.restore(),i.save(),i.beginPath(),i.arc(Y,Ct,nt,0,Math.PI*2),i.fillStyle=V?N.color:g||"#1a1a1a",i.fill(),i.lineWidth=(V?2:1.5)*n,i.strokeStyle=N.color,i.stroke(),i.fillStyle=V?"#fff":N.color,i.font=`${14*n}px sans-serif`,i.textAlign="center",i.textBaseline="middle",i.fillText(N.glyph,Y,Ct+n),i.restore(),this._pgEventHits.push({cx:Y/n,cy:Ct/n,r:nt/n,type:T.type,label:N.label,detail:T.detail,t:T.t})}),xt){const T=this._pgEventHits.find(N=>N.t===xt.t&&N.type===xt.type);if(T){const N=this._pgEventMeta(T.type),tt=ut=>`${Math.floor(ut/60)}:${String(Math.round(ut%60)).padStart(2,"0")}`,Y=this._pgEventDescription(T.type),V=[{t:T.label,bold:!0,col:N.color},...Y?[{t:Y}]:[],...T.detail?[{t:T.detail,dim:!0}]:[],{t:`${this._t("lbl.from_start",{},"From start")} ${tt(T.t)}`,dim:!0}];i.save(),i.font=`${10*n}px sans-serif`;const lt=Math.min(220*n,a-2*$),pt=[];V.forEach(ut=>{const At=String(ut.t).split(" ");let vt="";At.forEach(Lt=>{const Wt=vt?vt+" "+Lt:Lt;i.measureText(Wt).width>lt&&vt?(pt.push({...ut,t:vt}),vt=Lt):vt=Wt}),pt.push({...ut,t:vt})});const bt=14*n,_t=Math.min(lt+14*n,a-8*n),yt=pt.length*bt+10*n;let wt=T.cx*n-_t/2;wt=Math.max(4*n,Math.min(a-_t-4*n,wt));const It=Ct+nt+6*n;i.fillStyle=g,i.globalAlpha=.97,i.fillRect(wt,It,_t,yt),i.globalAlpha=1,i.strokeStyle=N.color,i.lineWidth=n,i.strokeRect(wt,It,_t,yt),i.textAlign="left",i.textBaseline="top",pt.forEach((ut,At)=>{i.font=`${ut.bold?"600 ":""}${10*n}px sans-serif`,i.fillStyle=ut.col||(ut.dim?p:(l.getPropertyValue("--primary-text-color")||"#ddd").trim()),i.fillText(ut.t,wt+7*n,It+5*n+At*bt)}),i.restore()}}if(this._pgHoverT!=null&&this._pgHoverT>=H&&this._pgHoverT<=q){const T=G(this._pgHoverT);i.save(),i.strokeStyle="#e34948",i.lineWidth=1.5*n,i.setLineDash([4*n,3*n]),i.beginPath(),i.moveTo(T,S),i.lineTo(T,o-v),i.stroke(),i.setLineDash([]);const N=this._pgInterpPower(s,this._pgHoverT),tt=X(N);i.fillStyle="#e34948",i.beginPath(),i.arc(T,tt,3.5*n,0,Math.PI*2),i.fill();const Y=dt=>`${Math.floor(dt/60)}:${String(Math.round(dt%60)).padStart(2,"0")}`,V=dt=>dt>=1e3?(dt/1e3).toFixed(2)+" kW":Math.round(dt)+" W",lt=[`${this._t("lbl.from_start",{},"From start")} ${Y(this._pgHoverT)}`,`${this._t("lbl.to_end",{},"To end")} ${Y(Math.max(0,D-this._pgHoverT))}`,`${this._t("lbl.power",{},"Power")} ${V(N)}`];i.font=`${9.5*n}px sans-serif`;const pt=Math.max(...lt.map(dt=>i.measureText(dt).width))+12*n,bt=lt.length*13*n+8*n;let _t=T+8*n;_t+pt>a-k&&(_t=T-pt-8*n);const yt=S+4*n;i.fillStyle=g,i.globalAlpha=.95,i.fillRect(_t,yt,pt,bt),i.globalAlpha=1,i.strokeStyle=_,i.lineWidth=n,i.strokeRect(_t,yt,pt,bt),i.fillStyle=p,i.textAlign="left",i.textBaseline="top",lt.forEach((dt,wt)=>i.fillText(dt,_t+6*n,yt+5*n+wt*13*n)),i.restore()}i.fillStyle=p,i.font=`${9*n}px sans-serif`,i.textAlign="center",i.textBaseline="top";const ie=T=>`${Math.floor(T/60)}:${String(Math.round(T%60)).padStart(2,"0")}`;for(let T=0;T<=4;T++){const N=H+Q*T/4,tt=G(N);i.textAlign=T===0?"left":T===4?"right":"center",i.fillText(ie(N),Math.max($,Math.min(a-k,tt)),P+u+1*n)}QD.id===this._pgCycleId)?._pg_duration||e[e.length-1].t||1,n=t??r,a=this._pgInterpPower(e,n),o=this._pgSeriesAt(n),i=o?this._pgMapState(o.state):"idle",l={idle:[this._t("lbl.pg_idle",{},"Idle"),"var(--secondary-background-color)"],detecting:[this._t("lbl.pg_detecting",{},"Detecting"),"#42a5f5"],running:[this._t("lbl.pg_ev_running",{},"Running"),"#66bb6a"],ending:[this._t("lbl.pg_ev_ending",{},"Ending"),"#ef5350"],anti_wrinkle:[this._t("lbl.pg_anti_wrinkle",{},"Anti-wrinkle"),"#ab47bc"]},[c,_]=l[i]||l.idle,p=o&&o.progress!=null?Math.round(o.progress):null,g=o&&o.remaining_s!=null?o.remaining_s:null,u=o&&o.energy_wh!=null?o.energy_wh:this._pgTrapEnergy(e,n),v=o&&o.confidence!=null?Math.round(o.confidence*100)+"%":"\u2014",b=o&&o.phase?o.phase:"\u2014",$=D=>Math.floor(D/60)+":"+String(Math.round(D%60)).padStart(2,"0"),k=D=>D>=1e3?(D/1e3).toFixed(2)+" kWh":D.toFixed(0)+" Wh",S=D=>D>=1e3?(D/1e3).toFixed(1)+" kW":Math.round(D)+" W",C=this.shadowRoot,R=D=>C&&C.getElementById(D),B=(D,A)=>{const E=R(D);E&&(E.textContent=A)},W=(D,A,E)=>{const M=R(D);M&&(M.style[A]=E)},O=R("wd-pg-state-badge");O&&(O.textContent=c,O.style.background=_,O.style.color=_.includes("var")?"":"#fff"),B("wd-pg-power",S(a)),B("wd-pg-pct",p!=null?p+"%":"\u2014%"),W("wd-pg-pbar","width",(p??0)+"%"),B("wd-pg-rem",g!=null?$(g):"\u2014"),B("wd-pg-energy",k(u)),B("wd-pg-conf",v),B("wd-pg-phase",b)}_pgIsUnknownCmd(t){const e=t&&(t.code||t.error&&t.error.code)||"",s=(t&&(t.message||t.error)||"").toString().toLowerCase();return e==="unknown_command"||s.includes("unknown command")||s.includes("unknown_command")}_pgInterpPower(t,e){if(!t.length)return 0;if(e<=t[0].t)return t[0].w;for(let s=1;s=e))break}return s}_drawPlaygroundCanvases(){this._tab==="playground"&&this._pgDrawCanvas()}_htmlPhases(){const t=this._devices[this._selIdx],e=t&&t.options.device_type||"washing_machine",s=this._canEdit(),r=this._phases.map(o=>{const i=o.is_default,l=o.translation_key?this._t(o.translation_key,{},o.description||""):o.description||"",c=s?` + + + ${i?"":``} + `:"";return` + ${d(o.name)} ${i?`${this._t("badge.built_in_tag",{},"built-in")}`:""} + ${d(l.length>60?l.slice(0,57)+"\u2026":l)} + ${c} + `}).join(""),n=s?`${this._t("lbl.actions",{},"Actions")}`:"",a=s?`
`:"";return` +
+
${this._t("hdr.phase_catalog",{},"Phase Catalog")}
+

${this._t("msg.phase_catalog_intro",{},"Named segments of a cycle (Pre-wash, Heating, Spin\u2026). Assign them to a profile from its control panel.")}

+ ${a} + ${this._phases.length===0?`

${this._t("msg.no_phases",{},"No phases defined.")}

`:`${n}${r}
${this._t("lbl.phase_name",{},"Name")}${this._t("lbl.description",{},"Description")}
`} +
`}_htmlDiagnostics(){const t=this._diag;let e;return t&&t._error?e=`

${this._t("msg.diagnostics_load_failed",{error:d(t._error)},"Could not load diagnostics: "+d(t._error))}

`:t?e=`
+
${t.total_cycles??"-"}
${this._t("lbl.cycles_count",{},"Cycles")}
+
${t.total_profiles??"-"}
${this._t("tab.profiles",{},"Profiles")}
+
${t.debug_traces_count??"-"}
${this._t("lbl.debug_traces",{},"Debug Traces")}
+
${t.file_size_kb!=null?t.file_size_kb.toFixed(1):"-"}
${this._t("lbl.file_kb",{},"File (kB)")}
+
`:e=`

${this._t("msg.loading",{},"Loading\u2026")}

`,` +
+
${this._t("hdr.storage_stats",{},"Storage Stats")}
+ ${e} +
+
+ ${this._canFull()?`
+
${this._t("hdr.maintenance",{},"Maintenance Actions")}
+
+
${this._t("hdr.process_history",{},"Process History")}

${this._t("msg.process_history_hint",{},"Re-run matching on all stored cycles, refresh tuning suggestions, retrain the ML models (if enabled), and recompute cycle health. Run this after a batch of reviews.")}

+
+
${this._t("hdr.clear_debug",{},"Clear Debug Traces")}

${this._t("msg.clear_debug_hint",{},"Remove stored debug data to free space.")}

+
+
${this._t("hdr.wipe_history",{},"Wipe History")}

${this._t("msg.wipe_history_warning",{},"Permanently delete all cycles and profiles. Cannot be undone.")}

+
+
+
+
+
${this._t("hdr.export_import",{},"Export / Import")}
+

${this._t("msg.export_description",{},"Choose exactly which profiles, cycles, settings and more to export to JSON, or analyze a file and import only the parts you want.")}

+
+ + + + +
+
+
+
${this._t("hdr.import_power_history",{},"Import power history")}
+

${this._t("msg.import_history_description",{},"Already had a smart plug before WashData? Upload a history export of its power sensor, or read it straight from Home Assistant, and the normal detection runs over it so past cycles turn up in your Cycles list ready to name.")}

+
+ +
+
`:`

${this._t("msg.maintenance_requires_access",{},"Maintenance and export/import require full access.")}

`}`}_maintLabel(t){return{descale:this._t("maint.descale",{},"Descale"),filter_clean:this._t("maint.filter_clean",{},"Clean filter"),drum_clean:this._t("maint.drum_clean",{},"Clean drum"),bearing_service:this._t("maint.bearing_service",{},"Bearing service"),other:this._t("maint.other",{},"Other")}[t]||t}_htmlMaintenance(){const t=this._canEdit(),e=this._maintenance;if(e&&e._error)return`

${this._t("msg.maintenance_load_error",{error:e._error},"Could not load maintenance data: "+e._error)}

`;if(!e)return`

${this._t("msg.loading",{},"Loading\u2026")}

`;const s=e.event_types&&e.event_types.length?e.event_types:["descale","filter_clean","drum_clean","bearing_service","other"],r=e.due||[],n=e.log||[],a=e.reminders||{},o=r.length?(()=>{const g=r.map(u=>this._maintLabel(u)).join(", ");return`
+ ${this._t("msg.maintenance_due",{items:d(g)},"Maintenance due: "+g)} +
`})():"",i=new Date().toISOString().slice(0,10),l=s.map(g=>``).join(""),c=t?`
+
${this._t("hdr.add_maintenance",{},"Add Maintenance Event")}
+
+
+
+
+
+
+
`:"",_=n.length?n.map(g=>{const u=t?``:"",v=g.notes?`
${d(g.notes)}
`:"";return`
+
+
+
${d(this._maintLabel(g.event_type))}
+
${st(g.date)}
+ ${v} +
+ ${u} +
+
`}).join(""):`

${this._t("msg.no_maintenance",{},"No maintenance recorded yet.")}

`,p=t?`
+
${this._t("hdr.maintenance_reminders",{},"Service Reminders")}
+

${this._t("msg.reminders_intro",{},"Show a reminder in the panel this many cycles after the last service. Leave blank or 0 to turn a reminder off.")}

+
+ ${s.map(g=>`
`).join("")} +
+
+
`:"";return`${o} + ${c} +
+
${this._t("hdr.maintenance_log",{},"Maintenance Log")}
+

${this._t("msg.maintenance_intro",{},"Log servicing you perform on this appliance and get reminded when each task is due again.")}

+
${_}
+
+ ${p}`}_htmlPanel(){const t=this._canEdit(),e=t&&this._constants&&this._constants.mlTrainingAvailable,s=new Set(["maintenance"]);t&&s.add("diagnostics"),e&&s.add("ml");let r=this._panelSubtab;s.has(r)||(r=this._panelSubtab="maintenance");const n=[["maintenance",this._t("tab.maintenance",{},"Maintenance")]];t&&n.push(["diagnostics",this._t("tab.diagnostics",{},"Diagnostics")]),e&&n.push(["ml",this._t("tab.ml",{},"ML Training")]);const a=n.map(([i,l])=>``).join(""),o=r==="diagnostics"&&t?this._htmlDiagnostics():r==="ml"&&e?this._htmlMlTab():this._htmlMaintenance();return`
${a}
${o}`}_levelSelect(t,e,s){const r=(s?[["inherit",this._t("access.inherit",{},"Inherit")]]:[]).concat([["none",this._t("access.none",{},"None (hidden)")],["read",this._t("access.read",{},"Read")],["edit",this._t("access.edit",{},"Edit")],["full",this._t("access.full",{},"Full")]]);return``}_htmlPanelPrefs(){const t=this._panelCfg&&this._panelCfg.prefs||{},e=this._hass&&this._hass.locale&&this._hass.locale.language||"en",r=[["",this._t("pref.use_panel_default",{},"(panel default)")],["status",this._t("tab.status",{},"Overview")],["history",this._t("tab.history",{},"Cycles")],["profiles",this._t("tab.profiles",{},"Profiles")],["settings",this._t("tab.settings",{},"Settings")],["playground",this._t("tab.playground",{},"Playground")]].map(([l,c])=>``).join(""),a=[["relative",this._t("pref.date_relative",{},"Relative (e.g. 2 hours ago)")],["absolute",this._t("pref.date_absolute",{},"Absolute (e.g. 14:32 on 2 Jul)")]].map(([l,c])=>``).join(""),o=t.lang_override||"",i=[["",this._t("pref.lang_auto",{lang:e.toUpperCase()},"System default ("+e.toUpperCase()+")")],["en",this._t("pref.lang_en",{},"English")]].map(([l,c])=>``).join("");return`
+
${this._t("hdr.my_preferences",{},"My Preferences")}
+

${this._t("msg.prefs_personal",{},"These apply to your Home Assistant account only.")}

+
${this._t("hdr.display",{},"Display")}
+
+
+
+
+
+
+ +
+ + ${Math.round((t.font_scale||1)*100)}% +
+
${this._t("msg.font_size_hint",{},"Make everything in this panel larger or smaller. Applies to your account on this device.")}
+
+
${this._t("hdr.status_graph",{},"Status Graph")}
+ ${Et(`id="wd-pref-expected" ${t.show_expected!==!1?"checked":""}`,this._t("lbl.show_expected",{},"Show expected curve overlay (matched profile, orange)"))} + ${Et(`id="wd-pref-raw" ${t.show_raw?"checked":""}`,this._t("lbl.show_raw",{},"Show raw socket toggle in live power graph"))} +
${this._t("hdr.diagnostics_pref",{},"Diagnostics")}
+ ${Et(`id="wd-pref-debug" ${t.show_debug?"checked":""}`,this._t("lbl.show_debug",{},"Show live match debug card on the Status page (confidence, ambiguity, top candidates)"))} +
+
`}_htmlPanelSettings(){const t=this._panelCfg&&this._panelCfg.panel||{},e=[["status",this._t("tab.status",{},"Overview")],["history",this._t("tab.history",{},"Cycles")],["profiles",this._t("tab.profiles",{},"Profiles")],["settings",this._t("tab.settings",{},"Settings")],["playground",this._t("tab.playground",{},"Playground")]],s=e.map(([a,o])=>``).join(""),r=t.hidden_tabs||[],n=e.filter(([a])=>a!=="status").map(([a,o])=>Nt(`data-hidetab="${a}" ${r.includes(a)?"checked":""}`,o)).join("");return`
+
${this._t("hdr.panel_settings",{},"Panel Settings (all users)")}
+
+
+
+
${n}
+
+
`}_htmlPanelAccess(){const t=this._panelCfg&&this._panelCfg.rbac||{enabled:!1,default_level:"none",users:{}},e=this._panelCfg&&this._panelCfg.users||[],s=this._devices||[],r=e.filter(a=>!a.is_admin).map(a=>{const o=(t.users||{})[a.id]||{default:"none",devices:{}},i=s.map(l=>`
${d(l.title)}${this._levelSelect(`data-rbacuser="${d(a.id)}" data-rbacdev="${d(l.entry_id)}"`,(o.devices||{})[l.entry_id]||"inherit",!0)}
`).join("");return`
+
${d(a.name)}
+
${this._t("lbl.default_other",{},"Default (other devices)")}${this._levelSelect(`data-rbacuser="${d(a.id)}" data-rbacdev="__default__"`,o.default||"none",!1)}
+ ${i} +
`}).join(""),n=e.filter(a=>a.is_admin).map(a=>`${d(a.name)} - full (admin)`).join(" ");return`
+
${this._t("hdr.access_control",{},"Access Control")}
+ ${Et(`id="wd-rbac-enabled" ${t.enabled?"checked":""}`,this._t("lbl.enable_access_control",{},"Enable per-user access control"),"",this._t("msg.rbac_hint",{},"When off, every Home Assistant user has full access (the default). Administrators always have full access and can manage everyone."))} +
${this._levelSelect('id="wd-rbac-default"',t.default_level||"none",!1)}
+ ${n?`
${n}
`:""} +
+
+ ${r||`

${this._t("msg.no_other_users",{},"No other Home Assistant users found.")}

`}`}_htmlStore(){if(!this._canEdit())return`
${this._t("msg.no_device_selected",{},"No device selected.")}
`;const t=this._storeStatus,e=`${this._t("store.website",{},"Store website \u2197")}`,s=`
${this._t("hdr.community_store",{},"Community Store")}
${e}
`;if(!this._onlineEnabled()||t&&t.enabled===!1)return`
${s} +

${this._t("msg.store_enable_hint",{},"Enable online features in Settings to browse and import community reference cycles.")}

`;let r;return this._storeView==="device"?r=this._htmlStoreDevice():this._storeView==="profile"?r=this._htmlStoreProfile():r=this._htmlStoreBrands(),`
+ ${s} + ${this._htmlStoreCrumbs()} + ${r} +
`}_htmlStoreCrumbs(){const t=[``];if(this._storeDevice){const e=this._storeDevice,s=`${e.brand||""} ${e.model||""}`.trim()||this._t("store.device",{},"Device");t.push('\u203A'),t.push(this._storeView==="device"?`${d(s)}`:``)}return this._storeProfile&&this._storeView==="profile"&&(t.push('\u203A'),t.push(`${d(this._storeProfile.program||"")}`)),`
${t.join("")}
`}_htmlStoreLoading(){return`
\u23F3
${this._t("msg.loading",{},"Loading\u2026")}
`}_htmlStoreBrands(){const t=this._storeDevices||[],e=String((this._opts||{}).store_model||"").trim().toLowerCase(),s=t.map(o=>{const i=`${d(o.brand||"")} ${d(o.model||"")}`.trim()||this._t("store.device",{},"Device"),l=o.applianceType?`${d(this._deviceTypeLabel(o.applianceType))}`:"",_=e&&String(o.model||"").toLowerCase()===e?`${this._t("store.your_model",{},"Yours")}`:"",p=Number(o.profileCount)||0,g=p>0?`${this._t("store.programs_count",{n:p},`Programs: ${p}`)}`:"";return``}).join("");if(!this._storeBrandScope())return` + +

${this._t("msg.store_declare_appliance",{},"Tell WashData which appliance you own and this tab shows the setups other people have shared for it. You can also type a brand above to look around.")}

+ `;const r=`

${this._t("store.no_results",{},"No matching appliances found. Try a different search.")}

`,n=this._storeLoading?this._htmlStoreLoading():t.length?`
${s}
`:r,a=!this._storeLoading&&t.length>1?`

${this._t("msg.store_sibling_hint",{},"Nothing shared for your exact model? A closely-related model from the same brand is usually a good starting point.")}

`:"";return` + + ${a} + ${n}`}_htmlStoreDevice(){const t=this._storeProfiles||[],e=t.map(o=>``).join(""),s=this._storeLoading?this._htmlStoreLoading():t.length?`
${e}
`:`

${this._t("store.no_programs",{},"No shared programs for this appliance yet.")}

`,r=this._storeDevice,n=this._busy.has("store-download-device");return(this._canEdit()&&r&&t.length?`
+ ${this._t("msg.store_download_device_intro",{},"Adopt every shared program and its reference cycles onto your device. Your own recorded cycles and stats are not affected.")} + ${Nt(`data-action="store-toggle-dl-settings" ${this._dlSettings?"checked":""} ${n?"disabled":""}`,this._t("lbl.adopt_settings",{},"Also adopt settings"),it(this._t("msg.adopt_settings_hint",{},"Overwrite this device's detection & matching thresholds with the shared ones. Your notifications, entities and energy price are never changed.")))} + +
`:"")+s}_htmlStoreProfile(){const t=this._storeCycles||[],e=t.map(r=>{const n=r.stats||{},a=this._storeSparkline(r.trace&&r.trace.points||[]),o=n.duration!=null?ot(n.duration):"-",i=n.energy_wh!=null?gt(n.energy_wh/1e3):"-",l=n.peak_w!=null?Ut(n.peak_w):"-",c=d(r.uploaderName||this._t("store.anon",{},"anonymous")),_=this._statusTag(r),p=r.rating||{},g=p.avg!=null&&p.count?this._t("store.rating_summary",{avg:Number(p.avg).toFixed(1),n:p.count},`\u2605 ${Number(p.avg).toFixed(1)} (${p.count})`):this._t("store.no_ratings",{},"No ratings yet");return`
+
+ ${a} +
+
${o} \xB7 ${i} \xB7 ${l} ${_}
+
${this._t("store.uploaded_by",{name:c},`Shared by ${c}`)} \xB7 \u2B07 ${r.downloads||0} \xB7 ${g}
+
+ +
+
`}).join("");return`
${this._storeLoading?this._htmlStoreLoading():t.length?e:`

${this._t("store.no_cycles",{},"No reference cycles shared for this program yet.")}

`}
`}_storeSparkline(t){const e=Array.isArray(t)?t.filter(u=>Array.isArray(u)&&u.length>=2):[];if(e.length<2)return'';const s=e.map(u=>u[0]),r=e.map(u=>u[1]),n=Math.min(...s),a=Math.max(...s),o=Math.max(1,...r),i=120,l=36,c=2,_=u=>c+(a>n?(u-n)/(a-n):0)*(i-2*c),p=u=>l-c-Math.max(0,u)/o*(l-2*c),g=e.map(u=>`${_(u[0]).toFixed(1)},${p(u[1]).toFixed(1)}`).join(" ");return``}_htmlGearModal(t){const e=this._isAdmin(),s=[["prefs",this._t("hdr.my_preferences",{},"My Preferences")]];e&&s.push(["panel",this._t("hdr.panel_settings",{},"Panel Settings")],["access",this._t("hdr.access_control",{},"Access Control")]),e&&this._constants&&this._constants.storeOnlineAvailable&&s.push(["online",this._t("hdr.online_account",{},"Online & Community")]);let r=t.tab;s.some(([o])=>o===r)||(r=t.tab="prefs");const n=s.map(([o,i])=>``).join(""),a=r==="panel"&&e?this._htmlPanelSettings():r==="access"&&e?this._htmlPanelAccess():r==="online"&&e?this._htmlOnlineSettings():this._htmlPanelPrefs();return`

${this._t("settings.gear.title",{},"Settings")}

+
${n}
+
${a}
`}_htmlOnlineSettings(){if(!(this._constants&&this._constants.storeOnlineAvailable))return`

${this._t("msg.online_unavailable",{},"Online features are not available on this server.")}

`;const t=this._onlineEnabled(),e=this._storeStatus||{},s=!!(t&&e.connected),r=this._busy.has("store-account"),n=t?s?`
+ ${this._t("store.connected_as",{name:d(e.name||e.uid||"")},`Connected as ${d(e.name||e.uid||"")}`)} + +
`:`
+ ${this._t("store.not_connected",{},"Not connected. Connect a GitHub account to confirm appliances and share your own cycles.")} + +
`:"";return`
+
${this._t("hdr.online_account",{},"Community Store & online features")}
+

${this._t("msg.online_intro_global",{},"Browse and share reference recordings with other WashData users, and confirm appliance entries. One connection applies to your whole WashData integration; appliance brand and model are set per device under Basic. All online features are opt-in and off by default.")}

+ ${Et(`data-action="store-toggle-online" ${t?"checked":""} ${r?"disabled":""}`,this._t("lbl.enable_online",{},"Enable online features"))} + ${t?this._htmlStorePrefs(r):""} + ${t?n:""} +
`}_htmlStorePrefs(t){const e=this._constants&&this._constants.storePrefs||{},s=re.map(n=>{const a=e[n.key]!==!1;return Et(`data-action="store-toggle-pref" data-pref="${d(n.key)}" ${a?"checked":""} ${t?"disabled":""}`,this._t(n.labelKey,{},n.labelFb),it(this._t(n.docKey,{},n.docFb)))}).join(""),r=this._busy.has("store-refresh-catalog");return s+` +
+ + ${this._t("msg.refresh_catalog_hint",{},"The community brand and appliance lists are cached to keep the shared store within its daily budget. Refresh to pick up entries added or approved by others.")} +
`}async _loadStoreStatus(t){if(!this._onlineEnabled()){this._storeStatus={enabled:!1},this._storeConnected=!1;return}try{const e=await this._ws({type:`${y}/store_status`,entry_id:t});if(!this._isActiveEntry(t))return;this._storeStatus=e||null,this._storeConnected=!!(e&&e.connected)}catch{}}_storeBrandScope(){return String(this._storeQuery||(this._opts||{}).store_brand||"").trim()}async _storeSearch(t){const e=this._devices[this._selIdx];if(!e)return;const s=e.entry_id;this._storeQuery=t||"",this._storeView="brands",this._storeDevice=null,this._storeProfile=null,this._storeProfiles=[],this._storeCycles=[];const r=this._storeBrandScope();if(!r){this._storeDevices=[],this._storeLoading=!1,this._render();return}this._storeLoading=!0,this._render();try{const n=await this._ws({type:`${y}/store_search_devices`,entry_id:s,query:r,appliance_type:this._storeApplianceType(),include_pending:!0});if(!this._isActiveEntry(s))return;n&&n.disabled?(this._storeStatus={enabled:!1},this._storeDevices=[]):this._storeDevices=this._sortStoreDevices(n&&n.items||[])}catch(n){this._isActiveEntry(s)&&(this._storeDevices=[],this._showToast(this._t("toast.store_search_failed",{error:n.message||n},"Search failed: "+(n.message||n)),"error"))}finally{this._isActiveEntry(s)&&(this._storeLoading=!1,this._render())}}_sortStoreDevices(t){const e=String((this._opts||{}).store_model||"").trim().toLowerCase(),s=r=>e&&String(r.model||"").toLowerCase()===e?0:this._storeItemHasContent(r)?1:2;return t.map((r,n)=>({d:r,i:n})).sort((r,n)=>s(r.d)-s(n.d)||r.i-n.i).map(r=>r.d)}_storeItemHasContent(t){return(Number(t&&t.profileCount)||0)>0||(Number(t&&t.cycleCount)||0)>0}_ensureStoreConnectListener(){if(this._storeConnectListener)return;const t=this._constants.storeWebOrigin;if(!t)return;let e;try{e=new URL(t).origin}catch{return}this._storeConnectListener=async s=>{if(s.origin!==e)return;const r=s.data;if(!r)return;const n=this._devices[this._selIdx];if(!n)return;const a=n.entry_id;if(r.type==="washdata-device-created"){const o={};r.brand&&(o.store_brand=r.brand),r.model&&(o.store_model=r.model),this._opts={...this._opts,...o},this._catalog.brands=void 0,this._catalog.devices=void 0,this._catalog.forBrand=null,this._catalog.brandsFull=!1,this._catalog.brandPrefixes=[],this._catalogEntry=null,this._showToast(this._t("toast.appliance_added",{},"Appliance added - awaiting approval")),this._render();return}if(r.type==="washdata-brand-created"){r.brand&&(this._opts={...this._opts,store_brand:r.brand}),this._catalog.brands=void 0,this._catalog.brandsFull=!1,this._catalog.brandPrefixes=[],this._catalogEntry=null,this._showToast(this._t("toast.brand_added",{},"Brand added - awaiting approval")),this._render();return}if(r.type==="washdata-profile-created"){const o=this._modal;o&&o.type==="store-share"&&(o.program=r.program||o.program,this._loadShareProfiles()),this._showToast(this._t("toast.profile_added",{},"Profile added - awaiting approval"));return}if(r.type==="washdata-connect")try{const o=await this._ws({type:`${y}/store_connect`,entry_id:a,refresh_token:r.refreshToken,uid:r.uid,name:r.displayName});if(o&&o.error){this._showToast(this._t("toast.store_connect_failed",{error:o.error},"Connect failed: "+o.error),"error");return}if(await this._loadStoreStatus(a),!this._isActiveEntry(a))return;this._showToast(this._t("toast.store_connected",{},"Connected to the community store")),this._render()}catch(o){this._showToast(this._t("toast.store_connect_failed",{error:o.message||o},"Connect failed: "+(o.message||o)),"error")}},window.addEventListener("message",this._storeConnectListener)}async _saveStoreOptions(t){const e=this._devices[this._selIdx];if(!e)return!1;const s=e.entry_id;try{return await this._ws({type:`${y}/set_options`,entry_id:s,options:t}),this._opts={...this._opts,...t},this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading")),!0}catch(r){return this._showToast(this._t("msg.toast_save_failed",{error:r.message||r},"Save failed: "+(r.message||r)),"error"),!1}}_htmlLogDrawer(){return`
+
+
+ ${this._t("hdr.logs",{},"Logs")} +
+ + +
+
+
+
${this._htmlLogFilters("drawer")}
+

${this._t("msg.log_buffer_hint",{},"Newest first \xB7 buffers the last 500 ha_washdata records since restart \xB7 drag the left edge to resize.")}

+
${this._logLinesHtml()}
+
+
`}_drawCurves(t,e){const s=this.shadowRoot&&this.shadowRoot.getElementById(t);if(!s)return null;const r=s.getBoundingClientRect(),n=window.devicePixelRatio||1,a=Math.max(1,Math.round(r.width*n)),o=Math.max(1,Math.round((r.height||240)*n));(s.width!==a||s.height!==o)&&(s.width=a,s.height=o);const i=s.getContext("2d"),l=getComputedStyle(this),c=(l.getPropertyValue("--primary-color")||"#03a9f4").trim()||"#03a9f4",_=(l.getPropertyValue("--divider-color")||"rgba(127,127,127,.3)").trim()||"rgba(127,127,127,.3)",p=(l.getPropertyValue("--secondary-text-color")||"#888").trim()||"#888",g=44*n,u=12*n,v=12*n,b=22*n,$=e.series||[];let k=e.xMax||0;k||($.forEach(A=>(A.points||[]).forEach(E=>{E[0]>k&&(k=E[0])})),e.band&&(e.band.max||[]).forEach(A=>{A[0]>k&&(k=A[0])})),k=k||1;let S=e.yMax||0;if(!S){const A=E=>(E||[]).forEach(M=>{M[1]>S&&(S=M[1])});$.forEach(E=>{E.noScale||A(E.points)}),e.band&&A(e.band.max),S=(S||10)*1.08}const C=this._canvasZoom&&this._canvasZoom[t],R=C?C.xMin:0,B=C?C.xMax:k,W=a-g-u,O=A=>g+(A-R)/(B-R)*W,D=A=>o-b-A/S*(o-v-b);i.clearRect(0,0,a,o),i.strokeStyle=_,i.lineWidth=n,i.fillStyle=p,i.font=`${11*n}px sans-serif`,i.textAlign="right",i.textBaseline="middle";for(let A=0;A<=2;A++){const E=v+A/2*(o-v-b);i.beginPath(),i.moveTo(g,E),i.lineTo(a-u,E),i.stroke(),i.fillText(Math.round(S*(1-A/2))+"W",g-4*n,E)}if(i.save(),i.beginPath(),i.rect(g,v,W,o-v),i.clip(),(e.bands||[]).forEach(A=>{i.fillStyle=A.fill;const E=O(A.x0),M=O(A.x1);i.fillRect(Math.min(E,M),v,Math.abs(M-E),o-v-b)}),e.band&&(e.band.min||[]).length&&(e.band.max||[]).length){i.beginPath(),e.band.max.forEach((A,E)=>E?i.lineTo(O(A[0]),D(A[1])):i.moveTo(O(A[0]),D(A[1])));for(let A=e.band.min.length-1;A>=0;A--)i.lineTo(O(e.band.min[A][0]),D(e.band.min[A][1]));i.closePath(),i.fillStyle=e.band.fill||St(c,.13),i.fill()}return $.forEach(A=>{const E=A.points||[];if(!E.length)return;const M=A.stroke==="primary"?c:A.stroke;if(A.fill){i.beginPath(),E.forEach((q,Q)=>Q?i.lineTo(O(q[0]),D(q[1])):i.moveTo(O(q[0]),D(q[1]))),i.lineTo(O(E[E.length-1][0]),D(0)),i.lineTo(O(E[0][0]),D(0)),i.closePath();const H=i.createLinearGradient(0,v,0,o-b);H.addColorStop(0,St(M,.33)),H.addColorStop(1,St(M,.03)),i.fillStyle=H,i.fill()}i.beginPath(),E.forEach((H,q)=>q?i.lineTo(O(H[0]),D(H[1])):i.moveTo(O(H[0]),D(H[1]))),i.strokeStyle=M,i.lineWidth=(A.width||1.5)*n,i.lineJoin="round",A.dash&&i.setLineDash([6*n,4*n]),i.globalAlpha=A.alpha!=null?A.alpha:1,i.stroke(),i.globalAlpha=1,A.dash&&i.setLineDash([])}),i.textAlign="center",i.textBaseline="top",(e.vlines||[]).forEach(A=>{const E=O(A.x);i.beginPath(),i.moveTo(E,v),i.lineTo(E,o-b),i.strokeStyle=A.color,i.lineWidth=2*n,i.setLineDash([4*n,3*n]),i.stroke(),i.setLineDash([]),A.handle&&(i.fillStyle=A.color,i.beginPath(),i.arc(E,v+4*n,4.5*n,0,Math.PI*2),i.fill()),A.label&&(i.fillStyle=A.color,i.fillText(A.label,E,v+(A.handle?12*n:2*n)))}),i.restore(),i.fillStyle=p,i.font=`${11*n}px sans-serif`,i.textBaseline="bottom",C&&(i.textAlign="left",i.fillText((R/60).toFixed(1)+" min",g,o-2*n)),i.textAlign="right",i.fillText((B/60).toFixed(0)+" min",a-u,o-2*n),s._wd={xMax:k,xMin:R,xViewMax:B,yMax:S,dpr:n,padT:v,padB:b,ch:o,primary:c,Xpx:O,Ypx:D,xToCss:A=>O(A)/n,cssToX:A=>Math.max(R,Math.min(B,R+(A*n-g)/W*(B-R))),series:(e.series||[]).map(A=>({points:A.points,stroke:A.stroke,name:A.name,cid:A.cid})),band:e.band||null,artifacts:e.artifacts||null,_opts:e},s._wd}_drawModalCanvas(){const t=this._modal;t&&(t.type==="cycle-detail"?this._drawCycleEditor():t.type==="compare-cycles"?this._drawCompareCanvas():t.type==="profile-group"?this._drawGroupCanvas():t.type==="profile-panel"&&(t.tab==="stats"?this._drawProfileEnvelope():t.tab==="phases"?this._drawPhaseEditor():t.tab==="cleanup"&&this._drawSpaghetti()))}_redrawCanvas(t){if(t==="wd-status-canvas")this._drawStatusCurve();else if(t==="wd-cyc-canvas")this._drawCycleEditor();else if(t==="wd-compare-canvas")this._drawCompareCanvas();else if(t==="wd-env-canvas")this._drawProfileEnvelope();else if(t==="wd-phase-canvas")this._drawPhaseEditor();else if(t==="wd-spag-canvas")this._drawSpaghetti();else if(t==="wd-pgroup-canvas")this._drawGroupCanvas();else{const e=this.shadowRoot&&this.shadowRoot.getElementById(t);e&&e._wd&&e._wd._opts&&this._drawCurves(t,e._wd._opts)}}_pref(t,e){const s=this._panelCfg&&this._panelCfg.prefs||{};return s[t]===void 0?e:s[t]}_setPref(t,e){this._panelCfg||(this._panelCfg={}),this._panelCfg.prefs={...this._panelCfg.prefs||{},[t]:e},this._ws({type:`${y}/set_user_prefs`,prefs:{[t]:e}}).catch(()=>{})}_drawStatusCurve(){const t=this._powerData||{},e=t.live||[];if(e.length<2)return;const s=this._statusEnv,r=this._pref("show_expected",!0),n=this._pref("show_raw_active",!1),a=[];let o=e[e.length-1][0];if(t.cycle_active&&s&&(s.avg||[]).length&&r){const l=s.target_duration||s.avg[s.avg.length-1][0];a.push({points:s.avg,stroke:"#ff9800",width:2,alpha:.4,name:this._t("lbl.expected",{},"Expected")}),o=Math.max(o,l)}a.push({points:e,stroke:"primary",fill:!0,width:2,name:this._t("lbl.power",{},"Power")}),n&&(t.raw||[]).length>1&&a.push({points:t.raw,stroke:"#9e9e9e",width:1,alpha:.65,name:this._t("lbl.raw_socket",{},"Raw socket"),noScale:!0});const i=[];(t.restart_gaps||[]).forEach(l=>{if(!e.length)return;const c=t.cycle_start_iso;if(!c)return;const _=new Date(c).getTime(),p=Math.max(0,(new Date(l.start_ts).getTime()-_)/1e3),g=Math.max(p+1,(new Date(l.end_ts).getTime()-_)/1e3);i.push({x0:p,x1:g,fill:"rgba(96,125,139,.20)"})}),this._drawCurves("wd-status-canvas",{series:a,xMax:o,bands:i})}_attachHover(t){const e=this.shadowRoot,s=e&&e.getElementById(t);s&&(s.addEventListener("pointermove",r=>this._onGraphHover(r,t)),s.addEventListener("pointerleave",()=>this._hideGraphTip()),s.addEventListener("wheel",r=>{const n=s._wd;if(!n)return;r.preventDefault();const a=s.getBoundingClientRect(),o=n.cssToX(r.clientX-a.left),i=this._canvasZoom[t],l=n.xMax,c=i?i.xMin:0,p=(i?i.xMax:l)-c,g=p*(r.deltaY>0?1.3:.75);if(g>=l*.99)delete this._canvasZoom[t];else{const u=(o-c)/p,v=Math.min(l,o-u*g+g),b=Math.max(0,v-g);this._canvasZoom[t]={xMin:b,xMax:Math.min(l,b+g)}}this._redrawCanvas(t)},{passive:!1}),s.addEventListener("dblclick",()=>{delete this._canvasZoom[t],this._redrawCanvas(t)}))}_onGraphHover(t,e){const s=t.clientX,r=t.clientY;if(this._hoverRafId){this._hoverPending={px:s,py:r,id:e};return}this._hoverPending={px:s,py:r,id:e},this._hoverRafId=requestAnimationFrame(()=>{this._hoverRafId=null;const{px:n,py:a,id:o}=this._hoverPending||{};o&&this._onGraphHoverInner(n,a,o)})}_onGraphHoverInner(t,e,s){const r=this.shadowRoot&&this.shadowRoot.getElementById(s),n=r&&r._wd;if(!n)return;const a=r.getBoundingClientRect(),o=n.cssToX(t-a.left),i=(e-a.top)*n.dpr;this._redrawCanvas(s);const l=r.getContext("2d"),c=n.Xpx(o);l.save(),l.strokeStyle="rgba(140,140,140,.75)",l.lineWidth=n.dpr,l.setLineDash([3*n.dpr,3*n.dpr]),l.beginPath(),l.moveTo(c,n.padT),l.lineTo(c,n.ch-n.padB),l.stroke(),l.setLineDash([]);const _=v=>v.stroke==="primary"?n.primary:v.stroke,p=(v,b)=>{l.fillStyle=b,l.beginPath(),l.arc(c,n.Ypx(v),3.4*n.dpr,0,6.2832),l.fill()},g=[`${this._t("lbl.from_start",{},"From start")}: ${Mt(o)}`,`${this._t("lbl.to_end",{},"To end")}: ${Mt(Math.max(0,n.xMax-o))}`],u=n.series||[];if(this._hoverNearest=null,u.length>4){let v=null,b=1/0;if(u.forEach($=>{const k=Dt($.points,o);if(k==null)return;const S=Math.abs(n.Ypx(k)-i);SS?l.lineTo(n.Xpx(k[0]),n.Ypx(k[1])):l.moveTo(n.Xpx(k[0]),n.Ypx(k[1]))),l.stroke(),p(v.v,$),g.push(`${d(v.s.name||"")}: ${v.v.toFixed(v.v<100?1:0)} W`),v.s.cid&&(g.push(`${this._t("lbl.click_to_select",{},"click to select")}`),this._hoverNearest={id:s,cid:v.s.cid})}}else u.forEach(v=>{const b=Dt(v.points,o);b!=null&&(p(b,_(v)),g.push(`${d(v.name||this._t("lbl.power",{},"Power"))}: ${b.toFixed(b<100?1:0)} W`))});if(n.band){const v=Dt(n.band.min,o),b=Dt(n.band.max,o);v!=null&&b!=null&&g.push(`${this._t("lbl.envelope",{},"Envelope")}: ${v.toFixed(0)}\u2013${b.toFixed(0)} W`)}(n.artifacts||[]).forEach(v=>{if(o>=v.start_s&&o<=v.end_s){const b=v.detail_key?this._t(v.detail_key,v.detail_params||{},v.detail||""):v.detail||"";g.push(`\u26A0 ${d(Zt(v.type,($,k,S)=>this._t($,k,S)))}: ${d(b)}`)}}),this._canvasZoom[s]&&g.push(`${this._t("lbl.zoom_hint",{},"scroll to zoom \xB7 dblclick to reset")}`),l.restore(),this._showGraphTip(t,e,g),this._syncSpagRowHighlight(this._hoverNearest?this._hoverNearest.cid:null)}_showGraphTip(t,e,s){const r=this._gtip;if(!r)return;r.innerHTML=s.join("
"),r.style.display="block";const n=r.offsetWidth,a=r.offsetHeight,o=16;let i=t+o,l=e+o;i+n>window.innerWidth-6&&(i=t-n-o),l+a>window.innerHeight-6&&(l=e-a-o),r.style.left=Math.max(6,i)+"px",r.style.top=Math.max(6,l)+"px"}_hideGraphTip(){this._hoverRafId&&(cancelAnimationFrame(this._hoverRafId),this._hoverRafId=null,this._hoverPending=null),this._gtip&&(this._gtip.style.display="none"),this._syncSpagRowHighlight(null)}_positionTip(t){const e=t.querySelector(".wd-tip-pop");if(!e)return;e.style.transform="";const s=!e.offsetWidth,r=s?e.style.cssText:"";s&&(e.style.visibility="hidden",e.style.display="block");const n=e.getBoundingClientRect();if(s&&(e.style.cssText=r),!n.width)return;const a=ue(t),o=6,i=a.left+o,l=a.right-o-n.width,c=l{s.style.backgroundColor=t&&s.dataset.cid===t?"var(--secondary-background-color,rgba(0,0,0,.06))":""})}_showToast(t,e="success",s={}){this._toastTimer&&clearTimeout(this._toastTimer);const r=s.duration||3500;this._toast={msg:t,cls:`wd-toast-${e}`,actionLabel:s.actionLabel||null,actionToken:s.actionToken||null},this._render(),this._toastTimer=setTimeout(()=>{this._toast=null,this._render()},r)}_profileOptions(t){return(this._profiles||[]).map(e=>``).join("")}_htmlModal(){const t=this._modal;if(t.type==="cycle-detail")return`
`;if(t.type==="profile-panel")return`
`;if(t.type==="profile-group")return`
`;if(t.type==="compare-cycles")return`
`;if(t.type==="gear-settings")return`
`;if(t.type==="export-select")return`
`;if(t.type==="import-wizard")return`
`;if(t.type==="history-import")return`
`;let e="";if(t.type==="confirm")e=`

${d(t.title)}

${d(t.message)}

+
+
`;else if(t.type==="label-cycle")e=`

${this._t("modal.label_cycle",{},"Label Cycle")}

+
+
+ +
+
`;else if(t.type==="create-profile"){const s=(this._cycles||[]).slice(0,40).map(r=>``).join("");e=`

${this._t("modal.create_profile",{},"Create Profile")}

+
+
+
${this._t("msg.manual_duration_ref_hint",{},"Only used when no reference cycle is selected \u2014 a reference cycle sets the duration from its own length.")}
+
+
`}else if(t.type==="create-phase")e=`

${this._t("modal.new_phase",{},"New Phase")}

+
+
+
+
`;else if(t.type==="edit-phase"){const s=t.isDefault?`

${this._t("msg.edit_builtin_phase",{},"This is a built-in phase. Saving creates a custom override \u2014 the original is preserved and can be restored by deleting the override.")}

`:"";e=`

${this._t("modal.edit_phase",{},"Edit Phase")} ${t.isDefault?`${this._t("badge.built_in_tag",{},"built-in")}`:""}

+ ${s} +
+
+
+
`}else if(t.type==="process-recording")e=`

${this._t("modal.process_recording",{},"Process Recording")}

+
+
+
+
${d(this._t("msg.head_trim_hint",{},"Remove this many seconds from the start"))}
+
${d(this._t("msg.tail_trim_hint",{},"Remove this many seconds from the end"))}
+
+
`;else if(t.type==="correct-feedback")e=`

${this._t("modal.correct_feedback",{},"Correct Feedback")}

+

WashData detected: ${d(t.detectedProfile)}

+
+
+
+
`;else if(t.type==="import-config")e=`

${this._t("modal.import_config",{},"Import Configuration")}

+

${this._t("msg.import_intro",{},"Load an exported file or paste a JSON payload below.")}

+
+
+
+
`;else if(t.type==="auto-label")e=`

${this._t("modal.auto_label",{},"Auto-Label Cycles")}

+

${this._t("msg.auto_label_intro",{},"Assign profiles to unlabelled cycles whose match confidence clears the threshold.")}

+
+
+
`;else if(t.type==="merge-cycles")e=`

${this._t("modal.merge_cycles",{n:t.ids.length},`Merge ${t.ids.length} Cycles`)}

+

${this._t("msg.merge_intro",{},"The selected cycles are combined into one (chronological order; gaps filled with 0 W). Pick the resulting profile.")}

+
+
+ +
+
`;else if(t.type==="bulk-relabel")e=`

${this._t("modal.relabel_cycles",{count:t.ids.length},`Relabel ${t.ids.length} cycles`)}

+
+
+
+
`;else if(t.type==="store-import"){const s=t.mode!=="merge",r=``;e=`

${this._t("modal.store_import",{},"Import reference cycle")}

+
+ + +
+ ${s?`
`:`
${r}
`} +
+
`}else if(t.type==="store-share"){const s=Array.isArray(t.profiles)?t.profiles:[],r=[],n=new Set,a=l=>{const c=(l||"").toLowerCase();l&&!n.has(c)&&(n.add(c),r.push(l))};a(t.program),s.forEach(l=>a(l.program));const o=t.profiles==null?` ${this._t("msg.loading",{},"Loading\u2026")}`:"",i=r.length?r.map(l=>``).join(""):``;e=`

${this._t("modal.store_share",{},"Share to community store")}

+

${this._t("msg.store_share_intro",{},"Upload this reference cycle so others with the same appliance can use it. It is reviewed before appearing publicly.")}

+
+
+ + +
+
+
+
`}else t.type==="store-share-device"&&(e=this._htmlShareDeviceModal(t));return`
`}_htmlShareDeviceModal(t){const e=this._shareableByProgram(),s=this._busy.has("store-share-device"),r=t.selected||new Set,n=!!t.consented,a=e.reduce((u,v)=>u+v.cycles.filter(b=>r.has(b.id)).length,0),o=e.some(u=>!u.noCycles);let i;t.loading?i=`
\u23F3
${this._t("msg.loading",{},"Loading\u2026")}
`:e.length?i=e.map(u=>{if(u.noCycles)return`
+
+ ${d(u.program)} + ${this._t("msg.share_profile_no_cycles",{},"No reference cycles \u2014 mark a cycle as \u2B50 in the Cycles tab to include this profile")} +
+
`;const v=u.cycles.every(C=>r.has(C.id)),b=!v&&u.cycles.some(C=>r.has(C.id)),$=u.cycles.map(C=>{const R=C.start_time?st(C.start_time):"",B=C.duration!=null?ot(C.duration):"";return``}).join(""),S=(this._sharePhasePrograms||[]).includes(u.program)?``:"";return`
+ +
${$}
+ ${S} +
`}).join(""):i=`
\u{1F4E4}
${this._t("msg.share_device_none",{},"No shareable cycles yet. Mark a recorded or hand-picked cycle as a reference cycle (\u2B50) in the Cycles tab first.")}
`;const l=d((this._opts.store_brand||"").trim()),c=d((this._opts.store_model||"").trim()),_=``,p=`
+ ${this._t("msg.share_guidelines_title",{},"Before you share")} +
    +
  • ${this._t("msg.share_guideline_naming",{},"Name each profile exactly as shown on the appliance dial or display (e.g. 'Cotton 40', 'Eco 60').")}
  • +
  • ${this._t("msg.share_guideline_quality",{},"Only share cycles that completed normally -- no mid-cycle interruptions, door-open events, or power blips.")}
  • +
  • ${this._t("msg.share_guideline_review",{},"Your upload starts as pending and appears publicly once enough community members confirm it.")}
  • +
+
`,g=o?``:"";return`

${this._t("modal.store_share_device",{},"Share this device")}

+

${this._t("msg.store_share_device_intro",{brand:l,model:c},`Upload ${l} ${c} with the reference cycles you select. Others with the same appliance can adopt your programs. Entries are reviewed before appearing publicly.`)}

+ ${p} +
${i}
+ ${o?_:""} + ${g} +
+ + +
`}_wizCatOrder(){return["profiles","real_cycles","reference_cycles","custom_phases","profile_groups","settings","matching_config","ml_models","feedback","suggestions","maintenance_log","history_logs","lifetime_stats"]}_wizCatLabel(t){return{profiles:this._t("lbl.cat_profiles",{},"Profiles (programs)"),real_cycles:this._t("lbl.cat_real_cycles",{},"Cycles (run history)"),reference_cycles:this._t("lbl.cat_reference_cycles",{},"Reference cycles (imported)"),custom_phases:this._t("lbl.cat_custom_phases",{},"Custom phases"),profile_groups:this._t("lbl.cat_profile_groups",{},"Profile groups"),settings:this._t("lbl.cat_settings",{},"Detection & matching settings"),matching_config:this._t("lbl.cat_matching_config",{},"Matcher tuning"),ml_models:this._t("lbl.cat_ml_models",{},"ML models"),feedback:this._t("lbl.cat_feedback",{},"Feedback & review labels"),suggestions:this._t("lbl.cat_suggestions",{},"Suggestions"),maintenance_log:this._t("lbl.cat_maintenance_log",{},"Maintenance log"),history_logs:this._t("lbl.cat_history_logs",{},"History & change logs"),lifetime_stats:this._t("lbl.cat_lifetime_stats",{},"Lifetime totals")}[t]||t}_wizInitSel(t,e){const s=new Set,r=new Set,n=new Set,a=new Set,o=t&&t.categories||{},i=l=>!!(l&&l.present&&(!e||l.importable!==!1));return i(o.profiles)&&(o.profiles.items||[]).forEach(l=>r.add(l.name)),i(o.real_cycles)&&(o.real_cycles.groups||[]).forEach(l=>l.cycles.forEach(c=>{c.id!=null&&n.add(String(c.id))})),i(o.reference_cycles)&&(o.reference_cycles.groups||[]).forEach(l=>l.cycles.forEach(c=>{c.id!=null&&a.add(String(c.id))})),Object.keys(o).forEach(l=>{["profiles","real_cycles","reference_cycles"].includes(l)||i(o[l])&&s.add(l)}),{cats:s,profiles:r,realIds:n,refIds:a}}_wizSelectionPayload(t){const e=t.sel,s=[];e.profiles.size&&s.push("profiles"),e.realIds.size&&s.push("real_cycles"),e.refIds.size&&s.push("reference_cycles"),e.cats.forEach(n=>s.push(n));const r={categories:s};return e.profiles.size&&(r.profiles=Array.from(e.profiles)),e.realIds.size&&(r.real_cycle_ids=Array.from(e.realIds)),e.refIds.size&&(r.reference_cycle_ids=Array.from(e.refIds)),r}_wizGroupIds(t,e,s){const n=(((t.categories||{})[e]||{}).groups||[]).find(a=>a.profile===s);return n?n.cycles.filter(a=>a.id!=null).map(a=>String(a.id)):[]}_wizCatState(t,e,s){const r=t.sel,n=(s.categories||{})[e]||{};if(e==="profiles"){const o=n.items||[],i=o.length,l=o.filter(c=>r.profiles.has(c.name)).length;return{sel:l,total:i,state:l===0?"none":l===i?"all":"some"}}if(e==="real_cycles"||e==="reference_cycles"){const o=e==="real_cycles"?r.realIds:r.refIds,i=n.count||0;let l=0;return(n.groups||[]).forEach(c=>c.cycles.forEach(_=>{o.has(String(_.id))&&l++})),{sel:l,total:i,state:l===0?"none":l===i?"all":"some"}}const a=r.cats.has(e);return{sel:a?1:0,total:1,state:a?"all":"none"}}_htmlSelectionTree(t,e,s={}){const r=e&&e.categories||{},n=new Set;s.conflicts&&(r.profiles&&r.profiles.items||[]).forEach(_=>{_.conflict&&n.add(_.name)});const a=this._wizCatOrder().filter(_=>r[_]&&r[_].present);let o=!1,i=!0;a.forEach(_=>{if(s.importableOnly&&r[_].importable===!1)return;const p=this._wizCatState(t,_,e);p.state!=="none"&&(o=!0),p.state!=="all"&&(i=!1)});const l=``,c=a.map(_=>{const p=r[_],g=s.importableOnly&&p.importable===!1,u=this._wizCatState(t,_,e),b=["profiles","real_cycles","reference_cycles"].includes(_)?`${u.sel}/${p.count}`:"",$=``;let k="";if(!g&&_==="profiles")k=`
${(p.items||[]).map(S=>``).join("")}
`;else if(!g&&(_==="real_cycles"||_==="reference_cycles")){const S=_==="real_cycles"?t.sel.realIds:t.sel.refIds;k=`
${(p.groups||[]).map(C=>{const R=C.cycles.map(M=>String(M.id)),B=R.filter(M=>S.has(M)).length,W=B===R.length&&R.length>0,O=B>0&&!W,D=`${_}:${C.profile}`,A=t.expanded&&t.expanded.has(D),E=A?C.cycles.map(M=>``).join(""):"";return`
+
+ + +
+ ${E} +
`}).join("")}
`}return`
${$}${k}
`}).join("");return`
${l}${c}
`}_htmlExportSelectModal(t){const e=this._busy.has("export-select");let s;t.loading||!t.inventory?s=`
\u23F3
${this._t("msg.loading",{},"Loading\u2026")}
`:s=this._htmlSelectionTree(t,{categories:t.inventory},{importableOnly:!1});const r=t.sel&&(t.sel.profiles.size||t.sel.realIds.size||t.sel.refIds.size||t.sel.cats.size);return`

${this._t("modal.export_select",{},"Export - choose data")}

+

${this._t("msg.export_select_intro",{},"Tick exactly what to include. Selecting profiles without their cycles still exports a matchable program (its learned shape travels along).")}

+ ${s} +
+ + +
`}_htmlImportWizardModal(t){const e=this._busy.has("import-wizard"),s=`

${this._t("modal.import_wizard",{},"Import - choose data")}

`;if(t.step==="input"||t.step==="analyze"){const $=t.step==="analyze";return`${s} +

${this._t("msg.import_analyze_hint",{},"Load an exported file (or paste its JSON). WashData analyzes it and shows exactly what can be imported before anything changes.")}

+
+
+ ${t.error?`

${d(t.error)}

`:""} +
+ + +
`}const r=t.manifest||{},n=r.device_type_match===!1,a=d(r.source_device_type||"?"),o=d(r.local_device_type||"?"),i=n?`
+ ${this._t("msg.device_type_mismatch_warn",{src:a,local:o},"This export is from a different appliance type ("+a+" vs "+o+"). Programs and cycles can still be imported as reference data, but device-specific settings and real-history import are disabled.")} +
`:"",l=this._htmlSelectionTree(t,r,{importableOnly:!0,conflicts:!0}),c=`
+
+ + +
+
${t.mode==="replace"?this._t("msg.replace_warn",{},"Each ticked category is wiped and replaced from the file. Unticked categories are left untouched."):this._t("msg.merge_hint",{},"Imported items are added; nothing local is lost. Name clashes are resolved below.")}
+
`,_=r.real_history_allowed!==!1,p=`
+
+ + +
+
${t.cycleDest==="real_history"?this._t("msg.dest_real_history_hint",{},"Imported cycles count as this device's own history and feed energy/usage stats. Use for moving one appliance to a new install."):this._t("msg.dest_reference_hint",{},"Imported cycles only improve program matching and never affect usage/energy statistics.")}
+
`;let g="";const v=(r.categories&&r.categories.profiles&&r.categories.profiles.items||[]).filter($=>$.conflict&&t.sel.profiles.has($.name));t.mode==="merge"&&v.length&&(g=`
+ ${v.map($=>`
+ ${d($.name)} + +
`).join("")} +
`);const b=t.sel&&(t.sel.profiles.size||t.sel.realIds.size||t.sel.refIds.size||t.sel.cats.size);return`${s} + ${i} + ${l} + ${c} + ${p} + ${g} +
+ + +
`}_histSkipReason(t){return{idle:this._t("lbl.hist_skip_idle",{},"nothing running"),sparse:this._t("lbl.hist_skip_sparse",{},"readings too far apart"),too_few_samples:this._t("lbl.hist_skip_short",{},"too few readings"),too_long:this._t("lbl.hist_skip_long",{},"no break long enough to split on")}[t]||t||""}_histSegReason(t){return{shorter_than_minimum:this._t("lbl.hist_reason_short",{},"shorter than this appliance's shortest real cycle"),no_clean_end:this._t("lbl.hist_reason_no_end",{},"never ended cleanly")}[t]||""}_htmlHistoryImportModal(t){const e=`

${this._t("modal.history_import",{},"Import power history")}

`,s=t.error?`

${d(t.error)}

`:"";if(t.step==="input"){const $=this._busy.has("hist-import"),k=$?"disabled":"";return`${e} +

${this._t("msg.hist_input_hint",{},"Upload a CSV downloaded from the History panel (entity, state, last changed), or let WashData read the sensor's history directly. Detection then runs over it exactly as it does live, and you choose which of the cycles it finds to keep.")}

+
+
+
+ +
+ ${this._t("lbl.hist_since",{},"Since")} + + +
+
${this._t("msg.hist_recorder_hint",{},"Reads from the date you pick up to now. Home Assistant keeps detailed history for 10 days by default and only hourly averages after that, which are too coarse to detect cycles from - pick a date further back only if your recorder is set to keep more.")}
+
+ ${s} +
+ + +
`}if(t.step==="scan"){const $=t.scanTaskId?(this._tasks||{})[t.scanTaskId]:null,k=$&&$.total>0?Math.round($.done/$.total*100):null;return`${e} +

${this._t("msg.hist_scanning",{},"Replaying your history through the detector. This runs in the background - you can close this dialog and come back to it.")}

+
+

${k==null?this._t("status.preparing",{},"Preparing\u2026"):`${k}%`}

+ ${s} +
+ +
`}if(t.step==="done"){const $=t.done||{},k=[this._t("msg.hist_imported_count",{n:$.imported||0},`${$.imported||0} cycles imported.`),$.duplicates?this._t("msg.hist_duplicates",{n:$.duplicates},`${$.duplicates} were already imported and were skipped.`):"",$.capped?this._t("msg.hist_capped",{},"The per-device limit for imported cycles was reached; the rest were not stored."):""].filter(Boolean);return`${e} + ${k.map(S=>`

${d(S)}

`).join("")} +

${this._t("msg.hist_next_step",{},"They are in your Cycles list, tagged as imported history. Open one and use Label to name the program it belongs to.")}

+
+ + +
`}const r=t.result||{},n=r.segments||[],a=r.parse||{},o=this._busy.has("hist-apply"),i=t.accept||new Set,l=[];a.rows_total&&l.push(this._t("msg.hist_rows_read",{n:a.rows_total},`${a.rows_total} readings read`)),a.first&&a.last&&l.push(`${st(a.first)} \u2013 ${st(a.last)}`),a.breaks&&l.push(this._t("msg.hist_breaks",{n:a.breaks},`${a.breaks} gaps where the sensor was unavailable`)),a.rows_other_entity&&l.push(this._t("msg.hist_other_entity",{n:a.rows_other_entity},`${a.rows_other_entity} readings for other entities ignored`)),a.entity_substituted_from&&l.push(this._t("msg.hist_entity_substituted",{used:a.entity_id||"?",wanted:a.entity_substituted_from},`read ${a.entity_id||"?"} (this device is configured for ${a.entity_substituted_from})`));const c=r.skipped||[],_={};c.forEach($=>{_[$.reason]=(_[$.reason]||0)+1});const p=Object.entries(_).map(([$,k])=>`${k} \xD7 ${this._histSkipReason($)}`).join(", "),g=r.settings||{},u=g.min_power!=null?this._t("msg.hist_settings_used",{w:g.min_power,s:g.off_delay},`Detected using this device's current settings (minimum power ${g.min_power} W, off delay ${g.off_delay} s).`):"";if(!n.length)return`${e} +

${this._t("msg.hist_none_found",{},"No cycles could be detected in that history.")}

+ ${l.length?`

${d(l.join(" \xB7 "))}

`:""} + ${p?`

${this._t("msg.hist_skipped_spans",{},"Skipped stretches")}: ${d(p)}

`:""} + ${u?`

${d(u)}

`:""} +
+ + +
`;const v=n.map($=>{const k=i.has($.index),S=this._histSegReason($.reason);return` + + ${d(st($.start_time))} + ${d(ot($.duration_s))} + ${$.energy_wh!=null?d(($.energy_wh/1e3).toFixed(2))+" kWh":"\u2013"} + ${d(String(Math.round($.peak_w)))} W + + ${S?`\u26A0 ${d(S)}`:`${d(this._t("lbl.hist_looks_complete",{},"complete"))}`} + `}).join(""),b=n.every($=>i.has($.index));return`${e} +

${this._t("msg.hist_found",{n:n.length},`Found ${n.length} cycles. Untick anything that does not look like a real run - nothing is stored until you import.`)}

+ ${l.length?`

${d(l.join(" \xB7 "))}

`:""} + ${p?`

${this._t("msg.hist_skipped_spans",{},"Skipped stretches")}: ${d(p)}

`:""} + ${u?`

${d(u)}

`:""} +
+
+ + + + + + + + + ${v}
${this._t("lbl.date",{},"Date")}${this._t("lbl.duration",{},"Duration")}${this._t("lbl.energy",{},"Energy")}${this._t("lbl.peak_power_short",{},"Peak")}${this._t("lbl.shape",{},"Shape")}${this._t("lbl.notes",{},"Notes")}
+
+ ${r.capped?`

${this._t("msg.hist_scan_capped",{n:r.found},`Only the first candidates are shown (${r.found} were found).`)}

`:""} + ${s} +
+ + +
`}_drawHistorySparklines(){const t=this.shadowRoot;if(!t)return;const e=this._modal,s=e&&e.result&&e.result.segments||[],r={};s.forEach(n=>{r[String(n.index)]=n.curve||[]}),t.querySelectorAll("canvas[data-hist-spark]").forEach(n=>{this._paintSparkline(n,r[n.dataset.histSpark]||[])})}_htmlCycleModal(t){if(!t.loaded)return`

${this._t("modal.cycle",{},"Cycle")}

\u23F3
${this._t("msg.loading_curve",{},"Loading curve\u2026")}
+
`;const e=t.curve||{},s=!!e.is_reference,r=e.labelable!==!1,n=e.editable!==!1,a=e.cycle_origin==="backfill",o=e.full_duration_s||e.duration||0,i=e.energy_kwh!=null?e.energy_kwh:null,l=t.ml||null;let c="";if(l&&l.ml_quality_score!=null){const E=l.ml_quality_label,M=E==="ok"?"var(--success-color,#4caf50)":E==="uncertain"?"var(--warning-color,#ff9800)":"var(--error-color,#f44336)",H=Math.round((1-l.ml_quality_score)*100);c=`
${H}%
${this._t("lbl.cycle_health",{},"Cycle health")}
`}const _=`
+
${ot(e.duration||o)}
${this._t("lbl.duration",{},"Duration")}
+
${gt(i)}
${this._t("lbl.energy",{},"Energy")}
+
${d(e.profile_name||this._t("lbl.unlabelled",{},"Unlabelled"))}
${this._t("lbl.profile",{},"Profile")}
+
${d(e.status||"-")}
${this._t("lbl.status",{},"Status")}
+ ${c} +
`,p=l&&l.ml_review||{},g=(this._feedbacks||[]).some(E=>E.cycle_id===t.cycleId),u=l&&l.ml_quality_label,v=!p.reviewed_at&&(g||["uncertain","review"].includes(u)||["force_stopped","interrupted"].includes(e.status)),b=v&&t.mode!=="review"?` \u25CF`:"",$=this._canEdit()&&n?`
+ + + + +
`:s?`
\u{1F4E5} ${a?this._t("msg.imported_history_readonly",{},"Detected in imported power history. It shapes program matching but is not counted in your statistics, and cannot be trimmed or split. Label it to name the program."):this._t("msg.imported_readonly",{},"Imported from the community store. Shown for reference and matching. It is not counted in your stats and cannot be edited.")}
`:"",k=this._canEdit()?(this._feedbacks||[]).find(E=>E.cycle_id===t.cycleId):null,S=k?k.detected_profile||k.profile_name||this._t("lbl.unknown",{},"Unknown"):"",C=k?` +
+
\u26A0 ${this._t("msg.pending_feedback",{},"Pending detection feedback")}
+

${this._t("msg.unsure_detected_prefix",{},"WashData is unsure it detected")} ${d(S)}${k.confidence!=null?` (${this._t("lbl.confidence",{},"confidence").toLowerCase()} ${(k.confidence*100).toFixed(0)}%)`:""}. ${this._t("msg.feedback_prompt",{},"Confirm it was right, correct the program, or ignore.")} ${this._t("msg.feedback_relabel_hint",{},"Re-labelling this cycle resolves it too.")}

+
+ + + +
+
`:"";let R="";if(t.mode==="view"){const E=!!(l&&l.ml_review&&l.ml_review.golden),H=this._canEdit()&&this._onlineEnabled()&&this._storeConnected&&E?``:"",q=this._canEdit()?`${r?` + `:""}`:"";R=`${C}
+ + ${H} + ${q}
`}else if(t.mode==="trim"){const E=this._busy.has("cyc-trim-apply"),M=t.timeMode||"s",H=M==="clock"?this._offsetToClock(t.trim.start):Math.round(t.trim.start),q=M==="clock"?this._offsetToClock(t.trim.end):Math.round(t.trim.end),Q=M==="clock"?"time":"number",J=M==="clock"?'step="1"':`min="0" max="${Math.ceil(o)}" step="1"`,G=M==="clock"?"":" "+this._t("lbl.unit_s",{},"(s)");R=`

${this._t("msg.trim_intro",{},"Drag the red handles, or enter values. Everything outside the window is removed.")}

+
+ ${this._t("lbl.input",{},"Input:")} + + +
+
+
+
+
+
+ + + +
`}else if(t.mode==="split"){const E=this._busy.has("cyc-split-apply"),M=(t.split.offsets||[]).slice().sort((Q,J)=>Q-J),H=[0,...M,o],q=H.slice(0,-1).map((Q,J)=>{const G=H[J+1];return`
+ ${ot(Q)} \u2013 ${ot(G)} +
`}).join("");R=`

${this._t("msg.split_intro",{},"Click the graph to add or remove a split point, or auto-detect by idle gaps. Each resulting segment can get its own profile.")}

+
+
+ + +
+
${M.length?q:`

${this._t("msg.no_split_points",{},"No split points yet.")}

`}
+
+ + +
`}else if(t.mode==="review"){const E=l&&l.ml_review||{},M=this._busy.has("cyc-review-save"),H=(f,m)=>``,Q=[["late_start",this._t("tag.late_start",{},"Late start")],["early_end",this._t("tag.early_end",{},"Early end")],["merged",this._t("tag.merged",{},"Merged cycles")],["split",this._t("tag.split",{},"Split cycle")],["noise",this._t("tag.noise",{},"Noise")],["wrong_profile",this._t("tag.wrong_profile",{},"Wrong profile")],["sensor_gap",this._t("tag.sensor_gap",{},"Sensor gap")]].map(([f,m])=>``).join(""),J=E.reviewed_at?`${this._t("lbl.reviewed_on",{date:new Date(E.reviewed_at).toLocaleDateString()},`reviewed ${new Date(E.reviewed_at).toLocaleDateString()}`)}`:"",G=it(this._t("msg.review_profile_tip",{},"The program this cycle is labelled as. If the auto-detected program was wrong, correct it here - labelling teaches matching for future cycles.")),X=it(this._t("msg.review_quality_tip",{},"How clean this cycle is. Good = a textbook example of this program; Bad = detected but noisy or atypical; Unusable = mis-detected (merged, truncated or spurious). Drives the health score and which cycles are allowed to train the model.")),ct=it(this._t("msg.review_recorded_tip",{},'Mark this as a hand-picked reference cycle for its program - the same role as a manually recorded cycle. Reference cycles are always kept, seed the matching template, and are never dropped by cleanup. (This is the "golden"/recorded flag; both are the same thing.)')),U=it(this._t("msg.review_tags_tip",{},"Optional flags describing what went wrong with this cycle, so training and cleanup can account for it.")),at=it(this._t("msg.review_notes_tip",{},"Free-text notes for your own reference. Not used by matching or training."));R=` + ${C} +

+ ${this._t("msg.review_confirm_help",{},"Confirm whether this cycle was detected correctly. Your reviews train the model on your machine - the more cycles you confirm, the better matching and health scoring get. A quick Good/Bad is enough.")} +

+
+ + + + ${J} +
+
${this._t("lbl.compare_profiles",{},"Compare with profiles")}${it(this._t("msg.compare_profiles_tip",{},"Overlay other profile envelopes on the chart above to see which one best fits this cycle."))}
+
${(this._profiles||[]).map(f=>{const m=(t.overlays||[]).includes(f.name),x=m?``:"";return``}).join("")||`${this._t("msg.no_profiles_compare",{},"No profiles to compare.")}`}
+
${this._t("lbl.tags",{},"Tags")}${U}
+
${Q}
+
${this._t("lbl.notes",{},"Notes")}${at}
+ +
+ + +
`}let B="";const W=t.mode==="view"||t.mode==="review"?e.artifacts||[]:[];if(W.length){const E=W.map(M=>{const H=M.detail_key?this._t(M.detail_key,M.detail_params||{},M.detail||""):M.detail||"";return`
  • ${d(Zt(M.type,(q,Q,J)=>this._t(q,Q,J)))} ${this._t("lbl.at",{},"at")} ${Mt(M.start_s)}\u2013${Mt(M.end_s)} \u2014 ${d(H)}
  • `}).join("");B=`
    +
    \u26A0 ${this._t("msg.artifact_header",{n:W.length},`${W.length} anomal${W.length>1?"ies":"y"} detected during this cycle`)}
    +
      ${E}
    +
    ${this._t("msg.artifact_footer",{},"Highlighted on the graph above. These are transient artifacts (e.g. the door opened mid-cycle), not necessarily problems.")}
    +
    `}let O="";const D=t.mode==="view"||t.mode==="review"?e.restart_gaps||[]:[];if(D.length){const E=D.map(M=>{const H=Math.round((M.gap_seconds||0)/60),q=H>=1?`${H}m`:`${Math.round(M.gap_seconds||0)}s`,Q=M.match_confidence!=null?` \xB7 ${this._t("lbl.pct_match_confidence",{pct:Math.round(M.match_confidence*100)},`${Math.round(M.match_confidence*100)}% match confidence`)}`:"",J=M.profile?` (${d(M.profile)})`:"";return`
  • ${this._t("msg.restart_gap_item",{dur:q},`${q} gap`)}: ${this._t("lbl.ha_restarted",{},"HA restarted")}${J}${Q}
  • `}).join("");O=`
    +
    \u21BB ${this._t("msg.restart_gap_header",{n:D.length},`${D.length} HA restart gap${D.length>1?"s":""} during this cycle`)}
    +
      ${E}
    +
    ${this._t("msg.restart_gap_footer",{},"Highlighted on the graph. Power data is missing for these intervals \u2014 matching used only real readings.")}
    +
    `}let A="";if(e.decimated){const E=(e.samples||[]).length,M=e.sample_count||E;A=`
    ${this._t("msg.samples_decimated",{shown:E,total:M},`Showing ${E} of ${M} samples (thinned for display; peaks kept). A wide gap here is thinning, not missing data.`)}
    `}return`

    ${this._t("lbl.cycle",{},"Cycle")} \xB7 ${d(st(e.start_time))}

    + ${_}${$} +
    + ${A} + ${B} + ${O} + ${R}`}_htmlProfilePanel(t){const e=this._canEdit();t.tab==="danger"&&!e&&(t.tab="stats");const s=[["stats",this._t("tab.pp_overview",{},"Overview")],["phases",this._t("tab.pp_phases",{},"Phases")],["cleanup",this._t("tab.pp_cleanup",{},"Cleanup")]];e&&s.push(["danger",this._t("tab.pp_manage",{},"Manage")]);const r=s.map(([o,i])=>``).join("");let n="";if(!t.loaded)n=`
    \u23F3
    ${this._t("msg.loading",{},"Loading\u2026")}
    `;else if(t.tab==="stats"){const o=t.stats||{},i=t.env||{},l=this._hass&&this._hass.config&&this._hass.config.currency||"",c=o.avg_energy!=null&&o.cycle_count?o.avg_energy*o.cycle_count:null,_=b=>b?Math.round(b/60)+"m":"-",p=(this._profileHealth||{})[t.name],g=(this._profileTrends||{})[t.name],u=p&&p.health_status!=="unknown"?(()=>{const b={healthy:["var(--success-color,#4caf50)","rgba(76,175,80,.12)"],fair:["var(--warning-color,#ff9800)","rgba(255,152,0,.12)"],poor:["var(--error-color,#f44336)","rgba(244,67,54,.12)"]},[$,k]=b[p.health_status]||b.fair,S=Math.round((p.health_score||0)*100),C=p.duration_cv!=null?` \xB7 ${this._t("stat.duration_cv",{pct:Math.round(p.duration_cv*100)},`duration CV ${Math.round(p.duration_cv*100)}%`)}`:"",R=p.confidence_mean!=null?` \xB7 ${this._t("stat.avg_confidence",{pct:Math.round(p.confidence_mean*100)},`avg confidence ${Math.round(p.confidence_mean*100)}%`)}`:"";return`
    + ${p.health_status==="poor"?this._t("health.poor",{},"\u26A0 Poor match fit"):p.health_status==="fair"?this._t("health.fair",{},"Fair match fit"):this._t("health.good",{},"\u2713 Good match fit")} + ${this._t("stat.score",{pct:S},`score ${S}%`)}${C}${R} + ${p.health_status==="poor"?`${this._t("msg.profile_poor_health_detail",{},"Cycles assigned to this profile have inconsistent shapes or low confidence. Consider rebuilding the envelope or reviewing labelled cycles.")}`:""} +
    `})():"",v=g&&(g.duration_trend!=="stable"||g.energy_trend&&g.energy_trend!=="stable")?(()=>{const b=[];g.duration_trend==="up"?b.push(this._t("msg.trend_duration_longer",{pct:`${g.duration_slope_pct>0?"+":""}${g.duration_slope_pct}`,avg:`${Math.round(g.duration_recent_mean_s/60)}m`},`Duration trending longer (${g.duration_slope_pct>0?"+":""}${g.duration_slope_pct}%/cycle) \u2014 recent avg ${Math.round(g.duration_recent_mean_s/60)}m`)):g.duration_trend==="down"&&b.push(this._t("msg.trend_duration_shorter",{pct:`${g.duration_slope_pct}`,avg:`${Math.round(g.duration_recent_mean_s/60)}m`},`Duration trending shorter (${g.duration_slope_pct}%/cycle) \u2014 recent avg ${Math.round(g.duration_recent_mean_s/60)}m`)),g.energy_trend==="up"?b.push(this._t("msg.trend_energy_up",{pct:`${g.energy_slope_pct>0?"+":""}${g.energy_slope_pct}`,avg:gt(g.energy_recent_mean_wh)},`Energy trending up (${g.energy_slope_pct>0?"+":""}${g.energy_slope_pct}%/cycle) \u2014 recent avg ${gt(g.energy_recent_mean_wh)}`)):g.energy_trend==="down"&&b.push(this._t("msg.trend_energy_down",{pct:`${g.energy_slope_pct}`},`Energy trending down (${g.energy_slope_pct}%/cycle)`));const $=g.duration_trend==="up"||g.energy_trend==="up";return`
    + ${this._t("msg.performance_trend",{n:g.cycle_count},`Performance trend (${g.cycle_count} cycles)`)}
    + ${b.map(C=>`${C}`).join("
    ")} + ${$?`
    ${this._t("msg.maintenance_advisory",{},"Increasing duration/energy may indicate appliance maintenance needed (e.g. descaling, filter cleaning).")}`:""} +
    `})():"";n=`
    +
    +
    ${this._t("lbl.duration",{},"Duration")}
    +
    ${_(o.avg_duration)}${this._t("stat.avg",{},"avg")}
    +
    ${this._t("stat.min",{v:_(o.min_duration)},`min ${_(o.min_duration)}`)} \xB7 ${this._t("stat.max",{v:_(o.max_duration)},`max ${_(o.max_duration)}`)}${i.duration_std_dev!=null?` \xB7 ${this._t("stat.consistency",{v:`${Math.round(i.duration_std_dev/60)}m`},`consistency \xB1${Math.round(i.duration_std_dev/60)}m`)}`:""}
    +
    +
    +
    ${this._t("lbl.energy",{},"Energy")}
    +
    ${gt(o.avg_energy)}${this._t("stat.avg",{},"avg")}
    +
    ${this._t("stat.total",{v:gt(c)},`total ${gt(c)}`)}
    +
    + ${o.avg_cost!=null?`
    +
    ${this._t("lbl.avg_cost",{},"Avg cost")}
    +
    ${o.avg_cost.toFixed(2)}${l?" "+l:""}${this._t("stat.avg",{},"avg")}
    +
    ${this._t("stat.total",{v:o.total_cost!=null?o.total_cost.toFixed(2)+(l?" "+l:""):"-"},`total ${o.total_cost!=null?o.total_cost.toFixed(2)+(l?" "+l:""):"-"}`)}
    +
    `:""} +
    +
    ${this._t("lbl.activity",{},"Activity")}
    +
    ${o.cycle_count||0}${this._t("lbl.cycles_lc",{},"cycles")}
    +
    ${this._t("stat.last_run",{v:o.last_run?st(o.last_run):"-"},`last run ${o.last_run?st(o.last_run):"-"}`)}
    +
    +
    + ${u} + ${v} + ${p&&p.shape_drift?(()=>{const b=p.shape_drift_correlation!=null?` (r=${Number(p.shape_drift_correlation).toFixed(2)})`:"";return`
    + ${this._t("msg.shape_drift_advisory",{},"\u26A0 Shape drifting")}${d(b)} + ${this._t("msg.shape_drift_detail",{},"The power pattern for this profile has shifted over time \u2014 possible appliance wear or maintenance needed (e.g. descaling, filter cleaning).")} +
    `})():""} + ${(()=>{const b=(this._profileAdvisories||[]).find($=>$&&$.profile===t.name&&$.code==="phase_inconsistent");return b?`
    + ${this._t("msg.advisory_phase_inconsistent_title",{},"\u26A0 Possibly mixed programs")} + ${d(this._t(b.message_key,b.message_params,b.message))} +
    `:""})()} + ${i.avg&&i.avg.length?`
    `:`

    ${this._t("msg.no_envelope",{},"No envelope yet - rebuild after labelling cycles.")}

    `}`}else if(t.tab==="phases"){const o=t.catalog||[],i=(t.phases||[]).map((c,_)=>{const p=o.map(g=>``).join("");return`
    + + \u2013 + ${this._t("lbl.timer_min",{},"min")} +
    `}).join(""),l=this._busy.has("pp-phase-save");n=`

    ${this._t("msg.phase_ranges_intro",{},"Phase ranges (minutes from cycle start) overlaid on the average curve. Edit values to preview live.")}

    + ${t.env&&t.env.avg&&t.env.avg.length?`
    `:`

    ${this._t("msg.no_envelope_overlay",{},"No envelope available to overlay.")}

    `} +
    ${i||`

    ${this._t("msg.no_phases_assigned",{},"No phases assigned.")}

    `}
    + ${e?`
    + + +
    `:""}`}else if(t.tab==="cleanup"){const o=t.cleanup&&t.cleanup.cycles||[],i=t.cleanup&&t.cleanup.selected||new Set,{col:l,dir:c}=this._cleanupSort,_={date:b=>b.start_time?new Date(b.start_time).getTime():0,duration:b=>b.duration,energy:b=>b.energy_kwh,status:b=>b.status||""},g=Qt(o,_[l]||_.date,c).map((b,$)=>{const k=o.indexOf(b),S=e?` + + `:"";return` + + + ${st(b.start_time)} + ${ot(b.duration)} + ${b.energy_kwh!=null?gt(b.energy_kwh):"-"} + ${d(b.status||"completed")} + ${S} + `}).join(""),u=` + + ${ft(this._t("lbl.date",{},"Date"),"date",l==="date",c,"cleanupsort")} + ${ft(this._t("lbl.duration",{},"Duration"),"duration",l==="duration",c,"cleanupsort","right")} + ${ft(this._t("lbl.energy",{},"Energy"),"energy",l==="energy",c,"cleanupsort","right")} + ${ft(this._t("lbl.status",{},"Status"),"status",l==="status",c,"cleanupsort")} + ${e?"":""} + `,v=this._busy.has("pp-cleanup-del");n=`

    ${this._t("msg.cleanup_intro",{},"Every labelled cycle overlaid. Tick outliers and delete to clean up the profile.")}

    + ${o.length?`
    `:`

    ${this._t("msg.no_cycles_profile",{},"No cycles for this profile.")}

    `} + ${o.length?`
    ${u}${g}
    `:""} + ${e?`
    `:""}`}else if(t.tab==="danger"){const o=this._busy.has("pp-rebuild"),i=t.stats&&t.stats.avg_duration?Math.round(t.stats.avg_duration/60):0;n=`
    +
    +
    ${this._t("msg.manual_duration_hint",{},"The profile's average/expected cycle length, used for time-remaining estimates. Edit to set it; leaving it unchanged keeps the current value.")}
    +
    + + + +
    `}const a=this._onlineEnabled()&&this._storeDeviceDeclared()?``:"";return`

    Profile \xB7 ${d(t.name)}

    +
    ${r}
    + ${n} +
    + + ${a} +
    `}_drawCycleEditor(){const t=this._modal;if(!t||t.type!=="cycle-detail"||!t.loaded)return;const e=t.curve||{},s=e.samples||[];if(!s.length)return;let r=e.full_duration_s||s[s.length-1][0]||1;const n=[],a=t.profileEnv;if((t.mode==="view"||t.mode==="review")&&a&&(a.avg||[]).length&&(n.push({points:a.avg,stroke:"#ff9800",width:2,alpha:.45,name:`${this._t("lbl.expected",{},"Expected")} (${e.profile_name||"profile"})`}),r=Math.max(r,a.target_duration||a.avg[a.avg.length-1][0]||0)),t.mode==="review"&&(t.overlays||[]).length){const c=this._profileEnvCache||{};(t.overlays||[]).forEach(_=>{const p=c[_];if(!p||!(p.avg||[]).length)return;const g=et[Math.max(0,(this._profiles||[]).findIndex(v=>v.name===_))%et.length];n.push({points:p.avg,stroke:g,width:1.6,alpha:.7,name:_});const u=p.avg[p.avg.length-1];r=Math.max(r,p.target_duration||(u?u[0]:0))})}n.push({points:s,stroke:"primary",fill:!0,width:2,name:this._t("lbl.power",{},"Power")});const o=[],i=[];let l=[];if(t.mode==="trim"){const c=t.trim.start,_=t.trim.end;o.push({x0:0,x1:c,fill:"rgba(244,67,54,.18)"}),o.push({x0:_,x1:r,fill:"rgba(244,67,54,.18)"}),i.push({x:c,color:"#f44336",label:"S"},{x:_,color:"#f44336",label:"E"})}else if(t.mode==="split")(t.split.offsets||[]).slice().sort((c,_)=>c-_).forEach((c,_)=>i.push({x:c,color:"#ff9800",label:"#"+(_+1)}));else{l=e.artifacts||[];const c={pause:"rgba(255,152,0,.22)",dip:"rgba(33,150,243,.18)",spike:"rgba(244,67,54,.18)"};l.forEach(p=>o.push({x0:p.start_s,x1:Math.max(p.end_s,p.start_s+1),fill:c[p.type]||"rgba(158,158,158,.18)"}));const _=e.start_time;(e.restart_gaps||[]).forEach(p=>{if(!_)return;const g=new Date(_).getTime(),u=Math.max(0,(new Date(p.start_ts).getTime()-g)/1e3),v=Math.max(u+1,(new Date(p.end_ts).getTime()-g)/1e3);o.push({x0:u,x1:v,fill:"rgba(96,125,139,.20)",label:"\u21BB"})})}this._drawCurves("wd-cyc-canvas",{series:n,xMax:r,bands:o,vlines:i,artifacts:l})}_htmlCompareModal(t){const e=t.ids||[],s={};(this._cycles||[]).forEach(o=>{s[o.id]=o});const r=t.hidden||new Set,n=e.map((o,i)=>{const l=s[o]||{},c=et[i%et.length],_=!r.has(o),p=!!(t.cycles&&t.cycles[o]),g=`${st(l.start_time)||String(o).slice(0,8)} \xB7 ${Math.round((l.duration||0)/60)}m \xB7 ${d(l.profile_name||this._t("lbl.unlabelled",{},"Unlabelled"))}`;return``}).join(""),a=(this._profiles||[]).map(o=>{const i=(t.overlays||[]).includes(o.name),l=et[Math.max(0,(this._profiles||[]).findIndex(_=>_.name===o.name))%et.length],c=i?``:"";return``}).join("")||`${this._t("msg.no_profiles_overlay",{},"No profiles to overlay.")}`;return`

    ${this._t("msg.compare_cycles_title",{count:e.length},`Compare ${e.length} cycles`)}

    + ${t.loaded?"":`
    ${this._t("msg.loading",{},"Loading\u2026")}
    `} +
    +
    ${this._t("msg.compare_selected_cycles",{},"Selected cycles (solid) \u2014 show / hide")}
    +
    ${n}
    +
    ${this._t("msg.compare_overlay_profiles",{},"Overlay profiles (faint)")}${it(this._t("msg.compare_overlay_tip",{},"Overlay learned profile envelopes to see which program each cycle resembles."))}
    +
    ${a}
    +
    + +
    `}_drawCompareCanvas(){const t=this._modal;if(!t||t.type!=="compare-cycles")return;const e=t.ids||[],s={};(this._cycles||[]).forEach(i=>{s[i.id]=i});const r=t.hidden||new Set,n=[];let a=0;const o=this._profileEnvCache||{};(t.overlays||[]).forEach(i=>{const l=o[i];if(!l||!(l.avg||[]).length)return;const c=et[Math.max(0,(this._profiles||[]).findIndex(p=>p.name===i))%et.length];n.push({points:l.avg,stroke:c,width:2,alpha:.4,name:i});const _=l.avg[l.avg.length-1];a=Math.max(a,l.target_duration||(_?_[0]:0))}),e.forEach((i,l)=>{if(r.has(i))return;const c=t.cycles&&t.cycles[i],_=c&&c.samples;if(!_||!_.length)return;const p=et[l%et.length],g=s[i]||{};n.push({points:_,stroke:p,width:1.8,alpha:.9,name:st(g.start_time)||String(i).slice(0,8)}),a=Math.max(a,c.full_duration_s||_[_.length-1][0]||0)}),this._drawCurves("wd-compare-canvas",{series:n,xMax:a||1})}_drawProfileEnvelope(){const t=this._modal;if(!t||!t.env||!(t.env.avg||[]).length)return;const e=t.env;this._drawCurves("wd-env-canvas",{series:[{points:e.avg,stroke:"primary",width:2,name:"Average"}],band:{min:e.min,max:e.max},xMax:e.target_duration||e.avg[e.avg.length-1][0]})}_drawPhaseEditor(){const t=this._modal;if(!t||!t.env||!(t.env.avg||[]).length)return;const e=t.env,s=e.target_duration||e.avg[e.avg.length-1][0],r=(t.phases||[]).map((a,o)=>({x0:a.start,x1:a.end,fill:St(et[o%et.length],.2)})),n=[];(t.phases||[]).forEach((a,o)=>{const i=et[o%et.length];n.push({x:a.start,color:i,label:a.name?a.name.slice(0,7):"",handle:!0}),n.push({x:a.end,color:i,handle:!0})}),this._drawCurves("wd-phase-canvas",{series:[{points:e.avg,stroke:"primary",width:2,name:"Average"}],band:{min:e.min,max:e.max},bands:r,vlines:n,xMax:s})}_drawSpaghetti(){const t=this._modal;if(!t||!t.cleanup||!(t.cleanup.cycles||[]).length)return;const e=t.cleanup.cycles,s=t.cleanup.selected||new Set,r=this._spagTableHoverCid||null;let n=1;e.forEach(o=>{const i=o.samples||[];i.length&&i[i.length-1][0]>n&&(n=i[i.length-1][0])});const a=e.map((o,i)=>{const l=s.has(o.cycle_id),c=r===o.cycle_id;return{points:o.samples||[],stroke:et[i%et.length],width:l||c?2.6:1,alpha:s.size?l?1:.22:r?c?1:.22:.7,name:st(o.start_time),cid:o.cycle_id}});this._drawCurves("wd-spag-canvas",{series:a,xMax:n})}_wire(){const t=this.shadowRoot;if(!t)return;const e=t.getElementById("wd-burger");e&&e.addEventListener("click",()=>{this.dispatchEvent(new CustomEvent("hass-toggle-menu",{bubbles:!0,composed:!0}))}),t.querySelectorAll(".wd-devcard[data-idx]").forEach(f=>f.addEventListener("click",()=>this._selectDevice(parseInt(f.dataset.idx,10)))),t.querySelectorAll("[data-tab]").forEach(f=>f.addEventListener("click",()=>{f.dataset.tab!=="settings"&&(this._pendingSettings={}),this._tab=f.dataset.tab,this._fetchTabData()})),t.querySelectorAll("[data-sec]").forEach(f=>f.addEventListener("click",()=>{this._snapshotFormToPending(t),this._settingsSec=f.dataset.sec,this._settingsSearch="",this._settingsSugOnly=!1,this._render()})),t.querySelectorAll("[data-ptab]").forEach(f=>f.addEventListener("click",()=>{const m=this._panelSubtab=f.dataset.ptab;this._render();const x=this._devices[this._selIdx];x&&(m==="diagnostics"&&!this._diag?this._fetchToolsData(x.entry_id).then(()=>{this._panelSubtab==="diagnostics"&&this._render()}):m==="logs"?this._fetchLogs().then(()=>{this._panelSubtab==="logs"&&this._render()}):m==="maintenance"?this._fetchMaintenance(x.entry_id).then(()=>{this._panelSubtab==="maintenance"&&this._render()}):m==="ml"&&this._fetchTabData())})),t.querySelectorAll("[data-gtab]").forEach(f=>f.addEventListener("click",()=>{this._gearTab=f.dataset.gtab,this._modal&&this._modal.type==="gear-settings"&&(this._modal.tab=this._gearTab);const m=this._devices[this._selIdx];this._gearTab==="online"&&m&&this._onlineEnabled()&&(this._ensureStoreConnectListener(),this._loadStoreStatus(m.entry_id).then(()=>{this._modal&&this._modal.type==="gear-settings"&&this._render()})),this._render()}));const s=t.getElementById("wd-store-brand");s&&s.addEventListener("change",()=>{const f=s.value.trim();this._opts={...this._opts,store_brand:f},delete this._pendingSettings.store_brand,this._catalog.forBrand=f,this._catalog.devices=void 0,this._render()});const r=t.getElementById("wd-store-model");r&&r.addEventListener("change",()=>{this._opts={...this._opts,store_model:r.value.trim()},delete this._pendingSettings.store_model,this._render()});const n=t.getElementById("wd-pg-canvas");if(n){const f=()=>{const I=n.getBoundingClientRect(),w=I.height||330,P=Bt+8,z=w-P-52;return{rect:I,padT:P,powerH:z}},m=(I,w)=>{const P=n.getBoundingClientRect(),z=I-P.left,F=w-P.top;if(F>Bt+4)return null;const K=this._pgEventHits||[];let Z=null,rt=1/0;for(const ht of K){const nt=(z-ht.cx)**2+(F-ht.cy)**2;nt<=(ht.r+4)**2&&nt{const{rect:w,padT:P,powerH:z}=f(),F=this._pgPowerPts;if(!F?.length)return 0;const K=Math.max(...F.map(Z=>Z.w),1);return Math.max(0,(1-Math.max(0,I-w.top-P)/z)*K)},L=I=>{const{rect:w,padT:P,powerH:z}=f(),F=this._pgPowerPts;if(!F?.length)return 0;const K=Math.max(...F.map(Z=>Z.w),1);return w.top+P+(1-Math.max(0,+I)/K)*z},j=I=>{const w=this._pgMap;if(!w)return null;const{rect:P}=f(),z=(I-P.left-w.padLpx)/Math.max(1,w.plotWpx);return w.vMin+Math.max(0,Math.min(1,z))*(w.vMax-w.vMin)};n.addEventListener("pointermove",I=>{if(this._pgDragging==="start_thr"){this._pgThreshStart=Math.max(0,x(I.clientY)),this._pgUpdateParamInput("start_threshold_w",this._pgThreshStart),this._pgDrawCanvas();return}if(this._pgDragging==="stop_thr"){this._pgThreshStop=Math.max(0,x(I.clientY)),this._pgUpdateParamInput("stop_threshold_w",this._pgThreshStop),this._pgDrawCanvas();return}if(this._pgDragging==="pan"&&this._pgPanStart){const Z=this._pgMap,rt=(I.clientX-this._pgPanStart.clientX)/Math.max(1,Z.plotWpx),ht=this._pgPanStart.vMax-this._pgPanStart.vMin;let nt=this._pgPanStart.vMin-rt*ht;nt=Math.max(0,Math.min(this._pgPanStart.totalDur-ht,nt)),this._pgView={min:nt,max:nt+ht},this._pgDrawCanvas();return}if(!this._pgPowerPts?.length)return;const w=m(I.clientX,I.clientY);if(w){n.style.cursor="pointer",this._pgHoverEvent={t:w.t,type:w.type},this._pgHoverT=null,this._pgDrawCanvas();return}this._pgHoverEvent&&(this._pgHoverEvent=null,this._pgDrawCanvas());const P=this._pgThreshStart??this._pgFieldVal("start_threshold_w",{})??50,z=this._pgThreshStop??this._pgFieldVal("stop_threshold_w",{})??5,F=Math.abs(I.clientY-L(P))<8||Math.abs(I.clientY-L(z))<8;n.style.cursor=F?"ns-resize":"crosshair";const K=j(I.clientX);K!=null&&(this._pgHoverT=K,this._pgUpdateStripAt(K),this._pgDrawCanvas())}),n.addEventListener("pointerdown",I=>{if(!this._pgPowerPts?.length||m(I.clientX,I.clientY))return;n.setPointerCapture(I.pointerId);const w=this._pgThreshStart??this._pgFieldVal("start_threshold_w",{})??50,P=this._pgThreshStop??this._pgFieldVal("stop_threshold_w",{})??5;Math.abs(I.clientY-L(w))<10?this._pgDragging="start_thr":Math.abs(I.clientY-L(P))<10?this._pgDragging="stop_thr":this._pgMap&&(this._pgDragging="pan",this._pgPanStart={clientX:I.clientX,vMin:this._pgMap.vMin,vMax:this._pgMap.vMax,totalDur:this._pgMap.totalDur},n.classList.add("wd-pg-panning"))}),n.addEventListener("pointerup",I=>{try{n.releasePointerCapture(I.pointerId)}catch{}const w=this._pgDragging==="start_thr"||this._pgDragging==="stop_thr";this._pgDragging=null,this._pgPanStart=null,n.classList.remove("wd-pg-panning"),w&&this._pgDrawCanvas()}),n.addEventListener("pointerleave",()=>{this._pgDragging||(this._pgHoverT=null,this._pgHoverEvent=null,this._pgUpdateStripAt(null),this._pgDrawCanvas())}),n.addEventListener("wheel",I=>{if(!this._pgPowerPts?.length||!this._pgMap)return;I.preventDefault();const w=this._pgMap,P=j(I.clientX);if(P==null)return;const z=w.vMax-w.vMin,F=I.deltaY>0?1.25:.8,K=Math.max(30,Math.min(w.totalDur,z*F)),Z=z>0?(P-w.vMin)/z:.5;let rt=P-Z*K;rt=Math.max(0,Math.min(w.totalDur-K,rt)),this._pgView=K>=w.totalDur-1?null:{min:rt,max:rt+K},this._pgDrawCanvas()},{passive:!1}),n.addEventListener("dblclick",()=>{this._pgView=null,this._pgDrawCanvas()})}t.querySelectorAll("input[data-pgkey]").forEach(f=>{f.addEventListener("input",()=>{const m=f.dataset.pgkey;if(f.dataset.pgtype==="bool"){const w=f.checked;m==="start_threshold_w"?this._pgThreshStart=w:m==="stop_threshold_w"?this._pgThreshStop=w:this._pgParamOverrides[m]=w,this._render();const P=t.querySelector(`input[data-pgkey="${m}"]`);P&&P.focus(),requestAnimationFrame(()=>this._pgDrawCanvas());return}const x=f.value.trim();if(!x)m==="start_threshold_w"?this._pgThreshStart=null:m==="stop_threshold_w"?this._pgThreshStop=null:delete this._pgParamOverrides[m];else{const w=parseFloat(x);isNaN(w)||(m==="start_threshold_w"?this._pgThreshStart=w:m==="stop_threshold_w"?this._pgThreshStop=w:this._pgParamOverrides[m]=w)}const L=f.selectionStart,j=f.value;this._render();const I=t.querySelector(`input[data-pgkey="${m}"]`);if(I){I.value=j,I.focus();try{I.setSelectionRange(L,L)}catch{}}requestAnimationFrame(()=>this._pgDrawCanvas())})});const a=t.getElementById("wd-pg-stress-toggle");a&&a.addEventListener("change",()=>{this._pgStressTail=a.checked,this._pgStressIdleW=null,this._render()});const o=t.getElementById("wd-pg-stress-idle-w");o&&o.addEventListener("input",()=>{const f=parseFloat(o.value);this._pgStressIdleW=Number.isFinite(f)&&f>=0?f:null});const i=t.getElementById("wd-pg-cyc-sel");i&&i.addEventListener("change",()=>this._pgSelectCycle(i.value));const l=t.getElementById("wd-pg-prof-sel");l&&l.addEventListener("change",()=>{this._pgProfileName=l.value,this._pgLoad()});const c=t.getElementById("wd-pg-preset-sel");c&&c.addEventListener("change",()=>{this._pgPresetSel=c.value,this._render()});const _=t.getElementById("wd-pg-preset-name");_&&_.addEventListener("input",()=>{this._pgPresetName=_.value;const f=(this._pgPresetName||"").trim(),m=this._pgPresetLimit>0&&(this._pgPresets||[]).length>=this._pgPresetLimit&&!(this._pgPresets||[]).some(j=>j.name===f),x=t.querySelector('[data-action="pg-preset-save"]');x&&(x.disabled=!(f&&!m));const L=t.getElementById("wd-pg-preset-limit-note");L&&(L.style.display=m?"":"none")});const p=t.getElementById("wd-pg-simn");p&&p.addEventListener("input",()=>{this._pgSimCycles=Math.max(1,Math.min(200,parseInt(p.value,10)||20))});const g=t.getElementById("wd-pg-sw-param");g&&g.addEventListener("change",()=>{this._pgSweepParam=g.value,this._pgSweepNew=null,this._render()});const u=t.getElementById("wd-pg-sw-obj");u&&u.addEventListener("change",()=>{this._pgSweepObjective=u.value,this._pgSweepNew=null,this._render()});const v=t.getElementById("wd-pg-sw-paramy"),b=t.getElementById("wd-pg-sw-from");b&&b.addEventListener("input",()=>{this._pgSweepFrom=b.value});const $=t.getElementById("wd-pg-sw-to");$&&$.addEventListener("input",()=>{this._pgSweepTo=$.value});const k=t.getElementById("wd-pg-sw-steps");k&&k.addEventListener("input",()=>{this._pgSweepSteps=parseInt(k.value,10)||5}),t.querySelectorAll("[data-statustoggle]").forEach(f=>f.addEventListener("change",async()=>{const m=f.dataset.statustoggle,x=f.checked;this._panelCfg||(this._panelCfg={}),this._panelCfg.prefs={...this._panelCfg.prefs||{},[m]:x},this._ws({type:`${y}/set_user_prefs`,prefs:{[m]:x}}).catch(()=>{});const L=this._devices[this._selIdx];if(L&&this._tab==="status")try{this._powerData=await this._ws({type:`${y}/get_power_history`,entry_id:L.entry_id,with_raw:this._pref("show_raw_active",!1)})}catch{}this._render()})),t.querySelectorAll("[data-sortact]").forEach(f=>f.addEventListener("click",()=>{const m=f.dataset.sortact,x=f.dataset.sortcol,L=j=>{j.col===x?j.dir*=-1:(j.col=x,j.dir=x==="date"?-1:1)};m==="cycsort"?L(this._cycleSort):m==="cleanupsort"&&L(this._cleanupSort),this._render()}));const S=t.getElementById("wd-cyc-filter-text");S&&S.addEventListener("input",f=>{const m=f.target.selectionStart;this._cycleFilter.text=S.value,this._render();const x=this.shadowRoot.getElementById("wd-cyc-filter-text");x&&(x.focus(),x.setSelectionRange(m,m))});const C=t.getElementById("wd-settings-search");C&&C.addEventListener("input",f=>{const m=f.target.selectionStart;this._settingsSearch=C.value,C.value.trim()&&(this._settingsSugOnly=!1),this._render();const x=this.shadowRoot.getElementById("wd-settings-search");x&&(x.focus(),x.setSelectionRange(m,m))});const R=t.getElementById("wd-cyc-filter-status");R&&R.addEventListener("change",()=>{this._cycleFilter.status=R.value,this._render()}),t.querySelectorAll(".wd-pillbox").forEach(f=>{const m=f.querySelector(".wd-pill-add"),x=j=>{const I=document.createElement("span");I.className="wd-pill",I.dataset.val=j,I.appendChild(document.createTextNode(j));const w=document.createElement("button");return w.type="button",w.className="wd-pill-x",w.setAttribute("aria-label","Remove"),w.textContent="\xD7",w.addEventListener("click",()=>I.remove()),I.appendChild(w),I},L=j=>{const I=String(j||"").trim();if(!I)return;Array.from(f.querySelectorAll(".wd-pill")).some(P=>P.dataset.val===I)||f.insertBefore(x(I),m.closest(".wd-combo")||m),m&&(m.value="")};f.querySelectorAll(".wd-pill-x").forEach(j=>j.addEventListener("click",()=>j.closest(".wd-pill")?.remove())),m&&(m.addEventListener("change",()=>L(m.value)),m.addEventListener("keydown",j=>{j.key==="Enter"&&(j.preventDefault(),L(m.value))}),m.addEventListener("blur",()=>L(m.value)))}),t.querySelectorAll(".wd-combo").forEach(f=>{const m=f.querySelector(".wd-combo-inp, .wd-pill-add"),x=f.querySelector(".wd-combo-drop");if(!m||!x)return;const L=f.classList.contains("wd-combo-pill"),j=m.dataset.opt||f.closest("[data-opt]")?.dataset.opt,I=P=>{this._ensureCatalogList(j,P);const z=(this._entityListCache||{})[j]||[],F=(P||"").toLowerCase(),K=F?z.filter(Z=>Z.toLowerCase().includes(F)).slice(0,40):z.slice(0,20);if(!K.length){x.hidden=!0;return}x.innerHTML=K.map(Z=>`
    ${d(Z)}
    `).join(""),x._kbd=-1,x.hidden=!1},w=P=>{if(P){if(L){const z=f.closest(".wd-pillbox");if(z&&!Array.from(z.querySelectorAll(".wd-pill")).some(F=>F.dataset.val===P)){const F=document.createElement("span");F.className="wd-pill",F.dataset.val=P,F.appendChild(document.createTextNode(P));const K=document.createElement("button");K.type="button",K.className="wd-pill-x",K.setAttribute("aria-label","Remove"),K.textContent="\xD7",K.addEventListener("click",()=>F.remove()),F.appendChild(K),z.insertBefore(F,f)}m.value=""}else m.value=P,m.dispatchEvent(new Event("change",{bubbles:!0}));x.hidden=!0}};m.addEventListener("focus",()=>I(m.value)),m.addEventListener("input",()=>I(m.value)),m.addEventListener("blur",()=>setTimeout(()=>{x.hidden=!0},150)),m.addEventListener("keydown",P=>{if(x.hidden&&P.key!=="ArrowDown")return;const z=x.querySelectorAll(".wd-combo-item");let F=x._kbd||-1;if(P.key==="ArrowDown"){if(P.preventDefault(),x.hidden){I(m.value);return}F=Math.min(F+1,z.length-1)}else if(P.key==="ArrowUp")P.preventDefault(),F=Math.max(F-1,0);else if(P.key==="Enter"&&!x.hidden){P.preventDefault(),F>=0?w(z[F].dataset.val):L&&m.value.trim()&&w(m.value.trim());return}else if(P.key==="Escape"){x.hidden=!0;return}else return;x._kbd=F,z.forEach((K,Z)=>K.classList.toggle("kbd",Z===F)),z[F]?.scrollIntoView({block:"nearest"})}),x.addEventListener("mousedown",P=>{const z=P.target.closest(".wd-combo-item");z&&(P.preventDefault(),w(z.dataset.val))})}),t.querySelectorAll(".wd-timerlist").forEach(f=>{const m=f.dataset.opt,x=()=>{if(!Array.isArray(this._pendingSettings[m])){const z=Array.isArray(this._opts&&this._opts[m])?this._opts[m]:[];this._pendingSettings[m]=z.map(F=>({...F}))}return this._pendingSettings[m]},L=z=>({offset_minutes:parseFloat(z.querySelector('[data-field="offset_minutes"]').value)||0,message:(z.querySelector('[data-field="message"]').value||"").trim(),auto_pause:z.querySelector('[data-field="auto_pause"]').checked}),j=z=>{const F=parseInt(z.dataset.tidx,10);if(isNaN(F))return;const K=x();K[F]=L(z)},I=z=>{const F=parseInt(z.dataset.tidx,10);isNaN(F)||(this._pendingSettings[m]=x().filter((K,Z)=>Z!==F)),z.remove(),f.querySelectorAll(".wd-timer-row").forEach((K,Z)=>{K.dataset.tidx=Z})},w=z=>{const F=z.querySelector(".wd-timer-remove");F&&F.addEventListener("click",()=>I(z)),z.querySelector('[data-field="auto_pause"]')?.addEventListener("change",()=>j(z)),z.querySelectorAll('[data-field="offset_minutes"],[data-field="message"]').forEach(K=>K.addEventListener("input",()=>j(z)))};f.querySelectorAll(".wd-timer-row").forEach(z=>w(z));const P=f.querySelector(".wd-timer-add");P&&P.addEventListener("click",()=>{const z=f.querySelectorAll(".wd-timer-row").length;x().push({offset_minutes:0,message:"",auto_pause:!1});const F=document.createElement("div");F.className="wd-timer-row",F.dataset.tidx=z,F.innerHTML=`
    `,w(F),f.insertBefore(F,P)})});const B=t.getElementById("wd-status-prog");B&&B.addEventListener("change",()=>{const f=this._devices[this._selIdx];if(!f)return;const m=B.value;this._ws({type:`${y}/set_program`,entry_id:f.entry_id,program:m}).then(()=>(this._showToast(m==="auto_detect"?this._t("msg.toast_auto_detect_enabled",{},"Auto-detect enabled"):this._t("msg.toast_program_set",{program:m},`Program set: ${m}`)),this._fetchAll())).catch(x=>this._showToast(this._t("msg.toast_failed",{error:x.message||x},"Failed: "+(x.message||x)),"error"))}),t.querySelectorAll("[data-cid]").forEach(f=>f.addEventListener("click",m=>{if(m.target.tagName==="INPUT"||f.dataset.action)return;const x=f.dataset.cid;f.dataset.selmode==="1"?(this._cycleSel.has(x)?this._cycleSel.delete(x):this._cycleSel.add(x),this._render()):this._onAction({dataset:{action:"open-cycle",cid:x}})})),t.querySelectorAll(".wd-cyc-overlay").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="cycle-detail")return;this._snapshotCycleReviewForm(t);const x=new Set(m.overlays||[]);f.checked?x.add(f.value):x.delete(f.value),m.overlays=[...x];const L=this._devices[this._selIdx];f.checked&&L?this._ensureProfileEnvs(L.entry_id,[f.value]).then(()=>this._render()):this._render()})),t.querySelectorAll(".wd-compare-cyc").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="compare-cycles")return;const x=m.hidden instanceof Set?m.hidden:new Set(m.hidden||[]);f.checked?x.delete(f.value):x.add(f.value),m.hidden=x,this._render()})),t.querySelectorAll(".wd-compare-overlay").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="compare-cycles")return;const x=new Set(m.overlays||[]);f.checked?x.add(f.value):x.delete(f.value),m.overlays=[...x];const L=this._devices[this._selIdx];f.checked&&L?this._ensureProfileEnvs(L.entry_id,[f.value]).then(()=>this._render()):this._render()})),t.querySelectorAll(".wd-pg-mem").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="profile-group")return;const x=t.getElementById("wd-pg-name");x&&(m.name=x.value);const L=new Set(m.members||[]);f.checked?L.add(f.value):L.delete(f.value),m.members=[...L],this._render()}));const W=t.getElementById("wd-pg-name");W&&W.addEventListener("input",()=>{const f=this._modal;f&&f.type==="profile-group"&&(f.name=W.value)}),t.querySelectorAll(".wd-csel").forEach(f=>f.addEventListener("change",()=>{const m=f.closest("[data-cid]"),x=m&&m.dataset.cid;x&&(f.checked?this._cycleSel.add(x):this._cycleSel.delete(x),this._render())}));const O=t.getElementById("wd-merge-prof");O&&O.addEventListener("change",()=>{const f=t.getElementById("wd-merge-new");f&&(f.style.display=O.value==="__create_new__"?"":"none")}),t.querySelectorAll(".wd-log-filter").forEach(f=>{const m=f.dataset.logfilter,x={level:"_logLevel",device:"_logDevice",component:"_logComponent",search:"_logSearch"}[m];if(!x)return;const L=m==="search"?"input":"change";f.addEventListener(L,()=>{this[x]=f.value,this._refreshLogViews(),this._syncLogFilters(f)})});const D=t.querySelector(".wd-log-resize");D&&D.addEventListener("pointerdown",f=>{f.preventDefault(),D.setPointerCapture(f.pointerId),D.classList.add("dragging");const m=t.querySelector(".wd-log-drawer"),x=f.clientX,L=m.offsetWidth,j=I=>{const w=Math.max(280,Math.min(900,L+(x-I.clientX)));m.style.width=w+"px",this._logDrawerWidth=w};D.addEventListener("pointermove",j),D.addEventListener("pointerup",()=>{D.removeEventListener("pointermove",j),D.classList.remove("dragging");try{localStorage.setItem("wd-log-width",String(this._logDrawerWidth))}catch{}},{once:!0})});const A=t.getElementById("wd-hist-file");A&&A.addEventListener("change",()=>{const f=A.files&&A.files[0];if(!f)return;const m=this._modal,x=new FileReader;x.onload=()=>{const L=t.getElementById("wd-hist-csv"),j=String(x.result||"");L&&(L.value=j),m&&m.type==="history-import"&&(m.csvText=j)},x.onerror=()=>this._showToast(this._t("toast.file_read_failed",{},"Could not read that file"),"error"),x.readAsText(f)}),t.querySelectorAll("[data-hist-pick]").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="history-import")return;const x=parseInt(f.dataset.histPick,10);f.checked?m.accept.add(x):m.accept.delete(x),this._render(),requestAnimationFrame(()=>this._drawHistorySparklines())}));const E=t.getElementById("wd-import-file");E&&E.addEventListener("change",()=>{const f=E.files&&E.files[0];if(!f)return;const m=new FileReader;m.onload=()=>{const x=t.getElementById("wd-import-json");x&&(x.value=String(m.result||""))},m.readAsText(f)}),t.querySelectorAll("[data-stab]").forEach(f=>f.addEventListener("click",()=>{this._toolsSubtab=f.dataset.stab;const m=this._devices[this._selIdx];m&&(this._tabLoading=!0,this._render(),this._fetchToolsData(m.entry_id).then(()=>{this._tabLoading=!1,this._render()}))})),t.querySelectorAll("[data-proftab]").forEach(f=>f.addEventListener("click",async()=>{this._profSubtab=f.dataset.proftab;const m=this._devices[this._selIdx];m&&this._profSubtab==="phase-catalog"&&!this._phases.length&&(this._tabLoading=!0,this._render(),await this._fetchPhases(m.entry_id),this._tabLoading=!1),this._render()}));const M=t.getElementById("wd-settings-save");M&&M.addEventListener("click",()=>this._saveSettings());const H=t.getElementById("wd-ml-save");H&&H.addEventListener("click",()=>this._saveSettings());const q=t.getElementById("wd-settings-form");q&&(q.addEventListener("submit",f=>f.preventDefault()),q.addEventListener("input",()=>this._liveValidateSettings(t)),q.addEventListener("change",()=>this._liveValidateSettings(t)),q.addEventListener("click",f=>{const m=f.target.closest(".wd-conflict-fix");if(!m)return;const x=m.dataset.ckey,L=parseFloat(m.dataset.cval),j=q.querySelector(`[data-opt="${x}"]`);j&&!isNaN(L)&&(j.value=L,this._cascadeConflictFix(t,q,x))}),this._liveValidateSettings(t));const Q=t.getElementById("wd-settings-revert");Q&&Q.addEventListener("click",async()=>{if(!this._prevOpts)return;const f=this._devices[this._selIdx];f&&await this._busyRun("save-settings",async()=>{try{const m=this._prevOpts;await this._ws({type:`${y}/set_options`,entry_id:f.entry_id,options:m}),this._opts={...m},this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null,this._pendingSettings={},this._showToast(this._t("toast.settings_reverted",{},"Settings reverted; integration reloading")),this._render()}catch(m){this._showToast(this._t("msg.toast_revert_failed",{error:m.message||m},"Revert failed: "+(m.message||m)),"error")}})});const J=t.getElementById("wd-settings-reload");J&&J.addEventListener("click",async()=>{const f=this._devices[this._selIdx];if(f){this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null,this._pendingSettings={};const m=await this._ws({type:`${y}/get_options`,entry_id:f.entry_id});this._opts=m.options||{},this._optDefaults=m.defaults||{},await this._fetchSuggestions(f.entry_id),this._render()}});const G=t.getElementById("wd-pref-fontscale");if(G){const f=t.getElementById("wd-pref-fontscale-val");G.addEventListener("input",()=>{const m=parseFloat(G.value)||1;this._applyFontScale(m),f&&(f.textContent=Math.round(m*100)+"%")}),G.addEventListener("change",()=>{this._setPref("font_scale",parseFloat(G.value)||1)})}t.querySelectorAll("[data-action]").forEach(f=>f.addEventListener("click",m=>this._onAction(m.currentTarget))),t.querySelectorAll("[data-maction]").forEach(f=>f.addEventListener("click",m=>this._onModalAction(m.currentTarget.dataset.maction,m.currentTarget))),t.querySelectorAll("select[data-maction]").forEach(f=>f.addEventListener("change",m=>this._onModalAction(m.currentTarget.dataset.maction,m.currentTarget))),t.querySelectorAll("input[data-indeterminate]").forEach(f=>{f.indeterminate=!0});const X=t.querySelector("[data-toast-undo]");X&&X.addEventListener("click",()=>this._undoDelete(X.dataset.toastUndo)),t.querySelectorAll(".wd-create-cluster").forEach(f=>f.addEventListener("click",()=>{const m=f.dataset.name||"";this._modal={type:"create-profile",prefillName:m},this._render()})),t.querySelectorAll("[data-suglock]").forEach(f=>f.addEventListener("click",async()=>{const m=f.dataset.suglock,x=this._devices[this._selIdx],L=x&&x.entry_id;if(!(!L||!m))try{if(await this._ws({type:`${y}/set_suggestion_lock`,entry_id:L,key:m,locked:!0}),!this._isActiveEntry(L))return;this._suggestions=(this._suggestions||[]).filter(j=>j.key!==m),(this._lockedSuggestions||[]).includes(m)||(this._lockedSuggestions=this._lockedSuggestions||[]).push(m),this._showToast(this._t("msg.sug_muted",{},"Won't suggest this setting again"),"info"),this._render()}catch{this._showToast(this._t("msg.sug_mute_failed",{},"Could not mute suggestion"),"error")}})),t.querySelectorAll("[data-sugkey]").forEach(f=>f.addEventListener("click",()=>{const m=f.dataset.sugkey,x=f.dataset.sugval,L=parseFloat(x);this._pendingSettings[m]=isNaN(L)?x:L,this._stagedSuggestions=!0,this._suggestions=this._suggestions.filter(w=>w.key!==m),this._showToast(this._t("msg.sug_staged",{key:m,val:x},`Set ${m} = ${x}. Save to apply.`),"info"),this._render();const j=this.shadowRoot,I=j?.getElementById("wd-settings-form");I&&this._cascadeConflictFix(j,I,m)}));const ct=t.getElementById("wd-label-profile");ct&&ct.addEventListener("change",()=>{const f=t.getElementById("wd-new-profile-row"),m=ct.value==="__create_new__";if(f&&(f.style.display=m?"":"none"),!m){const x=t.getElementById("wd-new-profile-name");x&&(x.value="")}});const U=t.getElementById("wd-cp-cycle");if(U){const f=()=>{const m=t.getElementById("wd-cp-dur");if(!m)return;const x=!!U.value;m.disabled=x,m.style.opacity=x?"0.5":""};U.addEventListener("change",f),f()}const at=t.getElementById("wd-pr-mode");at&&at.addEventListener("change",()=>{const f=t.getElementById("wd-pr-profile"),m=t.getElementById("wd-pr-existing");if(!f||!m)return;const x=at.value==="existing_profile";f.style.display=x?"none":"",m.style.display=x?"":"none"}),this._wireCycleCanvas(t),this._wirePhaseInputs(t),this._wirePhaseCanvas(t),this._wireCleanup(t),this._wireSplitSegments(t)}_syncTrimInputs(){const t=this.shadowRoot,e=this._modal,s=(e.timeMode||"s")==="clock",r=t.getElementById("wd-trim-start"),n=t.getElementById("wd-trim-end");r&&(r.value=s?this._offsetToClock(e.trim.start):Math.round(e.trim.start)),n&&(n.value=s?this._offsetToClock(e.trim.end):Math.round(e.trim.end))}_snapTrimBounds(){const t=this._modal,e=t.curve&&t.curve.samples||[];if(e.length<2)return;const s=r=>e.reduce((n,a)=>Math.abs(a[0]-r)n[0]===t.trim.start);r>=0&&r+10&&(t.trim.start=e[r-1][0])}}_offsetToClock(t){const e=this._modal,s=e&&e.curve&&e.curve.start_time;if(!s)return"";const r=new Date(new Date(s).getTime()+(t||0)*1e3);return`${String(r.getHours()).padStart(2,"0")}:${String(r.getMinutes()).padStart(2,"0")}:${String(r.getSeconds()).padStart(2,"0")}`}_clockToOffset(t){const e=this._modal,s=e&&e.curve&&e.curve.start_time;if(!s||!t)return null;const r=String(t).split(":").map(Number);if(!r.length||r.some(l=>!Number.isFinite(l)))return null;const n=new Date(s),a=new Date(n);a.setHours(r[0]||0,r[1]||0,r[2]||0,0);let o=(a-n)/1e3;const i=e.curve&&e.curve.full_duration_s||0;return o<-1&&o+86400<=i&&(o+=86400),Math.max(0,Math.min(i,o))}_trimInputToOffset(t){if(t===""||t==null)return null;if(this._modal.timeMode==="clock")return this._clockToOffset(t);const e=Jt(t,NaN);return Number.isFinite(e)?e:null}_toggleSplit(t){const e=this._modal,s=e.curve&&e.curve.full_duration_s||0,r=Math.max(20,s*.025),n=e.split.offsets,a=n.findIndex(o=>Math.abs(o-t)=0?n.splice(a,1):n.push(Math.round(t)),n.sort((o,i)=>o-i),e.split.profiles=[],this._render()}_wireCycleCanvas(t){const e=this._modal;if(!e||e.type!=="cycle-detail"||!e.loaded)return;const s=t.getElementById("wd-cyc-canvas");if(s)if(e.mode==="trim"){const r=t.getElementById("wd-trim-start"),n=t.getElementById("wd-trim-end");r&&r.addEventListener("input",()=>{const i=this._trimInputToOffset(r.value);i!==null&&(e.trim.start=Math.max(0,Math.min(i,e.trim.end-1)),this._drawCycleEditor())}),n&&n.addEventListener("input",()=>{const i=this._trimInputToOffset(n.value);i!==null&&(e.trim.end=Math.min(e.curve.full_duration_s,Math.max(i,e.trim.start+1)),this._drawCycleEditor())});const a=()=>{this._snapTrimBounds(),this._syncTrimInputs(),this._drawCycleEditor()};r&&r.addEventListener("change",a),n&&n.addEventListener("change",a),s.addEventListener("pointerdown",i=>{const l=s._wd;if(!l)return;const c=s.getBoundingClientRect(),_=i.clientX-c.left;e.drag=Math.abs(_-l.xToCss(e.trim.start))<=Math.abs(_-l.xToCss(e.trim.end))?"start":"end",s.setPointerCapture(i.pointerId)}),s.addEventListener("pointermove",i=>{if(!e.drag)return;const l=s._wd;if(!l)return;const c=s.getBoundingClientRect(),_=l.cssToX(i.clientX-c.left);e.drag==="start"?e.trim.start=Math.min(_,e.trim.end-1):e.trim.end=Math.max(_,e.trim.start+1),this._syncTrimInputs(),this._drawCycleEditor()});const o=()=>{e.drag&&(this._snapTrimBounds(),this._syncTrimInputs(),this._drawCycleEditor()),e.drag=null};s.addEventListener("pointerup",o),s.addEventListener("pointercancel",o)}else e.mode==="split"&&s.addEventListener("pointerdown",r=>{const n=s._wd;if(!n)return;const a=s.getBoundingClientRect();this._toggleSplit(n.cssToX(r.clientX-a.left))})}_wireSplitSegments(t){const e=this._modal;!e||e.type!=="cycle-detail"||e.mode!=="split"||t.querySelectorAll("[data-segidx]").forEach(s=>s.addEventListener("change",()=>{e.split.profiles[+s.dataset.segidx]=s.value||null}))}_wirePhaseInputs(t){const e=this._modal;!e||e.type!=="profile-panel"||e.tab!=="phases"||t.querySelectorAll("[data-phidx]").forEach(s=>{const r=()=>{const n=+s.dataset.phidx,a=s.dataset.phfield,o=e.phases[n];o&&(a==="name"?o.name=s.value:o[a]=Math.max(0,Jt(s.value,0)*60),this._drawPhaseEditor())};s.addEventListener("input",r),s.addEventListener("change",r)})}_wirePhaseCanvas(t){const e=this._modal;if(!e||e.type!=="profile-panel"||e.tab!=="phases"||!e.env||!(e.env.avg||[]).length)return;const s=t.getElementById("wd-phase-canvas");if(!s)return;const r=e.env.target_duration||e.env.avg[e.env.avg.length-1][0],n=Math.max(5,r*.01),a=i=>{const l=s._wd;if(!l)return null;let c=null,_=12;return(e.phases||[]).forEach((p,g)=>{[["start",p.start],["end",p.end]].forEach(([u,v])=>{const b=Math.abs(i-l.xToCss(v));b<_&&(_=b,c={idx:g,edge:u})})}),c};s.addEventListener("pointerdown",i=>{const l=s.getBoundingClientRect();e.phaseDrag=a(i.clientX-l.left),e.phaseDrag&&s.setPointerCapture(i.pointerId)}),s.addEventListener("pointermove",i=>{if(!e.phaseDrag)return;const l=s._wd;if(!l)return;const c=s.getBoundingClientRect(),_=l.cssToX(i.clientX-c.left),p=e.phases[e.phaseDrag.idx];p&&(e.phaseDrag.edge==="start"?p.start=Math.max(0,Math.min(_,p.end-n)):p.end=Math.min(r,Math.max(_,p.start+n)),this._syncPhaseInputs(e.phaseDrag.idx),this._drawPhaseEditor())});const o=()=>{e.phaseDrag=null};s.addEventListener("pointerup",o),s.addEventListener("pointercancel",o)}_syncPhaseInputs(t){const e=this.shadowRoot,s=this._modal.phases[t];if(!s)return;const r=e.querySelector(`[data-phidx="${t}"][data-phfield="start"]`),n=e.querySelector(`[data-phidx="${t}"][data-phfield="end"]`);r&&(r.value=(s.start/60).toFixed(1)),n&&(n.value=(s.end/60).toFixed(1))}_wireCleanup(t){const e=this._modal;if(!e||e.type!=="profile-panel"||e.tab!=="cleanup"||!e.cleanup)return;t.querySelectorAll("[data-cleanidx]").forEach(r=>r.addEventListener("change",()=>{const n=e.cleanup.cycles[+r.dataset.cleanidx];if(!n)return;r.checked?e.cleanup.selected.add(n.cycle_id):e.cleanup.selected.delete(n.cycle_id);const a=e.cleanup.selected,o=t.querySelector('[data-maction="pp-cleanup-del"]');o&&!this._busy.has("pp-cleanup-del")&&(o.disabled=a.size===0,o.textContent=this._t("btn.delete_selected",{n:a.size},`Delete selected (${a.size})`)),this._drawSpaghetti()})),t.querySelectorAll("tr[data-cid]").forEach(r=>{r.addEventListener("mouseenter",()=>{this._spagTableHoverCid=r.dataset.cid,this._drawSpaghetti()}),r.addEventListener("mouseleave",()=>{this._spagTableHoverCid=null,this._drawSpaghetti()})});const s=t.getElementById("wd-spag-canvas");s&&s.addEventListener("pointerdown",r=>{this._onGraphHoverInner(r.clientX,r.clientY,"wd-spag-canvas");const n=this._hoverNearest;if(n&&n.cid){const a=e.cleanup.selected;a.has(n.cid)?a.delete(n.cid):a.add(n.cid),this._render()}})}_onAction(t){const e=t.dataset.action,s=this.shadowRoot;if(e==="open-settings"){this._modal={type:"gear-settings",tab:this._gearTab||"prefs"},this._render();return}const r=this._devices[this._selIdx];if(!r)return;const n=r.entry_id;if(e.startsWith("sug-"))return this._onActSuggestions(e,t,r,n,s);if(e.startsWith("ml-"))return this._onActMl(e,t,r,n);if(e.startsWith("store-"))return this._onActStore(e,t,r,n,s);if(e.startsWith("auto-"))return this._onActAuto(e,t,r,n,s);if(e.startsWith("maint-"))return this._onActMaintenance(e,t,r,n,s);if(e.startsWith("pg-")&&e!=="pg-new"&&e!=="pg-edit"&&e!=="pg-suggest")return this._onActPlayground(e,t,r,n);if(e==="open-cycle"){const a=t.dataset.cid,o=t.dataset.mode==="review"?"review":"view";this._modal={type:"cycle-detail",entryId:n,cycleId:a,loaded:!1,mode:o,curve:null,ml:(this._mlById||{})[a]||null,trim:{start:0,end:0},split:{offsets:[],profiles:[]},drag:null},this._profiles.length||this._fetchProfiles(n),this._render(),this._ws({type:`${y}/get_cycle_power_data`,entry_id:n,cycle_id:a}).then(i=>{this._modal&&this._modal.cycleId===a&&(this._modal.curve=i,this._modal.loaded=!0,this._modal.trim={start:0,end:i.full_duration_s||0},this._render(),i.profile_name&&this._fetchCycleProfileEnv(n,i.profile_name))}).catch(i=>this._showToast(this._t("toast.could_not_load_cycle",{error:i.message||i},"Could not load cycle: "+(i.message||i)),"error"))}else if(e==="cleanup-edit-cycle"){const a=t.dataset.cid;this._prevModal=this._modal,this._modal={type:"cycle-detail",entryId:n,cycleId:a,loaded:!1,mode:"view",curve:null,ml:(this._mlById||{})[a]||null,trim:{start:0,end:0},split:{offsets:[],profiles:[]},drag:null},this._profiles.length||this._fetchProfiles(n),this._render(),this._ws({type:`${y}/get_cycle_power_data`,entry_id:n,cycle_id:a}).then(o=>{this._modal&&this._modal.cycleId===a&&(this._modal.curve=o,this._modal.loaded=!0,this._modal.trim={start:0,end:o.full_duration_s||0},this._render(),o.profile_name&&this._fetchCycleProfileEnv(n,o.profile_name))}).catch(o=>this._showToast(this._t("toast.could_not_load_cycle",{error:o.message||o},"Could not load cycle: "+(o.message||o)),"error"))}else if(e==="open-profile"){const a=t.dataset.pname;this._prevModal=null;const o=(this._profiles||[]).find(i=>i.name===a)||{name:a};this._modal={type:"profile-panel",name:a,tab:"stats",loaded:!1,stats:o,env:null,phases:[],catalog:[],cleanup:null},this._render(),Promise.all([this._ws({type:`${y}/get_profile_envelope`,entry_id:n,profile_name:a}).catch(()=>({envelope:null})),this._ws({type:`${y}/get_profile_phases`,entry_id:n,profile_name:a}).catch(()=>({phases:[]})),this._ws({type:`${y}/get_phase_catalog`,entry_id:n}).catch(()=>({phases:[]}))]).then(([i,l,c])=>{!this._modal||this._modal.name!==a||(this._modal.env=i.envelope,this._modal.phases=(l.phases||[]).map(_=>({name:_.name,start:_.start,end:_.end})),this._modal.catalog=(c.phases||[]).map(_=>_.name),this._modal.loaded=!0,this._render())})}else if(e==="create-profile")this._modal={type:"create-profile"},this._render();else if(e==="setup-cta"){const a=t.dataset.ctaAction||"";let o={};try{o=JSON.parse(t.dataset.ctaParams||"{}")}catch{}this._dispatchSetupCta(a,o)}else if(e==="setup-skip"){const a=t.dataset.step,o=t.dataset.snooze;if(a){let i;if(o==="never")i="never";else{const l=new Date;l.setDate(l.getDate()+14),i=l.toISOString()}this._setPref(a,i),this._reloadSetupStatus()}}else if(e==="hide-setup-card")this._setPref("setup_card_dismissed",!0),this._render();else if(e==="expand-setup")this._setPref("setup_card_dismissed",!1),this._render();else if(e==="set-settings-level"){const a=(t.type==="checkbox"?t.checked:t.dataset.slevel==="advanced")?"advanced":"basic";a!==this._pref("settings_level","basic")&&(this._snapshotFormToPending(s),this._setPref("settings_level",a),this._render())}else if(e==="pg-new"||e==="pg-edit"||e==="pg-suggest"){if(e==="pg-new")this._modal={type:"profile-group",orig:null,name:"",members:[]};else if(e==="pg-edit"){const a=t.dataset.gname,o=((this._profileGroups||{}).groups||[]).find(i=>i.name===a);this._modal={type:"profile-group",orig:a,name:a,members:o?[...o.members||[]]:[]}}else{const a=((this._profileGroups||{}).suggestions||[])[parseInt(t.dataset.idx,10)]||null;if(!a)return;this._modal={type:"profile-group",orig:a.existing_group||null,name:a.existing_group||"",members:[...a.members||[]]}}this._render(),this._ensureProfileEnvs(n,(this._profiles||[]).map(a=>a.name)).then(()=>{this._modal&&this._modal.type==="profile-group"&&this._render()})}else if(e==="rebuild-envelopes")this._kickAndTrack({type:`${y}/rebuild_envelopes`,entry_id:n},"rebuild-envelopes",async()=>{this._showToast(this._t("toast.envelopes_rebuilt",{},"Envelopes rebuilt")),await this._fetchProfiles(n)});else if(e==="rec-start")this._ws({type:`${y}/start_recording`,entry_id:n}).then(()=>(this._showToast(this._t("toast.recording_started",{},"Recording started")),this._fetchRecState(n))).then(()=>this._render()).catch(a=>this._showToast(this._t("toast.start_failed",{error:a.message||a},"Start failed: "+(a.message||a)),"error"));else if(e==="rec-stop")this._ws({type:`${y}/stop_recording`,entry_id:n}).then(()=>(this._showToast(this._t("toast.recording_stopped",{},"Recording stopped")),this._fetchRecState(n))).then(()=>this._render()).catch(a=>this._showToast(this._t("toast.stop_failed",{error:a.message||a},"Stop failed: "+(a.message||a)),"error"));else if(e==="rec-process-open")this._fetchProfiles(n).then(()=>{this._modal={type:"process-recording"},this._render()});else if(e==="rec-discard")this._modal={type:"confirm",title:this._t("modal.discard_recording_title",{},"Discard Recording"),message:this._t("modal.discard_recording_msg",{},"Discard the saved recording? This cannot be undone."),okLabel:this._t("btn.discard",{},"Discard"),onOk:async()=>{try{await this._ws({type:`${y}/discard_recording`,entry_id:n}),this._showToast(this._t("toast.recording_discarded",{},"Recording discarded")),await this._fetchRecState(n)}catch(a){this._showToast(this._t("toast.discard_failed",{error:a.message||a},"Discard failed: "+(a.message||a)),"error")}}},this._render();else if(e==="fb-confirm")this._ws({type:`${y}/resolve_feedback`,entry_id:n,cycle_id:t.dataset.cid,action:"confirm"}).then(()=>(this._showToast(this._t("toast.feedback_confirmed",{},"Feedback confirmed")),this._fetchFeedbacks(n))).then(()=>this._render()).catch(a=>this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error"));else if(e==="fb-ignore")this._ws({type:`${y}/resolve_feedback`,entry_id:n,cycle_id:t.dataset.cid,action:"ignore"}).then(()=>(this._showToast(this._t("toast.feedback_dismissed",{},"Feedback dismissed")),this._fetchFeedbacks(n))).then(()=>this._render()).catch(a=>this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error"));else if(e==="fb-correct")this._fetchProfiles(n).then(()=>{this._modal={type:"correct-feedback",cycleId:t.dataset.cid,detectedProfile:t.dataset.prof},this._render()});else if(e==="fb-dismiss-all")this._modal={type:"confirm",title:this._t("modal.dismiss_all_title",{},"Dismiss All Feedbacks"),message:this._t("modal.dismiss_all_msg",{count:this._feedbacks.length},`Dismiss all ${this._feedbacks.length} pending feedback requests?`),okLabel:this._t("modal.dismiss_all_ok",{},"Dismiss All"),onOk:async()=>{try{await this._ws({type:`${y}/dismiss_all_feedbacks`,entry_id:n}),this._showToast(this._t("toast.feedback_all_dismissed",{},"All feedbacks dismissed")),await this._fetchFeedbacks(n)}catch(a){this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}}},this._render();else if(e==="create-phase")this._modal={type:"create-phase",deviceType:t.dataset.dtype},this._render();else if(e==="edit-phase")this._modal={type:"edit-phase",phaseId:t.dataset.pid,phaseName:t.dataset.pname,phaseDesc:t.dataset.pdesc,isDefault:t.dataset.pisdefault==="true"},this._render();else if(e==="del-phase"){const a=t.dataset.pname,o=t.dataset.pid;this._modal={type:"confirm",title:this._t("modal.delete_phase_title",{},"Delete Phase"),message:this._t("modal.delete_phase_msg",{name:a},`Delete phase "${a}"?`),okLabel:this._t("btn.delete",{},"Delete"),onOk:async()=>{try{await this._ws({type:`${y}/delete_phase`,entry_id:n,phase_id:o}),this._showToast(this._t("toast.phase_deleted",{name:a},`Phase "${a}" deleted`)),await this._fetchPhases(n)}catch(i){this._showToast(this._t("msg.toast_delete_failed",{error:i.message||i},"Delete failed: "+(i.message||i)),"error")}}},this._render()}else if(e==="diag-refresh")this._fetchToolsData(n).then(()=>this._render());else if(e==="reprocess-history")this._modal={type:"confirm",title:this._t("modal.process_history_title",{},"Process History"),message:this._t("modal.process_history_msg",{},"Re-run matching, refresh suggestions, retrain ML (if enabled) and recompute cycle health across all stored cycles. This may take a while."),okLabel:this._t("modal.process_history_ok",{},"Process"),onOk:()=>this._kickAndTrack({type:`${y}/reprocess_history`,entry_id:n},"reprocess",async a=>{const o=a.count||0,i=[this._t("toast.processed_cycles",{n:o},o+" cycles")];a.suggestions!=null&&i.push(this._t("toast.processed_suggestions",{n:a.suggestions},a.suggestions+" suggestion(s)"));const l=a.ml_training&&a.ml_training.ok&&(a.ml_training.promoted||[]).length||0;l&&i.push(this._t("toast.processed_models",{n:l},l+" model(s) promoted")),this._showToast(this._t("toast.processed",{bits:i.join(", ")},"Processed "+i.join(", "))),await this._fetchToolsData(n)})},this._render();else if(e==="clear-debug")this._modal={type:"confirm",title:this._t("modal.clear_debug_title",{},"Clear Debug Data"),message:this._t("modal.clear_debug_msg",{},"Delete all stored debug traces?"),okLabel:this._t("status.clear",{},"Clear"),onOk:()=>this._busyRun("clear-debug",async()=>{try{const a=await this._ws({type:`${y}/clear_debug_data`,entry_id:n});this._showToast(this._t("toast.debug_cleared",{count:a.count||0},`Cleared ${a.count||0} debug traces`)),await this._fetchToolsData(n)}catch(a){this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}})},this._render();else if(e==="wipe-history")this._modal={type:"confirm",title:this._t("modal.wipe_all_title",{},"Wipe All Data"),message:this._t("modal.wipe_all_msg",{},"\u26A0\uFE0F This permanently deletes ALL cycles and profiles. This cannot be undone."),okLabel:this._t("modal.wipe_all_ok",{},"Wipe Everything"),onOk:()=>this._busyRun("wipe",async()=>{try{await this._ws({type:`${y}/wipe_history`,entry_id:n}),this._showToast(this._t("toast.all_wiped",{},"All data wiped")),this._cycles=[],this._profiles=[],await this._fetchToolsData(n)}catch(a){this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}})},this._render();else if(e==="export-config")this._ws({type:`${y}/export_config`,entry_id:n}).then(a=>{const o=new Blob([a.json_data],{type:"application/json"}),i=URL.createObjectURL(o),l=document.createElement("a");l.href=i,l.download=`washdata_export_${n.slice(0,8)}.json`,document.body.appendChild(l),l.click(),document.body.removeChild(l),URL.revokeObjectURL(i),this._showToast(this._t("toast.export_downloaded",{},"Export downloaded"))}).catch(a=>this._showToast(this._t("toast.export_failed",{error:a.message||a},"Export failed: "+(a.message||a)),"error"));else if(e==="export-select-open")this._modal={type:"export-select",inventory:null,loading:!0,sel:{cats:new Set,profiles:new Set,realIds:new Set,refIds:new Set},expanded:new Set},this._render(),(async()=>{let a=null;try{const o=await this._ws({type:`${y}/get_export_inventory`,entry_id:n});a=o&&o.manifest||null}catch(o){this._showToast(this._t("toast.export_failed",{error:o.message||o},"Export failed: "+(o.message||o)),"error")}if(!(!this._isActiveEntry(n)||!this._modal||this._modal.type!=="export-select")){if(!a){this._modal=null,this._render();return}this._modal.inventory=a,this._modal.sel=this._wizInitSel({categories:a},!1),this._modal.loading=!1,this._render()}})();else if(e==="cyc-select-toggle")this._selectMode=!this._selectMode,this._selectMode||this._cycleSel.clear(),this._render();else if(e==="cyc-auto-open")this._modal={type:"auto-label"},this._render();else if(e==="cyc-merge"){const a=Array.from(this._cycleSel);if(a.length<2)return;this._fetchProfiles(n).then(()=>{this._modal={type:"merge-cycles",ids:a},this._render()})}else if(e==="cyc-relabel"){const a=Array.from(this._cycleSel);if(!a.length)return;this._fetchProfiles(n).then(()=>{this._modal={type:"bulk-relabel",ids:a},this._render()})}else if(e==="cyc-load-more")this._busyRun("cyc-load-more",async()=>{try{await this._loadMoreCycles(n)}catch(a){this._showToast(this._t("toast.load_more_failed",{error:a.message||a},"Could not load more: "+(a.message||a)),"error")}});else if(e==="task-cancel"){const a=t.dataset.taskId;a&&(this._cancellingTasks.add(a),this._updateTaskPills(),this._ws({type:`${y}/cancel_task`,task_id:a}).catch(()=>{this._cancellingTasks.delete(a),this._updateTaskPills()}))}else if(e==="cyc-compare"){const a=Array.from(this._cycleSel);if(a.length<2)return;this._modal={type:"compare-cycles",ids:a,cycles:{},hidden:new Set,overlays:[],loaded:!1},this._profiles.length||this._fetchProfiles(n),this._render(),Promise.all(a.map(o=>this._ws({type:`${y}/get_cycle_power_data`,entry_id:n,cycle_id:o}).then(i=>({cid:o,r:i})).catch(()=>({cid:o,r:null})))).then(o=>{!this._modal||this._modal.type!=="compare-cycles"||(o.forEach(({cid:i,r:l})=>{l&&(this._modal.cycles[i]=l)}),this._modal.loaded=!0,this._render())})}else if(e==="cyc-bulk-del"){const a=Array.from(this._cycleSel);if(!a.length)return;this._deleteCyclesWithUndo(n,a)}else if(e==="retry-cycles")this._fetchCycles(n).then(()=>this._render());else if(e==="retry-profiles")Promise.all([this._fetchProfiles(n),this._fetchProfileGroups(n)]).then(()=>this._render());else if(e==="retry-suggestions")this._fetchSuggestions(n).then(()=>this._render());else if(e==="goto-suggestions")this._settingsSugOnly=!0,this._tab="settings",this._fetchTabData();else if(e==="goto-conflicts")this._tab="settings",this._fetchTabData();else if(e==="conf-goto-section"){const a=this._conflictKeysFromOpts();for(const o of mt)if((o.fields||(o.groups||[]).flatMap(l=>l.fields||[])).some(l=>a.has(l.key))){this._settingsSec=o.id,this._render();break}}else if(e==="toggle-settings-history")this._settingsHistoryOpen=!this._settingsHistoryOpen,this._render();else if(e==="settings-revert-key"){const a=t.dataset.key,o=JSON.parse(t.dataset.val);if(!a)return;const i=r.entry_id;this._ws({type:`${y}/set_options`,entry_id:i,options:{[a]:o}}).then(()=>this._ws({type:`${y}/get_options`,entry_id:i})).then(l=>(this._opts=l.options||{},this._optDefaults=l.defaults||{},this._fetchSettingsChangelog(i))).then(()=>{this._showToast(this._t("msg.toast_reverted",{key:this._t("setting."+a+".label",{},a)},"{key} reverted"),"success"),this._render()}).catch(l=>this._showToast(this._t("msg.toast_error",{error:l.message||l},"Error: "+(l.message||l)),"error"))}else if(e==="toggle-log-drawer"){this._logOpen=!this._logOpen;try{localStorage.setItem("wd-log-open",this._logOpen?"1":"0")}catch{}this._render(),this._logOpen&&this._fetchLogs().then(()=>{this._logOpen&&this._render()})}else if(e==="open-advanced"){const a=t.dataset.sub;a&&(this._panelSubtab=a),this._tab="advanced",this._render(),this._panelSubtab==="diagnostics"&&!this._diag?this._fetchToolsData(n).then(()=>{this._tab==="advanced"&&this._render()}):this._panelSubtab==="logs"?this._fetchLogs().then(()=>{this._tab==="advanced"&&this._render()}):this._panelSubtab==="maintenance"?this._fetchMaintenance(n).then(()=>{this._tab==="advanced"&&this._render()}):this._panelSubtab==="ml"&&this._fetchTabData()}else if(e==="add-device")this._navigate(`/config/integrations/integration/${y}`);else if(e==="goto-feedbacks")this._tab="history",this._cycleFilter={...this._cycleFilter,status:"needs_review"},this._fetchTabData();else if(e==="goto-recording")this._tab="status",this._fetchTabData();else if(e==="logs-refresh")this._fetchLogs().then(()=>this._render());else if(e==="logs-export")this._ws({type:`${y}/get_logs`,limit:500}).then(a=>{const o=(a.logs||[]).map(_=>`${new Date(_.ts*1e3).toISOString()} ${_.level} ${_.msg}`).join(` +`),i=new Blob([o],{type:"text/plain"}),l=URL.createObjectURL(i),c=document.createElement("a");c.href=l,c.download=`washdata_logs_${Date.now()}.txt`,document.body.appendChild(c),c.click(),document.body.removeChild(c),URL.revokeObjectURL(l),this._showToast(this._t("toast.logs_exported",{},"Logs exported"))}).catch(a=>this._showToast(this._t("toast.export_failed",{error:a.message||a},"Export failed: "+(a.message||a)),"error"));else if(e==="import-config-open")this._modal={type:"import-wizard",step:"input",jsonText:"",manifest:null,error:null,sel:{cats:new Set,profiles:new Set,realIds:new Set,refIds:new Set},expanded:new Set,mode:"merge",cycleDest:"reference",conflicts:{}},this._render();else if(e==="hist-import-open")this._modal={type:"history-import",step:"input",csvText:"",since:zt(),token:null,scanTaskId:null,applyTaskId:null,result:null,accept:new Set,done:null,error:null},this._render();else if(e==="import-config-raw")this._modal={type:"import-config"},this._render();else if(e==="save-prefs"){const a=s.getElementById("wd-pref-tab")?.value||"",o=!!s.getElementById("wd-pref-debug")?.checked,i=s.getElementById("wd-pref-expected")?!!s.getElementById("wd-pref-expected").checked:!0,l=!!s.getElementById("wd-pref-raw")?.checked,c=s.getElementById("wd-pref-datefmt")?.value||"relative",_=s.getElementById("wd-pref-lang")?.value||"",p=parseFloat(s.getElementById("wd-pref-fontscale")?.value)||1,g={default_tab:a,show_debug:o,show_expected:i,show_raw:l,date_format:c,lang_override:_,font_scale:p};this._busyRun("save-prefs",async()=>{try{await this._ws({type:`${y}/set_user_prefs`,prefs:g}),this._panelCfg&&(this._panelCfg.prefs={...this._panelCfg.prefs||{},...g});const u=_||this._hass&&this._hass.locale&&this._hass.locale.language;await this._loadPanelLang(u),this._render(),this._showToast(this._t("toast.preferences_saved",{},"Preferences saved"))}catch(u){this._showToast(this._t("toast.save_failed",{error:u.message||u},"Save failed: "+(u.message||u)),"error")}})}else if(e==="save-panel"){const a={default_tab:s.getElementById("wd-ps-deftab")?.value||"status",hidden_tabs:Array.from(s.querySelectorAll("[data-hidetab]")).filter(o=>o.checked).map(o=>o.dataset.hidetab)};this._busyRun("save-panel",async()=>{try{await this._ws({type:`${y}/set_panel_config`,panel:a}),this._panelCfg=await this._ws({type:`${y}/get_panel_config`}),this._tabInitialized=!0,this._applyPanelConfig(),this._showToast(this._t("toast.panel_settings_saved",{},"Panel settings saved"))}catch(o){this._showToast(this._t("msg.toast_save_failed",{error:o.message||o},"Save failed: "+(o.message||o)),"error")}})}else if(e==="pause-cycle")this._ws({type:`${y}/pause_cycle`,entry_id:n}).then(a=>{if(a&&a.ok===!1){this._showToast(this._t("toast.pause_no_cycle",{},"No active cycle to pause"),"error");return}return this._showToast(this._t("toast.cycle_paused",{},"Cycle paused")),this._fetchAll()}).catch(a=>this._showToast(this._t("toast.pause_failed",{error:a.message||a},"Pause failed: "+(a.message||a)),"error"));else if(e==="resume-cycle")this._ws({type:`${y}/resume_cycle`,entry_id:n}).then(a=>{if(a&&a.ok===!1){this._showToast(this._t("toast.resume_no_cycle",{},"No paused cycle to resume"),"error");return}return this._showToast(this._t("toast.cycle_resumed",{},"Cycle resumed")),this._fetchAll()}).catch(a=>this._showToast(this._t("msg.toast_resume_failed",{error:a.message||a},"Resume failed: "+(a.message||a)),"error"));else if(e==="terminate-cycle")this._modal={type:"confirm",title:this._t("modal.force_stop_title",{},"Force Stop Cycle"),message:this._t("modal.force_stop_msg",{},"Force-stop the active cycle now? The cycle will be saved as interrupted."),okLabel:this._t("btn.force_stop",{},"Force Stop"),onOk:async()=>{try{await this._ws({type:`${y}/terminate_cycle`,entry_id:n}),this._showToast(this._t("toast.cycle_force_stopped",{},"Cycle force-stopped")),await this._fetchAll()}catch(a){this._showToast(this._t("msg.toast_force_stop_failed",{error:a.message||a},"Force stop failed: "+(a.message||a)),"error")}}},this._render();else if(e==="save-rbac"){const a=!!s.getElementById("wd-rbac-enabled")?.checked,o=s.getElementById("wd-rbac-default")?.value||"none",i={};s.querySelectorAll("[data-rbacuser]").forEach(l=>{const c=l.dataset.rbacuser,_=l.dataset.rbacdev,p=l.value;i[c]||(i[c]={default:"none",devices:{}}),_==="__default__"?i[c].default=p:p&&p!=="inherit"&&(i[c].devices[_]=p)}),this._busyRun("save-rbac",async()=>{try{await this._ws({type:`${y}/set_panel_config`,rbac:{enabled:a,default_level:o,users:i}}),this._panelCfg=await this._ws({type:`${y}/get_panel_config`}),this._showToast(this._t("toast.access_saved",{},"Access control saved"))}catch(l){this._showToast(this._t("msg.toast_save_failed",{error:l.message||l},"Save failed: "+(l.message||l)),"error")}})}}_onActSuggestions(t,e,s,r,n){if(t==="sug-apply-all"){const a=this._suggestions.map(o=>o.key);this._busyRun("save-settings",async()=>{try{await this._ws({type:`${y}/apply_suggestions`,entry_id:r,keys:a}),this._showToast(this._t("toast.suggestions_applied",{},"Suggestions applied; integration reloading")),await this._fetchSuggestions(r);const o=await this._ws({type:`${y}/get_options`,entry_id:r});this._opts=o.options||{},this._optDefaults=o.defaults||{},this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null}catch(o){this._showToast(this._t("toast.apply_failed",{error:o.message||o},"Apply failed: "+(o.message||o)),"error")}})}else t==="sug-show-all"?(this._settingsSugOnly=!1,this._render()):t==="sug-dismiss"?(this._settingsSugOnly=!1,this._busyRun("save-settings",async()=>{try{await this._ws({type:`${y}/clear_suggestions`,entry_id:r}),this._suggestions=[],this._showToast(this._t("toast.suggestions_dismissed",{},"Suggestions dismissed"))}catch(a){this._showToast(this._t("toast.error",{error:a.message||a},"Error: "+(a.message||a)),"error")}})):t==="sug-unmute-all"?this._busyRun("save-settings",async()=>{try{const a=[...this._lockedSuggestions||[]],o=await Promise.allSettled(a.map(c=>this._ws({type:`${y}/set_suggestion_lock`,entry_id:r,key:c,locked:!1})));if(!this._isActiveEntry(r))return;const i=o.filter(c=>c.status==="rejected").length,l=o.slice().reverse().find(c=>c.status==="fulfilled");this._lockedSuggestions=l&&l.value&&l.value.locked_suggestions||[],await this._fetchSuggestions(r),i?this._showToast(this._t("toast.error",{error:`${i} suggestion(s) failed to unlock`},`${i} suggestion(s) failed to unlock`),"error"):this._showToast(this._t("msg.sug_unmuted_all",{},"Muted suggestions reset"),"success")}catch(a){this._showToast(this._t("toast.error",{error:a.message||a},"Error: "+(a.message||a)),"error")}}):t==="sug-analyze"&&this._busyRun("sug-analyze",async()=>{try{const a=await this._ws({type:`${y}/run_suggestion_analysis`,entry_id:r}),o=a&&a.count||0;this._showToast(o?this._t("toast.analysis_complete",{count:o},`Analysis complete: ${o} suggestion(s)`):this._t("toast.analysis_complete_none",{},"Analysis complete: no new suggestions")),await this._fetchSuggestions(r)}catch(a){this._showToast(this._t("toast.analysis_failed",{error:a.message||a},"Analysis failed: "+(a.message||a)),"error")}})}_onActMl(t,e,s,r){t==="ml-train-now"?this._kickAndTrack({type:`${y}/trigger_ml_training`,entry_id:r},"ml-train-now:"+r,async n=>{if(n&&n.ok){const a=(n.promoted||[]).length;this._showToast(a?this._t("toast.ml_training_promoted",{count:a},`Training complete: promoted ${a} model(s)`):this._t("toast.ml_training_no_improvement",{},"Training complete: baseline kept (no improvement)"))}else this._showToast(this._t("toast.ml_training_no_improvement",{},"Training complete: baseline kept (no improvement)"),"info");await this._loadMlTrainingStatus(r)}):t==="ml-revert-match"?this._busyRun("ml-revert-match",async()=>{try{await this._ws({type:`${y}/revert_matching_config`,entry_id:r}),this._showToast(this._t("toast.matching_reverted",{},"Matching weights reverted to defaults")),await this._loadMlTrainingStatus(r)}catch(n){this._showToast(this._t("toast.revert_failed",{error:n.message||n},"Revert failed: "+(n.message||n)),"error")}}):t==="ml-revert-models"&&this._busyRun("ml-revert-models",async()=>{try{await this._ws({type:`${y}/revert_ml_models`,entry_id:r}),this._showToast(this._t("toast.models_reverted",{},"On-device models reverted to baseline")),await this._loadMlTrainingStatus(r)}catch(n){this._showToast(this._t("msg.toast_revert_failed",{error:n.message||n},"Revert failed: "+(n.message||n)),"error")}})}_onActStore(t,e,s,r,n){if(t==="store-toggle-online"){const a=!!e.checked;this._busyRun("store-account",async()=>{try{const o=await this._ws({type:`${y}/store_set_online`,entry_id:r,enabled:a});this._constants.storeOnlineEnabled=!!(o&&o.enabled),this._constants.storeOnlineEnabled?(await this._loadStoreStatus(r),this._ensureStoreConnectListener()):(this._storeStatus={enabled:!1},this._storeConnected=!1)}catch(o){this._showToast(this._t("toast.store_error",{error:o.message||o},"Error: "+(o.message||o)),"error")}})}else if(t==="store-toggle-pref"){const a=e.dataset.pref,o=!!e.checked;if(!a)return;this._busyRun("store-account",async()=>{try{const i=await this._ws({type:`${y}/store_set_prefs`,entry_id:r,prefs:{[a]:o}});i&&i.prefs&&(this._constants={...this._constants,storePrefs:i.prefs})}catch(i){this._showToast(this._t("toast.store_error",{error:i.message||i},"Error: "+(i.message||i)),"error")}})}else if(t==="store-goto-identity")this._tab="settings",this._settingsSec="basic",this._fetchTabData(),requestAnimationFrame(()=>requestAnimationFrame(()=>{const a=this.shadowRoot&&this.shadowRoot.getElementById("wd-store-brand");a&&(a.focus(),a.scrollIntoView({block:"center"}))}));else if(t==="store-refresh-catalog")this._busyRun("store-refresh-catalog",async()=>{try{await this._ws({type:`${y}/store_refresh_catalog`,entry_id:r}),clearTimeout(this._brandSearchTimer),this._brandSearchTimer=null,this._catalog.brands=void 0,this._catalog.devices=void 0,this._catalog.forBrand=null,this._catalog.brandsFull=!1,this._catalog.brandPrefixes=[],this._catalogEntry=null,this._entityListCache&&(delete this._entityListCache.store_brand,delete this._entityListCache.store_model),this._showToast(this._t("toast.catalog_refreshed",{},"Community catalog refreshed"))}catch(a){this._showToast(this._t("toast.store_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}});else if(t==="store-connect"){const a=this._constants.storeWebOrigin;if(!a){this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener(),window.open(a+"/connect.html?origin="+encodeURIComponent(location.origin),"washdata_connect","width=480,height=640")}else if(t==="store-disconnect")this._busyRun("store-account",async()=>{try{await this._ws({type:`${y}/store_disconnect`,entry_id:r}),await this._loadStoreStatus(r),this._showToast(this._t("toast.store_disconnected",{},"Disconnected from the community store"))}catch(a){this._showToast(this._t("toast.store_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}});else if(t==="store-add-appliance"){const a=this._constants.storeWebOrigin;if(!a){this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener();const o=n.getElementById("wd-store-model"),i=n.getElementById("wd-store-brand"),l=new URLSearchParams({mode:"device",type:this._storeApplianceType(),brand:i&&i.value||this._opts.store_brand||"",model:o&&o.value||this._opts.store_model||"",origin:location.origin}).toString();window.open(a+"/create.html?"+l,"washdata_create","width=560,height=760")}else if(t==="store-add-brand"){const a=this._constants.storeWebOrigin;if(!a){this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener();const o=n.getElementById("wd-store-brand"),i=new URLSearchParams({mode:"brand",brand:o&&o.value||this._opts.store_brand||"",origin:location.origin}).toString();window.open(a+"/create.html?"+i,"washdata_create","width=560,height=760")}else if(t==="store-confirm-device"){const a=e.dataset.deviceId;this._busyRun("store-account",async()=>{try{const o=await this._ws({type:`${y}/store_confirm_device`,entry_id:r,device_id:a});if(o&&o.error){this._showToast(this._t("toast.store_error",{error:o.error},"Error: "+o.error),"error");return}const i=[...this._catalog.devices||[],...this._storeDevices||[],this._catalogEntry&&this._catalogEntry.device||null];for(const l of i)o&&l&&String(l.id)===String(a)&&(l.confirmCount=o.confirmCount,l.status=o.status);this._showToast(o&&o.status==="approved"?this._t("toast.device_approved",{},"Approved by the community"):this._t("toast.thanks_confirming",{},"Thanks for confirming")),this._render()}catch(o){this._showToast(this._t("toast.store_error",{error:o.message||o},"Error: "+(o.message||o)),"error")}})}else if(t==="store-rate-device"){const a=e.dataset.deviceId,o=parseInt(e.dataset.rating,10);if(!(o>=1&&o<=5))return;this._busyRun("store-account",async()=>{try{const i=await this._ws({type:`${y}/store_rate_device`,entry_id:r,device_id:a,rating:o});if(i&&i.error){this._showToast(this._t("toast.store_error",{error:i.error},"Error: "+i.error),"error");return}this._showToast(this._t("toast.rating_saved",{},"Quality rating saved"))}catch(i){this._showToast(this._t("toast.store_error",{error:i.message||i},"Error: "+(i.message||i)),"error")}})}else if(t==="store-search"){const a=n.getElementById("wd-store-q");this._storeSearch(a?a.value:"")}else if(t==="store-nav"){const a=e.dataset.view;a==="brands"?(this._storeView="brands",this._storeDevice=null,this._storeProfile=null,this._render()):a==="device"&&(this._storeView="device",this._storeProfile=null,this._render())}else if(t==="store-open-device"){const a=e.dataset.deviceId,o=(this._storeDevices||[]).find(i=>String(i.id)===String(a));if(!o)return;this._storeDevice=o,this._storeProfile=null,this._storeView="device",this._storeProfiles=[],this._storeCycles=[],this._storeLoading=!0,this._render(),this._ws({type:`${y}/store_get_profiles`,entry_id:r,device_id:o.id}).then(i=>{!this._isActiveEntry(r)||this._storeView!=="device"||(this._storeProfiles=i&&i.items||[])}).catch(()=>{this._isActiveEntry(r)&&(this._storeProfiles=[])}).finally(()=>{this._isActiveEntry(r)&&(this._storeLoading=!1,this._render())})}else if(t==="store-open-profile"){const a=e.dataset.profileId,o=(this._storeProfiles||[]).find(i=>String(i.id)===String(a));if(!o)return;this._storeProfile=o,this._storeView="profile",this._storeCycles=[],this._storeLoading=!0,this._render(),this._ws({type:`${y}/store_get_cycles`,entry_id:r,profile_id:o.id}).then(i=>{!this._isActiveEntry(r)||this._storeView!=="profile"||(this._storeCycles=i&&i.items||[])}).catch(()=>{this._isActiveEntry(r)&&(this._storeCycles=[])}).finally(()=>{this._isActiveEntry(r)&&(this._storeLoading=!1,this._render())})}else if(t==="store-onboard")this._storeQuery=(this._opts.store_brand||"").trim(),this._storeView="brands",this._storeDevice=null,this._storeProfile=null,this._tab="store",this._fetchTabData();else if(t==="store-toggle-dl-settings")this._dlSettings=!!e.checked;else if(t==="store-download-device"){const a=e.dataset.deviceId;if(!a)return;const o=!!this._dlSettings;this._busyRun("store-download-device",async()=>{try{const i=await this._ws({type:`${y}/store_download_device`,entry_id:r,device_id:a,include_settings:o});if(i&&(i.error||i.disabled)){const g=i.error||"unavailable";this._showToast(this._t("toast.store_download_failed",{error:g},"Download failed: "+g),"error");return}const l=i&&i.profiles_adopted||0,c=i&&i.cycles_imported||0,_=i&&i.settings_applied||0;if(!l&&!c&&!_){this._showToast(this._t("toast.store_download_nothing",{},"Nothing new to download - this setup is already on your device."),"info");return}await this._fetchProfiles(r),await this._fetchCycles(r);const p=i&&i.phases_applied||0;_?this._showToast(this._t("toast.store_device_downloaded_settings",{p:l,c,ph:p,s:_},`${l} program(s), ${c} recording(s), ${p} phase map(s), ${_} setting(s) added`)):p?this._showToast(this._t("toast.store_device_downloaded_phases",{p:l,c,ph:p},`${l} program(s), ${c} recording(s), ${p} phase map(s) added`)):this._showToast(this._t("toast.store_device_downloaded",{p:l,c},`${l} program(s), ${c} recording(s) added`))}catch(i){this._showToast(this._t("toast.store_download_failed",{error:i.message||i},"Download failed: "+(i.message||i)),"error")}})}else if(t==="store-import"){const a=e.dataset.cycleId,o=this._storeProfile&&this._storeProfile.program||"";this._modal={type:"store-import",cycleId:a,program:o,mode:"new"},this._render()}else if(t==="store-share-cycle"){const a=e.dataset.cid,o=e.dataset.prof||"";this._modal={type:"store-share",cycleId:a,program:o,profiles:null,deviceId:null},this._render(),this._loadShareProfiles()}else if(t==="store-share-device")this._modal={type:"store-share-device",selected:new Set,includePhases:new Set,includeSettings:!1,loading:!0},this._render(),(async()=>{try{const o=await this._ws({type:`${y}/get_shareable_cycles`,entry_id:r});this._shareableCycles=o&&o.items||[],this._sharePhasePrograms=o&&o.phase_programs||[],this._shareAllPrograms=o&&o.all_programs||[]}catch{this._shareableCycles=[],this._sharePhasePrograms=[],this._shareAllPrograms=[]}if(!this._isActiveEntry(r)||!this._modal||this._modal.type!=="store-share-device")return;const a=new Set;this._shareableByProgram().forEach(o=>o.cycles.forEach(i=>a.add(i.id))),this._modal.selected=a,this._modal.includePhases=new Set(this._sharePhasePrograms),this._modal.loading=!1,this._render()})();else if(t==="store-share-profile"){const a=e.dataset.prog||"";this._modal={type:"store-share-device",selected:new Set,includePhases:new Set,includeSettings:!1,loading:!0,focusProfile:a},this._render(),(async()=>{try{const i=await this._ws({type:`${y}/get_shareable_cycles`,entry_id:r});this._shareableCycles=i&&i.items||[],this._sharePhasePrograms=i&&i.phase_programs||[],this._shareAllPrograms=i&&i.all_programs||[]}catch{this._shareableCycles=[],this._sharePhasePrograms=[],this._shareAllPrograms=[]}if(!this._isActiveEntry(r)||!this._modal||this._modal.type!=="store-share-device")return;const o=new Set;this._shareableByProgram().filter(i=>i.program===a).forEach(i=>i.cycles.forEach(l=>o.add(l.id))),this._modal.selected=o,this._modal.includePhases=new Set((this._sharePhasePrograms||[]).filter(i=>i===a)),this._modal.loading=!1,this._render()})()}else if(t==="store-share-add-profile"){const a=this._modal,o=this._constants.storeWebOrigin;if(!o||!a){o||this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener();const i=new URLSearchParams({mode:"profile",device:a.deviceId||"",type:this._storeApplianceType(),brand:this._opts.store_brand||"",model:this._opts.store_model||"",origin:location.origin}).toString();window.open(o+"/create.html?"+i,"washdata_create","width=560,height=760")}}_onActAuto(t,e,s,r,n){if(t==="auto-new")this._navigate("/config/automation/edit/new");else if(t==="auto-new-started")this._newAutomationFromEvent("started");else if(t==="auto-new-finished")this._newAutomationFromEvent("finished");else if(t==="auto-delete"){const a=e.dataset.autoid,o=e.dataset.autoname||"this automation";this._modal={type:"confirm",title:this._t("modal.delete_automation_title",{},"Delete Automation"),message:this._t("modal.delete_automation_msg",{name:o},`Delete the automation "${o}" from Home Assistant? This cannot be undone.`),okLabel:this._t("btn.delete",{},"Delete"),onOk:async()=>{try{await this._hass.callApi("DELETE","config/automation/config/"+a),this._showToast(this._t("toast.automation_deleted",{},"Automation deleted")),await this._loadDeviceAutomations(r)}catch(i){this._showToast(this._t("toast.delete_failed",{error:i.message||i},"Delete failed: "+(i.message||i)),"error")}}},this._render()}else if(t==="auto-convert-legacy")this._convertLegacyActions();else if(t==="auto-remove-legacy")this._modal={type:"confirm",title:this._t("modal.remove_legacy_title",{},"Remove Legacy Actions"),message:this._t("modal.remove_legacy_msg",{},"Remove the legacy custom actions? They will stop firing on cycle events. This cannot be undone from the panel."),okLabel:this._t("btn.remove",{},"Remove"),onOk:async()=>{try{await this._ws({type:`${y}/set_options`,entry_id:r,options:{notify_actions:[]}}),this._opts={...this._opts,notify_actions:[]},this._showToast(this._t("toast.legacy_removed",{},"Legacy actions removed"))}catch(a){this._showToast(this._t("toast.delete_failed",{error:a.message||a},"Remove failed: "+(a.message||a)),"error")}}},this._render();else if(t==="auto-label"){const a=parseFloat(n.getElementById("wd-auto-label-threshold")?.value||"0.75");this._busyRun("auto-label",async()=>{try{await this._ws({type:`${y}/auto_label_cycles`,entry_id:r,confidence_threshold:a}),this._showToast(this._t("toast.auto_label_complete",{},"Auto-label complete")),await this._fetchCycles(r)}catch(o){this._showToast(this._t("toast.auto_label_failed",{error:o.message||o},"Auto-label failed: "+(o.message||o)),"error")}})}}_onActMaintenance(t,e,s,r,n){if(t==="maint-add"){const a=n.getElementById("wd-maint-type")?.value||"",o=n.getElementById("wd-maint-date")?.value||"",i=(n.getElementById("wd-maint-notes")?.value||"").trim();if(!a){this._showToast(this._t("toast.maint_add_failed",{error:this._t("lbl.event_type",{},"Event type")},"Could not add event: Event type"),"error");return}this._busyRun("maint-add",async()=>{try{const l={type:`${y}/add_maintenance_event`,entry_id:r,event_type:a};o&&(l.date=o),i&&(l.notes=i),await this._ws(l),await this._fetchMaintenance(r),this._showToast(this._t("toast.maint_added",{},"Maintenance event added")),this._render()}catch(l){this._showToast(this._t("toast.maint_add_failed",{error:l.message||l},"Could not add event: "+(l.message||l)),"error")}})}else if(t==="maint-delete"){const a=e.dataset.mid;this._modal={type:"confirm",title:this._t("modal.delete_maintenance_title",{},"Delete Maintenance Event"),message:this._t("modal.delete_maintenance_msg",{},"Delete this maintenance record? This cannot be undone."),okLabel:this._t("btn.delete",{},"Delete"),onOk:()=>this._busyRun("maint-delete",async()=>{try{await this._ws({type:`${y}/delete_maintenance_event`,entry_id:r,event_id:a}),await this._fetchMaintenance(r),this._showToast(this._t("toast.maint_deleted",{},"Maintenance event deleted"))}catch(o){this._showToast(this._t("toast.maint_delete_failed",{error:o.message||o},"Could not delete event: "+(o.message||o)),"error")}})},this._render()}else if(t==="maint-save-reminders"){const a={};n.querySelectorAll("[data-maint-rem]").forEach(o=>{const i=o.dataset.maintRem,l=parseInt(o.value,10);a[i]=!isNaN(l)&&l>0?l:0}),this._busyRun("maint-save-reminders",async()=>{try{await this._ws({type:`${y}/set_options`,entry_id:r,options:{maintenance_reminder_cycles:a}}),await this._fetchMaintenance(r),this._showToast(this._t("toast.reminders_saved",{},"Service reminders saved")),this._render()}catch(o){this._showToast(this._t("toast.reminders_save_failed",{error:o.message||o},"Could not save reminders: "+(o.message||o)),"error")}})}}_onActPlayground(t,e,s,r){if(t==="pg-analysis-tab"){const n=e.dataset.subtab||"history";n!==this._pgAnalysisTab&&(this._pgAnalysisTab=n,this._render(),requestAnimationFrame(()=>this._drawPlaygroundCanvases()))}else if(t==="pg-run-history")this._pgRunHistory();else if(t==="pg-batch-cancel"){this._pgBatchCancel=!0;const n=this._pgHistoryTaskId||this._pgSweepTaskId;n&&this._ws({type:`${y}/cancel_task`,task_id:n}).catch(()=>{})}else if(t==="pg-load-run"){const n=e.dataset.taskId;if(!n)return;const a=this._tasks[n];if(!a||a.kind!=="pg_history"&&a.kind!=="pg_sweep"){this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info");return}const o=a.entry_id;this._ws({type:`${y}/get_task_result`,task_id:n}).then(i=>{if(!this._isActiveEntry(o))return;const l=i&&i.result;if(!l){this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info");return}a.kind==="pg_history"?(this._pgHistory=l,this._pgAnalysisTab="history"):(this._pgSweepNew=l.error?null:l,this._pgAnalysisTab="sweep"),this._render()}).catch(()=>this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info"))}else if(t==="pg-open-cycle")this._pgSelectCycle(e.dataset.cid);else if(t==="pg-sweep-run2")this._pgRunSweep2();else if(t==="pg-sweep-apply2")this._pgApplySweepValue(e.dataset.val);else if(t==="pg-run"||t==="pg-load")this._pgLoad();else if(t==="pg-cancel-run")this._pgCancelRun();else if(t==="pg-reset-params")this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={},this._pgStressTail=!1,this._pgStressIdleW=null,this._render(),requestAnimationFrame(()=>this._pgDrawCanvas());else if(t==="pg-apply-settings")this._pgApplyToSettings();else if(t==="pg-load-live")this._pgLoadLive();else if(t==="pg-load-suggested")this._pgLoadSuggested("classic");else if(t==="pg-load-calibrated")this._pgLoadSuggested("ml");else if(t==="pg-preset-save")this._pgSavePreset();else if(t==="pg-preset-load"){const n=(this._pgPresets||[]).find(a=>a.name===this._pgPresetSel);n&&(this._pgApplyPresetValues(n.values),this._showToast(this._t("toast.pg_preset_loaded",{name:n.name},`Preset "${n.name}" loaded`)),this._render(),requestAnimationFrame(()=>this._pgDrawCanvas()))}else t==="pg-preset-delete"?this._pgDeletePreset():t==="pg-publish-one"&&this._pgPublishOne(e.dataset.pgkey)}async _onModalAction(t,e){const s=this.shadowRoot,r=this._devices[this._selIdx],n=r?r.entry_id:null,a=this._modal;if(t==="cancel"){if(a&&a.type==="cycle-detail"&&this._prevModal){const o=this._devices[this._selIdx];o?await this._closeCycleDetail(o.entry_id):(this._modal=null,this._render())}else this._modal=null,this._render();return}if(t==="ok"&&a&&a.onOk){const o=a.onOk;this._modal=null,this._render(),await o(),this._render();return}if(a&&a.type==="profile-group"){if(t==="pg-save"){const o=s.getElementById("wd-pg-name")?.value?.trim(),i=Array.from(s.querySelectorAll(".wd-pg-mem")).filter(l=>l.checked).map(l=>l.value);if(!o){this._showToast(this._t("toast.group_name_required",{},"Group name is required"),"error");return}if(i.length<2){this._showToast(this._t("toast.min_2_profiles",{},"Select at least 2 profiles for a group"),"error");return}await this._busyRun("pg-save",async()=>{try{a.orig&&a.orig!==o&&await this._ws({type:`${y}/rename_profile_group`,entry_id:n,name:a.orig,new_name:o}),await this._ws({type:`${y}/save_profile_group`,entry_id:n,name:o,members:i}),this._showToast(this._t("toast.group_saved",{},"Group saved")),this._modal=null,await this._fetchProfileGroups(n)}catch(l){this._showToast(this._t("msg.toast_save_failed",{error:l.message||l},"Save failed: "+(l.message||l)),"error")}});return}if(t==="pg-delete"&&a.orig){await this._busyRun("pg-save",async()=>{try{await this._ws({type:`${y}/delete_profile_group`,entry_id:n,name:a.orig}),this._showToast(this._t("toast.group_deleted",{},"Group deleted")),this._modal=null,await this._fetchProfileGroups(n)}catch(o){this._showToast(this._t("msg.toast_delete_failed",{error:o.message||o},"Delete failed: "+(o.message||o)),"error")}});return}}if(t.startsWith("store-import-")||t==="store-share-ok"||t.startsWith("wiz-")||t.startsWith("imp-")||t==="import-back"||t==="import-analyze"||t==="import-apply-ok")return this._onMActImport(t,e,a,n,s);if(t.startsWith("hist-"))return this._onMActHistoryImport(t,e,a,n,s);if(t.startsWith("sd-")||t==="store-share-device-ok")return this._onMActStoreShare(t,e,a,n);if(a&&a.type==="export-select"&&t==="export-generate"&&n){const o=this._wizSelectionPayload(a);await this._busyRun("export-select",async()=>{try{const i=await this._ws({type:`${y}/export_config_selective`,entry_id:n,selection:o}),l=new Blob([i.json_data],{type:"application/json"}),c=URL.createObjectURL(l),_=document.createElement("a");_.href=c,_.download=`washdata_export_${n.slice(0,8)}.json`,document.body.appendChild(_),_.click(),document.body.removeChild(_),URL.revokeObjectURL(c),this._modal=null,this._showToast(this._t("toast.export_selective_done",{},"Export downloaded"))}catch(i){this._showToast(this._t("toast.export_failed",{error:i.message||i},"Export failed: "+(i.message||i)),"error")}});return}if(t.startsWith("cyc-")||t.startsWith("trim-mode-"))return this._onMActCycleDetail(t,a,n,s);if(a&&a.type==="profile-panel"){if(t.indexOf("pp-tab-")===0){const o=t.slice(7);a.tab=o,this._render(),o==="cleanup"&&!a.cleanup&&this._ws({type:`${y}/get_profile_cycles`,entry_id:n,profile_name:a.name}).then(i=>{this._modal&&this._modal.name===a.name&&(this._modal.cleanup={cycles:i.cycles||[],selected:new Set},this._render())}).catch(()=>{this._modal&&(this._modal.cleanup={cycles:[],selected:new Set},this._render())});return}return this._onMActProfilePanel(t,e,a,n,s)}if(t==="label-ok"&&n){const o=s.getElementById("wd-label-profile"),i=o?o.value:"",l=i||null,c=i==="__create_new__"&&s.getElementById("wd-new-profile-name")?.value?.trim()||null;this._modal=null;try{await this._ws({type:`${y}/label_cycle`,entry_id:n,cycle_id:a.cycleId,profile_name:l||null,new_profile_name:c}),this._showToast(this._t("toast.cycle_labelled",{},"Cycle labelled")),await this._fetchCycles(n),await this._fetchProfiles(n),await this._fetchFeedbacks(n)}catch(_){this._showToast(this._t("toast.label_failed",{error:_.message||_},"Label failed: "+(_.message||_)),"error")}this._render()}else if(t==="create-profile-ok"&&n){const o=s.getElementById("wd-cp-name")?.value?.trim(),i=s.getElementById("wd-cp-cycle")?.value||null,l=parseFloat(s.getElementById("wd-cp-dur")?.value||0);if(this._modal=null,!o){this._showToast(this._t("toast.profile_name_required",{},"Profile name is required"),"error"),this._render();return}const c=!i&&l>0?l:null;try{await this._ws({type:`${y}/create_profile`,entry_id:n,name:o,reference_cycle:i||null,manual_duration_min:c}),this._showToast(this._t("toast.profile_created",{name:o},`Profile "${o}" created`)),await this._fetchProfiles(n)}catch(_){this._showToast(this._t("toast.create_failed",{error:_.message||_},"Create failed: "+(_.message||_)),"error")}this._render()}else if(t==="create-phase-ok"&&n){const o=s.getElementById("wd-ph-name")?.value?.trim(),i=s.getElementById("wd-ph-desc")?.value?.trim()||"";if(this._modal=null,!o){this._showToast(this._t("toast.phase_name_required",{},"Phase name is required"),"error"),this._render();return}try{await this._ws({type:`${y}/create_phase`,entry_id:n,device_type:a.deviceType||"",name:o,description:i}),this._showToast(this._t("toast.phase_created",{name:o},`Phase "${o}" created`)),await this._fetchPhases(n)}catch(l){this._showToast(this._t("msg.toast_create_failed",{error:l.message||l},"Create failed: "+(l.message||l)),"error")}this._render()}else if(t==="edit-phase-ok"&&n){const o=s.getElementById("wd-eph-name")?.value?.trim(),i=s.getElementById("wd-eph-desc")?.value?.trim()||"";if(this._modal=null,!o){this._showToast(this._t("toast.name_required",{},"Name required"),"error"),this._render();return}try{await this._ws({type:`${y}/update_phase`,entry_id:n,phase_id:a.phaseId,new_name:o,description:i}),this._showToast(this._t("toast.phase_updated",{},"Phase updated")),await this._fetchPhases(n)}catch(l){this._showToast(this._t("toast.update_failed",{error:l.message||l},"Update failed: "+(l.message||l)),"error")}this._render()}else if(t==="process-rec-ok"&&n){const o=s.getElementById("wd-pr-mode")?.value;let i=s.getElementById("wd-pr-profile")?.value?.trim();o==="existing_profile"&&(i=s.getElementById("wd-pr-profile-sel")?.value||i);const l=parseFloat(s.getElementById("wd-pr-head")?.value||0),c=parseFloat(s.getElementById("wd-pr-tail")?.value||0);if(this._modal=null,!i){this._showToast(this._t("msg.toast_profile_name_required",{},"Profile name is required"),"error"),this._render();return}try{await this._ws({type:`${y}/process_recording`,entry_id:n,profile_name:i,save_mode:o,head_trim:l,tail_trim:c}),this._showToast(this._t("toast.recording_saved",{},"Recording saved to profile")),await this._fetchRecState(n),await this._fetchProfiles(n)}catch(_){this._showToast(this._t("msg.toast_save_failed",{error:_.message||_},"Save failed: "+(_.message||_)),"error")}this._render()}else if(t==="correct-fb-ok"&&n){const o=s.getElementById("wd-fb-profile")?.value,i=parseFloat(s.getElementById("wd-fb-dur")?.value||0)||null;this._modal=null;try{await this._ws({type:`${y}/resolve_feedback`,entry_id:n,cycle_id:a.cycleId,action:"correct",corrected_profile:o,corrected_duration_min:i}),this._showToast(this._t("toast.correction_submitted",{},"Correction submitted")),await this._fetchFeedbacks(n)}catch(l){this._showToast(this._t("msg.toast_error",{error:l.message||l},"Error: "+(l.message||l)),"error")}this._render()}else if(t==="import-ok"&&n){const o=s.getElementById("wd-import-json")?.value;if(this._modal=null,!o?.trim()){this._showToast(this._t("toast.json_required",{},"JSON data is required"),"error"),this._render();return}try{await this._ws({type:`${y}/import_config`,entry_id:n,json_data:o}),this._showToast(this._t("toast.import_successful",{},"Import successful; integration reloading")),await this._fetchCycles(n)}catch(i){this._showToast(this._t("toast.import_failed",{error:i.message||i},"Import failed: "+(i.message||i)),"error")}this._render()}else if(t==="auto-run"&&n){const o=parseFloat(s.getElementById("wd-al-thr")?.value||"0.75");this._modal=null,this._render(),await this._busyRun("auto-label",async()=>{try{await this._ws({type:`${y}/auto_label_cycles`,entry_id:n,confidence_threshold:o}),this._showToast(this._t("msg.toast_auto_label_complete",{},"Auto-label complete")),await this._fetchCycles(n)}catch(i){this._showToast(this._t("msg.toast_auto_label_failed",{error:i.message||i},"Auto-label failed: "+(i.message||i)),"error")}})}else if(t==="merge-ok"&&n){const o=s.getElementById("wd-merge-prof")?.value||"",i=s.getElementById("wd-merge-newname")?.value?.trim()||null,l=a.ids||[];this._modal=null,this._render(),this._kickAndTrack({type:`${y}/apply_merge`,entry_id:n,cycle_ids:l,target_profile:o||null,new_profile_name:i},"cyc-merge",async()=>{this._showToast(this._t("toast.cycles_merged",{},"Cycles merged")),this._cycleSel.clear(),this._selectMode=!1,await this._fetchCycles(n),await this._fetchProfiles(n)})}else if(t==="bulk-relabel-ok"&&n){const o=s.getElementById("wd-relabel-profile")?.value||"",i=(a.ids||[]).slice();if(this._modal=null,this._render(),!i.length)return;await this._busyRun("cyc-relabel",async()=>{try{for(const l of i)await this._ws({type:`${y}/label_cycle`,entry_id:n,cycle_id:l,profile_name:o||null});this._showToast(this._t("toast.relabel_done",{count:i.length},`Relabelled ${i.length} cycle(s)`)),this._cycleSel.clear(),this._selectMode=!1,await this._fetchCycles(n),await this._fetchProfiles(n),await this._fetchFeedbacks(n)}catch(l){this._showToast(this._t("toast.relabel_failed",{error:l.message||l},"Relabel failed: "+(l.message||l)),"error")}})}}async _histUpload(t,e){const s=await this._ws({type:`${y}/history_import_begin`,entry_id:t}),r=Math.max(4096,s.chunk_bytes||512*1024);let n=0,a=0;for(;aa&&(o=i+1)}await this._ws({type:`${y}/history_import_chunk`,entry_id:t,token:s.token,seq:n,text:e.slice(a,o)}),n+=1,a=o}return s.token}async _histStartScan(t,e,s){e.token=s,e.step="scan",e.error=null,this._render();const r=await this._ws({type:`${y}/start_history_import_scan`,entry_id:t,token:s});e.scanTaskId=r.task_id,this._addProvisionalTask(r.task_id,"history_import",t,0),this._tasksSubscribed||this._pollTaskGeneric(r.task_id),this._histAdopt(r.task_id)}async _histAdopt(t){if(!t)return;let e=(this._tasks||{})[t];if(!e||e.state==="running")try{e=await this._ws({type:`${y}/get_task_result`,task_id:t})}catch{return}e&&e.state&&e.state!=="running"&&this._histTaskFinished(e)}async _histTaskFinished(t){const e=this._modal;if(!(!e||e.type!=="history-import")&&!(t.id!==e.scanTaskId&&t.id!==e.applyTaskId)&&(this._histSettled=this._histSettled||new Set,!this._histSettled.has(t.id))){if(this._histSettled.add(t.id),t.id===e.scanTaskId){if(t.state==="error"){e.step="input",e.error=t.error||this._t("msg.hist_scan_failed",{},"Scanning failed."),this._render();return}try{const s=t.result?t:await this._ws({type:`${y}/get_task_result`,task_id:t.id});e.result=s.result||{},e.accept=new Set((e.result.segments||[]).filter(r=>r.accept).map(r=>r.index)),e.step="review",this._render(),requestAnimationFrame(()=>this._drawHistorySparklines())}catch(s){e.step="input",e.error=String(s&&s.message||s),this._render()}return}if(t.id===e.applyTaskId){if(t.state==="error"){e.error=t.error==="scan_expired"?this._t("msg.hist_scan_expired",{},"That scan is no longer available. Please scan again."):t.error||this._t("msg.hist_import_failed",{},"Import failed."),e.step="review",this._render();return}try{const r=t.result?t:await this._ws({type:`${y}/get_task_result`,task_id:t.id});e.done=r.result||{}}catch{e.done={}}e.step="done",this._render();const s=this._devices[this._selIdx];s&&(this._fetchCycles(s.entry_id),this._fetchProfiles(s.entry_id))}}}async _onMActHistoryImport(t,e,s,r,n){if(!(!s||s.type!=="history-import"||!r)){if(t==="hist-scan"){const a=n.getElementById("wd-hist-csv"),o=a?a.value:s.csvText||"";if(!o.trim()){this._showToast(this._t("toast.hist_csv_required",{},"Load a CSV file or paste its contents first"),"error");return}s.csvText=o,await this._busyRun("hist-import",async()=>{try{const i=await this._histUpload(r,o);await this._histStartScan(r,s,i)}catch(i){s.error=String(i&&i.message||i),this._render()}});return}if(t==="hist-recorder"){const a=n.getElementById("wd-hist-since"),o=a&&a.value||zt();s.since=o,await this._busyRun("hist-import",async()=>{try{const i=await this._ws({type:`${y}/history_import_recorder`,entry_id:r,start_date:o});if(!i.rows){s.error=this._t("msg.hist_recorder_empty",{},"Home Assistant has no detailed history for this sensor in that window."),this._render();return}await this._histStartScan(r,s,i.token)}catch(i){s.error=String(i&&i.message||i),this._render()}});return}if(t==="hist-cancel-scan"){if(s.scanTaskId)try{await this._ws({type:`${y}/cancel_task`,task_id:s.scanTaskId})}catch{}s.step="input",s.scanTaskId=null,this._render();return}if(t==="hist-back"){s.step="input",s.result=null,s.error=null,this._render();return}if(t==="hist-toggle-all"){const a=s.result&&s.result.segments||[],o=a.every(i=>s.accept.has(i.index));s.accept=o?new Set:new Set(a.map(i=>i.index)),this._render(),requestAnimationFrame(()=>this._drawHistorySparklines());return}if(t==="hist-apply"){if(!s.accept||!s.accept.size)return;await this._busyRun("hist-apply",async()=>{try{const a=await this._ws({type:`${y}/apply_history_import`,entry_id:r,scan_task_id:s.scanTaskId,accept:[...s.accept].sort((o,i)=>o-i)});s.applyTaskId=a.task_id,this._addProvisionalTask(a.task_id,"history_import_apply",r,s.accept.size),this._tasksSubscribed||this._pollTaskGeneric(a.task_id),this._histAdopt(a.task_id)}catch(a){s.error=String(a&&a.message||a),this._render()}});return}if(t==="hist-goto-cycles"){this._modal=null,this._tab="history",this._cycleFilter={...this._cycleFilter||{},status:"imported"},this._fetchTabData();return}}}async _onMActImport(t,e,s,r,n){if(s&&s.type==="store-import"){if(t==="store-import-mode-new"){s.mode="new",this._render();return}if(t==="store-import-mode-merge"){s.mode="merge",this._render();return}if(t==="store-import-ok"){const a={type:`${y}/store_import_cycle`,entry_id:r,cycle_id:s.cycleId};if(s.mode==="merge"){const o=n.getElementById("wd-store-import-target")?.value||"";if(!o){this._showToast(this._t("toast.store_pick_profile",{},"Pick a profile to merge into"),"error");return}a.target_profile=o}else{const o=(n.getElementById("wd-store-import-name")?.value||"").trim()||s.program;if(!o){this._showToast(this._t("toast.store_name_required",{},"Enter a profile name"),"error");return}a.new_profile_name=o}await this._busyRun("store-import",async()=>{try{const o=await this._ws(a);if(o&&o.error){this._showToast(this._t("toast.store_import_failed",{error:o.error},"Import failed: "+o.error),"error");return}this._modal=null,this._showToast(this._t("toast.store_imported",{profile:o&&o.profile||""},`Imported into ${o&&o.profile||"profile"}`)),await this._fetchProfiles(r)}catch(o){this._showToast(this._t("toast.store_import_failed",{error:o.message||o},"Import failed: "+(o.message||o)),"error")}});return}}if(s&&s.type==="store-share"&&t==="store-share-ok"){const a=(n.getElementById("wd-store-share-prog")?.value||"").trim(),o=(n.getElementById("wd-store-share-desc")?.value||"").trim();if(!a){this._showToast(this._t("toast.store_pick_profile",{},"Pick a profile to share into"),"error");return}await this._busyRun("store-share",async()=>{try{const i=await this._ws({type:`${y}/store_upload_cycle`,entry_id:r,local_cycle_id:s.cycleId,program:a,description:o});if(i&&i.error){if(i.error==="no_appliance_declared")this._showToast(this._t("toast.store_no_appliance",{},"Set your appliance brand and model in Settings first."),"error");else{const l=i.detail?`${i.error} - ${i.detail}`:i.error;this._showToast(this._t("toast.store_share_failed",{error:l},"Share failed: "+l),"error")}return}this._modal=null,this._showToast(this._t("toast.store_shared",{},"Shared to the community store - pending review."))}catch(i){this._showToast(this._t("toast.store_share_failed",{error:i.message||i},"Share failed: "+(i.message||i)),"error")}});return}if(s&&(s.type==="export-select"||s.type==="import-wizard")){const a=s.type==="export-select"?{categories:s.inventory||{}}:s.manifest||{categories:{}};if(t==="wiz-toggle-all"){const o=a.categories||{},i=s.type==="import-wizard";let l=!0;this._wizCatOrder().filter(c=>o[c]&&o[c].present).forEach(c=>{i&&o[c].importable===!1||this._wizCatState(s,c,a).state!=="all"&&(l=!1)}),s.sel=l?{cats:new Set,profiles:new Set,realIds:new Set,refIds:new Set}:this._wizInitSel(a,i),this._render();return}if(t==="wiz-toggle-cat"){const o=e.dataset.cat,l=this._wizCatState(s,o,a).state!=="all";if(o==="profiles"){const c=a.categories.profiles&&a.categories.profiles.items||[];s.sel.profiles=new Set(l?c.map(_=>_.name):[])}else if(o==="real_cycles"||o==="reference_cycles"){const c=new Set;l&&(a.categories[o]&&a.categories[o].groups||[]).forEach(_=>_.cycles.forEach(p=>{p.id!=null&&c.add(String(p.id))})),o==="real_cycles"?s.sel.realIds=c:s.sel.refIds=c}else l?s.sel.cats.add(o):s.sel.cats.delete(o);this._render();return}if(t==="wiz-toggle-profile"){const o=e.dataset.name;s.sel.profiles.has(o)?s.sel.profiles.delete(o):s.sel.profiles.add(o),this._render();return}if(t==="wiz-toggle-cycgroup"){const o=e.dataset.cat,i=e.dataset.prof,l=o==="real_cycles"?s.sel.realIds:s.sel.refIds,c=this._wizGroupIds(a,o,i),_=c.length>0&&c.every(p=>l.has(p));c.forEach(p=>{_?l.delete(p):l.add(p)}),this._render();return}if(t==="wiz-toggle-cyc"){const o=e.dataset.cat,i=e.dataset.cid,l=o==="real_cycles"?s.sel.realIds:s.sel.refIds;l.has(i)?l.delete(i):l.add(i),this._render();return}if(t==="wiz-expand"){const o=e.dataset.key;s.expanded||(s.expanded=new Set),s.expanded.has(o)?s.expanded.delete(o):s.expanded.add(o),this._render();return}}if(s&&s.type==="import-wizard"){if(t==="import-back"){s.step="input",s.error=null,this._render();return}if(t==="imp-mode-merge"){s.mode="merge",this._render();return}if(t==="imp-mode-replace"){s.mode="replace",this._render();return}if(t==="imp-dest-reference"){s.cycleDest="reference",this._render();return}if(t==="imp-dest-real"){(s.manifest||{}).real_history_allowed!==!1&&(s.cycleDest="real_history",this._render());return}if(t==="imp-conflict"){s.conflicts[e.dataset.prof]=e.value;return}if(t==="import-analyze"&&r){const a=n.getElementById("wd-import-json"),o=a?a.value:s.jsonText||"";if(s.jsonText=o,!o.trim()){this._showToast(this._t("toast.json_required",{},"JSON data is required"),"error");return}s.step="analyze",s.error=null,this._render();try{const i=await this._ws({type:`${y}/analyze_import`,entry_id:r,json_data:o});if(!this._isActiveEntry(r)||!this._modal||this._modal.type!=="import-wizard")return;const l=i&&i.manifest||{};if(l.error){s.step="input",s.error=l.error,this._render();return}s.manifest=l,s.sel=this._wizInitSel(l,!0),s.conflicts={},(l.categories&&l.categories.profiles&&l.categories.profiles.items||[]).forEach(c=>{c.conflict&&(s.conflicts[c.name]="import_as_copy")}),s.step="select",this._render()}catch(i){if(!this._isActiveEntry(r)||!this._modal||this._modal.type!=="import-wizard")return;s.step="input",s.error=i&&i.message||String(i),this._render()}return}if(t==="import-apply-ok"&&r){const a=this._wizSelectionPayload(s);await this._busyRun("import-wizard",async()=>{try{const o=await this._ws({type:`${y}/import_config_selective`,entry_id:r,json_data:s.jsonText,selection:a,mode:s.mode,conflict_resolutions:s.conflicts,cycle_destination:s.cycleDest,apply_settings:!0}),i=o&&o.summary||{};this._modal=null,this._showToast(this._t("toast.import_selective_done",{profiles:i.profiles_imported||0,cycles:(i.real_cycles_imported||0)+(i.reference_cycles_imported||0)},`Imported ${i.profiles_imported||0} profile(s) and ${(i.real_cycles_imported||0)+(i.reference_cycles_imported||0)} cycle(s)`)),await this._fetchCycles(r),await this._fetchProfiles(r)}catch(o){this._showToast(this._t("toast.import_failed",{error:o.message||o},"Import failed: "+(o.message||o)),"error")}});return}}}async _onMActStoreShare(t,e,s,r){if(s&&s.type==="store-share-device"){if(t==="sd-toggle-cyc"){const n=e.dataset.cid;s.selected.has(n)?s.selected.delete(n):s.selected.add(n),this._render();return}if(t==="sd-toggle-prof"){const n=e.dataset.prog,a=this._shareableByProgram().find(o=>o.program===n);if(a){const o=a.cycles.every(i=>s.selected.has(i.id));a.cycles.forEach(i=>{o?s.selected.delete(i.id):s.selected.add(i.id)})}this._render();return}if(t==="sd-toggle-phases"){const n=e.dataset.prog;s.includePhases||(s.includePhases=new Set),s.includePhases.has(n)?s.includePhases.delete(n):s.includePhases.add(n),this._render();return}if(t==="sd-toggle-settings"){s.includeSettings=!s.includeSettings,this._render();return}if(t==="sd-toggle-consent"){s.consented=!s.consented,this._render();return}if(t==="sd-toggle-guide"){s.guideOpen=!s.guideOpen,this._render();return}if(t==="store-share-device-ok"){const n=new Map;(this._shareableCycles||[]).forEach(l=>n.set(l.id,(l.profile_name||"").trim()));const a=Array.from(s.selected).map(l=>({local_cycle_id:l,program:n.get(l)||""})).filter(l=>l.program);if(!a.length){this._showToast(this._t("toast.share_device_none_sel",{},"Select at least one cycle to share"),"error");return}const o=new Set(a.map(l=>l.program)),i=Array.from(s.includePhases||[]).filter(l=>o.has(l));await this._busyRun("store-share-device",async()=>{try{const l=await this._ws({type:`${y}/store_upload_device`,entry_id:r,items:a,include_phases:i,include_settings:!!s.includeSettings});if(l&&l.error){if(l.error==="no_appliance_declared")this._showToast(this._t("toast.store_no_appliance",{},"Set your appliance brand and model in Settings first."),"error");else{const u=l.detail?`${l.error} - ${l.detail}`:l.error;this._showToast(this._t("toast.store_share_failed",{error:u},"Share failed: "+u),"error")}return}const c=l&&l.cycle_ids&&l.cycle_ids.length||0,_=l&&l.errors&&l.errors.length||0,p=l&&l.duplicates||0,g=l&&l.created!=null?l.created:c;if(!c){const u=l&&l.errors&&l.errors[0]||l&&l.detail||"upload_failed";this._showToast(this._t("toast.store_share_failed",{error:u},"Share failed: "+u),"error");return}this._modal=null,_?this._showToast(this._t("toast.store_device_shared_partial",{n:c,failed:_},`Shared ${c} cycle(s); ${_} could not be uploaded.`),"info"):p&&!g?this._showToast(this._t("toast.store_device_shared_all_dup",{n:p},`All ${p} cycle(s) were already in the community store.`),"info"):p?this._showToast(this._t("toast.store_device_shared_some_dup",{created:g,dup:p},`Shared ${g} cycle(s); ${p} were already in the store.`)):this._showToast(this._t("toast.store_device_shared",{n:g},`Shared ${g} cycle(s) to the community store - pending review.`))}catch(l){this._showToast(this._t("toast.store_share_failed",{error:l.message||l},"Share failed: "+(l.message||l)),"error")}});return}}}async _onMActCycleDetail(t,e,s,r){if(e&&e.type==="cycle-detail"){if(t==="cyc-view"){e.mode="view",this._render();return}if(t==="cyc-trim"){e.mode="trim",(!e.trim||e.trim.end<=0)&&(e.trim={start:0,end:e.curve&&e.curve.full_duration_s||0}),this._render();return}if(t==="cyc-split"){e.mode="split",this._render();return}if(t==="cyc-review"){e.mode="review",this._render();return}if(t==="cyc-review-save"){const n=e.cycleId,a=r.getElementById("wd-cyc-rev-quality")?.value||"",o=!!r.getElementById("wd-cyc-rev-golden")?.checked,i=r.getElementById("wd-cyc-rev-notes")?.value||"",l=Array.from(r.querySelectorAll(".wd-cyc-rev-tag")).filter(p=>p.checked).map(p=>p.value),c=r.getElementById("wd-cyc-rev-label")?.value??"",_=e.curve&&e.curve.profile_name||"";await this._busyRun("cyc-review-save",async()=>{try{await this._ws({type:`${y}/set_ml_review`,entry_id:s,cycle_id:n,quality:a,golden:o,tags:l,notes:i}),c!==_&&await this._ws({type:`${y}/label_cycle`,entry_id:s,cycle_id:n,profile_name:c||null}),this._showToast(this._t("toast.review_saved",{},"Review saved")),await this._fetchCycles(s),c!==_&&await this._fetchFeedbacks(s),await this._loadMlIndex(s),this._modal&&this._modal.cycleId===n&&(this._modal.ml=(this._mlById||{})[n]||this._modal.ml)}catch(p){this._showToast(this._t("msg.toast_save_failed",{error:p.message||p},"Save failed: "+(p.message||p)),"error")}});return}if(t==="trim-mode-s"){e.timeMode="s",this._render();return}if(t==="trim-mode-clock"){e.timeMode="clock",this._render();return}if(t==="cyc-reset-trim"){e.trim={start:0,end:e.curve&&e.curve.full_duration_s||0},this._render();return}if(t==="cyc-clear-split"){e.split={offsets:[],profiles:[]},this._render();return}if(t==="cyc-label"){this._profiles.length||await this._fetchProfiles(s),this._modal={type:"label-cycle",cycleId:e.cycleId},this._render();return}if(t==="cyc-delete"){const n=e.cycleId;this._modal=null,this._render(),this._deleteCyclesWithUndo(s,[n]);return}if(t==="cyc-auto-split"){const n=parseInt(r.getElementById("wd-split-gap")?.value||"900",10);await this._busyRun("cyc-auto",async()=>{try{const a=await this._ws({type:`${y}/analyze_split`,entry_id:s,cycle_id:e.cycleId,gap_seconds:n});e.split.offsets=(a.split_offsets||[]).slice(),e.split.profiles=[],e.split.offsets.length||this._showToast(this._t("toast.no_split_found",{},"No idle gaps found to split on"),"info")}catch(a){this._showToast(this._t("toast.auto_detect_failed",{error:a.message||a},"Auto-detect failed: "+(a.message||a)),"error")}});return}if(t==="cyc-apply-trim"){const n=e.cycleId,a=e.trim.start,o=e.trim.end,i=e.curve&&e.curve.full_duration_s||0,l=i>0?Math.max(0,Math.round((o-a)/i*100)):100;if(l<50&&!confirm(this._t("msg.trim_destructive_confirm",{pct:l},`This keeps only ${l}% of the cycle and cannot be undone. Continue?`)))return;this._kickAndTrack({type:`${y}/trim_cycle`,entry_id:s,cycle_id:n,start_s:a,end_s:o},"cyc-trim-apply",async()=>{this._showToast(this._t("toast.cycle_trimmed",{},"Cycle trimmed")),await this._closeCycleDetail(s),await this._fetchCycles(s)});return}if(t==="cyc-apply-split"){const n=e.cycleId,a=e.split.offsets.slice(),o=e.split.profiles.slice();this._kickAndTrack({type:`${y}/apply_split`,entry_id:s,cycle_id:n,split_offsets:a,segment_profiles:o},"cyc-split-apply",async i=>{this._showToast(this._t("toast.split_complete",{count:(i.new_ids||[]).length},`Split into ${(i.new_ids||[]).length} cycles`)),await this._closeCycleDetail(s),await this._fetchCycles(s),await this._fetchProfiles(s)});return}}}async _onMActProfilePanel(t,e,s,r,n){if(s&&s.type==="profile-panel"){if(t==="pp-phase-add"){const a=s.env&&s.env.target_duration||(s.env&&s.env.avg&&s.env.avg.length?s.env.avg[s.env.avg.length-1][0]:600),o=s.phases.length?s.phases[s.phases.length-1].end:0,i=Math.min(o,a);s.phases.push({name:s.catalog[0]||"",start:i,end:Math.min(i+Math.max(60,a*.1),a)}),this._render();return}if(t==="pp-phase-rm"){const a=+(e&&e.dataset.idx||-1);a>=0&&(s.phases.splice(a,1),this._render());return}if(t==="pp-phase-save"){const a=s.phases.filter(o=>o.name).map(o=>({name:o.name,start:o.start,end:o.end}));await this._busyRun("pp-phase-save",async()=>{try{await this._ws({type:`${y}/set_profile_phases`,entry_id:r,profile_name:s.name,phases:a}),this._showToast(this._t("toast.phases_saved",{},"Phases saved"))}catch(o){this._showToast(this._t("msg.toast_save_failed",{error:o.message||o},"Save failed: "+(o.message||o)),"error")}});return}if(t==="pp-cleanup-del"){const a=s.cleanup?Array.from(s.cleanup.selected):[];if(!a.length)return;await this._busyRun("pp-cleanup-del",async()=>{try{for(const i of a)await this._ws({type:`${y}/delete_cycle`,entry_id:r,cycle_id:i});this._showToast(this._t("toast.cycles_deleted",{count:a.length},`Deleted ${a.length} cycle(s)`));const o=await this._ws({type:`${y}/get_profile_cycles`,entry_id:r,profile_name:s.name});this._modal&&(this._modal.cleanup={cycles:o.cycles||[],selected:new Set}),await this._fetchProfiles(r)}catch(o){this._showToast(this._t("msg.toast_delete_failed",{error:o.message||o},"Delete failed: "+(o.message||o)),"error")}});return}if(t==="pp-rename"){const a=n.getElementById("wd-pp-rename")?.value?.trim(),o=parseFloat(n.getElementById("wd-pp-dur")?.value||"0");if(!a){this._showToast(this._t("msg.toast_name_required",{},"Name required"),"error");return}try{await this._ws({type:`${y}/rename_profile`,entry_id:r,profile_name:s.name,new_name:a,manual_duration_min:o>0?o:null}),this._showToast(this._t("toast.profile_renamed",{},"Profile renamed")),s.name=a,await Promise.all([this._fetchProfiles(r),this._fetchProfileGroups(r)]),s.stats=(this._profiles||[]).find(i=>i.name===a)||s.stats,this._render()}catch(i){this._showToast(this._t("toast.rename_failed",{error:i.message||i},"Rename failed: "+(i.message||i)),"error")}return}if(t==="pp-rebuild"){this._kickAndTrack({type:`${y}/rebuild_envelopes`,entry_id:r},"pp-rebuild",async()=>{try{const a=await this._ws({type:`${y}/get_profile_envelope`,entry_id:r,profile_name:s.name});this._modal&&(this._modal.env=a.envelope)}catch{}this._showToast(this._t("toast.envelope_rebuilt",{},"Envelope rebuilt"))});return}if(t==="pp-delete"){this._deleteProfileWithUndo(r,s.name);return}}}_snapshotFormToPending(t){t&&t.querySelectorAll("#wd-settings-form [data-opt], #wd-ml-form [data-opt]").forEach(e=>{const s=e.dataset.opt,r=kt[s],n=r&&r.type||e.dataset.ftype||"text";if(e.type==="checkbox"){this._pendingSettings[s]=e.checked;return}if(n==="checkboxlist"){this._pendingSettings[s]=this._collectCheckboxlist(e,s);return}if(n==="entitylist"){this._pendingSettings[s]=Array.from(e.querySelectorAll(".wd-pill")).map(a=>a.dataset.val).filter(Boolean);return}if(n==="timerlist"){this._pendingSettings[s]=Array.from(e.querySelectorAll(".wd-timer-row")).map(a=>({offset_minutes:parseFloat(a.querySelector('[data-field="offset_minutes"]').value)||0,message:(a.querySelector('[data-field="message"]').value||"").trim(),auto_pause:a.querySelector('[data-field="auto_pause"]').checked})).filter(a=>a.offset_minutes>0);return}if(n==="number"){const a=String(e.value).trim();if(a===""){r&&r.clearable?this._pendingSettings[s]=null:delete this._pendingSettings[s];return}const o=parseFloat(a);isNaN(o)||(this._pendingSettings[s]=o);return}if(n==="list"){this._pendingSettings[s]=String(e.value).split(",").map(a=>a.trim()).filter(Boolean);return}if(n==="intlist"){this._pendingSettings[s]=ee(e.value);return}if(n==="json"){const a=String(e.value).trim();if(!a){this._pendingSettings[s]=[];return}try{this._pendingSettings[s]=JSON.parse(a)}catch{}return}if(n==="entity"||n==="device"){const a=String(e.value).trim();this._pendingSettings[s]=a||null;return}this._pendingSettings[s]=e.value})}_collectCheckboxlist(t,e){const s=Array.from(t.querySelectorAll("[data-choice]")).filter(n=>n.checked).map(n=>n.dataset.choice);if(s.length)return s;const r=kt[e];return r&&Array.isArray(r.def)&&r.def.length?r.def.slice():s}_conflictKeysForOpts(t,e){const s=Object.assign({},e||{},t),r=new Set;for(const n of Vt)if(n.check(s))for(const a of Object.keys(n.fieldErrors(s)))r.add(a);return r}_conflictCountForOpts(t,e){return this._conflictKeysForOpts(t,e).size}_conflictKeysFromOpts(){return this._conflictKeysForOpts(Object.assign({},this._opts,this._pendingSettings),this._optDefaults)}_readSettingsFormValues(t){const e=Object.assign({},this._optDefaults,this._opts,this._pendingSettings);return t&&t.querySelectorAll("#wd-settings-form [data-opt]").forEach(s=>{const r=s.dataset.opt;if(s.type==="checkbox"){e[r]=s.checked;return}if(s.dataset.ftype==="checkboxlist"){e[r]=this._collectCheckboxlist(s,r);return}const n=parseFloat(s.value);isNaN(n)?s.value!==""&&(e[r]=s.value):e[r]=n}),e}_liveValidateSettings(t){if(!t)return{};const e=this._readSettingsFormValues(t),s=t.getElementById("wd-settings-form");if(!s)return{};const r={};for(const a of this._suggestions||[])a.key!=null&&a.suggested!=null&&(r[a.key]=+a.suggested);const n={};for(const a of Vt){if(!a.check(e))continue;const o=a.fieldErrors(e);for(const[i,l]of Object.entries(o)){const c=r[i],_=c!=null&&!a.check({...e,[i]:c})?{...l,suggFix:c}:l;(n[i]=n[i]||[]).push(_)}}return s.querySelectorAll("[data-cerr]").forEach(a=>{const o=a.dataset.cerr,i=n[o],l=s.querySelector(`.wd-field[data-field="${o}"]`);!i||!i.length?(a.hidden=!0,a.innerHTML="",l&&l.classList.remove("wd-has-conflict")):(a.hidden=!1,l&&l.classList.add("wd-has-conflict"),a.innerHTML=i.map(c=>{const _=this._t(c.msgKey,c.msgVars,c.msgFb);let p="";if(c.suggFix!=null){const g=+c.suggFix.toFixed(2);p=`${this._t("conflict.suggestion_resolves",{val:g},`Stage the pending suggestion (${g}) below to fix this`)}`}else if(c.fixVal!=null&&!isNaN(+c.fixVal)){const g=Number.isInteger(c.fixVal)?c.fixVal:+c.fixVal.toFixed(2);p=``}return`
    \u26A0 ${d(_)}${p}
    `}).join(""))}),n}_cascadeConflictFix(t,e,s){const r=new Set;for(let n=0;n<10;n++){const a=this._liveValidateSettings(t);let o=!1;for(const[i,l]of Object.entries(a)){if(i===s)continue;const c=l.find(p=>p.fixVal!=null&&!isNaN(+p.fixVal)&&+p.fixVal>0);if(!c)continue;const _=e.querySelector(`[data-opt="${i}"]`);_?_.value=c.fixVal:(this._preCascadeOpts==null&&(this._preCascadeOpts=JSON.parse(JSON.stringify(this._opts||{}))),this._opts={...this._opts,[i]:c.fixVal},(this._cascadePending??={})[i]=c.fixVal),r.add(i),o=!0;break}if(!o)break}if(this._liveValidateSettings(t),this._snapshotFormToPending(t),r.size>0){const n=r.size,a=n>1?"s":"";this._showToast(this._t("conflict.cascade_toast",{n},`Other settings adjusted for consistency: ${n}`),"success")}}async _saveSettings(){const t=this.shadowRoot,e=this._devices[this._selIdx];if(!e)return;const s=Object.assign({},this._pendingSettings,this._cascadePending);this._invalidJson=null,t.querySelectorAll("[data-opt]").forEach(o=>{const i=o.dataset.opt,l=kt[i],c=l&&l.type||o.dataset.ftype||"text";if(o.type==="checkbox"){s[i]=o.checked;return}if(c==="checkboxlist"){s[i]=this._collectCheckboxlist(o,i);return}if(c==="entitylist"){s[i]=Array.from(o.querySelectorAll(".wd-pill")).map(p=>p.dataset.val).filter(Boolean);return}if(c==="timerlist"){s[i]=Array.from(o.querySelectorAll(".wd-timer-row")).map(p=>({offset_minutes:parseFloat(p.querySelector('[data-field="offset_minutes"]').value)||0,message:(p.querySelector('[data-field="message"]').value||"").trim(),auto_pause:p.querySelector('[data-field="auto_pause"]').checked})).filter(p=>p.offset_minutes>0);return}const _=o.value;if(c==="number"){const p=String(_).trim();if(p===""){l&&l.clearable?s[i]=null:delete s[i];return}const g=parseFloat(p);isNaN(g)||(s[i]=g);return}if(c==="list"){s[i]=String(_).split(",").map(p=>p.trim()).filter(Boolean);return}if(c==="intlist"){s[i]=ee(_);return}if(c==="json"){const p=String(_).trim();if(!p){s[i]=[];return}try{s[i]=JSON.parse(p)}catch{this._invalidJson=i}return}if(c==="entity"||c==="device"){const p=String(_).trim();s[i]=p||null;return}s[i]=_});const r=this._optDefaults||{};for(const o of Object.keys(r))!(o in this._opts)&&o in s&&JSON.stringify(s[o])===JSON.stringify(r[o])&&delete s[o];if(this._invalidJson){this._showToast(this._t("toast.invalid_json",{key:this._invalidJson},`"${this._invalidJson}" is not valid JSON - fix it or clear the field before saving.`),"error");return}const n=this._liveValidateSettings(t),a=new Set(Object.keys(n));if(a.size>0){const o={},i={};for(const[l,c]of Object.entries(s)){if(a.has(l)){i[l]=c;continue}JSON.stringify(c)!==JSON.stringify((this._opts||{})[l])&&(o[l]=c)}this._pendingSettings={...this._pendingSettings,...i},Object.keys(o).length?await this._busyRun("save-settings",async()=>{try{await this._ws({type:`${y}/set_options`,entry_id:e.entry_id,options:o}),this._opts={...this._opts,...o},this._showToast(this._t("toast.saved_except_conflicts",{},"Saved. Fix the highlighted conflicts to save the rest."),"info")}catch(l){this._showToast(this._t("msg.toast_save_failed",{error:l.message||l},"Save failed: "+(l.message||l)),"error")}}):this._showToast(this._t("toast.settings_conflicts",{},"Fix the highlighted setting conflicts before saving."),"error");return}await this._busyRun("save-settings",async()=>{try{const o=JSON.parse(JSON.stringify(this._preCascadeOpts||this._opts||{}));if(await this._ws({type:`${y}/set_options`,entry_id:e.entry_id,options:s}),this._opts={...this._opts,...s},this._prevOpts=o,this._cascadePending={},this._preCascadeOpts=null,this._pendingSettings={},this._stagedSuggestions){try{await this._ws({type:`${y}/clear_suggestions`,entry_id:e.entry_id})}catch{}this._stagedSuggestions=!1,this._suggestions=[]}this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading"))}catch(o){this._showToast(this._t("msg.toast_save_failed",{error:o.message||o},"Save failed: "+(o.message||o)),"error")}})}}customElements.get("ha-washdata-panel")||customElements.define("ha-washdata-panel",me); diff --git a/custom_components/ha_washdata/www/ws-types.d.ts b/custom_components/ha_washdata/www/ws-types.d.ts index 698c16e3..46fea6aa 100644 --- a/custom_components/ha_washdata/www/ws-types.d.ts +++ b/custom_components/ha_washdata/www/ws-types.d.ts @@ -20,6 +20,8 @@ export interface AnalyzeSplitResponse { segments: number[][]; split_offsets: number[]; samples: number[][]; + sample_count: number; + decimated: boolean; full_duration_s: number; } @@ -54,11 +56,13 @@ export interface DeviceInfo { current_power_w: number | null; cycle_progress_pct: number | null; suggestions_count: number; + suggestion_keys: string[]; feedback_count: number; recording: boolean; is_user_paused: boolean; manual_program: boolean; options: Record; + option_defaults: Record; } export interface DismissAllFeedbacksResponse { @@ -109,6 +113,8 @@ export interface GetConstantsResponse { export interface GetCyclePowerDataResponse { cycle_id?: string; samples?: number[][]; + sample_count?: number; + decimated?: boolean; full_duration_s?: number; start_time?: string | null; end_time?: string | null; @@ -119,12 +125,16 @@ export interface GetCyclePowerDataResponse { artifacts?: Record[]; restart_gaps?: unknown[]; is_reference?: boolean; + labelable?: boolean; + editable?: boolean; + cycle_origin?: string; } export interface GetDeviceCyclesResponse { entry_id: string; cycles: Record[]; reference_cycles: Record[]; + backfill_cycles: Record[]; total: number; has_more: boolean; } @@ -203,6 +213,7 @@ export interface GetMlTrainingStatusResponse { export interface GetOptionsResponse { options: Record; + defaults: Record; } export interface GetPanelConfigResponse { @@ -300,6 +311,26 @@ export interface GetSuggestionsResponse { locked_suggestions: string[]; } +export interface HistoryImportBeginResponse { + token: string; + max_bytes: number; + chunk_bytes: number; +} + +export interface HistoryImportChunkResponse { + received_bytes: number; + next_seq: number; +} + +export interface HistoryImportRecorderResponse { + token: string; + rows: number; + entity_id: string; + days: number; + start_date: string; + truncated: boolean; +} + export interface ImportConfigSelectiveResponse { success: boolean; summary: Record; @@ -393,6 +424,13 @@ export interface StartTaskResponse { task_id: string; } +export interface StoreCatalogEntryResponse { + device_id?: string; + brand?: Record | null; + device?: Record | null; + disabled?: boolean; +} + export interface StoreConfirmResponse { confirmed?: boolean; confirmCount?: number; @@ -447,6 +485,11 @@ export interface StoreQualityResponse { disabled?: boolean; } +export interface StoreRefreshCatalogResponse { + ok?: boolean; + disabled?: boolean; +} + export interface StoreSimpleResponse { connected?: boolean; uid?: string | null; @@ -915,6 +958,7 @@ export interface GetDtwDebugRequest { export interface GetPlaygroundSettingsRequest { entry_id: string; + include_suggestions?: boolean; } export interface SavePlaygroundPresetRequest { @@ -967,6 +1011,34 @@ export interface StartPlaygroundCycleDetailRequest { stress_idle_w?: number | null; } +export interface HistoryImportBeginRequest { + entry_id: string; +} + +export interface HistoryImportChunkRequest { + entry_id: string; + token: string; + seq: number; + text: string; +} + +export interface HistoryImportRecorderRequest { + entry_id: string; + start_date?: string | null; + days?: number; +} + +export interface StartHistoryImportScanRequest { + entry_id: string; + token: string; +} + +export interface ApplyHistoryImportRequest { + entry_id: string; + scan_task_id: string; + accept: unknown[]; +} + export interface StoreStatusRequest { entry_id: string; } @@ -1018,6 +1090,17 @@ export interface StoreGetDeviceProfilesRequest { appliance_type: string; } +export interface StoreGetCatalogEntryRequest { + entry_id: string; + brand: string; + model: string; + appliance_type: string; +} + +export interface StoreRefreshCatalogRequest { + entry_id: string; +} + export interface StoreConfirmDeviceRequest { entry_id: string; device_id: string; @@ -1161,6 +1244,11 @@ export interface WashDataWsRequests { "ha_washdata/start_playground_history": StartPlaygroundHistoryRequest; "ha_washdata/start_playground_sweep": StartPlaygroundSweepRequest; "ha_washdata/start_playground_cycle_detail": StartPlaygroundCycleDetailRequest; + "ha_washdata/history_import_begin": HistoryImportBeginRequest; + "ha_washdata/history_import_chunk": HistoryImportChunkRequest; + "ha_washdata/history_import_recorder": HistoryImportRecorderRequest; + "ha_washdata/start_history_import_scan": StartHistoryImportScanRequest; + "ha_washdata/apply_history_import": ApplyHistoryImportRequest; "ha_washdata/store_status": StoreStatusRequest; "ha_washdata/store_connect": StoreConnectRequest; "ha_washdata/store_disconnect": StoreDisconnectRequest; @@ -1170,6 +1258,8 @@ export interface WashDataWsRequests { "ha_washdata/store_get_cycles": StoreGetCyclesRequest; "ha_washdata/store_get_device_quality": StoreGetDeviceQualityRequest; "ha_washdata/store_get_device_profiles": StoreGetDeviceProfilesRequest; + "ha_washdata/store_get_catalog_entry": StoreGetCatalogEntryRequest; + "ha_washdata/store_refresh_catalog": StoreRefreshCatalogRequest; "ha_washdata/store_confirm_device": StoreConfirmDeviceRequest; "ha_washdata/store_rate_device": StoreRateDeviceRequest; "ha_washdata/store_set_online": StoreSetOnlineRequest; @@ -1270,6 +1360,11 @@ export interface WashDataWsResponses { "ha_washdata/start_playground_history": StartTaskResponse; "ha_washdata/start_playground_sweep": StartTaskResponse; "ha_washdata/start_playground_cycle_detail": StartTaskResponse; + "ha_washdata/history_import_begin": HistoryImportBeginResponse; + "ha_washdata/history_import_chunk": HistoryImportChunkResponse; + "ha_washdata/history_import_recorder": HistoryImportRecorderResponse; + "ha_washdata/start_history_import_scan": StartTaskResponse; + "ha_washdata/apply_history_import": StartTaskResponse; "ha_washdata/store_status": StoreStatusResponse; "ha_washdata/store_connect": StoreSimpleResponse; "ha_washdata/store_disconnect": StoreSimpleResponse; @@ -1285,6 +1380,8 @@ export interface WashDataWsResponses { "ha_washdata/store_set_online": StoreOnlineResponse; "ha_washdata/store_set_prefs": StorePrefsResponse; "ha_washdata/store_get_device_profiles": StoreDeviceProfilesResponse; + "ha_washdata/store_get_catalog_entry": StoreCatalogEntryResponse; + "ha_washdata/store_refresh_catalog": StoreRefreshCatalogResponse; "ha_washdata/store_upload_device": StoreUploadDeviceResponse; "ha_washdata/store_download_device": StoreDownloadDeviceResponse; "ha_washdata/get_shareable_cycles": GetShareableCyclesResponse; diff --git a/zigbee2mqtt/state.json b/zigbee2mqtt/state.json index cd5541e2..655dcb29 100644 --- a/zigbee2mqtt/state.json +++ b/zigbee2mqtt/state.json @@ -4,10 +4,10 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 122.4, + "voltage": 122.1, "countdown_to_turn_on": 0, "ac_frequency": 60, - "power_factor": 0.11, + "power_factor": 0.13, "update": { "state": "idle", "installed_version": 268513381, @@ -22,15 +22,15 @@ }, "0xffffb40e0607af27": { "state": "ON", - "voltage": 121.9, + "voltage": 122, "ac_frequency": 60, "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "power": 1.9, - "current": 0.1, - "energy": 32.14, - "power_factor": 0.17, + "power": 3.8, + "current": 0.11, + "energy": 32.19, + "power_factor": 0.28, "update": { "state": "idle", "installed_version": 268513381, @@ -45,9 +45,9 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 121.9, + "voltage": 122.5, "countdown_to_turn_on": 0, - "energy": 63.6, + "energy": 63.64, "power_factor": 0.2, "ac_frequency": 60, "update": { @@ -58,8 +58,8 @@ "latest_release_notes": null }, "linkquality": 138, - "power": 84.2, - "current": 0.78, + "power": 0.2, + "current": 0.01, "power_on_behavior": "on" }, "0xb40e060fffe031e3": { @@ -74,13 +74,13 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 121.3, + "voltage": 121.5, "state": "ON", "ac_frequency": 60, - "energy": 128.77, - "power": 3.7, - "current": 0.05, - "power_factor": 0.41, + "energy": 128.83, + "power": 94.6, + "current": 0.84, + "power_factor": 0.4, "update": { "state": "idle", "installed_version": 268513381, @@ -95,13 +95,13 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 122.2, - "energy": 60.55, + "voltage": 122.5, + "energy": 60.59, "state": "ON", - "power": 27.6, - "current": 0.45, + "power": 28.2, + "current": 0.44, "ac_frequency": 60, - "power_factor": 0.55, + "power_factor": 0.54, "update": { "state": "idle", "installed_version": 268513381, @@ -114,12 +114,12 @@ }, "0xffffb40e060895b3": { "state": "ON", - "voltage": 120.7, + "voltage": 122.5, "ac_frequency": 60, "energy": 8.78, "current": 0.01, - "power": 0.2, - "power_factor": 0.11, + "power": 0.1, + "power_factor": 0.2, "linkquality": 109, "update": { "state": "idle", @@ -136,7 +136,7 @@ "0xffffb40e0608864e": { "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 122.7, + "voltage": 123, "energy": 20.07, "countdown_to_turn_on": 0, "state": "ON", @@ -170,15 +170,15 @@ } }, "0xffffb40e060893d8": { - "state": "OFF", + "state": "ON", "led_brightness": 100, - "voltage": 122.3, + "voltage": 123, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, "energy": 3.27, "power_on_behavior": "on", "linkquality": 142, - "current": 0, + "current": 0.06, "ac_frequency": 60, "update": { "state": "idle", @@ -187,12 +187,12 @@ "latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota", "latest_release_notes": null }, - "power_factor": 0, - "power": 0 + "power_factor": 0.86, + "power": 9.7 }, "0xa4c1380d0679ffff": { "battery": 100, - "temperature": 26.4, + "temperature": 26.7, "temperature_units": "celsius", "temperature_calibration": 0, "update": { @@ -245,7 +245,7 @@ }, "0xb40e060fffe717c5": { "battery": 100, - "occupancy": false, + "occupancy": true, "tamper": false, "battery_low": false, "linkquality": 138,