// WashData - Home Assistant integration for appliance cycle monitoring via smart plugs. // Copyright (C) 2026 Lukas Bandura // SPDX-License-Identifier: AGPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // ha-washdata-panel.js - WashData full-screen panel // Registers the custom element. // // Display constants (state colors, device types) come from the backend // (ha_washdata/get_constants) so they are defined in exactly one place. State // and device-type labels are localized via hass.localize against the // integration translations, with the backend values as canonical fallback. 'use strict'; const _DOMAIN = 'ha_washdata'; // Cache-buster for on-demand asset fetches (per-language translation files). The // panel module is imported as ha-washdata-panel.js?v=, so reuse that same // version: translation files are then cached per release and busted on upgrade. // Empty when unavailable (e.g. loaded without a query) — we simply omit ?v=. const _PANEL_VERSION = (() => { try { return new URL(import.meta.url).searchParams.get('v') || ''; } catch (_) { return ''; } })(); // The panel is push-driven: HA calls set hass() on every entity state change // (realtime), and subscribe_events / subscribe_tasks push cycle + task updates. // So the interval poll is only a slow SAFETY heartbeat for store-derived data // (suggestions/feedback counts) that isn't reflected in an entity state, and the // set-hass refresh is coalesced. Previously these were a tight 5s poll + a 2s // refetch-on-every-global-change, which did ~30 full refetches/min for nothing. const _POLL_MS = 20000; const _HASS_REFRESH_MS = 6000; // Cycles-tab page size. Kept modest so the "Load more" control actually engages // for typical histories instead of loading everything in one page. const _CYCLE_PAGE_SIZE = 25; // playground.MAX_BATCH_CYCLES: the most cycles one Test-on-history / Optimize run replays. const _PG_MAX_BATCH_CYCLES = 50; // The external integration the Maintenance section points to (#461). It reads the // cycle-count sensor (translation_key "cycle_count", unit "cycles"), a public contract. const _MAINTENANCE_SUPPORTER_URL = 'https://github.com/iluebbe/maintenance_supporter'; // Detector states that mean "a cycle is in flight". Single source for the device // bar dot, the status header and the pause/resume/force-stop controls -- these // used to be three hand-copied lists and the controls one had drifted (it was // missing 'paused', so an auto-paused cycle showed no buttons at all). const _ACTIVE_STATES = ['running', 'starting', 'paused', 'user_paused', 'ending', 'anti_wrinkle']; // Declarative community-store preference toggles, rendered in the gear's Online & // Community pane. To ship a new online setting: add one row here AND one default in // store_account._DEFAULT_PREFS -- the generic get_prefs / store_set_prefs plumbing // carries it end-to-end (no per-setting wiring). All are booleans. const _STORE_PREFS = [ { key: 'show_contributor', def: true, labelKey: 'lbl.show_contributor', labelFb: 'Show contributor names', docKey: 'setting.show_contributor.doc', docFb: 'Show the "by " attribution on community appliances and reference cycles.' }, // Opt-in (audit STORE-14): cycles you share carry no uploader name unless this is on. { key: 'share_name', def: false, labelKey: 'lbl.share_name', labelFb: 'Show my name on what I share', docKey: 'setting.share_name.doc', docFb: 'Show your account name as the uploader of cycles you share. Off: shared without a name.' }, ]; // Height (CSS px) of the band above the Playground plot where event pin heads // sit, out of the busy curve area. Shared by _pgDrawCanvas and the pointer // layout() closure so threshold-drag math stays aligned with the drawn plot. const _PG_PIN_BAND_H = 34; // Chart gesture tuning (see _attachGraphGestures). // A double tap / double click halves the visible window, i.e. zooms 2x. HA uses // a fixed +/-15% slice of the axis; a relative step composes better because our // charts start from wildly different cycle lengths (a 3 min air-fryer run and a // 4 h wash both live in these canvases). const _CANVAS_DBLTAP_ZOOM = 0.5; // Deepest zoom, as a multiple of the full trace. Without a floor a runaway // pinch collapses the axis onto a single sample and both edge labels read the // same minute. const _CANVAS_MAX_ZOOM = 200; // ...and never below this many seconds, which keeps very short cycles usable. const _CANVAS_MIN_VIEW_S = 5; // Grab radius (CSS px) of the axis-pointer handle. 22 is a touch target, not a // pixel-hunt: HA draws its handle at size 20. const _AXIS_HANDLE_GRAB = 22; // Minutes of recorder history the cycle dialog can draw either side of a cycle // (item 513, discussion #463). Same list ws_set_user_prefs accepts; 0 = off. const _CYCLE_CONTEXT_CHOICES = [0, 5, 10, 30, 60]; const _CYCLE_CONTEXT_DEFAULT_MIN = 10; // Floor for the measured panel height (_syncPanelHeight), so a bad measurement in a // hidden/zero-height container cannot collapse the UI to nothing. const _MIN_PANEL_HEIGHT = 320; // Distinct colors for overlaying many cycle curves (history cleanup). const _PALETTE = [ '#e6194B', '#3cb44b', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#a9a9a9', ]; // ─── Settings schema (single declarative source) ─────────────────────────────── // Each field: {key, label, type, unit?, step?, min?, max?, def?, hint?, doc?, opts?} // type: number | text | textarea | checkbox | select | entity | device | devicetype | list // `doc` is shown in a hover tooltip (condensed from SETTINGS_VISUALIZED.md). // Defined before _SETTINGS_SECTIONS because notification field docs reference it. const _NOTIFY_VARS = '{device}, {duration}, {minutes}, {program}, {energy_kwh}, {cost}, {time_finished}, {vs_typical}, {cycle_count}'; const _SETTINGS_SECTIONS = [ { id: 'basic', label: 'Basic', intro: 'Core identity and the essentials most setups need.', groups: [ { sub: 'Device info', fields: [ { key: 'name', label: 'Device Name', basic: true, type: 'text', doc: 'Name shown in Home Assistant for this device.' }, { key: 'device_type', label: 'Device Type', basic: true, type: 'devicetype', doc: 'Sets detection defaults (thresholds, off delay, end handling) for this kind of appliance. Change it only if the device was set up as the wrong type.' }, { key: 'store_brand', label: 'Appliance Brand', type: 'storebrand', optional: true, basic: true, doc: 'Optional. Your appliance brand from the community catalog, used to find and share reference recordings. Leave blank if you do not use online features.' }, { key: 'store_model', label: 'Appliance Model', type: 'storemodel', optional: true, basic: true, doc: 'Optional. Your appliance model from the community catalog (pick a brand first). If it is not listed, you can add it.' }, ] }, { sub: 'Basic configuration', fields: [ { key: 'power_sensor', label: 'Power Sensor', basic: true, type: 'entity', domain: 'sensor', doc: 'The sensor that reports this appliance\'s live power in watts (e.g. sensor.washer_power). All detection is based on it.' }, { key: 'min_power', label: 'Minimum Power', unit: 'W', type: 'number', step: 0.1, min: 0, def: 2.0, basic: true, doc: 'Readings below this count as 0 W (standby). Filters out the idle draw of smart plugs and standby LEDs.' }, { key: 'off_delay', label: 'Off Delay', unit: 's', type: 'number', min: 0, def: 180, basic: true, doc: 'How long power must stay low before the cycle is finished. Power coming back within this time continues the cycle. Dishwashers can be idle 20-60 min while drying, so set it longer than that.' }, { key: 'linked_device', label: 'Group Under Device', type: 'device', doc: 'Optional. Show this WashData device under another device (e.g. the smart plug) in Home Assistant, as "Connected via ...".' }, ] }, // Energy and cost moved here from Notifications (audit UI-25): the price drives // every cost figure, not just the peak-rate tip that stays with the messages. { sub: 'Energy', fields: [ { key: 'energy_sensor', label: 'Energy Meter Entity', type: 'entity', domain: 'sensor', optional: true, doc: 'Optional kWh or Wh counter (total_increasing, e.g. the plug\'s own meter). Cycle energy is then read from it, which is more accurate than computing it from a slow power sensor. If its reading is unusable, the computed value is used.' }, { key: 'energy_price_entity', label: 'Energy Price Entity', type: 'entity', domain: 'sensor', basic: true, notPrice: true, doc: 'Sensor with the current price per kWh (e.g. a dynamic tariff). It overrides the static price below, so it must be a price, not the plug\'s power or energy sensor.' }, { key: 'energy_price_static', label: 'Static Energy Price (per kWh)', type: 'number', step: 0.001, min: 0, basic: true, doc: 'Fixed price per kWh, used when no price entity is set.' }, { key: 'energy_price_dynamic', label: 'Time-Weighted Cost', type: 'checkbox', def: true, basic: true, doc: 'Charge each cycle at the price at each moment it ran, instead of the price when it finished. Needs a price entity. Process History (Diagnostics) recosts older cycles.' }, ] }, ] }, { 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, doc: 'Power must rise above this to start a cycle. Too low: standby power causes false starts. Too high: slow-starting programs (cold fill) are missed.' }, { key: 'stop_threshold_w', label: 'Stop Threshold', unit: 'W', type: 'number', step: 0.1, min: 0, doc: 'Power must fall below this before the off delay starts counting. Keep it below the Start Threshold. Too high, and low-power phases (rinse holds, anti-crease) start the end countdown by mistake.' }, { key: 'min_off_gap', label: 'Min Off Gap', unit: 's', type: 'number', min: 0, doc: 'An off period shorter than this does not split a cycle, so soak pauses stay in one wash. Keep it shorter than the gap between back-to-back loads, or those merge into one cycle.' }, ] }, { sub: 'Cycle Start', fields: [ { key: 'start_duration_threshold', label: 'Start Duration', internal: true, unit: 's', type: 'number', min: 0, def: 5, doc: 'Power must stay above the Start Threshold this long before a cycle starts, so brief on/off blips are ignored.' }, { key: 'start_energy_threshold', label: 'Start Energy', internal: true, unit: 'Wh', type: 'number', step: 0.01, min: 0, def: 0.2, doc: 'Energy the appliance must use before the cycle counts as running. A short power spike uses little energy, so it does not start a cycle.' }, { key: 'completion_min_seconds', label: 'Min Cycle Duration', unit: 's', type: 'number', min: 0, def: 600, doc: 'Cycles shorter than this (test runs, opening the door to add a sock) are saved as Interrupted and get no finish notification.' }, { key: 'curve_preroll_seconds', label: 'Curve Pre-roll', unit: 's', type: 'number', step: 10, min: 0, max: 600, def: 0, doc: 'Adds readings from failed start attempts to the front of a cycle, for machines that probe before starting (door lock, first fill). 300 s covers most; 0 = off. Recorded starts move earlier, so learned durations drift for a while.' }, ] }, { sub: 'Cycle End', fields: [ { key: 'end_energy_threshold', label: 'End Energy', unit: 'Wh', type: 'number', step: 0.001, min: 0, def: 0.05, doc: 'Energy allowed during the off delay. More than this and the cycle keeps going, so tumbles and drying tails stay attached. Lower it if cycles end during a tail; raise it if finished cycles keep waiting.' }, { key: 'smart_termination_duration_ratio', label: 'Smart Termination Ratio', type: 'number', step: 0.01, min: 0.5, max: 1.0, doc: 'Fraction of the program\'s average duration a cycle must reach before Smart Termination may end it early. If run times vary (water temperature, sensor-dry, load size), lower it (e.g. 0.85). Leave empty for 0.98 (0.99 on dishwashers).' }, ] }, { sub: 'Power Off', fields: [ { key: 'power_off_threshold_w', label: 'Power Off Threshold', unit: 'W', type: 'number', step: 0.1, min: 0, def: 0, doc: 'After a cycle, power below this for the Power Off Delay sets the state to Off, instead of the Progress Reset Delay doing it. Set it above the switched-off draw, below standby and the Stop Threshold. 0 disables it (default).' }, { 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 before the state returns to Off. Only used when that threshold is above 0. Checked periodically, so the real wait can be slightly longer.' }, ] }, { sub: 'Signal Processing', fields: [ { key: 'sampling_interval', label: 'Sampling Interval', internal: true, unit: 's', type: 'number', min: 1, def: 30, doc: 'Minimum time between processed readings. Readings that arrive sooner are skipped (low readings during a cycle are always kept). Set it to your plug\'s reporting interval or lower; a higher value only throws readings away.' }, ] }, ] }, { 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: 0.01, min: 0, max: 1, def: 0.4, doc: 'Live match score a cycle needs before Smart Termination or the anti-crease finish may end it early. It does not change the shown program. Raise it if weak matches end cycles early; lower it if clear matches wait the off delay.' }, { key: 'profile_unmatch_threshold', label: 'Unmatch Threshold', internal: true, type: 'number', step: 0.01, min: 0, max: 1, def: 0.35, doc: 'If the live match score drops below this, the current program guess is cleared. Keep it a little below the Match Threshold so a brief dip does not reset it.' }, { key: 'profile_match_interval', label: 'Match Interval', internal: true, unit: 's', type: 'number', min: 0, doc: 'How often to match a running cycle against profiles. Default 300 s (5 min).' }, ] }, { sub: 'Duration Gates', fields: [ { key: 'profile_match_min_duration_ratio', label: 'Min Duration Ratio', internal: true, type: 'number', step: 0.01, min: 0, max: 1, def: 0.1, doc: 'A program is only considered after the cycle has run this fraction of its typical duration. 0.10: a 2-hour program becomes a candidate after 12 minutes. Not applied in a cycle\'s first 15 minutes.' }, { key: 'profile_match_max_duration_ratio', label: 'Max Duration Ratio', internal: true, type: 'number', step: 0.01, min: 0, def: 1.8, doc: 'A program is dropped once the cycle has run longer than this multiple of its typical duration. 1.8: a cycle 80% longer than the program no longer matches it.' }, { key: 'duration_tolerance', label: 'Estimate Tolerance', internal: true, type: 'number', step: 0.01, min: 0, max: 1, def: 0.1, doc: 'Learning feedback only: a cycle within +/-X% of its estimated duration counts as a good estimate. Does not affect matching.' }, ] }, { 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 kinds of cycles shape each program\'s curve and matching. Unticking a kind deletes nothing; useful if you do not trust imported data. Statistics always count only this machine\'s cycles. Unticking all is ignored.' }, ] }, { 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: 'Match score at cycle end at or above which the program is labelled without asking. Raise it for more certainty; lower it to label more cycles automatically. Keep it above Learning Confidence.' }, { key: 'learning_confidence', label: 'Learning Confidence', internal: true, type: 'number', step: 0.01, min: 0, max: 1, def: 0.6, doc: 'Lowest match score for labelling a finished cycle. A clear winner is labelled; a close call goes to the review queue in Cycles. Below this, nothing is labelled or asked. Keep it below Auto-Label Confidence.' }, ] }, ] }, { id: 'timing', label: 'Timing & Watchdog', intro: 'Background cadence, the offline watchdog and housekeeping.', groups: [ { sub: 'Watchdog', fields: [ { key: 'watchdog_interval', label: 'Watchdog Interval', internal: true, unit: 's', type: 'number', min: 1, def: 30, doc: 'How often the background check looks for stalled sensors and timeouts. Default 30 s.' }, { key: 'no_update_active_timeout', label: 'No-Update Timeout', internal: true, unit: 's', type: 'number', min: 0, def: 600, doc: 'If no power update arrives for this long during a cycle, assume the plug went offline and force-stop the cycle. The default 600 s allows for cloud or mesh delays.' }, ] }, { sub: 'Housekeeping', fields: [ { key: 'progress_reset_delay', label: 'Progress Reset Delay', unit: 's', type: 'number', min: 0, def: 1800, doc: 'After a cycle, keep progress at 100% this long before resetting to Idle.' }, { key: 'auto_maintenance', label: 'Auto Maintenance (nightly cleanup)', type: 'checkbox', def: true, doc: 'Nightly cleanup: label old cycles that now clearly match a program, rebuild program curves, refresh anomaly marks and cycle health, and remove debug traces if Save Debug Traces is off. Cycles and their power traces are always kept.' }, { key: 'power_profile_interval_min', label: 'Power Profile Interval', unit: 'min', type: 'number', min: 1, def: 15, doc: 'Bucket size of the power_profile attribute (average watts per slot) used by planners such as EMHASS and tibber_prices. Smaller keeps short spikes; larger smooths the curve. Default 15 min. Does not affect detection.' }, ] }, { sub: 'Debug', fields: [ { key: 'expose_debug_entities', label: 'Expose Debug Entities', internal: true, type: 'checkbox', doc: 'Add diagnostic entities (match confidence, ambiguity, internal state). Off keeps the entity list clean.' }, { key: 'save_debug_traces', label: 'Save Debug Traces', type: 'checkbox', doc: 'Also store the matcher\'s debug data (every candidate\'s scores) with each cycle, for troubleshooting. Adds about 20 KB per cycle.' }, ] }, ] }, { id: 'anti_wrinkle', label: 'Anti-Wrinkle', intro: 'Keeps a dryer\'s low-power tumble pulses after the main phase attached to the finished cycle.', onlyDeviceTypes: ['washing_machine', 'dryer', 'washer_dryer'], fields: [ { key: 'anti_wrinkle_enabled', label: 'Enable Anti-Wrinkle Detection', basic: true, type: 'checkbox', doc: 'Keep a dryer\'s short low-power tumble pulses after the main heat phase with 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 counts as a real new cycle, not a tumble. Set it just above your tumble-pulse power.' }, { key: 'anti_wrinkle_max_duration', label: 'Max Duration', unit: 's', type: 'number', min: 0, def: 60, doc: 'A pulse longer than this counts as a real cycle, not an anti-wrinkle tumble.' }, { key: 'anti_wrinkle_exit_power', label: 'Exit Power Threshold', internal: true, unit: 'W', type: 'number', step: 0.1, min: 0, def: 0.8, doc: 'In anti-wrinkle mode, power below this counts as quiet between tumble pulses, and quiet for longer than the Max Pulse Gap ends the mode. The Stop Threshold is used when it is higher, so this only changes anything when set above the Stop Threshold.' }, { key: 'anti_wrinkle_idle_timeout', label: 'Max Pulse Gap', unit: 's', type: 'number', step: 30, min: 0, def: 120, doc: 'How long the dryer may stay quiet between tumble pulses before anti-wrinkle mode ends. Set it above your dryer\'s longest gap, or later pulses are read as false starts.' }, { key: 'anti_crease_finalize_ratio', label: 'Anti-Crease Finalize Ratio', type: 'number', step: 0.01, min: 0.5, max: 1.0, def: 0.98, doc: 'Fraction of the program\'s average duration a cycle must reach before its low-power tumbling counts as the anti-crease tail. Lower it (e.g. 0.75) on load-sensing dryers. Keep washers at the default (0.98), or a wash can end halfway.' }, ] }, { id: 'dishwasher', label: 'Dishwasher', intro: 'End handling for dishwashers, which often dry quietly for a long time 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: 'After the expected duration, how long the dishwasher must stay below the Stop Threshold before WashData stops waiting for the final drain. Raise it if a late drain after a long dry is missed. The wait never exceeds 30 minutes.' }, ] }, { id: 'delay', label: 'Delay Start', intro: 'Detects an appliance that is on and waiting for a delayed start.', fields: [ { key: 'delay_start_detect_enabled', label: 'Enable Delay-Start Detection', type: 'checkbox', doc: 'Detect an appliance that is on and waiting for a delayed start, so its standby draw is not taken 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 this long before the appliance counts as waiting to start.' }, { key: 'delay_timeout_hours', label: 'Timeout Hours', unit: 'h', type: 'number', step: 0.5, min: 0, def: 8.0, doc: 'Give up waiting for a delayed start after this many hours and return to idle.' }, ] }, { id: 'triggers', label: 'Triggers & Door', intro: 'Optional extras: end trigger, door sensor, pause switch and unload reminder.', groups: [ { sub: 'External End Trigger', fields: [ { key: 'external_end_trigger_enabled', label: 'Enable External End Trigger', type: 'checkbox', doc: 'Let a binary sensor signal the end of a cycle, in addition to power-based detection.' }, { key: 'external_end_trigger', label: 'External Trigger Entity', type: 'entity', domain: 'binary_sensor', doc: 'Binary sensor whose change marks the cycle end (e.g. a "finished" contact or another integration).' }, { key: 'external_end_trigger_inverted', label: 'Invert External Trigger (trigger on OFF)', type: 'checkbox', doc: 'Treat the sensor turning OFF (not ON) as the end signal.' }, ] }, { sub: 'Door & Pause', fields: [ { key: 'door_sensor_entity', label: 'Door Sensor Entity', basic: true, type: 'entity', domain: 'binary_sensor', doc: 'Optional door sensor. Shows when the appliance was opened to unload after a cycle.' }, { key: 'door_opens_at_end', label: 'Door Opens Automatically At End', type: 'checkbox', doc: 'For dishwashers that open the door at the end to dry (e.g. AirDry). A door left open for the dwell time below ends the cycle instead of pausing it; a brief open is ignored. Needs a Door Sensor Entity.' }, { key: 'door_end_dwell_seconds', label: 'Door-Open End Dwell', unit: 's', type: 'number', min: 1, def: 60, doc: 'With "Door Opens Automatically At End" on: how long the door must stay open before the cycle ends. Long enough to ignore adding a dish (default 60 s).' }, { 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 that can safely lose power mid-cycle.' }, { key: 'switch_entity', label: 'Switch Entity', type: 'entity', domain: 'switch', doc: 'Optional switch turned off on pause and back on when resuming, 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: true, doc: 'Minutes after a cycle ends before the "unload the machine" reminder. 0 disables it.' }, { key: 'notify_unload_repeat', label: 'Repeat Until Unloaded', type: 'checkbox', doc: 'Resend the unload reminder every "Unload Nag Delay" minutes until the unload is confirmed or you tap "Stop reminding" (mobile app). Needs a Door Sensor Entity, an Unload Confirmation Entity, or "Confirm Unload Manually".' }, { key: 'unload_confirm_entity', label: 'Unload Confirmation Entity', type: 'entity', domains: ['event', 'button', 'input_button', 'binary_sensor', 'sensor', 'input_boolean', 'switch', 'scene', 'tag'], doc: 'Optional. Any entity that confirms the load was taken out, for machines without a door sensor (a button, input_button, motion sensor, scene). Every state change counts except turning off or unavailable. Also enables the unload reminder.' }, { key: 'unload_track_without_door', label: 'Confirm Unload Manually', type: 'checkbox', doc: 'Run the unload reminder without a door sensor or confirmation entity. Confirm the unload from your own automation with the "Mark Unloaded" button or the ha_washdata.mark_unloaded service. Ignored when a Door Sensor Entity is set.' }, { key: 'pump_stuck_duration', label: 'Pump Stuck Duration', unit: 's', type: 'number', min: 0, def: 1800, onlyDeviceType: 'pump', doc: 'How long a pump may run nonstop before the stuck-pump event fires.' }, ] }, ] }, { id: 'notifications', label: 'Notifications', groups: [ { sub: 'Services', fields: [ { key: 'notify_start_services', label: 'Start Services', type: 'entitylist', domain: 'notify', placeholder: 'add a notify service…', basic: true, doc: 'notify.* services to call when a cycle starts (phone, dashboard, ...). Leave empty for none.' }, { key: 'notify_finish_services', label: 'Finish Services', type: 'entitylist', domain: 'notify', placeholder: 'add a notify service…', basic: true, doc: 'notify.* services to call when a cycle finishes. Leave empty for none.' }, { key: 'notify_live_services', label: 'Live Progress Services', basic: true, type: 'entitylist', domain: 'notify', placeholder: 'add a notify service…', doc: 'notify.* services for live progress updates while a cycle runs. Leave empty to turn them off.' }, { key: 'notify_people', label: 'People (for Only When Home)', type: 'entitylist', domain: 'person', placeholder: 'add a person…', doc: 'person.* entities that "Notify Only When Home" checks.' }, { key: 'notify_only_when_home', label: 'Notify Only When Home', type: 'checkbox', doc: 'Only notify when at least one of the people above is home.' }, { key: 'notify_fire_events', label: 'Fire HA Events for Notifications', type: 'checkbox', def: true, doc: 'Also fire ha_washdata_* events on cycle start and finish for 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: 'Live updates stop once a cycle runs this percent past its expected duration (20 = up to 1.2x the expected time).' }, { key: 'notify_live_chronometer', label: 'Use Live Chronometer', type: 'checkbox', doc: 'Show a live countdown timer in the notification (where supported) instead of a fixed estimate.' }, { key: 'notify_live_silent', label: 'Silent Live Updates', type: 'checkbox', def: true, doc: 'iOS only. Update live progress silently, at a battery-saving priority that iOS may batch. The first update and the start/finish notifications still alert. Off: every update alerts.' }, { key: 'notify_live_sticky', label: 'Keep Live Notification On Tap', type: 'checkbox', doc: 'Android only. Keep the live progress notification when tapped. Off: a tap dismisses it.' }, { key: 'notify_live_click_action', label: 'Notification Tap Target', type: 'text', optional: true, doc: 'What a tap on this appliance\'s notification opens. Blank: the WashData panel on this appliance. Enter a dashboard path (e.g. /lovelace/laundry) or URL, or "none" for no tap action.' }, { key: 'notify_timeout_seconds', label: 'Auto-Dismiss After', unit: 's', type: 'number', min: 0, def: 0, doc: 'Dismiss the notification after this many seconds (where supported). 0 keeps it until you dismiss it.' }, ] }, { sub: 'Messages', fields: [ { key: 'notify_title', label: 'Notification Title', type: 'text', def: 'WashData: {device}', doc: 'Notification title. Only {device} is substituted; any other variable falls back to the default title.' }, { key: 'notify_icon', label: 'Notification Icon', type: 'text', def: '', doc: 'Small icon on the notification (e.g. mdi:washing-machine). Sent only to mobile_app targets, not notify groups. On iOS it replaces the app icon and needs companion app 2026.8 or newer. Blank: platform default.' }, { key: 'notify_icon_color', label: 'Notification Colour', type: 'color', def: '', placeholder: '#03A9F4', doc: 'Accent colour for this appliance\'s notifications, so washer, dryer and dishwasher are easy to tell apart. On iOS it fills the circle behind the icon and colours the Live Activity bar. Many Android phones (Samsung especially) ignore it. Use hex, e.g. #03A9F4. Blank: platform default.' }, { key: 'notify_start_message', label: 'Start Message', type: 'textarea', def: '{device} started.', doc: 'Body sent when a cycle starts. Template variables: {device}, {program}. Any other variable falls back to the default text.' }, { key: 'notify_finish_message', label: 'Finish Message', type: 'textarea', def: '{device} finished. Duration: {duration} min.', basic: true, doc: 'Body sent when a cycle finishes. Variables: {device}, {program}, {duration} (minutes), {duration_hm} (1 h 05 min), {energy_kwh}, {cost}, {time_finished}, {vs_typical}, {cycle_count}, {status} (completed or force_stopped; interrupted cycles send nothing). Any other variable falls back to the default text.' }, { key: 'notify_pre_complete_message', label: 'Pre-Complete Message', type: 'textarea', def: '{device}: Less than {minutes} minutes remaining.', doc: 'Text of each live progress update. Template variables: {device}, {program}, {minutes} (minutes left). Any other variable falls back to the default text.' }, { key: 'notify_reminder_message', label: 'Reminder Message', type: 'textarea', def: '', doc: 'Text of the one-time Pre-End Alert. Template variables: {device}, {program}, {minutes} (the Pre-End Alert setting). Any other variable falls back to the default text.' }, { key: 'notify_channel', label: 'Android Channel (start/live)', type: 'text', def: '', placeholder: 'e.g. WashData', suggestions: ['WashData', 'WashData Status', 'Appliance Status'], doc: 'Android channel for start and live messages; the channel sets sound and priority. Blank: 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 channel for the finish message. Blank reuses the start/live channel.' }, { key: 'peak_rate_threshold', label: 'Peak-Rate Threshold (per kWh)', type: 'number', step: 0.001, min: 0, def: 0, clearable: true, doc: 'If the price per kWh is at or above this when a cycle starts, add a peak-rate tip to the start notification. 0 or blank disables it.' }, { key: 'peak_rate_message', label: 'Peak-Rate Message', type: 'text', def: '', placeholder: 'Running at peak rate ({price}/kWh).', doc: 'Custom text for the peak-rate tip. Template variables: {device}, {price}. Blank uses the default.' }, ] }, { sub: 'Cycle Timers', fields: [ { key: 'notify_cycle_timers', label: 'Cycle Timers', type: 'timerlist', doc: 'Notifications at set minutes into a cycle (e.g. to add softener). Message supports {device}, {program}, {minutes}. With Auto-pause, the cycle also pauses there and the notification gets a Resume button.' }, ] }, { sub: 'Quiet Hours & Milestones', fields: [ { key: 'notify_quiet_start_hour', label: 'Quiet Hours Start', basic: true, unit: 'h', type: 'number', min: 0, max: 23, clearable: true, doc: 'Start of a do-not-disturb window (0-23). Finish, reminder and clean-laundry notifications in this window are held until it ends. Can cross midnight (e.g. 22 to 7). Blank disables it.' }, { key: 'notify_quiet_end_hour', label: 'Quiet Hours End', basic: true, unit: 'h', type: 'number', min: 0, max: 23, clearable: true, doc: 'End of the do-not-disturb window (0-23); held notifications are sent then. Blank disables it.' }, { key: 'notify_milestones', label: 'Cycle Milestones', type: 'intlist', def: '50, 100, 500, 1000', placeholder: '50, 100, 500, 1000', doc: 'Cycle counts that trigger a one-off celebration notification, comma-separated (e.g. 50, 100, 500, 1000). Blank disables them.' }, { 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: false, doc: 'Use a model trained on this machine to predict a running cycle\'s energy and cost, and let a well-known program that stops unusually early finish without the full off delay. Off: power-based logic only.' }, { key: 'ml_training_enabled', label: 'Learn from this machine', type: 'checkbox', def: false, doc: 'Retrain the energy model on this machine\'s finished cycles from time to time. A new model is kept only if it beats the simple estimate on test cycles.' }, { key: 'ml_training_hour', label: 'Learn at hour', unit: 'h', type: 'number', min: 0, max: 23, def: 2, doc: 'Hour of day (0-23) for overnight training. Pick a quiet hour, e.g. 2.' }, { key: 'ml_training_min_cycles', label: 'Cycles needed first', type: 'number', min: 5, def: 30, doc: 'Wait for at least this many recorded cycles before training.' }, { key: 'ml_training_interval_days', label: 'Check at most every', unit: 'days', type: 'number', min: 1, def: 7, doc: 'Retrain at most once every this many days.' }, ] }, ]; // Flat key -> field-definition map (built from the schema; drives save coercion). const _FIELD_BY_KEY = {}; for (const sec of _SETTINGS_SECTIONS) { const groups = sec.groups || [{ fields: sec.fields }]; for (const grp of groups) for (const f of (grp.fields || [])) _FIELD_BY_KEY[f.key] = f; } // Offline fallback defaults for the two Stage-1 matcher ratios the Playground // shows (the backend ships the real ones as pg_match_defaults). const _PG_MATCH_DEFAULTS = { profile_match_min_duration_ratio: 0.1, profile_match_max_duration_ratio: 1.8, }; // ─── Setting conflict rules ─────────────────────────────────────────────────── // Each rule describes a cross-parameter invariant. `check(vals, ctx)` returns true // when the invariant is violated. `fieldErrors(vals, ctx)` maps each affected key to // an error descriptor: `{msgKey, msgVars, msgFb, fixVal}` where `fixVal` is the // suggested value for THAT field (always actionable in the current section). // `ctx.stateOf(entity_id)` reaches the hass state for rules that need an entity's // attributes; every numeric rule ignores it. // ─── Setting notes (informational, not conflicts) ───────────────────────────── // Same shape as _SETTING_CONFLICTS, but these describe a value the integration // DERIVES from more than one setting. They are not violations - nothing here is // misconfigured - so they render as a neutral note rather than an error with a // fix button, and they never contribute to the section conflict dots. const _SETTING_NOTES = [ { // #445: the detector waits max(off_delay, min_off_gap), so a min_off_gap // above off_delay silently governs the end of the cycle. The reporter set // off_delay to 180 s, waited 6 minutes and force-stopped three cycles, // because the 480 s their washing machine actually waited was never shown. keys: ['off_delay', 'min_off_gap'], check: v => v.off_delay != null && v.min_off_gap != null && v.min_off_gap > v.off_delay, fieldNotes: v => ({ off_delay: { msgKey: 'note.off_delay.effective', msgVars: { eff: v.min_off_gap, gap: v.min_off_gap, delay: v.off_delay }, msgFb: `A finished cycle actually waits ${v.min_off_gap} s, not ${v.off_delay} s: Min Off Gap (${v.min_off_gap} s) is longer and takes precedence.`, }, }), }, ]; // Device classes and units that prove a sensor is not a price per kWh (#439). // Mirrors manager._NON_PRICE_DEVICE_CLASSES / _NON_PRICE_UNITS - keep in step. const _NON_PRICE_DEVICE_CLASSES = new Set(['energy', 'energy_storage', 'power', 'gas', 'water', 'current', 'voltage']); const _NON_PRICE_UNITS = new Set(['w', 'kw', 'mw', 'wh', 'kwh', 'mwh', 'va', 'kva', 'varh', 'a', 'ma', 'v', 'mv']); // Why the chosen Energy Price Entity cannot be a price per kWh, or null (#439). // Only positive evidence rejects, exactly as the backend does: an entity HA has // not loaded yet has no attributes to judge and is left alone. function _nonPriceReason(id, vals, ctx) { if (!id) return null; if (id === vals.power_sensor) { return { msgKey: 'conflict.price_entity.power_sensor', msgVars: {}, msgFb: 'This is the device\'s Power Sensor, not a price. Cycles would be costed in watts per kWh; clear this field.' }; } if (id === vals.energy_sensor) { return { msgKey: 'conflict.price_entity.energy_sensor', msgVars: {}, msgFb: 'This is the device\'s Energy Meter, not a price. Cycles would be costed at the meter reading per kWh; clear this field.' }; } const st = (ctx && ctx.stateOf) ? ctx.stateOf(id) : null; if (!st) return null; const attrs = st.attributes || {}; const dc = String(attrs.device_class == null ? '' : attrs.device_class).trim().toLowerCase(); if (_NON_PRICE_DEVICE_CLASSES.has(dc)) { return { msgKey: 'conflict.price_entity.device_class', msgVars: {dc}, msgFb: `This sensor measures ${dc}, not a price per kWh. Clear this field or pick a tariff sensor.` }; } const unit = String(attrs.unit_of_measurement == null ? '' : attrs.unit_of_measurement).trim(); if (_NON_PRICE_UNITS.has(unit.toLowerCase())) { return { msgKey: 'conflict.price_entity.unit', msgVars: {unit}, msgFb: `This sensor reads in ${unit}, not a price per kWh. Clear this field or pick a tariff sensor.` }; } return null; } const _SETTING_CONFLICTS = [ { // energy_price_entity pointing at something that is provably not a price (#439). // The picker lists every sensor, and a price entity outranks the static price, so // choosing the plug's own kWh counter silently costs every cycle at // energy * meter_reading. No fix button: the fix is to clear the field. keys: ['energy_price_entity', 'energy_sensor', 'power_sensor'], check: (v, ctx) => _nonPriceReason(v.energy_price_entity, v, ctx) != null, fieldErrors: (v, ctx) => ({ energy_price_entity: _nonPriceReason(v.energy_price_entity, v, ctx) }), }, { // start_threshold_w > stop_threshold_w (hysteresis band must be positive) keys: ['start_threshold_w', 'stop_threshold_w'], check: v => v.start_threshold_w != null && v.stop_threshold_w != null && v.start_threshold_w <= v.stop_threshold_w, fieldErrors: v => ({ start_threshold_w: { msgKey: 'conflict.hysteresis.start', msgVars: {stop: v.stop_threshold_w}, msgFb: `Must be above Stop Threshold (${v.stop_threshold_w} W)`, fixVal: +Math.max(v.stop_threshold_w + 0.5, v.stop_threshold_w * 1.25).toFixed(1) }, stop_threshold_w: { msgKey: 'conflict.hysteresis.stop', msgVars: {start: v.start_threshold_w}, msgFb: `Must be below Start Threshold (${v.start_threshold_w} W)`, fixVal: +Math.min(v.start_threshold_w - 0.5, v.start_threshold_w * 0.8).toFixed(1) }, }), }, { // min_power <= stop_threshold_w (noise gate must sit below the stop floor) keys: ['min_power', 'stop_threshold_w'], check: v => v.min_power != null && v.stop_threshold_w != null && v.min_power > v.stop_threshold_w, fieldErrors: v => ({ min_power: { msgKey: 'conflict.min_power.min_power', msgVars: {stop: v.stop_threshold_w}, msgFb: `Must be at or below Stop Threshold (${v.stop_threshold_w} W)`, fixVal: +(v.stop_threshold_w * 0.8).toFixed(1) }, stop_threshold_w: { msgKey: 'conflict.min_power.stop', msgVars: {min: v.min_power}, msgFb: `Must be at or above Min Power (${v.min_power} W)`, fixVal: +(v.min_power * 1.25).toFixed(1) }, }), }, { // power_off_threshold_w < stop_threshold_w when > 0 (else feature silently ignored) keys: ['power_off_threshold_w', 'stop_threshold_w'], check: v => v.power_off_threshold_w != null && v.power_off_threshold_w > 0 && v.stop_threshold_w != null && v.power_off_threshold_w >= v.stop_threshold_w, fieldErrors: v => ({ power_off_threshold_w: { msgKey: 'conflict.power_off.threshold', msgVars: {stop: v.stop_threshold_w}, msgFb: `Must be below Stop Threshold (${v.stop_threshold_w} W) to take effect`, fixVal: +(v.stop_threshold_w * 0.6).toFixed(1) }, stop_threshold_w: { msgKey: 'conflict.power_off.stop', msgVars: {pot: v.power_off_threshold_w}, msgFb: `Must be above Power Off Threshold (${v.power_off_threshold_w} W)`, fixVal: +(v.power_off_threshold_w * 1.67).toFixed(1) }, }), }, { // off_delay <= min_off_gap (effective_off_delay = max(off_delay, min_off_gap)) keys: ['off_delay', 'min_off_gap'], check: v => v.off_delay != null && v.min_off_gap != null && v.off_delay > v.min_off_gap, fieldErrors: v => ({ off_delay: { msgKey: 'conflict.off_delay.off_delay', msgVars: {gap: v.min_off_gap}, msgFb: `Off Delay (${v.off_delay} s) overrides Min Off Gap (${v.min_off_gap} s); cycles within the gap may merge`, fixVal: v.min_off_gap }, min_off_gap: { msgKey: 'conflict.off_delay.gap', msgVars: {delay: v.off_delay}, msgFb: `Min Off Gap should be at least Off Delay (${v.off_delay} s)`, fixVal: v.off_delay }, }), }, { // watchdog_interval >= 2 * sampling_interval (avoid false-zero injections) keys: ['watchdog_interval', 'sampling_interval'], check: v => v.watchdog_interval != null && v.sampling_interval != null && v.watchdog_interval < 2 * v.sampling_interval, fieldErrors: v => ({ watchdog_interval: { msgKey: 'conflict.watchdog.interval', msgVars: {si: v.sampling_interval}, msgFb: `Should be at least 2× Sampling Interval (${v.sampling_interval} s)`, fixVal: +(2 * v.sampling_interval + 1) }, sampling_interval: { msgKey: 'conflict.watchdog.sampling', msgVars: {wi: v.watchdog_interval}, msgFb: `Sampling Interval should be at most half of Watchdog Interval (${v.watchdog_interval} s)`, fixVal: +Math.floor(v.watchdog_interval / 2) }, }), }, { // no_update_active_timeout > watchdog_interval (timeout must outlast one tick) keys: ['no_update_active_timeout', 'watchdog_interval'], check: v => v.no_update_active_timeout != null && v.watchdog_interval != null && v.no_update_active_timeout <= v.watchdog_interval, fieldErrors: v => ({ no_update_active_timeout: { msgKey: 'conflict.no_update_timeout.timeout', msgVars: {wi: v.watchdog_interval}, msgFb: `Must be greater than Watchdog Interval (${v.watchdog_interval} s)`, fixVal: v.watchdog_interval * 2 }, watchdog_interval: { msgKey: 'conflict.no_update_timeout.watchdog', msgVars: {to: v.no_update_active_timeout}, msgFb: `Must be less than No-Update Timeout (${v.no_update_active_timeout} s)`, fixVal: +Math.floor(v.no_update_active_timeout / 2) }, }), }, { // start_duration_threshold >= sampling_interval (debounce must span at least one sample) keys: ['start_duration_threshold', 'sampling_interval'], check: v => v.start_duration_threshold != null && v.sampling_interval != null && v.start_duration_threshold < v.sampling_interval, fieldErrors: v => ({ start_duration_threshold: { msgKey: 'conflict.start_dur.threshold', msgVars: {si: v.sampling_interval}, msgFb: `Should be at least one Sampling Interval (${v.sampling_interval} s) to prevent single-sample false starts`, fixVal: v.sampling_interval }, sampling_interval: { msgKey: 'conflict.start_dur.sampling', msgVars: {sdt: v.start_duration_threshold}, msgFb: `Sampling Interval exceeds Start Duration (${v.start_duration_threshold} s); single-sample spikes can open a cycle`, fixVal: v.start_duration_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, fieldErrors: v => ({ 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) }, }), }, { // profile_match_threshold <= auto_label_confidence keys: ['profile_match_threshold', 'auto_label_confidence'], check: v => v.profile_match_threshold != null && v.auto_label_confidence != null && v.profile_match_threshold > v.auto_label_confidence, fieldErrors: v => ({ profile_match_threshold: { msgKey: 'conflict.confidence.match_for_auto', msgVars: {alc: v.auto_label_confidence}, msgFb: `Must be at or below Auto-Label Confidence (${v.auto_label_confidence})`, fixVal: +(v.auto_label_confidence).toFixed(2) }, auto_label_confidence: { msgKey: 'conflict.confidence.auto', 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_unmatch_threshold < profile_match_threshold (committed match must not immediately un-match) keys: ['profile_unmatch_threshold', 'profile_match_threshold'], check: v => v.profile_unmatch_threshold != null && v.profile_match_threshold != null && v.profile_unmatch_threshold >= v.profile_match_threshold, fieldErrors: v => ({ profile_unmatch_threshold: { msgKey: 'conflict.unmatch.unmatch', msgVars: {match: v.profile_match_threshold}, msgFb: `Must be below Match Threshold (${v.profile_match_threshold}); otherwise a committed match un-matches instantly`, fixVal: +(v.profile_match_threshold - 0.05).toFixed(2) }, profile_match_threshold: { msgKey: 'conflict.unmatch.match', msgVars: {un: v.profile_unmatch_threshold}, msgFb: `Must be above Unmatch Threshold (${v.profile_unmatch_threshold})`, fixVal: +(v.profile_unmatch_threshold + 0.05).toFixed(2) }, }), }, // (No anti_wrinkle_exit_power vs stop_threshold_w rule: the detector counts quiet // below the higher of the two, so any pair is valid and the old "must be below // stop" fix moved the exit power to where it does nothing; #285 #296 #325.) { // anti_wrinkle_max_power > start_threshold_w — only for devices that support anti-wrinkle keys: ['anti_wrinkle_max_power', 'start_threshold_w'], check: v => ['washing_machine','dryer','washer_dryer'].includes(v.device_type) && v.anti_wrinkle_max_power != null && v.start_threshold_w != null && v.anti_wrinkle_max_power <= v.start_threshold_w, fieldErrors: v => ({ anti_wrinkle_max_power: { msgKey: 'conflict.anti_wrinkle_max.max', msgVars: {start: v.start_threshold_w}, msgFb: `Must be above Start Threshold (${v.start_threshold_w} W); otherwise anti-wrinkle duration limit is bypassed`, fixVal: +(v.start_threshold_w * 2.0).toFixed(0) }, start_threshold_w: { msgKey: 'conflict.anti_wrinkle_max.start', msgVars: {max: v.anti_wrinkle_max_power}, msgFb: `Must be below Anti-Wrinkle Max Power (${v.anti_wrinkle_max_power} W)`, fixVal: +(v.anti_wrinkle_max_power * 0.5).toFixed(1) }, }), }, { // pump_stuck_duration < no_update_active_timeout — only for pump/sump-pump devices keys: ['pump_stuck_duration', 'no_update_active_timeout'], check: v => v.device_type === 'pump' && v.pump_stuck_duration != null && v.no_update_active_timeout != null && v.no_update_active_timeout <= v.pump_stuck_duration, fieldErrors: v => ({ pump_stuck_duration: { msgKey: 'conflict.pump_stuck.duration', msgVars: {to: v.no_update_active_timeout}, msgFb: `Must be below No-Update Timeout (${v.no_update_active_timeout} s) so the stuck alarm can fire first`, fixVal: v.no_update_active_timeout - 60 }, no_update_active_timeout: { msgKey: 'conflict.pump_stuck.timeout', msgVars: {ps: v.pump_stuck_duration}, msgFb: `Must exceed Pump Stuck Duration (${v.pump_stuck_duration} s) so the stuck alarm can fire first`, fixVal: v.pump_stuck_duration + 60 }, }), }, { // profile_match_min_duration_ratio < profile_match_max_duration_ratio (matching window must be non-empty) keys: ['profile_match_min_duration_ratio', 'profile_match_max_duration_ratio'], check: v => v.profile_match_min_duration_ratio != null && v.profile_match_max_duration_ratio != null && v.profile_match_min_duration_ratio >= v.profile_match_max_duration_ratio, fieldErrors: v => ({ profile_match_min_duration_ratio: { msgKey: 'conflict.duration_ratio.min', msgVars: {max: v.profile_match_max_duration_ratio}, msgFb: `Must be less than Max Duration Ratio (${v.profile_match_max_duration_ratio})`, fixVal: +(v.profile_match_max_duration_ratio * 0.5).toFixed(2) }, profile_match_max_duration_ratio: { msgKey: 'conflict.duration_ratio.max', msgVars: {min: v.profile_match_min_duration_ratio}, msgFb: `Must be greater than Min Duration Ratio (${v.profile_match_min_duration_ratio})`, fixVal: +(v.profile_match_min_duration_ratio * 2.0).toFixed(2) }, }), }, { // end_energy_threshold >= stop_threshold_w * off_delay / 3600 (#376) // The energy gate is evaluated over an off_delay-long window, so it implies a // wattage. Below this it forbids what stop_threshold_w (the power gate) allows, // and the cycle can only close through a fallback path (smart termination, // watchdog force-end) — surfacing only as an unexplained late completion. keys: ['end_energy_threshold', 'stop_threshold_w', 'off_delay'], check: v => v.end_energy_threshold != null && v.stop_threshold_w != null && v.off_delay != null && v.off_delay > 0 && v.end_energy_threshold < v.stop_threshold_w * v.off_delay / 3600, fieldErrors: v => ({ end_energy_threshold: { msgKey: 'conflict.end_energy.energy', msgVars: {w: v.stop_threshold_w, d: v.off_delay}, msgFb: `Too strict for Stop Threshold (${v.stop_threshold_w} W) over Off Delay (${v.off_delay} s); the cycle can only end through a fallback path`, fixVal: Math.ceil(v.stop_threshold_w * v.off_delay / 3600 * 1000) / 1000 }, stop_threshold_w: { msgKey: 'conflict.end_energy.stop', msgVars: {e: v.end_energy_threshold, d: v.off_delay, p: +(v.end_energy_threshold * 3600 / v.off_delay).toFixed(2)}, msgFb: `End Energy Threshold (${v.end_energy_threshold} Wh over ${v.off_delay} s) only permits ${+(v.end_energy_threshold * 3600 / v.off_delay).toFixed(2)} W`, fixVal: Math.floor(v.end_energy_threshold * 3600 / v.off_delay * 10) / 10 }, }), }, ]; // ─── Styles ────────────────────────────────────────────────────────────────── const _CSS = ` :host { /* The panel owns the full height it is given and scrolls INTERNALLY (.wd-main). HA renders a non-iframe custom panel into a plain container that sets only display/background and the safe-area padding, and no height at all (frontend src/panels/custom/ha-panel-custom.ts), so the old min-height: 100% resolved against an auto-height parent and the panel simply grew with its content: the DOCUMENT scrolled, .wd-main's overflow never engaged, and a dropdown opened near the bottom was cut off by the window edge with no way to reach the rest of it (scrolling the page moves the anchor and closes it). The insets are subtracted because that same container adds them as padding around us, so 100dvh alone would overflow by exactly that much. */ /* NOT display:flex, and the layout must not depend on it: a rule in the embedding document targeting our tag beats any :host rule regardless of specificity, and both HA and the E2E fixture set display:block on the panel element. The height survives that because _syncPanelHeight writes it inline. .wd-root below takes height:100% of it and is the flex column everything else resolves against. */ display: block; height: calc(100dvh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px)); overflow: hidden; background: var(--primary-background-color); color: var(--primary-text-color); 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; } /* The device list and the tab bar are the app's navigation: they stay put while only the pane below them scrolls. Sticky rather than hoisted out of .wd-main, so the DOM order and the tablist/tabpanel relationship are untouched. The negative margins bleed the opaque background over .wd-body's own padding, so a card scrolling past cannot show through above or beside the nav; the padding puts that space back inside the sticky box. */ .wd-nav { position: sticky; top: 0; z-index: 40; background: var(--primary-background-color); margin: -20px -16px 20px; padding: 20px 16px 0; } .wd-nav .wd-tabs { margin-bottom: 0; } .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; } /* Audit UI-20: a horizontal scroller that hides content past an edge gets a shadow on that edge, so on a phone the tab strip and wide tables visibly continue instead of looking cut off. A shadow rather than a fade: past a table's last visible column there is usually only whitespace, where a fade shows nothing. _syncScrollAffordances sets the classes only while there really is more content on that side; backgrounds do not affect layout, and nothing overflows at desktop widths, so the desktop view is unchanged. The background of a scroll container does not scroll with it, so the shadow stays on the visible edge. Mid-grey reads on light and dark themes alike. */ .wd-ovf-l, .wd-ovf-r { background-repeat: no-repeat; background-size: 22px 100%; } .wd-ovf-r { background-image: linear-gradient(to left, rgba(127,127,127,.42), rgba(127,127,127,0)); background-position: right center; } .wd-ovf-l { background-image: linear-gradient(to right, rgba(127,127,127,.42), rgba(127,127,127,0)); background-position: left center; } .wd-ovf-l.wd-ovf-r { background-image: linear-gradient(to right, rgba(127,127,127,.42), rgba(127,127,127,0)), linear-gradient(to left, rgba(127,127,127,.42), rgba(127,127,127,0)); background-position: left center, right center; } .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 — 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; } /* Derived-value note (#445): informational, never an error - no red, no fix button. */ .wd-setting-note { font-size: .78em; color: var(--secondary-text-color); margin-top: 4px; padding: 5px 9px; border-radius: var(--wd-radius-sm); background: var(--secondary-background-color); border-left: 3px solid var(--primary-color); } /* 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; } /* The label shrinks and ellipsizes; the remove button never does (#467: a long notify service pushed it out of the clipped pill). */ .wd-pill-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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, .wd-tip:focus .wd-tip-pop { display: block; } .wd-tip:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 1px; } .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-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-confirm-list { margin: 0 0 12px; padding-left: 18px; max-height: 50vh; overflow-y: auto; } .wd-confirm-list li { margin: 4px 0; } .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); } /* #444: these are the only cards rendered as a button element, and resetting font without color left them on the UA buttontext system color - which follows the BROWSER/OS color scheme, not the HA theme. On a light HA theme served to a dark-mode browser (Catppuccin Auto Latte Macchiato, the reporter's setup) the profile titles came out near-white on a light card. Everything beside them was readable because it names a theme token explicitly; only the inherited color was wrong. (No backticks in this block: it lives inside a JS template literal.) */ button.wd-attn-card, button.wd-profile-card { appearance: none; font: inherit; color: 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; } /* The box is a combobox (#416), so the flex child is the wrapper, not the input. */ .wd-store-search .wd-combo { flex: 1 1 auto; min-width: 180px; } .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; } /* #460: a group's overflow:hidden drops its automatic min-height to 0, so past the max-height above the groups shrank and clipped their own rows instead of the tree scrolling (a phone hits the cap at once). Rows keep their height; the tree scrolls. */ .wd-sd-tree > * { flex-shrink: 0; } .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: '▶ '; font-size: .7em; } .wd-share-guide[open] > summary::before { content: '▼ '; } .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-stale { margin: 6px 0 0; padding: 6px 12px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--warning-color, #ffa600); color: var(--primary-text-color); font-size: .85em; } .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 { position: relative; margin: 10px 0; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 6px; } /* touch-action: pan-y is load-bearing, not cosmetic. With "none" (the pre-0.5.6 value) a one-finger swipe that started on a graph was swallowed, so every chart was a 160-240px band the user could not scroll past on a phone (issue #413). With pan-y the browser keeps the vertical swipe (page scrolls) while still handing us the horizontal drag AND both pointers of a two-finger pinch, which is what makes touch zoom possible at all. Mirrors what Home Assistant's own ha-chart-base does via moveOnMouseMove/preventDefaultMouseMove. */ .wd-canvas-wrap canvas { width: 100%; height: 240px; display: block; touch-action: pan-y; cursor: crosshair; } /* Zoom reset affordance, mirroring HA's mdiRestart chart button: only rendered while the canvas actually has a zoom viewport. */ .wd-zoom-reset { position: absolute; top: 10px; right: 12px; z-index: 2; display: none; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border-radius: 50%; border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); cursor: pointer; font-size: 15px; line-height: 1; opacity: .92; box-shadow: 0 1px 4px rgba(0,0,0,.25); } .wd-zoom-reset.wd-zoom-reset--on { display: inline-flex; } .wd-zoom-reset:hover { opacity: 1; border-color: var(--primary-color); } .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 — 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; } .wd-gtip--pinned { border-color: var(--primary-color); } .wd-gtip-dismiss { display: block; margin-top: 4px; opacity: .55; font-size: 11px; } /* 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.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; } /* Flipped above the input when there is more room there (_positionComboDrop). */ .wd-combo-drop.wd-drop-up { top: auto; bottom: 100%; margin-top: 0; margin-bottom: 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; } /* Colour field: swatch + hex text box + clear (#454) */ .wd-colorfield { display: flex; gap: 6px; align-items: center; width: 100%; } .wd-colorfield input[type="text"] { flex: 1 1 auto; min-width: 0; } .wd-color-sw { flex: 0 0 auto; width: 34px; height: 34px; padding: 2px; cursor: pointer; border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--secondary-background-color); } .wd-color-sw::-webkit-color-swatch-wrapper { padding: 0; } .wd-color-sw::-webkit-color-swatch { border: none; border-radius: 4px; } .wd-color-sw::-moz-color-swatch { border: none; border-radius: 4px; } .wd-color-clear { flex: 0 0 auto; width: 30px; height: 34px; border-radius: var(--wd-radius-md); border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--secondary-text-color); font-size: 1.1em; line-height: 1; cursor: pointer; } .wd-color-clear:hover { color: var(--primary-text-color); } .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-nav { margin: -12px -10px 12px; padding: 12px 10px 0; } .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-sd-prof-name { white-space: normal; overflow-wrap: anywhere; } /* #460: wrap, never ellipsise a choice */ .wd-canvas-wrap canvas { height: 200px; } .wd-zoom-reset { width: 36px; height: 36px; top: 8px; right: 8px; font-size: 17px; } .wd-gtip { white-space: normal; max-width: calc(100vw - 24px); } .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 */ /* The render target between :host and .wd-shell (created in _boot). It must be a full-height flex column of its own: an unstyled wrapper is a block box sized by its content, so .wd-shell's flex:1 had no flex container to resolve against and the whole chain down to .wd-main fell back to content height. .wd-main then never overflowed (scrollHeight == clientHeight, so no scrollbar) and instead spilled out of the clipped host, stranding the bottom of every long tab where no scrolling reaches it. */ .wd-root { height: 100%; display: flex; flex-direction: column; } /* min-height:0 so the row below can actually shrink and hand its overflow to .wd-main, instead of the shell growing to fit and pushing past the host. */ .wd-shell { display: flex; flex-direction: column; flex: 1; min-height: 0; } .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%; } /* Same pan-y contract as .wd-canvas-wrap canvas above: page keeps the vertical swipe, we keep horizontal drags and pinch. This canvas previously had no touch-action at all (default auto), so on touch the browser cancelled every gesture and none of the pan / threshold-drag / hover features worked. */ #wd-pg-canvas { display: block; width: 100%; height: 330px; cursor: crosshair; touch-action: pan-y; 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-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; } } `; // ─── Helpers ───────────────────────────────────────────────────────────────── // The _fmt* helpers below feed HTML templates directly, often with values that // came from outside (a community-store document is untyped: audit STORE-19), so // they never echo their input: anything that is not a finite number renders '-'. function _fmtDuration(s) { const n = Number(s); if (s == null || !isFinite(n) || n < 0) return '-'; s = n; const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60); if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${sec}s`; return `${sec}s`; } function _fmtPower(w) { const n = Number(w); if (w == null || !isFinite(n)) return '-'; return n >= 100 ? `${_fmtNum(Math.round(n), 0)} W` : `${_fmtNum(n, 1)} W`; } // Watt label for a chart axis / grid line. #453: a fixed Math.round() prints "0W" // on every tick of a standby trace, so an appliance idling at 0.3 W got three // identical zero labels next to a perfectly legible curve. The decimal count // follows the distance between ticks rather than the value, so the labels always // resolve one grid step (capped at 4 decimals to keep them inside the axis gutter). function _fmtAxisW(v, step) { const s = Math.abs(step || v || 0); const dec = s > 0 ? Math.min(4, Math.max(0, Math.ceil(-Math.log10(s)) + 1)) : 0; return `${Number(v.toFixed(dec))}W`; } // Round grid step (1/2/2.5/5 x 10^n) covering `span` in about `divisions` lines. // Scale free, so it gives 500 W steps on a 2 kW cycle and 0.1 W steps on a standby // tail, and always lands on a value that reads as a round number. function _niceStep(span, divisions) { const raw = Math.abs(span) / Math.max(1, divisions); if (!(raw > 0) || !isFinite(raw)) return 1; const mag = Math.pow(10, Math.floor(Math.log10(raw))); const norm = raw / mag; return (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag; } function _fmtEnergy(kwh) { if (kwh == null || !isFinite(Number(kwh))) return '-'; return `${_fmtNum(kwh, 2)} kWh`; } // Current cycle-date display mode ('relative' | 'absolute'), synced from the // user's persisted "Cycle date display" preference by _render() on each paint. let _datePref = 'relative'; // The HA user's display locale (audit UI-08), synced by _render() like _datePref // so the module-level formatters follow the user's language and number format // instead of the browser's. `undefined` means "browser default", which is what // HA itself does for the "system" choices. let _uiDateLocale; // panel language (lang_override, else HA language) let _uiNumLocale; // HA number_format mapped to a locale, as HA's frontend does let _uiNumGrouping = true; let _uiHour12; // HA time_format '12' / '24'; undefined = locale default const _nfCache = new Map(); function _syncUiLocale(lang, locale) { const loc = locale || {}; _uiDateLocale = lang || undefined; const nf = loc.number_format; _uiNumLocale = nf === 'comma_decimal' ? ['en-US', 'en'] : nf === 'decimal_comma' ? ['de', 'es', 'it'] : nf === 'space_comma' ? ['fr', 'sv', 'cs'] : nf === 'quote_decimal' ? ['de-CH'] : nf === 'system' ? undefined : (lang || undefined); _uiNumGrouping = nf !== 'none'; _uiHour12 = loc.time_format === '12' ? true : loc.time_format === '24' ? false : undefined; } function _numFmt(opts) { const key = JSON.stringify([_uiNumLocale, _uiNumGrouping, opts]); let f = _nfCache.get(key); if (!f) { const o = Object.assign({ useGrouping: _uiNumGrouping }, opts); try { f = new Intl.NumberFormat(_uiNumLocale, o); } catch (_) { f = new Intl.NumberFormat(undefined, o); } _nfCache.set(key, f); } return f; } // Display-only number: `dec` fraction digits (fixed, like toFixed). Never use the // result for a value that is sent back to the backend. A non-number renders '-', // never the input itself: callers drop the result straight into innerHTML (STORE-19). function _fmtNum(v, dec = 0) { const n = Number(v); if (!isFinite(n)) return '-'; return _numFmt({ minimumFractionDigits: dec, maximumFractionDigits: dec }).format(n); } // A community-store counter (downloads, favourites, ratings) as a non-negative // number. Store documents are untyped, so a missing, negative or non-numeric // counter reads as 0 rather than reaching the page as-is (audit STORE-19). function _storeCount(v) { const n = Number(v); return isFinite(n) && n > 0 ? Math.floor(n) : 0; } // Money in HA's configured currency, formatted for the user's locale ("€0.21", // "0,21 €"). A currency that is not an ISO 4217 code keeps the old "0.21 XYZ". function _fmtCost(v, cur) { const n = Number(v); if (v == null || !isFinite(n)) return '-'; if (cur) { try { return _numFmt({ style: 'currency', currency: cur }).format(n); } catch (_) { /* not ISO 4217 */ } } return `${_fmtNum(n, 2)}${cur ? ' ' + cur : ''}`; } function _dateOpts(opts) { return _uiHour12 === undefined ? opts : Object.assign({ hour12: _uiHour12 }, opts); } function _fmtTimeOfDay(d) { const opts = _dateOpts({ hour: '2-digit', minute: '2-digit', second: '2-digit' }); try { return d.toLocaleTimeString(_uiDateLocale, opts); } catch (_) { return d.toLocaleTimeString(undefined, opts); } } function _fmtDay(ts) { const d = new Date(ts); try { return d.toLocaleDateString(_uiDateLocale); } catch (_) { return d.toLocaleDateString(); } } // ─── 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) { const diffSec = Math.round((ms - Date.now()) / 1000); // < 0 = in the past let rtf; try { rtf = new Intl.RelativeTimeFormat(_uiDateLocale, { numeric: 'auto' }); } catch (_) { return _fmtAbsDate(ms); } const abs = Math.abs(diffSec); const units = [['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60]]; for (const [name, span] of units) { if (abs >= span) return rtf.format(Math.round(diffSec / span), name); } return rtf.format(diffSec, 'second'); } function _fmtAbsDate(ms) { const opts = _dateOpts({ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); try { return new Date(ms).toLocaleString(_uiDateLocale, opts); } catch (_) { return new Date(ms).toLocaleString(undefined, opts); } } // Normalize any timestamp (ISO string, unix seconds, unix millis, or a bare // YYYY-MM-DD calendar date) to epoch millis, then format per the date-display // preference. `mode` overrides the preference for a single call site. function _fmtDate(ts, mode) { if (!ts) return '-'; let ms; if (typeof ts === 'number') { // Numeric epoch: ms (Date.now(), ~1e12+) vs seconds (~1e9). Anything >= 1e12 // is already-milliseconds — handles Date.now() values consistently. ms = ts >= 1e12 ? ts : ts * 1000; } else { const s = String(ts); const md = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); // Bare calendar dates (maintenance YYYY-MM-DD) parse as LOCAL midnight, not // UTC, so they don't shift a day in negative-offset timezones. ms = md ? new Date(+md[1], +md[2] - 1, +md[3]).getTime() : new Date(s).getTime(); } 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, '''); } // A _t() var that is markup on purpose (built from escaped parts). _t() escapes // every other var, so wrapping one in _html() is the explicit, greppable opt-out // (audit UI-21: a raw search term reached innerHTML through a translated string). class _TrustedHtml { constructor(html) { this.html = String(html == null ? '' : html); } toString() { return this.html; } } function _html(s) { return new _TrustedHtml(s); } // CLDR plural category ('one', 'few', 'other', ...) of `n` in `lang`; one cached // Intl.PluralRules per language. An unknown tag degrades to English rules. const _pluralRules = new Map(); function _pluralCat(lang, n) { let pr = _pluralRules.get(lang); if (pr === undefined) { try { pr = new Intl.PluralRules(lang || 'en'); } catch (_) { pr = null; } _pluralRules.set(lang, pr); } return pr ? pr.select(n) : (n === 1 ? 'one' : 'other'); } // Allow only http(s) links. Community-supplied URLs (e.g. a device's manualUrl) // must never render a `javascript:`/`data:` href, which _esc does not neutralise. // Returns '' for anything that is not an absolute http(s) URL. function _safeHttpUrl(u) { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; } // Apply an alpha channel to any CSS color and return a valid rgba() string. // The base color may come from a theme's `--primary-color` (issues #314/#315), // which `getPropertyValue` returns as the raw declared token: `#03a9f4` for hex // themes but `rgb(238, 147, 0)` for many custom themes / HA's accent picker. // The old `col + '55'` alpha-suffix concatenation produced `rgb(238, 147, 0)55`, // an invalid color that made canvas `addColorStop`/`fillStyle` throw and froze // the panel. This parses hex (#rgb/#rgba/#rrggbb/#rrggbbaa) and rgb()/rgba(), // and never throws -- an unrecognised color falls back to itself so the draw // still succeeds (worst case: no transparency) rather than aborting the render. function _withAlpha(col, alpha) { const s = String(col == null ? '' : col).trim(); let m = s.match(/^#([0-9a-fA-F]{3,8})$/); if (m) { let h = m[1]; if (h.length === 3 || h.length === 4) h = h.split('').map(c => c + c).join(''); if (h.length === 6 || h.length === 8) { const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } } m = s.match(/^rgba?\(([^)]+)\)$/i); if (m) { const parts = m[1].split(',').map(p => p.trim()); if (parts.length >= 3) return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`; } return s; } function _num(v, def) { const n = parseFloat(v); return isNaN(n) ? def : n; } // Visible, keyboard-focusable descendants of `root` (for modal focus trapping). // A selector that finds "the same control" after a full re-render: its id, else the // data-* attributes the panel uses to identify buttons, rows and inputs. const _FOCUS_ATTRS = ['data-action', 'data-cid', 'data-idx', 'data-tab', 'data-sec', 'data-gtab', 'data-ptab', 'data-hsub', 'data-proftab', 'data-opt', 'data-pgkey', 'data-gname', 'data-sugkey']; function _focusKey(el) { if (!el || !el.getAttribute || el.tagName === 'BODY') return null; const esc = (v) => (window.CSS && CSS.escape) ? CSS.escape(v) : String(v).replace(/["\\]/g, '\\$&'); if (el.id) return '#' + esc(el.id); const parts = _FOCUS_ATTRS.filter(a => el.hasAttribute(a)).map(a => `[${a}="${esc(el.getAttribute(a))}"]`); return parts.length ? el.tagName.toLowerCase() + parts.join('') : null; } function _focusableEls(root) { if (!root) return []; const sel = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; return Array.from(root.querySelectorAll(sel)).filter(el => el.getClientRects().length > 0); } // D7: humanize a changelog value for display (null → em-dash-free placeholder). function _chgVal(v) { if (v == null || v === '') return '(none)'; if (v === true) return 'on'; if (v === false) return 'off'; if (Array.isArray(v)) return v.join(', ') || '(none)'; return String(v); } // Sort an array by a getter function, direction +1=asc -1=desc. function _sortBy(arr, getter, dir) { return arr.slice().sort((a, b) => { const av = getter(a), bv = getter(b); if (av == null && bv == null) return 0; if (av == null) return 1; if (bv == null) return -1; return (av < bv ? -1 : av > bv ? 1 : 0) * dir; }); } // Sortable element: shows ▲/▼ on the active column, ↕ on others. // Pass align='right' for numeric columns, tipText for a native title= tooltip (no icon needed). function _th(label, col, active, dir, action, align, tipText) { const icon = active ? (dir === 1 ? ' ▲' : ' ▼') : ' ↕'; const alignStyle = align === 'right' ? 'text-align:right;' : ''; const titleAttr = tipText ? ` title="${_esc(tipText)}"` : ''; return `${label}${icon}`; } // mm:ss for a seconds value (graph hover readout). function _fmtClock(s) { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; return `${m}:${String(sec).padStart(2, '0')}`; } // Plain label for a detected cycle artifact type. function _artifactLabel(type, t) { const entries = { pause: ['lbl.artifact_interruption', 'Interruption'], dip: ['lbl.artifact_low_power', 'Low power'], spike: ['lbl.artifact_high_power', 'High power'] }; const [key, fb] = entries[type] || ['lbl.artifact_anomaly', 'Anomaly']; return t ? t(key, {}, fb) : fb; } // Slugify a sub-group label to a translation key fragment. // "Door & Pause" → "door_pause", "Auto-Labeling" → "auto_labeling", etc. function _slugSub(s) { return s.toLowerCase().replace(/[\s&/\-]+/g, '_').replace(/^_+|_+$/g, '').replace(/_+/g, '_'); } // Normalise a device name for a ?device= deep link (#428): fold case and // diacritics, then reduce every run of spaces/punctuation to one dash, so // "Waschmaschine (Keller)" is also reachable as waschmaschine-keller. The class // is \p{L}\p{N} rather than a-z0-9 so a Cyrillic or CJK name keeps its letters // instead of slugging away to an empty string that would match anything. function _deviceSlug(s) { let t = String(s == null ? '' : s).trim().toLowerCase(); try { t = t.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); } catch (_) { /* keep as-is */ } return t.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, ''); } // Parse a comma-separated string into a sorted list of unique positive ints. // Backs the `intlist` setting type (e.g. notify_milestones), which the backend // stores as a list of ints but the panel edits as a comma-separated string. function _parseIntList(s) { const seen = new Set(); String(s == null ? '' : s).split(',').forEach(part => { const n = parseInt(part.trim(), 10); if (Number.isFinite(n) && n > 0) seen.add(n); }); return Array.from(seen).sort((a, b) => a - b); } // Linear-interpolated y at offset x for a sorted [[x,y],...] series. // Like _valueAt, but null where the series has no reading: before its first point, // after its last, and inside a gap (a null-power point breaks the run). The cycle // chart's series are read this way while recorder context is drawn around them // (item 513), so the lead-in does not show the trace's first reading as "Power". function _valueInRun(pts, x) { if (!pts || !pts.length) return null; for (let i = 0; i < pts.length; i++) { const b = pts[i]; if (b[0] < x) continue; if (b[0] === x) return b[1] == null ? null : b[1]; if (i === 0) return null; const a = pts[i - 1]; if (a[1] == null || b[1] == null) return null; const span = (b[0] - a[0]) || 1; return a[1] + (b[1] - a[1]) * ((x - a[0]) / span); } return null; } function _valueAt(pts, x) { if (!pts || !pts.length) return null; if (x <= pts[0][0]) return pts[0][1]; if (x >= pts[pts.length - 1][0]) return pts[pts.length - 1][1]; for (let i = 1; i < pts.length; i++) { if (pts[i][0] >= x) { const a = pts[i - 1], b = pts[i]; const span = (b[0] - a[0]) || 1; return a[1] + (b[1] - a[1]) * ((x - a[0]) / span); } } return pts[pts.length - 1][1]; } // A full `#rrggbb` value, which is the only form `` accepts. // Anything else the user types (a CSS name, a short #rgb) stays in the text box and // simply leaves the swatch showing the placeholder colour. const _COLOR_HEX_RE = /^#[0-9a-fA-F]{6}$/; // The swatch value for an UNSET colour field. The swatch cannot be empty, so it // falls back to the field's placeholder, which is what _field() renders initially. const _COLOR_SWATCH_FALLBACK = '#03A9F4'; function _colorSwatchDefault(placeholder) { return _COLOR_HEX_RE.test(String(placeholder || '')) ? String(placeholder) : _COLOR_SWATCH_FALLBACK; } // Build one form field group. `f` is a schema field; opts are resolved by caller. function _field(f, value, extra) { extra = extra || {}; const key = f.key; const labelText = f.unit ? `${f.label} (${f.unit})` : f.label; const _u = f.unit ? ` ${f.unit}` : ''; const tip = f.doc ? _tip(f.doc, f.diagram || _DIAGRAM_BY_KEY[key], extra.tText) : ''; // D7: "changed" marker (a small dot with a tooltip) when this field has a // recorded change in the settings changelog. const chgDot = extra.changed ? `` : ''; if (f.type === 'checkbox') { const chk = value ? 'checked' : ''; // Switch style. The tooltip sits inline at the end of the row (outside the //