1997 lines
551 KiB
JavaScript
1997 lines
551 KiB
JavaScript
/*!
|
|
* WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
|
* Copyright (C) 2026 Lukas Bandura
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*
|
|
* This file is a MINIFIED BUILD. The corresponding readable source ships in the
|
|
* same directory and is the licensed, preferred form for modification.
|
|
* This program comes with ABSOLUTELY NO WARRANTY. See the GNU Affero General
|
|
* Public License for details: <https://www.gnu.org/licenses/>.
|
|
*/
|
|
const y="ha_washdata",Ht=(()=>{try{return new URL(import.meta.url).searchParams.get("v")||""}catch{return""}})(),ae=2e4,ne=6e3,Rt=25,re=[{key:"show_contributor",labelKey:"lbl.show_contributor",labelFb:"Show contributor names",docKey:"setting.show_contributor.doc",docFb:'Show the "by <contributor>" attribution on community appliances and reference cycles.'}],Bt=34,et=["#e6194B","#3cb44b","#4363d8","#f58231","#911eb4","#42d4f4","#f032e6","#bfef45","#fabed4","#469990","#dcbeff","#9A6324","#800000","#aaffc3","#808000","#ffd8b1","#000075","#a9a9a9"],$t="{device}, {duration}, {minutes}, {program}, {energy_kwh}, {cost}, {time_finished}, {vs_typical}, {cycle_count}",mt=[{id:"basic",label:"Basic",intro:"Core identity and the essentials most setups need.",groups:[{sub:"Device info",fields:[{key:"name",label:"Device Name",type:"text",doc:"Display name shown in the HA integrations list and device registry."},{key:"device_type",label:"Device Type",type:"devicetype",doc:"Appliance class. Sets sensible detection defaults (thresholds, off-delay, end handling) tuned for that appliance type; change it only if the device was originally set up as the wrong type."},{key:"store_brand",label:"Appliance Brand",type:"storebrand",optional:!0,doc:"Optional. The appliance brand, picked from the community catalog. Used to find and share matching reference recordings. Leave blank if you are not using online features."},{key:"store_model",label:"Appliance Model",type:"storemodel",optional:!0,doc:"Optional. The appliance model, picked from the community catalog once a brand is set. If your model is not listed you can add it to the catalog."}]},{sub:"Basic configuration",fields:[{key:"power_sensor",label:"Power Sensor",type:"entity",domain:"sensor",doc:"The sensor entity reporting live power in watts for this appliance (e.g. sensor.washer_power). All cycle detection is based on this signal."},{key:"min_power",label:"Minimum Power",unit:"W",type:"number",step:.1,min:0,def:2,basic:!0,doc:"Absolute minimum power considered active. Readings below this are treated as 0 W (standby), filtering out the phantom load of smart plugs and standby LEDs."},{key:"off_delay",label:"Off Delay",unit:"s",type:"number",min:0,def:180,basic:!0,doc:"Time to wait after power drops before declaring the cycle finished. If power resumes within this window the cycle continues seamlessly - this bridges pauses between wash stages. Dishwashers have long drying phases (power off for 20-60 min) so the off-delay must exceed that to keep the whole wash+dry as one cycle."},{key:"linked_device",label:"Group Under Device",type:"device",doc:'Optionally nest this WashData device under another device (e.g. the smart plug) in the HA device registry, shown as "Connected via ...".'}]}]},{id:"detection",label:"Detection",intro:"How a cycle is detected as starting, running and finishing.",groups:[{sub:"Thresholds & Gap",fields:[{key:"start_threshold_w",label:"Start Threshold",unit:"W",type:"number",step:1,min:0,basic:!0,doc:"Power must rise above this level to confirm a cycle has started. Setting it too low causes false starts from standby power; too high and slow-starting programs (cold fill) are missed. The suggestion engine sets this just above the machine's observed lowest active power."},{key:"stop_threshold_w",label:"Stop Threshold",unit:"W",type:"number",step:.1,min:0,basic:!0,doc:"Power must fall below this level before the off-delay countdown begins. Set it below the Start Threshold - the gap between them is the hysteresis band that prevents flicker. If set too high, low-power phases (rinse holds, anti-crease) falsely trigger the end sequence."},{key:"min_off_gap",label:"Min Off Gap",unit:"s",type:"number",min:0,basic:!0,doc:"If the machine powers off for less than this time, the on/off/on sequence is treated as one continuous cycle. Prevents soak programs (machine powers off for several minutes mid-wash) from being split into two separate cycles. Set it shorter than the gap between your back-to-back loads if you want those counted as separate cycles. Device-type defaults protect the typical intra-cycle pause for each appliance."}]},{sub:"Cycle Start",fields:[{key:"start_duration_threshold",label:"Start Duration",unit:"s",type:"number",min:0,def:5,doc:"Power must stay above the start threshold this long to confirm a real start, preventing split-second on/off toggles from starting a cycle."},{key:"start_energy_threshold",label:"Start Energy",unit:"Wh",type:"number",step:.01,min:0,def:.2,doc:"Energy (power x time) the appliance must consume before RUNNING. A brief high-power spike has very low energy and is ignored, preventing false starts."},{key:"completion_min_seconds",label:"Min Cycle Duration",unit:"s",type:"number",min:0,def:600,basic:!0,doc:"Cycles shorter than this are discarded as ghost cycles (test runs, opening the door to add a sock)."}]},{sub:"Cycle End",fields:[{key:"end_energy_threshold",label:"End Energy",unit:"Wh",type:"number",step:.001,min:0,def:.05,doc:"During the off-delay countdown, accumulated energy (watts x time) is compared to this threshold. If exceeded, the countdown resets - keeping anti-crease tumbles and dishwasher drying tails attached to the cycle instead of cutting them short. Raise it if cycles end too early during cool-down; lower it if detection is sluggish."},{key:"end_repeat_count",label:"End Repeat Count",type:"number",min:1,def:1,doc:"Number of consecutive below-stop-threshold readings required before the cycle ends. 1 is fine for most plugs. Raise to 2-3 if your smart plug occasionally reports a false-zero sample mid-cycle and your cycles are ending prematurely."},{key:"smart_termination_duration_ratio",label:"Smart Termination Ratio",type:"number",step:.01,min:.5,max:1,doc:"How far into the matched program's expected duration a cycle must be before Smart Termination may end it early once power drops. The expected duration is the program's average, so on appliances whose runtime varies a lot - washers on cold winter vs warm summer inlet water, sensor-dry dryers, load-dependent programs - about half of all runs finish shorter than that average and never get the fast finish, ending only via the fallback timeout minutes late. Lower this (e.g. 0.85) on those machines so the early finish still fires; raise it toward 1.0 to be more conservative. Leave empty for the default (0.98, or 0.99 for dishwashers). It can only ever end a cycle earlier, never later, and never fires on an ambiguous or low-confidence match."}]},{sub:"Power Off",fields:[{key:"power_off_threshold_w",label:"Power Off Threshold",unit:"W",type:"number",step:.1,min:0,def:0,doc:"Optional power-based Off detection. When above 0, once a cycle has finished and power stays below this level for the Power Off Delay, the machine is treated as switched off and the state returns to Off. Leave at 0 to disable (the default). Set it above the true switched-off floor and below the Stop Threshold and your machine's finished-but-on standby draw; if it is not below the Stop Threshold it is ignored. When enabled it replaces the Progress Reset Delay for returning to Off, so a finished machine stays in Finished/Clean until it is actually powered off."},{key:"power_off_delay",label:"Power Off Delay",unit:"s",type:"number",min:0,def:30,doc:"How long power must stay below the Power Off Threshold after a cycle finishes before the state returns to Off. Only used when the Power Off Threshold is above 0. Checked on the background cadence, so the effective delay rounds up to the next state-expiry tick."}]},{sub:"Signal Processing",fields:[{key:"sampling_interval",label:"Sampling Interval",unit:"s",type:"number",min:1,def:30,doc:"Expected time between sensor readings - used to size the smoothing window and start debounce correctly. Every sensor update is captured regardless of this value; it only calibrates the downstream calculations. The suggestion engine measures your sensor's actual cadence from past cycles and sets this automatically."},{key:"smoothing_window",label:"Smoothing Window",type:"number",min:1,def:2,doc:"How much the raw power signal is smoothed. Low (2) is responsive but noisy; high (5) smooths spikes but adds lag."}]}]},{id:"matching",label:"Matching",intro:"How finished cycles are matched to learned profiles and labelled.",notDeviceTypes:["other"],groups:[{sub:"Match Scoring",fields:[{key:"profile_match_threshold",label:"Match Threshold",type:"number",step:.01,min:0,max:1,def:.4,doc:"Minimum similarity score (0-1) required at cycle end to accept a program identification. Raise it to reduce wrong identifications; lower it if your machine's programs are not being matched. Default 0.4 is a conservative starting point."},{key:"profile_unmatch_threshold",label:"Unmatch Threshold",type:"number",step:.01,min:0,max:1,def:.35,doc:"If a live mid-cycle match drops below this score, the tentative identification is cleared. Keep it a little below the Match Threshold so a brief dip in similarity does not flip the display back to unmatched."},{key:"profile_match_interval",label:"Match Interval",unit:"s",type:"number",min:0,doc:"How often to attempt profile matching during a running cycle. Default 300 s (5 minutes) balances detection speed and CPU."}]},{sub:"Duration Gates",fields:[{key:"profile_match_min_duration_ratio",label:"Min Duration Ratio",type:"number",step:.01,min:0,max:1,def:.1,doc:"Minimum cycle length relative to the profile. 0.9 means a cycle must be at least 90% of the profile duration to match."},{key:"profile_match_max_duration_ratio",label:"Max Duration Ratio",type:"number",step:.01,min:0,def:1.5,doc:"Maximum cycle length relative to the profile. 1.5 means a cycle must be under 150% of the profile duration to match."},{key:"profile_duration_tolerance",label:"Profile Duration Tolerance",type:"number",step:.01,min:0,max:1,def:.25,doc:"The +/- band around a profile average duration used during matching. 0.25 means a 60 min profile matches 45-75 min cycles."},{key:"duration_tolerance",label:"Estimate Tolerance",type:"number",step:.01,min:0,max:1,def:.1,doc:"Tolerance for time-remaining estimates (learning feedback, not matching). If the actual duration is within +/-X% of the estimate it counts as a good match."}]},{sub:"Profile Evidence",fields:[{key:"profile_evidence_sources",label:"Cycles that shape a program",type:"checkboxlist",def:["real_cycles","reference_cycles","backfill_cycles"],choices:[["real_cycles","Cycles this machine ran"],["reference_cycles","Downloaded from the community store"],["backfill_cycles","Found in imported power history"]],doc:"Which cycles are used to build each program's power curve, and to match a finished cycle against it. Unticking a kind stops it shaping your programs without deleting anything - the cycles stay in your Cycles list and can still be labelled or removed. Useful if you do not trust imported data. Statistics are unaffected: they always count only the cycles this machine actually ran. Unticking everything is ignored, since a program with no cycles behind it could never match."}]},{sub:"Auto-Labeling",fields:[{key:"auto_label_confidence",label:"Auto-Label Confidence",type:"number",step:.01,min:0,max:1,def:.9,doc:"If the match score at cycle end is at or above this, the program is labeled automatically without any confirmation prompt. Raise it to require higher certainty before auto-labeling; lower it to automate more. Works in conjunction with Learning Confidence below it."},{key:"learning_confidence",label:"Learning Confidence",type:"number",step:.01,min:0,max:1,def:.6,doc:"If the match score falls between this and Auto-Label Confidence, WashData flags the finished cycle for review in the Cycles queue so you can verify the identified program. Below this score the match is too uncertain to surface. Must be kept below Auto-Label Confidence."}]}]},{id:"phase_eta",label:"Time Remaining",intro:"Phase-aware time-remaining, for machines whose cycle length depends on temperature or spin.",onlyDeviceTypes:["washing_machine","washer_dryer"],fields:[{key:"enable_phase_matching",label:"Phase-aware time remaining",type:"checkbox",def:!1,doc:"Break each running cycle into phases (heating, wash, spin) and budget the time remaining per phase, blended with the classic estimate - leaning on the phase budget early in the cycle and the classic estimate near the end. This personalises the countdown to how long your machine actually heats and runs, which is most noticeable in the first half of a cycle. Off = the classic estimate only. Only the time-remaining display is affected; program matching and cycle detection are unchanged."}]},{id:"timing",label:"Timing & Watchdog",intro:"Background cadence, the offline watchdog and housekeeping.",groups:[{sub:"Watchdog",fields:[{key:"watchdog_interval",label:"Watchdog Interval",unit:"s",type:"number",min:1,def:30,doc:"How often the background watchdog checks for stalled sensors and elapsed timeouts. Default 30 s."},{key:"no_update_active_timeout",label:"No-Update Timeout",unit:"s",type:"number",min:0,def:600,doc:"If no power updates arrive for this long while running, assume the plug dropped offline and force-stop to avoid a zombie cycle. Default 600 s allows for cloud or mesh lag."}]},{sub:"Housekeeping",fields:[{key:"progress_reset_delay",label:"Progress Reset Delay",unit:"s",type:"number",min:0,def:1800,doc:"After finishing, hold progress at 100% for this long so Completed is visible on dashboards before resetting to Idle."},{key:"auto_maintenance",label:"Auto Maintenance (nightly cleanup)",type:"checkbox",def:!0,doc:"Run nightly housekeeping: rebuild profile envelopes, recompute cycle health, prune debug traces and retain the most recent cycles."},{key:"power_profile_interval_min",label:"Power Profile Interval",unit:"min",type:"number",min:1,def:15,doc:"Bucket size for the per-profile power_profile sensor attribute (the flat per-slot average-watts array consumed by external planners such as EMHASS and tibber_prices). Smaller buckets keep short power spikes sharp; larger buckets smooth the shape. Default 15 min. Read-time only; does not affect detection."}]},{sub:"Debug",fields:[{key:"expose_debug_entities",label:"Expose Debug Entities",type:"checkbox",doc:"Publish extra diagnostic HA entities (match confidence, ambiguity, state internals). Off keeps the entity list clean for normal use."},{key:"save_debug_traces",label:"Save Debug Traces",type:"checkbox",doc:"Store the full power trace and matching debug data for each cycle. Useful for troubleshooting but increases storage size."}]}]},{id:"anti_wrinkle",label:"Anti-Wrinkle",intro:"Anti-wrinkle / anti-crease mode detects low-power tumble pulses after the main phase and keeps them attached to the finished cycle instead of reading them as new cycles.",onlyDeviceTypes:["washing_machine","dryer","washer_dryer"],fields:[{key:"anti_wrinkle_enabled",label:"Enable Anti-Wrinkle Detection",type:"checkbox",doc:"Recognise the short low-power tumble pulses a dryer emits after the main heat phase and keep them attached to the finished cycle instead of reading them as new cycles."},{key:"anti_wrinkle_max_power",label:"Max Anti-Wrinkle Power",unit:"W",type:"number",step:10,min:0,def:400,doc:"A pulse above this power is treated as a real new cycle, not an anti-wrinkle tumble. Set just above the tumble-pulse power."},{key:"anti_wrinkle_max_duration",label:"Max Duration",unit:"s",type:"number",min:0,def:60,doc:"Pulses longer than this are treated as a real cycle rather than an anti-wrinkle tumble."},{key:"anti_wrinkle_exit_power",label:"Exit Power Threshold",unit:"W",type:"number",step:.1,min:0,def:.8,doc:"Power must fall below this between pulses for anti-wrinkle mode to stay active."},{key:"anti_wrinkle_idle_timeout",label:"Max Pulse Gap",unit:"s",type:"number",step:30,min:0,def:120,doc:"How long the machine may stay quiet between two tumble pulses before anti-wrinkle mode ends. Set it above the longest gap your dryer leaves between pulses, otherwise every later pulse is read as a false start."}]},{id:"dishwasher",label:"Dishwasher",intro:"End-of-cycle handling for dishwashers, which typically finish with a long near-silent drying phase before a short final drain.",onlyDeviceTypes:["dishwasher"],fields:[{key:"dishwasher_end_spike_quiet_release",label:"Passive-Dry Quiet Release",unit:"s",type:"number",step:60,min:0,def:600,doc:"Once the cycle passes its expected duration, how long the dishwasher must stay quiet (below the Stop Threshold) before WashData stops waiting for a final drain and ends the cycle. Raise it if your machine has a long silent drying phase before a late final drain that is being missed - a wider window lets the learned duration follow seasonal drift (colder inlet water = longer cycles) instead of locking to the old average. It only ever shortens the wait relative to the internal 30-minute end-spike cap, never extends it."}]},{id:"delay",label:"Delay Start",intro:"Delayed-start detection identifies when an appliance is powered but has not yet begun its cycle.",fields:[{key:"delay_start_detect_enabled",label:"Enable Delay-Start Detection",type:"checkbox",doc:"Detect when the appliance is powered on and waiting (delayed start / standby) but has not begun its cycle, so standby draw is not mistaken for a running cycle."},{key:"delay_confirm_seconds",label:"Confirm Seconds",unit:"s",type:"number",min:0,def:60,doc:"Power must stay in the standby band for this long before the appliance is treated as waiting-to-start rather than running."},{key:"delay_timeout_hours",label:"Timeout Hours",unit:"h",type:"number",step:.5,min:0,def:8,doc:"Stop waiting in delayed-start mode after this many hours and return to idle, so a machine left powered but never started does not wait forever."}]},{id:"triggers",label:"Triggers & Door",intro:"Optional external signals: an end trigger, a door sensor, a pause switch, and the unload reminder.",groups:[{sub:"External End Trigger",fields:[{key:"external_end_trigger_enabled",label:"Enable External End Trigger",type:"checkbox",doc:"Let an external binary sensor signal the end of a cycle, in addition to the built-in power-based detection."},{key:"external_end_trigger",label:"External Trigger Entity",type:"entity",domain:"binary_sensor",doc:'Binary sensor whose state change marks the cycle end (e.g. an appliance "finished" contact or a companion integration).'},{key:"external_end_trigger_inverted",label:"Invert External Trigger (trigger on OFF)",type:"checkbox",doc:"Treat the trigger sensor turning OFF (rather than ON) as the end-of-cycle signal."}]},{sub:"Door & Pause",fields:[{key:"door_sensor_entity",label:"Door Sensor Entity",type:"entity",domain:"binary_sensor",doc:"Optional door binary sensor. Used to detect when the appliance has been opened/unloaded after a cycle."},{key:"door_opens_at_end",label:"Door Opens Automatically At End",type:"checkbox",doc:"For dishwashers that pop the door open at the end of the cycle to dry (AirDry and similar). With this on, a door-open on a running cycle no longer pauses it forever; instead, if the door stays open for the dwell below, WashData treats the cycle as finished. A brief open (adding an item) is ignored. Requires a Door Sensor Entity."},{key:"door_end_dwell_seconds",label:"Door-Open End Dwell",unit:"s",type:"number",min:1,def:60,doc:'How long the door must stay open before WashData ends the cycle, when "Door Opens Automatically At End" is on. Long enough to ignore quickly adding a dish (default 60 s), short enough to end promptly once the machine pops the door.'},{key:"pause_cuts_power",label:"Pause Also Cuts Power (via switch)",type:"checkbox",doc:"When a cycle is paused, also switch off the Switch Entity below. Only for appliances whose plug can safely be cut mid-cycle."},{key:"switch_entity",label:"Switch Entity",type:"entity",domain:"switch",doc:'Optional switch toggled off on pause and back on when resuming, used together with "Pause also cuts power".'}]},{sub:"Unload Reminder",fields:[{key:"notify_unload_delay_minutes",label:"Unload Nag Delay",unit:"min",type:"number",min:0,def:60,basic:!0,doc:'Minutes after a cycle ends before sending the still-waiting "unload the machine" reminder. Set 0 to disable the reminder.'},{key:"notify_unload_repeat",label:"Repeat Until Door Opens",type:"checkbox",doc:'Keep re-sending the unload reminder every "Unload Nag Delay" minutes until you open the door or tap "Stop reminding" on the notification. Requires a Door Sensor Entity (the reminder itself does). The dismiss button works on Home Assistant companion-app (mobile) notifications.'},{key:"pump_stuck_duration",label:"Pump Stuck Duration",unit:"s",type:"number",min:0,def:1800,onlyDeviceType:"pump",doc:"Seconds a pump may run continuously before it is flagged as possibly stuck (fires the stuck-pump event)."}]}]},{id:"notifications",label:"Notifications",groups:[{sub:"Services",fields:[{key:"notify_start_services",label:"Start Services",type:"entitylist",domain:"notify",placeholder:"add a notify service\u2026",basic:!0,doc:"notify.* services called when a cycle starts. Add one per target (phone, dashboard, etc.); leave empty for no start notification."},{key:"notify_finish_services",label:"Finish Services",type:"entitylist",domain:"notify",placeholder:"add a notify service\u2026",basic:!0,doc:"notify.* services called when a cycle finishes. Add one per target; leave empty for no finish notification."},{key:"notify_live_services",label:"Live Progress Services",type:"entitylist",domain:"notify",placeholder:"add a notify service\u2026",doc:"notify.* services called for live progress updates while a cycle runs. Leave empty to disable live-progress notifications."},{key:"notify_people",label:"People (for Only When Home)",type:"entitylist",domain:"person",placeholder:"add a person\u2026",doc:'person.* entities used by "Notify Only When Home" to decide whether anyone is home.'},{key:"notify_only_when_home",label:"Notify Only When Home",type:"checkbox",doc:"Only send notifications when at least one of the linked people (above) is home."},{key:"notify_fire_events",label:"Fire HA Events for Notifications",type:"checkbox",def:!0,doc:"Also fire ha_washdata_* events on cycle start/finish so you can build your own automations."}]},{sub:"Timing",fields:[{key:"notify_before_end_minutes",label:"Pre-End Alert",unit:"min",type:"number",min:0,def:0,doc:"Send an Almost Done alert when estimated time remaining drops below this. 0 disables it."},{key:"notify_live_interval_seconds",label:"Live Update Interval",unit:"s",type:"number",min:30,def:300,doc:"How often live-progress notifications are refreshed while a cycle runs."},{key:"notify_live_overrun_percent",label:"Live Overrun % Before Alert",unit:"%",type:"number",min:0,def:20,doc:"If a cycle runs past its estimate by more than this percentage, send an overrun alert."},{key:"notify_live_chronometer",label:"Use Live Chronometer",type:"checkbox",doc:"Show a live-updating countdown timer in the notification (on platforms that support it) instead of a static estimate."},{key:"notify_live_sticky",label:"Keep Live Notification On Tap",type:"checkbox",doc:"Android only. Make the live-progress notification persistent (sticky) so tapping it does not dismiss the ongoing thread. Off keeps the default behaviour where a tap dismisses it."},{key:"notify_live_click_action",label:"Live Notification Tap Target",type:"text",optional:!0,doc:"Android only. Where a tap on the live-progress notification opens (e.g. /lovelace/laundry, or a full URL) instead of the app landing page. Leave blank for the default."},{key:"notify_timeout_seconds",label:"Auto-Dismiss After",unit:"s",type:"number",min:0,def:0,doc:"Automatically dismiss the notification after this many seconds (on platforms that support it). 0 keeps it until dismissed manually."}]},{sub:"Messages",fields:[{key:"notify_title",label:"Notification Title",type:"text",def:"WashData: {device}",doc:`Notification title. Template variables: ${$t}.`},{key:"notify_icon",label:"Notification Icon",type:"text",def:"",doc:"Optional mdi icon for the notification (e.g. mdi:washing-machine). Leave blank for the platform default."},{key:"notify_start_message",label:"Start Message",type:"textarea",def:"{device} started.",doc:`Body sent when a cycle starts. Template variables: ${$t}.`},{key:"notify_finish_message",label:"Finish Message",type:"textarea",def:"{device} finished. Duration: {duration}m.",basic:!0,doc:`Body sent when a cycle finishes. Template variables: ${$t}. {time_finished} and {vs_typical} are most useful here.`},{key:"notify_pre_complete_message",label:"Pre-Complete Message",type:"textarea",def:"{device}: Less than {minutes} minutes remaining.",doc:`Body of the pre-end / almost-done alert. Template variables: ${$t}.`},{key:"notify_reminder_message",label:"Reminder Message",type:"textarea",def:"",doc:`Body of the still-waiting unload reminder. Blank uses the built-in default. Template variables: ${$t}.`},{key:"notify_channel",label:"Android Channel (start/live)",type:"text",def:"",placeholder:"e.g. WashData",suggestions:["WashData","WashData Status","Appliance Status"],doc:"Android notification channel name for start/live messages (controls per-channel sound and priority on the mobile app). Blank uses the companion app default."},{key:"notify_finish_channel",label:"Android Channel (finish)",type:"text",def:"",placeholder:"e.g. WashData Finished",suggestions:["WashData Finished","WashData Alerts","Appliance Finished"],doc:"Android notification channel name for the finish message. Blank reuses the start/live channel."}]},{sub:"Energy",fields:[{key:"energy_sensor",label:"Energy Meter Entity",type:"entity",domain:"sensor",optional:!0,doc:"Optional cumulative energy counter (total_increasing kWh/Wh, e.g. the plug's own lifetime meter). When set, each cycle's reported energy is taken from this counter's start-to-end delta, which avoids the under-counting you get from integrating a slow-reporting power sensor. Falls back to the integrated value if the reading is missing, its unit is unknown, or the delta is not positive. Leave blank to keep integrating the power sensor."},{key:"energy_price_entity",label:"Energy Price Entity",type:"entity",domain:"sensor",basic:!0,doc:"Sensor with the current electricity price per kWh (e.g. a dynamic tariff). Takes precedence over the static price below. Each cycle freezes the price in effect when it finished."},{key:"energy_price_static",label:"Static Energy Price (per kWh)",type:"number",step:.001,min:0,basic:!0,doc:"Fixed price per kWh used for cost figures when no live price entity is set above."},{key:"peak_rate_threshold",label:"Peak-Rate Threshold (per kWh)",type:"number",step:.001,min:0,def:0,clearable:!0,doc:"When a cycle starts and the current price per kWh is at or above this value, append a peak-rate tip to the start notification. 0 or blank disables the tip."},{key:"peak_rate_message",label:"Peak-Rate Message",type:"text",def:"",placeholder:"Running at peak rate ({price}/kWh).",doc:"Optional custom text for the peak-rate tip appended to the start notification. Template variables: {device}, {price}. Blank uses the built-in default."}]},{sub:"Cycle Timers",fields:[{key:"notify_cycle_timers",label:"Cycle Timers",type:"timerlist",doc:"Notifications at specific minutes into a cycle (e.g. to add softener). Message supports {device}, {program}, {minutes}. Enable Auto-pause to pause at that point and receive an interactive notification with a Resume button; resume via the panel, the pause/resume service, or the notification action."}]},{sub:"Quiet Hours & Milestones",fields:[{key:"notify_quiet_start_hour",label:"Quiet Hours Start",unit:"h",type:"number",min:0,max:23,clearable:!0,doc:"Start of a do-not-disturb window (0-23). Finish, reminder and clean-laundry notifications that would fire during quiet hours are held and delivered when the window ends. Leave blank to disable. Supports windows that cross midnight (e.g. start 22, end 7)."},{key:"notify_quiet_end_hour",label:"Quiet Hours End",unit:"h",type:"number",min:0,max:23,clearable:!0,doc:"End of the do-not-disturb window (0-23). Held notifications are delivered at this hour. Leave blank to disable."},{key:"notify_milestones",label:"Cycle Milestones",type:"intlist",def:"50, 100, 500, 1000",placeholder:"50, 100, 500, 1000",doc:"Comma-separated cycle counts that trigger a one-off celebration notification when reached (e.g. 50, 100, 500, 1000). Blank disables milestone notifications."},{key:"notify_milestone_message",label:"Milestone Message",type:"textarea",def:"{device} has completed {cycle_count} cycles!",doc:"Message for the milestone notification. Template variables: {device}, {cycle_count}."}]}]},{id:"ml_training",label:"ML Training",fields:[{key:"enable_ml_models",label:"Apply smart models during a cycle",type:"checkbox",def:!1,doc:"While a cycle runs, let the models refine the live results: a steadier time-remaining and energy/cost estimate, and an anti-premature-stop guard on end detection (it can only ever delay a finish, never end one early, and is bounded). Uses your fine-tuned models when available, otherwise the built-in ones. Off = the classic power-based logic only (still reliable)."},{key:"ml_training_enabled",label:"Learn from this machine",type:"checkbox",def:!1,doc:"Periodically study your reviewed cycles overnight and fine-tune the models to this specific machine. A change is only kept when it genuinely scores better on held-out cycles, so this can only help or stay the same \u2014 never regress."},{key:"ml_training_hour",label:"Learn at hour",unit:"h",type:"number",min:0,max:23,def:2,doc:"Local hour of day (0-23) to do the overnight fine-tuning. Pick a quiet hour such as 2 (02:00)."},{key:"ml_training_min_cycles",label:"Cycles needed first",type:"number",min:5,def:30,doc:"Wait until at least this many cycles have been recorded before fine-tuning, so there is enough to learn from."},{key:"ml_training_interval_days",label:"Check at most every",unit:"days",type:"number",min:1,def:7,doc:"Re-check for improvements at most once per this many days."}]}],kt={};for(const h of mt){const t=h.groups||[{fields:h.fields}];for(const e of t)for(const s of e.fields||[])kt[s.key]=s}const qt={profile_match_min_duration_ratio:.1,profile_match_max_duration_ratio:1.5,corr_weight:.45,keep_min_score:.1,dtw_bandwidth:.2,dtw_blend:.5,dtw_ensemble_w:.7,dtw_ddtw_scale:30,dtw_refine_top_n:5,duration_weight:.22,energy_weight:.22,duration_scale:.175,energy_scale:.25},Vt=[{keys:["start_threshold_w","stop_threshold_w"],check:h=>h.start_threshold_w!=null&&h.stop_threshold_w!=null&&h.start_threshold_w<=h.stop_threshold_w,fieldErrors:h=>({start_threshold_w:{msgKey:"conflict.hysteresis.start",msgVars:{stop:h.stop_threshold_w},msgFb:`Must be above Stop Threshold (${h.stop_threshold_w} W)`,fixVal:+Math.max(h.stop_threshold_w+.5,h.stop_threshold_w*1.25).toFixed(1)},stop_threshold_w:{msgKey:"conflict.hysteresis.stop",msgVars:{start:h.start_threshold_w},msgFb:`Must be below Start Threshold (${h.start_threshold_w} W)`,fixVal:+Math.min(h.start_threshold_w-.5,h.start_threshold_w*.8).toFixed(1)}})},{keys:["min_power","stop_threshold_w"],check:h=>h.min_power!=null&&h.stop_threshold_w!=null&&h.min_power>h.stop_threshold_w,fieldErrors:h=>({min_power:{msgKey:"conflict.min_power.min_power",msgVars:{stop:h.stop_threshold_w},msgFb:`Must be at or below Stop Threshold (${h.stop_threshold_w} W)`,fixVal:+(h.stop_threshold_w*.8).toFixed(1)},stop_threshold_w:{msgKey:"conflict.min_power.stop",msgVars:{min:h.min_power},msgFb:`Must be at or above Min Power (${h.min_power} W)`,fixVal:+(h.min_power*1.25).toFixed(1)}})},{keys:["power_off_threshold_w","stop_threshold_w"],check:h=>h.power_off_threshold_w!=null&&h.power_off_threshold_w>0&&h.stop_threshold_w!=null&&h.power_off_threshold_w>=h.stop_threshold_w,fieldErrors:h=>({power_off_threshold_w:{msgKey:"conflict.power_off.threshold",msgVars:{stop:h.stop_threshold_w},msgFb:`Must be below Stop Threshold (${h.stop_threshold_w} W) to take effect`,fixVal:+(h.stop_threshold_w*.6).toFixed(1)},stop_threshold_w:{msgKey:"conflict.power_off.stop",msgVars:{pot:h.power_off_threshold_w},msgFb:`Must be above Power Off Threshold (${h.power_off_threshold_w} W)`,fixVal:+(h.power_off_threshold_w*1.67).toFixed(1)}})},{keys:["off_delay","min_off_gap"],check:h=>h.off_delay!=null&&h.min_off_gap!=null&&h.off_delay>h.min_off_gap,fieldErrors:h=>({off_delay:{msgKey:"conflict.off_delay.off_delay",msgVars:{gap:h.min_off_gap},msgFb:`Off Delay (${h.off_delay} s) overrides Min Off Gap (${h.min_off_gap} s); cycles within the gap may merge`,fixVal:h.min_off_gap},min_off_gap:{msgKey:"conflict.off_delay.gap",msgVars:{delay:h.off_delay},msgFb:`Min Off Gap should be at least Off Delay (${h.off_delay} s)`,fixVal:h.off_delay}})},{keys:["watchdog_interval","sampling_interval"],check:h=>h.watchdog_interval!=null&&h.sampling_interval!=null&&h.watchdog_interval<2*h.sampling_interval,fieldErrors:h=>({watchdog_interval:{msgKey:"conflict.watchdog.interval",msgVars:{si:h.sampling_interval},msgFb:`Should be at least 2\xD7 Sampling Interval (${h.sampling_interval} s)`,fixVal:+(2*h.sampling_interval+1)},sampling_interval:{msgKey:"conflict.watchdog.sampling",msgVars:{wi:h.watchdog_interval},msgFb:`Sampling Interval should be at most half of Watchdog Interval (${h.watchdog_interval} s)`,fixVal:+Math.floor(h.watchdog_interval/2)}})},{keys:["no_update_active_timeout","watchdog_interval"],check:h=>h.no_update_active_timeout!=null&&h.watchdog_interval!=null&&h.no_update_active_timeout<=h.watchdog_interval,fieldErrors:h=>({no_update_active_timeout:{msgKey:"conflict.no_update_timeout.timeout",msgVars:{wi:h.watchdog_interval},msgFb:`Must be greater than Watchdog Interval (${h.watchdog_interval} s)`,fixVal:h.watchdog_interval*2},watchdog_interval:{msgKey:"conflict.no_update_timeout.watchdog",msgVars:{to:h.no_update_active_timeout},msgFb:`Must be less than No-Update Timeout (${h.no_update_active_timeout} s)`,fixVal:+Math.floor(h.no_update_active_timeout/2)}})},{keys:["start_duration_threshold","sampling_interval"],check:h=>h.start_duration_threshold!=null&&h.sampling_interval!=null&&h.start_duration_threshold<h.sampling_interval,fieldErrors:h=>({start_duration_threshold:{msgKey:"conflict.start_dur.threshold",msgVars:{si:h.sampling_interval},msgFb:`Should be at least one Sampling Interval (${h.sampling_interval} s) to prevent single-sample false starts`,fixVal:h.sampling_interval},sampling_interval:{msgKey:"conflict.start_dur.sampling",msgVars:{sdt:h.start_duration_threshold},msgFb:`Sampling Interval exceeds Start Duration (${h.start_duration_threshold} s); single-sample spikes can open a cycle`,fixVal:h.start_duration_threshold}})},{keys:["learning_confidence","profile_match_threshold"],check:h=>h.learning_confidence!=null&&h.profile_match_threshold!=null&&h.learning_confidence<h.profile_match_threshold,fieldErrors:h=>({learning_confidence:{msgKey:"conflict.confidence.learning",msgVars:{match:h.profile_match_threshold},msgFb:`Must be at or above Match Threshold (${h.profile_match_threshold})`,fixVal:+h.profile_match_threshold.toFixed(2)},profile_match_threshold:{msgKey:"conflict.confidence.match_for_learning",msgVars:{lc:h.learning_confidence},msgFb:`Must be at or below Learning Confidence (${h.learning_confidence})`,fixVal:+h.learning_confidence.toFixed(2)}})},{keys:["profile_match_threshold","auto_label_confidence"],check:h=>h.profile_match_threshold!=null&&h.auto_label_confidence!=null&&h.profile_match_threshold>h.auto_label_confidence,fieldErrors:h=>({profile_match_threshold:{msgKey:"conflict.confidence.match_for_auto",msgVars:{alc:h.auto_label_confidence},msgFb:`Must be at or below Auto-Label Confidence (${h.auto_label_confidence})`,fixVal:+h.auto_label_confidence.toFixed(2)},auto_label_confidence:{msgKey:"conflict.confidence.auto",msgVars:{match:h.profile_match_threshold},msgFb:`Must be at or above Match Threshold (${h.profile_match_threshold})`,fixVal:+h.profile_match_threshold.toFixed(2)}})},{keys:["profile_unmatch_threshold","profile_match_threshold"],check:h=>h.profile_unmatch_threshold!=null&&h.profile_match_threshold!=null&&h.profile_unmatch_threshold>=h.profile_match_threshold,fieldErrors:h=>({profile_unmatch_threshold:{msgKey:"conflict.unmatch.unmatch",msgVars:{match:h.profile_match_threshold},msgFb:`Must be below Match Threshold (${h.profile_match_threshold}); otherwise a committed match un-matches instantly`,fixVal:+(h.profile_match_threshold-.05).toFixed(2)},profile_match_threshold:{msgKey:"conflict.unmatch.match",msgVars:{un:h.profile_unmatch_threshold},msgFb:`Must be above Unmatch Threshold (${h.profile_unmatch_threshold})`,fixVal:+(h.profile_unmatch_threshold+.05).toFixed(2)}})},{keys:["anti_wrinkle_exit_power","stop_threshold_w"],check:h=>["washing_machine","dryer","washer_dryer"].includes(h.device_type)&&h.anti_wrinkle_exit_power!=null&&h.stop_threshold_w!=null&&h.anti_wrinkle_exit_power>=h.stop_threshold_w,fieldErrors:h=>({anti_wrinkle_exit_power:{msgKey:"conflict.anti_wrinkle_exit.exit",msgVars:{stop:h.stop_threshold_w},msgFb:`Must be below Stop Threshold (${h.stop_threshold_w} W); otherwise the anti-wrinkle exit power is ignored`,fixVal:+(h.stop_threshold_w*.4).toFixed(1)},stop_threshold_w:{msgKey:"conflict.anti_wrinkle_exit.stop",msgVars:{exit:h.anti_wrinkle_exit_power},msgFb:`Must be above Anti-Wrinkle Exit Power (${h.anti_wrinkle_exit_power} W)`,fixVal:+(h.anti_wrinkle_exit_power*2.5).toFixed(1)}})},{keys:["anti_wrinkle_max_power","start_threshold_w"],check:h=>["washing_machine","dryer","washer_dryer"].includes(h.device_type)&&h.anti_wrinkle_max_power!=null&&h.start_threshold_w!=null&&h.anti_wrinkle_max_power<=h.start_threshold_w,fieldErrors:h=>({anti_wrinkle_max_power:{msgKey:"conflict.anti_wrinkle_max.max",msgVars:{start:h.start_threshold_w},msgFb:`Must be above Start Threshold (${h.start_threshold_w} W); otherwise anti-wrinkle duration limit is bypassed`,fixVal:+(h.start_threshold_w*2).toFixed(0)},start_threshold_w:{msgKey:"conflict.anti_wrinkle_max.start",msgVars:{max:h.anti_wrinkle_max_power},msgFb:`Must be below Anti-Wrinkle Max Power (${h.anti_wrinkle_max_power} W)`,fixVal:+(h.anti_wrinkle_max_power*.5).toFixed(1)}})},{keys:["pump_stuck_duration","no_update_active_timeout"],check:h=>h.device_type==="pump"&&h.pump_stuck_duration!=null&&h.no_update_active_timeout!=null&&h.no_update_active_timeout<=h.pump_stuck_duration,fieldErrors:h=>({pump_stuck_duration:{msgKey:"conflict.pump_stuck.duration",msgVars:{to:h.no_update_active_timeout},msgFb:`Must be less than No-Update Timeout (${h.no_update_active_timeout} s) so the stuck alarm fires before the watchdog kills the cycle`,fixVal:h.no_update_active_timeout-60},no_update_active_timeout:{msgKey:"conflict.pump_stuck.timeout",msgVars:{ps:h.pump_stuck_duration},msgFb:`Must exceed Pump Stuck Duration (${h.pump_stuck_duration} s) so the stuck alarm fires before the cycle is force-stopped`,fixVal:h.pump_stuck_duration+60}})},{keys:["profile_match_min_duration_ratio","profile_match_max_duration_ratio"],check:h=>h.profile_match_min_duration_ratio!=null&&h.profile_match_max_duration_ratio!=null&&h.profile_match_min_duration_ratio>=h.profile_match_max_duration_ratio,fieldErrors:h=>({profile_match_min_duration_ratio:{msgKey:"conflict.duration_ratio.min",msgVars:{max:h.profile_match_max_duration_ratio},msgFb:`Must be less than Max Duration Ratio (${h.profile_match_max_duration_ratio})`,fixVal:+(h.profile_match_max_duration_ratio*.5).toFixed(2)},profile_match_max_duration_ratio:{msgKey:"conflict.duration_ratio.max",msgVars:{min:h.profile_match_min_duration_ratio},msgFb:`Must be greater than Min Duration Ratio (${h.profile_match_min_duration_ratio})`,fixVal:+(h.profile_match_min_duration_ratio*2).toFixed(2)}})},{keys:["end_energy_threshold","stop_threshold_w","off_delay"],check:h=>h.end_energy_threshold!=null&&h.stop_threshold_w!=null&&h.off_delay!=null&&h.off_delay>0&&h.end_energy_threshold<h.stop_threshold_w*h.off_delay/3600,fieldErrors:h=>({end_energy_threshold:{msgKey:"conflict.end_energy.energy",msgVars:{w:h.stop_threshold_w,d:h.off_delay},msgFb:`Too strict for Stop Threshold (${h.stop_threshold_w} W) over Off Delay (${h.off_delay} s); the cycle can only end through a fallback path`,fixVal:Math.ceil(h.stop_threshold_w*h.off_delay/3600*1e3)/1e3},stop_threshold_w:{msgKey:"conflict.end_energy.stop",msgVars:{e:h.end_energy_threshold,d:h.off_delay},msgFb:`End Energy Threshold (${h.end_energy_threshold} Wh over ${h.off_delay} s) only permits ${+(h.end_energy_threshold*3600/h.off_delay).toFixed(2)} W`,fixVal:Math.floor(h.end_energy_threshold*3600/h.off_delay*10)/10}})}],oe=`
|
|
:host {
|
|
display: block;
|
|
background: var(--primary-background-color);
|
|
color: var(--primary-text-color);
|
|
min-height: 100%;
|
|
font-family: var(--paper-font-body1_-_font-family, Roboto, sans-serif);
|
|
--wd-radius-sm: 4px;
|
|
--wd-radius-md: 8px;
|
|
--wd-radius-lg: 12px;
|
|
--wd-space-xs: 4px;
|
|
--wd-space-sm: 6px;
|
|
--wd-space-md: 10px;
|
|
--wd-space-lg: 16px;
|
|
--wd-space-xl: 24px;
|
|
--wd-font-sm: 0.75em;
|
|
--wd-font-xs: 0.7em;
|
|
--wd-white: #fff;
|
|
--wd-tint-xs: rgba(0,0,0,0.04);
|
|
--wd-tint-sm: rgba(0,0,0,0.08);
|
|
--wd-tint-md: rgba(0,0,0,0.12);
|
|
}
|
|
.wd-header {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 14px 24px;
|
|
background: var(--app-header-background-color, var(--primary-color));
|
|
color: var(--app-header-text-color, #fff);
|
|
position: sticky; top: 0; z-index: 20;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,.25);
|
|
}
|
|
.wd-header h1 { margin: 0; font-size: 1.25em; font-weight: 600; letter-spacing: .01em; }
|
|
.wd-logo { flex-shrink: 0; opacity: .95; }
|
|
.wd-burger { display: none; align-items: center; justify-content: center; background: transparent; border: none; color: inherit; cursor: pointer; padding: 5px; margin: -2px 2px -2px -4px; border-radius: var(--wd-radius-md); flex-shrink: 0; }
|
|
.wd-burger:hover { background: rgba(255,255,255,.16); }
|
|
.wd-gear-btn { background: transparent; border: none; color: inherit; cursor: pointer; padding: 5px; margin-left: 4px; border-radius: var(--wd-radius-md); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; opacity: .8; }
|
|
.wd-gear-btn:hover { background: rgba(255,255,255,.16); opacity: 1; }
|
|
@media (max-width: 870px) { .wd-burger { display: inline-flex; } }
|
|
.wd-burger.wd-burger--force { display: inline-flex; }
|
|
.wd-header .wd-sub { font-size: .72em; opacity: .75; margin-top: 2px; }
|
|
.wd-header .wd-ts { margin-left: auto; font-size: .7em; opacity: .65; white-space: nowrap; }
|
|
.wd-body { max-width: 1160px; margin: 0 auto; padding: 20px 16px 60px; }
|
|
.wd-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
|
.wd-chip {
|
|
padding: 5px 16px; border-radius: 16px;
|
|
border: 1px solid var(--divider-color, rgba(0,0,0,.12));
|
|
background: var(--card-background-color); color: var(--primary-text-color);
|
|
cursor: pointer; font-size: .85em; transition: background .15s, color .15s;
|
|
}
|
|
.wd-chip:hover { background: var(--secondary-background-color); }
|
|
.wd-chip.active { background: var(--primary-color); color: var(--wd-white); border-color: var(--primary-color); }
|
|
.wd-tabs {
|
|
display: flex; gap: 2px;
|
|
border-bottom: 1px solid var(--divider-color, rgba(0,0,0,.1));
|
|
margin-bottom: 20px; overflow-x: auto;
|
|
}
|
|
.wd-tab {
|
|
padding: 10px 22px; border: none; background: transparent;
|
|
color: var(--secondary-text-color); font-size: .8em; font-weight: 600;
|
|
letter-spacing: .07em; text-transform: uppercase; cursor: pointer;
|
|
border-bottom: 2px solid transparent; transition: color .15s, border-color .15s;
|
|
white-space: nowrap;
|
|
}
|
|
.wd-tab:hover { color: var(--primary-text-color); }
|
|
.wd-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
|
|
.wd-pane { display: none; }
|
|
.wd-pane.active { display: block; }
|
|
.wd-card {
|
|
background: var(--card-background-color); border-radius: var(--wd-radius-lg);
|
|
padding: 20px 22px; margin-bottom: 16px;
|
|
box-shadow: var(--ha-card-box-shadow, 0 2px 6px rgba(0,0,0,.08));
|
|
}
|
|
.wd-card-title {
|
|
margin: 0 0 14px; font-size: .72em; font-weight: 600;
|
|
letter-spacing: .09em; text-transform: uppercase;
|
|
color: var(--secondary-text-color);
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.wd-card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; align-items: center; }
|
|
.wd-badge {
|
|
display: inline-flex; align-items: center; gap: 7px;
|
|
padding: 5px 14px; border-radius: 20px; font-size: .85em; font-weight: 500;
|
|
margin-bottom: 18px;
|
|
}
|
|
.wd-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
|
.wd-running .wd-dot { animation: wd-pulse 1.4s ease-in-out infinite; }
|
|
@keyframes wd-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
|
|
.wd-stats {
|
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
gap: 12px; margin-bottom: 18px;
|
|
}
|
|
.wd-stat { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 14px 10px; text-align: center; }
|
|
.wd-stat-val { font-size: 1.5em; font-weight: 600; line-height: 1.1; }
|
|
.wd-stat-lbl { margin-top: 5px; font-size: .72em; color: var(--secondary-text-color); }
|
|
.wd-prog-bg { background: var(--secondary-background-color); border-radius: 6px; height: 10px; overflow: hidden; }
|
|
.wd-prog-fill { height: 100%; background: var(--primary-color); border-radius: 6px; transition: width .6s ease; }
|
|
.wd-prog-row { display: flex; justify-content: space-between; margin-top: 6px; font-size: .78em; color: var(--secondary-text-color); }
|
|
/* D1: compact phase timeline below the progress bar */
|
|
.wd-ptl-wrap { margin-top: 8px; }
|
|
.wd-ptl { position: relative; height: 12px; border-radius: 6px; overflow: hidden; background: var(--secondary-background-color); }
|
|
.wd-ptl-seg { position: absolute; top: 0; bottom: 0; }
|
|
.wd-ptl-seg-lbl { position: absolute; left: 4px; top: 50%; transform: translateY(-50%); font-size: 8px; line-height: 1; color: var(--wd-white); white-space: nowrap; overflow: hidden; max-width: calc(100% - 6px); text-shadow: 0 0 2px rgba(0,0,0,.55); pointer-events: none; }
|
|
.wd-ptl-cursor { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--primary-text-color, #111); box-shadow: 0 0 0 1px rgba(255,255,255,.6); }
|
|
.wd-ptl-cur { margin-top: 5px; font-size: .74em; color: var(--secondary-text-color); }
|
|
.wd-cycle-ctrl { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
|
.wd-table { width: 100%; border-collapse: collapse; font-size: .875em; }
|
|
.wd-table th {
|
|
text-align: left; padding: 8px 12px;
|
|
color: var(--secondary-text-color); font-weight: 600; font-size: .72em;
|
|
letter-spacing: .07em; text-transform: uppercase;
|
|
border-bottom: 1px solid var(--divider-color);
|
|
}
|
|
.wd-table td { padding: 10px 12px; border-bottom: 1px solid var(--divider-color, rgba(0,0,0,.05)); vertical-align: middle; }
|
|
.wd-table tr:last-child td { border-bottom: none; }
|
|
.wd-table tbody tr:hover td { background: var(--secondary-background-color); }
|
|
.wd-table-wrap { overflow-x: auto; }
|
|
.wd-th-sort { cursor: pointer; user-select: none; white-space: nowrap; }
|
|
.wd-th-sort:hover { color: var(--primary-color); }
|
|
.wd-tc-date { white-space: nowrap; color: var(--secondary-text-color); font-size: .82em; }
|
|
.wd-tc-num { white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
|
|
/* Dedicated flags/icons column: keep every badge on one line so review/anomaly/source
|
|
icons never overwrite each other or spill into the profile name. */
|
|
.wd-tc-flags { white-space: nowrap; font-size: .9em; }
|
|
th.wd-tc-flags { color: var(--secondary-text-color); font-weight: 500; }
|
|
.wd-filter-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
.wd-filter-input { flex: 1; min-width: 120px; padding: 5px 10px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size: .84em; }
|
|
.wd-filter-select { padding: 5px 8px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size: .84em; }
|
|
.wd-row-link { cursor: pointer; }
|
|
.wd-pill { display: inline-block; padding: 2px 9px; border-radius: var(--wd-radius-sm); background: var(--secondary-background-color); color: var(--secondary-text-color); font-size: .78em; }
|
|
.wd-tag { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 10px; font-size: .72em; font-weight: 600; vertical-align: middle; background: var(--secondary-background-color); color: var(--secondary-text-color); margin-left: 4px; }
|
|
.wd-btn {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer;
|
|
font-size: .85em; font-weight: 500; transition: opacity .15s;
|
|
white-space: nowrap;
|
|
}
|
|
.wd-btn:hover { opacity: .85; }
|
|
.wd-btn:disabled { opacity: .55; cursor: default; }
|
|
.wd-btn-primary { background: var(--primary-color); color: var(--wd-white); }
|
|
.wd-btn-secondary { background: var(--secondary-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); }
|
|
.wd-btn-danger { background: var(--error-color, #f44336); color: var(--wd-white); }
|
|
.wd-btn-sm { padding: 4px 10px; font-size: .78em; }
|
|
.wd-btn-xs { padding: 2px 8px; font-size: .72em; }
|
|
.wd-spin {
|
|
display: inline-block; width: 13px; height: 13px;
|
|
border: 2px solid currentColor; border-right-color: transparent;
|
|
border-radius: 50%; animation: wd-rot .7s linear infinite; vertical-align: -2px;
|
|
}
|
|
@keyframes wd-rot { to { transform: rotate(360deg); } }
|
|
.wd-field { margin-bottom: 16px; }
|
|
.wd-field label { display: block; font-size: .82em; font-weight: 600; margin-bottom: 5px; color: var(--secondary-text-color); letter-spacing: .04em; text-transform: uppercase; }
|
|
.wd-field input[type=text], .wd-field input[type=number], .wd-field select, .wd-field textarea {
|
|
width: 100%; box-sizing: border-box; padding: 8px 10px; border-radius: 6px;
|
|
border: 1px solid var(--divider-color, rgba(0,0,0,.2));
|
|
background: var(--secondary-background-color);
|
|
color: var(--primary-text-color); font-size: .9em; font-family: inherit;
|
|
}
|
|
.wd-field textarea { min-height: 64px; resize: vertical; }
|
|
.wd-field input[type=checkbox] { width: auto; margin-right: 8px; }
|
|
/* Switch-style boolean settings (replaces the old plain checkbox). Scoped under
|
|
.wd-field-switch so the switch label wins over the generic ".wd-field label"
|
|
(display:block, higher specificity) rule and stays a centered flex row. */
|
|
.wd-field-switch label { margin: 0; }
|
|
.wd-field-switch .wd-switch-row { display: flex; align-items: center; gap: 10px; min-height: 22px; }
|
|
/* Switch label: a flex row [toggle][text], vertically centred. The .wd-field
|
|
.wd-switch-lbl selector is needed to beat .wd-field label (display:block +
|
|
.82em + uppercase, specificity 0,1,1) which otherwise leaks in and both breaks
|
|
the vertical centring (align-items is a no-op on a block) and shrinks the label.
|
|
The bare .wd-switch-lbl covers inline use outside a .wd-field (e.g. adopt). */
|
|
.wd-switch-lbl,
|
|
.wd-field .wd-switch-lbl {
|
|
display: inline-flex; align-items: center; gap: 10px; cursor: pointer;
|
|
min-width: 0; margin: 0; font-size: 1rem; font-weight: 400;
|
|
letter-spacing: normal; text-transform: none;
|
|
}
|
|
/* Match the switch label to every other setting name (see .wd-field label). */
|
|
.wd-switch-text { font-size: .82em; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--secondary-text-color); }
|
|
#wd-settings-form .wd-switch-text, #wd-ml-form .wd-switch-text { color: var(--primary-text-color); }
|
|
/* Normal-case, readable label for switches outside the Settings form (store /
|
|
panel prefs / access control), whose labels are descriptive sentences. */
|
|
.wd-switch-text--plain { font-size: .9em; font-weight: 600; line-height: 1.3; letter-spacing: normal; text-transform: none; color: var(--primary-text-color); }
|
|
.wd-switch { position: relative; display: inline-flex; flex: 0 0 auto; width: 40px; height: 22px; }
|
|
.wd-switch input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
|
|
.wd-switch-slider { position: absolute; inset: 0; border-radius: 22px; background: var(--switch-unchecked-track-color, rgba(120,120,120,.5)); transition: background .2s; }
|
|
.wd-switch-slider::before { content: ""; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px; border-radius: 50%; background: var(--switch-unchecked-button-color, #fafafa); box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .2s; }
|
|
.wd-switch input:checked + .wd-switch-slider { background: var(--switch-checked-track-color, var(--primary-color, #03a9f4)); }
|
|
/* Force a light thumb: some themes set --switch-checked-button-color to the accent,
|
|
which made the knob vanish into the (also-accent) track. A white thumb reads on
|
|
any track colour. */
|
|
.wd-switch input:checked + .wd-switch-slider::before { transform: translateX(18px); background: #fff; }
|
|
.wd-switch input:focus-visible + .wd-switch-slider { outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; }
|
|
/* A11y: a shared keyboard focus ring for all interactive controls (many HA themes
|
|
suppress the UA default outline). */
|
|
.wd-tab:focus-visible, .wd-btn:focus-visible, .wd-chip:focus-visible,
|
|
.wd-sec-btn:focus-visible, .wd-subtab:focus-visible, .wd-mini-tab:focus-visible,
|
|
.wd-devcard:focus-visible, [tabindex]:focus-visible, a:focus-visible, select:focus-visible {
|
|
outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; border-radius: var(--wd-radius-sm);
|
|
}
|
|
/* A11y: honor the user's reduced-motion preference \u2014 drop non-essential animation. */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.wd-dot, .wd-devdot, .wd-rec-active, .wd-spin, .wd-toast { animation: none !important; }
|
|
* { scroll-behavior: auto !important; }
|
|
}
|
|
/* Notifications > Automations: split "New" dropdown + pills. */
|
|
.wd-auto-dd summary { cursor: pointer; list-style: none; }
|
|
.wd-auto-dd summary::-webkit-details-marker { display: none; }
|
|
.wd-auto-dd summary::marker { content: ''; }
|
|
.wd-auto-pill { display: inline-flex; align-items: center; gap: 2px; max-width: 100%; background: var(--secondary-background-color); border: 1px solid var(--divider-color); border-radius: 16px; padding: 3px 4px 3px 12px; }
|
|
.wd-auto-pill-link { text-decoration: none; color: var(--primary-text-color); font-size: .92em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.wd-auto-pill-link:hover { text-decoration: underline; }
|
|
.wd-auto-pill-x { flex: 0 0 auto; border: none; background: transparent; color: var(--secondary-text-color); cursor: pointer; font-size: 1.15em; line-height: 1; padding: 0 5px; border-radius: 50%; }
|
|
.wd-auto-pill-x:hover { background: var(--error-color, #f44336); color: var(--wd-white); }
|
|
.wd-field-hint { font-size: .78em; color: var(--secondary-text-color); margin-top: 4px; }
|
|
/* Entity-pill multi-picker (compact chips + inline add input) */
|
|
.wd-pillbox { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; padding: 5px 6px; min-height: 34px;
|
|
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--card-background-color); }
|
|
.wd-pillbox:focus-within { border-color: var(--primary-color); }
|
|
.wd-pillbox .wd-pill { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; padding: 2px 4px 2px 9px;
|
|
font-size: .82em; line-height: 1.4; border-radius: var(--wd-radius-lg); background: var(--primary-color); color: var(--wd-white);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.wd-pill-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0;
|
|
border: 0; border-radius: 50%; background: rgba(255,255,255,.25); color: var(--wd-white); font-size: 13px; line-height: 1;
|
|
cursor: pointer; flex: none; }
|
|
.wd-pill-x:hover { background: rgba(255,255,255,.45); }
|
|
.wd-pill-add { flex: 1; min-width: 90px; border: 0 !important; background: transparent !important; padding: 3px 4px !important;
|
|
font-size: .88em; color: var(--primary-text-color); outline: none; }
|
|
.wd-form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0 20px; }
|
|
/* Cycle timer list */
|
|
.wd-timerlist { display: flex; flex-direction: column; gap: 8px; }
|
|
.wd-timer-row { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px;
|
|
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--card-background-color); }
|
|
.wd-timer-top { display: flex; align-items: center; gap: 8px; }
|
|
.wd-timer-top input[type="number"] { width: 70px; flex: 0 0 auto; }
|
|
.wd-timer-top textarea { flex: 1 1 auto; min-width: 0; box-sizing: border-box; resize: vertical; min-height: 32px; height: 34px; }
|
|
.wd-timer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
.wd-timer-footer .wd-switch-lbl { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
|
.wd-timer-add { align-self: flex-start; margin-top: 4px; }
|
|
/* Roomier Settings layout (scoped so modals keep their compact spacing) */
|
|
#wd-settings-form .wd-form-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 18px 28px; align-items: start; }
|
|
#wd-settings-form .wd-field { margin-bottom: 0; background: var(--secondary-background-color); border-radius: 10px; padding: 12px 14px; }
|
|
#wd-settings-form .wd-field label { color: var(--primary-text-color); }
|
|
#wd-settings-form .wd-field input[type=text], #wd-settings-form .wd-field input[type=number], #wd-settings-form .wd-field select, #wd-settings-form .wd-field textarea { background: var(--card-background-color); padding: 9px 11px; }
|
|
#wd-settings-form .wd-subhead { margin: 22px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--divider-color); }
|
|
.wd-sec-intro { font-size: .85em; color: var(--secondary-text-color); margin: 0 0 16px; line-height: 1.5; }
|
|
.wd-label-row { display: flex; align-items: center; }
|
|
.wd-tip {
|
|
display: inline-flex; width: 15px; height: 15px; border-radius: 50%;
|
|
align-items: center; justify-content: center; font-size: 10px; font-style: italic;
|
|
background: var(--secondary-background-color); color: var(--secondary-text-color);
|
|
cursor: help; margin-left: 6px; position: relative; border: 1px solid var(--divider-color);
|
|
font-weight: 700; text-transform: none; letter-spacing: normal;
|
|
}
|
|
.wd-tip-pop {
|
|
display: none; position: absolute; bottom: 150%; left: 50%; transform: translateX(-50%);
|
|
width: 264px; background: var(--card-background-color); color: var(--primary-text-color);
|
|
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 10px 12px;
|
|
box-shadow: 0 4px 18px rgba(0,0,0,.35); z-index: 60;
|
|
text-align: left; font-weight: 400; text-transform: none; letter-spacing: normal;
|
|
}
|
|
.wd-tip:hover .wd-tip-pop { display: block; }
|
|
.wd-tip-txt { font-size: 12px; line-height: 1.5; display: block; }
|
|
.wd-dg { display: block; width: 100%; height: auto; margin-bottom: 8px; background: var(--secondary-background-color); border-radius: 6px; }
|
|
.wd-dg .ln { fill: none; stroke: var(--primary-color); stroke-width: 2.5; }
|
|
.wd-dg .ln2 { fill: none; stroke: var(--secondary-text-color); stroke-width: 1.5; opacity: .7; }
|
|
.wd-dg .ok { fill: none; stroke: var(--success-color, #4caf50); stroke-width: 2; }
|
|
.wd-dg .bad { fill: none; stroke: var(--error-color, #f44336); stroke-width: 2; }
|
|
.wd-dg .dash { stroke-dasharray: 4 3; }
|
|
.wd-dg .fz { fill: var(--primary-color); opacity: .18; }
|
|
.wd-dg .fw { fill: var(--warning-color, #ff9800); opacity: .2; }
|
|
.wd-dg .fb { fill: var(--error-color, #f44336); opacity: .14; }
|
|
.wd-dg text { fill: var(--secondary-text-color); font-size: 9px; }
|
|
.wd-dg .ax { stroke: var(--divider-color); stroke-width: 1; }
|
|
.wd-sug {
|
|
display: flex; align-items: center; gap: 8px; margin-top: 6px;
|
|
padding: 6px 10px; border-radius: var(--wd-radius-md); font-size: .82em;
|
|
background: rgba(255,152,0,.10); border: 1px solid rgba(255,152,0,.40);
|
|
box-sizing: border-box; flex-wrap: wrap;
|
|
}
|
|
.wd-sug.wd-sug-split { flex-direction: column; align-items: stretch; gap: 0; padding: 0; overflow: hidden; }
|
|
.wd-sug-opt { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 10px; }
|
|
.wd-sug-opt:not(:last-child) { border-bottom: 1px solid rgba(255,152,0,.28); }
|
|
.wd-sug-chip {
|
|
display: inline-flex; align-items: center; gap: 3px; flex-shrink: 0;
|
|
font-size: .75em; font-weight: 700; letter-spacing: .04em;
|
|
padding: 2px 7px; border-radius: 10px; white-space: nowrap;
|
|
}
|
|
.wd-sug-chip-obs { background: rgba(255,152,0,.22); }
|
|
.wd-sug-chip-cal { background: rgba(33,150,243,.18); }
|
|
.wd-sug-val { font-weight: 700; flex-shrink: 0; }
|
|
.wd-sug-impact-line { flex-basis: 100%; font-size: .86em; opacity: .70; font-style: italic; margin-top: 2px; }
|
|
.wd-sug-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.wd-sug-sep { display: none; }
|
|
.wd-sug-impact { display: none; }
|
|
.wd-sug-use { border: none; background: var(--warning-color, #ff9800); color: var(--wd-white); border-radius: var(--wd-radius-sm); padding: 2px 8px; font-size: .92em; cursor: pointer; flex-shrink: 0; }
|
|
.wd-sug-lock { border: none; background: transparent; color: var(--secondary-text-color); border-radius: var(--wd-radius-sm); padding: 2px 6px; font-size: .92em; cursor: pointer; flex-shrink: 0; opacity: .65; }
|
|
.wd-sug-lock:hover { opacity: 1; background: rgba(255,152,0,.15); }
|
|
.wd-conflict-err { display: flex; flex-direction: column; gap: 4px; margin-top: 5px; }
|
|
.wd-conflict-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: .8em; color: var(--error-color, #b71c1c); padding: 5px 9px; border-left: 3px solid var(--error-color, #b71c1c); background: rgba(183,28,28,.07); border-radius: 0 5px 5px 0; }
|
|
.wd-conflict-fix { border: 1px solid var(--error-color, #b71c1c); background: none; color: var(--error-color, #b71c1c); border-radius: var(--wd-radius-sm); padding: 1px 7px; font-size: .92em; cursor: pointer; white-space: nowrap; flex: none; }
|
|
.wd-conflict-fix:hover { background: var(--error-color, #b71c1c); color: var(--wd-white); }
|
|
.wd-conflict-sug-note { font-style: italic; opacity: 0.85; flex: none; }
|
|
#wd-settings-form .wd-field.wd-has-conflict { outline: 2px solid var(--error-color, #b71c1c); outline-offset: -1px; }
|
|
.wd-rev-sub { display: flex; align-items: center; gap: 6px; margin: 14px 0 6px; font-size: .85em; font-weight: 600; color: var(--primary-text-color); }
|
|
.wd-rev-tags { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
|
.wd-rev-tag { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--divider-color); font-size: .85em; cursor: pointer; }
|
|
.wd-rev-tag input { margin: 0; }
|
|
.wd-rev-notes { width: 100%; box-sizing: border-box; background: var(--card-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 9px 11px; font: inherit; resize: vertical; }
|
|
.wd-sug-banner {
|
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
|
padding: 12px 16px; border-radius: 10px; margin-bottom: 16px;
|
|
background: rgba(255,152,0,.12); border: 1px solid rgba(255,152,0,.4);
|
|
}
|
|
.wd-subhead { font-size: .76em; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--primary-color); margin: 8px 0 10px; grid-column: 1 / -1; }
|
|
.wd-section-nav { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 0; }
|
|
.wd-sec-btn {
|
|
padding: 5px 14px; border-radius: 14px; border: 1px solid var(--divider-color);
|
|
background: transparent; color: var(--secondary-text-color); font-size: .8em; cursor: pointer; transition: background .15s;
|
|
}
|
|
.wd-sec-btn.active { background: var(--primary-color); color: var(--wd-white); border-color: var(--primary-color); }
|
|
.wd-level-toggle { display: inline-flex; gap: 4px; }
|
|
.wd-sec-btn { position: relative; }
|
|
/* Basic/Advanced slide toggle */
|
|
.wd-mode-switch { display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font-size: .82em; user-select: none; white-space: nowrap; }
|
|
.wd-mode-switch-label { color: var(--secondary-text-color); transition: color .15s; }
|
|
.wd-mode-switch-label.active { color: var(--primary-color); font-weight: 600; }
|
|
.wd-toggle-track { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
|
|
.wd-toggle-track input { opacity: 0; width: 0; height: 0; position: absolute; }
|
|
.wd-toggle-knob { position: absolute; inset: 0; border-radius: 20px; background: var(--divider-color); transition: background .2s; }
|
|
.wd-toggle-knob::after { content: ''; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .2s; }
|
|
.wd-toggle-track input:checked + .wd-toggle-knob { background: var(--primary-color); }
|
|
.wd-toggle-track input:checked + .wd-toggle-knob::after { transform: translateX(16px); }
|
|
.wd-sec-sug-dot { position: absolute; top: 2px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--warning-color, #ff9800); display: inline-block; pointer-events: none; }
|
|
.wd-sec-conf-dot { position: absolute; top: 2px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--error-color, #b71c1c); display: inline-block; pointer-events: none; }
|
|
.wd-subtabs { display: flex; gap: 2px; border-bottom: 1px solid var(--divider-color); margin-bottom: 18px; flex-wrap: wrap; }
|
|
.wd-subtab { padding: 8px 18px; border: none; background: transparent; color: var(--secondary-text-color); font-size: .8em; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; transition: color .15s; }
|
|
.wd-subtab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
|
|
.wd-profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
|
.wd-profile-card {
|
|
background: var(--card-background-color); border-radius: 10px; padding: 16px;
|
|
border: 1px solid var(--divider-color, rgba(0,0,0,.08)); cursor: pointer; transition: border-color .15s, transform .1s;
|
|
}
|
|
.wd-profile-card:hover { border-color: var(--primary-color); transform: translateY(-1px); }
|
|
button.wd-attn-card, button.wd-profile-card { appearance: none; font: inherit; text-align: left; width: 100%; }
|
|
button.wd-profile-card { display: block; }
|
|
.wd-prof-wrap { position: relative; }
|
|
.wd-profile-name { font-weight: 600; font-size: 1em; margin-bottom: 6px; }
|
|
.wd-profile-meta { font-size: .8em; color: var(--secondary-text-color); }
|
|
/* Profile-card header: name on the left, mini power-signature sparkline on the
|
|
right, both on one line and vertically aligned regardless of badge count. */
|
|
.wd-profile-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
.wd-profile-head .wd-profile-name { margin: 0; flex: 1 1 auto; min-width: 0; }
|
|
/* Status-pill row: wraps cleanly on its own line below the name, and every pill
|
|
(health / trend / warm-up / imported) shares one uniform compact pill shape. */
|
|
.wd-profile-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px; }
|
|
.wd-profile-badges .wd-badge { margin: 0; padding: 2px 9px; border-radius: 999px; font-size: .72em; font-weight: 600; gap: 4px; }
|
|
/* D2: mini duration sparkline on profile cards */
|
|
.wd-prof-spark { width: 64px; height: 20px; display: block; flex-shrink: 0; }
|
|
/* Community Store */
|
|
.wd-store-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; font-size: .85em; }
|
|
.wd-crumb { background: none; border: none; color: var(--primary-color); cursor: pointer; padding: 2px 4px; font: inherit; }
|
|
.wd-crumb:hover { text-decoration: underline; }
|
|
.wd-crumb.active { color: var(--primary-text-color); font-weight: 700; cursor: default; }
|
|
.wd-crumb-sep { color: var(--secondary-text-color); }
|
|
.wd-store-search { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
|
|
.wd-store-search input { flex: 1; min-width: 180px; padding: 8px 11px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); font-size: .9em; }
|
|
.wd-store-list { display: flex; flex-direction: column; gap: 8px; }
|
|
/* Browse rows (appliances / programs): tappable list rows with a hover affordance
|
|
and a chevron, instead of flat cards. */
|
|
.wd-store-rows { display: flex; flex-direction: column; gap: 6px; }
|
|
.wd-store-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; text-align: left; width: 100%; cursor: pointer; padding: 11px 14px; background: var(--secondary-background-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); color: var(--primary-text-color); transition: border-color .12s ease, background .12s ease; }
|
|
.wd-store-row:hover { border-color: var(--primary-color); background: var(--card-background-color); }
|
|
.wd-store-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
.wd-store-row-title { font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.wd-store-row-sub { display: flex; align-items: center; gap: 10px; font-size: .8em; color: var(--secondary-text-color); }
|
|
.wd-store-chip { background: var(--accent-dim, rgba(0,180,216,.14)); color: var(--primary-color); border-radius: 999px; padding: 1px 8px; font-size: .92em; text-transform: capitalize; }
|
|
.wd-store-fav { color: var(--warning-color, #f0b429); }
|
|
.wd-store-row-arrow { color: var(--secondary-text-color); font-size: 1.3em; flex-shrink: 0; }
|
|
.wd-store-cycle-top { display: flex; align-items: center; gap: 12px; }
|
|
.wd-store-cycle-stats { flex: 1; min-width: 0; }
|
|
.wd-store-spark { width: 120px; height: 36px; flex-shrink: 0; display: block; background: var(--secondary-background-color); border-radius: 6px; }
|
|
.wd-store-conn { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
|
.wd-store-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
|
.wd-tag-pending { background: rgba(56,139,253,.18); color: var(--info-color, #58a6ff); }
|
|
.wd-tag-approved { background: rgba(63,185,80,.18); color: var(--success-color, #3fb950); }
|
|
.wd-store-picker-detail { margin-top: 6px; font-size: .85em; color: var(--secondary-text-color); display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
|
|
.wd-store-picker-actions { display: flex; align-items: center; gap: 8px; flex-basis: 100%; margin-top: 4px; flex-wrap: wrap; }
|
|
.wd-star-row { display: inline-flex; gap: 2px; }
|
|
.wd-star-btn { background: none; border: none; cursor: pointer; color: var(--warning-color, #f0b429); font-size: 1.1em; padding: 0 1px; line-height: 1; }
|
|
.wd-star-btn:hover { transform: scale(1.15); }
|
|
/* Share-device selection tree (profile -> its reference cycles). */
|
|
.wd-sd-tree { display: flex; flex-direction: column; gap: 8px; max-height: 44vh; overflow-y: auto; margin-bottom: 16px; }
|
|
.wd-sd-group { border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--secondary-background-color); overflow: hidden; }
|
|
.wd-sd-prof { display: flex; align-items: center; gap: 8px; padding: 9px 12px; cursor: pointer; font-weight: 600; }
|
|
.wd-sd-prof-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.wd-sd-count { font-size: .8em; font-weight: 400; color: var(--secondary-text-color); }
|
|
.wd-sd-cycles { display: flex; flex-direction: column; border-top: 1px solid var(--divider-color); }
|
|
.wd-sd-cyc { display: flex; align-items: center; gap: 8px; padding: 6px 12px 6px 28px; cursor: pointer; font-size: .9em; }
|
|
.wd-sd-cyc:hover, .wd-sd-prof:hover { background: var(--card-background-color); }
|
|
.wd-sd-cyc-meta { color: var(--secondary-text-color); }
|
|
.wd-sd-phase { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-top: 1px solid var(--divider-color); cursor: pointer; font-size: .85em; color: var(--secondary-text-color); }
|
|
.wd-sd-phase:hover { background: var(--card-background-color); }
|
|
.wd-sd-settings { display: flex; align-items: center; gap: 8px; padding: 10px 2px 2px; cursor: pointer; font-size: .9em; }
|
|
.wd-sd-consent { display: flex; align-items: flex-start; gap: 8px; padding: 10px 2px 2px; cursor: pointer; font-size: .9em; }
|
|
.wd-sd-group-nocyc { opacity: .65; }
|
|
.wd-sd-prof-disabled { display: flex; align-items: baseline; gap: 8px; padding: 9px 12px; font-weight: 600; flex-wrap: wrap; }
|
|
.wd-sd-nocyc-note { font-size: .8em; font-weight: 400; color: var(--secondary-text-color); }
|
|
.wd-share-guide { margin-bottom: 10px; border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); overflow: hidden; }
|
|
.wd-share-guide > summary { padding: 8px 12px; cursor: pointer; font-size: .85em; font-weight: 600; color: var(--secondary-text-color); list-style: none; }
|
|
.wd-share-guide > summary::-webkit-details-marker { display: none; }
|
|
.wd-share-guide > summary::before { content: '\u25B6 '; font-size: .7em; }
|
|
.wd-share-guide[open] > summary::before { content: '\u25BC '; }
|
|
.wd-share-guide-list { margin: 0; padding: 4px 12px 10px 28px; font-size: .85em; color: var(--secondary-text-color); line-height: 1.5; }
|
|
.wd-share-guide-list li { margin-bottom: 4px; }
|
|
.wd-linkbtn { background: none; border: none; padding: 0; color: var(--primary-color); cursor: pointer; font: inherit; text-decoration: underline; }
|
|
.wd-gear-body { margin-top: 12px; }
|
|
.wd-empty { text-align: center; padding: 48px 24px; color: var(--secondary-text-color); }
|
|
.wd-empty .wd-icon { font-size: 3em; margin-bottom: 10px; }
|
|
.wd-error-state { display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin-bottom: 10px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--divider-color); color: var(--error-color, #b71c1c); font-size: .9em; }
|
|
.wd-info { font-size: .9em; color: var(--secondary-text-color); line-height: 1.6; margin: 0; }
|
|
.wd-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
|
.wd-modal { background: var(--card-background-color); border-radius: var(--wd-radius-lg); padding: 24px; max-width: 480px; width: calc(100% - 32px); max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.3); }
|
|
.wd-modal-lg { max-width: 880px; }
|
|
.wd-modal h2 { margin: 0 0 16px; font-size: 1.1em; display: flex; align-items: center; gap: 10px; }
|
|
.wd-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; flex-wrap: wrap; }
|
|
.wd-canvas-wrap { margin: 10px 0; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 6px; }
|
|
.wd-canvas-wrap canvas { width: 100%; height: 240px; display: block; touch-action: none; cursor: crosshair; }
|
|
.wd-mode-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
.wd-mini-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; flex-wrap: wrap; }
|
|
.wd-mini-tab { padding: 7px 16px; border: none; background: transparent; color: var(--secondary-text-color); font-size: .82em; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; }
|
|
.wd-mini-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
|
|
.wd-kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin: 4px 0 14px; }
|
|
.wd-kv-item { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 10px; text-align: center; }
|
|
.wd-kv-val { font-size: 1.25em; font-weight: 700; }
|
|
.wd-kv-lbl { font-size: .7em; color: var(--secondary-text-color); margin-top: 3px; }
|
|
.wd-seg-row, .wd-phase-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
|
.wd-swatch { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; display: inline-block; }
|
|
.wd-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 200; padding: 10px 20px; border-radius: var(--wd-radius-md); font-size: .9em; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,.25); animation: wd-toast-in .2s ease; }
|
|
@keyframes wd-toast-in { from { opacity: 0; transform: translateX(-50%) translateY(10px); } }
|
|
.wd-toast-success { background: var(--success-color, #4caf50); color: var(--wd-white); }
|
|
.wd-toast-error { background: var(--error-color, #f44336); color: var(--wd-white); }
|
|
.wd-toast-info { background: var(--info-color, #2196f3); color: var(--wd-white); }
|
|
/* D4: undo toast \u2014 action button + row layout */
|
|
.wd-toast { display: flex; align-items: center; gap: 14px; }
|
|
.wd-toast-action { background: rgba(255,255,255,.22); color: inherit; border: none; border-radius: 6px; padding: 5px 12px; font: inherit; font-weight: 700; cursor: pointer; text-transform: uppercase; letter-spacing: .04em; font-size: .85em; }
|
|
.wd-toast-action:hover { background: rgba(255,255,255,.34); }
|
|
/* D7: "changed since last save" marker beside a settings field label */
|
|
.wd-chg-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--info-color, #2196f3); margin-left: 6px; flex-shrink: 0; cursor: help; }
|
|
.wd-diag-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 16px; }
|
|
.wd-diag-stat { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 12px; text-align: center; }
|
|
.wd-diag-val { font-size: 1.6em; font-weight: 700; }
|
|
.wd-diag-lbl { font-size: .72em; color: var(--secondary-text-color); margin-top: 4px; }
|
|
.wd-feedback-item { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--divider-color); }
|
|
.wd-feedback-item:last-child { border-bottom: none; }
|
|
.wd-feedback-body { flex: 1; }
|
|
.wd-feedback-profile { font-weight: 600; }
|
|
.wd-feedback-meta { font-size: .78em; color: var(--secondary-text-color); }
|
|
.wd-rec-status { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
|
.wd-rec-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
|
.wd-rec-active { background: var(--error-color, #f44336); animation: wd-pulse 1s ease-in-out infinite; }
|
|
.wd-rec-ready { background: var(--success-color, #4caf50); }
|
|
.wd-rec-idle { background: var(--disabled-color, #bdbdbd); }
|
|
/* Graph hover tooltip (follows the cursor) */
|
|
.wd-gtip { position: fixed; z-index: 300; display: none; pointer-events: none; background: var(--card-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 7px 10px; font-size: 12px; line-height: 1.5; box-shadow: 0 4px 16px rgba(0,0,0,.4); white-space: nowrap; }
|
|
.wd-gtip b { font-weight: 700; }
|
|
/* Status chart legend + toggles */
|
|
.wd-leg { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; font-size: .8em; color: var(--secondary-text-color); }
|
|
.wd-leg-i { display: inline-flex; align-items: center; gap: 6px; }
|
|
.wd-leg-i input { margin: 0 2px 0 0; width: auto; }
|
|
.wd-leg-sw { width: 16px; height: 3px; border-radius: 2px; display: inline-block; }
|
|
/* Status program selector */
|
|
.wd-prog-ctl { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
.wd-prog-ctl label { font-size: .72em; text-transform: uppercase; letter-spacing: .08em; color: var(--secondary-text-color); margin: 0; }
|
|
.wd-prog-ctl select { padding: 8px 11px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); min-width: 200px; font-size: .9em; }
|
|
.wd-prog-tag { font-size: .78em; padding: 3px 9px; border-radius: 10px; }
|
|
.wd-prog-tag.auto { background: rgba(76,175,80,.18); color: var(--success-color, #4caf50); }
|
|
.wd-prog-tag.manual { background: rgba(255,152,0,.2); color: var(--warning-color, #ff9800); }
|
|
/* Status-rich device selector */
|
|
.wd-devbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
|
|
.wd-devcard { display: flex; align-items: center; gap: 9px; padding: 9px 13px; border-radius: var(--wd-radius-lg); border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); cursor: pointer; font-size: .9em; }
|
|
.wd-devcard.active { border-color: var(--primary-color); box-shadow: 0 0 0 1px var(--primary-color); }
|
|
.wd-devadd { border-style: dashed; color: var(--secondary-text-color); }
|
|
.wd-devadd:hover { border-color: var(--primary-color); color: var(--primary-color); }
|
|
.wd-devdot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
.wd-devdot.run { animation: wd-pulse 1.4s ease-in-out infinite; }
|
|
.wd-devname { font-weight: 600; }
|
|
.wd-devsub { font-size: .72em; color: var(--secondary-text-color); }
|
|
.wd-dbadge { font-size: .72em; padding: 1px 7px; border-radius: 10px; background: var(--secondary-background-color); }
|
|
.wd-dbadge.rec { background: var(--error-color, #f44336); color: var(--wd-white); }
|
|
.wd-dbadge.sug { background: rgba(255,152,0,.22); }
|
|
.wd-dbadge.fb { background: rgba(33,150,243,.22); }
|
|
.wd-dbadge.conf { background: rgba(183,28,28,.18); color: var(--error-color, #b71c1c); }
|
|
/* Attention cards (status dashboard) */
|
|
.wd-attn { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; margin-bottom: 16px; }
|
|
.wd-attn-card { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; background: var(--card-background-color); border: 1px solid var(--divider-color); cursor: pointer; transition: border-color .15s; }
|
|
.wd-attn-card:hover { border-color: var(--primary-color); }
|
|
.wd-attn-icon { font-size: 1.5em; line-height: 1; }
|
|
.wd-attn-body { flex: 1; min-width: 0; }
|
|
.wd-attn-title { font-weight: 600; }
|
|
.wd-attn-sub { font-size: .76em; color: var(--secondary-text-color); }
|
|
/* F1 first-run onboarding card (Status power-chart area) */
|
|
.wd-onboard { margin-top: 12px; padding: 16px; border-radius: 10px; border: 1px dashed var(--divider-color); background: var(--secondary-background-color); }
|
|
.wd-onboard .wd-card-title { margin-top: 0; }
|
|
.wd-onboard-skip { font-size: .8em; color: var(--secondary-text-color); text-decoration: underline; cursor: pointer; }
|
|
.wd-onboard-skip:hover { color: var(--primary-color); }
|
|
/* Setup card (replaces getting-started card, phases 0-3) and phase-4 chip */
|
|
.wd-setup-card { margin-top: 12px; padding: 16px; border-radius: 10px; border: 1px solid var(--primary-color, #03a9f4); background: color-mix(in srgb, var(--primary-color, #03a9f4) 8%, var(--card-background-color, var(--ha-card-background))); }
|
|
.wd-setup-card .wd-card-title { margin-top: 0; }
|
|
.wd-setup-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 20px; font-size: .82em; font-weight: 600; cursor: pointer; border: 1px solid var(--divider-color); background: var(--secondary-background-color); margin-top: 12px; }
|
|
.wd-setup-chip--healthy { border-color: var(--success-color, #4caf50); background: color-mix(in srgb, var(--success-color, #4caf50) 10%, var(--card-background-color, transparent)); }
|
|
.wd-setup-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.wd-setup-dot--green { background: var(--success-color, #4caf50); }
|
|
.wd-link { background: none; border: none; padding: 0; color: var(--primary-color); cursor: pointer; font: inherit; text-decoration: underline; display: inline; }
|
|
.wd-link:hover { opacity: .8; }
|
|
/* Logs page */
|
|
.wd-logbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
|
.wd-logs { font-family: monospace; font-size: .76em; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 10px; height: 56vh; min-height: 140px; overflow: auto; resize: vertical; }
|
|
#wd-log-lines-page { height: auto; min-height: 200px; resize: none; }
|
|
/* Grouped stat blocks (profile overview) */
|
|
.wd-sg-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin: 4px 0 16px; }
|
|
.wd-sg { background: var(--secondary-background-color); border-radius: 10px; padding: 14px; }
|
|
.wd-sg-h { font-size: .7em; text-transform: uppercase; letter-spacing: .08em; color: var(--secondary-text-color); margin-bottom: 6px; }
|
|
.wd-sg-main { font-size: 1.55em; font-weight: 700; line-height: 1.1; }
|
|
.wd-sg-main span { font-size: .5em; font-weight: 400; color: var(--secondary-text-color); margin-left: 4px; }
|
|
.wd-sg-sub { font-size: .78em; color: var(--secondary-text-color); margin-top: 5px; line-height: 1.5; }
|
|
.wd-logline { padding: 2px 0; border-bottom: 1px solid var(--divider-color); white-space: pre-wrap; word-break: break-word; }
|
|
.wd-logline:last-child { border-bottom: none; }
|
|
/* Entity combobox */
|
|
.wd-combo { position: relative; width: 100%; }
|
|
.wd-combo-drop { position: absolute; top: 100%; left: 0; right: 0; z-index: 60;
|
|
background: var(--card-background-color,#fff); border: 1px solid var(--divider-color);
|
|
border-radius: 6px; box-shadow: 0 4px 14px rgba(0,0,0,.18);
|
|
max-height: 220px; overflow-y: auto; margin-top: 3px; }
|
|
.wd-combo-item { padding: 7px 12px; cursor: pointer; font-size: .86em; white-space: nowrap;
|
|
overflow: hidden; text-overflow: ellipsis; }
|
|
.wd-combo-item:hover, .wd-combo-item.kbd { background: var(--secondary-background-color); }
|
|
.wd-combo-row { display: flex; gap: 6px; align-items: center; }
|
|
.wd-combo-row .wd-combo { flex: 1 1 auto; }
|
|
.wd-addbtn { flex: 0 0 auto; width: 34px; height: 34px; border-radius: var(--wd-radius-md); border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); font-size: 1.25em; line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
|
.wd-addbtn:hover { background: var(--primary-color); color: #fff; border-color: var(--primary-color); }
|
|
.wd-loglvl { font-weight: 700; margin-right: 6px; }
|
|
.wd-logcomp { display: inline-block; font-size: .72em; color: var(--secondary-text-color); background: var(--secondary-background-color); border-radius: 4px; padding: 0 5px; margin-right: 6px; }
|
|
.wd-logdev { display: inline-block; font-size: .72em; color: var(--primary-color); margin-right: 6px; }
|
|
.wd-logts { color: var(--secondary-text-color); margin-right: 6px; }
|
|
.wd-lvl-ERROR, .wd-lvl-CRITICAL { color: var(--error-color, #f44336); }
|
|
.wd-lvl-WARNING { color: var(--warning-color, #ff9800); }
|
|
.wd-lvl-INFO { color: var(--info-color, #2196f3); }
|
|
.wd-lvl-DEBUG { color: var(--secondary-text-color); }
|
|
/* Compact cycle list */
|
|
.wd-clist { display: flex; flex-direction: column; }
|
|
.wd-crow { display: flex; align-items: center; gap: 10px; padding: 9px 6px; border-bottom: 1px solid var(--divider-color); cursor: pointer; }
|
|
.wd-crow:hover { background: var(--secondary-background-color); }
|
|
.wd-crow:last-child { border-bottom: none; }
|
|
.wd-cmain { flex: 1; min-width: 0; overflow: hidden; }
|
|
.wd-cprog { font-weight: 600; }
|
|
.wd-cdate { font-size: .74em; color: var(--secondary-text-color); }
|
|
.wd-cmeta { text-align: right; font-size: .76em; color: var(--secondary-text-color); white-space: nowrap; }
|
|
/* Responsive / touch (portrait, phones, side panel) */
|
|
@media (max-width: 680px) {
|
|
.wd-body { padding: 12px 10px 64px; }
|
|
.wd-card { padding: 14px; margin-bottom: 12px; }
|
|
.wd-form-grid { grid-template-columns: 1fr; }
|
|
.wd-stats { grid-template-columns: repeat(2, 1fr); }
|
|
.wd-kv { grid-template-columns: repeat(2, 1fr); }
|
|
.wd-tab { padding: 9px 13px; }
|
|
.wd-modal { padding: 16px; width: calc(100% - 18px); }
|
|
.wd-modal-lg { max-width: 100%; }
|
|
.wd-canvas-wrap canvas { height: 200px; }
|
|
.wd-header { padding: 12px 14px; }
|
|
.wd-btn { padding: 9px 15px; } /* larger touch targets */
|
|
.wd-tip-pop { width: 210px; }
|
|
.wd-pg-lane-lbl { flex: 0 1 120px; max-width: 120px; }
|
|
#wd-settings-form .wd-form-grid { grid-template-columns: 1fr; gap: 12px 0; }
|
|
}
|
|
/* Log drawer */
|
|
.wd-shell { display: flex; flex-direction: column; min-height: 100%; }
|
|
.wd-content-row { display: flex; flex: 1; overflow: hidden; min-height: 0; }
|
|
.wd-main { flex: 1; overflow-y: auto; min-width: 0; }
|
|
.wd-log-drawer {
|
|
position: relative; width: 0; overflow: hidden;
|
|
transition: width .28s cubic-bezier(.4,0,.2,1);
|
|
border-left: 1px solid var(--divider-color);
|
|
display: flex; flex-direction: column;
|
|
background: var(--primary-background-color);
|
|
}
|
|
.wd-log-drawer.open { width: 380px; }
|
|
.wd-log-resize {
|
|
position: absolute; left: 0; top: 0; bottom: 0; width: 6px; cursor: ew-resize; z-index: 2;
|
|
transition: background .15s;
|
|
}
|
|
.wd-log-resize:hover, .wd-log-resize.dragging { background: var(--primary-color, #03a9f4); opacity: .35; }
|
|
.wd-log-drawer-head {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 10px 14px; border-bottom: 1px solid var(--divider-color);
|
|
font-weight: 600; font-size: .9em; flex-shrink: 0; white-space: nowrap;
|
|
}
|
|
.wd-log-drawer-body { flex: 1; overflow-y: auto; padding: 10px 14px; min-width: 0; }
|
|
.wd-log-close-btn {
|
|
background: none; border: none; cursor: pointer; color: inherit; opacity: .65;
|
|
padding: 3px 6px; border-radius: var(--wd-radius-sm); font-size: 1.1em; line-height: 1;
|
|
}
|
|
.wd-log-close-btn:hover { opacity: 1; background: var(--secondary-background-color); }
|
|
.wd-gear-btn.log-active { background: rgba(255,255,255,.22); }
|
|
@media (max-width: 680px) {
|
|
.wd-log-drawer.open { width: 100vw !important; position: fixed; top: 0; right: 0; bottom: 0; z-index: 30; border-left: none; }
|
|
.wd-log-resize { display: none; }
|
|
}
|
|
.wd-pg-delta-up { color: var(--success-color, #4caf50); font-weight: 700; }
|
|
.wd-pg-delta-down { color: var(--error-color, #f44336); font-weight: 700; }
|
|
.wd-pg-delta-flat { color: var(--secondary-text-color); }
|
|
/* F3: Unified Playground */
|
|
.wd-pg-canvas-wrap { position: relative; width: 100%; }
|
|
#wd-pg-canvas { display: block; width: 100%; height: 330px; cursor: crosshair; border-radius: 6px; background: var(--secondary-background-color); margin: 10px 0 0; }
|
|
.wd-pg-strip { display: flex; align-items: center; gap: 10px; padding: 8px 2px; font-size: .88em; font-variant-numeric: tabular-nums; flex-wrap: wrap; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.2)); margin-bottom: 12px; }
|
|
.wd-pg-strip-state { padding: 2px 10px; border-radius: 20px; font-weight: 700; font-size: .83em; white-space: nowrap; }
|
|
.wd-pg-strip-pbar { display: inline-flex; align-items: center; gap: 5px; }
|
|
.wd-pg-strip-track { width: 60px; height: 6px; background: var(--secondary-background-color); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
|
.wd-pg-strip-fill { height: 100%; background: var(--primary-color); border-radius: 3px; transition: width .15s; }
|
|
.wd-pg-params { display: flex; flex-direction: column; gap: 2px; }
|
|
.wd-pg-param-row { display: flex; align-items: center; gap: 6px; }
|
|
.wd-pg-param-lbl { flex: 1; font-size: .83em; color: var(--secondary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.wd-pg-param-inp { width: 76px; flex: 0 0 76px; }
|
|
.wd-pg-param-drag { font-size: .75em; color: var(--primary-color); cursor: default; flex: 0 0 12px; }
|
|
.wd-pg-score-bar-row { display: flex; align-items: center; gap: 6px; font-size: .82em; margin: 2px 0; }
|
|
.wd-pg-score-bar-lbl { flex: 0 0 80px; color: var(--secondary-text-color); }
|
|
.wd-pg-score-bar-track { flex: 1; height: 6px; background: var(--secondary-background-color); border-radius: 3px; overflow: hidden; }
|
|
.wd-pg-score-bar-fill { height: 100%; border-radius: 3px; }
|
|
.wd-pg-score-bar-val { flex: 0 0 42px; text-align: right; font-variant-numeric: tabular-nums; color: var(--secondary-text-color); }
|
|
.wd-pg-cand-row { display: flex; align-items: center; gap: 6px; font-size: .82em; margin: 3px 0; }
|
|
.wd-pg-cand-name { flex: 0 0 110px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.wd-pg-cand-track { flex: 1; height: 7px; background: var(--secondary-background-color); border-radius: var(--wd-radius-sm); overflow: hidden; }
|
|
.wd-pg-cand-fill { height: 100%; border-radius: var(--wd-radius-sm); }
|
|
.wd-pg-cand-pct { flex: 0 0 34px; text-align: right; color: var(--secondary-text-color); }
|
|
/* Playground: settings control panel (live-settings source + named presets) */
|
|
.wd-pg-ctrl { border: 1px solid var(--divider-color, rgba(127,127,127,.25)); border-radius: var(--wd-radius-md, 10px); padding: 8px 10px; margin: 0 0 10px; background: var(--secondary-background-color); }
|
|
.wd-pg-ctrl .wd-btn { white-space: nowrap; }
|
|
.wd-pg-preset-sel { min-width: 150px; max-width: 220px; font-size: .82em; }
|
|
.wd-pg-preset-name { flex: 1 1 150px; min-width: 120px; max-width: 220px; font-size: .82em; }
|
|
/* Per-setting publish arrow. The empty slot keeps every row's value column aligned
|
|
whether or not that row currently has a publishable change. */
|
|
.wd-pg-pub { width: 20px; height: 20px; flex: 0 0 20px; padding: 0; border: none; border-radius: 5px; cursor: pointer; background: var(--primary-color); color: var(--text-primary-color, #fff); font-size: .8em; line-height: 1; }
|
|
.wd-pg-pub:hover { filter: brightness(1.15); }
|
|
.wd-pg-pub-slot { width: 20px; flex: 0 0 20px; display: inline-block; }
|
|
/* Playground: unified workbench (graph+settings always on top) + "Across your
|
|
cycles" drawer with History/Optimize sub-tabs. */
|
|
.wd-pg-drawer { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--divider-color, rgba(127,127,127,.25)); }
|
|
.wd-pg-drawer-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; }
|
|
.wd-pg-subtabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: var(--secondary-background-color); }
|
|
.wd-pg-subtab { border: none; background: transparent; color: var(--secondary-text-color); font: inherit; font-size: .84em; font-weight: 600; padding: 5px 13px; border-radius: 8px; cursor: pointer; }
|
|
.wd-pg-subtab:hover { color: var(--primary-text-color); }
|
|
.wd-pg-subtab.active { background: var(--card-background-color, var(--primary-background-color)); color: var(--primary-color); box-shadow: 0 1px 3px rgba(0,0,0,.12); }
|
|
.wd-pg-hrow { cursor: pointer; }
|
|
.wd-pg-hrow:hover td { background: var(--secondary-background-color); }
|
|
.wd-pg-hrow.selected td { background: color-mix(in srgb, var(--primary-color) 14%, transparent); box-shadow: inset 2px 0 0 var(--primary-color); }
|
|
.wd-pg-sim-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; margin-top: 4px; }
|
|
.wd-pg-sim-main, .wd-pg-sim-side { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
.wd-pg-simbar { height: 6px; border-radius: 3px; background: var(--secondary-background-color); overflow: hidden; margin: 6px 0 0; }
|
|
.wd-pg-simbar-fill { height: 100%; width: 40%; border-radius: 3px; background: var(--primary-color); animation: wd-pg-indeterminate 1.1s ease-in-out infinite; }
|
|
@keyframes wd-pg-indeterminate { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
|
|
.wd-pg-batchbar-fill { height: 100%; width: 0%; border-radius: 3px; background: var(--primary-color); transition: width .18s ease; }
|
|
/* Header activity pills (background-task registry) */
|
|
.wd-task-pills { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 0 0 0 12px; }
|
|
.wd-task-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 4px 3px 9px; border-radius: 12px; background: rgba(255,255,255,.16); color: var(--app-header-text-color, #fff); font-size: .78em; line-height: 1; }
|
|
.wd-task-pill-lbl { font-weight: 600; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.wd-task-pill-pct { font-variant-numeric: tabular-nums; opacity: .95; }
|
|
.wd-task-pill-eta { opacity: .75; }
|
|
.wd-task-pill-x { border: none; background: rgba(0,0,0,.18); color: inherit; width: 16px; height: 16px; border-radius: 50%; cursor: pointer; font-size: .9em; line-height: 1; display: inline-flex; align-items: center; justify-content: center; padding: 0; }
|
|
.wd-task-pill-x:hover { background: rgba(0,0,0,.32); }
|
|
.wd-task-pill--cancelling { background: rgba(255,160,0,.28); }
|
|
.wd-task-pill-cancelling { opacity: .9; font-style: italic; }
|
|
.wd-task-pill-x--cancelling { opacity: .4; cursor: default; pointer-events: none; }
|
|
.wd-task-spin { width: 10px; height: 10px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: wd-spin-kf .8s linear infinite; opacity: .9; }
|
|
@keyframes wd-spin-kf { to { transform: rotate(360deg); } }
|
|
.wd-pg-batchrow { display: flex; align-items: center; gap: 10px; margin: 6px 0 8px; }
|
|
.wd-pg-batchrow .wd-pg-simbar { flex: 1; margin: 0; }
|
|
#wd-pg-canvas.wd-pg-panning { cursor: grabbing; }
|
|
.wd-pg-alerts-card { background: var(--secondary-background-color); border-radius: 10px; padding: 12px; }
|
|
.wd-pg-outcome-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
.wd-pg-outcome-item { text-align: center; background: var(--card-background-color, var(--primary-background-color)); border-radius: 8px; padding: 8px 4px; }
|
|
.wd-pg-outcome-val { font-size: 1.05em; font-weight: 700; }
|
|
.wd-pg-outcome-lbl { font-size: .68em; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: .04em; margin-top: 2px; }
|
|
.wd-pg-alert { border-left: 3px solid var(--info-color, #2196f3); padding: 4px 0 4px 10px; }
|
|
/* History table */
|
|
.wd-pg-htable { width: 100%; border-collapse: collapse; font-size: .84em; }
|
|
.wd-pg-htable th { text-align: left; font-weight: 600; color: var(--secondary-text-color); padding: 6px 8px; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.2)); font-size: .82em; }
|
|
.wd-pg-htable td { padding: 6px 8px; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.12)); }
|
|
.wd-pg-htable tr[data-action] { cursor: pointer; }
|
|
.wd-pg-htable tr[data-action]:hover { background: var(--secondary-background-color); }
|
|
.wd-pg-diffbadge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: .82em; font-weight: 600; margin: 0 6px 6px 0; }
|
|
/* Sweep heatmap */
|
|
@media (max-width: 720px) {
|
|
.wd-pg-sim-grid { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 640px) {
|
|
.wd-pg-strip { gap: 7px; font-size: .82em; }
|
|
}
|
|
`;function ot(h){if(h==null||h<0)return"-";const t=Math.floor(h/3600),e=Math.floor(h%3600/60),s=Math.floor(h%60);return t>0?`${t}h ${e}m`:e>0?`${e}m ${s}s`:`${s}s`}function Ut(h){return h==null?"-":h>=100?`${Math.round(h)} W`:`${h.toFixed(1)} W`}function gt(h){return h==null?"-":`${h.toFixed(2)} kWh`}let Gt="relative";const le=3700;function Kt(h){const t=e=>String(e).padStart(2,"0");return`${h.getFullYear()}-${t(h.getMonth()+1)}-${t(h.getDate())}`}function Xt(h){const t=new Date;return t.setDate(t.getDate()-h),Kt(t)}function de(){return Kt(new Date)}function zt(){return Xt(10)}function ce(){return Xt(le-1)}function he(h){const t=Math.round((h-Date.now())/1e3);let e;try{e=new Intl.RelativeTimeFormat(void 0,{numeric:"auto"})}catch{return Yt(h)}const s=Math.abs(t),r=[["year",31536e3],["month",2592e3],["week",604800],["day",86400],["hour",3600],["minute",60]];for(const[n,a]of r)if(s>=a)return e.format(Math.round(t/a),n);return e.format(t,"second")}function Yt(h){return new Date(h).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}function st(h,t){if(!h)return"-";let e;if(typeof h=="number")e=h>=1e12?h:h*1e3;else{const s=String(h),r=/^(\d{4})-(\d{2})-(\d{2})$/.exec(s);e=r?new Date(+r[1],+r[2]-1,+r[3]).getTime():new Date(s).getTime()}return isNaN(e)?"-":(t||Gt)==="relative"?he(e):Yt(e)}function Pt(h){if(h==null)return"unknown error";if(h instanceof Error)return`${h.name}: ${h.message}`;const t=h.code!=null?String(h.code):"",e=h.message!=null?String(h.message):"";if(!t&&!e)try{return JSON.stringify(h)}catch{return String(h)}const s=t==="unknown_command"?" (the integration is probably still starting up; this should stop on its own)":"";return`${t||"error"}${e?": "+e:""}${s}`}function d(h){return String(h??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function pe(h){const t=String(h??"").trim();return/^https?:\/\//i.test(t)?t:""}function St(h,t){const e=String(h??"").trim();let s=e.match(/^#([0-9a-fA-F]{3,8})$/);if(s){let r=s[1];if((r.length===3||r.length===4)&&(r=r.split("").map(n=>n+n).join("")),r.length===6||r.length===8){const n=parseInt(r.slice(0,2),16),a=parseInt(r.slice(2,4),16),o=parseInt(r.slice(4,6),16);return`rgba(${n}, ${a}, ${o}, ${t})`}}if(s=e.match(/^rgba?\(([^)]+)\)$/i),s){const r=s[1].split(",").map(n=>n.trim());if(r.length>=3)return`rgba(${r[0]}, ${r[1]}, ${r[2]}, ${t})`}return e}function Jt(h,t){const e=parseFloat(h);return isNaN(e)?t:e}function Ft(h){return h?Array.from(h.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')).filter(e=>e.getClientRects().length>0):[]}function Tt(h){return h==null||h===""?"(none)":h===!0?"on":h===!1?"off":Array.isArray(h)?h.join(", ")||"(none)":String(h)}function Qt(h,t,e){return h.slice().sort((s,r)=>{const n=t(s),a=t(r);return n==null&&a==null?0:n==null?1:a==null?-1:(n<a?-1:n>a?1:0)*e})}function ft(h,t,e,s,r,n,a){const o=e?s===1?" \u25B2":" \u25BC":' <span style="opacity:.35">\u2195</span>',i=n==="right"?"text-align:right;":"",l=a?` title="${d(a)}"`:"";return`<th class="wd-th-sort" data-sortcol="${t}" data-sortact="${r}" style="cursor:pointer;user-select:none;${i}"${l}>${h}${o}</th>`}function Mt(h){h=Math.max(0,Math.round(h));const t=Math.floor(h/3600),e=Math.floor(h%3600/60),s=h%60;return t>0?`${t}:${String(e).padStart(2,"0")}:${String(s).padStart(2,"0")}`:`${e}:${String(s).padStart(2,"0")}`}function Zt(h,t){const e={pause:["lbl.artifact_interruption","Interruption"],dip:["lbl.artifact_low_power","Low power"],spike:["lbl.artifact_high_power","High power"]},[s,r]=e[h]||["lbl.artifact_anomaly","Anomaly"];return t?t(s,{},r):r}function te(h){return h.toLowerCase().replace(/[\s&/\-]+/g,"_").replace(/^_+|_+$/g,"").replace(/_+/g,"_")}function ee(h){const t=new Set;return String(h??"").split(",").forEach(e=>{const s=parseInt(e.trim(),10);Number.isFinite(s)&&s>0&&t.add(s)}),Array.from(t).sort((e,s)=>e-s)}function Dt(h,t){if(!h||!h.length)return null;if(t<=h[0][0])return h[0][1];if(t>=h[h.length-1][0])return h[h.length-1][1];for(let e=1;e<h.length;e++)if(h[e][0]>=t){const s=h[e-1],r=h[e],n=r[0]-s[0]||1;return s[1]+(r[1]-s[1])*((t-s[0])/n)}return h[h.length-1][1]}function _e(h,t,e){e=e||{};const s=h.key,r=h.unit?`${h.label} (${h.unit})`:h.label,n=h.unit?` ${h.unit}`:"",a=h.doc?it(h.doc,h.diagram||ge[s]):"",o=e.changed?`<span class="wd-chg-dot" title="${d(e.changed)}" aria-label="${d(e.changed)}"></span>`:"";if(h.type==="checkbox")return`<div class="wd-field wd-field-switch"><div class="wd-switch-row"><label class="wd-switch-lbl"><span class="wd-switch"><input type="checkbox" data-opt="${s}" ${t?"checked":""}><span class="wd-switch-slider"></span></span><span class="wd-switch-text">${d(h.label)}</span></label>${o}${a}</div>${h.hint?`<div class="wd-field-hint">${d(h.hint)}</div>`:""}</div>`;let i="";const l=t??"";if(h.type==="select"||h.type==="devicetype"||h.type==="device"){const C=(e.opts||[]).map(([R,B])=>`<option value="${d(R)}" ${String(l)===String(R)?"selected":""}>${d(B)}</option>`).join("");i=`<select data-opt="${s}" data-ftype="${h.type}">${C}</select>`}else if(h.type==="textarea")i=`<textarea data-opt="${s}" data-ftype="textarea">${d(l)}</textarea>`;else if(h.type==="json"){const S=l===""||l==null?"":typeof l=="string"?l:JSON.stringify(l,null,2);i=`<textarea data-opt="${s}" data-ftype="json" rows="3" placeholder='${d(e.t("placeholder.json_buttons",{},'[{"action":"ID","title":"Label"}]'))}'>${d(S)}</textarea>`}else if(h.type==="checkboxlist"){const S=Array.isArray(t)&&t.length?t.map(String):Array.isArray(h.def)?h.def.map(String):[];i=`<div data-opt="${s}" data-ftype="checkboxlist" class="wd-checkboxlist">`+(h.choices||[]).map(([C,R])=>`<label class="wd-switch-lbl" style="display:flex;align-items:center;gap:8px;margin:2px 0"><span class="wd-switch"><input type="checkbox" data-choice="${d(C)}" ${S.includes(String(C))?"checked":""}><span class="wd-switch-slider"></span></span><span class="wd-switch-text">${d(e.t?e.t("lbl.evidence_"+C,{},R):R)}</span></label>`).join("")+"</div>"}else if(h.type==="entitylist"){const C=(Array.isArray(t)?t:t?[t]:[]).map(R=>`<span class="wd-pill" data-val="${d(R)}">${d(R)}<button type="button" class="wd-pill-x" aria-label="${d(e.t?e.t("btn.remove",{},"Remove"):"Remove")}">\xD7</button></span>`).join("");i=`<div class="wd-pillbox" data-opt="${s}" data-ftype="entitylist">${C}<div class="wd-combo wd-combo-pill"><input type="text" class="wd-pill-add" autocomplete="off" spellcheck="false" placeholder="${d(e.t("placeholder."+(h.domain||"add"),{},h.placeholder||"add\u2026"))}"><div class="wd-combo-drop" hidden></div></div></div>`}else if(h.type==="timerlist"){const S=Array.isArray(l)?l:[],C=e.t?e.t("lbl.timer_min",{},"min"):"min",R=e.t?e.t("lbl.timer_msg_placeholder",{},"Message (optional, {device}/{program}/{minutes})"):"Message (optional, {device}/{program}/{minutes})",B=e.t?e.t("lbl.timer_auto_pause",{},"Auto-pause"):"Auto-pause",W=e.t?e.t("btn.remove_timer",{},"Delete"):"Delete",O=e.t?e.t("btn.add_timer",{},"+ Add timer"):"+ Add timer",D=(E,M)=>{const H=E&&E.offset_minutes?String(E.offset_minutes):"",q=E&&E.message?d(E.message):"",Q=E&&E.auto_pause?" checked":"";return`<div class="wd-timer-row" data-tidx="${M}"><div class="wd-timer-top"><input type="number" min="1" placeholder="${d(C)}" data-field="offset_minutes" value="${d(H)}"><textarea placeholder="${d(R)}" data-field="message">${q}</textarea></div><div class="wd-timer-footer"><label class="wd-switch-lbl"><span class="wd-switch"><input type="checkbox" data-field="auto_pause"${Q}><span class="wd-switch-slider"></span></span><span class="wd-switch-text">${d(B)}</span></label><button type="button" class="wd-btn wd-btn-sm wd-btn-danger wd-timer-remove">${d(W)}</button></div></div>`},A=S.map((E,M)=>D(E,M)).join("");i=`<div class="wd-timerlist" data-opt="${s}" data-ftype="timerlist">${A}<button type="button" class="wd-btn wd-btn-sm wd-btn-secondary wd-timer-add">${d(O)}</button></div>`}else if(h.type==="entity"){const S=h.placeholder?` placeholder="${d(h.placeholder)}"`:"";i=`<div class="wd-combo"><input type="text" class="wd-combo-inp" data-opt="${s}" data-ftype="entity" value="${d(l)}" autocomplete="off" spellcheck="false"${S}><div class="wd-combo-drop" hidden></div></div>`}else if(h.type==="list"){const S=Array.isArray(l)?l.join(", "):d(l);i=`<input type="text" data-opt="${s}" data-ftype="list" value="${d(S)}" placeholder="${d(e.t("placeholder.notify_services_list",{},"notify.mobile_app_phone, ..."))}">`}else if(h.type==="intlist"){const S=Array.isArray(l)?l.join(", "):String(l??""),C=h.placeholder?` placeholder="${d(h.placeholder)}"`:"";i=`<input type="text" data-opt="${s}" data-ftype="intlist" value="${d(S)}"${C}>`}else{const S=h.type==="number"?"number":"text",C=e.datalistId?` list="${e.datalistId}"`:"",R=h.step!=null?` step="${h.step}"`:"",B=h.min!=null?` min="${h.min}"`:"",W=h.max!=null?` max="${h.max}"`:"",O=h.placeholder?` placeholder="${d(h.placeholder)}"`:"";i=`<input type="${S}" data-opt="${s}" data-ftype="${h.type}" value="${d(l)}"${R}${B}${W}${C}${O}>${e.datalist||""}`}const c=e.suggestion,_=e.mlSuggestion,p=c&&c.suggested!=null&&!Ot(c.suggested,t)?c.suggested:null,g=_&&_.value!=null&&!Ot(_.value,t)?_.value:null,u=e.t,v=c?c.reason_key?u(c.reason_key,c.reason_params||{},c.reason||""):c.reason||"":"",b=_?_.reason_key?u(_.reason_key,_.reason_params||{},_.reason||""):_.reason||"":"",$=S=>`<button type="button" class="wd-sug-use" data-sugkey="${s}" data-sugval="${d(S)}">${e.useBtnLabel||"Use"}</button>`;let k="";if(p!=null&&g!=null){const S=parseFloat(p),C=parseFloat(g);if((!isNaN(S)&&!isNaN(C)?Math.abs(S-C)/Math.max(Math.abs(S),Math.abs(C),1e-9):1)<.05){const B=u("suggestion.calibrated_label",{},"Calibrated"),W=it([v,b?`${B}: ${b}`:""].filter(Boolean).join(`
|
|
|
|
`));k=`<div class="wd-sug"><span class="wd-sug-chip wd-sug-chip-obs">\u{1F4A1} ${d(u("suggestion.both_agree",{},"WashData recommends"))}</span><span class="wd-sug-val">${d(p)}${n}</span>${$(p)}${W}</div>`}else{const B=v?it(v):"",W=b?it(b):"",O=u("suggestion.observed_label",{},"Observed"),D=u("suggestion.calibrated_label",{},"Calibrated");let A="",E="";if(!isNaN(S)&&!isNaN(C)){const q=C>S;E=u(`suggestion.impact.${s}.${q?"higher":"lower"}`,{},""),A=u(`suggestion.impact.${s}.${q?"lower":"higher"}`,{},"")}const M=A?`<div class="wd-sug-impact-line">${d(A)}</div>`:"",H=E?`<div class="wd-sug-impact-line">${d(E)}</div>`:"";k=`<div class="wd-sug wd-sug-split"><div class="wd-sug-opt"><span class="wd-sug-chip wd-sug-chip-obs">\u{1F4A1} ${d(O)}</span><span class="wd-sug-val">${d(p)}${n}</span>${$(p)}${B}${M}</div><div class="wd-sug-opt"><span class="wd-sug-chip wd-sug-chip-cal">\u{1F916} ${d(D)}</span><span class="wd-sug-val">${d(g)}${n}</span>${$(g)}${W}${H}</div></div>`}}else if(p!=null){const S=v?it(v):"",C=t!=null&&t!==""?` <span style="opacity:.6;font-size:.9em">(now ${d(t)}${n})</span>`:"";k=`<div class="wd-sug"><span class="wd-sug-chip wd-sug-chip-obs">\u{1F4A1} ${d(u("suggestion.observed_label",{},"Observed"))}</span><span class="wd-sug-val">${d(p)}${n}</span>${C}${$(p)}${S}</div>`}else if(g!=null){const S=b?it(b):"";k=`<div class="wd-sug"><span class="wd-sug-chip wd-sug-chip-cal">\u{1F916} ${d(u("suggestion.calibrated_label",{},"Calibrated"))}</span><span class="wd-sug-val">${d(g)}${n}</span>${$(g)}${S}</div>`}if(k&&s){const S=d(u("btn.mute_suggestion",{},"Stop suggesting this setting")),C=`<button type="button" class="wd-sug-lock" data-suglock="${s}" title="${S}" aria-label="${S}">\u{1F515}</button>`;k=k.replace(/<\/div>\s*$/,C+"</div>")}return`<div class="wd-field" data-field="${s}"><div class="wd-label-row"><label style="margin:0">${d(r)}</label>${o}${a}</div>${i}${h.hint?`<div class="wd-field-hint">${d(h.hint)}</div>`:""}<div class="wd-conflict-err" data-cerr="${s}" hidden></div>${k}</div>`}function Ot(h,t){if(h==null||t==null)return!1;const e=parseFloat(h),s=parseFloat(t);return!isNaN(e)&&!isNaN(s)?Math.abs(e-s)<1e-6:String(h)===String(t)}const ge={min_power:"min_power",off_delay:"off_delay",smoothing_window:"smoothing",start_threshold_w:"hysteresis",stop_threshold_w:"hysteresis",start_energy_threshold:"start_energy",running_dead_zone:"dead_zone",profile_duration_tolerance:"duration_tolerance",profile_match_min_duration_ratio:"match_ratios",profile_match_max_duration_ratio:"match_ratios",progress_reset_delay:"progress_reset",completion_min_seconds:"min_duration",min_off_gap:"min_off_gap",start_duration_threshold:"start_duration",end_energy_threshold:"end_energy_thresh",end_repeat_count:"end_repeat",profile_match_threshold:"confidence",profile_unmatch_threshold:"confidence",auto_label_confidence:"confidence",learning_confidence:"confidence",no_update_active_timeout:"watchdog_timeout",anti_wrinkle_enabled:"anti_wrinkle",anti_wrinkle_max_power:"anti_wrinkle",anti_wrinkle_max_duration:"anti_wrinkle",anti_wrinkle_exit_power:"anti_wrinkle",anti_wrinkle_idle_timeout:"anti_wrinkle",sampling_interval:"sampling"};function ue(h){let t=0,e=window.innerWidth;for(let s=h.parentElement;s;s=s.parentElement){if(getComputedStyle(s).overflowX==="visible")continue;const r=s.getBoundingClientRect();r.left>t&&(t=r.left),r.right<e&&(e=r.right)}return{left:t,right:e}}function it(h,t){return`<span class="wd-tip">i<span class="wd-tip-pop">${t?fe(t):""}<span class="wd-tip-txt">${d(h)}</span></span></span>`}function Nt(h,t,e=""){return`<label class="wd-switch-lbl"><span class="wd-switch"><input type="checkbox" ${h}><span class="wd-switch-slider"></span></span><span class="wd-switch-text wd-switch-text--plain">${d(t)}</span></label>${e||""}`}function Et(h,t,e="",s=""){return`<div class="wd-field wd-field-switch"><div class="wd-switch-row">${Nt(h,t,e)}</div>${s?`<div class="wd-field-hint">${d(s)}</div>`:""}</div>`}function fe(h){const t=s=>`<svg class="wd-dg" viewBox="0 0 200 90" preserveAspectRatio="xMidYMid meet">${s}</svg>`,e='<line class="ax" x1="8" y1="78" x2="192" y2="78"/>';switch(h){case"smoothing":return t(`${e}
|
|
<polyline class="ln2" points="8,60 22,30 36,66 50,28 64,62 78,34 92,64 106,30 120,60 134,36 148,62 162,32 176,58 190,40"/>
|
|
<polyline class="ln" points="8,58 30,48 52,44 74,42 96,42 118,44 140,42 162,44 190,46"/>
|
|
<text x="10" y="14">raw vs smoothed</text>`);case"min_power":return t(`${e}
|
|
<rect class="fb" x="8" y="64" width="184" height="14"/>
|
|
<line class="bad dash" x1="8" y1="64" x2="192" y2="64"/>
|
|
<polyline class="ln" points="8,72 30,40 60,30 90,34 120,28 150,66 175,72 190,72"/>
|
|
<text x="150" y="60">min</text><text x="10" y="14">below = off</text>`);case"hysteresis":return t(`${e}
|
|
<rect class="fz" x="8" y="34" width="184" height="24"/>
|
|
<line class="ok dash" x1="8" y1="34" x2="192" y2="34"/>
|
|
<line class="bad dash" x1="8" y1="58" x2="192" y2="58"/>
|
|
<polyline class="ln" points="8,72 40,72 70,24 110,24 140,72 175,72 190,72"/>
|
|
<text x="10" y="30">start</text><text x="10" y="70">stop</text>`);case"start_energy":return t(`${e}
|
|
<polyline class="bad" points="40,78 41,26 42,78"/>
|
|
<text x="14" y="20">spike ignored</text>
|
|
<rect class="fz" x="110" y="34" width="46" height="44"/>
|
|
<polyline class="ln" points="110,78 110,34 156,34 156,78"/>
|
|
<text x="104" y="28">energy counts</text>`);case"off_delay":return t(`${e}
|
|
<rect class="fw" x="96" y="20" width="46" height="58"/>
|
|
<polyline class="ln" points="8,40 90,40 96,74 142,74 148,40 175,40 175,74 190,74"/>
|
|
<text x="98" y="16">off-delay wait</text>`);case"duration_tolerance":return t(`${e}
|
|
<rect class="fz" x="60" y="36" width="80" height="22"/>
|
|
<line class="ax" x1="60" y1="47" x2="140" y2="47"/>
|
|
<line class="ln" x1="100" y1="30" x2="100" y2="64"/>
|
|
<text x="62" y="30">-tol</text><text x="120" y="30">+tol</text><text x="84" y="74">profile</text>`);case"match_ratios":return t(`${e}
|
|
<line class="ax" x1="20" y1="47" x2="180" y2="47"/>
|
|
<rect class="fz" x="70" y="40" width="60" height="14"/>
|
|
<line class="bad" x1="70" y1="34" x2="70" y2="60"/>
|
|
<line class="bad" x1="130" y1="34" x2="130" y2="60"/>
|
|
<line class="ln" x1="100" y1="30" x2="100" y2="64"/>
|
|
<text x="58" y="28">min</text><text x="120" y="28">max</text>`);case"dead_zone":return t(`${e}
|
|
<rect class="fw" x="8" y="20" width="34" height="58"/>
|
|
<polyline class="ln" points="8,72 14,30 20,72 26,32 32,72 42,40 90,36 140,36 175,72 190,72"/>
|
|
<text x="10" y="16">ignored</text>`);case"progress_reset":return t(`${e}
|
|
<rect class="fz" x="60" y="24" width="80" height="50"/>
|
|
<polyline class="ln" points="8,74 60,24 140,24 141,74 190,74"/>
|
|
<text x="72" y="20">held at 100%</text>`);case"min_duration":return t(`${e}
|
|
<polyline class="bad" points="30,78 34,50 38,78"/>
|
|
<text x="20" y="44">too short</text>
|
|
<polyline class="ln" points="90,78 100,40 150,40 160,78"/>
|
|
<text x="106" y="34">kept</text>`);case"min_off_gap":return t(`${e}
|
|
<rect class="fw" x="68" y="32" width="48" height="46"/>
|
|
<polyline class="ln" points="8,78 14,78 26,44 38,32 52,44 68,78 116,78 128,32 150,32 162,44 174,78 192,78"/>
|
|
<text x="72" y="26">off-gap</text>
|
|
<text x="9" y="14">gap below min = one cycle</text>`);case"start_duration":return t(`${e}
|
|
<line class="bad dash" x1="8" y1="50" x2="192" y2="50"/>
|
|
<polyline class="bad" points="34,78 36,28 38,78"/>
|
|
<polyline class="ln" points="8,78 100,78 112,50 192,50"/>
|
|
<rect class="fw" x="112" y="50" width="44" height="28"/>
|
|
<text x="26" y="24">spike: ignored</text>
|
|
<text x="114" y="46">confirmed</text>`);case"end_energy_thresh":return t(`${e}
|
|
<polyline class="ln" points="8,78 14,78 28,28 66,28 76,60 110,60 125,78 192,78"/>
|
|
<rect class="fz" x="76" y="60" width="49" height="18"/>
|
|
<line class="ax" x1="8" y1="68" x2="192" y2="68"/>
|
|
<text x="10" y="14">tail energy above thresh: timer resets</text>
|
|
<text x="78" y="56">accum</text>
|
|
<text x="168" y="65">thr</text>`);case"end_repeat":return t(`${e}
|
|
<line class="bad dash" x1="8" y1="54" x2="192" y2="54"/>
|
|
<polyline class="ln" points="8,78 16,78 28,30 78,30 88,60 108,60 128,60 148,60 162,78 192,78"/>
|
|
<line class="ax" x1="88" y1="54" x2="88" y2="78"/>
|
|
<line class="ax" x1="108" y1="54" x2="108" y2="78"/>
|
|
<line class="ax" x1="128" y1="54" x2="128" y2="78"/>
|
|
<line class="ax" x1="148" y1="54" x2="148" y2="78"/>
|
|
<text x="90" y="48">R1 R2 R3</text>
|
|
<text x="10" y="14">N reads below stop = end</text>`);case"confidence":return t(`
|
|
<rect class="fb" x="8" y="40" width="56" height="22"/>
|
|
<rect class="fw" x="64" y="40" width="90" height="22"/>
|
|
<rect class="fz" x="154" y="40" width="38" height="22"/>
|
|
<line class="ax" x1="8" y1="40" x2="192" y2="40"/>
|
|
<line class="ax" x1="8" y1="62" x2="192" y2="62"/>
|
|
<text x="14" y="36">no match</text>
|
|
<text x="70" y="36">feedback</text>
|
|
<text x="156" y="36">auto</text>
|
|
<text x="8" y="74">0.0</text>
|
|
<text x="178" y="74">1.0</text>`);case"watchdog_timeout":return t(`${e}
|
|
<polyline class="ln" points="8,40 74,40 82,78"/>
|
|
<rect class="fw" x="82" y="22" width="76" height="56"/>
|
|
<line class="bad" x1="164" y1="26" x2="176" y2="38"/>
|
|
<line class="bad" x1="176" y1="26" x2="164" y2="38"/>
|
|
<text x="86" y="18">no updates</text>
|
|
<text x="10" y="18">sensor offline: force-stop</text>`);case"anti_wrinkle":return t(`${e}
|
|
<rect class="fz" x="78" y="44" width="114" height="34"/>
|
|
<polyline class="ln" points="8,78 14,78 24,30 54,30 68,78 84,60 96,78 110,60 122,78 136,60 148,78 162,60 174,78 192,78"/>
|
|
<text x="10" y="14">heat phase</text>
|
|
<text x="82" y="40">tumble pulses kept</text>`);case"sampling":return t(`${e}
|
|
<line class="ok" x1="24" y1="30" x2="24" y2="78"/>
|
|
<line class="ok" x1="62" y1="30" x2="62" y2="78"/>
|
|
<line class="ok" x1="100" y1="30" x2="100" y2="78"/>
|
|
<line class="ok" x1="138" y1="30" x2="138" y2="78"/>
|
|
<line class="ok" x1="176" y1="30" x2="176" y2="78"/>
|
|
<line class="fz" x1="8" y1="54" x2="192" y2="54"/>
|
|
<text x="36" y="50">SI</text>
|
|
<text x="74" y="50">SI</text>
|
|
<text x="112" y="50">SI</text>
|
|
<text x="10" y="14">typical reading interval</text>`);default:return""}}class me extends HTMLElement{constructor(){super(),this._hass=null,this._initialized=!1,this._pollTimer=null,this._toastTimer=null,this._hassUpdateThrottle=null,this._evtUnsubs=[],this._hoverRafId=null,this._hoverPending=null,this._constants={stateColors:{},deviceTypes:[],mlLabEnabled:!1,mlSuggestionsEnabled:!1,mlTrainingAvailable:!1,storeOnlineAvailable:!1,storeOnlineEnabled:!1,storeWebOrigin:"",storePrefs:{},pgMatchDefaults:{},version:"",iconUrl:""},this._constantsLoaded=!1,this._devices=[],this._cycles=[],this._refCycles=[],this._shareableCycles=[],this._sharePhasePrograms=[],this._shareAllPrograms=[],this._dlSettings=!1,this._selectMode=!1,this._cycleSel=new Set,this._profiles=[],this._profileGroups={groups:[],suggestions:[],min_cohesion:.85},this._profileEnvCache={},this._suggestions=[],this._lockedSuggestions=[],this._feedbacks=[],this._diag=null,this._phases=[],this._recState=null,this._opts={},this._optDefaults={},this._mlComparison=null,this._mlById={},this._mlLoading=!1,this._mlSettings={},this._mlSettingsLoading=!1,this._mlSettingsByEntry={},this._lockedByEntry={},this._mlTrainingStatus=null,this._setupStatus=null,this._selIdx=0,this._tab="status",this._settingsSec="basic",this._settingsSearch="",this._settingsSugOnly=!1,this._settingsHistoryOpen=!1,this._canvasZoom={},this._toolsSubtab="recording",this._loading=!0,this._tabLoading=!1,this._lastRefresh=null,this._powerHistory=[],this._powerT0=null,this._statusEnv=null,this._statusEnvName=null,this._powerData={live:[],raw:[],cycle_active:!1,cycle_elapsed_s:0},this._stagedSuggestions=!1,this._pendingSettings={},this._busy=new Set,this._tasks={},this._cancellingTasks=new Set,this._taskCallbacks={},this._tasksSubscribed=!1,this._pgHistoryTaskId=null,this._pgSweepTaskId=null,this._panelCfg=null,this._panelTrans=null,this._pollMs=ae,this._panelSubtab="maintenance",this._gearTab="prefs",this._catalog={brands:void 0,devices:void 0,forBrand:null,approvedOnly:!1,brandsFull:!1,brandPrefixes:[]},this._catalogEntry=null,this._maintenance=null,this._logs=[],this._logLevel="",this._logDevice="",this._logComponent="",this._logSearch="";try{this._logOpen=localStorage.getItem("wd-log-open")==="1",this._logDrawerWidth=Math.max(280,parseInt(localStorage.getItem("wd-log-width")||"380",10)||380)}catch{this._logOpen=!1,this._logDrawerWidth=380}this._tabInitialized=!1,this._modal=null,this._prevModal=null,this._toast=null,this._cycleSort={col:"date",dir:-1},this._cycleFilter={text:"",status:""},this._cleanupSort={col:"date",dir:-1},this._profSubtab="profiles",this._statusPhases=[],this._statusPhasesName=null,this._cycleOffset=0,this._cyclesTotal=0,this._cyclesHasMore=!1,this._undoBuffer=new Map,this._undoSeq=0,this._kbdHandler=null,this._tipHandler=null,this._settingsChangelog=null,this._settingsChangeByKey={},this._pgCycleId="",this._pgProfileName="",this._pgPowerPts=null,this._pgDtwData=null,this._pgEnvData=null,this._pgAnalysisTab="history",this._pgDetail=null,this._pgDetailBusy=!1,this._pgHistory=null,this._pgSweepObjective="match_accuracy",this._pgSweepNew=null,this._pgDetailDebounceTimer=null,this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={},this._pgEffective=null,this._pgPublishable=null,this._pgPresets=[],this._pgPresetLimit=0,this._pgPresetSel="",this._pgPresetName="",this._pgSuggClassic={},this._pgSuggMl=null,this._pgMlSuggEnabled=!1,this._pgView=null,this._pgHoverT=null,this._pgMap=null,this._pgStressTail=!1,this._pgStressIdleW=null,this._pgPanStart=null,this._pgHoverEvent=null,this._pgBatchProgress=null,this._pgBatchCancel=!1,this._pgLoadSeq=0,this._pgDragging=null,this._pgNeedsRestart=!1,this._pgSimCycles=20,this._pgSweepParam="off_delay",this._pgSweepFrom="",this._pgSweepTo="",this._pgSweepSteps=5,this._pgLoading=!1,this._storeView="brands",this._storeQuery="",this._storeDevices=[],this._storeDevice=null,this._storeProfiles=[],this._storeProfile=null,this._storeCycles=[],this._storeStatus=null,this._storeConnected=!1,this._storeLoading=!1,this._storeConnectListener=null}set hass(t){const e=this._hass;if(this._hass=t,!this._initialized&&t){this._initialized=!0,this._boot();return}e!==t&&this._initialized&&!this._loading&&!this._hassUpdateThrottle&&(this._hassUpdateThrottle=setTimeout(()=>{this._hassUpdateThrottle=null,this._fetchAll()},ne))}set panel(t){this._panel=t}set narrow(t){this._narrow=t}connectedCallback(){this._initialized&&(this._startPoll(),this._setupSubscriptions(),this._fetchAll(),this.shadowRoot&&!this._kbdHandler&&(this._kbdHandler=t=>this._onKeydown(t),this.shadowRoot.addEventListener("keydown",this._kbdHandler)),this.shadowRoot&&!this._tipHandler&&(this._tipHandler=t=>{const e=t.target,s=e&&e.closest?e.closest(".wd-tip"):null;s&&this._positionTip(s)},this.shadowRoot.addEventListener("pointerover",this._tipHandler))),this._onResize=()=>this._resizeLogsPage(),window.addEventListener("resize",this._onResize)}disconnectedCallback(){this._onResize&&(window.removeEventListener("resize",this._onResize),this._onResize=null),this._stopPoll(),this._hassUpdateThrottle&&(clearTimeout(this._hassUpdateThrottle),this._hassUpdateThrottle=null),this._pgRestartRetryTimer&&(clearTimeout(this._pgRestartRetryTimer),this._pgRestartRetryTimer=null),this._evtUnsubs.forEach(t=>{try{t()}catch{}}),this._evtUnsubs=[],this._flushPendingDeletes(),this._kbdHandler&&this.shadowRoot&&(this.shadowRoot.removeEventListener("keydown",this._kbdHandler),this._kbdHandler=null),this._tipHandler&&this.shadowRoot&&(this.shadowRoot.removeEventListener("pointerover",this._tipHandler),this._tipHandler=null),this._storeConnectListener&&(window.removeEventListener("message",this._storeConnectListener),this._storeConnectListener=null)}_boot(){const t=this.attachShadow({mode:"open"}),e=document.createElement("style");e.textContent=oe,t.appendChild(e),this._container=document.createElement("div"),t.appendChild(this._container),this._gtip=document.createElement("div"),this._gtip.className="wd-gtip",t.appendChild(this._gtip),this._kbdHandler=s=>this._onKeydown(s),t.addEventListener("keydown",this._kbdHandler),this._tipHandler=s=>{const r=s.target,n=r&&r.closest?r.closest(".wd-tip"):null;n&&this._positionTip(n)},t.addEventListener("pointerover",this._tipHandler),this._loadPanelTranslations().catch(()=>{}).finally(()=>{this._fetchAll(),this._startPoll()}),this._setupSubscriptions()}_setupSubscriptions(){this._evtUnsubs.forEach(e=>{try{e()}catch{}}),this._evtUnsubs=[],this._tasksSubscribed=!1;const t=this._hass&&this._hass.connection;if(t&&t.subscribeMessage){const e=s=>{const r=s.data||{};if(r.entry_id&&(this._fetchAll(),s.event_type==="ha_washdata_cycle_ended")){const n=this._devices[this._selIdx];n&&n.entry_id===r.entry_id&&this._fetchCycles(r.entry_id).then(()=>{this._tab==="history"&&this._render()})}};for(const s of["ha_washdata_cycle_started","ha_washdata_cycle_ended"])t.subscribeMessage(e,{type:"subscribe_events",event_type:s}).then(r=>{this._evtUnsubs.push(r)}).catch(()=>{});t.subscribeMessage(s=>this._onTaskEvent(s),{type:`${y}/subscribe_tasks`}).then(s=>{this._tasksSubscribed=!0,this._evtUnsubs.push(s)}).catch(()=>{this._tasksSubscribed=!1})}}_onTaskEvent(t){const e=t&&t.task;if(!e||!e.id)return;const s=this._tasks[e.id];s&&(s.updated_at||0)>(e.updated_at||0)||(this._tasks[e.id]=e,e.state!=="running"&&this._cancellingTasks.delete(e.id),this._updateTaskPills(),this._pgAdoptTask(e),this._onTrackedTaskProgress(e),(e.kind==="history_import"||e.kind==="history_import_apply")&&(e.state==="running"?this._modal&&this._modal.type==="history-import"&&this._render():this._histTaskFinished(e)),this._settleTaskCallback(e))}_pgAdoptTask(t){if(t.kind!=="pg_sweep"&&t.kind!=="pg_history")return;const e=this._devices[this._selIdx];if(!e||t.entry_id!==e.entry_id)return;const s=t.kind==="pg_sweep";if(this._pgSweepTaskId||this._pgHistoryTaskId)return;const r=t.state!=="running"&&t.finished_at&&Date.now()/1e3-t.finished_at<30;t.state!=="running"&&!r||(s?this._pgSweepTaskId=t.id:this._pgHistoryTaskId=t.id,this._pgAnalysisTab=s?"sweep":"history",this._busy.add(s?"pg-sweep":"pg-history"),t.state==="running"&&(this._pgBatchProgress={done:t.done||0,total:t.total||0}),this._render())}_pgAdoptExisting(){Object.values(this._tasks||{}).forEach(t=>{this._pgAdoptTask(t),this._onTrackedTaskProgress(t)})}_htmlPgRecentRuns(t){const e=this._devices[this._selIdx];if(!e)return"";const s=Object.values(this._tasks||{}).filter(n=>n.kind===t&&n.entry_id===e.entry_id&&n.state!=="running"&&n.has_result).sort((n,a)=>(a.finished_at||0)-(n.finished_at||0)).slice(0,8);if(!s.length)return"";const r=s.map(n=>{const a=n.finished_at?st(n.finished_at*1e3):"",o=n.state==="cancelled"?" \u26A0":"";return`<button class="wd-btn wd-btn-sm" data-action="pg-load-run" data-task-id="${d(n.id)}" title="${d(this._t("lbl.pg_load_run",{},"Reload this run"))}">${d(a)}${o}</button>`}).join("");return`<div style="margin:2px 0 10px">
|
|
<span style="font-size:.72em;color:var(--secondary-text-color);text-transform:uppercase;letter-spacing:.05em">${this._t("lbl.pg_recent_runs",{},"Recent runs")}</span>
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:4px">${r}</div>
|
|
</div>`}_settleTaskCallback(t){if(t.state==="running")return;const e=this._taskCallbacks[t.id];if(e){delete this._taskCallbacks[t.id],e(t);return}this._autoSettleAdopted(t)}_autoSettleAdopted(t){if(t.state!=="done"||t.kind!=="ml_training"&&t.kind!=="reprocess"||!t.finished_at||Date.now()/1e3-t.finished_at>60||(this._autoSettled=this._autoSettled||new Set,this._autoSettled.has(t.id)))return;const e=this._devices[this._selIdx];if(!e||t.entry_id!==e.entry_id)return;this._autoSettled.add(t.id);const s=e.entry_id,r=()=>{this._isActiveEntry(s)&&this._render()};t.kind==="ml_training"?this._loadMlTrainingStatus(s).finally(r):this._fetchToolsData(s).finally(r)}async _kickAndTrack(t,e,s){if(this._busy.has(e))return;this._busy.add(e),this._render();let r;try{const a=await this._ws(t);if(r=a&&a.task_id,!r)throw new Error("no task id");const o=String(t.type||"").endsWith("reprocess_history")?"reprocess":String(t.type||"").endsWith("trigger_ml_training")?"ml_training":String(t.type||"").endsWith("apply_split")?"split":String(t.type||"").endsWith("trim_cycle")?"trim":String(t.type||"").endsWith("apply_merge")?"merge":String(t.type||"").endsWith("rebuild_envelopes")?"rebuild":"task";this._addProvisionalTask(r,o,t.entry_id,0)}catch(a){this._busy.delete(e),this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error"),this._render();return}this._taskCallbacks[r]=async a=>{if(this._busy.delete(e),a.state==="error"){this._showToast(this._t("msg.toast_error",{error:a.error||""},"Error: "+(a.error||"")),"error"),this._render();return}if(a.state==="cancelled"){this._showToast(this._t("toast.task_cancelled",{},"Cancelled."),"info"),this._render();return}let o=null;try{const i=await this._ws({type:`${y}/get_task_result`,task_id:a.id});o=i&&i.result}catch{}if(o==null){this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info"),this._render();return}try{await s(o,a.state)}catch{}this._render()};const n=this._tasks[r];if(n&&n.state!=="running"){this._settleTaskCallback(n);return}this._tasksSubscribed||this._pollTaskGeneric(r)}_finalizeTaskError(t,e){const s=Object.assign({},this._tasks[t]||{id:t},{id:t,state:"error",error:e,finished_at:Date.now()/1e3});this._tasks[t]=s,this._updateTaskPills(),this._settleTaskCallback(s)}async _pollTaskGeneric(t){let e=0;for(let s=0;s<3600&&this._taskCallbacks[t];s++){let r;try{r=await this._ws({type:`${y}/get_task_result`,task_id:t}),e=0}catch{if(++e>=5){this._finalizeTaskError(t,"lost connection");return}await new Promise(a=>setTimeout(a,1200));continue}if(!r){this._finalizeTaskError(t,"no result");return}if(this._tasks[t]=r,this._updateTaskPills(),r.state!=="running"){this._settleTaskCallback(r);return}await new Promise(n=>setTimeout(n,1200))}this._taskCallbacks[t]&&this._finalizeTaskError(t,"timed out")}_deviceName(t){const e=(this._devices||[]).find(s=>s.entry_id===t);return e&&(e.name||e.title)||""}_taskActionLabel(t){return{pg_history:this._t("lbl.task_pg_history",{},"Test on history"),pg_sweep:this._t("lbl.task_pg_sweep",{},"Optimize"),pg_detail:this._t("lbl.task_pg_detail",{},"Simulate cycle"),split:this._t("lbl.task_split",{},"Splitting cycle"),trim:this._t("lbl.task_trim",{},"Trimming cycle"),merge:this._t("lbl.task_merge",{},"Merging cycles"),rebuild:this._t("lbl.task_rebuild",{},"Rebuilding envelopes"),reprocess:this._t("lbl.task_reprocess",{},"Reprocessing"),ml_training:this._t("lbl.task_ml_training",{},"Learning"),history_import:this._t("lbl.task_history_import",{},"Scanning power history"),history_import_apply:this._t("lbl.task_history_import_apply",{},"Importing cycles")}[t]||t}_fmtEta(t){return t=Math.round(t),t<60?this._t("lbl.eta_secs",{n:t},`~${t}s left`):this._t("lbl.eta_mins",{n:Math.round(t/60)},`~${Math.round(t/60)}m left`)}_exclNote(t){if(!t||!t.total)return"";const e=(t.items||[]).map(([s,r])=>`${r} ${this._t("suggestion.exclusions.reason."+s,{},String(s).replace(/_/g," "))}`).join(", ");return" "+this._t("suggestion.exclusions.summary",{total:t.total,parts:e},`Excluded ${t.total} mis-detected cycle(s): ${e}.`)}_htmlTaskPills(){const t=Object.values(this._tasks||{}).filter(s=>s.state==="running"),e=new Set(t.map(s=>s.id));return this._cancellingTasks.forEach(s=>{e.has(s)||this._cancellingTasks.delete(s)}),t.length?t.map(s=>{const r=this._cancellingTasks.has(s.id),n=this._deviceName(s.entry_id),a=s.label_key?this._t(s.label_key,s.label_params||{},s.label||this._taskActionLabel(s.kind)):this._taskActionLabel(s.kind),o=(n?n+" \xB7 ":"")+a,i=s.progress!=null?Math.round(s.progress*100)+"%":"",l=s.eta_s!=null&&s.eta_s>0?this._fmtEta(s.eta_s):"",c=this._t("task.cancelling",{},"Cancelling\u2026"),_="wd-task-pill"+(r?" wd-task-pill--cancelling":""),p=r?" \xB7 "+c:i?" "+i:"";return`<span class="${_}" title="${d(o+p)}"><span class="wd-task-spin"></span><span class="wd-task-pill-lbl">${d(o)}</span>`+(r?`<span class="wd-task-pill-cancelling">${d(c)}</span>`:(i?`<span class="wd-task-pill-pct">${i}</span>`:"")+(l?`<span class="wd-task-pill-eta">${d(l)}</span>`:""))+`<button class="wd-task-pill-x${r?" wd-task-pill-x--cancelling":""}" data-action="task-cancel" data-task-id="${d(s.id)}" ${r?"disabled ":""}title="${d(this._t("btn.cancel",{},"Cancel"))}">\u2715</button></span>`}).join(""):""}_updateTaskPills(){const t=this.shadowRoot;if(!t)return;const e=t.getElementById("wd-task-pills");e&&(e.innerHTML=this._htmlTaskPills())}_addProvisionalTask(t,e,s,r){!t||this._tasks[t]||(this._tasks[t]={id:t,entry_id:s,kind:e,label:this._taskActionLabel(e),state:"running",done:0,total:r||0,progress:r?0:null,eta_s:null,updated_at:0,has_result:!1},this._updateTaskPills())}_onTrackedTaskProgress(t){if(!(t.id!==this._pgHistoryTaskId&&t.id!==this._pgSweepTaskId)){if(t.state==="running"){this._pgBatchProgress={done:t.done||0,total:t.total||0},this._pgUpdateBatchBar(t.done||0,t.total||0);return}this._pgFinishTask(t,t.id===this._pgHistoryTaskId)}}async _pgFinishTask(t,e){let s=null;try{if(t.state==="done"||t.state==="cancelled"){const r=await this._ws({type:`${y}/get_task_result`,task_id:t.id});s=r&&r.result}}catch{}this._isActiveEntry(t.entry_id)&&(t.state==="error"?this._showToast(this._t("msg.toast_error",{error:t.error||""},"Error: "+(t.error||"")),"error"):s&&(e?this._pgHistory=s:this._pgSweepNew=s&&!s.error?s:null),e?(this._busy.delete("pg-history"),this._pgHistoryTaskId=null):(this._busy.delete("pg-sweep"),this._pgSweepTaskId=null),this._pgBatchProgress=null,this._render())}async _pgPollTask(t){for(let e=0;e<3600&&(t===this._pgHistoryTaskId||t===this._pgSweepTaskId);e++){let s;try{s=await this._ws({type:`${y}/get_task_result`,task_id:t})}catch{break}if(!s)break;if(this._tasks[t]=s,this._updateTaskPills(),s.state!=="running"){this._onTrackedTaskProgress(s);return}this._pgBatchProgress={done:s.done||0,total:s.total||0},this._pgUpdateBatchBar(s.done||0,s.total||0),await new Promise(r=>setTimeout(r,1200))}}_panelTransUrl(t){const e=`/ha_washdata/panel-translations/${encodeURIComponent(t)}.json`;return Ht?`${e}?v=${encodeURIComponent(Ht)}`:e}async _fetchPanelLang(t){if(!t)return null;const e=[t],s=t.indexOf("-");s>0&&e.push(t.slice(0,s));for(const r of e)try{const n=await fetch(this._panelTransUrl(r));if(n.ok){const a=await n.json();if(a&&typeof a=="object")return a}}catch{}return null}async _loadPanelLang(t){if(!t||this._panelTrans&&this._panelTrans[t])return;const e=await this._fetchPanelLang(t);e&&(this._panelTrans||(this._panelTrans={}),this._panelTrans[t]=e)}async _loadPanelTranslations(){const t=this._hass&&this._hass.locale&&this._hass.locale.language;await Promise.all([this._loadPanelLang("en"),t&&t!=="en"?this._loadPanelLang(t):Promise.resolve()])}_startPoll(){this._stopPoll(),this._pollTimer=setInterval(()=>this._fetchAll(),this._pollMs)}_stopPoll(){this._pollTimer&&(clearInterval(this._pollTimer),this._pollTimer=null)}async _ws(t){return this._hass.connection.sendMessagePromise(t)}async _fetchAll(){if(!this._hass)return;const t=this._loading;try{if(!this._constantsLoaded){try{const r=await this._ws({type:`${y}/get_constants`});this._constants={stateColors:r.state_colors||{},deviceTypes:r.device_types||[],mlLabEnabled:!!r.ml_lab_enabled,mlSuggestionsEnabled:!!r.ml_suggestions_enabled,mlTrainingAvailable:!!r.ml_training_available,storeOnlineAvailable:!!r.store_online_available,storeOnlineEnabled:!!r.store_online_enabled,storeWebOrigin:r.store_web_origin||"",storePrefs:r.store_prefs||{},pgMatchDefaults:r.pg_match_defaults||{},PROFILE_MIN_WARMUP_CYCLES:r.PROFILE_MIN_WARMUP_CYCLES,version:r.version||"",iconUrl:r.icon_url||""}}catch{}try{this._panelCfg=await this._ws({type:`${y}/get_panel_config`}),this._applyPanelConfig()}catch{}this._constantsLoaded=!0}const e=await this._ws({type:`${y}/get_devices`});if(this._lastFetchErr=null,this._devices=e.devices||[],this._lastRefresh=new Date,this._selIdx===0&&this._devices.length>1){const r=localStorage.getItem("wd-last-device");if(r){const n=this._devices.findIndex(a=>a.entry_id===r);n>0&&(this._selIdx=n)}}const s=this._devices[this._selIdx];if(s&&this._tab==="status"){try{this._powerData=await this._ws({type:`${y}/get_power_history`,entry_id:s.entry_id,with_raw:this._pref("show_raw_active",!1)})}catch{}if(this._pref("show_debug",!1))try{this._matchDebug=await this._ws({type:`${y}/get_match_debug`,entry_id:s.entry_id})}catch{}if(this._canEdit()&&s.recording)try{this._recState=await this._ws({type:`${y}/get_recording_state`,entry_id:s.entry_id})}catch{}}if(s&&s.current_program){if(this._statusEnvName!==s.current_program){this._statusEnvName=s.current_program;try{const r=await this._ws({type:`${y}/get_profile_envelope`,entry_id:s.entry_id,profile_name:s.current_program});this._statusEnv=r.envelope||null}catch{this._statusEnv=null}}await this._ensureStatusPhases(s.entry_id,s.current_program)}else this._statusEnv=null,this._statusEnvName=null,this._statusPhases=[],this._statusPhasesName=null;if(t&&s){if(await this._fetchCycles(s.entry_id),await this._fetchSuggestions(s.entry_id),await this._fetchProfiles(s.entry_id),this._tab==="status")try{this._setupStatus=await this._ws({type:`${y}/get_setup_status`,entry_id:s.entry_id})}catch{this._setupStatus=null}if(this._constants.storeOnlineAvailable){try{const r=await this._ws({type:`${y}/get_options`,entry_id:s.entry_id});this._isActiveEntry(s.entry_id)&&(this._opts=r.options||{})}catch{}this._onlineEnabled()&&await this._loadStoreStatus(s.entry_id)}}this._logOpen&&this._isAdmin()&&this._fetchLogs().then(()=>this._refreshLogDrawer()).catch(()=>{})}catch(e){const s=Pt(e);s!==this._lastFetchErr&&(this._lastFetchErr=s,console.warn("[WashData panel] fetch error -",s,e))}finally{this._loading=!1;const e=this.shadowRoot,s=e&&e.activeElement,r=!!(s&&["SELECT","INPUT","TEXTAREA","OPTION"].includes(s.tagName));t?this._render():this._tab==="status"&&!this._modal&&!r?this._render():this._tab==="status"&&!this._modal&&r?(this._drawStatusCurve(),this._refreshDeviceBar()):this._refreshDeviceBar()}}async _fetchCycles(t){this._cyclesError=!1;try{const e=await this._ws({type:`${y}/get_device_cycles`,entry_id:t,limit:Rt,offset:0});this._cycles=e.cycles||[],this._refCycles=[...e.reference_cycles||[],...e.backfill_cycles||[]],this._cycleOffset=this._cycles.length,this._cyclesTotal=e.total!=null?e.total:this._cycles.length,this._cyclesHasMore=e.has_more!=null?!!e.has_more:!1}catch{this._cyclesError=!0,this._cycles=[],this._refCycles=[],this._cycleOffset=0,this._cyclesTotal=0,this._cyclesHasMore=!1}}async _loadMoreCycles(t){const e=await this._ws({type:`${y}/get_device_cycles`,entry_id:t,limit:Rt,offset:this._cycleOffset}),s=e.cycles||[],r=new Set(this._cycles.map(n=>n.id));for(const n of s)r.has(n.id)||this._cycles.push(n);this._cycleOffset+=s.length,this._cyclesTotal=e.total!=null?e.total:this._cyclesTotal,this._cyclesHasMore=e.has_more!=null?!!e.has_more:s.length>=Rt}async _ensureStatusPhases(t,e){if(!e){this._statusPhases=[],this._statusPhasesName=null;return}if(this._statusPhasesName!==e){this._statusPhasesName=e;try{const s=await this._ws({type:`${y}/get_profile_phases`,entry_id:t,profile_name:e});this._statusPhases=(s.phases||[]).map(r=>({name:r.name,start:r.start,end:r.end}))}catch{this._statusPhases=[]}}}async _fetchSettingsChangelog(t){try{const s=await this._ws({type:`${y}/get_settings_changelog`,entry_id:t});this._settingsChangelog=s.changelog||[]}catch{this._settingsChangelog=this._settingsChangelog||[]}const e={};for(const s of this._settingsChangelog||[])s&&s.key!=null&&!(s.key in e)&&(e[s.key]=s);this._settingsChangeByKey=e}_registerUndo(t){const e="u"+ ++this._undoSeq;return t.timer=setTimeout(()=>this._commitDelete(e),1e4),this._undoBuffer.set(e,t),e}_undoDelete(t){const e=this._undoBuffer.get(t);if(e){this._undoBuffer.delete(t),e.timer&&clearTimeout(e.timer);try{e.restore()}catch{}this._toastTimer&&clearTimeout(this._toastTimer),this._toast=null,this._render()}}async _commitDelete(t){const e=this._undoBuffer.get(t);if(!e)return;this._undoBuffer.delete(t),e.timer&&clearTimeout(e.timer);const s=(r,n)=>{const a=this._devices[this._selIdx],o=a&&a.entry_id;if(e.eid===o){try{e.restore(r)}catch{}this._showToast(n,"error"),this._render()}};try{const r=await e.commit();r&&r.length&&s(r,this._t("toast.delete_partial_failed",{},"Some items could not be deleted and were restored"))}catch(r){s(null,this._t("toast.delete_failed",{error:r&&r.message||r},"Delete failed: "+(r&&r.message||r)))}}_isActiveEntry(t){const e=this._devices[this._selIdx];return!!e&&e.entry_id===t}_flushPendingDeletes(){return!this._undoBuffer||!this._undoBuffer.size?Promise.resolve():Promise.all(Array.from(this._undoBuffer.keys()).map(t=>this._commitDelete(t)))}_deleteCyclesWithUndo(t,e){const s=new Set(e),r=[];if(this._cycles=(this._cycles||[]).filter((i,l)=>s.has(i.id)?(r.push({idx:l,rec:i,ref:!1}),!1):!0),this._refCycles=(this._refCycles||[]).filter((i,l)=>s.has(i.id)?(r.push({idx:l,rec:i,ref:!0}),!1):!0),!r.length)return;this._cycleSel.clear(),this._selectMode=!1,this._render();const n=i=>{const l=i&&i.length?i:r,c=this._cycles.slice(),_=this._refCycles.slice();l.slice().sort((p,g)=>p.idx-g.idx).forEach(({idx:p,rec:g,ref:u})=>{const v=u?_:c;v.splice(Math.min(p,v.length),0,g)}),this._cycles=c,this._refCycles=_},a=async()=>{const i=[];for(const l of r)try{await this._ws({type:`${y}/delete_cycle`,entry_id:t,cycle_id:l.rec.id})}catch{i.push(l)}if(i.length<r.length&&this._isActiveEntry(t))try{await this._fetchProfiles(t)}catch{}return i},o=this._registerUndo({eid:t,restore:n,commit:a});this._showToast(this._t("msg.cycles_deleted",{count:r.length},`${r.length} cycle(s) deleted`),"success",{actionLabel:this._t("btn.undo",{},"Undo"),actionToken:o,duration:1e4})}_deleteProfileWithUndo(t,e){const s=(this._profiles||[]).findIndex(i=>i.name===e),r=s>=0?this._profiles[s]:{name:e};s>=0&&(this._profiles=this._profiles.filter(i=>i.name!==e)),this._modal=null,this._render();const n=i=>{const l=this._profiles.slice();l.splice(Math.min(s<0?l.length:s,l.length),0,r),this._profiles=l},a=async()=>{try{await this._ws({type:`${y}/delete_profile`,entry_id:t,profile_name:e,unlabel_cycles:!0})}catch{return[{idx:s,rec:r}]}try{await this._fetchProfiles(t),this._tab==="profiles"&&this._render()}catch{}return[]},o=this._registerUndo({eid:t,restore:n,commit:a});this._showToast(this._t("msg.profile_deleted",{name:e},"Profile deleted"),"success",{actionLabel:this._t("btn.undo",{},"Undo"),actionToken:o,duration:1e4})}_onKeydown(t){if(!t.defaultPrevented){if(t.key==="Escape"){this._modal&&(t.preventDefault(),this._onModalAction("cancel",null));return}if(this._modal&&t.key==="Tab"){const e=this.shadowRoot,s=e&&e.querySelector('.wd-modal[role="dialog"]');if(s){const r=Ft(s);if(r.length){const n=e.activeElement,a=r.indexOf(n);t.shiftKey?a<=0&&(t.preventDefault(),r[r.length-1].focus()):(a===-1||a===r.length-1)&&(t.preventDefault(),r[0].focus())}}return}if(["ArrowLeft","ArrowRight","Home","End"].includes(t.key)){const e=this.shadowRoot,s=e&&e.activeElement;if(s&&s.classList.contains("wd-tab")){const r=[...e.querySelectorAll("button.wd-tab")],n=r.indexOf(s);if(n===-1)return;let a=n;t.key==="ArrowRight"?a=(n+1)%r.length:t.key==="ArrowLeft"?a=(n-1+r.length)%r.length:t.key==="Home"?a=0:t.key==="End"&&(a=r.length-1),t.preventDefault(),r[a].click(),r[a].focus()}}}}async _loadMlIndex(t){if(this._mlById=this._mlById||{},!!this._constants.mlLabEnabled)try{const e=await this._ws({type:`${y}/get_ml_comparison`,entry_id:t});if(!this._isActiveEntry(t))return;this._mlComparison=e;const s={};for(const r of e&&e.cycles||[])s[r.id]=r;this._mlById=s,this._mlSettings=e&&e.settings_comparison||this._mlSettings,this._mlSettingsByEntry[t]=this._mlSettings}catch{}}async _loadMlSettings(t){if(this._mlSettings=this._mlSettings||{},!!this._constants.mlSuggestionsEnabled)try{const e=this._mlComparison||await this._ws({type:`${y}/get_ml_comparison`,entry_id:t});if(!this._isActiveEntry(t))return;this._mlComparison=e,this._mlSettings=e&&e.settings_comparison||{},this._mlSettingsByEntry[t]=this._mlSettings}catch{}}async _loadMlTrainingStatus(t){if(this._constants.mlTrainingAvailable)try{const e=await this._ws({type:`${y}/get_ml_training_status`,entry_id:t});if(!this._isActiveEntry(t))return;this._mlTrainingStatus=e}catch{}}async _fetchCycleProfileEnv(t,e){if(e)try{const s=await this._ws({type:`${y}/get_profile_envelope`,entry_id:t,profile_name:e}),r=this._modal;r&&r.type==="cycle-detail"&&r.entryId===t&&r.curve&&(r.curve.profile_name||"")===e&&(r.profileEnv=s.envelope||null,this._render())}catch{}}async _fetchSuggestions(t){this._suggestionsError=!1;try{const e=await this._ws({type:`${y}/get_suggestions`,entry_id:t});if(!this._isActiveEntry(t))return;this._suggestions=e.suggestions||[],this._lockedSuggestions=e.locked_suggestions||[],this._lockedByEntry[t]=this._lockedSuggestions}catch{this._isActiveEntry(t)&&(this._suggestionsError=!0,this._suggestions=[])}}async _fetchProfiles(t){this._profilesError=!1;try{const e=await this._ws({type:`${y}/get_profiles`,entry_id:t});this._profiles=e.profiles||[],this._profileHealth=e.profile_health||{},this._profileTrends=e.profile_trends||{},this._coverageGaps=e.coverage_gaps||{},this._profileAdvisories=e.profile_advisories||[]}catch{this._profilesError=!0}return this._profiles}async _ensureProfileEnvs(t,e){this._profileEnvCache=this._profileEnvCache||{};const s=[...new Set(e)].filter(r=>r&&!(r in this._profileEnvCache));return s.length?(await Promise.all(s.map(async r=>{try{const n=await this._ws({type:`${y}/get_profile_envelope`,entry_id:t,profile_name:r});this._profileEnvCache[r]=n&&n.envelope||null}catch{this._profileEnvCache[r]=null}})),this._profileEnvCache):this._profileEnvCache}async _fetchProfileGroups(t){this._profileGroupsError=!1;try{const e=await this._ws({type:`${y}/get_profile_groups`,entry_id:t});this._profileGroups={groups:e.groups||[],suggestions:e.suggestions||[],min_cohesion:e.min_cohesion||.85}}catch{this._profileGroupsError=!0,this._profileGroups={groups:[],suggestions:[],min_cohesion:.85}}return this._profileGroups}async _selectDevice(t){if(t===this._selIdx)return;await this._flushPendingDeletes(),this._selIdx=t;const e=this._devices[t];e&&localStorage.setItem("wd-last-device",e.entry_id),this._pendingSettings={},this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null,this._stagedSuggestions=!1,this._mlComparison=null,this._mlById={},this._mlSettings={},this._profileEnvCache={},this._powerHistory=[],this._powerT0=null,this._statusEnv=null,this._statusEnvName=null,this._statusPhases=[],this._statusPhasesName=null,this._cycleOffset=0,this._cyclesTotal=0,this._cyclesHasMore=!1,this._settingsChangelog=null,this._settingsChangeByKey={},this._powerData={live:[],raw:[],cycle_active:!1,cycle_elapsed_s:0},this._matchDebug=null,this._profiles=[],this._profileHealth={},this._profileTrends={},this._coverageGaps={},this._profileAdvisories=[],this._opts={},this._optDefaults={},this._suggestions=[],this._lockedSuggestions=[],this._cycles=[],this._refCycles=[],this._recState=null,this._diag=null,this._maintenance=null,this._phases=[],this._mlTrainingStatus=null,this._setupStatus=null,this._deviceAutomations=[],this._selectMode=!1,this._cycleSel=new Set,this._cycleFilter={text:"",status:""},this._profSubtab="profiles",this._pgCycleId="",this._pgProfileName="",this._pgPowerPts=null,this._pgDtwData=null,this._pgEnvData=null,this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={},this._pgEffective=null,this._pgPublishable=null,this._pgPresets=[],this._pgPresetSel="",this._pgPresetName="",this._pgPresetLimit=0,this._pgSuggClassic={},this._pgSuggMl=null,this._pgMlSuggEnabled=!1,this._pgView=null,this._pgHoverT=null,this._pgLoadSeq++,this._pgNeedsRestart=!1,this._pgLoading=!1,this._pgDetail=null,this._pgHistory=null,this._pgSweepNew=null,this._pgHistoryTaskId=null,this._pgSweepTaskId=null,this._busy.delete("pg-history"),this._busy.delete("pg-sweep"),this._pgBatchProgress=null,this._pgDetailDebounceTimer&&(clearTimeout(this._pgDetailDebounceTimer),this._pgDetailDebounceTimer=null),this._pgStressTail=!1,this._pgStressIdleW=null,this._storeView="brands",this._storeDevice=null,this._storeProfile=null,this._storeQuery="",this._storeDevices=[],this._storeProfiles=[],this._storeCycles=[],this._storeStatus=null,this._storeConnected=!1,this._storeLoading=!1,clearTimeout(this._brandSearchTimer),this._brandSearchTimer=null,this._catalog={brands:this._catalog.brands,devices:void 0,forBrand:null,approvedOnly:this._catalog.approvedOnly,brandsFull:this._catalog.brandsFull,brandPrefixes:this._catalog.brandPrefixes},this._entityListCache&&delete this._entityListCache.store_model;const s=this._devices[this._selIdx];s&&await this._fetchSuggestions(s.entry_id),this._fetchTabData()}_refreshDeviceBar(){const t=this.shadowRoot;if(!t)return;const e=t.querySelector(".wd-devbar"),s=this._htmlDeviceBar();if(e&&s){const r=document.createElement("div");r.innerHTML=s;const n=r.firstElementChild;n&&(e.replaceWith(n),n.querySelectorAll(".wd-devcard[data-idx]").forEach(a=>a.addEventListener("click",()=>this._selectDevice(parseInt(a.dataset.idx,10)))))}}_refreshLogDrawer(){this._logOpen&&(this._refreshLogViews(),this._refreshLogFilterOptions())}_refreshLogFilterOptions(){const t=this.shadowRoot;if(!t)return;const e=(n,a,o)=>`<option value="">${d(n)}</option>`+a.map(i=>`<option value="${d(i)}" ${i===o?"selected":""}>${d(i)}</option>`).join(""),s=e(this._t("log.all_devices",{},"All devices"),this._logDevices(),this._logDevice),r=e(this._t("log.all_components",{},"All components"),this._logComponents(),this._logComponent);t.querySelectorAll('.wd-log-filter[data-logfilter="device"]').forEach(n=>{n.innerHTML=s,n.value=this._logDevice||""}),t.querySelectorAll('.wd-log-filter[data-logfilter="component"]').forEach(n=>{n.innerHTML=r,n.value=this._logComponent||""})}async _fetchTabData(){const t=this._devices[this._selIdx];if(!t)return;const e=t.entry_id;this._tabLoading=!0,this._render();try{if(this._tab==="status"){if(this._powerData=await this._ws({type:`${y}/get_power_history`,entry_id:e,with_raw:this._pref("show_raw_active",!1)}),this._profiles.length||await this._fetchProfiles(e),!this._profiles.length&&!this._cycles.length&&await this._fetchCycles(e),await this._ensureStatusPhases(e,t.current_program),this._pref("show_debug",!1))try{this._matchDebug=await this._ws({type:`${y}/get_match_debug`,entry_id:e})}catch{}if(this._canEdit())try{this._recState=await this._ws({type:`${y}/get_recording_state`,entry_id:e})}catch{}try{this._setupStatus=await this._ws({type:`${y}/get_setup_status`,entry_id:e})}catch{this._setupStatus=null}}else if(this._tab==="history"){await this._fetchCycles(e),this._profiles.length||await this._fetchProfiles(e);try{const s=await this._ws({type:`${y}/get_feedbacks`,entry_id:e});this._feedbacks=s.feedbacks||[]}catch{}this._constants.storeOnlineAvailable&&this._onlineEnabled()&&this._loadStoreStatus(e).then(()=>{this._tab==="history"&&this._render()}),this._constants.mlLabEnabled&&(this._mlLoading=!0,this._loadMlIndex(e).finally(()=>{this._mlLoading=!1;const s=this._modal;s&&s.type==="cycle-detail"&&!s.ml&&this._mlById[s.cycleId]&&(s.ml=this._mlById[s.cycleId]),(this._tab==="history"||s&&s.type==="cycle-detail")&&this._render()}))}else if(this._tab==="profiles"){if(await this._fetchProfiles(e),this._fetchProfileGroups(e).then(()=>{this._tab==="profiles"&&this._render()}),this._cycles.length||this._fetchCycles(e).then(()=>{this._tab==="profiles"&&this._render()}),this._profSubtab==="phase-catalog")try{const s=await this._ws({type:`${y}/get_phase_catalog`,entry_id:e});this._phases=s.phases||[]}catch{}}else if(this._tab==="settings"){const s=await this._ws({type:`${y}/get_options`,entry_id:e});if(!this._isActiveEntry(e))return;this._opts=s.options||{},this._optDefaults=s.defaults||{},await this._fetchSuggestions(e),await this._fetchSettingsChangelog(e),this._constants.mlSuggestionsEnabled&&(this._mlSettingsLoading=!0,this._loadMlSettings(e).finally(()=>{this._mlSettingsLoading=!1,this._tab==="settings"&&this._renderPreservingFormEdits()})),this._constants.mlTrainingAvailable&&this._loadMlTrainingStatus(e).finally(()=>{this._tab==="settings"&&this._renderPreservingFormEdits()}),this._autoLoading=!0,this._loadDeviceAutomations(e).finally(()=>{this._autoLoading=!1,this._tab==="settings"&&this._renderPreservingFormEdits()}),this._constants.storeOnlineAvailable&&this._loadStoreStatus(e).finally(()=>{this._tab==="settings"&&this._renderPreservingFormEdits()})}else if(this._tab==="store"){try{const s=await this._ws({type:`${y}/get_options`,entry_id:e});if(!this._isActiveEntry(e))return;this._opts=s.options||{}}catch{}if(this._profiles.length||this._fetchProfiles(e),await this._loadStoreStatus(e),!this._isActiveEntry(e))return;this._ensureStoreConnectListener(),this._onlineEnabled()&&this._storeSearch(this._storeBrandScope())}else if(this._tab==="advanced"&&this._panelSubtab==="ml"){const s=await this._ws({type:`${y}/get_options`,entry_id:e});if(!this._isActiveEntry(e))return;this._opts=s.options||{},this._loadMlTrainingStatus(e).finally(()=>{this._tab==="advanced"&&this._panelSubtab==="ml"&&this._renderPreservingFormEdits()})}else if(this._tab==="playground"){if(await Promise.all([(async()=>{try{const s=await this._ws({type:`${y}/get_options`,entry_id:e});this._isActiveEntry(e)&&(this._opts=s.options||{})}catch{}})(),this._pgFetchSettings(e,!1),this._fetchCycles(e),this._profiles.length?Promise.resolve():this._fetchProfiles(e)]),!this._isActiveEntry(e))return;this._pgFetchSuggestions(e),!this._pgCycleId&&this._cycles?.length&&(this._pgCycleId=this._cycles[0].id,this._pgProfileName=""),this._pgNeedsRestart=!1,this._pgRestartRetries=0,this._pgAdoptExisting()}else this._tab==="advanced"&&this._panelSubtab==="maintenance"&&!this._maintenance&&this._fetchMaintenance(e).then(()=>{this._tab==="advanced"&&this._render()})}catch(s){console.warn("[WashData panel] tab data fetch error -",Pt(s),s)}finally{this._tabLoading=!1,this._render()}}async _fetchToolsData(t){try{const e=await this._ws({type:`${y}/get_diagnostics`,entry_id:t});if(!this._isActiveEntry(t))return;this._diag=e.stats||{}}catch(e){console.warn("[WashData panel] tools fetch error -",Pt(e),e),this._diag={_error:String(e&&e.message||e)}}}async _fetchMaintenance(t){try{const e=await this._ws({type:`${y}/get_maintenance_log`,entry_id:t});this._maintenance=e||{}}catch(e){console.warn("[WashData panel] maintenance fetch error -",Pt(e),e),this._maintenance={_error:String(e&&e.message||e)}}}async _fetchLogs(){try{const t=await this._ws({type:`${y}/get_logs`,level:null,limit:500});this._logs=t.logs||[]}catch(t){console.warn("[WashData panel] logs fetch error -",Pt(t),t)}}_logComponents(){return[...new Set((this._logs||[]).map(t=>t.logger).filter(Boolean))].sort()}_logDevices(){return[...new Set((this._logs||[]).map(t=>t.device).filter(Boolean))].sort()}_filteredLogRecords(){const t={DEBUG:10,INFO:20,WARNING:30,ERROR:40,CRITICAL:50},e=this._logLevel&&t[this._logLevel]||0,s=this._logDevice,r=this._logComponent,n=(this._logSearch||"").trim().toLowerCase();return(this._logs||[]).filter(a=>!(e&&(t[a.level]||0)<e||s&&a.device!==s||r&&a.logger!==r||n&&!((a.msg||"").toLowerCase().includes(n)||(a.logger||"").toLowerCase().includes(n))))}_logLinesHtml(){const t=this._filteredLogRecords();return t.length?t.slice().reverse().map(e=>{const s=new Date(e.ts*1e3).toLocaleTimeString(),r=e.device?`<span class="wd-logdev">${d(e.device)}</span>`:"";return`<div class="wd-logline"><span class="wd-logts">${s}</span><span class="wd-loglvl wd-lvl-${d(e.level)}">${d(e.level)}</span><span class="wd-logcomp">${d(e.logger||"")}</span>${r}${d(e.msg)}</div>`}).join(""):(this._logs||[]).length>0?`<p class="wd-info" style="margin:8px 0">${this._t("msg.no_logs_match",{},"No log records match these filters.")}</p>`:`<p class="wd-info" style="margin:8px 0">${this._t("msg.no_logs",{},"No log records buffered yet.")}</p>`}_htmlLogFilters(t){const s=["","DEBUG","INFO","WARNING","ERROR"].map(a=>`<option value="${a}" ${this._logLevel===a?"selected":""}>${a||this._t("log.all_levels",{},"All levels")}</option>`).join(""),r=`<option value="">${d(this._t("log.all_devices",{},"All devices"))}</option>`+this._logDevices().map(a=>`<option value="${d(a)}" ${this._logDevice===a?"selected":""}>${d(a)}</option>`).join(""),n=`<option value="">${d(this._t("log.all_components",{},"All components"))}</option>`+this._logComponents().map(a=>`<option value="${d(a)}" ${this._logComponent===a?"selected":""}>${d(a)}</option>`).join("");return`<select class="wd-log-filter" data-logfilter="level" data-ctx="${t}" style="font-size:.8em;padding:2px 4px">${s}</select>
|
|
<select class="wd-log-filter" data-logfilter="device" data-ctx="${t}" style="font-size:.8em;padding:2px 4px">${r}</select>
|
|
<select class="wd-log-filter" data-logfilter="component" data-ctx="${t}" style="font-size:.8em;padding:2px 4px">${n}</select>
|
|
<input class="wd-log-filter wd-log-search" data-logfilter="search" data-ctx="${t}" type="search" value="${d(this._logSearch)}" placeholder="${d(this._t("log.search_ph",{},"Search logs\u2026"))}" style="font-size:.8em;padding:2px 6px;min-width:120px">`}_refreshLogViews(){const t=this.shadowRoot;if(!t)return;const e=this._logLinesHtml();["wd-log-lines-drawer","wd-log-lines-page"].forEach(s=>{const r=t.getElementById(s);r&&(r.innerHTML=e)})}_syncLogFilters(t){const e=this.shadowRoot;e&&e.querySelectorAll(`.wd-log-filter[data-logfilter="${t.dataset.logfilter}"]`).forEach(s=>{s!==t&&s.value!==t.value&&(s.value=t.value)})}async _fetchRecState(t){try{this._recState=await this._ws({type:`${y}/get_recording_state`,entry_id:t})}catch{}}async _fetchFeedbacks(t){try{const e=await this._ws({type:`${y}/get_feedbacks`,entry_id:t});this._feedbacks=e.feedbacks||[]}catch{}}async _fetchPhases(t){try{const e=await this._ws({type:`${y}/get_phase_catalog`,entry_id:t});this._phases=e.phases||[]}catch{}}_localize(t,e){try{const s=this._hass&&this._hass.localize?this._hass.localize(t):"";return s&&s!==t?s:e}catch{return e}}_tLookup(t,e){const s=this._panelTrans&&(this._panelTrans[e]||this._panelTrans.en);if(!s)return null;const r=t.split(".").reduce((n,a)=>n&&n[a]!==void 0?n[a]:null,s);return r&&typeof r=="string"?r:null}_t(t,e={},s=""){let r;const a=this._panelCfg&&this._panelCfg.prefs&&this._panelCfg.prefs.lang_override||this._hass&&this._hass.locale&&this._hass.locale.language;this._panelTrans?r=a&&this._tLookup(t,a)||this._tLookup(t,"en")||s:r=this._localize(`component.${y}.panel.${t}`,s);for(const[o,i]of Object.entries(e))r=r.replace(new RegExp(`\\{${o}\\}`,"g"),String(i));return r}_stateColor(t){const e=this._constants.stateColors||{};return e[t]||e.unknown||"var(--disabled-color, #bdbdbd)"}_stateLabel(t){const e=(t||"unknown").replace(/_/g," ").replace(/\b\w/g,s=>s.toUpperCase());return this._localize(`component.${y}.entity.sensor.washer_state.state.${t}`,e)}_deviceTypeLabel(t){const e=(this._constants.deviceTypes||[]).find(r=>r.id===t),s=e?e.label:(t||"").replace(/_/g," ");return this._localize(`component.${y}.selector.device_type.options.${t}`,s)}_deviceTypeOpts(t){return(this._constants.deviceTypes||[]).map(e=>[e.id,this._deviceTypeLabel(e.id)])}_deviceOpts(){const t=[["","- None -"]],e=this._hass&&this._hass.devices?this._hass.devices:{};return Object.values(e).forEach(s=>{const r=s.name_by_user||s.name||s.id;t.push([s.id,r])}),t}_applyFontScale(t){let e=parseFloat(t);isFinite(e)||(e=1),e=Math.max(.7,Math.min(2,e)),this.style.fontSize=e===1?"":Math.round(e*1e3)/10+"%"}_applyPanelConfig(){const t=this._panelCfg;if(!t)return;this._applyFontScale(t.prefs&&t.prefs.font_scale||1);const e=t.prefs&&t.prefs.lang_override;e&&!(this._panelTrans&&this._panelTrans[e])&&this._loadPanelLang(e).then(()=>this._render()).catch(()=>{});const s=t.panel||{};if(!this._tabInitialized){const r=t.prefs&&t.prefs.default_tab||s.default_tab;r&&["status","history","profiles","settings","playground"].includes(r)&&(this._tab=r),this._tabInitialized=!0}}_isAdmin(){return!!(this._panelCfg&&this._panelCfg.is_admin)}_curPerm(){const t=this._devices[this._selIdx];return t&&t.perm||"full"}_canEdit(){const t=this._curPerm();return this._isAdmin()||t==="edit"||t==="full"}_canFull(){const t=this._curPerm();return this._isAdmin()||t==="full"}_onlineEnabled(){return!!(this._constants&&this._constants.storeOnlineAvailable&&this._constants.storeOnlineEnabled)}_visibleTabIds(){const t=this._isAdmin(),e=!t&&this._panelCfg&&this._panelCfg.panel&&this._panelCfg.panel.hidden_tabs||[],s=["status","history","profiles"];return this._canEdit()&&s.push("settings"),this._canEdit()&&s.push("playground"),this._canEdit()&&this._onlineEnabled()&&s.push("store"),s.push("advanced"),s.filter(r=>t||!e.includes(r))}async _busyRun(t,e){this._busy.add(t),this._render();try{return await e()}finally{this._busy.delete(t),this._render()}}async _closeCycleDetail(t){const e=this._prevModal;if(this._prevModal=null,e&&e.type==="profile-panel"){if(this._modal=e,this._render(),e.tab==="cleanup")try{const s=await this._ws({type:`${y}/get_profile_cycles`,entry_id:t,profile_name:e.name});this._modal&&this._modal.type==="profile-panel"&&this._modal.name===e.name&&(this._modal.cleanup={cycles:s.cycles||[],selected:new Set},this._render(),this._drawSpaghetti())}catch{}}else this._modal=null,this._render()}_render(){if(!this._container)return;Gt=this._pref("date_format","relative");const t=this.shadowRoot,e=t&&(t.activeElement||this.getRootNode()&&this.getRootNode().activeElement)||null;this._container.innerHTML=this._buildHtml(),this._wire(),this._drawStatusCurve(),this._drawModalCanvas(),this._drawProfileSparklines(),this._drawHistorySparklines(),this._drawPlaygroundCanvases(),["wd-status-canvas","wd-cyc-canvas","wd-compare-canvas","wd-env-canvas","wd-phase-canvas","wd-spag-canvas","wd-pgroup-canvas"].forEach(s=>this._attachHover(s)),this._syncModalFocus(e),requestAnimationFrame(()=>this._resizeLogsPage())}_resizeLogsPage(){const t=this.shadowRoot&&this.shadowRoot.getElementById("wd-log-lines-page");if(!t)return;const e=t.getBoundingClientRect();e.top<=0||(t.style.height=window.innerHeight-e.top-24+"px")}_syncModalFocus(t=null){const e=this.shadowRoot;if(!e)return;const s=e.querySelector('.wd-modal[role="dialog"]');if(s){const r=s.querySelector("h2");if(r&&!r.id&&(r.id="wd-modal-title"),this._modalFocusActive){const n=e.activeElement||this.getRootNode()&&this.getRootNode().activeElement||null;if(!n||!s.contains(n)){const a=Ft(s);try{(a[0]||s).focus()}catch{}}}else{this._modalFocusActive=!0;const n=t||null;n&&!s.contains(n)&&(this._modalReturnFocus=n);const a=Ft(s);try{(a[0]||s).focus()}catch{}}}else if(this._modalFocusActive){this._modalFocusActive=!1;const r=this._modalReturnFocus;if(this._modalReturnFocus=null,r&&r.isConnected&&typeof r.focus=="function")try{r.focus()}catch{}}}_renderPreservingFormEdits(){this._snapshotFormToPending(this.shadowRoot),this._render()}_snapshotCycleReviewForm(t){const e=this._modal;if(!t||!e||e.type!=="cycle-detail"||e.mode!=="review")return;const s=t.getElementById("wd-cyc-rev-quality"),r=t.getElementById("wd-cyc-rev-golden"),n=t.getElementById("wd-cyc-rev-notes"),a=t.getElementById("wd-cyc-rev-label");if(!s&&!r&&!n&&!a)return;e.ml||(e.ml={}),e.ml.ml_review||(e.ml.ml_review={});const o=e.ml.ml_review;s&&(o.quality=s.value||""),r&&(o.golden=!!r.checked),n&&(o.notes=n.value||""),o.tags=Array.from(t.querySelectorAll(".wd-cyc-rev-tag")).filter(i=>i.checked).map(i=>i.value),a&&e.curve&&(e.curve.profile_name=a.value||"")}_buildHtml(){const t=this._toast?`<div class="wd-toast ${this._toast.cls}" role="${this._toast.cls.includes("error")?"alert":"status"}" aria-live="${this._toast.cls.includes("error")?"assertive":"polite"}"><span>${d(this._toast.msg)}</span>${this._toast.actionLabel?`<button type="button" class="wd-toast-action" data-toast-undo="${d(this._toast.actionToken||"")}">${d(this._toast.actionLabel)}</button>`:""}</div>`:"";return`
|
|
<div class="wd-shell">
|
|
${this._htmlHeader()}
|
|
<div class="wd-content-row">
|
|
<div class="wd-main">
|
|
<div class="wd-body">
|
|
${this._loading?`<div class="wd-empty"><div class="wd-icon">\u23F3</div>${this._t("msg.loading",{},"Loading\u2026")}</div>`:this._htmlBody()}
|
|
</div>
|
|
</div>
|
|
${this._logOpen&&this._isAdmin()?this._htmlLogDrawer():'<div class="wd-log-drawer"></div>'}
|
|
</div>
|
|
</div>
|
|
${this._modal?this._htmlModal():""}
|
|
${t}
|
|
`}_htmlHeader(){const e=Array.from(this._busy).some(i=>!(i==="pg-sweep"||i==="pg-history"||i==="reprocess"||i.startsWith("ml-train-now")))?`<span class="wd-badge" style="margin:0 0 0 12px;color:var(--app-header-text-color,#fff);background:rgba(255,255,255,.15)">${this._t("status.working",{},"Working\u2026")}</span>`:"",s=this._constants.iconUrl,r=s?`<img class="wd-logo" src="${d(s)}" width="30" height="30" alt="WashData" aria-hidden="true" style="border-radius:6px;object-fit:contain">`:`<svg class="wd-logo" viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
|
|
<rect x="4" y="2.5" width="16" height="19" rx="2.5"/>
|
|
<line x1="7" y1="6" x2="9.5" y2="6"/>
|
|
<circle cx="12" cy="14" r="5"/>
|
|
<circle cx="12" cy="14" r="2"/>
|
|
</svg>`,n=this._constants.version;return`
|
|
<div class="wd-header">
|
|
${`<button class="wd-burger${this._hass&&this._hass.dockedSidebar==="always_hidden"?" wd-burger--force":""}" id="wd-burger" aria-label="${d(this._t("hdr.toggle_sidebar",{},"Toggle Home Assistant sidebar"))}" title="${d(this._t("hdr.toggle_sidebar",{},"Toggle Home Assistant sidebar"))}">
|
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="17" x2="20" y2="17"/></svg>
|
|
</button>`}
|
|
${r}
|
|
<div><h1>WashData</h1><div class="wd-sub">${n?`v${d(n)} · `:""}${this._t("msg.appliance_monitor",{},"Appliance monitor")}</div></div>
|
|
${e}
|
|
<span class="wd-task-pills" id="wd-task-pills">${this._htmlTaskPills()}</span>
|
|
<span style="flex:1"></span>
|
|
<button class="wd-gear-btn" id="wd-settings-btn" data-action="open-settings" title="${d(this._t("settings.gear.title",{},"Settings"))}" aria-label="${d(this._t("settings.gear.title",{},"Settings"))}"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
|
${this._isAdmin()?`<button class="wd-gear-btn${this._logOpen?" log-active":""}" data-action="toggle-log-drawer" title="${d(this._t("hdr.logs",{},"Logs"))}" aria-label="${d(this._t("hdr.logs",{},"Logs"))}" aria-pressed="${this._logOpen}"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16"/><path d="M4 10h16"/><path d="M4 15h10"/><path d="M4 20h7"/></svg></button>`:""}
|
|
</div>
|
|
`}_htmlBody(){if(!this._devices.length)return`<div class="wd-empty"><div class="wd-icon">\u{1F9FA}</div>${this._t("msg.no_devices",{},"No WashData devices configured yet.")}</div>`;const t=this._mlSugKeys().size,e=this._suggestions.length||t?" \u{1F4A1}":"",s=this._conflictKeysFromOpts().size>0?" \u26A0":"",n=this._busy.has("pg-sim")||this._busy.has("pg-sweep")?'<span class="wd-spin" style="margin-left:4px;vertical-align:middle"></span>':"",a={status:this._t("tab.status",{},"Overview"),history:this._t("tab.history",{},"Cycles"),profiles:this._t("tab.profiles",{},"Profiles"),settings:this._t("tab.settings",{},"Settings")+s+e,playground:this._t("tab.playground",{},"Playground")+n,store:this._t("tab.store",{},"Store"),advanced:this._t("tab.advanced",{},"Advanced")},o=this._visibleTabIds();o.includes(this._tab)||(this._tab="status");const i=o.map(c=>`<button class="wd-tab ${this._tab===c?"active":""}" role="tab" id="wd-tab-${c}" aria-selected="${this._tab===c}" tabindex="${this._tab===c?"0":"-1"}" data-tab="${c}">${a[c]}</button>`).join(""),l=(c,_)=>o.includes(c)?`<div class="wd-pane ${this._tab===c&&!this._tabLoading?"active":""}" role="tabpanel" aria-labelledby="wd-tab-${c}">${_}</div>`:"";return`
|
|
${this._htmlDeviceBar()}
|
|
<div class="wd-tabs" role="tablist">${i}</div>
|
|
${this._tabLoading?`<div class="wd-empty" style="padding:24px"><div class="wd-icon">\u23F3</div>${this._t("msg.loading",{},"Loading\u2026")}</div>`:""}
|
|
${l("status",this._htmlStatus())}
|
|
${l("history",this._htmlHistory())}
|
|
${l("profiles",this._htmlProfiles())}
|
|
${l("settings",this._htmlSettings())}
|
|
${l("playground",this._htmlPlayground())}
|
|
${l("store",this._htmlStore())}
|
|
${l("advanced",this._htmlPanel())}
|
|
`}_htmlDeviceBar(){const t=this._isAdmin()?`<button class="wd-devcard wd-devadd" data-action="add-device" title="${d(this._t("btn.add_device_tip",{},"Add another WashData device"))}">${this._t("btn.add_device",{},"+ Add device")}</button>`:"";return this._devices.length<=1?t?`<div class="wd-devbar">${t}</div>`:"":`<div class="wd-devbar">${this._devices.map((e,s)=>{const r=e.is_user_paused?"user_paused":e.detector_state||"unknown",n=["running","starting","paused","user_paused","ending","anti_wrinkle","rinse"].includes(r),a=!!e.recording,o=a?"var(--error-color, #f44336)":this._stateColor(r),i=a?this._t("status.recording",{},"Recording"):this._stateLabel(r),l=[],c=this._conflictCountForOpts(e.options||{},e.option_defaults||{});c&&l.push(`<span class="wd-dbadge conf">\u26A0 ${c}</span>`);const _=this._sugCountsForDevice(e).total;return _&&l.push(`<span class="wd-dbadge sug">\u{1F4A1} ${_}</span>`),e.feedback_count&&l.push(`<span class="wd-dbadge fb">\u{1F4AC} ${e.feedback_count}</span>`),`<button class="wd-devcard ${s===this._selIdx?"active":""}" data-idx="${s}">
|
|
<span class="wd-devdot ${a||n?"run":""}" style="background:${o}"></span>
|
|
<span><span class="wd-devname">${d(e.title)}</span> <span class="wd-devsub">${d(i)}</span></span>
|
|
${l.join("")}
|
|
</button>`}).join("")}${t}</div>`}_htmlStatus(){const t=this._devices[this._selIdx];if(!t)return`<div class="wd-empty">${this._t("msg.no_device_selected",{},"No device selected.")}</div>`;const e=!!t.is_user_paused,s=e?"user_paused":t.detector_state||"unknown",r=!!t.recording,n=r?"var(--error-color, #f44336)":this._stateColor(s),a=r?this._t("status.recording",{},"Recording"):this._stateLabel(s),o=r||["running","starting","paused","user_paused","ending","anti_wrinkle","rinse"].includes(s),i=t.cycle_progress_pct,l=t.time_remaining_s,c=t.current_program,_=!!t.manual_program,p=c||"auto_detect",g=(this._profiles||[]).map(x=>x.name);c&&!g.includes(c)&&g.unshift(c);const u=g.map(x=>`<option value="${d(x)}" ${p===x?"selected":""}>${d(x)}</option>`).join(""),v=c?_?this._t("badge.manual",{},"(manually selected)"):this._t("badge.auto",{},"(auto-detected)"):"",b=v?`<span class="wd-prog-tag ${_?"manual":"auto"}">${v}</span>`:"",$=`<div class="wd-prog-ctl"><label>${this._t("lbl.program",{},"Program")}</label>${it(this._t("lbl.program_tip",{},"Override which profile is matched to the current cycle. Auto-detect lets the integration pick the best match automatically. Pin a specific program to force-match it when auto-detect is wrong or you know what is running."))}
|
|
<select id="wd-status-prog">
|
|
<option value="auto_detect" ${p==="auto_detect"?"selected":""}>${this._t("status.auto_detect",{},"Auto-detect")}</option>
|
|
${u}
|
|
</select>${b}</div>`,k=[];t.recording&&this._canEdit()&&k.push(`<div class="wd-attn-card"><span class="wd-attn-icon">\u25CF</span><div class="wd-attn-body"><div class="wd-attn-title">${this._t("msg.recording_in_progress",{},"Recording in progress")}</div><div class="wd-attn-sub">${this._t("msg.see_recorder",{},"See recorder widget below")}</div></div></div>`),t.feedback_count&&this._canEdit()&&k.push(`<button class="wd-attn-card" type="button" data-action="goto-feedbacks"><span class="wd-attn-icon">\u{1F4AC}</span><div class="wd-attn-body"><div class="wd-attn-title">${this._t("msg.feedback_cycles_pending",{n:t.feedback_count},`To review: ${t.feedback_count}`)}</div><div class="wd-attn-sub">${this._t("msg.review_to_cycles",{},"Open the Cycles review queue")}</div></div></button>`);const S=this._conflictKeysFromOpts();if(S.size&&this._canEdit()){const x=S.size,L=x>1?"s":"";k.push(`<button class="wd-attn-card" type="button" style="border-color:var(--error-color,#b71c1c)" data-action="goto-conflicts"><span class="wd-attn-icon">\u26A0</span><div class="wd-attn-body"><div class="wd-attn-title" style="color:var(--error-color,#b71c1c)">${this._t("conflict.attn_title",{n:x},`Setting conflicts: ${x}`)}</div><div class="wd-attn-sub">${this._t("conflict.attn_sub",{},"Fix conflicts before saving")}</div></div></button>`)}const C=this._sugCountsForDevice(t);if(C.total&&this._canEdit()){const x=C.total,L=[];C.classic&&L.push(this._t("lbl.n_classic_suggestions",{n:C.classic},`${C.classic} classic`)),C.ml&&L.push(this._t("lbl.n_ml_suggestions",{n:C.ml},`${C.ml} ML`)),k.push(`<button class="wd-attn-card" type="button" data-action="goto-suggestions"><span class="wd-attn-icon">\u{1F4A1}</span><div class="wd-attn-body"><div class="wd-attn-title">${this._t("lbl.n_tuning_suggestions",{n:x},`${x} tuning suggestion${x>1?"s":""}`)}</div><div class="wd-attn-sub">${L.join(" \xB7 ")} \xB7 ${this._t("msg.review_in_settings",{},"Review in Settings")}</div></div></button>`)}const R=k.length?`<div class="wd-attn">${k.join("")}</div>`:"",B=o&&i!=null?`
|
|
<div class="wd-prog-bg"><div class="wd-prog-fill" style="width:${Math.min(100,i)}%"></div></div>
|
|
<div class="wd-prog-row"><span>${i.toFixed(1)}%</span>${l!=null?`<span>${this._t("lbl.time_remaining",{v:ot(l)},`~${ot(l)} remaining`)}</span>`:""}</div>
|
|
`:"",O=((this._powerData||{}).live||[]).length>1,D=this._pref("show_expected",!0),A=this._pref("show_raw_active",!1),E=`<div class="wd-leg">
|
|
<span class="wd-leg-i"><span class="wd-leg-sw" style="background:var(--primary-color)"></span> ${this._t("lbl.power",{},"Power")}</span>
|
|
${this._statusEnv?`<label class="wd-leg-i"><input type="checkbox" data-statustoggle="show_expected" ${D?"checked":""}><span class="wd-leg-sw" style="background:#ff9800"></span> ${this._t("lbl.expected",{},"Expected")}</label>`:""}
|
|
${this._pref("show_raw",!1)?`<label class="wd-leg-i"><input type="checkbox" data-statustoggle="show_raw_active" ${A?"checked":""}><span class="wd-leg-sw" style="background:#9e9e9e"></span> ${this._t("lbl.raw_socket",{},"Raw socket")}</label>`:""}
|
|
</div>`,M=this._cyclesTotal||0,H=(this._profiles||[]).length,q=this._pref("setup_card_dismissed",!1),Q=this._setupStatus,J=Q&&!O,G=J?this._htmlSetupCard(Q,q):"",X=O?`<div class="wd-canvas-wrap" style="margin-top:14px"><canvas id="wd-status-canvas" role="img" aria-label="${d(this._t("lbl.aria_power_chart",{},"Power consumption chart"))}" style="height:160px"></canvas></div>${E}`:J?G:`<p class="wd-info" style="margin-top:12px">${this._t("msg.live_chart_loading",{},"Live power chart appears as readings arrive.")}</p>`,ct=this._pref("show_debug",!1);let U="";if(ct){const x=this._matchDebug||{},L=x.confidence!=null?`${(x.confidence*100).toFixed(1)}%`:"-",j=(x.candidates||[]).map(I=>`<tr><td>${d(I.profile_name)}</td><td>${I.confidence_pct}%</td><td>${I.mae}</td><td>${I.correlation}</td><td>${I.duration_ratio>=0?"+":""}${I.duration_ratio}%</td></tr>`).join("");U=`<div class="wd-card">
|
|
<div class="wd-card-title">Live Match Debug ${it("Confidence: how closely the current power curve matches the top candidate profile (0-100%). Ambiguous: the two best candidates score within 5% of each other - the label is uncertain until the cycle finishes.")}</div>
|
|
<div class="wd-kv" style="margin-bottom:12px">
|
|
<div class="wd-kv-item"><div class="wd-kv-val">${L}</div><div class="wd-kv-lbl">${this._t("lbl.confidence",{},"Confidence")}</div></div>
|
|
<div class="wd-kv-item"><div class="wd-kv-val" style="font-size:1em;color:${x.ambiguous?"var(--warning-color,#ff9800)":"var(--success-color,#4caf50)"}">${x.ambiguous?this._t("status.ambiguous",{},"Ambiguous"):this._t("status.clear",{},"Clear")}</div><div class="wd-kv-lbl">${this._t("lbl.label",{},"Match")}</div></div>
|
|
</div>
|
|
${j?`<table class="wd-table"><thead><tr><th>Profile</th><th>Conf</th><th>MAE</th><th>Corr</th><th>Duration</th></tr></thead><tbody>${j}</tbody></table>`:`<p class="wd-info">${this._t("msg.no_match_yet",{},"No match attempt yet - this populates during a running cycle.")}</p>`}
|
|
</div>`}const at=[];this._canEdit()&&at.push(`<button class="wd-attn-card" type="button" data-action="open-advanced" data-sub="diagnostics"><span class="wd-attn-icon">\u{1FA7A}</span><div class="wd-attn-body"><div class="wd-attn-title">${this._t("hdr.logs_diagnostics",{},"Diagnostics")}</div><div class="wd-attn-sub">${this._t("msg.storage_diagnostics",{},"Storage stats, maintenance, export/import")}</div></div></button>`),at.push(`<button class="wd-attn-card" type="button" data-action="open-settings"><span class="wd-attn-icon">\u2699\uFE0F</span><div class="wd-attn-body"><div class="wd-attn-title">${this._t("settings.gear.title",{},"Settings")}</div><div class="wd-attn-sub">${this._isAdmin()?this._t("msg.preferences_admin",{},"Preferences, panel & access control"):this._t("msg.preferences_adv",{},"Preferences")}</div></div></button>`);const f=`<div class="wd-card"><div class="wd-card-title">${this._t("hdr.tools_and_data",{},"Tools & Data")}</div><div class="wd-attn" style="margin-bottom:0;margin-top:12px">${at.join("")}</div></div>`,m=(()=>{if(!this._canEdit())return"";const L=["running","starting","ending","anti_wrinkle","rinse"].includes(s),j=L&&!e,I=e,w=L||e;return!j&&!I&&!w?"":`<div class="wd-cycle-ctrl" style="margin-top:0">
|
|
${I?`<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="resume-cycle" title="${d(this._t("btn.resume_cycle_tip",{},"Resume the paused cycle"))}">${this._t("btn.resume_cycle",{},"Resume")}</button>`:""}
|
|
${j?`<button class="wd-btn wd-btn-sm" data-action="pause-cycle" title="${d(this._t("btn.pause_cycle_tip",{},"Pause the running cycle \u2014 the appliance will resume where it left off"))}">${this._t("btn.pause_cycle",{},"Pause")}</button>`:""}
|
|
${w?`<button class="wd-btn wd-btn-sm wd-btn-danger" data-action="terminate-cycle" title="${d(this._t("btn.force_stop_tip",{},"Immediately end the current cycle and mark it as force-stopped"))}">${this._t("btn.force_stop",{},"Force Stop")}</button>`:""}
|
|
</div>`})();return`
|
|
${R}
|
|
<div class="wd-card">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:10px">
|
|
<div class="wd-card-title" style="margin:0">${d(t.title)}</div>
|
|
${m}
|
|
</div>
|
|
<div class="wd-badge ${o?"wd-running":""}" style="color:${n};background:color-mix(in srgb, ${n} 13%, transparent);">
|
|
<span class="wd-dot"></span>${d(a)}
|
|
${!r&&t.sub_state&&t.sub_state.toLowerCase()!==s?`<span style="opacity:.7;font-size:.85em">(${d(t.sub_state)})</span>`:""}
|
|
</div>
|
|
${$}
|
|
<div class="wd-stats">
|
|
<div class="wd-stat"><div class="wd-stat-val">${Ut(t.current_power_w)}</div><div class="wd-stat-lbl">${this._t("lbl.power",{},"Power")}</div></div>
|
|
<div class="wd-stat"><div class="wd-stat-val">${i!=null?i.toFixed(0)+"%":"-"}</div><div class="wd-stat-lbl">${this._t("lbl.progress",{},"Progress")}</div></div>
|
|
<div class="wd-stat"><div class="wd-stat-val">${ot(l)}</div><div class="wd-stat-lbl">${this._t("lbl.remaining",{},"Remaining")}</div></div>
|
|
</div>
|
|
${B}
|
|
${this._htmlPhaseTimeline(t,i,o)}
|
|
${J?"":`<div class="wd-card-title" style="margin-top:18px">${this._t("hdr.live_power",{},"Live Power")}</div>`}
|
|
${X}
|
|
</div>
|
|
${this._canEdit()?this._htmlRecordingWidget():""}
|
|
${U}
|
|
${f}
|
|
`}_htmlSetupCard(t,e=!1){if(!t)return"";const{phase:s,message_key:r,message_params:n,cta_label_key:a,cta_action:o,secondary_label_key:i,secondary_action:l,skippable:c,dismissible:_,step_key:p}=t;if(s==="phase4")return`
|
|
<div class="wd-setup-chip wd-setup-chip--healthy" data-action="expand-setup" role="button" tabindex="0"
|
|
title="${d(this._t("setup.cta.show_guidance",{},"Show guidance"))}">
|
|
<span class="wd-setup-dot wd-setup-dot--green"></span>
|
|
<span>${this._t("setup.hdr.healthy_chip",{},"Setup complete")}</span>
|
|
</div>`;if(s==="phase3"&&e)return`
|
|
<div class="wd-setup-chip" data-action="expand-setup" role="button" tabindex="0"
|
|
title="${d(this._t("setup.cta.show_guidance",{},"Show guidance"))}">
|
|
<span class="wd-setup-dot" style="background:var(--warning-color,#ff9800)"></span>
|
|
<span>${this._t("setup.hdr.card",{},"Device Setup")}</span>
|
|
</div>`;const g=this._t(r,n||{},""),u=this._t(a,{},"Continue"),v=i?this._t(i,{},""):null,b=c?`
|
|
<span style="display:flex;gap:12px;margin-top:6px">
|
|
<a class="wd-link" data-action="setup-skip" data-step="${d(p||"")}" data-snooze="14d"
|
|
style="font-size:.85em">${this._t("setup.cta.skip_step",{},"Skip this step")}</a>
|
|
<a class="wd-link" data-action="setup-skip" data-step="${d(p||"")}" data-snooze="never"
|
|
style="font-size:.85em;opacity:.7">${this._t("setup.cta.skip_forever",{},"Don't show again")}</a>
|
|
</span>`:"",$=_?`
|
|
<a class="wd-link" data-action="hide-setup-card" style="font-size:.8em;opacity:.6;margin-top:4px">
|
|
${this._t("setup.cta.hide_guidance",{},"Hide guidance")}
|
|
</a>`:"";return`
|
|
<div class="wd-setup-card" data-phase="${d(s)}">
|
|
<div class="wd-card-title">${this._t("setup.hdr.card",{},"Device Setup")}</div>
|
|
<p style="margin:6px 0 12px">${d(g)}</p>
|
|
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
|
<button class="wd-btn wd-btn-primary" data-action="setup-cta"
|
|
data-cta-action="${d(o||"")}"
|
|
data-cta-params="${d(JSON.stringify(n||{}))}">
|
|
${d(u)}
|
|
</button>
|
|
${v?`<a class="wd-link" data-action="setup-cta"
|
|
data-cta-action="${d(l||"")}"
|
|
data-cta-params="${d(JSON.stringify(n||{}))}">
|
|
${d(v)}
|
|
</a>`:""}
|
|
</div>
|
|
${b}
|
|
${$}
|
|
</div>`}_dispatchSetupCta(t,e){if(t){if(t==="open_recorder"){this.shadowRoot.querySelector(".wd-rec-dot")?.closest(".wd-card")?.scrollIntoView({behavior:"smooth"});return}if(t==="open_cycles"||t==="open_cycles_unlabeled"){this._tab="history",t==="open_cycles_unlabeled"&&(this._cycleFilter={...this._cycleFilter||{},status:"unlabeled"}),this._fetchTabData();return}if(t==="open_profiles"||t==="open_profiles_groups"){this._tab="profiles",this._fetchTabData();return}if(t==="open_suggestions"){this._tab="settings",this._settingsSugOnly=!0,this._fetchTabData();return}if(t==="create_profile_from_cluster"){this._tab="profiles",this._fetchTabData();return}if(t&&t.startsWith("open_cycle:")){this._tab="history",this._fetchTabData();return}}}async _reloadSetupStatus(){const t=this._devices[this._selIdx];if(t){try{this._setupStatus=await this._ws({type:`${y}/get_setup_status`,entry_id:t.entry_id})}catch{this._setupStatus=null}this._render()}}_htmlPhaseTimeline(t,e,s){const r=this._statusPhases||[];if(!s||!r.length||!t.current_program)return"";let n=this._statusEnv&&this._statusEnv.target_duration||0;if(!n){const _=(this._profiles||[]).find(p=>p.name===t.current_program);n=_&&_.avg_duration||0}if(n||(n=Math.max(1,...r.map(_=>_.end||0))),n<=0)return"";const a=e!=null?Math.min(1,Math.max(0,e/100)):null;let o="";const i=r.map((_,p)=>{const g=Math.max(0,Math.min(1,(_.start||0)/n)),u=Math.max(0,Math.min(1,(_.end||0)/n)),v=Math.max(0,(u-g)*100),b=et[p%et.length],$=a==null?!0:g<=a;a!=null&&a>=g&&a<u&&(o=_.name||"");const k=_.name&&v>12?`<span class="wd-ptl-seg-lbl">${d(_.name)}</span>`:"";return`<div class="wd-ptl-seg" style="left:${(g*100).toFixed(2)}%;width:${v.toFixed(2)}%;background:${b};opacity:${$?.85:.28}" title="${d(_.name||"")}">${k}</div>`}).join(""),l=a!=null?`<div class="wd-ptl-cursor" style="left:${(a*100).toFixed(2)}%"></div>`:"",c=o?`<div class="wd-ptl-cur">${this._t("lbl.current_phase",{},"Current phase")}: <b>${d(o)}</b></div>`:"";return`<div class="wd-ptl-wrap">
|
|
<div class="wd-ptl" role="img" aria-label="${d(this._t("lbl.phase_timeline",{},"Phase timeline"))}">${i}${l}</div>
|
|
${c}
|
|
</div>`}_htmlRecordingWidget(){const t=this._recState,e=this._devices[this._selIdx],r=!!(e&&e.recording)?"recording":t?t.state:"idle",n=r==="recording"?"wd-rec-active":r==="stopped"?"wd-rec-ready":"wd-rec-idle",a=r==="recording"?this._t("status.recording",{},"Recording\u2026"):r==="stopped"?this._t("status.ready",{},"Ready to process"):this._t("status.idle",{},"Idle");let o="";r==="recording"?o=t?`${ot(t.duration_s)} \xB7 ${t.sample_count||0} samples`:"":r==="stopped"&&(o=`${t.sample_count||0} samples \xB7 ${ot(t.duration_s)}`);const i=r==="recording"?`<button class="wd-btn wd-btn-danger wd-btn-sm" data-action="rec-stop" title="${d(this._t("btn.rec_stop_tip",{},"Stop recording and hold the captured trace for review"))}">${this._t("btn.rec_stop",{},"Stop")}</button>`:r==="stopped"?`<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="rec-process-open" title="${d(this._t("btn.process_tip",{},"Save the recorded trace as a new or existing profile"))}">${this._t("btn.process",{},"Process")}</button>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="rec-discard" title="${d(this._t("btn.discard_tip",{},"Discard the recorded trace without saving"))}">${this._t("btn.discard",{},"Discard")}</button>`:`<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="rec-start" title="${d(this._t("btn.rec_start_tip",{},"Begin recording the appliance's power trace \u2014 start just before running a cycle"))}">${this._t("btn.record",{},"Start Recording")}</button>`;return`<div class="wd-card" style="margin-top:0">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<div class="wd-rec-dot ${n}"></div>
|
|
<div><strong>${this._t("hdr.manual_recording",{},"Manual Recording")}</strong>${it(this._t("hdr.manual_recording_tip",{},"Run a cycle intentionally while WashData records the power trace. Start just before the appliance starts, Stop when it finishes, then Process to save it as a named profile."))}${o?`<span class="wd-field-hint" style="margin-left:8px">${o}</span>`:""}</div>
|
|
</div>
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap">${i}</div>
|
|
</div>
|
|
</div>`}_htmlHistory(){const t=this._cycles||[],e=this._refCycles||[],s=e.concat(t),r=this._canEdit(),n=this._selectMode&&r,a=this._cycleSel,{col:o,dir:i}=this._cycleSort,{text:l,status:c}=this._cycleFilter,_=this._mlById||{},p=new Set((this._feedbacks||[]).map(w=>w.cycle_id)),g=w=>_[w.id],u=w=>{const P=g(w);return!!(P&&P.ml_review&&P.ml_review.reviewed_at)},v=w=>{const P=g(w);return!!(P&&P.ml_review&&P.ml_review.golden)},b=w=>{if(p.has(w.id))return!0;if(u(w))return!1;const P=g(w),z=P&&P.ml_quality_label;return["uncertain","review"].includes(z)||["force_stopped","interrupted"].includes(w.status)},$=s.filter(b).length;let k=s;if(l){const w=l.toLowerCase();k=k.filter(P=>(P.profile_name||P.matched_profile||"").toLowerCase().includes(w))}c==="unlabelled"?k=k.filter(w=>!w.profile_name&&!w.matched_profile):c==="needs_review"?k=k.filter(b):c==="imported"?k=k.filter(w=>w.is_reference):c&&(k=k.filter(w=>(w.status||"completed")===c));const S={date:w=>w.start_time?new Date(w.start_time).getTime():0,confidence:w=>w.match_confidence,duration:w=>w.duration,energy:w=>w.energy_kwh!=null?w.energy_kwh:w.energy_wh!=null?w.energy_wh/1e3:null,cost:w=>w.cost!=null?w.cost:-1,status:w=>w.status||"completed",profile:w=>(w.profile_name||w.matched_profile||"\uFFFF").toLowerCase()};k=Qt(k,S[o]||S.date,i);const C=w=>w==="completed"?"var(--success-color, #4caf50)":w==="interrupted"?"var(--error-color, #f44336)":w==="force_stopped"?"var(--warning-color, #ff9800)":"var(--secondary-text-color)",R=w=>{if(!w.is_reference)return"";const P=w.cycle_origin==="backfill",z=P?this._t("badge.backfilled_tip",{},"Detected in imported power history. Shapes program matching only, not counted in stats."):this._t("badge.imported_tip",{},"Imported from the community store. Used for matching only, not counted in stats.");return` <span title="${d(z)}" style="color:var(--info-color,#2196f3)">${P?"\u{1F557}":"\u{1F4E5}"}</span>`},B=w=>v(w)?' <span title="'+d(this._t("badge.golden_cycle",{},"Recorded reference cycle"))+'" style="color:var(--warning-color,#ff9800)">\u2B50</span>':p.has(w.id)?' <span title="'+d(this._t("badge.feedback_requested",{},"Feedback requested"))+'" style="color:var(--info-color,#2196f3)">\u{1F4AC}</span>':u(w)?' <span title="'+d(this._t("badge.reviewed",{},"Reviewed"))+'" style="color:var(--success-color,#4caf50)">\u2713</span>':b(w)?' <span title="'+d(this._t("badge.needs_review",{},"Needs review"))+'" style="color:var(--error-color,#f44336)">\u25CF</span>':"",W=w=>{if(w.anomaly!=="overrun")return"";const P=w.overrun_ratio?" "+this._t("badge.overrun_ratio",{x:Number(w.overrun_ratio).toFixed(1)},`(${Number(w.overrun_ratio).toFixed(1)}x expected)`):"";return` <span title="${d(this._t("badge.overrun",{},"Ran longer than usual"))}${d(P)}" style="color:var(--warning-color,#ff9800)">\u23F1</span>`},O=w=>{if(w.anomaly!=="underrun")return"";const P=w.underrun_ratio?" "+this._t("badge.underrun_ratio",{pct:Math.round(w.underrun_ratio*100)},`(${Math.round(w.underrun_ratio*100)}% of expected)`):"";return` <span title="${d(this._t("badge.underrun",{},"Finished faster than usual"))}${d(P)}" style="color:var(--info-color,#2196f3)">\u26A1</span>`},D=w=>{if(!w.energy_anomaly||w.energy_anomaly==="none")return"";const P=w.energy_anomaly==="energy_spike",z=w.energy_z_score!=null?` (${w.energy_z_score>0?"+":""}${Number(w.energy_z_score).toFixed(1)}\u03C3)`:"",F=P?"badge.energy_spike":"badge.energy_low",K=P?"Higher energy than usual":"Lower energy than usual",Z=P?"\u{1F53A}":"\u{1F53B}",rt=P?"var(--error-color,#f44336)":"var(--info-color,#2196f3)";return` <span title="${d(this._t(F,{},K))}${d(z)}" style="color:${rt}">${Z}</span>`},A=w=>{const P=Array.isArray(w.artifacts)?w.artifacts.length:0;return P?` <span title="${d(this._t("badge.artifact_tip",{n:P},`${P} anomal${P>1?"ies":"y"} detected (e.g. door opened mid-cycle) \u2014 open to see them on the graph`))}" style="color:var(--warning-color,#ff9800)">\u26A0</span>`:""},E=w=>{const P=Array.isArray(w.restart_gaps)?w.restart_gaps.length:0;return P?` <span title="${d(this._t("badge.restart_gap_tip",{n:P},`${P} HA restart gap${P>1?"s":""} during this cycle \u2014 power trace has a hole`))}" style="color:var(--info-color,#2196f3)">\u21BB</span>`:""},M=this._hass&&this._hass.config&&this._hass.config.currency||"",H=w=>w.cost!=null?`${w.cost.toFixed(2)}${M?" "+M:""}`:"-",q=k.map(w=>{const P=w.profile_name||w.matched_profile,z=w.match_confidence!=null?w.match_confidence*100:null,F=w.status||"completed",K=w.energy_kwh!=null?w.energy_kwh:w.energy_wh!=null?w.energy_wh/1e3:null,Z=n,rt=Z?`<input type="checkbox" class="wd-csel" ${a.has(w.id)?"checked":""} style="width:auto;margin:0">`:`<span class="wd-devdot" style="background:${C(F)}" title="${d(F)}"></span>`,ht={completed:this._t("status.completed",{},"Completed"),interrupted:this._t("status.interrupted",{},"Interrupted"),force_stopped:this._t("status.force_stopped",{},"Force stopped"),active:this._t("status.active",{},"Active")}[F]||F,nt=`${R(w)}${B(w)}${W(w)}${O(w)}${D(w)}${A(w)}${E(w)}`.trim();return`<tr data-cid="${d(w.id)}" data-selmode="${Z?1:0}" style="cursor:pointer">
|
|
<td style="width:26px;padding:6px 4px 6px 8px">${rt}</td>
|
|
<td>${P?d(P):`<span style="color:var(--secondary-text-color)">${this._t("lbl.unlabelled",{},"Unlabelled")}</span>`}</td>
|
|
<td class="wd-tc-flags">${nt}</td>
|
|
<td><span style="color:${C(F)};font-size:.9em">${d(ht)}</span></td>
|
|
<td class="wd-tc-date">${st(w.start_time)}</td>
|
|
<td class="wd-tc-num">${ot(w.duration)}</td>
|
|
<td class="wd-tc-num">${K!=null?gt(K):"-"}</td>
|
|
<td class="wd-tc-num">${H(w)}</td>
|
|
<td class="wd-tc-num">${z!=null?z.toFixed(0)+"%":"-"}</td>
|
|
</tr>`}).join(""),Q=`<thead><tr>
|
|
<th style="width:26px;padding:6px 4px 6px 8px"></th>
|
|
${ft(this._t("lbl.profile",{},"Profile"),"profile",o==="profile",i,"cycsort","",this._t("col.profile_tip",{},"Matched program name. Unlabelled means no profile matched at end of cycle."))}
|
|
<th class="wd-tc-flags" title="${d(this._t("col.flags_tip",{},"Review, anomaly and source flags for the cycle. Hover an icon for detail."))}">${this._t("lbl.flags",{},"Flags")}</th>
|
|
${ft(this._t("lbl.status",{},"Status"),"status",o==="status",i,"cycsort","",this._t("col.status_tip",{},"Cycle outcome: Completed (natural end), Interrupted (abrupt power drop), Force Stopped (manual), or Needs Review (feedback pending)."))}
|
|
${ft(this._t("lbl.date",{},"Date"),"date",o==="date",i,"cycsort","",this._t("col.date_tip",{},"Date and time the cycle started."))}
|
|
${ft(this._t("lbl.duration",{},"Duration"),"duration",o==="duration",i,"cycsort","right",this._t("col.duration_tip",{},"Total cycle run time from start to end."))}
|
|
${ft(this._t("lbl.energy",{},"Energy"),"energy",o==="energy",i,"cycsort","right",this._t("col.energy_tip",{},"Total energy consumed (kWh). Computed by integrating power over time."))}
|
|
${ft(this._t("lbl.cost",{},"Cost"),"cost",o==="cost",i,"cycsort","right",this._t("col.cost_tip",{},"Energy cost for this cycle, frozen at completion using the price in effect then (energy x price per kWh). Set a price under Settings to populate it."))}
|
|
${ft(this._t("lbl.confidence",{},"Confidence"),"confidence",o==="confidence",i,"cycsort","right",this._t("col.confidence_tip",{},"Profile match confidence (0-100%). How closely the cycle power curve matched the identified program."))}
|
|
</tr></thead>`,J=`<div class="wd-filter-bar">
|
|
<input type="text" class="wd-filter-input" id="wd-cyc-filter-text" placeholder="${d(this._t("msg.filter_by_profile",{},"Filter by profile\u2026"))}" value="${d(l)}" autocomplete="off">
|
|
<select id="wd-cyc-filter-status" class="wd-filter-select">
|
|
<option value="" ${c?"":"selected"}>${this._t("status.all_statuses",{},"All statuses")}</option>
|
|
<option value="needs_review" ${c==="needs_review"?"selected":""}>${this._t("badge.needs_review",{},"Needs review")}${$?` (${$})`:""}</option>
|
|
<option value="completed" ${c==="completed"?"selected":""}>${this._t("status.completed",{},"Completed")}</option>
|
|
<option value="interrupted" ${c==="interrupted"?"selected":""}>${this._t("status.interrupted",{},"Interrupted")}</option>
|
|
<option value="force_stopped" ${c==="force_stopped"?"selected":""}>${this._t("status.force_stopped",{},"Force stopped")}</option>
|
|
<option value="unlabelled" ${c==="unlabelled"?"selected":""}>${this._t("lbl.unlabelled",{},"Unlabelled")}</option>
|
|
${e.length?`<option value="imported" ${c==="imported"?"selected":""}>${this._t("status.imported",{},"Imported")} (${e.length})</option>`:""}
|
|
</select>
|
|
</div>`,G=k.length!==s.length?this._t("lbl.n_shown",{n:k.length},`, ${k.length} shown`):"",X=e.length?this._t("lbl.n_imported_note",{n:e.length},`, ${e.length} imported`):"",ct=this._t("lbl.cycles_title",{n:`${t.length}${X}${G}`},`Cycles (${t.length}${X}${G})`),U=r?`<div class="wd-card-actions" style="margin:0 0 4px;justify-content:flex-end">
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cyc-auto-open" title="${d(this._t("btn.auto_label_cycles_tip",{},"Automatically assign profile names to unlabelled cycles whose match confidence clears the threshold"))}">${this._t("btn.auto_label_cycles",{},"Auto-label cycles")}</button>
|
|
<button class="wd-btn ${n?"wd-btn-primary":"wd-btn-secondary"} wd-btn-sm" data-action="cyc-select-toggle">${n?this._t("btn.done",{},"Done"):this._t("btn.select",{},"Select")}</button>
|
|
</div>`:"",at=new Set(e.map(w=>w.id)),f=[...a].some(w=>at.has(w)),m=n?`<div class="wd-card-actions" style="margin:0 0 10px">
|
|
<span class="wd-info" style="margin:0">${this._t("lbl.n_selected",{n:a.size},`${a.size} selected`)}</span>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cyc-compare" ${a.size<2?"disabled":""}>${this._t("btn.compare",{},"Compare")}${a.size>=2?` (${a.size})`:""}</button>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cyc-merge" ${a.size<2||f?"disabled":""}${f?` title="${d(this._t("msg.merge_no_imports",{},"Imported recordings cannot be merged into a real cycle. Deselect them to merge."))}"`:""}>${this._t("btn.merge",{},"Merge")}${a.size>=2?` (${a.size})`:""}</button>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cyc-relabel" ${a.size<1?"disabled":""}>${this._t("btn.relabel",{count:a.size},`Relabel (${a.size})`)}</button>
|
|
<button class="wd-btn wd-btn-danger wd-btn-sm" data-action="cyc-bulk-del" ${a.size<1?"disabled":""}>${this._t("btn.delete",{},"Delete")}${a.size>=1?` (${a.size})`:""}</button>
|
|
</div>`:"",x=this._busy.has("cyc-load-more"),L=this._cyclesHasMore?`<div style="text-align:center;margin-top:12px">
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cyc-load-more" ${x?"disabled":""}>${x?'<span class="wd-spin"></span> ':""}${this._t("btn.load_more",{},"Load more")}</button>
|
|
</div>`:"",j=`
|
|
<div class="wd-card">
|
|
<div class="wd-card-title">${ct}</div>
|
|
${J}
|
|
${U}${m}
|
|
${k.length===0?`<div class="wd-empty" style="padding:24px"><div class="wd-icon">\u{1F4CB}</div>${s.length?this._t("msg.no_cycles_match",{},"No cycles match the current filter."):this._t("msg.no_cycles_yet",{},"No cycles recorded yet.")}</div>`:`<div class="wd-table-wrap"><table class="wd-table">${Q}<tbody>${q}</tbody></table></div>`}
|
|
${L}
|
|
</div>`;return(this._cyclesError?`<div class="wd-error-state"><span>${this._t("msg.fetch_error",{},"Failed to load data.")}</span><button class="wd-btn" type="button" data-action="retry-cycles">${this._t("btn.retry",{},"Retry")}</button></div>`:"")+j}_trendIcon(t){return t==="up"?`<span title="${d(this._t("trend.up",{},"Trending up"))}" style="color:var(--warning-color,#ff9800)">\u2191</span>`:t==="down"?`<span title="${d(this._t("trend.down",{},"Trending down"))}" style="color:var(--info-color,#2196f3)">\u2193</span>`:""}_profileCardHtml(t){const e=t.avg_duration?this._t("lbl.duration_avg",{v:Math.round(t.avg_duration/60)},`~${Math.round(t.avg_duration/60)}m avg`):this._t("lbl.no_duration",{},"no duration"),s=t.avg_energy!=null?` \xB7 ${gt(t.avg_energy)}/cycle`:"",r=t.avg_energy!=null&&t.cycle_count?` \xB7 <strong>${gt(t.avg_energy*t.cycle_count)}</strong> total`:"",n=this._hass&&this._hass.config&&this._hass.config.currency||"",a=t.avg_cost!=null?` \xB7 ${this._t("lbl.avg_cost",{},"Avg")} ${t.avg_cost.toFixed(2)}${n?" "+n:""}/${this._t("lbl.per_cycle_short",{},"cycle")}`:"",o=(this._profileHealth||{})[t.name],i=(this._profileTrends||{})[t.name];let l="";o&&o.health_status==="poor"?l=`<span class="wd-badge" style="color:var(--error-color,#f44336);background:rgba(244,67,54,.12)" title="${d(this._t("badge.poor_fit_tip",{},"Inconsistent match history \u2014 consider rebuilding this profile"))}">\u26A0 ${this._t("badge.poor_fit",{},"poor fit")}</span>`:o&&o.health_status==="fair"&&(l=`<span class="wd-badge" style="color:var(--warning-color,#ff9800);background:rgba(255,152,0,.12)" title="${d(this._t("badge.fair_fit_tip",{},"Moderate match consistency \u2014 some cycles assigned to this profile have lower confidence scores. Label more cycles or re-record the profile to improve accuracy."))}">${this._t("badge.fair_fit",{},"fair fit")}</span>`);let c="";if(i){const k=this._trendIcon(i.duration_trend),S=i.energy_trend?this._trendIcon(i.energy_trend):"";if(i.duration_trend!=="stable"||i.energy_trend==="up"){const C=[];if(i.duration_trend!=="stable"){const B=`${i.duration_slope_pct>0?"+":""}${i.duration_slope_pct}`;C.push(i.duration_trend==="up"?this._t("msg.duration_trend_up_tip",{pct:B},`Duration up (${B}%/cycle)`):this._t("msg.duration_trend_down_tip",{pct:B},`Duration down (${B}%/cycle)`))}if(i.energy_trend&&i.energy_trend!=="stable"){const B=`${i.energy_slope_pct>0?"+":""}${i.energy_slope_pct}`;C.push(i.energy_trend==="up"?this._t("msg.energy_trend_up_tip",{pct:B},`Energy up (${B}%/cycle)`):this._t("msg.energy_trend_down_tip",{pct:B},`Energy down (${B}%/cycle)`))}const R=C.join(", ")||this._t("msg.performance_trending",{},"Performance trending");c=`<span class="wd-badge" style="color:var(--secondary-text-color,#888)" title="${d(R)}">${k}${S||""}</span>`}}const _=this._constants&&this._constants.PROFILE_MIN_WARMUP_CYCLES||5,p=o&&o.cycle_count||0,u=p<_&&!t.is_imported?`<span class="wd-badge" title="${d(this._t("msg.warmup_detail",{needed:_},`This profile needs ${_} labelled cycles before auto-matching begins. Every confirmed cycle helps it learn.`))}" style="background:var(--info-color,#2196f3);color:#fff">${this._t("msg.warmup_badge",{done:p,needed:_},`Still learning (${p}/${_} cycles)`)}</span>`:"",v=t.is_imported?`<span class="wd-badge" title="${d(this._t("badge.imported_tip",{},"Imported from the community store. Used for matching only, not counted in stats."))}" style="background:var(--info-color,#2196f3);color:#fff">\u{1F4E5} ${this._t("status.imported",{},"Imported")}</span>`:"",b=[l,c,u,v].filter(Boolean).join(" "),$=Array.isArray(t.signature_curve)&&t.signature_curve.length>=3?`<canvas class="wd-prof-spark" data-spark-prof="${d(t.name)}" width="64" height="20" aria-label="${d(this._t("lbl.sparkline",{name:t.name},"Average power curve"))}"></canvas>`:"";return`
|
|
<div class="wd-prof-wrap">
|
|
<button class="wd-profile-card" type="button" data-action="open-profile" data-pname="${d(t.name)}">
|
|
<div class="wd-profile-head">
|
|
<span class="wd-profile-name">${d(t.name)}</span>
|
|
${$}
|
|
</div>
|
|
${b?`<div class="wd-profile-badges">${b}</div>`:""}
|
|
<div class="wd-profile-meta">${t.cycle_count||0} cycles \xB7 ${e}${s}${r}${a}</div>
|
|
</button>
|
|
</div>`}_paintSparkline(t,e){if(!t||!Array.isArray(e)||e.length<3)return;const s=(getComputedStyle(this).getPropertyValue("--primary-color")||"#03a9f4").trim()||"#03a9f4",r=window.devicePixelRatio||1,n=t.getBoundingClientRect(),a=t.width=Math.max(1,Math.round((n.width||64)*r)),o=t.height=Math.max(1,Math.round((n.height||20)*r)),i=t.getContext("2d");i.clearRect(0,0,a,o);const l=Math.max(...e,1),c=2*r,_=g=>c+(e.length===1?0:g/(e.length-1)*(a-2*c)),p=g=>o-c-Math.max(0,g)/l*(o-2*c);i.beginPath(),e.forEach((g,u)=>{const v=_(u),b=p(g);u===0?i.moveTo(v,b):i.lineTo(v,b)}),i.strokeStyle=s,i.lineWidth=1.5*r,i.lineJoin="round",i.lineCap="round",i.stroke()}_drawProfileSparklines(){const t=this.shadowRoot;if(!t)return;const e=t.querySelectorAll("canvas[data-spark-prof]");if(!e.length)return;const s={};for(const r of this._profiles||[])s[r.name]=r;e.forEach(r=>{const n=s[r.dataset.sparkProf];this._paintSparkline(r,n&&n.signature_curve||[])})}_htmlProfiles(){const t=this._busy.has("rebuild-envelopes"),e=this._canEdit(),s={};this._profiles.forEach(u=>{s[u.name]=u});const r=this._profileGroups||{groups:[],suggestions:[]},n=new Set;r.groups.forEach(u=>(u.members||[]).forEach(v=>n.add(v)));const a=r.groups.map(u=>{const v=(u.members||[]).map(C=>s[C]?this._profileCardHtml(s[C]):"").join(""),b=Math.round((u.cohesion!=null?u.cohesion:1)*100),$=u.cohesive?`<span class="wd-badge" style="color:var(--success-color,#4caf50);background:rgba(76,175,80,.14);margin-bottom:0">${this._t("lbl.cohesion_good",{pct:b},"cohesion "+b+"%")}</span>`:`<span class="wd-badge" style="color:var(--warning-color,#ff9800);background:rgba(255,152,0,.14);margin-bottom:0">${this._t("lbl.cohesion_low",{pct:b},"\u26A0 low cohesion "+b+"%")}</span>`,k=u.cohesive?"":`<p class="wd-info" style="margin:0 0 8px;color:var(--warning-color,#ff9800)">${this._t("msg.group_not_cohesive",{},"These profiles aren't similar enough to group reliably, so matching treats them individually until you remove the outlier or split the group.")}</p>`;return`<div class="wd-card" style="margin-bottom:12px">
|
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px">
|
|
${e?`<button class="wd-btn-link" style="font-size:1.05em;font-weight:600;text-align:left;padding:0;border:none;background:none;cursor:pointer;color:inherit" data-action="pg-edit" data-gname="${d(u.name)}">\u{1F517} ${d(u.name)}</button>`:`<span style="font-size:1.05em;font-weight:600">\u{1F517} ${d(u.name)}</span>`}
|
|
${$}<span style="flex:1"></span>
|
|
${e?`<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="pg-edit" data-gname="${d(u.name)}">${this._t("btn.manage",{},"Manage")}</button>`:""}
|
|
</div>
|
|
${k}
|
|
<div class="wd-profiles-grid">${v}</div>
|
|
</div>`}).join(""),o=this._profiles.filter(u=>!n.has(u.name)),i=o.map(u=>this._profileCardHtml(u)).join(""),l=this._profiles.length===0&&!(this._cycles||[]).length&&!(this._refCycles||[]).length,_=(e&&this._onlineEnabled()&&l?`
|
|
<div class="wd-sug-banner" style="border-color:var(--primary-color);background:var(--accent-dim,rgba(0,180,216,.08))">
|
|
<span>\u{1F4E5} ${this._t("msg.onboard_download",{},"New device? Adopt a ready-made setup (programs, reference cycles and phases) from another WashData user with the same appliance.")}</span>
|
|
<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="store-onboard">${this._t("btn.browse_community_setups",{},"Browse community setups")}</button>
|
|
</div>`:"")+`
|
|
<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("tab.profiles",{},"Profiles")} (${this._profiles.length})</div>
|
|
<p class="wd-info">${this._t("msg.profiles_intro",{},"Click a profile for stats, phases and cleanup. Group near-identical programs (same shape/duration, different temperature or spin) so matching reliably picks between them.")}</p>
|
|
${e?`<div class="wd-card-actions">
|
|
<button class="wd-btn wd-btn-primary" data-action="create-profile" title="${d(this._t("btn.new_profile_tip",{},"Create a new program profile from an existing labelled cycle or recording"))}">${this._t("btn.new_profile",{},"+ New Profile")}</button>
|
|
<button class="wd-btn wd-btn-secondary" data-action="pg-new" title="${d(this._t("btn.new_group_tip",{},"Group near-identical profiles (same shape/duration, different temperature or spin) so the matcher reliably picks between them"))}">${this._t("btn.new_group",{},"+ New Group")}</button>
|
|
<button class="wd-btn wd-btn-secondary" data-action="rebuild-envelopes" ${t?"disabled":""} title="${d(this._t("btn.rebuild_tip",{},"Recompute the expected power envelope (min/max band) for all profiles from their labelled cycles \u2014 run after labelling new cycles or correcting old ones"))}">${t?'<span class="wd-spin"></span> '+this._t("status.rebuilding",{},"Rebuilding\u2026"):this._t("btn.rebuild",{},"Rebuild Envelopes")}</button>
|
|
</div>`:""}
|
|
</div>
|
|
${a}
|
|
${this._profiles.length===0?`<div class="wd-empty"><div class="wd-icon">\u{1F4CA}</div>${this._t("msg.no_profiles_yet",{},"No profiles yet. Create one from a labelled cycle.")}</div>`:o.length?`${a?`<div class="wd-card-title" style="margin:6px 0 8px">${this._t("lbl.ungrouped",{},"Ungrouped")}</div>`:""}<div class="wd-profiles-grid">${i}</div>`:""}`,p=[["profiles",this._t("tab.subtab_profiles",{},"Profiles")],["phase-catalog",this._t("tab.subtab_phase_catalog",{},"Phase Catalog")]].map(([u,v])=>`<button class="wd-subtab ${this._profSubtab===u?"active":""}" data-proftab="${u}">${v}</button>`).join(""),g=this._profilesError||this._profileGroupsError?`<div class="wd-error-state"><span>${this._t("msg.fetch_error",{},"Failed to load data.")}</span><button class="wd-btn" type="button" data-action="retry-profiles">${this._t("btn.retry",{},"Retry")}</button></div>`:"";return`
|
|
<div class="wd-subtabs">${p}</div>
|
|
${this._profSubtab==="phase-catalog"?this._htmlPhases():g+_}
|
|
`}_htmlProfileGroupModal(t){const e=this._busy.has("pg-save"),s=this._profileEnvCache||{},r=p=>et[Math.max(0,this._profiles.findIndex(g=>g.name===p))%et.length],n=t.members||[],a=this._profiles.map(p=>{const g=n.includes(p.name),u=p.avg_duration?`~${Math.round(p.avg_duration/60)}m`:"",v=p.avg_energy!=null?` \xB7 ${gt(p.avg_energy)}`:"",b=g?`<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${r(p.name)};margin:0 2px"></span>`:"";return`<label class="wd-rev-tag"><input type="checkbox" class="wd-pg-mem" value="${d(p.name)}" ${g?"checked":""}> ${b}${d(p.name)} <span style="color:var(--secondary-text-color);font-size:.85em">${u}${v}</span></label>`}).join(""),o=n.filter(p=>s[p]&&(s[p].avg||[]).length),i=o.length?`<div class="wd-leg">${o.map(p=>`<span class="wd-leg-i"><span class="wd-leg-sw" style="background:${r(p)}"></span> ${d(p)}</span>`).join("")}</div>`:"",l=o.length?`<div class="wd-canvas-wrap" style="margin-top:8px"><canvas id="wd-pgroup-canvas" role="img" aria-label="${d(this._t("lbl.aria_envelope_chart",{},"Profile power envelope chart"))}" style="height:150px"></canvas></div>${i}`:`<p class="wd-info">${this._t("msg.group_preview_hint",{},"Tick 2+ members to preview and compare their power curves.")}</p>`,c=((this._profileGroups||{}).groups||[]).find(p=>p.name===t.orig),_=c&&c.cohesion!=null?`<span class="wd-badge" style="color:${c.cohesive?"var(--success-color,#4caf50)":"var(--warning-color,#ff9800)"};background:${c.cohesive?"rgba(76,175,80,.14)":"rgba(255,152,0,.14)"}">${c.cohesive?this._t("lbl.cohesion_good",{pct:Math.round(c.cohesion*100)},"cohesion "+Math.round(c.cohesion*100)+"%"):this._t("lbl.cohesion_low",{pct:Math.round(c.cohesion*100)},"\u26A0 low cohesion "+Math.round(c.cohesion*100)+"%")}</span>`:"";return`<h2>${t.orig?this._t("modal.edit_group",{},"Edit profile group"):this._t("modal.new_group",{},"New profile group")}</h2>
|
|
<div class="wd-field" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><label style="margin:0">${this._t("lbl.group_name",{},"Group name")}</label><input type="text" id="wd-pg-name" value="${d(t.name||"")}" placeholder="${d(this._t("placeholder.group_name",{},"e.g. Cotton 2:47"))}" style="flex:1;min-width:180px">${_}</div>
|
|
${l}
|
|
<div class="wd-rev-sub">${this._t("lbl.members",{},"Members")}${n.length?` (${n.length})`:""}</div>
|
|
<div class="wd-rev-tags">${a||`<span class="wd-info">${this._t("msg.no_profiles_yet_short",{},"No profiles yet.")}</span>`}</div>
|
|
<p class="wd-info" style="margin-top:10px">${this._t("msg.group_modal_help",{},"Group programs with the same shape that differ in temperature/spin (durations may vary). Matching scores the group as one candidate, then picks the best-fitting member. Pick at least 2; the overlay shows how alike they are.")}</p>
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
${t.orig?`<button class="wd-btn wd-btn-danger" data-maction="pg-delete" title="${d(this._t("btn.delete_group_tip",{},"Delete this group only - the member profiles are kept"))}">${this._t("btn.delete_group",{},"Delete Group")}</button>`:""}
|
|
<button class="wd-btn wd-btn-primary" data-maction="pg-save" ${e?"disabled":""}>${e?'<span class="wd-spin"></span> '+this._t("status.saving",{},"Saving\u2026"):this._t("btn.save_group",{},"Save Group")}</button>
|
|
</div>`}_drawGroupCanvas(){const t=this._modal;if(!t||t.type!=="profile-group")return;const e=this._profileEnvCache||{},s=a=>et[Math.max(0,this._profiles.findIndex(o=>o.name===a))%et.length];let r=0;const n=(t.members||[]).filter(a=>e[a]&&(e[a].avg||[]).length).map(a=>{const o=e[a],i=o.avg[o.avg.length-1];return r=Math.max(r,o.target_duration||(i?i[0]:0)),{points:o.avg,stroke:s(a),width:2,alpha:.9,name:a}});n.length&&this._drawCurves("wd-pgroup-canvas",{series:n,xMax:r})}_settingsLevel(){return this._pref("settings_level","basic")==="advanced"?"advanced":"basic"}_settingFieldVisible(t){return this._settingsLevel()==="advanced"||!!t.basic}_secHasBasicFields(t){return(t.fields||(t.groups||[]).flatMap(s=>s.fields||[])).some(s=>s.basic)}_htmlSettings(){const t=Object.assign({},this._opts,this._pendingSettings);if(!Object.keys(t).length)return`<div class="wd-empty"><div class="wd-icon">\u2699\uFE0F</div>${this._t("msg.loading_settings",{},"Loading settings\u2026")}</div>`;const e=this._suggestionsError?`<div class="wd-error-state"><span>${this._t("msg.fetch_error",{},"Failed to load data.")}</span><button class="wd-btn" type="button" data-action="retry-suggestions">${this._t("btn.retry",{},"Retry")}</button></div>`:"",r=this._settingsLevel()==="basic",n=new Set((this._suggestions||[]).map(U=>U.key)),a=this._mlSugKeys(t),o=new Set([...n,...a]),i=U=>(U.fields||(U.groups||[]).flatMap(f=>f.fields||[])).some(f=>o.has(f.key)),l=this._conflictKeysFromOpts(),c=U=>(U.fields||(U.groups||[]).flatMap(f=>f.fields||[])).some(f=>l.has(f.key)),_=this._opts&&this._opts.device_type||"",p=mt.filter(U=>!(U.id==="ml_training"||_&&U.notDeviceTypes&&U.notDeviceTypes.includes(_)||_&&U.onlyDeviceTypes&&!U.onlyDeviceTypes.includes(_)||r&&!this._secHasBasicFields(U))),g=(p.find(U=>U.id===this._settingsSec)||p[0]||{}).id,u=p.map(U=>{const at=i(U),f=c(U);return`<button class="wd-sec-btn ${g===U.id?"active":""}" data-sec="${U.id}">${d(this._t("section."+U.id+".label",{},U.label))}${f?'<span class="wd-sec-conf-dot"></span>':at?'<span class="wd-sec-sug-dot"></span>':""}</button>`}).join(""),v=`<label class="wd-mode-switch" title="${d(this._t("lbl.settings_detail_level",{},"Settings detail level"))}">
|
|
<span class="wd-mode-switch-label ${r?"active":""}">${this._t("lbl.settings_basic",{},"Basic")}</span>
|
|
<span class="wd-toggle-track">
|
|
<input type="checkbox" id="wd-settings-level-chk" ${r?"":"checked"} data-action="set-settings-level">
|
|
<span class="wd-toggle-knob"></span>
|
|
</span>
|
|
<span class="wd-mode-switch-label ${r?"":"active"}">${this._t("lbl.settings_advanced",{},"Advanced")}</span>
|
|
</label>`,b=r?`<p class="wd-info" style="margin:0 0 10px;font-size:.82em">${this._t("msg.settings_basic_note",{},"Showing essential settings. Switch to Advanced for the full list.")}</p>`:"",$=this._busy.has("save-settings"),k=l.size,S=k!==1?"s":"",C=k?`
|
|
<div class="wd-sug-banner" style="background:rgba(183,28,28,.10);border-color:rgba(183,28,28,.4);color:var(--error-color,#b71c1c)">
|
|
<span>\u26A0 ${this._t("conflict.settings_banner",{n:k},`Setting conflicts: ${k}. Check the highlighted sections and fix them before saving.`)}</span>
|
|
<button class="wd-btn wd-btn-sm wd-btn-secondary" data-action="conf-goto-section">${this._t("conflict.settings_banner_btn",{},"Go to first")}</button>
|
|
</div>`:"",R=this._suggestions.length,B=[...a].filter(U=>!n.has(U)).length,W=R+B,O=this._settingsSugOnly&&!this._settingsSearch,D=R?`<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="sug-apply-all">${this._t("btn.apply_all",{},"Apply all")}</button>`:"",A=R?`<button class="wd-btn wd-btn-sm wd-btn-secondary" data-action="sug-dismiss">${this._t("btn.dismiss",{},"Dismiss")}</button>`:"",E=W?O?`
|
|
<div class="wd-sug-banner">
|
|
<span>\u{1F4A1} ${this._t("msg.showing_suggestions",{count:W},`Showing ${W} setting${W>1?"s":""} with suggestions.`)} <span style="text-decoration:underline;cursor:pointer" data-action="sug-show-all">${this._t("msg.show_all_settings",{},"Show all settings")}</span>.</span>
|
|
${D}
|
|
</div>`:`
|
|
<div class="wd-sug-banner">
|
|
<span>\u{1F4A1} ${this._t("msg.tuning_suggestions_available",{count:W},`${W} tuning suggestion${W>1?"s":""} available from observed cycles. They appear beside the relevant fields.`)}</span>
|
|
<button class="wd-btn wd-btn-sm wd-btn-secondary" data-action="goto-suggestions">${this._t("btn.show_only",{},"Show only")}</button>
|
|
${D}
|
|
${A}
|
|
</div>`:"",M=this._busy.has("sug-analyze"),H=`<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="sug-analyze" ${M?"disabled":""} title="${d(this._t("btn.run_analysis_tip",{},"Analyze your recorded cycles now and refresh tuning suggestions"))}">${M?'<span class="wd-spin"></span> '+this._t("status.analyzing",{},"Analyzing\u2026"):this._t("btn.run_analysis",{},"\u{1F50D} Run suggestion analysis")}</button>`,q=this._settingsSearch||"",Q=q.trim().toLowerCase(),J=`<input type="text" id="wd-settings-search" class="wd-filter-input" placeholder="${this._t("msg.search_placeholder",{},"Search settings\u2026")}" value="${d(q)}" autocomplete="off" style="flex:0 0 auto;width:200px;max-width:40%">`,G=Q?this._htmlSettingsSearch(t,Q):O?this._htmlSettingsSugOnly(t):this._htmlSettingsSection(t),X=(this._lockedSuggestions||[]).length,ct=X?`
|
|
<div class="wd-sug-banner" style="opacity:.92">
|
|
<span>\u{1F515} ${this._t("msg.n_suggestions_muted",{count:X},`${X} suggestion${X>1?"s":""} muted; the auto-tuner will not propose these.`)}</span>
|
|
<button class="wd-btn wd-btn-sm wd-btn-secondary" data-action="sug-unmute-all">${this._t("btn.reset_muted",{},"Reset muted")}</button>
|
|
</div>`:"";return`
|
|
${e}
|
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap">
|
|
<div class="wd-card-title" style="margin:0">${this._t("tab.settings",{},"Settings")}${this._mlSettingsLoading?` <span style="font-size:.6em;color:var(--secondary-text-color);font-weight:400">${this._t("msg.ml_loading",{},"loading ML\u2026")}</span>`:""}</div>
|
|
${H}
|
|
</div>
|
|
${C}${E}${ct}${b}
|
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
|
|
${J}
|
|
<div class="wd-section-nav" style="flex:1;margin:0;margin-bottom:0">${u}</div>
|
|
${v}
|
|
</div>
|
|
<div class="wd-card">
|
|
<form id="wd-settings-form">${G}</form>
|
|
<div class="wd-card-actions" style="margin-top:20px">
|
|
<button class="wd-btn wd-btn-primary" id="wd-settings-save" ${$?"disabled":""}>${$?'<span class="wd-spin"></span> '+this._t("status.saving",{},"Saving\u2026"):this._t("btn.save_settings",{},"Save Settings")}</button>
|
|
<button class="wd-btn wd-btn-secondary" id="wd-settings-revert" ${this._prevOpts?"":"disabled"} title="${this._prevOpts?this._t("btn.revert_settings_tip",{},"Restore settings from before your last save"):this._t("btn.revert_settings_tip_none",{},"Save first to enable undo")}">${this._t("btn.revert_settings",{},"Revert changes")}</button>
|
|
<button class="wd-btn wd-btn-secondary" id="wd-settings-reload" title="${d(this._t("btn.refresh_settings_tip",{},"Reload settings from the server"))}">${this._t("btn.refresh",{},"Refresh")}</button>
|
|
</div>
|
|
<p class="wd-info" style="margin-top:12px;font-size:.78em">${this._t("msg.saving_triggers_reload",{},"Saving triggers an integration reload. HA entities may briefly show as unavailable.")}</p>
|
|
</div>
|
|
${this._htmlSettingsHistory()}
|
|
`}_htmlSettingsHistory(){const t=this._settingsChangelog||[];if(!t.length)return"";const e=this._settingsHistoryOpen,s=this._canEdit(),r=t.slice(0,100).map(n=>{const a=s?`<button class="wd-btn wd-btn-secondary wd-btn-xs" data-action="settings-revert-key" data-key="${d(n.key)}" data-val="${d(JSON.stringify(n.old))}" title="${d(this._t("btn.revert_to_previous",{},"Restore this setting to its previous value"))}">${this._t("btn.revert",{},"Revert")}</button>`:"";return`<tr>
|
|
<td>${d(this._t("setting."+n.key+".label",{},n.key))}</td>
|
|
<td>${d(Tt(n.old))} \u2192 ${d(Tt(n.new))}</td>
|
|
<td class="wd-tc-date">${st(n.timestamp)}</td>
|
|
<td style="text-align:right">${a}</td>
|
|
</tr>`}).join("");return`<div class="wd-card" style="margin-top:12px">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none" data-action="toggle-settings-history">
|
|
<div class="wd-card-title" style="margin:0">${this._t("hdr.settings_history",{},"Settings history")} <span style="font-size:.75em;font-weight:400;color:var(--secondary-text-color)">(${t.length})</span></div>
|
|
<span style="font-size:.85em;color:var(--secondary-text-color)">${e?"\u25B2":"\u25BC"}</span>
|
|
</div>
|
|
${e?`<div class="wd-table-wrap" style="margin-top:10px"><table class="wd-table"><thead><tr>
|
|
<th>${this._t("lbl.setting",{},"Setting")}</th>
|
|
<th>${this._t("lbl.change",{},"Change")}</th>
|
|
<th>${this._t("lbl.date",{},"Date")}</th>
|
|
<th></th>
|
|
</tr></thead><tbody>${r}</tbody></table></div>`:""}
|
|
</div>`}_renderField(t,e){if(t.onlyDeviceType&&(e.device_type||"washing_machine")!==t.onlyDeviceType)return"";if(t.type==="storebrand"||t.type==="storemodel")return this._renderStorePicker(t,e);let s=e[t.key];if(s===void 0){const l=this._optDefaults;s=l&&l[t.key]!=null?l[t.key]:t.def}const r={};if(t.type==="devicetype")r.opts=this._deviceTypeOpts(s||e.device_type);else if(t.type==="device")r.opts=this._deviceOpts();else if(t.type==="select")r.opts=t.opts||[];else if(t.type==="entity"){const l=this._hass&&this._hass.states?this._hass.states:{},c=t.domain==="binary_sensor"?["binary_sensor","sensor"]:t.domain?[t.domain]:null,_=Object.keys(l).filter(p=>!c||c.some(g=>p.startsWith(g+"."))).sort().slice(0,500);this._entityListCache||(this._entityListCache={}),this._entityListCache[t.key]=_}else if(t.type==="entitylist"){const l=this._hass&&this._hass.states?this._hass.states:{},c=Object.keys(l).filter(_=>!t.domain||_.startsWith(t.domain+".")).sort();if(t.domain==="notify"&&this._hass&&this._hass.services&&this._hass.services.notify){const _=Object.keys(this._hass.services.notify).map(p=>`notify.${p}`);r.entities=[...new Set([...c,..._])].sort().slice(0,500)}else r.entities=c.slice(0,500);this._entityListCache||(this._entityListCache={}),this._entityListCache[t.key]=r.entities}else if(Array.isArray(t.suggestions)&&t.suggestions.length){const l=`wd-dl-${t.key}`;r.datalistId=l,r.datalist=`<datalist id="${l}">${t.suggestions.map(c=>`<option value="${d(c)}">`).join("")}</datalist>`}const n=this._suggestions.find(l=>l.key===t.key);if(n){const l=Object.assign({},n.reason_params||{});n.exclusions&&n.exclusions.total&&(l.excl=this._exclNote(n.exclusions)),r.suggestion={suggested:n.suggested,current:n.current,reason:n.reason,reason_key:n.reason_key,reason_params:l}}const a=(this._mlSettings||{})[t.key];a&&a.ml_value!=null&&!(this._lockedSuggestions||[]).includes(t.key)&&(r.mlSuggestion={value:a.ml_value,reason:a.ml_reason,reason_key:a.ml_reason_key,reason_params:a.ml_reason_params}),r.useBtnLabel=this._t("btn.use",{},"Use"),r.t=this._t.bind(this);const o=(this._settingsChangeByKey||{})[t.key];o&&(r.changed=this._t("msg.setting_changed",{old:Tt(o.old),new:Tt(o.new),date:st(o.timestamp)},`Changed from ${Tt(o.old)} to ${Tt(o.new)} on ${st(o.timestamp)}`));const i=Object.assign({},t,{label:this._t("setting."+t.key+".label",{},t.label||""),doc:t.doc!=null?this._t("setting."+t.key+".doc",{},t.doc):t.doc});return _e(i,s,r)}_renderStorePicker(t,e){const s=t.type==="storebrand",r=t.key,n=String(e[r]==null?"":e[r]),a=this._t("setting."+r+".label",{},t.label||""),o=this._t("setting."+r+".doc",{},t.doc||""),i=d(this._t("placeholder."+r,{},s?"e.g. Bosch":"e.g. WAT28660"));return this._onlineEnabled()?s?this._renderBrandPicker(r,n,a,o,i):this._renderModelPicker(r,e,n,a,o,i):`<div class="wd-field"><label>${d(a)}</label>
|
|
<input type="text" data-opt="${r}" data-ftype="text" value="${d(n)}" placeholder="${i}">
|
|
<div class="wd-field-hint">${d(this._t("msg.store_picker_offline",{},"Enable online features in the settings gear to pick from the community catalog."))}</div></div>`}_statusTag(t){if(t&&t.status==="pending"){const e=d(this._t("badge.awaiting_tip",{},"Awaiting community approval")),s=typeof t.confirmCount=="number"?this._t("badge.awaiting_n",{n:t.confirmCount},`Awaiting approval \xB7 ${t.confirmCount} confirmed`):this._t("badge.awaiting",{},"Awaiting approval");return`<span class="wd-tag wd-tag-pending" title="${e}">${s}</span>`}return t&&t.status==="approved"?`<span class="wd-tag wd-tag-approved">${this._t("badge.approved",{},"Approved")}</span>`:""}_renderBrandPicker(t,e,s,r,n){this._ensureCatalogEntry();const a=Array.isArray(this._catalog.brands)?this._catalog.brands:[];this._entityListCache=this._entityListCache||{},this._entityListCache[t]=a.map(l=>l.brand).filter(Boolean);const o=this._statusTag(this._catalogEntryFor(e,"brand")),i=this._catalog.brands===null?` <span class="wd-info" style="font-size:.85em">${this._t("msg.loading",{},"Loading\u2026")}</span>`:"";return`<div class="wd-field"><label>${d(s)} ${r?it(r):""}${o}${i}</label>
|
|
<div class="wd-combo-row">
|
|
<div class="wd-combo">
|
|
<input type="text" id="wd-store-brand" class="wd-combo-inp" data-opt="${t}" data-ftype="text" value="${d(e)}" placeholder="${n}" autocomplete="off" spellcheck="false">
|
|
<div class="wd-combo-drop" hidden></div>
|
|
</div>
|
|
<button type="button" class="wd-addbtn" data-action="store-add-brand" title="${d(this._t("tip.add_brand",{},"Brand not listed? Add it to the community catalog"))}" aria-label="${d(this._t("tip.add_brand",{},"Add brand"))}">+</button>
|
|
</div></div>`}_renderModelPicker(t,e,s,r,n,a){const o=String(e.store_brand||"");if(!o)return`<div class="wd-field"><label>${d(r)}</label>
|
|
<input type="text" id="wd-store-model" data-opt="${t}" data-ftype="text" value="${d(s)}" placeholder="${a}" disabled>
|
|
<div class="wd-field-hint">${d(this._t("msg.pick_brand_first",{},"Pick an appliance brand first."))}</div></div>`;this._ensureCatalogEntry(),this._catalog.forBrand!==o&&(this._catalog.forBrand=o,this._catalog.devices=void 0);const i=Array.isArray(this._catalog.devices)?this._catalog.devices:[];this._entityListCache=this._entityListCache||{},this._entityListCache[t]=i.map(u=>u.model).filter(Boolean);const l=this._catalogEntryFor(s,"device"),c=this._statusTag(l),_=this._catalog.devices===null?` <span class="wd-info" style="font-size:.85em">${this._t("msg.loading",{},"Loading\u2026")}</span>`:"";let p="";if(l){const u=[],v=pe(l.manualUrl);v&&u.push(`<a href="${d(v)}" target="_blank" rel="noopener noreferrer nofollow">${this._t("link.manual",{},"Manual \u2197")}</a>`),(this._constants&&this._constants.storePrefs||{}).show_contributor!==!1&&u.push(this._t("store.contributed_by",{name:d(l.createdByName||this._t("lbl.anonymous",{},"Anonymous"))},`by ${d(l.createdByName||"Anonymous")}`));const b=!!(this._storeStatus&&this._storeStatus.connected);let $="";if(b){const k=[1,2,3,4,5].map(S=>`<button type="button" class="wd-star-btn" data-action="store-rate-device" data-device-id="${d(l.id)}" data-rating="${S}" aria-label="${this._t("lbl.rate_n",{n:S},`${S} stars`)}">\u2605</button>`).join("");$=`<button type="button" class="wd-btn wd-btn-secondary wd-btn-sm" data-action="store-confirm-device" data-device-id="${d(l.id)}" ${l.status!=="pending"?"disabled":""}>${l.status==="pending"?this._t("btn.confirm_appliance",{},"Confirm this appliance"):this._t("btn.confirmed",{},"Confirmed")}</button>
|
|
<span class="wd-star-row" title="${d(this._t("lbl.rate_quality",{},"Rate quality"))}">${k}</span>`}else $=`<span class="wd-info" style="font-size:.85em">${this._t("msg.connect_to_confirm",{},"Connect in the settings gear to confirm or rate.")}</span>`;p=`<div class="wd-store-picker-detail">${u.join(" \xB7 ")}<div class="wd-store-picker-actions">${$}</div></div>`}const g=this._onlineEnabled()&&this._storeDeviceDeclared()?`<div class="wd-store-actions">
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="store-share-device" title="${d(this._t("btn.share_device_tip",{},"Share this appliance and its recorded reference cycles to the community store so others with the same machine can adopt them"))}">${this._t("btn.share_device",{},"\u2B06 Share this device")}</button>
|
|
<button class="wd-btn wd-btn-ghost wd-btn-sm" data-action="store-download-device" ${l?`data-device-id="${d(l.id)}"`:""} title="${d(this._t("msg.store_download_device_intro",{},"Adopt every shared program and its reference cycles onto your device."))}">${this._t("btn.download_device",{},"Download this setup")}</button>
|
|
</div>`:"";return`<div class="wd-field"><label>${d(r)} ${n?it(n):""}${c}${_}</label>
|
|
<div class="wd-combo-row">
|
|
<div class="wd-combo">
|
|
<input type="text" id="wd-store-model" class="wd-combo-inp" data-opt="${t}" data-ftype="text" value="${d(s)}" placeholder="${a}" autocomplete="off" spellcheck="false">
|
|
<div class="wd-combo-drop" hidden></div>
|
|
</div>
|
|
<button type="button" class="wd-addbtn" data-action="store-add-appliance" title="${d(this._t("tip.add_appliance",{},"Model not listed? Add your appliance to the community catalog"))}" aria-label="${d(this._t("tip.add_appliance",{},"Add appliance"))}">+</button>
|
|
</div>
|
|
${p}${g}</div>`}_storeApplianceType(){return{washing_machine:"washer",washer:"washer",dryer:"dryer",dishwasher:"dishwasher",washer_dryer:"washer_dryer"}[this._opts.device_type]||""}_storeDeviceDeclared(){return!!((this._opts.store_brand||"").trim()&&(this._opts.store_model||"").trim())}_shareableByProgram(){const t=new Map;for(const s of this._shareableCycles||[]){const r=(s.profile_name||"").trim();r&&(t.has(r)||t.set(r,[]),t.get(r).push(s))}const e=Array.from(t.entries()).map(([s,r])=>({program:s,cycles:r})).sort((s,r)=>s.program.localeCompare(r.program));for(const s of this._shareAllPrograms||[])t.has(s)||e.push({program:s,cycles:[],noCycles:!0});return e}_catalogEntryKey(){const t=this._opts||{};return[(t.store_brand||"").trim().toLowerCase(),(t.store_model||"").trim().toLowerCase(),t.device_type||""].join("|")}_ensureCatalogEntry(){const t=this._catalogEntryKey(),e=this._catalogEntry;e&&e.key===t||(this._catalogEntry={key:t,brand:null,device:null,deviceId:null,loading:!0},this._loadCatalogEntry(t))}_catalogEntryFor(t,e){const s=this._catalogEntry;if(!s||s.key!==this._catalogEntryKey())return null;const r=e==="brand"?s.brand:s.device;if(!r)return null;const n=e==="brand"?r.brand:r.model;return String(n||"").toLowerCase()===String(t||"").trim().toLowerCase()?r:null}async _loadCatalogEntry(t){const e=this._devices[this._selIdx],s=this._opts||{},r=(s.store_brand||"").trim(),n=(s.store_model||"").trim();if(!e||!this._onlineEnabled()||!r||!n){this._catalogEntry&&this._catalogEntry.key===t&&(this._catalogEntry.loading=!1);return}let a=null;try{a=await this._ws({type:`${y}/store_get_catalog_entry`,entry_id:e.entry_id,brand:r,model:n,appliance_type:s.device_type||""})}catch{}!this._catalogEntry||this._catalogEntry.key!==t||(this._catalogEntry={key:t,loading:!1,brand:a&&a.brand||null,device:a&&a.device||null,deviceId:a&&a.device_id||null},this._isActiveEntry(e.entry_id)&&this._renderPreservingFormEdits())}_refreshComboAfterLoad(t,e,s=!0){const r=this.shadowRoot&&this.shadowRoot.getElementById(t);if(r&&this.shadowRoot.activeElement===r){s&&r.dispatchEvent(new Event("input",{bubbles:!0}));return}this._isActiveEntry(e)&&this._render()}_ensureCatalogList(t,e){if(this._onlineEnabled()){if(t==="store_brand"){this._ensureBrandCandidates(e);return}if(t==="store_model"){const s=String((this._opts||{}).store_brand||"").trim();if(!s)return;(this._catalog.forBrand!==s||this._catalog.devices===void 0)&&(this._catalog.forBrand=s,this._catalog.devices=null,this._loadCatalogDevices(s))}}}_ensureBrandCandidates(t){const e=String(t||"").trim().toLowerCase();if(this._catalog.brandsFull)return;if(!e){clearTimeout(this._brandSearchTimer),this._brandSearchTimer=null,this._catalog.brands===void 0&&(this._catalog.brands=null,this._loadCatalogBrands(""));return}(this._catalog.brandPrefixes||(this._catalog.brandPrefixes=[])).some(r=>e.startsWith(r))||(clearTimeout(this._brandSearchTimer),this._brandSearchTimer=setTimeout(()=>this._loadCatalogBrands(e),250))}_mergeBrandCandidates(t){const e=new Map((Array.isArray(this._catalog.brands)?this._catalog.brands:[]).map(s=>[String(s.id!=null?s.id:s.brand),s]));for(const s of t||[])e.set(String(s.id!=null?s.id:s.brand),s);this._catalog.brands=Array.from(e.values()).sort((s,r)=>String(s.brand||"").localeCompare(String(r.brand||"")))}async _loadCatalogBrands(t=""){this._entityListCache=this._entityListCache||{};let e=!1;const s=this._devices[this._selIdx];if(!s||!this._onlineEnabled()){this._catalog.brands=[],this._entityListCache.store_brand=[];return}if(!(t&&this._catalog.brandsFull)){this._catalog.brands===void 0&&(this._catalog.brands=null);try{const r=await this._ws({type:`${y}/store_list_brands`,entry_id:s.entry_id,query:t||null,include_pending:!this._catalog.approvedOnly}),n=r&&r.items||[];t?(this._mergeBrandCandidates(n),(this._catalog.brandPrefixes||(this._catalog.brandPrefixes=[])).push(t)):(this._catalog.brands=n,this._catalog.brandsFull=!0)}catch{Array.isArray(this._catalog.brands)||(this._catalog.brands=[]),e=!0}this._entityListCache.store_brand=(this._catalog.brands||[]).map(r=>r.brand).filter(Boolean),this._refreshComboAfterLoad("wd-store-brand",s.entry_id,!e)}}async _loadCatalogDevices(t){this._entityListCache=this._entityListCache||{};const e=this._devices[this._selIdx];if(!e||!this._onlineEnabled()||!t){this._catalog.devices=[],this._entityListCache.store_model=[];return}try{const s=await this._ws({type:`${y}/store_search_devices`,entry_id:e.entry_id,query:t,appliance_type:this._storeApplianceType(),include_pending:!this._catalog.approvedOnly});this._catalog.forBrand===t&&(this._catalog.devices=s&&s.items||[])}catch{this._catalog.forBrand===t&&(this._catalog.devices=[])}this._entityListCache.store_model=(this._catalog.devices||[]).map(s=>s.model).filter(Boolean),this._refreshComboAfterLoad("wd-store-model",e.entry_id)}async _loadShareProfiles(){const t=this._devices[this._selIdx],e=this._modal;if(!t||!e||e.type!=="store-share")return;const s=(this._opts.store_brand||"").trim(),r=(this._opts.store_model||"").trim();if(!s||!r){e.profiles=[],this._modal===e&&this._render();return}try{const n=await this._ws({type:`${y}/store_get_device_profiles`,entry_id:t.entry_id,brand:s,model:r,appliance_type:this._opts.device_type||""});if(this._modal!==e)return;e.profiles=n&&n.items||[],e.deviceId=n&&n.device_id||null}catch{this._modal===e&&(e.profiles=[])}this._modal===e&&this._render()}_htmlAutomations(){const t=this._deviceAutomations||[],e=t.length?t.map(n=>`<span class="wd-auto-pill"><a class="wd-auto-pill-link" href="/config/automation/edit/${encodeURIComponent(n.id)}" target="_top" title="${this._t("hdr.automation_open",{},"Open in the automation editor")}">\u{1F517} ${d(n.name)}${n.enabled?"":' <span style="opacity:.6">(off)</span>'}</a><button type="button" class="wd-auto-pill-x" data-action="auto-delete" data-autoid="${d(n.id)}" data-autoname="${d(n.name)}" title="${this._t("hdr.automation_delete",{},"Delete this automation")}">\xD7</button></span>`).join(""):`<span class="wd-info" style="margin:0">${this._autoLoading?this._t("msg.loading",{},"Loading\u2026"):this._t("hdr.no_automations",{},"No automations reference this device yet.")}</span>`,s=Array.isArray(this._opts.notify_actions)?this._opts.notify_actions:[],r=s.length?`
|
|
<div style="border:1px solid var(--warning-color,#ff9800);border-radius:8px;padding:10px 12px;margin-bottom:12px;background:rgba(255,152,0,.08)">
|
|
<div style="font-weight:600;margin-bottom:4px">${this._t("msg.legacy_actions_title",{count:s.length},`${s.length} legacy custom action${s.length>1?"s":""} still running`)}</div>
|
|
<p class="wd-info" style="margin:0 0 8px">${this._t("msg.old_actions_warning",{},"Configured with the old actions editor (now removed). They still fire on cycle events but can no longer be edited here. Convert them into a normal automation, or remove them.")}</p>
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
|
<button type="button" class="wd-btn wd-btn-primary wd-btn-sm" data-action="auto-convert-legacy">${this._t("btn.convert_to_automation",{},"Convert to automation")}</button>
|
|
<button type="button" class="wd-btn wd-btn-danger wd-btn-sm" data-action="auto-remove-legacy">${this._t("btn.remove",{},"Remove")}</button>
|
|
</div>
|
|
</div>`:"";return`
|
|
<div class="wd-subhead">${this._t("hdr.automations",{},"Automations")}</div>
|
|
<p class="wd-info" style="margin-bottom:10px">${this._t("msg.automations_intro",{start:"<code>ha_washdata_cycle_started</code>",end:"<code>ha_washdata_cycle_ended</code>"},"WashData fires {start} / {end} events and exposes entities, so notifications and actions are best built as normal Home Assistant automations. Automations that use this device appear below.")}</p>
|
|
${r}
|
|
<div class="wd-auto-pills" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px">${e}</div>
|
|
<div class="wd-auto-new" style="display:flex;gap:6px;align-items:center;margin-bottom:18px">
|
|
<button type="button" class="wd-btn wd-btn-primary wd-btn-sm" data-action="auto-new">${this._t("btn.new_automation",{},"\uFF0B New Automation")}</button>
|
|
<details class="wd-auto-dd" style="position:relative">
|
|
<summary class="wd-btn wd-btn-secondary wd-btn-sm">${this._t("btn.from_template",{},"From template \u25BE")}</summary>
|
|
<div class="wd-auto-dd-menu" style="position:absolute;z-index:5;margin-top:4px;background:var(--card-background-color);border:1px solid var(--divider-color);border-radius:8px;padding:6px;min-width:190px;box-shadow:0 4px 14px rgba(0,0,0,.25)">
|
|
<button type="button" class="wd-btn wd-btn-secondary wd-btn-sm" data-action="auto-new-started" style="width:100%;margin-bottom:4px">${this._t("btn.on_cycle_started",{},"On cycle started")}</button>
|
|
<button type="button" class="wd-btn wd-btn-secondary wd-btn-sm" data-action="auto-new-finished" style="width:100%">${this._t("btn.on_cycle_finished",{},"On cycle finished")}</button>
|
|
</div>
|
|
</details>
|
|
</div>`}async _loadDeviceAutomations(t){this._deviceAutomations=[];const e=this._hass;if(!(!e||!e.callWS))try{let s=null;const r=e.devices||{};for(const i of Object.values(r))if((i.config_entries||[]).includes(t)){s=i.id;break}const n=s?await e.callWS({type:"search/related",item_type:"device",item_id:s}):await e.callWS({type:"search/related",item_type:"config_entry",item_id:t});if(!this._isActiveEntry(t))return;const a=n&&n.automation||[],o=e.states||{};this._deviceAutomations=a.map(i=>{const l=o[i]&&o[i].attributes||{};return{entity_id:i,id:l.id,name:l.friendly_name||i,enabled:o[i]?o[i].state==="on":!0}}).filter(i=>i.id)}catch{this._isActiveEntry(t)&&(this._deviceAutomations=[])}}_navigate(t){try{history.pushState(null,"",t),this.dispatchEvent(new CustomEvent("location-changed",{bubbles:!0,composed:!0,detail:{replace:!1}}))}catch{try{window.location.assign(t)}catch{}}}async _newAutomationFromEvent(t){const e=this._devices[this._selIdx];if(!e)return;const s=this._hass,r=t==="started"?"ha_washdata_cycle_started":"ha_washdata_cycle_ended",n=t==="started"?"started":"finished",a={alias:`${e.title||"WashData"}: cycle ${n}`,description:`Runs when the WashData ${e.title||""} cycle ${n}. Add your actions (notify, lights, ...).`,mode:"single",trigger:[{platform:"event",event_type:r,event_data:{entry_id:e.entry_id}}],condition:[],action:[]},o="washdata_"+Date.now().toString(36);try{s&&s.callApi?(await s.callApi("POST","config/automation/config/"+o,a),this._navigate("/config/automation/edit/"+o)):this._navigate("/config/automation/edit/new")}catch(i){this._showToast(this._t("msg.toast_automation_failed",{error:i.message||i},"Could not create automation: "+(i.message||i)),"error")}}async _convertLegacyActions(){const t=this._devices[this._selIdx],e=this._hass,s=Array.isArray(this._opts.notify_actions)?this._opts.notify_actions:[];if(!t||!s.length)return;const r={alias:`${t.title||"WashData"}: migrated custom actions`,description:"Migrated from WashData legacy custom actions (which ran on cycle start, finish and live). Trim the triggers as needed. Note: old {device}/{duration}-style placeholders are NOT templated here - replace them with Jinja templates such as {{ trigger.event.data.device_name }}.",mode:"single",trigger:[{platform:"event",event_type:"ha_washdata_cycle_started",event_data:{entry_id:t.entry_id}},{platform:"event",event_type:"ha_washdata_cycle_ended",event_data:{entry_id:t.entry_id}}],condition:[],action:s},n="washdata_"+Date.now().toString(36);if(!e||!e.callApi){this._showToast(this._t("msg.toast_no_automation",{},"Cannot create automation here"),"error");return}let a=!1;try{await e.callApi("POST","config/automation/config/"+n,r),a=!0,await this._ws({type:`${y}/set_options`,entry_id:t.entry_id,options:{notify_actions:[]}}),this._isActiveEntry(t.entry_id)&&(this._opts={...this._opts,notify_actions:[]}),this._showToast(this._t("msg.toast_automation_migrated",{},"Actions migrated to an automation; opening editor")),this._navigate("/config/automation/edit/"+n)}catch(o){const i=o.message||o;if(!a){this._showToast(this._t("msg.toast_convert_failed",{error:i},"Convert failed: "+i),"error");return}let l=null;try{const c=await this._ws({type:`${y}/get_options`,entry_id:t.entry_id}),_=c&&c.options||{},p=Array.isArray(_.notify_actions)?_.notify_actions:[];l=p.length>0,this._isActiveEntry(t.entry_id)&&(this._opts={...this._opts,notify_actions:p})}catch{l=null}if(l===!1){this._showToast(this._t("msg.toast_automation_migrated",{},"Actions migrated to an automation; opening editor")),this._navigate("/config/automation/edit/"+n);return}if(l===!0){try{await e.callApi("DELETE","config/automation/config/"+n),this._showToast(this._t("msg.toast_convert_rolled_back",{error:i},"Migration failed and was rolled back (no automation left behind): "+i),"error")}catch{this._showToast(this._t("msg.toast_convert_orphan",{},"The automation was created, but clearing the old actions failed. Do not retry: remove the legacy actions manually to avoid a duplicate automation."),"error")}return}this._showToast(this._t("msg.toast_convert_orphan",{},"The automation was created, but clearing the old actions failed. Do not retry: remove the legacy actions manually to avoid a duplicate automation."),"error")}}_htmlSettingsSection(t){const e=this._opts&&this._opts.device_type||"",s=this._settingsLevel()==="basic",r=l=>!(l.id==="ml_training"||e&&l.notDeviceTypes&&l.notDeviceTypes.includes(e)||e&&l.onlyDeviceTypes&&!l.onlyDeviceTypes.includes(e)||s&&!this._secHasBasicFields(l)),n=mt.find(l=>l.id===this._settingsSec&&r(l))||mt.find(l=>r(l))||mt[0],a=n.intro||this._t("section."+n.id+".intro",{},"")?`<p class="wd-sec-intro">${d(this._t("section."+n.id+".intro",{},n.intro||""))}</p>`:"",o="";if(n.id==="notifications"){const l=`<p class="wd-info" style="margin-bottom:16px">${this._t("msg.notify_services_hint",{entity:"<code>notify.<name></code>",vars:"<code>"+d($t)+"</code>"},"Use {entity} service IDs (comma-separated for multiple). Template variables: {vars}.")}</p>`,c=n.groups.map(p=>{const g=(p.fields||[]).filter(u=>this._settingFieldVisible(u)).map(u=>this._renderField(u,t)).filter(Boolean).join("");return g?`<div class="wd-subhead">${d(this._t("setting_group."+te(p.sub)+".label",{},p.sub))}</div><div class="wd-form-grid">${g}</div>`:""}).join("");return`${s?"":this._htmlAutomations()}${l}${c}`}if(n.groups){const l=n.groups.map(c=>{const _=c.sub?`<div class="wd-subhead">${d(this._t("setting_group."+te(c.sub)+".label",{},c.sub))}</div>`:"",p=(c.fields||[]).filter(g=>this._settingFieldVisible(g)).map(g=>this._renderField(g,t)).filter(Boolean).join("");return p?`${_}<div class="wd-form-grid">${p}</div>`:""}).join("");return`${a}${o}${l}`}const i=(n.fields||[]).filter(l=>this._settingFieldVisible(l)).map(l=>this._renderField(l,t)).filter(Boolean).join("");return`${a}${o}<div class="wd-form-grid">${i}</div>`}_htmlSettingsSearch(t,e){const s=this._opts&&this._opts.device_type||"",r=mt.filter(i=>!(i.id==="ml_training"||s&&i.notDeviceTypes&&i.notDeviceTypes.includes(s)||s&&i.onlyDeviceTypes&&!i.onlyDeviceTypes.includes(s))),n=i=>`${i.label||""} ${i.key||""} ${i.doc||""} ${i.hint||""}`.toLowerCase().includes(e);let a="",o=0;for(const i of r){const c=(i.fields||(i.groups||[]).flatMap(p=>p.fields||[])).filter(n);if(!c.length)continue;const _=c.map(p=>this._renderField(p,t)).filter(Boolean).join("");_&&(o+=c.length,a+=`<div class="wd-subhead">${d(this._t("section."+i.id+".label",{},i.label))}</div><div class="wd-form-grid">${_}</div>`)}return o?a:`<p class="wd-info" style="padding:12px">${this._t("msg.no_settings_match",{q:e},`No settings match "${d(e)}"`)}</p>`}_mlSugKeys(t){const e=t||Object.assign({},this._optDefaults,this._opts,this._pendingSettings||{});return this._mlSugKeysFrom(this._mlSettings,e,this._lockedSuggestions)}_mlSugKeysFrom(t,e,s){const r=new Set(s||[]),n=e||{},a=new Set;for(const[o,i]of Object.entries(t||{}))r.has(o)||i&&i.ml_value!=null&&!Ot(i.ml_value,n[o])&&a.add(o);return a}_sugCountsForDevice(t){if(!t)return{classic:0,ml:0,total:0};const e=this._devices[this._selIdx],s=!!(e&&e.entry_id===t.entry_id),r=Array.isArray(t.suggestion_keys)?t.suggestion_keys:null,n=r?r.length:t.suggestions_count||0,a=s?Object.assign({},t.option_defaults||{},t.options||{},this._opts,this._pendingSettings||{}):Object.assign({},t.option_defaults||{},t.options||{}),o=this._mlSugKeysFrom(s?this._mlSettings:this._mlSettingsByEntry[t.entry_id],a,s?this._lockedSuggestions:this._lockedByEntry[t.entry_id]),i=r?[...o].filter(l=>!r.includes(l)).length:o.size;return{classic:n,ml:i,total:n+i}}_htmlSettingsSugOnly(t){const e=new Set((this._suggestions||[]).map(a=>a.key));for(const a of this._mlSugKeys(t))e.add(a);if(!e.size)return`<p class="wd-info" style="padding:12px">${this._t("msg.no_suggestions",{},"No active suggestions.")}</p>`;const s=this._opts&&this._opts.device_type||"",r=mt.filter(a=>!(a.id==="ml_training"||s&&a.notDeviceTypes&&a.notDeviceTypes.includes(s)||s&&a.onlyDeviceTypes&&!a.onlyDeviceTypes.includes(s)));let n="";for(const a of r){const i=(a.fields||(a.groups||[]).flatMap(c=>c.fields||[])).filter(c=>e.has(c.key));if(!i.length)continue;const l=i.map(c=>this._renderField(c,t)).filter(Boolean).join("");l&&(n+=`<div class="wd-subhead">${d(this._t("section."+a.id+".label",{},a.label))}</div><div class="wd-form-grid">${l}</div>`)}return n||`<p class="wd-info" style="padding:12px">${this._t("msg.no_suggestions",{},"No active suggestions.")}</p>`}_htmlMlTab(){const t=this._opts;if(!Object.keys(t).length)return`<div class="wd-empty"><div class="wd-icon">\u{1F916}</div>${this._t("msg.loading",{},"Loading\u2026")}</div>`;const e=this._mlTrainingStatus,s=this._devices[this._selIdx],r=s&&s.entry_id,n=mt.find(i=>i.id==="ml_training"),a=n?(n.fields||[]).map(i=>this._renderField(i,t)).filter(Boolean).join(""):"",o=this._busy.has("save-settings");return`
|
|
<div class="wd-card-title" style="margin:0 0 4px">${this._t("hdr.ml_smart_learning",{},"Smart Learning")}</div>
|
|
<p class="wd-sec-intro">${this._t("msg.ml_intro",{},"WashData ships with smart models that work out of the box.")}</p>
|
|
|
|
${this._htmlMlStatusSection(e,r)}
|
|
|
|
<div class="wd-card" style="margin-top:12px">
|
|
<div class="wd-card-title" style="margin:0 0 4px">${this._t("hdr.ml_settings_card",{},"Settings")}</div>
|
|
<p class="wd-info" style="margin:0 0 12px">${this._t("msg.ml_settings_intro",{},"Two independent switches: one applies the models while a cycle runs, the other lets WashData fine-tune them to your machine over time.")}</p>
|
|
<form id="wd-ml-form"><div class="wd-form-grid">${a}</div></form>
|
|
<div class="wd-card-actions" style="margin-top:12px">
|
|
<button class="wd-btn wd-btn-primary" id="wd-ml-save" ${o?"disabled":""}>${o?'<span class="wd-spin"></span> '+this._t("status.saving",{},"Saving\u2026"):this._t("btn.save",{},"Save")}</button>
|
|
</div>
|
|
<p class="wd-info" style="margin-top:10px;font-size:.78em">${this._t("msg.saving_triggers_reload",{},"Saving triggers an integration reload.")}</p>
|
|
</div>
|
|
|
|
${this._htmlMlLearnedSection(e)}
|
|
${this._htmlMatchingTuningCard()}
|
|
`}_htmlMlStatusSection(t,e){const s=e&&this._busy.has("ml-train-now:"+e)||t&&t.running,r=this._canEdit()?`<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="ml-train-now" ${s?"disabled":""}>${s?`<span class="wd-spin"></span> ${this._t("status.training",{},"Training\u2026")}`:this._t("btn.train_now",{},"Train now")}</button>`:"";if(!t)return`<div class="wd-card"><div class="wd-card-title" style="margin:0 0 4px">${this._t("hdr.status",{},"Status")}</div><p class="wd-info" style="margin:0">${this._t("msg.loading",{},"Loading\u2026")}</p></div>`;const n=Object.keys(t.on_device_models||{}).length,a=n?`<span style="color:var(--success-color,#4caf50);font-weight:600">${this._t("ml.personalized",{},"\u25CF Personalized to this machine")}</span> <span style="color:var(--secondary-text-color)">${this._t("lbl.models_fine_tuned",{count:n},"(fine-tuned models: "+n+")")}</span>`:`<span style="color:var(--secondary-text-color)">${this._t("ml.builtin_models",{},"\u25CF Using built-in models")}</span>`,o=t.cycle_count||0,i=t.min_cycles||0,l=o>=i,c=i>0?Math.min(100,Math.round(o/i*100)):100,_=l?"var(--success-color,#4caf50)":"var(--warning-color,#ff9800)",p=Math.max(0,i-o),g=l?this._t("msg.enough_data",{current:o,min:i},`Enough data to learn from (${o}/${i} cycles).`):this._t("msg.collecting_data",{need:p,current:o,min:i},`Collecting data. Cycles still needed before fine-tuning can start: ${p} (${o}/${i}).`),u=`<div style="height:8px;border-radius:6px;background:var(--secondary-background-color);overflow:hidden;margin:8px 0"><div style="width:${c}%;height:100%;background:${_}"></div></div>`,v=t.last_trained?st(t.last_trained):"never",b=s?`<span style="color:var(--info-color,#2196f3)"><span class="wd-spin"></span> ${this._t("status.fine_tuning",{},"fine-tuning now\u2026")}</span>`:t.enabled?this._t("lbl.auto_fine_tune_on",{hour:String(t.hour).padStart(2,"0")},`auto fine-tune on (around ${String(t.hour).padStart(2,"0")}:00)`):this._t("lbl.auto_fine_tune_off",{},"auto fine-tune off");return`<div class="wd-card">
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap">
|
|
<div class="wd-card-title" style="margin:0">${this._t("hdr.status",{},"Status")}</div>${r}
|
|
</div>
|
|
<div style="margin:8px 0 2px">${a}</div>
|
|
<p class="wd-info" style="margin:0">${g}</p>
|
|
${u}
|
|
<p class="wd-info" style="margin:0">${this._t("lbl.last_checked",{},"Last checked:")} <strong>${d(v)}</strong> \xB7 ${b}</p>
|
|
</div>`}_htmlMlLearnedSection(t){if(!t)return"";const e=t.on_device_models||{},s=Object.keys(e),r=this._busy.has("ml-revert-models");let n;if(!s.length)n=`<p class="wd-info" style="margin:0">${this._t("msg.no_fine_tuned",{},"Nothing fine-tuned yet \u2014 WashData is using its built-in models.")}</p>`;else{const a=s.map(i=>{const l=e[i]||{},c=l.trained_at?st(l.trained_at):"unknown";return`<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--divider-color)">
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-weight:600">${d(l.label_key?this._t(l.label_key,{},l.label||i):l.label||i)}${this._mlTrendBadge(l.trend)}</div>
|
|
<div class="wd-info" style="font-size:.8em;margin:0">${d(l.blurb_key?this._t(l.blurb_key,{},l.blurb||""):l.blurb||"")} \xB7 ${this._t("ml.fine_tuned_at",{when:d(c)},"fine-tuned "+d(c))}</div>
|
|
</div>
|
|
${this._mlQualityChip(l)}
|
|
</div>`}).join(""),o=this._canEdit()?`<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="ml-revert-models" ${r?"disabled":""} title="${d(this._t("btn.reset_ml_models_tip",{},"Discard the fine-tuned models and go back to the built-in ones. WashData can re-learn them later."))}" style="margin-top:12px">${r?'<span class="wd-spin"></span> '+this._t("status.resetting",{},"Resetting\u2026"):this._t("btn.reset_to_builtin",{},"Reset to built-in models")}</button>`:"";n=`<div>${a}</div>${o}`}return`<div class="wd-card" style="margin-top:12px">
|
|
<div class="wd-card-title" style="margin:0 0 4px">${this._t("hdr.ml_learned",{},"What WashData has learned")}</div>
|
|
<p class="wd-info" style="margin:0 0 10px">${this._t("msg.ml_learned_intro",{},"Models fine-tuned to this machine.")}</p>
|
|
${n}
|
|
</div>`}_mlQualityChip(t){let e=0,s="",r=t.metric_key?this._t(t.metric_key,t.metric_params||{},t.metric||""):t.metric||"";if(t.auc!=null)e=Math.max(0,Math.min(1,(t.auc-.5)/.5))*100,s=t.auc>=.85?this._t("ml.fit_strong",{},"Strong"):t.auc>=.75?this._t("ml.fit_good",{},"Good"):t.auc>=.65?this._t("ml.fit_fair",{},"Fair"):this._t("ml.fit_weak",{},"Weak");else if(t.model_mae!=null&&t.naive_mae!=null&&t.naive_mae>0){const a=Math.max(0,(t.naive_mae-t.model_mae)/t.naive_mae);e=Math.min(1,a)*100,s=a>=.5?this._t("ml.fit_strong",{},"Strong"):a>=.2?this._t("ml.fit_good",{},"Good"):this._t("ml.fit_slight",{},"Slight"),r=this._t("ml.better_than_baseline",{pct:(a*100).toFixed(0),metric:r},`${(a*100).toFixed(0)}% better than the baseline estimate (${r})`)}else return"";const n=e>=70?"var(--success-color,#4caf50)":e>=40?"var(--warning-color,#ff9800)":"var(--secondary-text-color)";return`<div title="${d(r)}" style="text-align:right;flex:0 0 auto">
|
|
<div style="font-size:.8em;font-weight:600;color:${n}">${s} ${this._t("ml.fit_word",{},"fit")}</div>
|
|
<div style="height:6px;width:90px;border-radius:5px;background:var(--secondary-background-color);overflow:hidden;margin:3px 0 0 auto"><div style="width:${e.toFixed(0)}%;height:100%;background:${n}"></div></div>
|
|
</div>`}_mlTrendBadge(t){if(!t)return"";const s={improving:[this._t("badge.improving",{},"\u2197 improving"),"var(--success-color,#4caf50)",this._t("ml.trend_improving_tip",{},"This model's fit has improved across recent re-checks.")],declining:[this._t("badge.declining",{},"\u2198 declining"),"var(--warning-color,#ff9800)",this._t("ml.trend_declining_tip",{},"This model's fit has slipped across recent re-checks \u2014 reviewing more cycles may help it re-learn.")],steady:[this._t("badge.steady",{},"\u2192 steady"),"var(--secondary-text-color)",this._t("ml.trend_steady_tip",{},"This model's fit has held roughly steady across recent re-checks.")]}[t];return s?` <span title="${d(s[2])}" style="font-size:.72em;font-weight:600;color:${s[1]};margin-left:6px">${s[0]}</span>`:""}_htmlMatchingTuningCard(){const t=this._mlTrainingStatus,e=t&&t.matching;if(!e)return"";const s=e.defaults||{},r=e.tuned||null,n=r&&r.config||null,a=e.active==="tuned"&&n,o=this._busy.has("ml-revert-match"),i=g=>g==null||isNaN(g)?"-":Number(g).toFixed(2),l=[["corr_weight","Shape (correlation)"],["duration_weight","Duration agreement"],["energy_weight","Energy agreement"],["dtw_ensemble_w","DTW derivative blend (DDTW)"]].map(([g,u])=>{const v=s[g],b=a?n[g]:s[g],$=a&&v!=null&&b!=null&&Math.abs(v-b)>1e-9;return`<tr>
|
|
<td>${u}</td>
|
|
<td style="text-align:right;color:var(--secondary-text-color)">${i(v)}</td>
|
|
<td style="text-align:right;font-weight:${$?"700":"400"};color:${$?"var(--primary-color)":"inherit"}">${i(b)}</td>
|
|
</tr>`}).join(""),c=a?`<span class="wd-badge" style="color:var(--success-color,#4caf50);background:rgba(76,175,80,.14)">${this._t("badge.using_tuned",{},"Using tuned weights")}</span>`:`<span class="wd-badge" style="color:var(--secondary-text-color);background:var(--secondary-background-color)">${this._t("badge.using_defaults",{},"Using shipped defaults")}</span>`;let _="";if(a){const g=r.trained_at?st(r.trained_at):"unknown",u=r.baseline_test_top1,v=r.tuned_test_top1,b=u!=null&&v!=null?` \xB7 held-out top-1 ${(u*100).toFixed(0)}% \u2192 <strong>${(v*100).toFixed(0)}%</strong>`:"";_=`<p class="wd-info" style="margin:8px 0 0">Tuned ${d(g)} from ${r.cycle_count||0} cycles${b}.</p>`}const p=a?`<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="ml-revert-match" ${o?"disabled":""}>${o?'<span class="wd-spin"></span> '+this._t("status.reverting",{},"Reverting\u2026"):this._t("btn.reset_to_defaults",{},"Reset to defaults")}</button>`:"";return`<div class="wd-card" style="margin-top:12px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:6px">
|
|
<div class="wd-card-title" style="margin:0">${this._t("hdr.ml_matching_tuning",{},"Program-matching fine-tuning")}</div>${p}
|
|
</div>
|
|
<p class="wd-info" style="margin:0 0 8px">${this._t("msg.matching_tuning_intro",{},"When learning, WashData also adjusts how much program matching weighs shape versus duration and energy.")}</p>
|
|
<div style="margin-bottom:8px">${c}</div>
|
|
<table class="wd-table" style="max-width:420px">
|
|
<thead><tr><th>${this._t("lbl.emphasis",{},"Emphasis")}</th><th style="text-align:right">${this._t("lbl.default",{},"Default")}</th><th style="text-align:right">${this._t("lbl.in_use",{},"In use")}</th></tr></thead>
|
|
<tbody>${l}</tbody>
|
|
</table>
|
|
${_}
|
|
</div>`}_pgOverrideFields(){return[["start_threshold_w","Start Threshold","W","Minimum watts to count as started","detection"],["stop_threshold_w","Stop Threshold","W","Below this, machine counts as off","detection"],["off_delay","Off Delay","s","Seconds of low power before cycle ends","timing"],["min_off_gap","Min Off Gap","s","Gap required to separate two cycles","timing"],["completion_min_seconds","Min Cycle Duration","s","Shortest run that counts as a real cycle","timing"],["start_duration_threshold","Start Duration","s","Seconds above threshold to confirm start","timing"],["end_repeat_count","End Repeat Count","","Low readings in a row before ending","advanced"],["interrupted_min_seconds","Interrupted Min","s","Short cycles flagged as interrupted","advanced"],["anti_wrinkle_enabled","Enable Anti-Wrinkle Detection","","Absorb the tumble pulses after the main phase instead of reading them as new cycles","advanced","bool"],["anti_wrinkle_max_power","Max Anti-Wrinkle Power","W","A pulse above this ends anti-wrinkle and opens a new cycle","advanced"],["anti_wrinkle_max_duration","Max Anti-Wrinkle Duration","s","A pulse longer than this ends anti-wrinkle and opens a new cycle","advanced"],["anti_wrinkle_exit_power","Anti-Wrinkle Exit Power","W","Power must fall below this between pulses for anti-wrinkle to stay active","advanced"],["anti_wrinkle_idle_timeout","Max Pulse Gap","s","Quiet time allowed between two tumble pulses before anti-wrinkle ends","advanced"],["dishwasher_end_spike_quiet_release","Passive-Dry Quiet Release","s","Dishwasher: quiet seconds after expected duration before the end-of-cycle drain wait is released","advanced"],["smart_termination_duration_ratio","Smart Termination Ratio","","Fraction of the matched program's expected duration a cycle must reach before Smart Termination may end it early; lower it for load- or temperature-dependent machines","advanced"],["profile_match_min_duration_ratio","Min Duration Ratio","","Stage 1: shortest run (vs the profile) still allowed to match","matching"],["profile_match_max_duration_ratio","Max Duration Ratio","","Stage 1: longest run (vs the profile) still allowed to match","matching"],["corr_weight","Correlation Weight","","Stage 2: balance between curve shape (correlation) and power level (MAE); default 0.45","matching"],["keep_min_score","Keep Min Score","","Stage 2: floor score to stay in the race; default 0.1 admits weak matches, later stages pick the best","matching"],["dtw_bandwidth","DTW Bandwidth","","Stage 3: Sakoe-Chiba warp band (0 = DTW off; default 0.2 = 20% of cycle length)","matching"],["dtw_blend","DTW Blend","","Stage 3: 0 = core score only, 1 = DTW score only, 0.5 = equal blend (default)","matching"],["dtw_ensemble_w","DTW Ensemble Weight","","Stage 3 (ensemble mode): weight on scaled-L1 vs derivative DTW; default 0.7 favours level-aware","matching"],["dtw_ddtw_scale","DDTW Scale","","Stage 3: DDTW half-saturation distance; smaller = more shape-sensitive (default 30)","matching"],["dtw_refine_top_n","DTW Refine Top-N","","Stage 3: candidates DTW re-scores; raise to 7-9 if correct profile ranks 4th-5th (default 5)","matching"],["duration_weight","Duration Weight","","Stage 4: how strongly run-length agreement affects the final score (default 0.22)","matching"],["energy_weight","Energy Weight","","Stage 4: how strongly energy agreement affects the final score (default 0.22)","matching"],["duration_scale","Duration Scale","","Stage 4: log-ratio where duration agreement halves; smaller = stricter penalty (default 0.175)","matching"],["energy_scale","Energy Scale","","Stage 4: log-ratio where energy agreement halves; smaller = stricter penalty (default 0.25)","matching"]]}_pgFieldVal(t,e){const s=e||{};if(s[t]!==void 0)return s[t];const r=this._pgEffective||{};if(r[t]!==void 0&&r[t]!==null)return r[t];const n=this._opts||{};if(n[t]!==void 0&&n[t]!==null)return n[t];const a=kt[t]||{};if(a.def!==void 0)return a.def;const o=this._constants&&this._constants.pgMatchDefaults||{};return o[t]!==void 0?o[t]:qt[t]!==void 0?qt[t]:""}async _pgFetchSettings(t,e=!0){try{const s=await this._ws({type:`${y}/get_playground_settings`,entry_id:t,include_suggestions:e});if(!this._isActiveEntry(t))return;this._pgEffective=s.effective||{},this._pgPublishable=Array.isArray(s.publishable)?s.publishable:null,this._pgPresets=Array.isArray(s.presets)?s.presets:[],this._pgPresetLimit=s.preset_limit||0,this._pgPresetSel&&!this._pgPresets.some(r=>r.name===this._pgPresetSel)&&(this._pgPresetSel=""),e&&this._pgApplySuggestions(s)}catch(s){this._pgIsUnknownCmd(s)&&(this._pgNeedsRestart=!0)}}_pgApplySuggestions(t){this._pgSuggClassic=t.classic_suggestions&&typeof t.classic_suggestions=="object"?t.classic_suggestions:{},this._pgSuggMl=t.ml_suggestions&&typeof t.ml_suggestions=="object"?t.ml_suggestions:null,this._pgMlSuggEnabled=!!t.ml_suggestions_enabled}async _pgFetchSuggestions(t){try{const e=await this._ws({type:`${y}/get_playground_settings`,entry_id:t,include_suggestions:!0});if(!this._isActiveEntry(t))return;this._pgApplySuggestions(e),this._tab==="playground"&&this._render()}catch{}}_pgCurrentValues(){const t={};for(const[e]of this._pgOverrideFields()){const s=this._pgFieldVal(e,{});s!==""&&s!==null&&s!==void 0&&(t[e]=s)}this._pgThreshStart!=null&&(t.start_threshold_w=this._pgThreshStart),this._pgThreshStop!=null&&(t.stop_threshold_w=this._pgThreshStop);for(const[e,s]of Object.entries(this._pgParamOverrides||{}))t[e]=s;return t}_pgStagedVal(t){return t==="start_threshold_w"?this._pgThreshStart??void 0:t==="stop_threshold_w"?this._pgThreshStop??void 0:this._pgParamOverrides[t]}_pgSetStaged(t,e){t==="start_threshold_w"?this._pgThreshStart=e:t==="stop_threshold_w"?this._pgThreshStop=e:this._pgParamOverrides[t]=e}_pgClearStaged(t){t==="start_threshold_w"?this._pgThreshStart=null:t==="stop_threshold_w"?this._pgThreshStop=null:delete this._pgParamOverrides[t]}_pgChangedKeys(){const t=this._pgEffective||{},e=[];for(const[s]of this._pgOverrideFields()){const r=this._pgStagedVal(s);if(r==null)continue;const n=t[s];n!=null&&this._pgSameVal(r,n)||e.push(s)}return e}_pgSameVal(t,e){if(typeof t=="boolean"||typeof e=="boolean")return!!t==!!e;const s=parseFloat(t),r=parseFloat(e);return!isNaN(s)&&!isNaN(r)?Math.abs(s-r)<1e-9:String(t)===String(e)}_pgIsPublishable(t){return Array.isArray(this._pgPublishable)?this._pgPublishable.includes(t):!!kt[t]}_htmlPgControlPanel(){const t=this._pgChangedKeys(),e=this._canEdit(),s=!!this._pgEffective,r=t.filter(b=>this._pgIsPublishable(b)),n=s?t.length?`<span style="color:var(--warning-color,#ff9800);font-weight:600">${this._t("msg.pg_n_changed",{n:t.length},t.length+" changed vs live settings")}</span>`:`<span style="color:var(--success-color,#4caf50)">\u2713 ${this._t("msg.pg_matches_live",{},"Matches live settings")}</span>`:`<span style="color:var(--secondary-text-color)">${this._t("msg.pg_live_unavailable",{},"Live settings unavailable \u2014 showing defaults.")}</span>`,a=`<option value="">${d(this._t("lbl.pg_preset_none",{},"Select a preset\u2026"))}</option>`+(this._pgPresets||[]).map(b=>`<option value="${d(b.name)}" ${this._pgPresetSel===b.name?"selected":""}>${d(b.name)}</option>`).join(""),o=!!this._pgPresetSel,i=this._pgPresetLimit>0&&(this._pgPresets||[]).length>=this._pgPresetLimit&&!(this._pgPresets||[]).some(b=>b.name===(this._pgPresetName||"").trim()),l=`<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:6px">
|
|
<select id="wd-pg-preset-sel" class="wd-pg-preset-sel" aria-label="${d(this._t("lbl.pg_preset",{},"Playground preset"))}">${a}</select>
|
|
<button class="wd-btn wd-btn-sm" data-action="pg-preset-load" ${o?"":"disabled"} title="${d(this._t("btn.pg_preset_load_tip",{},"Replace the Playground values with this preset (live settings are untouched)"))}">${this._t("btn.pg_preset_load",{},"Load preset")}</button>
|
|
${e?`<button class="wd-btn wd-btn-sm wd-btn-danger" data-action="pg-preset-delete" ${o?"":"disabled"} aria-label="${d(this._t("btn.pg_preset_delete",{},"Delete preset"))}" title="${d(this._t("btn.pg_preset_delete",{},"Delete preset"))}">\u{1F5D1}</button>`:""}
|
|
</div>`,c=e?`<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:6px">
|
|
<input id="wd-pg-preset-name" type="text" class="wd-pg-preset-name" maxlength="60" value="${d(this._pgPresetName||"")}" placeholder="${d(this._t("lbl.pg_preset_name",{},"New preset name"))}" aria-label="${d(this._t("lbl.pg_preset_name",{},"New preset name"))}">
|
|
<button class="wd-btn wd-btn-sm" data-action="pg-preset-save" ${(this._pgPresetName||"").trim()&&!i?"":"disabled"} title="${d(this._t("btn.pg_preset_save_tip",{},"Save every value below as a named preset for this device"))}">${this._t("btn.pg_preset_save",{},"Save as preset")}</button>
|
|
<span id="wd-pg-preset-limit-note" style="font-size:.72em;color:var(--warning-color,#ff9800)${i?"":";display:none"}">${this._t("msg.pg_preset_limit",{n:this._pgPresetLimit},"Preset limit reached ("+this._pgPresetLimit+")")}</span>
|
|
</div>`:"",_=e&&r.length?`<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="pg-apply-settings" title="${d(this._t("btn.pg_apply_to_settings_tip",{},"Copy the values you edited here into this device's live settings"))}">${this._t("btn.pg_publish_all",{n:r.length},"Publish "+r.length+" to integration")}</button>`:"",p=Object.keys(this._pgSuggClassic||{}).length,g=Object.keys(this._pgSuggMl||{}).length,u=p>0?`<button class="wd-btn wd-btn-sm" data-action="pg-load-suggested" title="${d(this._t("btn.pg_load_suggested_tip",{},"Stage the auto-tuner's current suggestions as Playground overrides"))}">\u2193 ${this._t("btn.pg_load_suggested",{n:p},"Load suggested ("+p+")")}</button>`:"",v=this._pgMlSuggEnabled&&g>0?`<button class="wd-btn wd-btn-sm" data-action="pg-load-calibrated" title="${d(this._t("btn.pg_load_calibrated_tip",{},"Stage ML-calibrated suggestions as Playground overrides"))}">\u2193 ${this._t("btn.pg_load_calibrated",{n:g},"Load Calibrated (ML) ("+g+")")}</button>`:"";return`<div class="wd-pg-ctrl">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
|
|
<span class="wd-subhead" style="margin:0">${this._t("hdr.pg_settings_source",{},"Settings source")}</span>
|
|
<span style="font-size:.75em">${n}</span>
|
|
</div>
|
|
<p class="wd-info" style="margin:4px 0 0;font-size:.72em">${this._t("msg.pg_ctrl_intro",{},"Values start from this device's live integration settings. Edits stay in the Playground until you publish them.")}</p>
|
|
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:6px">
|
|
<button class="wd-btn wd-btn-sm" data-action="pg-load-live" title="${d(this._t("btn.pg_load_live_tip",{},"Re-read the integration's current settings and drop every Playground edit"))}">\u27F3 ${this._t("btn.pg_load_live",{},"Load live settings")}</button>
|
|
${u}${v}
|
|
${_}
|
|
</div>
|
|
${l}
|
|
${c}
|
|
</div>`}_pgApplyPresetValues(t){this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={};const e=this._pgEffective||{};for(const[s,r]of Object.entries(t||{})){if(r==null)continue;const n=e[s];n!=null&&this._pgSameVal(r,n)||this._pgSetStaged(s,r)}}async _pgSavePreset(){const t=this._devices[this._selIdx],e=(this._pgPresetName||"").trim();!t||!this._canEdit()||!e||(this._pgPresets||[]).some(s=>s.name===e)&&!confirm(this._t("msg.pg_preset_overwrite",{name:e},`Overwrite the preset "${e}"?`))||(await this._busyRun("pg-preset-save",async()=>{try{const s=await this._ws({type:`${y}/save_playground_preset`,entry_id:t.entry_id,name:e,values:this._pgCurrentValues()});if(!this._isActiveEntry(t.entry_id))return;this._pgPresets=Array.isArray(s.presets)?s.presets:this._pgPresets,this._pgPresetSel=e,this._pgPresetName="",this._showToast(this._t("toast.pg_preset_saved",{name:e},`Preset "${e}" saved`))}catch(s){this._showToast(this._t("msg.toast_save_failed",{error:s.message||s},"Save failed: "+(s.message||s)),"error")}}),this._render())}async _pgDeletePreset(){const t=this._devices[this._selIdx],e=this._pgPresetSel;!t||!this._canEdit()||!e||confirm(this._t("msg.pg_preset_delete_confirm",{name:e},`Delete the preset "${e}"?`))&&(await this._busyRun("pg-preset-delete",async()=>{try{const s=await this._ws({type:`${y}/delete_playground_preset`,entry_id:t.entry_id,name:e});if(!this._isActiveEntry(t.entry_id))return;this._pgPresets=Array.isArray(s.presets)?s.presets:(this._pgPresets||[]).filter(r=>r.name!==e),this._pgPresetSel=""}catch(s){this._showToast(this._t("msg.toast_error",{error:s.message||s},"Error: "+(s.message||s)),"error")}}),this._render())}async _pgLoadLive(){const t=this._devices[this._selIdx];if(!t)return;const e=this._pgChangedKeys();e.length&&!confirm(this._t("msg.pg_load_live_confirm",{n:e.length},`Discard ${e.length} Playground edit(s) and reload the integration's current settings?`))||(await this._busyRun("pg-load-live",async()=>{this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={},await this._pgFetchSettings(t.entry_id)}),this._render(),requestAnimationFrame(()=>this._pgDrawCanvas()))}_pgLoadSuggested(t){const e=t==="ml"?this._pgSuggMl||{}:this._pgSuggClassic||{},s=this._pgEffective||{};let r=0;for(const[n,a]of Object.entries(e)){if(a==null)continue;const o=s[n];o!=null&&this._pgSameVal(a,o)||(this._pgSetStaged(n,a),r++)}if(r===0)this._showToast(this._t("msg.pg_sugg_none",{},"All suggestions already match the current settings"));else{const n=t==="ml"?"toast.pg_sugg_ml_loaded":"toast.pg_sugg_loaded",a=t==="ml"?`Staged ${r} ML-calibrated value(s) - run the Playground to compare`:`Staged ${r} suggested value(s) - run the Playground to compare`;this._showToast(this._t(n,{n:r},a))}this._render(),requestAnimationFrame(()=>this._pgDrawCanvas())}async _pgPublishOne(t){const e=this._devices[this._selIdx];if(!e||!this._canEdit()||!t||!this._pgIsPublishable(t))return;const s=this._pgStagedVal(t);if(s==null)return;const r=this._t("setting."+t+".label",{},t);confirm(this._t("msg.pg_publish_one_confirm",{label:r,value:s},`Save ${r} = ${s} to this device's settings?`))&&(await this._busyRun("pg-publish-"+t,async()=>{try{if(await this._ws({type:`${y}/set_options`,entry_id:e.entry_id,options:{[t]:s}}),!this._isActiveEntry(e.entry_id))return;this._opts={...this._opts,[t]:s},this._pgEffective&&(this._pgEffective={...this._pgEffective,[t]:s}),this._pgClearStaged(t),this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading"))}catch(n){this._showToast(this._t("msg.toast_save_failed",{error:n.message||n},"Save failed: "+(n.message||n)),"error")}}),this._render())}_htmlPlayground(){if(!this._devices[this._selIdx])return`<div class="wd-empty">${this._t("msg.no_device_selected",{},"No device selected.")}</div>`;const e=this._cycles||[],s=this._profiles||[],r=e.map(u=>{const v=u.profile_name||u.matched_profile||this._t("lbl.unlabelled",{},"Unlabelled"),b=u.duration?` \xB7 ${Math.round(u.duration/60)} min`:"",$=u.start_time?` \xB7 ${st(u.start_time)}`:"";return`<option value="${d(u.id)}" ${this._pgCycleId===u.id?"selected":""}>${d(v+b+$)}</option>`}).join(""),n=`<option value="">${d(this._t("lbl.auto_detect",{},"Auto-detect"))}</option>`+s.map(u=>`<option value="${d(u.name)}" ${this._pgProfileName===u.name?"selected":""}>${d(u.name)}</option>`).join(""),a=this._pgLoading,o=`<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:4px">
|
|
<div class="wd-field" style="min-width:180px;margin:0"><label>${this._t("lbl.cycle",{},"Cycle")}</label><select id="wd-pg-cyc-sel" ${a?"disabled":""}>${r||'<option value="">\u2014</option>'}</select></div>
|
|
<div class="wd-field" style="min-width:160px;margin:0"><label>${this._t("lbl.profile",{},"Profile")}</label><select id="wd-pg-prof-sel" ${a?"disabled":""}>${n}</select></div>
|
|
<div style="display:flex;gap:6px;align-items:flex-end;padding-bottom:2px">
|
|
<button class="wd-btn wd-btn-primary" data-action="pg-run" ${a?"disabled":""} style="min-width:72px">\u25B6 ${this._t("btn.run",{},"Run")}</button>
|
|
${a?`<button class="wd-btn" data-action="pg-cancel-run" style="min-width:72px">\u2715 ${this._t("btn.cancel",{},"Cancel")}</button>`:""}
|
|
</div>
|
|
</div>`,i=a?`<div class="wd-pg-simbar" role="progressbar" aria-label="${d(this._t("msg.pg_simulating",{},"Simulating cycle\u2026"))}"><div class="wd-pg-simbar-fill"></div></div>
|
|
<div style="font-size:.78em;color:var(--secondary-text-color);margin:4px 0 0">${this._t("msg.pg_simulating",{},"Simulating cycle\u2026")}</div>`:"",l=!this._pgPowerPts&&!a?`<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;gap:6px">
|
|
<div style="font-size:1.6em;opacity:.25">〜</div>
|
|
<div style="font-size:.82em;color:var(--secondary-text-color);text-align:center">${this._t("msg.pg_canvas_empty2",{},"Pick a cycle above and press Run to simulate it. Then hover to read values, scroll to zoom, and drag to pan.")}</div>
|
|
</div>`:"",c=`${i}<div class="wd-pg-canvas-wrap" style="position:relative"><canvas id="wd-pg-canvas" role="img" aria-label="${d(this._t("lbl.aria_playground_chart2",{},"Interactive cycle power graph: hover to read time/power, scroll to zoom, drag to pan"))}"></canvas>${l}</div>`,_=this._htmlPgStrip(),p=this._pgNeedsRestart?`<p class="wd-info" style="color:var(--warning-color,#ff9800);margin:4px 0">\u26A0 ${this._t("msg.pg_restart_note",{},"Restart Home Assistant to enable simulation tools.")}</p>`:"",g=`${o}${c}${_}
|
|
<div class="wd-pg-sim-grid">
|
|
<div class="wd-pg-sim-main">${this._htmlPgParamRows()}</div>
|
|
<div class="wd-pg-sim-side">${this._htmlPgAlerts()}${this._htmlPgAnalysis()}</div>
|
|
</div>`;return`<div class="wd-card">
|
|
<div class="wd-card-title" style="margin:0 0 10px">${this._t("hdr.playground",{},"Playground")}</div>
|
|
<p class="wd-sec-intro" style="margin:0 0 10px">${this._t("msg.playground_intro",{},"Explore how settings affect detection on your real cycle data. Nothing here changes live configuration until you explicitly apply it.")}</p>
|
|
${p}
|
|
${g}
|
|
${this._htmlPgDrawer()}
|
|
</div>`}_htmlPgDrawer(){const t=this._pgAnalysisTab||"history",s=`<div class="wd-pg-subtabs" role="tablist">
|
|
${[["history",this._t("lbl.pg_mode_history",{},"Test on history")],["sweep",this._t("lbl.pg_mode_optimize",{},"Optimize")]].map(([n,a])=>`<button role="tab" aria-selected="${t===n}" class="wd-pg-subtab${t===n?" active":""}" data-action="pg-analysis-tab" data-subtab="${n}">${d(a)}</button>`).join("")}
|
|
</div>`,r=t==="sweep"?this._htmlPgSweepMode():this._htmlPgHistoryMode();return`<section class="wd-pg-drawer">
|
|
<div class="wd-pg-drawer-head">
|
|
<span class="wd-subhead" style="margin:0">${this._t("hdr.pg_across_cycles",{},"Across your cycles")}</span>
|
|
${s}
|
|
</div>
|
|
${r}
|
|
</section>`}_htmlPgParamRows(){const t=this._pgOverrideFields(),e=new Set(["start_threshold_w","stop_threshold_w"]),s={detection:"#2a78d6",timing:"#1baf7a",advanced:"#eda100",matching:"#a05cd6"},r={detection:this._t("lbl.pg_group_detection",{},"Detection triggers"),timing:this._t("lbl.pg_group_timing",{},"Timing rules"),advanced:this._t("lbl.pg_group_advanced",{},"Edge cases"),matching:this._t("lbl.pg_group_matching",{},"Program matching")};let n="";const a=t.map(([p,g,u,v,b,$])=>{const k=this._t("setting."+p+".label",{},g),S=this._pgFieldVal(p,{});let C;p==="start_threshold_w"?C=this._pgThreshStart??S:p==="stop_threshold_w"?C=this._pgThreshStop??S:C=this._pgParamOverrides[p]??S;const R=$==="bool",B=e.has(p),W=u||"",O=s[b]||"#2a78d6",D=r[b]||"";let A="";b&&b!==n&&(A=`<div style="display:flex;align-items:center;gap:8px;margin:${n?"12px":"2px"} 0 5px">
|
|
<div style="width:3px;height:14px;border-radius:2px;background:${O};flex-shrink:0"></div>
|
|
<span style="font-size:.7em;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--secondary-text-color)">${d(D)}</span>
|
|
</div>`,n=b);const E=this._pgStagedVal(p),M=E!=null&&!(S!==""&&S!==null&&S!==void 0&&this._pgSameVal(E,S)),H=this._canEdit()&&M&&this._pgIsPublishable(p)?`<button class="wd-pg-pub" data-action="pg-publish-one" data-pgkey="${d(p)}" aria-label="${d(this._t("btn.pg_publish_one",{label:k},"Publish "+k+" to the integration"))}" title="${d(this._t("btn.pg_publish_one",{label:k},"Publish "+k+" to the integration"))}">\u2191</button>`:'<span class="wd-pg-pub-slot" aria-hidden="true"></span>';return`${A}<div style="display:flex;align-items:flex-start;gap:6px;margin:0 0 6px 11px">
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:.82em;font-weight:600;margin-bottom:1px">${d(k)}${B?` <span style="color:${O};font-size:.85em" title="${d(this._t("lbl.pg_drag_hint",{},"Drag line on graph"))}">\u2195</span>`:""}</div>
|
|
${v?`<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${d(this._t("pg_desc."+p,{},v))}</div>`:""}
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
|
${R?`<input class="wd-pg-param-chk" type="checkbox" data-pgkey="${d(p)}" data-pgtype="bool" aria-label="${d(k)}" ${C?"checked":""} style="width:18px;height:18px;margin:1px 27px 0 27px">`:`<input class="wd-pg-param-inp" type="text" inputmode="decimal" data-pgkey="${d(p)}" value="${C!==""?d(String(C)):""}" placeholder="${S!==""?d(String(S)):""}" aria-label="${d(k)}" style="width:72px">`}
|
|
${W?`<span style="font-size:.75em;color:var(--secondary-text-color);min-width:14px">${d(W)}</span>`:""}
|
|
${H}
|
|
</div>
|
|
</div>`}).join(""),i=`<div style="display:flex;align-items:center;gap:8px;margin:12px 0 5px">
|
|
<div style="width:3px;height:14px;border-radius:2px;background:#c0392b;flex-shrink:0"></div>
|
|
<span style="font-size:.7em;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--secondary-text-color)">${d(this._t("lbl.pg_stress_group",{},"Idle termination test"))}</span>
|
|
</div>`,l=`<div style="display:flex;align-items:flex-start;gap:6px;margin:0 0 6px 11px">
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:.82em;font-weight:600;margin-bottom:1px">${d(this._t("lbl.pg_stress_toggle",{},"Test idle termination"))}</div>
|
|
<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${d(this._t("lbl.pg_stress_toggle_desc",{},"Simulates the appliance staying at its standby draw after recording ends \u2014 shows if and when WashData stops the cycle."))}</div>
|
|
</div>
|
|
<label style="display:flex;align-items:center;gap:4px;flex-shrink:0;cursor:pointer">
|
|
<input type="checkbox" id="wd-pg-stress-toggle" ${this._pgStressTail?"checked":""} aria-label="${d(this._t("lbl.pg_stress_toggle",{},"Test idle termination"))}">
|
|
</label>
|
|
</div>`,c=this._pgStressTail?`<div style="display:flex;align-items:flex-start;gap:6px;margin:0 0 6px 11px">
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:.82em;font-weight:600;margin-bottom:1px">${d(this._t("lbl.pg_stress_idle_w",{},"Idle level (W)"))}</div>
|
|
<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${d(this._t("lbl.pg_stress_idle_w_desc",{},"Override the auto-detected standby floor (leave blank for auto)."))}</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
|
<input class="wd-pg-param-inp" type="text" inputmode="decimal" id="wd-pg-stress-idle-w" value="${this._pgStressIdleW!=null?d(String(this._pgStressIdleW)):""}" placeholder="${d(this._t("lbl.pg_stress_idle_auto",{},"Auto"))}" aria-label="${d(this._t("lbl.pg_stress_idle_w",{},"Idle level (W)"))}" style="width:72px">
|
|
<span style="font-size:.75em;color:var(--secondary-text-color);min-width:14px">W</span>
|
|
</div>
|
|
</div>`:"",_=`${i}${l}${c}`;return`<div class="wd-pg-params">
|
|
<div class="wd-subhead" style="margin:0 0 6px">${this._t("hdr.pg_detection_params",{},"Detection settings")}</div>
|
|
${this._htmlPgControlPanel()}
|
|
${a}${_}
|
|
<div style="display:flex;gap:6px;margin:8px 0 4px;align-items:center;flex-wrap:wrap">
|
|
<button class="wd-btn wd-btn-sm" data-action="pg-reset-params">${this._t("btn.reset",{},"Reset")}</button>
|
|
${this._pgDetailBusy?'<span class="wd-spin" style="align-self:center"></span>':""}
|
|
</div>
|
|
</div>`}_htmlPgAlerts(){const t=this._pgDetail;if(!t)return"";const e=t.outcome||{},s={error:"var(--error-color,#f44336)",warn:"var(--warning-color,#ff9800)",info:"var(--info-color,#2196f3)"},r=Array.isArray(t.alerts)?t.alerts:[],n=r.length?r.map(c=>`<div class="wd-pg-alert" style="border-left-color:${s[c.severity]||s.info}">
|
|
<span style="font-weight:600">${d(this._pgAlertLabel(c.code))}</span>
|
|
<div style="font-size:.78em;color:var(--secondary-text-color)">${d(c.detail_key?this._t(c.detail_key,c.detail_params||{},c.detail||""):c.detail||"")}</div>
|
|
</div>`).join(""):`<div style="font-size:.82em;color:var(--success-color,#4caf50)">\u2713 ${this._t("msg.pg_no_alerts",{},"No issues detected in this run.")}</div>`,a=e.termination_reason?String(e.termination_reason):"\u2014",o=e.final_duration_s?Math.round(e.final_duration_s/60)+" min":"\u2014",i=e.projected_energy_wh!=null?e.projected_energy_wh>=1e3?(e.projected_energy_wh/1e3).toFixed(2)+" kWh":Math.round(e.projected_energy_wh)+" Wh":"\u2014",l=(c,_)=>`<div class="wd-pg-outcome-item"><div class="wd-pg-outcome-val">${d(_)}</div><div class="wd-pg-outcome-lbl">${d(c)}</div></div>`;return`<div class="wd-pg-alerts-card">
|
|
<div class="wd-subhead" style="margin:0 0 6px">${this._t("hdr.pg_outcome",{},"Simulation outcome")}</div>
|
|
<div class="wd-pg-outcome-grid">
|
|
${l(this._t("lbl.pg_ended",{},"Ended"),a)}
|
|
${l(this._t("lbl.duration",{},"Duration"),o)}
|
|
${l(this._t("lbl.pg_proj_energy",{},"Proj. energy"),i)}
|
|
</div>
|
|
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">${n}</div>
|
|
</div>`}_pgAlertLabel(t){return{overrun:this._t("lbl.pg_alert_overrun",{},"Overrun"),underrun:this._t("lbl.pg_alert_underrun",{},"Underrun"),did_not_finish:this._t("lbl.pg_alert_did_not_finish",{},"Did not finish"),false_end:this._t("lbl.pg_alert_false_end",{},"Split into multiple cycles"),unmatched:this._t("lbl.pg_alert_unmatched",{},"Unmatched"),ambiguous:this._t("lbl.pg_alert_ambiguous",{},"Ambiguous match"),energy_anomaly:this._t("lbl.pg_alert_energy",{},"Energy anomaly"),timeout_end:this._t("lbl.pg_alert_timeout_end",{},"Ended by timeout, not prediction"),would_run_indefinitely:this._t("lbl.pg_alert_indefinite",{},"Would run indefinitely"),stress_terminated:this._t("lbl.pg_alert_stress_ok",{},"Idle termination: cycle stopped"),stress_above_threshold:this._t("lbl.pg_alert_stress_warn",{},"Idle draw above stop threshold"),stress_hit_cap:this._t("lbl.pg_alert_stress_cap",{},"Hit safety cap")}[t]||t}_htmlPgHistoryMode(){const t=this._busy.has("pg-history"),e=this._pgHistory,s=Object.keys(this._pgParamOverrides||{}).length>0||this._pgThreshStart!=null||this._pgThreshStop!=null,r=`<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
|
|
<span style="font-size:.85em">${this._t("lbl.last",{},"Last")}</span>
|
|
<input type="number" id="wd-pg-simn" value="${this._pgSimCycles}" min="1" max="200" style="width:56px">
|
|
<span style="font-size:.85em">${this._t("lbl.cycles_lc",{},"cycles")}</span>
|
|
<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="pg-run-history" ${t?"disabled":""}>\u25B6 ${this._t("btn.run",{},"Run")}</button>
|
|
${s?`<span style="font-size:.78em;color:var(--warning-color,#ff9800)">\u2699 ${this._t("msg.pg_override_active",{},"Using your edited settings vs. current")}</span>`:""}
|
|
</div>`,n=t?this._htmlPgBatchBar():"",a=`<p class="wd-sec-intro" style="margin:0 0 8px">${this._t("msg.pg_history_intro2",{},"Replay your recent cycles through the real detector and matcher with the settings above. Click any row to load that cycle in the graph; edit a setting to see a before/after comparison.")}</p>`;if(!e||!Array.isArray(e.rows))return`${a}${r}${this._htmlPgRecentRuns("pg_history")}${n}${t?"":`<div class="wd-empty" style="padding:24px">${this._t("msg.pg_history_empty",{},"Press Run to replay your cycles.")}</div>`}`;let o="";if(e.diff){const u=(v,b,$)=>`<span class="wd-pg-diffbadge" style="background:${b}22;color:${b}">${v} ${d($)}</span>`;o=`<div style="margin:0 0 10px">
|
|
${u((e.diff.newly_correct||[]).length,"var(--success-color,#4caf50)",this._t("lbl.pg_newly_correct",{},"newly correct"))}
|
|
${u((e.diff.regressed||[]).length,"var(--error-color,#f44336)",this._t("lbl.pg_regressed",{},"regressed"))}
|
|
${u((e.diff.end_timing_changed||[]).length,"var(--warning-color,#ff9800)",this._t("lbl.pg_end_timing_changed",{},"end-timing changed"))}
|
|
</div>`}const i=e.summary||{},l=`<div style="font-size:.82em;color:var(--secondary-text-color);margin-bottom:8px">${this._t("msg.pg_history_summary",{detected:i.detected,correct:i.match_correct,total:i.cycles},`${i.detected}/${i.cycles} detected \xB7 ${i.match_correct} matched correctly`)}</div>`,c=u=>(this._cycles||[]).find(v=>v.id===u),_={};(e.baseline_rows||[]).forEach(u=>{_[u.cycle_id]=u});const p=e.rows.map(u=>{const v=c(u.cycle_id),b=v&&v.start_time?st(v.start_time):(u.cycle_id||"").slice(0,8),$=u.match_correct===!0?"\u2713":u.match_correct===!1?"\u2717":"\u2014",k=u.match_correct===!0?"var(--success-color,#4caf50)":u.match_correct===!1?"var(--error-color,#f44336)":"var(--secondary-text-color)",S=u.matched_profile||this._t("lbl.unlabelled",{},"Unlabelled"),C=u.duration_s?Math.round(u.duration_s/60)+"m":"\u2014",R=u.overrun_ratio!=null?` (${Math.round(u.overrun_ratio*100)}%)`:"",B=u.alerts&&u.alerts.length?` <span title="${d(u.alerts.join(", "))}" style="color:var(--warning-color,#ff9800)">\u26A0</span>`:"",W=_[u.cycle_id];let O="";return W&&(W.match_correct!==!0&&u.match_correct===!0?O=`<span style="color:var(--success-color,#4caf50)">\u25B2 ${this._t("lbl.pg_fixed",{},"fixed")}</span>`:W.match_correct===!0&&u.match_correct!==!0?O=`<span style="color:var(--error-color,#f44336)">\u25BC ${this._t("lbl.pg_broke",{},"broke")}</span>`:String(W.termination_reason)!==String(u.termination_reason)&&(O=`<span style="color:var(--warning-color,#ff9800)">${d(String(W.termination_reason||"\u2014"))}\u2192${d(String(u.termination_reason||"\u2014"))}</span>`)),`<tr class="wd-pg-hrow${u.cycle_id===this._pgCycleId?" selected":""}" data-action="pg-open-cycle" data-cid="${d(u.cycle_id)}" title="${d(this._t("msg.pg_row_load_hint",{},"Load this cycle in the graph above"))}">
|
|
<td>${d(b)}</td>
|
|
<td><span style="color:${k};font-weight:700">${$}</span> ${d(S)}</td>
|
|
<td>${d(String(u.termination_reason||"\u2014"))}</td>
|
|
<td style="font-variant-numeric:tabular-nums">${C}${R}${B}</td>
|
|
${e.diff?`<td>${O}</td>`:""}
|
|
</tr>`}).join(""),g=`<table class="wd-pg-htable"><thead><tr>
|
|
<th>${this._t("lbl.cycle",{},"Cycle")}</th>
|
|
<th>${this._t("lbl.match",{},"Match")}</th>
|
|
<th>${this._t("lbl.pg_ended",{},"Ended")}</th>
|
|
<th>${this._t("lbl.duration",{},"Duration")}</th>
|
|
${e.diff?`<th>${this._t("lbl.pg_vs_current",{},"vs current")}</th>`:""}
|
|
</tr></thead><tbody>${p}</tbody></table>`;return`${a}${r}${this._htmlPgRecentRuns("pg_history")}${o}${l}${g}`}_htmlPgBatchBar(){const t=this._pgBatchProgress;return t?`<div class="wd-pg-batchrow">
|
|
<div class="wd-pg-simbar"><div class="wd-pg-batchbar-fill" id="wd-pg-batch-fill" style="width:${t.total?Math.round(100*t.done/t.total):0}%"></div></div>
|
|
<span style="font-size:.78em;color:var(--secondary-text-color);white-space:nowrap;font-variant-numeric:tabular-nums"><span id="wd-pg-batch-count">${t.done}</span>/${t.total}</span>
|
|
<button class="wd-btn wd-btn-sm" data-action="pg-batch-cancel">\u2715 ${this._t("btn.cancel",{},"Cancel")}</button>
|
|
</div>`:""}_pgUpdateBatchBar(t,e){const s=this.shadowRoot;if(!s)return;const r=s.getElementById("wd-pg-batch-fill"),n=s.getElementById("wd-pg-batch-count"),a=e?Math.round(100*t/e):0;r&&(r.style.width=a+"%"),n&&(n.textContent=String(t))}async _pgRunHistory(){const t=this._devices[this._selIdx];if(!t)return;const e=(this._cycles||[]).slice(0,Math.max(1,this._pgSimCycles||20)).map(r=>r.id);if(!e.length){this._showToast(this._t("msg.no_cycles_selected",{},"No cycles available."),"error");return}const s={...this._pgParamOverrides};this._pgThreshStart!=null&&(s.start_threshold_w=this._pgThreshStart),this._pgThreshStop!=null&&(s.stop_threshold_w=this._pgThreshStop),this._pgBatchProgress={done:0,total:e.length},this._busy.add("pg-history"),this._render();try{const r=await this._ws({type:`${y}/start_playground_history`,entry_id:t.entry_id,cycle_ids:e,settings_override:s});if(this._pgHistoryTaskId=r&&r.task_id,this._pgNeedsRestart=!1,!this._pgHistoryTaskId)throw new Error("no task id");this._addProvisionalTask(this._pgHistoryTaskId,"pg_history",t.entry_id,e.length),this._tasksSubscribed||this._pgPollTask(this._pgHistoryTaskId)}catch(r){this._busy.delete("pg-history"),this._pgBatchProgress=null,this._pgIsUnknownCmd(r)?this._pgNeedsRestart=!0:this._showToast(this._t("msg.toast_error",{error:r.message||r},"Error: "+(r.message||r)),"error"),this._render()}}_pgSweepObjectives(){return[["match_accuracy",this._t("lbl.pg_obj_match",{},"Match accuracy"),!1],["end_timing_accuracy",this._t("lbl.pg_obj_endtiming",{},"End-timing accuracy"),!1],["false_end_rate",this._t("lbl.pg_obj_falseend",{},"False-end rate"),!0],["median_overrun",this._t("lbl.pg_obj_overrun",{},"Duration off-target"),!0],["ambiguity_rate",this._t("lbl.pg_obj_ambiguity",{},"Ambiguity rate"),!0]]}_htmlPgSweepMode(){const t=this._busy.has("pg-sweep"),s=this._pgOverrideFields().filter(([,,,,,i])=>i!=="bool").map(([i,l])=>`<option value="${i}" ${this._pgSweepParam===i?"selected":""}>${d(this._t("setting."+i+".label",{},l))}</option>`).join(""),r=this._pgSweepObjectives().map(([i,l])=>`<option value="${i}" ${this._pgSweepObjective===i?"selected":""}>${d(l)}</option>`).join(""),n=`<p class="wd-sec-intro" style="margin:0 0 8px">${this._t("msg.pg_sweep_intro2",{},"Find the setting that best meets an objective across your recent cycles. Nothing changes until you apply it.")}</p>`,a=`<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-bottom:8px">
|
|
<div class="wd-field" style="margin:0;min-width:150px"><label>${this._t("lbl.pg_sweep_param",{},"Parameter")}</label><select id="wd-pg-sw-param">${s}</select></div>
|
|
<div class="wd-field" style="margin:0;min-width:150px"><label>${this._t("lbl.pg_objective",{},"Objective")}</label><select id="wd-pg-sw-obj">${r}</select></div>
|
|
<div class="wd-field" style="margin:0"><label>${this._t("lbl.from",{},"From")}</label><input type="number" id="wd-pg-sw-from" value="${d(String(this._pgSweepFrom))}" style="width:70px" step="any"></div>
|
|
<div class="wd-field" style="margin:0"><label>${this._t("lbl.to",{},"To")}</label><input type="number" id="wd-pg-sw-to" value="${d(String(this._pgSweepTo))}" style="width:70px" step="any"></div>
|
|
<div class="wd-field" style="margin:0"><label>${this._t("lbl.pg_steps",{},"Steps")}</label><input type="number" id="wd-pg-sw-steps" value="${Math.max(2,Math.min(12,this._pgSweepSteps||5))}" min="2" max="12" style="width:52px"></div>
|
|
<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="pg-sweep-run2" ${t?"disabled":""} style="margin-bottom:2px">\u25B6 ${this._t("btn.run",{},"Run")}</button>
|
|
</div>`,o=t?this._htmlPgBatchBar():"";return`${n}${a}${this._htmlPgRecentRuns("pg_sweep")}${o}${this._htmlPgSweepResult()}`}_htmlPgSweepResult(){const t=this._pgSweepNew;if(!t||!Array.isArray(t.points)||!t.points.length)return"";const e=this._pgSweepObjectives().find(p=>p[0]===t.objective),s=e?e[2]:!1,r=t.points.filter(p=>p.metric!=null).map(p=>p.metric);if(!r.length)return`<div class="wd-empty" style="padding:16px">${this._t("msg.pg_sweep_no_metric",{},"Not enough data to score this objective.")}</div>`;const n=Math.min(...r),a=Math.max(...r),o=s?Math.min(...r):Math.max(...r),i=(t.points.find(p=>p.metric===o)||{}).value,l=p=>t.objective==="median_overrun"?Math.round(p*100)+"% off":Math.round(p*100)+"%",c=t.points.map(p=>{const g=a>n&&p.metric!=null?(p.metric-n)/(a-n):p.metric!=null?1:0,u=p.metric===o,v=t.current_value!=null&&Math.abs(p.value-t.current_value)<1e-6,b=u?"var(--success-color,#4caf50)":"var(--primary-color)";return`<div style="display:flex;align-items:center;gap:8px;margin:3px 0;font-size:.82em">
|
|
<span style="flex:0 0 74px;text-align:right;font-variant-numeric:tabular-nums">${d(String(p.value))}${v?" \u25C0":""}</span>
|
|
<div style="flex:1;height:16px;border-radius:4px;background:var(--secondary-background-color);overflow:hidden"><div style="height:100%;width:${Math.round((p.metric!=null?.15+.85*g:0)*100)}%;background:${b};border-radius:4px"></div></div>
|
|
<span style="flex:0 0 46px;text-align:right;font-variant-numeric:tabular-nums">${p.metric!=null?l(p.metric):"\u2014"}</span>
|
|
</div>`}).join(""),_=this._canEdit()&&i!=null?`<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="pg-sweep-apply2" data-val="${d(String(i))}">${this._t("btn.pg_apply_best",{},"Apply best")}</button>`:"";return`<div style="font-size:.82em;color:var(--secondary-text-color);margin:4px 0 6px">${this._t("lbl.pg_best_value",{},"Best value found")}: <strong style="color:var(--primary-text-color)">${d(String(i))}</strong> \xB7 ${l(o)} \xB7 <span>\u25C0 ${this._t("lbl.pg_current_value",{},"Current value:")}</span></div>
|
|
${c}
|
|
<div style="margin-top:8px">${_}</div>`}async _pgRunSweep2(){const t=this._devices[this._selIdx];if(!t)return;const e=parseFloat(this._pgSweepFrom),s=parseFloat(this._pgSweepTo);if(isNaN(e)||isNaN(s)||e===s){this._showToast(this._t("msg.toast_name_required",{},"Set a valid From/To range."),"error");return}const r=Math.max(2,Math.min(12,this._pgSweepSteps||5)),n=Array.from({length:r},(l,c)=>+(e+(s-e)*c/(r-1)).toFixed(3)),a=this._pgSweepParam||"off_delay",o=this._pgSweepObjective||"match_accuracy",i={type:`${y}/start_playground_sweep`,entry_id:t.entry_id,param:a,values:n,objective:o};this._pgBatchProgress={done:0,total:n.length},this._busy.add("pg-sweep"),this._render();try{const l=await this._ws(i);if(this._pgSweepTaskId=l&&l.task_id,this._pgNeedsRestart=!1,!this._pgSweepTaskId)throw new Error("no task id");this._addProvisionalTask(this._pgSweepTaskId,"pg_sweep",t.entry_id,n.length),this._tasksSubscribed||this._pgPollTask(this._pgSweepTaskId)}catch(l){this._busy.delete("pg-sweep"),this._pgBatchProgress=null,this._pgIsUnknownCmd(l)?this._pgNeedsRestart=!0:this._showToast(this._t("msg.toast_error",{error:l.message||l},"Error: "+(l.message||l)),"error"),this._render()}}async _pgApplyToSettings(){const t=this._devices[this._selIdx];if(!t||!this._canEdit())return;const e={};for(const n of this._pgChangedKeys())this._pgIsPublishable(n)&&(e[n]=this._pgStagedVal(n));const s=Object.keys(e);if(!s.length)return;const r=s.map(n=>this._t("setting."+n+".label",{},n)).join(", ");confirm(this._t("msg.pg_apply_settings_confirm",{n:s.length,list:r},`Save these ${s.length} setting(s) to this device? ${r}`))&&(await this._busyRun("pg-apply-settings",async()=>{try{if(await this._ws({type:`${y}/set_options`,entry_id:t.entry_id,options:e}),!this._isActiveEntry(t.entry_id))return;this._opts={...this._opts,...e},this._pgEffective&&(this._pgEffective={...this._pgEffective,...e});for(const n of s)this._pgClearStaged(n);this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading"))}catch(n){this._showToast(this._t("msg.toast_save_failed",{error:n.message||n},"Save failed: "+(n.message||n)),"error")}}),this._render())}async _pgApplySweepValue(t){const e=this._devices[this._selIdx];if(!e||!this._canEdit()||t==null)return;const s=this._pgSweepParam,r=this._t("setting."+s+".label",{},s);confirm(this._t("msg.pg_apply_confirm",{label:r,value:t},"Apply best value: "+r+" = "+t+"?"))&&await this._busyRun("pg-sweep-apply",async()=>{try{await this._ws({type:`${y}/set_options`,entry_id:e.entry_id,options:{[s]:+t}}),this._opts={...this._opts,[s]:+t},s==="start_threshold_w"?this._pgThreshStart=+t:s==="stop_threshold_w"?this._pgThreshStop=+t:this._pgParamOverrides[s]=+t,this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading")),this._render()}catch(n){this._showToast(this._t("msg.toast_save_failed",{error:n.message||n},"Save failed: "+(n.message||n)),"error")}})}_htmlPgStrip(){return`<div class="wd-pg-strip" id="wd-pg-strip">
|
|
<span class="wd-pg-strip-state" id="wd-pg-state-badge" style="background:var(--secondary-background-color);text-transform:uppercase">${this._t("lbl.pg_idle",{},"Idle")}</span>
|
|
<span style="font-size:.75em"><span style="color:var(--secondary-text-color);text-transform:uppercase">${this._t("lbl.power",{},"Power")} </span><span id="wd-pg-power">\u2014</span></span>
|
|
<span class="wd-pg-strip-pbar">
|
|
<span class="wd-pg-strip-track"><span class="wd-pg-strip-fill" id="wd-pg-pbar" style="width:0%"></span></span>
|
|
<span id="wd-pg-pct">\u2014%</span>
|
|
</span>
|
|
<span style="font-size:.75em;color:var(--secondary-text-color);text-transform:uppercase" title="${d(this._t("lbl.pg_time_left_model_tip",{},"Model-estimated time remaining (phase estimator + ML blend), not a static countdown"))}">${this._t("lbl.pg_time_left_model",{},"Time left (model)")} <span id="wd-pg-rem" style="color:var(--primary-text-color,inherit)">\u2014</span></span>
|
|
<span style="font-size:.75em;color:var(--secondary-text-color);text-transform:uppercase">${this._t("lbl.energy",{},"Energy")} <span id="wd-pg-energy" style="color:var(--primary-text-color,inherit)">\u2014</span></span>
|
|
<span style="font-size:.75em;color:var(--secondary-text-color);text-transform:uppercase">${this._t("lbl.match",{},"Match")} <span id="wd-pg-conf" style="color:var(--primary-text-color,inherit)">\u2014</span></span>
|
|
<span style="font-size:.75em;color:var(--secondary-text-color);text-transform:uppercase">${this._t("lbl.phase",{},"Phase")} <span id="wd-pg-phase" style="color:var(--primary-text-color,inherit)">\u2014</span></span>
|
|
</div>`}_htmlPgAnalysis(){const t=this._pgDtwData,e=(i,l,c,_,p)=>{const g=c&&l!=null?Math.max(0,Math.min(1,l/c)):l!=null?Math.max(0,Math.min(1,l)):0;return`<div class="wd-pg-score-bar-row">
|
|
<span class="wd-pg-score-bar-lbl">${d(i)}</span>
|
|
<div class="wd-pg-score-bar-track"><div class="wd-pg-score-bar-fill" style="width:${Math.round(g*100)}%;background:${_}"></div></div>
|
|
<span class="wd-pg-score-bar-val">${p!=null?d(String(p)):"\u2014"}</span>
|
|
</div>`};let s="";if(t&&(t.stage2||t.stage4)){const i=t.stage2||{},l=t.stage4||{},c=t.dtw||{},_=l.final_score??c.blended_score??i.score,p=this._pgDetail&&this._pgDetail.outcome&&this._pgDetail.outcome.confidence,g=p??_;let u="\u2014",v="var(--secondary-text-color)";g!=null&&(g>=.7?(u="\u2705 "+this._t("lbl.pg_strong_match",{},"Strong match"),v="var(--success-color, #4caf50)"):g>=.4?(u="\u26A0 "+this._t("lbl.pg_weak_match",{},"Weak match"),v="var(--warning-color, #ff9800)"):(u="\u274C "+this._t("lbl.pg_poor_match",{},"Poor match"),v="var(--error-color, #f44336)"));const b=t.profile_name||this._pgProfileName||"\u2014";s+=`<div style="font-weight:700;font-size:.9em;color:${v};margin-bottom:8px">${u}</div>`,b!=="\u2014"&&(s+=`<div style="font-size:.82em;color:var(--secondary-text-color);margin-bottom:6px">${d(b)} \xB7 ${d(this._t("lbl.score",{},"score"))} ${_!=null?_.toFixed(3):"\u2014"}</div>`),s+=e(this._t("lbl.correlation",{},"Correlation"),i.correlation,1,"#42a5f5",i.correlation!=null?i.correlation.toFixed(2):null),c.blended_score!=null&&(s+=e(this._t("lbl.pg_dtw",{},"DTW"),c.blended_score,1,"#ab47bc",c.blended_score.toFixed(2))),l.duration_agreement!=null&&(s+=e(this._t("lbl.duration",{},"Duration"),l.duration_agreement,1,"#66bb6a",l.duration_agreement.toFixed(2))),l.energy_agreement!=null&&(s+=e(this._t("lbl.energy",{},"Energy"),l.energy_agreement,1,"#ffa726",l.energy_agreement.toFixed(2))),s+='<div style="height:1px;background:var(--divider-color,rgba(127,127,127,.2));margin:8px 0"></div>'}else t||(s+=`<p class="wd-info" style="margin:0 0 8px">${this._t("msg.pg_analysis_empty2",{},"Press Run to see match analysis.")}</p>`);const r=this._pgDetail&&this._pgDetail.outcome,n=r&&r.matched_profile||t&&t.profile_name||this._pgProfileName||(this._cycles||[]).find(i=>i.id===this._pgCycleId)?.profile_name||"",a=r&&r.confidence;if(n&&a!=null){const i=Math.round(Math.max(0,Math.min(1,a))*100);s+=`<div class="wd-subhead" style="margin:0 0 4px">${d(this._t("lbl.pg_match_confidence",{},"Match confidence"))}</div>`,s+=`<div class="wd-pg-cand-row">
|
|
<span class="wd-pg-cand-name" title="${d(n)}">${d(n)}</span>
|
|
<div class="wd-pg-cand-track"><div class="wd-pg-cand-fill" style="width:${i}%;background:var(--primary-color)"></div></div>
|
|
<span class="wd-pg-cand-pct">${i}%</span>
|
|
</div>`}const o=t&&t.stage4&&t.stage4.final_score;if(o!=null){const i=Math.round(Math.max(0,Math.min(1,o))*100);s+=`<div class="wd-subhead" style="margin:8px 0 4px" title="${d(this._t("lbl.pg_envelope_fit_tip",{},"How closely the cycle sits inside this profile's saved power envelope - a different lens than match confidence."))}">${d(this._t("lbl.pg_envelope_fit",{},"Envelope fit"))}</div>`,s+=`<div class="wd-pg-cand-row">
|
|
<div class="wd-pg-cand-track"><div class="wd-pg-cand-fill" style="width:${i}%;background:#eda100"></div></div>
|
|
<span class="wd-pg-cand-pct">${i}%</span>
|
|
</div>`}return`<div>${s||`<p class="wd-info" style="margin:0">${this._t("msg.pg_analysis_hint2",{},"Pick a cycle and press Run to load match analysis.")}</p>`}</div>`}async _pgLoad(){const t=this._devices[this._selIdx];if(!t||this._pgLoading)return;const e=this._pgCycleId||this._cycles?.[0]?.id||"";if(!e)return;this._pgCycleId=e,this._pgLoading=!0,this._pgView=null,this._pgHoverT=null,this._pgPowerPts=null,this._pgDtwData=null,this._pgEnvData=null,this._pgDetail=null;const s=++this._pgLoadSeq;this._render();try{const r=await this._ws({type:`${y}/get_cycle_power_data`,entry_id:t.entry_id,cycle_id:e}),n=r.samples||[],a=[];for(const l of n){if(!Array.isArray(l)||l.length<2)continue;const c=+l[0],_=+l[1];!isNaN(c)&&!isNaN(_)&&a.push({t:c,w:_})}if(this._pgPowerPts=a.length?a:null,typeof r.full_duration_s=="number"&&r.full_duration_s>0){const l=(this._cycles||[]).find(c=>c.id===e);l&&(l._pg_duration=r.full_duration_s)}const o=this._pgProfileName||(this._cycles||[]).find(l=>l.id===e)?.profile_name||"";if(a.length)try{const l={type:`${y}/get_dtw_debug`,entry_id:t.entry_id,cycle_id:e};o&&(l.profile_name=o),this._pgDtwData=await this._ws(l),this._pgNeedsRestart=!1}catch(l){this._pgIsUnknownCmd(l)&&(this._pgNeedsRestart=!0),this._pgDtwData=null}const i=this._pgDtwData?.profile_name||o;if(i)try{const l=await this._ws({type:`${y}/get_profile_envelope`,entry_id:t.entry_id,profile_name:i});this._pgEnvData=l.envelope||null}catch{this._pgEnvData=null}if(s!==this._pgLoadSeq)return;await this._pgLoadDetail(t.entry_id,e)}catch(r){this._showToast(this._t("msg.toast_error",{error:r.message||r},"Error: "+(r.message||r)),"error")}s===this._pgLoadSeq&&(this._pgLoading=!1,this._render(),requestAnimationFrame(()=>{this._pgDrawCanvas(),this._pgUpdateStripAt(null)}),this._pgNeedsRestart&&this._tab==="playground"?(this._pgRestartRetries||0)<5&&!this._pgRestartRetryTimer&&(this._pgRestartRetries=(this._pgRestartRetries||0)+1,this._pgRestartRetryTimer=setTimeout(()=>{this._pgRestartRetryTimer=null,this._pgNeedsRestart&&this._tab==="playground"&&this._pgLoad()},3e3)):this._pgRestartRetries=0)}_pgCancelRun(){this._pgLoadSeq++,this._pgLoading=!1,this._render()}_pgSelectCycle(t){t&&(this._pgCycleId=t,this._pgProfileName="",this._pgView=null,this._pgHoverT=null,this._pgLoading=!1,this._pgLoad(),requestAnimationFrame(()=>{const e=this.shadowRoot&&this.shadowRoot.getElementById("wd-pg-canvas");e&&typeof e.scrollIntoView=="function"&&e.scrollIntoView({behavior:"smooth",block:"center"})}))}async _pgLoadDetail(t,e){const s={...this._pgParamOverrides};this._pgThreshStart!=null&&(s.start_threshold_w=this._pgThreshStart),this._pgThreshStop!=null&&(s.stop_threshold_w=this._pgThreshStop);try{const r=await this._ws({type:`${y}/start_playground_cycle_detail`,entry_id:t,cycle_id:e,settings_override:s,stress_tail:this._pgStressTail,stress_idle_w:this._pgStressIdleW!=null?parseFloat(this._pgStressIdleW):null}),n=r&&r.task_id;if(!n)throw new Error("no task id");this._addProvisionalTask(n,"pg_detail",t,0);const a=await new Promise(o=>{this._taskCallbacks[n]=async l=>{if(l.state==="done"||l.state==="cancelled")try{const c=await this._ws({type:`${y}/get_task_result`,task_id:l.id});o(c&&c.result)}catch{o(null)}else o(null)};const i=this._tasks[n];i&&i.state!=="running"?this._settleTaskCallback(i):this._tasksSubscribed||this._pollTaskGeneric(n)});if(!this._isActiveEntry(t))return;this._pgDetail=a&&!a.error?a:null,this._pgNeedsRestart=!1}catch(r){this._pgIsUnknownCmd(r)&&(this._pgNeedsRestart=!0),this._pgDetail=null}}_pgRerunDetail(){const t=this._devices[this._selIdx];!t||!this._pgCycleId||(clearTimeout(this._pgDetailDebounceTimer),this._pgDetailDebounceTimer=setTimeout(async()=>{this._pgDetailBusy=!0,this._render(),await this._pgLoadDetail(t.entry_id,this._pgCycleId),this._pgDetailBusy=!1,this._render(),requestAnimationFrame(()=>this._pgDrawCanvas())},220))}_pgMapState(t){return t==="running"||t==="paused"?"running":t==="ending"?"ending":t==="starting"?"detecting":t==="anti_wrinkle"?"anti_wrinkle":"idle"}_pgSeriesAt(t){const e=this._pgDetail&&this._pgDetail.series;if(!e||!e.length)return null;let s=e[0];for(const r of e)if(r.t<=t)s=r;else break;return s}_pgStateSegsFromSeries(t){const e=this._pgDetail&&this._pgDetail.series;if(!e||!e.length)return[];const s=[];let r=null;for(const n of e){const a=this._pgMapState(n.state);!r||r.state!==a?(r={start:n.t,end:n.t,state:a},s.push(r)):r.end=n.t}return s.length&&(s[s.length-1].end=t),s}_pgDrawCanvas(){if(this._tab!=="playground")return;const t=this.shadowRoot,e=t&&t.getElementById("wd-pg-canvas");if(!e)return;const s=this._pgPowerPts,r=e.getBoundingClientRect(),n=window.devicePixelRatio||1,a=Math.max(1,Math.round(r.width*n)),o=Math.max(1,Math.round((r.height||280)*n));(e.width!==a||e.height!==o)&&(e.width=a,e.height=o);const i=e.getContext("2d"),l=getComputedStyle(this),c=(l.getPropertyValue("--primary-color")||"#03a9f4").trim(),_=(l.getPropertyValue("--divider-color")||"rgba(127,127,127,.2)").trim(),p=(l.getPropertyValue("--secondary-text-color")||"#888").trim(),g=(l.getPropertyValue("--secondary-background-color")||"#1a1a1a").trim();i.clearRect(0,0,a,o);const u=34*n,v=14*n,b=Bt*n,$=44*n,k=8*n,S=b+8*n,C=u+v+4*n,R=o-S-C;if(!s||!s.length){this._pgLoading&&(i.fillStyle=p,i.font=`${12*n}px sans-serif`,i.textAlign="center",i.textBaseline="middle",i.fillText(this._t("msg.loading",{},"Loading\u2026"),a/2,o/2));return}const B=this._pgDetail&&this._pgDetail.outcome&&this._pgDetail.outcome.stress,W=B&&B.enabled?B.synthetic_from_s:null,O=W!=null&&this._pgDetail&&this._pgDetail.series?this._pgDetail.series.filter(T=>T.t>=W):[];let D=(this._cycles||[]).find(T=>T.id===this._pgCycleId)?._pg_duration||s[s.length-1].t||1;B&&B.terminated&&B.terminated_after_s!=null&&W!=null?D=Math.max(D,W+B.terminated_after_s):O.length&&(D=Math.max(D,O[O.length-1].t));const A=Math.max(...s.map(T=>T.w),...O.map(T=>T.power||0),1),E=this._pgThreshStart??this._pgFieldVal("start_threshold_w",{})??50,M=this._pgThreshStop??this._pgFieldVal("stop_threshold_w",{})??5;let H=0,q=D;this._pgView&&this._pgView.max-this._pgView.min>1&&(H=Math.max(0,this._pgView.min),q=Math.min(D,this._pgView.max),q-H<=1&&(H=0,q=D));const Q=Math.max(1e-6,q-H),J=a-$-k,G=T=>$+(T-H)/Q*J,X=T=>S+(1-Math.max(0,T)/A)*R;this._pgMap={vMin:H,vMax:q,totalDur:D,padLpx:$/n,plotWpx:J/n},i.strokeStyle="rgba(127,127,127,0.12)",i.lineWidth=n,i.setLineDash([]),[.25,.5,.75,1].map(T=>Math.round(T*A/100)*100||Math.round(T*A)).forEach(T=>{const N=X(T);N<S||N>S+R||(i.beginPath(),i.moveTo($,N),i.lineTo(a-k,N),i.stroke(),i.fillStyle=p,i.font=`${9*n}px sans-serif`,i.textAlign="right",i.textBaseline="middle",i.fillText(T+"W",$-4*n,N))}),i.save(),i.beginPath(),i.rect($,S,J,R),i.clip();const U=this._pgEnvData,at=U&&Array.isArray(U.avg)?U.avg.filter(T=>Array.isArray(T)&&T.length>=2):[];if(at.length){const T=Math.max(...at.map(V=>V[0]))||1,N=V=>G(V/T*D),tt=Array.isArray(U.min)?U.min.filter(V=>Array.isArray(V)&&V.length>=2):[],Y=Array.isArray(U.max)?U.max.filter(V=>Array.isArray(V)&&V.length>=2):[];if(tt.length&&Y.length){i.beginPath(),Y.forEach((V,lt)=>lt?i.lineTo(N(V[0]),X(V[1])):i.moveTo(N(V[0]),X(V[1])));for(let V=tt.length-1;V>=0;V--)i.lineTo(N(tt[V][0]),X(tt[V][1]));i.closePath(),i.fillStyle="#eda10012",i.fill()}else i.beginPath(),i.strokeStyle="#eda100",i.lineWidth=2*n,i.setLineDash([5*n,4*n]),at.forEach((V,lt)=>lt?i.lineTo(N(V[0]),X(V[1])):i.moveTo(N(V[0]),X(V[1]))),i.stroke(),i.setLineDash([])}const f=this._pgDtwData,m=f&&Array.isArray(f.profile_trace)?f.profile_trace.filter(T=>Array.isArray(T)&&T.length>=2):[],x=f&&Array.isArray(f.cycle_trace)?f.cycle_trace.filter(T=>Array.isArray(T)&&T.length>=2):[],L=f&&Array.isArray(f.warp_path)?f.warp_path:[];if(x.length&&m.length&&L.length){const T=Math.max(...m.map(Y=>Y[0]))||1,N=Math.max(...x.map(Y=>Y[0]))||1,tt=Math.max(1,Math.floor(L.length/25));i.save(),i.globalAlpha=.13,i.strokeStyle="#fff",i.lineWidth=n;for(let Y=0;Y<L.length;Y+=tt){const V=L[Y];if(!Array.isArray(V)||V.length<2)continue;const lt=Math.min(V[0],x.length-1),pt=Math.min(V[1],m.length-1),bt=G(x[lt][0]/N*D),_t=X(x[lt][1]),yt=G(m[pt][0]/T*D),dt=X(m[pt][1]);i.beginPath(),i.moveTo(bt,_t),i.lineTo(yt,dt),i.stroke()}i.restore()}if(m.length){const T=Math.max(...m.map(N=>N[0]))||1;i.beginPath(),i.strokeStyle="#eda100",i.lineWidth=2*n,i.setLineDash([6*n,4*n]),m.forEach((N,tt)=>{const Y=G(N[0]/T*D),V=X(N[1]);tt?i.lineTo(Y,V):i.moveTo(Y,V)}),i.stroke(),i.setLineDash([])}if(i.beginPath(),i.moveTo(G(0),X(0)),s.forEach(T=>i.lineTo(G(T.t),X(T.w))),i.lineTo(G(s[s.length-1].t),X(0)),i.closePath(),i.fillStyle=St(c,.1),i.fill(),i.beginPath(),i.strokeStyle=c,i.lineWidth=2*n,s.forEach((T,N)=>N?i.lineTo(G(T.t),X(T.w)):i.moveTo(G(T.t),X(T.w))),i.stroke(),O.length){const T=s[s.length-1];i.beginPath(),i.moveTo(G(T.t),X(T.w)),O.forEach(N=>i.lineTo(G(N.t),X(N.power))),i.lineTo(G(O[O.length-1].t),X(0)),i.lineTo(G(T.t),X(0)),i.closePath(),i.fillStyle=St(c,.06),i.fill(),i.beginPath(),i.strokeStyle=c,i.lineWidth=1.5*n,i.setLineDash([4*n,4*n]),i.moveTo(G(T.t),X(T.w)),O.forEach(N=>i.lineTo(G(N.t),X(N.power))),i.stroke(),i.setLineDash([])}if(W!=null&&W<q){const T=G(W);i.fillStyle="rgba(192,57,43,0.07)",i.fillRect(T,S,Math.max(0,$+J-T),R),i.save(),i.strokeStyle="rgba(192,57,43,0.5)",i.lineWidth=n,i.setLineDash([4*n,4*n]),i.beginPath(),i.moveTo(T,S),i.lineTo(T,S+R),i.stroke(),i.restore()}i.restore();const j=(T,N,tt)=>{const Y=X(T);Y<S-2||Y>S+R+2||(i.save(),i.strokeStyle=N,i.lineWidth=2*n,i.setLineDash([8*n,4*n]),i.beginPath(),i.moveTo($,Y),i.lineTo(a-k,Y),i.stroke(),i.setLineDash([]),i.fillStyle=N,i.beginPath(),i.arc($+(a-$-k)*.06,Y,5*n,0,Math.PI*2),i.fill(),i.fillStyle=N,i.font=`bold ${9*n}px sans-serif`,i.textAlign="left",i.textBaseline="bottom",i.fillText(tt+" "+Math.round(T)+"W",$+14*n,Y-2*n),i.restore())};j(+E,"#2a78d6",this._t("lbl.start",{},"Start")),j(+M,"#e34948",this._t("btn.stop",{},"Stop"));const I={idle:g,detecting:"#42a5f566",running:"#66bb6a66",ending:"#ef535066",anti_wrinkle:"#ab47bc66"},w={idle:this._t("lbl.pg_idle",{},"Idle"),detecting:this._t("lbl.pg_detecting",{},"Detecting"),running:this._t("lbl.pg_ev_running",{},"Running"),ending:this._t("lbl.pg_ev_ending",{},"Ending"),anti_wrinkle:this._t("lbl.pg_anti_wrinkle",{},"Anti-wrinkle")},P=o-u-v;i.fillStyle=g,i.fillRect($,P,a-$-k,u),i.save(),i.beginPath(),i.rect($,P,J,u),i.clip(),this._pgStateSegsFromSeries(D).forEach(T=>{const N=G(T.start),tt=G(T.end);i.fillStyle=I[T.state]||_,i.fillRect(N,P,Math.max(1,tt-N),u),tt-N>50*n&&(i.fillStyle=p,i.font=`${8*n}px sans-serif`,i.textAlign="center",i.textBaseline="middle",i.fillText((w[T.state]||T.state).toUpperCase(),(N+tt)/2,P+u/2))}),i.restore();const F=(this._cycles||[]).find(T=>T.id===this._pgCycleId),K=this._pgDtwData?.profile_name||this._pgProfileName||F?.profile_name,Z=K?(this._profiles||[]).find(T=>T.name===K):null,rt=o-v;Z&&Array.isArray(Z.phases)&&Z.phases.length&&(i.save(),i.beginPath(),i.rect($,rt,J,v),i.clip(),Z.phases.forEach((T,N)=>{const tt=G((T.start||0)*D),Y=G((T.end||1)*D),V=N*47%360;i.fillStyle=`hsla(${V},60%,55%,0.55)`,i.fillRect(tt,rt,Math.max(1,Y-tt),v),Y-tt>40*n&&(i.fillStyle="#fff",i.font=`${7*n}px sans-serif`,i.textAlign="center",i.textBaseline="middle",i.fillText(T.name,(tt+Y)/2,rt+v/2))}),i.restore()),this._pgEventHits=[];const ht=this._pgDetail&&Array.isArray(this._pgDetail.events)?this._pgDetail.events.filter(T=>T.type!=="state"&&T.t>=H&&T.t<=q).slice().sort((T,N)=>T.t-N.t):[],nt=11*n,Ct=S-b/2-2*n,se=nt*2+3*n;let jt=-1/0;const xt=this._pgHoverEvent;if(ht.forEach(T=>{const N=this._pgEventMeta(T.type),tt=G(T.t);let Y=Math.max(tt,jt+se);Y=Math.max($+nt,Math.min(a-k-nt,Y)),jt=Y;const V=xt&&xt.t===T.t&&xt.type===T.type;i.save(),i.strokeStyle=N.color,i.globalAlpha=V?.9:.5,i.lineWidth=(V?2:1)*n,i.beginPath(),i.moveTo(Y,Ct+nt),i.lineTo(tt,S),i.stroke(),V&&(i.globalAlpha=.3,i.setLineDash([2*n,3*n]),i.beginPath(),i.moveTo(tt,S),i.lineTo(tt,o-v),i.stroke(),i.setLineDash([])),i.restore(),i.save(),i.beginPath(),i.arc(Y,Ct,nt,0,Math.PI*2),i.fillStyle=V?N.color:g||"#1a1a1a",i.fill(),i.lineWidth=(V?2:1.5)*n,i.strokeStyle=N.color,i.stroke(),i.fillStyle=V?"#fff":N.color,i.font=`${14*n}px sans-serif`,i.textAlign="center",i.textBaseline="middle",i.fillText(N.glyph,Y,Ct+n),i.restore(),this._pgEventHits.push({cx:Y/n,cy:Ct/n,r:nt/n,type:T.type,label:N.label,detail:T.detail,t:T.t})}),xt){const T=this._pgEventHits.find(N=>N.t===xt.t&&N.type===xt.type);if(T){const N=this._pgEventMeta(T.type),tt=ut=>`${Math.floor(ut/60)}:${String(Math.round(ut%60)).padStart(2,"0")}`,Y=this._pgEventDescription(T.type),V=[{t:T.label,bold:!0,col:N.color},...Y?[{t:Y}]:[],...T.detail?[{t:T.detail,dim:!0}]:[],{t:`${this._t("lbl.from_start",{},"From start")} ${tt(T.t)}`,dim:!0}];i.save(),i.font=`${10*n}px sans-serif`;const lt=Math.min(220*n,a-2*$),pt=[];V.forEach(ut=>{const At=String(ut.t).split(" ");let vt="";At.forEach(Lt=>{const Wt=vt?vt+" "+Lt:Lt;i.measureText(Wt).width>lt&&vt?(pt.push({...ut,t:vt}),vt=Lt):vt=Wt}),pt.push({...ut,t:vt})});const bt=14*n,_t=Math.min(lt+14*n,a-8*n),yt=pt.length*bt+10*n;let wt=T.cx*n-_t/2;wt=Math.max(4*n,Math.min(a-_t-4*n,wt));const It=Ct+nt+6*n;i.fillStyle=g,i.globalAlpha=.97,i.fillRect(wt,It,_t,yt),i.globalAlpha=1,i.strokeStyle=N.color,i.lineWidth=n,i.strokeRect(wt,It,_t,yt),i.textAlign="left",i.textBaseline="top",pt.forEach((ut,At)=>{i.font=`${ut.bold?"600 ":""}${10*n}px sans-serif`,i.fillStyle=ut.col||(ut.dim?p:(l.getPropertyValue("--primary-text-color")||"#ddd").trim()),i.fillText(ut.t,wt+7*n,It+5*n+At*bt)}),i.restore()}}if(this._pgHoverT!=null&&this._pgHoverT>=H&&this._pgHoverT<=q){const T=G(this._pgHoverT);i.save(),i.strokeStyle="#e34948",i.lineWidth=1.5*n,i.setLineDash([4*n,3*n]),i.beginPath(),i.moveTo(T,S),i.lineTo(T,o-v),i.stroke(),i.setLineDash([]);const N=this._pgInterpPower(s,this._pgHoverT),tt=X(N);i.fillStyle="#e34948",i.beginPath(),i.arc(T,tt,3.5*n,0,Math.PI*2),i.fill();const Y=dt=>`${Math.floor(dt/60)}:${String(Math.round(dt%60)).padStart(2,"0")}`,V=dt=>dt>=1e3?(dt/1e3).toFixed(2)+" kW":Math.round(dt)+" W",lt=[`${this._t("lbl.from_start",{},"From start")} ${Y(this._pgHoverT)}`,`${this._t("lbl.to_end",{},"To end")} ${Y(Math.max(0,D-this._pgHoverT))}`,`${this._t("lbl.power",{},"Power")} ${V(N)}`];i.font=`${9.5*n}px sans-serif`;const pt=Math.max(...lt.map(dt=>i.measureText(dt).width))+12*n,bt=lt.length*13*n+8*n;let _t=T+8*n;_t+pt>a-k&&(_t=T-pt-8*n);const yt=S+4*n;i.fillStyle=g,i.globalAlpha=.95,i.fillRect(_t,yt,pt,bt),i.globalAlpha=1,i.strokeStyle=_,i.lineWidth=n,i.strokeRect(_t,yt,pt,bt),i.fillStyle=p,i.textAlign="left",i.textBaseline="top",lt.forEach((dt,wt)=>i.fillText(dt,_t+6*n,yt+5*n+wt*13*n)),i.restore()}i.fillStyle=p,i.font=`${9*n}px sans-serif`,i.textAlign="center",i.textBaseline="top";const ie=T=>`${Math.floor(T/60)}:${String(Math.round(T%60)).padStart(2,"0")}`;for(let T=0;T<=4;T++){const N=H+Q*T/4,tt=G(N);i.textAlign=T===0?"left":T===4?"right":"center",i.fillText(ie(N),Math.max($,Math.min(a-k,tt)),P+u+1*n)}Q<D-1&&(i.fillStyle=p,i.font=`${8*n}px sans-serif`,i.textAlign="right",i.textBaseline="top",i.fillText(this._t("lbl.pg_zoom_hint",{},"double-click to reset zoom"),a-k,S))}_pgEventMeta(t){const s={detected:["\u25CF","#42a5f5",this._t("lbl.pg_ev_detected",{},"Detected")],match_commit:["\u25C6","#66bb6a",this._t("lbl.pg_ev_match",{},"Match committed")],match_changed:["\u25C7","#eda100",this._t("lbl.pg_ev_match_changed",{},"Match changed")],match_ambiguous:["\u25C8","#eda100",this._t("lbl.pg_ev_ambiguous",{},"Ambiguous")],unmatched:["\u25CB","#9e9e9e",this._t("lbl.pg_ev_unmatched",{},"Unmatched")],notify_start:["\u{1F514}","#2a78d6",this._t("lbl.pg_ev_notify_start",{},"Start notification")],notify_pre_complete:["\u{1F514}","#2a78d6",this._t("lbl.pg_ev_notify_pre",{},"Almost-done notification")],notify_finish:["\u{1F514}","#2a78d6",this._t("lbl.pg_ev_notify_finish",{},"Finish notification")],notify_milestone:["\u{1F3C6}","#2a78d6",this._t("lbl.pg_ev_notify_milestone",{},"Milestone notification")],notify_held:["\u{1F319}","#7e57c2",this._t("lbl.pg_ev_notify_held",{},"Notification held (quiet hours)")],finished:["\u2713","#4caf50",this._t("lbl.pg_ev_finished",{},"Finished")]}[t]||["\u2022","var(--secondary-text-color)",t];return{glyph:s[0],color:s[1],label:s[2]}}_pgEventDescription(t){return{detected:this._t("pg_evd.detected",{},"The detector recognized a cycle had started."),match_commit:this._t("pg_evd.match_commit",{},"The matcher committed to a program with enough confidence."),match_changed:this._t("pg_evd.match_changed",{},"The leading program changed as more of the cycle was seen."),match_ambiguous:this._t("pg_evd.match_ambiguous",{},"Two programs scored close together, so the match was uncertain."),unmatched:this._t("pg_evd.unmatched",{},"No saved program fit this cycle."),notify_start:this._t("pg_evd.notify_start",{},"A start notification would be sent."),notify_pre_complete:this._t("pg_evd.notify_pre_complete",{},"An almost-done notification would be sent."),notify_finish:this._t("pg_evd.notify_finish",{},"A finish notification would be sent."),notify_milestone:this._t("pg_evd.notify_milestone",{},"A milestone notification would be sent."),notify_held:this._t("pg_evd.notify_held",{},"A notification was held back for quiet hours."),finished:this._t("pg_evd.finished",{},"The cycle reached a terminal state and ended.")}[t]||""}_pgUpdateParamInput(t,e){const s=this.shadowRoot,r=s&&s.querySelector(`input[data-pgkey="${t}"]`);r&&(r.dataset.pgtype==="bool"?r.checked=!!e:r.value=typeof e=="number"?Math.round(e):e)}_pgUpdateStripAt(t){const e=this._pgPowerPts;if(!e?.length)return;const r=(this._cycles||[]).find(D=>D.id===this._pgCycleId)?._pg_duration||e[e.length-1].t||1,n=t??r,a=this._pgInterpPower(e,n),o=this._pgSeriesAt(n),i=o?this._pgMapState(o.state):"idle",l={idle:[this._t("lbl.pg_idle",{},"Idle"),"var(--secondary-background-color)"],detecting:[this._t("lbl.pg_detecting",{},"Detecting"),"#42a5f5"],running:[this._t("lbl.pg_ev_running",{},"Running"),"#66bb6a"],ending:[this._t("lbl.pg_ev_ending",{},"Ending"),"#ef5350"],anti_wrinkle:[this._t("lbl.pg_anti_wrinkle",{},"Anti-wrinkle"),"#ab47bc"]},[c,_]=l[i]||l.idle,p=o&&o.progress!=null?Math.round(o.progress):null,g=o&&o.remaining_s!=null?o.remaining_s:null,u=o&&o.energy_wh!=null?o.energy_wh:this._pgTrapEnergy(e,n),v=o&&o.confidence!=null?Math.round(o.confidence*100)+"%":"\u2014",b=o&&o.phase?o.phase:"\u2014",$=D=>Math.floor(D/60)+":"+String(Math.round(D%60)).padStart(2,"0"),k=D=>D>=1e3?(D/1e3).toFixed(2)+" kWh":D.toFixed(0)+" Wh",S=D=>D>=1e3?(D/1e3).toFixed(1)+" kW":Math.round(D)+" W",C=this.shadowRoot,R=D=>C&&C.getElementById(D),B=(D,A)=>{const E=R(D);E&&(E.textContent=A)},W=(D,A,E)=>{const M=R(D);M&&(M.style[A]=E)},O=R("wd-pg-state-badge");O&&(O.textContent=c,O.style.background=_,O.style.color=_.includes("var")?"":"#fff"),B("wd-pg-power",S(a)),B("wd-pg-pct",p!=null?p+"%":"\u2014%"),W("wd-pg-pbar","width",(p??0)+"%"),B("wd-pg-rem",g!=null?$(g):"\u2014"),B("wd-pg-energy",k(u)),B("wd-pg-conf",v),B("wd-pg-phase",b)}_pgIsUnknownCmd(t){const e=t&&(t.code||t.error&&t.error.code)||"",s=(t&&(t.message||t.error)||"").toString().toLowerCase();return e==="unknown_command"||s.includes("unknown command")||s.includes("unknown_command")}_pgInterpPower(t,e){if(!t.length)return 0;if(e<=t[0].t)return t[0].w;for(let s=1;s<t.length;s++)if(e<=t[s].t){const r=t[s].t-t[s-1].t;if(r<=0)return t[s].w;const n=(e-t[s-1].t)/r;return t[s-1].w+n*(t[s].w-t[s-1].w)}return t[t.length-1].w}_pgTrapEnergy(t,e){let s=0;for(let r=1;r<t.length;r++){const n=t[r-1].t,a=t[r-1].w,o=Math.min(t[r].t,e),i=this._pgInterpPower(t,o);if(!(o<=n)&&(s+=(a+i)/2*(o-n)/3600,o>=e))break}return s}_drawPlaygroundCanvases(){this._tab==="playground"&&this._pgDrawCanvas()}_htmlPhases(){const t=this._devices[this._selIdx],e=t&&t.options.device_type||"washing_machine",s=this._canEdit(),r=this._phases.map(o=>{const i=o.is_default,l=o.translation_key?this._t(o.translation_key,{},o.description||""):o.description||"",c=s?`
|
|
<td>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="edit-phase" data-pid="${d(o.id)}" data-pname="${d(o.name)}" data-pdesc="${d(o.description||"")}" data-pisdefault="${i}">${this._t("btn.edit",{},"Edit")}</button>
|
|
${i?"":`<button class="wd-btn wd-btn-danger wd-btn-sm" data-action="del-phase" data-pid="${d(o.id)}" data-pname="${d(o.name)}" style="margin-left:4px">${this._t("btn.delete",{},"Delete")}</button>`}
|
|
</td>`:"";return`<tr>
|
|
<td>${d(o.name)} ${i?`<span class="wd-tag">${this._t("badge.built_in_tag",{},"built-in")}</span>`:""}</td>
|
|
<td>${d(l.length>60?l.slice(0,57)+"\u2026":l)}</td>
|
|
${c}
|
|
</tr>`}).join(""),n=s?`<th>${this._t("lbl.actions",{},"Actions")}</th>`:"",a=s?`<div class="wd-card-actions" style="margin-bottom:14px"><button class="wd-btn wd-btn-primary" data-action="create-phase" data-dtype="${d(e)}">${this._t("btn.new_phase",{},"+ New Phase")}</button></div>`:"";return`
|
|
<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.phase_catalog",{},"Phase Catalog")}</div>
|
|
<p class="wd-info" style="margin-bottom:14px">${this._t("msg.phase_catalog_intro",{},"Named segments of a cycle (Pre-wash, Heating, Spin\u2026). Assign them to a profile from its control panel.")}</p>
|
|
${a}
|
|
${this._phases.length===0?`<p class="wd-info">${this._t("msg.no_phases",{},"No phases defined.")}</p>`:`<table class="wd-table"><thead><tr><th>${this._t("lbl.phase_name",{},"Name")}</th><th>${this._t("lbl.description",{},"Description")}</th>${n}</tr></thead><tbody>${r}</tbody></table>`}
|
|
</div>`}_htmlDiagnostics(){const t=this._diag;let e;return t&&t._error?e=`<p class="wd-info" style="color:var(--error-color)">${this._t("msg.diagnostics_load_failed",{error:d(t._error)},"Could not load diagnostics: "+d(t._error))}</p>`:t?e=`<div class="wd-diag-grid">
|
|
<div class="wd-diag-stat"><div class="wd-diag-val">${t.total_cycles??"-"}</div><div class="wd-diag-lbl">${this._t("lbl.cycles_count",{},"Cycles")}</div></div>
|
|
<div class="wd-diag-stat"><div class="wd-diag-val">${t.total_profiles??"-"}</div><div class="wd-diag-lbl">${this._t("tab.profiles",{},"Profiles")}</div></div>
|
|
<div class="wd-diag-stat"><div class="wd-diag-val">${t.debug_traces_count??"-"}</div><div class="wd-diag-lbl">${this._t("lbl.debug_traces",{},"Debug Traces")}</div></div>
|
|
<div class="wd-diag-stat"><div class="wd-diag-val">${t.file_size_kb!=null?t.file_size_kb.toFixed(1):"-"}</div><div class="wd-diag-lbl">${this._t("lbl.file_kb",{},"File (kB)")}</div></div>
|
|
</div>`:e=`<p class="wd-info">${this._t("msg.loading",{},"Loading\u2026")}</p>`,`
|
|
<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.storage_stats",{},"Storage Stats")}</div>
|
|
${e}
|
|
<div class="wd-card-actions"><button class="wd-btn wd-btn-secondary" data-action="diag-refresh" title="${d(this._t("btn.refresh_diag_tip",{},"Reload storage statistics"))}">${this._t("btn.refresh",{},"Refresh")}</button></div>
|
|
</div>
|
|
${this._canFull()?`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.maintenance",{},"Maintenance Actions")}</div>
|
|
<div style="display:flex;flex-direction:column;gap:12px">
|
|
<div><strong>${this._t("hdr.process_history",{},"Process History")}</strong><p class="wd-info" style="margin:4px 0">${this._t("msg.process_history_hint",{},"Re-run matching on all stored cycles, refresh tuning suggestions, retrain the ML models (if enabled), and recompute cycle health. Run this after a batch of reviews.")}</p>
|
|
<button class="wd-btn wd-btn-secondary" data-action="reprocess-history">${this._t("btn.process_history",{},"Process Now")}</button></div>
|
|
<div><strong>${this._t("hdr.clear_debug",{},"Clear Debug Traces")}</strong><p class="wd-info" style="margin:4px 0">${this._t("msg.clear_debug_hint",{},"Remove stored debug data to free space.")}</p>
|
|
<button class="wd-btn wd-btn-secondary" data-action="clear-debug">${this._t("btn.clear_debug",{},"Clear Debug Data")}</button></div>
|
|
<div><strong>${this._t("hdr.wipe_history",{},"Wipe History")}</strong><p class="wd-info" style="margin:4px 0">${this._t("msg.wipe_history_warning",{},"Permanently delete all cycles and profiles. Cannot be undone.")}</p>
|
|
<button class="wd-btn wd-btn-danger" data-action="wipe-history">${this._t("btn.wipe_all",{},"Wipe All Data")}</button></div>
|
|
</div>
|
|
</div>
|
|
<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.export_import",{},"Export / Import")}</div>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.export_description",{},"Choose exactly which profiles, cycles, settings and more to export to JSON, or analyze a file and import only the parts you want.")}</p>
|
|
<div class="wd-card-actions">
|
|
<button class="wd-btn wd-btn-primary" data-action="export-select-open">${this._t("btn.export_selected",{},"Export (choose data)")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-action="import-config-open">${this._t("btn.import_json",{},"Import from JSON")}</button>
|
|
<button class="wd-btn wd-btn-secondary" data-action="export-config">${this._t("btn.export_all",{},"Quick export everything")}</button>
|
|
<button class="wd-btn wd-btn-secondary" data-action="import-config-raw">${this._t("btn.import_raw",{},"Advanced: replace all from JSON")}</button>
|
|
</div>
|
|
</div>
|
|
<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.import_power_history",{},"Import power history")}</div>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.import_history_description",{},"Already had a smart plug before WashData? Upload a history export of its power sensor, or read it straight from Home Assistant, and the normal detection runs over it so past cycles turn up in your Cycles list ready to name.")}</p>
|
|
<div class="wd-card-actions">
|
|
<button class="wd-btn wd-btn-primary" data-action="hist-import-open">${this._t("btn.import_power_history",{},"Import power history")}</button>
|
|
</div>
|
|
</div>`:`<div class="wd-card"><p class="wd-info">${this._t("msg.maintenance_requires_access",{},"Maintenance and export/import require full access.")}</p></div>`}`}_maintLabel(t){return{descale:this._t("maint.descale",{},"Descale"),filter_clean:this._t("maint.filter_clean",{},"Clean filter"),drum_clean:this._t("maint.drum_clean",{},"Clean drum"),bearing_service:this._t("maint.bearing_service",{},"Bearing service"),other:this._t("maint.other",{},"Other")}[t]||t}_htmlMaintenance(){const t=this._canEdit(),e=this._maintenance;if(e&&e._error)return`<div class="wd-card"><p class="wd-info" style="color:var(--error-color)">${this._t("msg.maintenance_load_error",{error:e._error},"Could not load maintenance data: "+e._error)}</p></div>`;if(!e)return`<div class="wd-card"><p class="wd-info">${this._t("msg.loading",{},"Loading\u2026")}</p></div>`;const s=e.event_types&&e.event_types.length?e.event_types:["descale","filter_clean","drum_clean","bearing_service","other"],r=e.due||[],n=e.log||[],a=e.reminders||{},o=r.length?(()=>{const g=r.map(u=>this._maintLabel(u)).join(", ");return`<div style="margin-bottom:14px;padding:10px 12px;border-radius:6px;background:rgba(255,152,0,.10);border-left:3px solid var(--warning-color,#ff9800)">
|
|
<span style="font-weight:600;color:var(--warning-color,#ff9800)">${this._t("msg.maintenance_due",{items:d(g)},"Maintenance due: "+g)}</span>
|
|
</div>`})():"",i=new Date().toISOString().slice(0,10),l=s.map(g=>`<option value="${d(g)}">${d(this._maintLabel(g))}</option>`).join(""),c=t?`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.add_maintenance",{},"Add Maintenance Event")}</div>
|
|
<div class="wd-form-grid">
|
|
<div class="wd-field"><label>${this._t("lbl.date",{},"Date")}</label><input type="date" id="wd-maint-date" value="${i}" max="${i}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.event_type",{},"Event type")}</label><select id="wd-maint-type">${l}</select></div>
|
|
</div>
|
|
<div class="wd-field"><label>${this._t("lbl.notes",{},"Notes")}</label><input type="text" id="wd-maint-notes" placeholder="${d(this._t("placeholder.maintenance_notes",{},"e.g. replaced filter, cleaned door seal"))}"></div>
|
|
<div class="wd-card-actions"><button class="wd-btn wd-btn-primary" data-action="maint-add">${this._t("btn.add_maintenance",{},"Add maintenance event")}</button></div>
|
|
</div>`:"",_=n.length?n.map(g=>{const u=t?`<button class="wd-btn wd-btn-danger wd-btn-sm" data-action="maint-delete" data-mid="${d(g.id)}">${this._t("btn.delete",{},"Delete")}</button>`:"",v=g.notes?`<div class="wd-info" style="margin-top:2px">${d(g.notes)}</div>`:"";return`<div class="wd-card" style="background:var(--secondary-background-color);padding:10px 12px">
|
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px">
|
|
<div>
|
|
<div style="font-weight:600">${d(this._maintLabel(g.event_type))}</div>
|
|
<div class="wd-info" style="margin-top:2px">${st(g.date)}</div>
|
|
${v}
|
|
</div>
|
|
${u}
|
|
</div>
|
|
</div>`}).join(""):`<p class="wd-info">${this._t("msg.no_maintenance",{},"No maintenance recorded yet.")}</p>`,p=t?`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.maintenance_reminders",{},"Service Reminders")}</div>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.reminders_intro",{},"Show a reminder in the panel this many cycles after the last service. Leave blank or 0 to turn a reminder off.")}</p>
|
|
<div class="wd-form-grid">
|
|
${s.map(g=>`<div class="wd-field"><label>${d(this._maintLabel(g))}</label><input type="number" min="0" step="1" data-maint-rem="${d(g)}" value="${a[g]!=null?d(a[g]):""}" placeholder="${d(this._t("lbl.reminder_every",{},"Remind every (cycles)"))}"></div>`).join("")}
|
|
</div>
|
|
<div class="wd-card-actions"><button class="wd-btn wd-btn-primary" data-action="maint-save-reminders">${this._t("btn.save_reminders",{},"Save reminders")}</button></div>
|
|
</div>`:"";return`${o}
|
|
${c}
|
|
<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.maintenance_log",{},"Maintenance Log")}</div>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.maintenance_intro",{},"Log servicing you perform on this appliance and get reminded when each task is due again.")}</p>
|
|
<div style="display:flex;flex-direction:column;gap:8px">${_}</div>
|
|
</div>
|
|
${p}`}_htmlPanel(){const t=this._canEdit(),e=t&&this._constants&&this._constants.mlTrainingAvailable,s=new Set(["maintenance"]);t&&s.add("diagnostics"),e&&s.add("ml");let r=this._panelSubtab;s.has(r)||(r=this._panelSubtab="maintenance");const n=[["maintenance",this._t("tab.maintenance",{},"Maintenance")]];t&&n.push(["diagnostics",this._t("tab.diagnostics",{},"Diagnostics")]),e&&n.push(["ml",this._t("tab.ml",{},"ML Training")]);const a=n.map(([i,l])=>`<button class="wd-subtab ${r===i?"active":""}" data-ptab="${i}">${l}</button>`).join(""),o=r==="diagnostics"&&t?this._htmlDiagnostics():r==="ml"&&e?this._htmlMlTab():this._htmlMaintenance();return`<div class="wd-subtabs">${a}</div>${o}`}_levelSelect(t,e,s){const r=(s?[["inherit",this._t("access.inherit",{},"Inherit")]]:[]).concat([["none",this._t("access.none",{},"None (hidden)")],["read",this._t("access.read",{},"Read")],["edit",this._t("access.edit",{},"Edit")],["full",this._t("access.full",{},"Full")]]);return`<select ${t}>${r.map(([n,a])=>`<option value="${n}" ${e===n?"selected":""}>${a}</option>`).join("")}</select>`}_htmlPanelPrefs(){const t=this._panelCfg&&this._panelCfg.prefs||{},e=this._hass&&this._hass.locale&&this._hass.locale.language||"en",r=[["",this._t("pref.use_panel_default",{},"(panel default)")],["status",this._t("tab.status",{},"Overview")],["history",this._t("tab.history",{},"Cycles")],["profiles",this._t("tab.profiles",{},"Profiles")],["settings",this._t("tab.settings",{},"Settings")],["playground",this._t("tab.playground",{},"Playground")]].map(([l,c])=>`<option value="${l}" ${(t.default_tab||"")===l?"selected":""}>${d(c)}</option>`).join(""),a=[["relative",this._t("pref.date_relative",{},"Relative (e.g. 2 hours ago)")],["absolute",this._t("pref.date_absolute",{},"Absolute (e.g. 14:32 on 2 Jul)")]].map(([l,c])=>`<option value="${l}" ${(t.date_format||"relative")===l?"selected":""}>${d(c)}</option>`).join(""),o=t.lang_override||"",i=[["",this._t("pref.lang_auto",{lang:e.toUpperCase()},"System default ("+e.toUpperCase()+")")],["en",this._t("pref.lang_en",{},"English")]].map(([l,c])=>`<option value="${l}" ${o===l?"selected":""}>${d(c)}</option>`).join("");return`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.my_preferences",{},"My Preferences")}</div>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.prefs_personal",{},"These apply to your Home Assistant account only.")}</p>
|
|
<div class="wd-subhead">${this._t("hdr.display",{},"Display")}</div>
|
|
<div class="wd-form-grid">
|
|
<div class="wd-field"><label>${this._t("lbl.default_tab",{},"Default tab when opening the panel")}</label><select id="wd-pref-tab">${r}</select></div>
|
|
<div class="wd-field"><label>${this._t("lbl.cycle_date_display",{},"Cycle date display")}</label><select id="wd-pref-datefmt">${a}</select></div>
|
|
<div class="wd-field"><label>${this._t("lbl.panel_language",{},"Panel language")}</label><select id="wd-pref-lang">${i}</select></div>
|
|
</div>
|
|
<div class="wd-field" style="margin-top:8px">
|
|
<label>${this._t("lbl.font_size",{},"Panel font size")}</label>
|
|
<div style="display:flex;align-items:center;gap:12px">
|
|
<input type="range" id="wd-pref-fontscale" min="0.85" max="1.5" step="0.05" value="${t.font_scale||1}" style="flex:1" aria-label="${d(this._t("lbl.font_size",{},"Panel font size"))}">
|
|
<span id="wd-pref-fontscale-val" style="min-width:3.2em;text-align:right;font-variant-numeric:tabular-nums">${Math.round((t.font_scale||1)*100)}%</span>
|
|
</div>
|
|
<div class="wd-field-hint">${this._t("msg.font_size_hint",{},"Make everything in this panel larger or smaller. Applies to your account on this device.")}</div>
|
|
</div>
|
|
<div class="wd-subhead">${this._t("hdr.status_graph",{},"Status Graph")}</div>
|
|
${Et(`id="wd-pref-expected" ${t.show_expected!==!1?"checked":""}`,this._t("lbl.show_expected",{},"Show expected curve overlay (matched profile, orange)"))}
|
|
${Et(`id="wd-pref-raw" ${t.show_raw?"checked":""}`,this._t("lbl.show_raw",{},"Show raw socket toggle in live power graph"))}
|
|
<div class="wd-subhead">${this._t("hdr.diagnostics_pref",{},"Diagnostics")}</div>
|
|
${Et(`id="wd-pref-debug" ${t.show_debug?"checked":""}`,this._t("lbl.show_debug",{},"Show live match debug card on the Status page (confidence, ambiguity, top candidates)"))}
|
|
<div class="wd-card-actions"><button class="wd-btn wd-btn-primary" data-action="save-prefs">${this._t("btn.save_preferences",{},"Save Preferences")}</button></div>
|
|
</div>`}_htmlPanelSettings(){const t=this._panelCfg&&this._panelCfg.panel||{},e=[["status",this._t("tab.status",{},"Overview")],["history",this._t("tab.history",{},"Cycles")],["profiles",this._t("tab.profiles",{},"Profiles")],["settings",this._t("tab.settings",{},"Settings")],["playground",this._t("tab.playground",{},"Playground")]],s=e.map(([a,o])=>`<option value="${a}" ${(t.default_tab||"status")===a?"selected":""}>${d(o)}</option>`).join(""),r=t.hidden_tabs||[],n=e.filter(([a])=>a!=="status").map(([a,o])=>Nt(`data-hidetab="${a}" ${r.includes(a)?"checked":""}`,o)).join("");return`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.panel_settings",{},"Panel Settings (all users)")}</div>
|
|
<div class="wd-form-grid">
|
|
<div class="wd-field"><label>${this._t("lbl.panel_default_tab",{},"Default tab")}</label><select id="wd-ps-deftab">${s}</select></div>
|
|
</div>
|
|
<div class="wd-field"><label>${this._t("lbl.hide_tabs",{},"Hide tabs for non-admins")}</label><div style="display:flex;flex-wrap:wrap;gap:10px 22px;margin-top:6px">${n}</div></div>
|
|
<div class="wd-card-actions"><button class="wd-btn wd-btn-primary" data-action="save-panel">${this._t("btn.save_panel_settings",{},"Save Panel Settings")}</button></div>
|
|
</div>`}_htmlPanelAccess(){const t=this._panelCfg&&this._panelCfg.rbac||{enabled:!1,default_level:"none",users:{}},e=this._panelCfg&&this._panelCfg.users||[],s=this._devices||[],r=e.filter(a=>!a.is_admin).map(a=>{const o=(t.users||{})[a.id]||{default:"none",devices:{}},i=s.map(l=>`<div class="wd-seg-row"><span style="min-width:160px">${d(l.title)}</span>${this._levelSelect(`data-rbacuser="${d(a.id)}" data-rbacdev="${d(l.entry_id)}"`,(o.devices||{})[l.entry_id]||"inherit",!0)}</div>`).join("");return`<div class="wd-card" style="background:var(--secondary-background-color)">
|
|
<div class="wd-profile-name">${d(a.name)}</div>
|
|
<div class="wd-seg-row"><span style="min-width:160px">${this._t("lbl.default_other",{},"Default (other devices)")}</span>${this._levelSelect(`data-rbacuser="${d(a.id)}" data-rbacdev="__default__"`,o.default||"none",!1)}</div>
|
|
${i}
|
|
</div>`}).join(""),n=e.filter(a=>a.is_admin).map(a=>`<span class="wd-pill">${d(a.name)} - full (admin)</span>`).join(" ");return`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.access_control",{},"Access Control")}</div>
|
|
${Et(`id="wd-rbac-enabled" ${t.enabled?"checked":""}`,this._t("lbl.enable_access_control",{},"Enable per-user access control"),"",this._t("msg.rbac_hint",{},"When off, every Home Assistant user has full access (the default). Administrators always have full access and can manage everyone."))}
|
|
<div class="wd-field"><label>${this._t("lbl.default_access_level",{},"Default level for users not listed below")}</label>${this._levelSelect('id="wd-rbac-default"',t.default_level||"none",!1)}</div>
|
|
${n?`<div class="wd-field"><label>${this._t("lbl.administrators",{},"Administrators")}</label><div>${n}</div></div>`:""}
|
|
<div class="wd-card-actions"><button class="wd-btn wd-btn-primary" data-action="save-rbac">${this._t("btn.save_access_control",{},"Save Access Control")}</button></div>
|
|
</div>
|
|
${r||`<div class="wd-card"><p class="wd-info">${this._t("msg.no_other_users",{},"No other Home Assistant users found.")}</p></div>`}`}_htmlStore(){if(!this._canEdit())return`<div class="wd-empty">${this._t("msg.no_device_selected",{},"No device selected.")}</div>`;const t=this._storeStatus,e=`<a href="https://3dg1luk43.github.io/washdata-store" target="_blank" rel="noopener noreferrer" style="font-size:.8em;font-weight:400;color:var(--primary-color);text-decoration:none;white-space:nowrap" title="${d(this._t("store.website_tip",{},"Open the community store website"))}">${this._t("store.website",{},"Store website \u2197")}</a>`,s=`<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><div class="wd-card-title" style="margin:0">${this._t("hdr.community_store",{},"Community Store")}</div>${e}</div>`;if(!this._onlineEnabled()||t&&t.enabled===!1)return`<div class="wd-card">${s}
|
|
<p class="wd-info">${this._t("msg.store_enable_hint",{},"Enable online features in Settings to browse and import community reference cycles.")}</p></div>`;let r;return this._storeView==="device"?r=this._htmlStoreDevice():this._storeView==="profile"?r=this._htmlStoreProfile():r=this._htmlStoreBrands(),`<div class="wd-card">
|
|
${s}
|
|
${this._htmlStoreCrumbs()}
|
|
${r}
|
|
</div>`}_htmlStoreCrumbs(){const t=[`<button class="wd-crumb ${this._storeView==="brands"?"active":""}" data-action="store-nav" data-view="brands">${this._t("store.browse",{},"Browse")}</button>`];if(this._storeDevice){const e=this._storeDevice,s=`${e.brand||""} ${e.model||""}`.trim()||this._t("store.device",{},"Device");t.push('<span class="wd-crumb-sep">\u203A</span>'),t.push(this._storeView==="device"?`<span class="wd-crumb active">${d(s)}</span>`:`<button class="wd-crumb" data-action="store-nav" data-view="device">${d(s)}</button>`)}return this._storeProfile&&this._storeView==="profile"&&(t.push('<span class="wd-crumb-sep">\u203A</span>'),t.push(`<span class="wd-crumb active">${d(this._storeProfile.program||"")}</span>`)),`<div class="wd-store-crumbs">${t.join("")}</div>`}_htmlStoreLoading(){return`<div class="wd-empty" style="padding:24px"><div class="wd-icon">\u23F3</div>${this._t("msg.loading",{},"Loading\u2026")}</div>`}_htmlStoreBrands(){const t=this._storeDevices||[],e=String((this._opts||{}).store_model||"").trim().toLowerCase(),s=t.map(o=>{const i=`${d(o.brand||"")} ${d(o.model||"")}`.trim()||this._t("store.device",{},"Device"),l=o.applianceType?`<span class="wd-store-chip">${d(this._deviceTypeLabel(o.applianceType))}</span>`:"",_=e&&String(o.model||"").toLowerCase()===e?`<span class="wd-tag wd-tag-approved" title="${d(this._t("store.your_model_tip",{},"This is the appliance you declared in Settings"))}">${this._t("store.your_model",{},"Yours")}</span>`:"",p=Number(o.profileCount)||0,g=p>0?`<span class="wd-store-chip">${this._t("store.programs_count",{n:p},`Programs: ${p}`)}</span>`:"";return`<button class="wd-store-row" data-action="store-open-device" data-device-id="${d(o.id)}">
|
|
<span class="wd-store-row-main">
|
|
<span class="wd-store-row-title">${i}${_}${this._statusTag(o)}</span>
|
|
<span class="wd-store-row-sub">${l}${g}<span class="wd-store-fav" title="${d(this._t("store.favorites",{},"Favourites"))}">\u2605 ${o.favoriteCount||0}</span></span>
|
|
</span>
|
|
<span class="wd-store-row-arrow" aria-hidden="true">\u203A</span>
|
|
</button>`}).join("");if(!this._storeBrandScope())return`
|
|
<div class="wd-store-search">
|
|
<input type="text" id="wd-store-q" placeholder="${d(this._t("store.search_brand_ph",{},"Search by brand\u2026"))}" value="" autocomplete="off" spellcheck="false">
|
|
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="store-search">${this._t("btn.search",{},"Search")}</button>
|
|
</div>
|
|
<p class="wd-info" style="margin-bottom:10px">${this._t("msg.store_declare_appliance",{},"Tell WashData which appliance you own and this tab shows the setups other people have shared for it. You can also type a brand above to look around.")}</p>
|
|
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="store-goto-identity">${this._t("btn.set_brand_model",{},"Set brand & model")}</button>`;const r=`<p class="wd-info">${this._t("store.no_results",{},"No matching appliances found. Try a different search.")}</p>`,n=this._storeLoading?this._htmlStoreLoading():t.length?`<div class="wd-store-rows">${s}</div>`:r,a=!this._storeLoading&&t.length>1?`<p class="wd-info" style="margin-bottom:8px">${this._t("msg.store_sibling_hint",{},"Nothing shared for your exact model? A closely-related model from the same brand is usually a good starting point.")}</p>`:"";return`
|
|
<div class="wd-store-search">
|
|
<input type="text" id="wd-store-q" placeholder="${d(this._t("store.search_brand_ph",{},"Search by brand\u2026"))}" value="${d(this._storeQuery)}" autocomplete="off" spellcheck="false">
|
|
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="store-search">${this._t("btn.search",{},"Search")}</button>
|
|
</div>
|
|
${a}
|
|
${n}`}_htmlStoreDevice(){const t=this._storeProfiles||[],e=t.map(o=>`<button class="wd-store-row" data-action="store-open-profile" data-profile-id="${d(o.id)}">
|
|
<span class="wd-store-row-main"><span class="wd-store-row-title">${d(o.program||"")}${this._statusTag(o)}</span></span>
|
|
<span class="wd-store-row-arrow" aria-hidden="true">\u203A</span>
|
|
</button>`).join(""),s=this._storeLoading?this._htmlStoreLoading():t.length?`<div class="wd-store-rows">${e}</div>`:`<p class="wd-info">${this._t("store.no_programs",{},"No shared programs for this appliance yet.")}</p>`,r=this._storeDevice,n=this._busy.has("store-download-device");return(this._canEdit()&&r&&t.length?`<div class="wd-card" style="margin-bottom:10px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
<span class="wd-info" style="flex:1;min-width:180px">${this._t("msg.store_download_device_intro",{},"Adopt every shared program and its reference cycles onto your device. Your own recorded cycles and stats are not affected.")}</span>
|
|
${Nt(`data-action="store-toggle-dl-settings" ${this._dlSettings?"checked":""} ${n?"disabled":""}`,this._t("lbl.adopt_settings",{},"Also adopt settings"),it(this._t("msg.adopt_settings_hint",{},"Overwrite this device's detection & matching thresholds with the shared ones. Your notifications, entities and energy price are never changed.")))}
|
|
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="store-download-device" data-device-id="${d(r.id)}" ${n?"disabled":""}>${n?'<span class="wd-spin"></span> ':"\u2B07 "}${this._t("btn.download_device",{},"Download this setup")}</button>
|
|
</div>`:"")+s}_htmlStoreProfile(){const t=this._storeCycles||[],e=t.map(r=>{const n=r.stats||{},a=this._storeSparkline(r.trace&&r.trace.points||[]),o=n.duration!=null?ot(n.duration):"-",i=n.energy_wh!=null?gt(n.energy_wh/1e3):"-",l=n.peak_w!=null?Ut(n.peak_w):"-",c=d(r.uploaderName||this._t("store.anon",{},"anonymous")),_=this._statusTag(r),p=r.rating||{},g=p.avg!=null&&p.count?this._t("store.rating_summary",{avg:Number(p.avg).toFixed(1),n:p.count},`\u2605 ${Number(p.avg).toFixed(1)} (${p.count})`):this._t("store.no_ratings",{},"No ratings yet");return`<div class="wd-card">
|
|
<div class="wd-store-cycle-top">
|
|
${a}
|
|
<div class="wd-store-cycle-stats">
|
|
<div><b>${o}</b> \xB7 ${i} \xB7 ${l} ${_}</div>
|
|
<div class="wd-info">${this._t("store.uploaded_by",{name:c},`Shared by ${c}`)} \xB7 \u2B07 ${r.downloads||0} \xB7 ${g}</div>
|
|
</div>
|
|
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="store-import" data-cycle-id="${d(r.id)}">${this._t("btn.import",{},"Import")}</button>
|
|
</div>
|
|
</div>`}).join("");return`<div class="wd-store-list">${this._storeLoading?this._htmlStoreLoading():t.length?e:`<p class="wd-info">${this._t("store.no_cycles",{},"No reference cycles shared for this program yet.")}</p>`}</div>`}_storeSparkline(t){const e=Array.isArray(t)?t.filter(u=>Array.isArray(u)&&u.length>=2):[];if(e.length<2)return'<svg class="wd-store-spark" viewBox="0 0 120 36" preserveAspectRatio="none" aria-hidden="true"></svg>';const s=e.map(u=>u[0]),r=e.map(u=>u[1]),n=Math.min(...s),a=Math.max(...s),o=Math.max(1,...r),i=120,l=36,c=2,_=u=>c+(a>n?(u-n)/(a-n):0)*(i-2*c),p=u=>l-c-Math.max(0,u)/o*(l-2*c),g=e.map(u=>`${_(u[0]).toFixed(1)},${p(u[1]).toFixed(1)}`).join(" ");return`<svg class="wd-store-spark" viewBox="0 0 ${i} ${l}" preserveAspectRatio="none" aria-hidden="true"><polyline fill="none" stroke="var(--primary-color)" stroke-width="1.5" points="${g}"/></svg>`}_htmlGearModal(t){const e=this._isAdmin(),s=[["prefs",this._t("hdr.my_preferences",{},"My Preferences")]];e&&s.push(["panel",this._t("hdr.panel_settings",{},"Panel Settings")],["access",this._t("hdr.access_control",{},"Access Control")]),e&&this._constants&&this._constants.storeOnlineAvailable&&s.push(["online",this._t("hdr.online_account",{},"Online & Community")]);let r=t.tab;s.some(([o])=>o===r)||(r=t.tab="prefs");const n=s.map(([o,i])=>`<button class="wd-subtab ${r===o?"active":""}" data-gtab="${o}">${i}</button>`).join(""),a=r==="panel"&&e?this._htmlPanelSettings():r==="access"&&e?this._htmlPanelAccess():r==="online"&&e?this._htmlOnlineSettings():this._htmlPanelPrefs();return`<h2 id="wd-modal-title"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>${this._t("settings.gear.title",{},"Settings")}<button class="wd-btn wd-btn-secondary wd-btn-sm" data-maction="cancel" aria-label="${d(this._t("btn.close",{},"Close"))}" style="margin-left:auto">\u2715</button></h2>
|
|
<div class="wd-subtabs">${n}</div>
|
|
<div class="wd-gear-body">${a}</div>`}_htmlOnlineSettings(){if(!(this._constants&&this._constants.storeOnlineAvailable))return`<p class="wd-info">${this._t("msg.online_unavailable",{},"Online features are not available on this server.")}</p>`;const t=this._onlineEnabled(),e=this._storeStatus||{},s=!!(t&&e.connected),r=this._busy.has("store-account"),n=t?s?`<div class="wd-store-conn">
|
|
<span class="wd-info">${this._t("store.connected_as",{name:d(e.name||e.uid||"")},`Connected as ${d(e.name||e.uid||"")}`)}</span>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="store-disconnect" ${r?"disabled":""}>${this._t("btn.disconnect",{},"Disconnect")}</button>
|
|
</div>`:`<div class="wd-store-conn">
|
|
<span class="wd-info">${this._t("store.not_connected",{},"Not connected. Connect a GitHub account to confirm appliances and share your own cycles.")}</span>
|
|
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="store-connect">${this._t("btn.connect_github",{},"Connect GitHub")}</button>
|
|
</div>`:"";return`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.online_account",{},"Community Store & online features")}</div>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.online_intro_global",{},"Browse and share reference recordings with other WashData users, and confirm appliance entries. One connection applies to your whole WashData integration; appliance brand and model are set per device under Basic. All online features are opt-in and off by default.")}</p>
|
|
${Et(`data-action="store-toggle-online" ${t?"checked":""} ${r?"disabled":""}`,this._t("lbl.enable_online",{},"Enable online features"))}
|
|
${t?this._htmlStorePrefs(r):""}
|
|
${t?n:""}
|
|
</div>`}_htmlStorePrefs(t){const e=this._constants&&this._constants.storePrefs||{},s=re.map(n=>{const a=e[n.key]!==!1;return Et(`data-action="store-toggle-pref" data-pref="${d(n.key)}" ${a?"checked":""} ${t?"disabled":""}`,this._t(n.labelKey,{},n.labelFb),it(this._t(n.docKey,{},n.docFb)))}).join(""),r=this._busy.has("store-refresh-catalog");return s+`
|
|
<div class="wd-field" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px">
|
|
<button type="button" class="wd-btn wd-btn-secondary wd-btn-sm" data-action="store-refresh-catalog" ${r||t?"disabled":""}>${r?'<span class="wd-spin"></span> ':""}${this._t("btn.refresh_catalog",{},"Refresh catalog")}</button>
|
|
<span class="wd-info" style="margin:0;flex:1;min-width:180px">${this._t("msg.refresh_catalog_hint",{},"The community brand and appliance lists are cached to keep the shared store within its daily budget. Refresh to pick up entries added or approved by others.")}</span>
|
|
</div>`}async _loadStoreStatus(t){if(!this._onlineEnabled()){this._storeStatus={enabled:!1},this._storeConnected=!1;return}try{const e=await this._ws({type:`${y}/store_status`,entry_id:t});if(!this._isActiveEntry(t))return;this._storeStatus=e||null,this._storeConnected=!!(e&&e.connected)}catch{}}_storeBrandScope(){return String(this._storeQuery||(this._opts||{}).store_brand||"").trim()}async _storeSearch(t){const e=this._devices[this._selIdx];if(!e)return;const s=e.entry_id;this._storeQuery=t||"",this._storeView="brands",this._storeDevice=null,this._storeProfile=null,this._storeProfiles=[],this._storeCycles=[];const r=this._storeBrandScope();if(!r){this._storeDevices=[],this._storeLoading=!1,this._render();return}this._storeLoading=!0,this._render();try{const n=await this._ws({type:`${y}/store_search_devices`,entry_id:s,query:r,appliance_type:this._storeApplianceType(),include_pending:!0});if(!this._isActiveEntry(s))return;n&&n.disabled?(this._storeStatus={enabled:!1},this._storeDevices=[]):this._storeDevices=this._sortStoreDevices(n&&n.items||[])}catch(n){this._isActiveEntry(s)&&(this._storeDevices=[],this._showToast(this._t("toast.store_search_failed",{error:n.message||n},"Search failed: "+(n.message||n)),"error"))}finally{this._isActiveEntry(s)&&(this._storeLoading=!1,this._render())}}_sortStoreDevices(t){const e=String((this._opts||{}).store_model||"").trim().toLowerCase(),s=r=>e&&String(r.model||"").toLowerCase()===e?0:this._storeItemHasContent(r)?1:2;return t.map((r,n)=>({d:r,i:n})).sort((r,n)=>s(r.d)-s(n.d)||r.i-n.i).map(r=>r.d)}_storeItemHasContent(t){return(Number(t&&t.profileCount)||0)>0||(Number(t&&t.cycleCount)||0)>0}_ensureStoreConnectListener(){if(this._storeConnectListener)return;const t=this._constants.storeWebOrigin;if(!t)return;let e;try{e=new URL(t).origin}catch{return}this._storeConnectListener=async s=>{if(s.origin!==e)return;const r=s.data;if(!r)return;const n=this._devices[this._selIdx];if(!n)return;const a=n.entry_id;if(r.type==="washdata-device-created"){const o={};r.brand&&(o.store_brand=r.brand),r.model&&(o.store_model=r.model),this._opts={...this._opts,...o},this._catalog.brands=void 0,this._catalog.devices=void 0,this._catalog.forBrand=null,this._catalog.brandsFull=!1,this._catalog.brandPrefixes=[],this._catalogEntry=null,this._showToast(this._t("toast.appliance_added",{},"Appliance added - awaiting approval")),this._render();return}if(r.type==="washdata-brand-created"){r.brand&&(this._opts={...this._opts,store_brand:r.brand}),this._catalog.brands=void 0,this._catalog.brandsFull=!1,this._catalog.brandPrefixes=[],this._catalogEntry=null,this._showToast(this._t("toast.brand_added",{},"Brand added - awaiting approval")),this._render();return}if(r.type==="washdata-profile-created"){const o=this._modal;o&&o.type==="store-share"&&(o.program=r.program||o.program,this._loadShareProfiles()),this._showToast(this._t("toast.profile_added",{},"Profile added - awaiting approval"));return}if(r.type==="washdata-connect")try{const o=await this._ws({type:`${y}/store_connect`,entry_id:a,refresh_token:r.refreshToken,uid:r.uid,name:r.displayName});if(o&&o.error){this._showToast(this._t("toast.store_connect_failed",{error:o.error},"Connect failed: "+o.error),"error");return}if(await this._loadStoreStatus(a),!this._isActiveEntry(a))return;this._showToast(this._t("toast.store_connected",{},"Connected to the community store")),this._render()}catch(o){this._showToast(this._t("toast.store_connect_failed",{error:o.message||o},"Connect failed: "+(o.message||o)),"error")}},window.addEventListener("message",this._storeConnectListener)}async _saveStoreOptions(t){const e=this._devices[this._selIdx];if(!e)return!1;const s=e.entry_id;try{return await this._ws({type:`${y}/set_options`,entry_id:s,options:t}),this._opts={...this._opts,...t},this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading")),!0}catch(r){return this._showToast(this._t("msg.toast_save_failed",{error:r.message||r},"Save failed: "+(r.message||r)),"error"),!1}}_htmlLogDrawer(){return`<div class="wd-log-drawer open" style="width:${this._logDrawerWidth}px">
|
|
<div class="wd-log-resize" title="${d(this._t("lbl.drag_to_resize",{},"Drag to resize"))}"></div>
|
|
<div class="wd-log-drawer-head">
|
|
<span>${this._t("hdr.logs",{},"Logs")}</span>
|
|
<div style="display:flex;align-items:center;gap:6px">
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="logs-export" style="padding:3px 8px">${this._t("btn.export",{},"Export")}</button>
|
|
<button class="wd-log-close-btn" data-action="toggle-log-drawer" title="${d(this._t("btn.close",{},"Close"))}">\u2715</button>
|
|
</div>
|
|
</div>
|
|
<div class="wd-log-drawer-body">
|
|
<div class="wd-logbar" style="flex-wrap:wrap;margin-bottom:6px">${this._htmlLogFilters("drawer")}</div>
|
|
<p class="wd-info" style="margin:0 0 8px;font-size:.78em">${this._t("msg.log_buffer_hint",{},"Newest first \xB7 buffers the last 500 ha_washdata records since restart \xB7 drag the left edge to resize.")}</p>
|
|
<div class="wd-logs" id="wd-log-lines-drawer" style="max-height:none;resize:none">${this._logLinesHtml()}</div>
|
|
</div>
|
|
</div>`}_drawCurves(t,e){const s=this.shadowRoot&&this.shadowRoot.getElementById(t);if(!s)return null;const r=s.getBoundingClientRect(),n=window.devicePixelRatio||1,a=Math.max(1,Math.round(r.width*n)),o=Math.max(1,Math.round((r.height||240)*n));(s.width!==a||s.height!==o)&&(s.width=a,s.height=o);const i=s.getContext("2d"),l=getComputedStyle(this),c=(l.getPropertyValue("--primary-color")||"#03a9f4").trim()||"#03a9f4",_=(l.getPropertyValue("--divider-color")||"rgba(127,127,127,.3)").trim()||"rgba(127,127,127,.3)",p=(l.getPropertyValue("--secondary-text-color")||"#888").trim()||"#888",g=44*n,u=12*n,v=12*n,b=22*n,$=e.series||[];let k=e.xMax||0;k||($.forEach(A=>(A.points||[]).forEach(E=>{E[0]>k&&(k=E[0])})),e.band&&(e.band.max||[]).forEach(A=>{A[0]>k&&(k=A[0])})),k=k||1;let S=e.yMax||0;if(!S){const A=E=>(E||[]).forEach(M=>{M[1]>S&&(S=M[1])});$.forEach(E=>{E.noScale||A(E.points)}),e.band&&A(e.band.max),S=(S||10)*1.08}const C=this._canvasZoom&&this._canvasZoom[t],R=C?C.xMin:0,B=C?C.xMax:k,W=a-g-u,O=A=>g+(A-R)/(B-R)*W,D=A=>o-b-A/S*(o-v-b);i.clearRect(0,0,a,o),i.strokeStyle=_,i.lineWidth=n,i.fillStyle=p,i.font=`${11*n}px sans-serif`,i.textAlign="right",i.textBaseline="middle";for(let A=0;A<=2;A++){const E=v+A/2*(o-v-b);i.beginPath(),i.moveTo(g,E),i.lineTo(a-u,E),i.stroke(),i.fillText(Math.round(S*(1-A/2))+"W",g-4*n,E)}if(i.save(),i.beginPath(),i.rect(g,v,W,o-v),i.clip(),(e.bands||[]).forEach(A=>{i.fillStyle=A.fill;const E=O(A.x0),M=O(A.x1);i.fillRect(Math.min(E,M),v,Math.abs(M-E),o-v-b)}),e.band&&(e.band.min||[]).length&&(e.band.max||[]).length){i.beginPath(),e.band.max.forEach((A,E)=>E?i.lineTo(O(A[0]),D(A[1])):i.moveTo(O(A[0]),D(A[1])));for(let A=e.band.min.length-1;A>=0;A--)i.lineTo(O(e.band.min[A][0]),D(e.band.min[A][1]));i.closePath(),i.fillStyle=e.band.fill||St(c,.13),i.fill()}return $.forEach(A=>{const E=A.points||[];if(!E.length)return;const M=A.stroke==="primary"?c:A.stroke;if(A.fill){i.beginPath(),E.forEach((q,Q)=>Q?i.lineTo(O(q[0]),D(q[1])):i.moveTo(O(q[0]),D(q[1]))),i.lineTo(O(E[E.length-1][0]),D(0)),i.lineTo(O(E[0][0]),D(0)),i.closePath();const H=i.createLinearGradient(0,v,0,o-b);H.addColorStop(0,St(M,.33)),H.addColorStop(1,St(M,.03)),i.fillStyle=H,i.fill()}i.beginPath(),E.forEach((H,q)=>q?i.lineTo(O(H[0]),D(H[1])):i.moveTo(O(H[0]),D(H[1]))),i.strokeStyle=M,i.lineWidth=(A.width||1.5)*n,i.lineJoin="round",A.dash&&i.setLineDash([6*n,4*n]),i.globalAlpha=A.alpha!=null?A.alpha:1,i.stroke(),i.globalAlpha=1,A.dash&&i.setLineDash([])}),i.textAlign="center",i.textBaseline="top",(e.vlines||[]).forEach(A=>{const E=O(A.x);i.beginPath(),i.moveTo(E,v),i.lineTo(E,o-b),i.strokeStyle=A.color,i.lineWidth=2*n,i.setLineDash([4*n,3*n]),i.stroke(),i.setLineDash([]),A.handle&&(i.fillStyle=A.color,i.beginPath(),i.arc(E,v+4*n,4.5*n,0,Math.PI*2),i.fill()),A.label&&(i.fillStyle=A.color,i.fillText(A.label,E,v+(A.handle?12*n:2*n)))}),i.restore(),i.fillStyle=p,i.font=`${11*n}px sans-serif`,i.textBaseline="bottom",C&&(i.textAlign="left",i.fillText((R/60).toFixed(1)+" min",g,o-2*n)),i.textAlign="right",i.fillText((B/60).toFixed(0)+" min",a-u,o-2*n),s._wd={xMax:k,xMin:R,xViewMax:B,yMax:S,dpr:n,padT:v,padB:b,ch:o,primary:c,Xpx:O,Ypx:D,xToCss:A=>O(A)/n,cssToX:A=>Math.max(R,Math.min(B,R+(A*n-g)/W*(B-R))),series:(e.series||[]).map(A=>({points:A.points,stroke:A.stroke,name:A.name,cid:A.cid})),band:e.band||null,artifacts:e.artifacts||null,_opts:e},s._wd}_drawModalCanvas(){const t=this._modal;t&&(t.type==="cycle-detail"?this._drawCycleEditor():t.type==="compare-cycles"?this._drawCompareCanvas():t.type==="profile-group"?this._drawGroupCanvas():t.type==="profile-panel"&&(t.tab==="stats"?this._drawProfileEnvelope():t.tab==="phases"?this._drawPhaseEditor():t.tab==="cleanup"&&this._drawSpaghetti()))}_redrawCanvas(t){if(t==="wd-status-canvas")this._drawStatusCurve();else if(t==="wd-cyc-canvas")this._drawCycleEditor();else if(t==="wd-compare-canvas")this._drawCompareCanvas();else if(t==="wd-env-canvas")this._drawProfileEnvelope();else if(t==="wd-phase-canvas")this._drawPhaseEditor();else if(t==="wd-spag-canvas")this._drawSpaghetti();else if(t==="wd-pgroup-canvas")this._drawGroupCanvas();else{const e=this.shadowRoot&&this.shadowRoot.getElementById(t);e&&e._wd&&e._wd._opts&&this._drawCurves(t,e._wd._opts)}}_pref(t,e){const s=this._panelCfg&&this._panelCfg.prefs||{};return s[t]===void 0?e:s[t]}_setPref(t,e){this._panelCfg||(this._panelCfg={}),this._panelCfg.prefs={...this._panelCfg.prefs||{},[t]:e},this._ws({type:`${y}/set_user_prefs`,prefs:{[t]:e}}).catch(()=>{})}_drawStatusCurve(){const t=this._powerData||{},e=t.live||[];if(e.length<2)return;const s=this._statusEnv,r=this._pref("show_expected",!0),n=this._pref("show_raw_active",!1),a=[];let o=e[e.length-1][0];if(t.cycle_active&&s&&(s.avg||[]).length&&r){const l=s.target_duration||s.avg[s.avg.length-1][0];a.push({points:s.avg,stroke:"#ff9800",width:2,alpha:.4,name:this._t("lbl.expected",{},"Expected")}),o=Math.max(o,l)}a.push({points:e,stroke:"primary",fill:!0,width:2,name:this._t("lbl.power",{},"Power")}),n&&(t.raw||[]).length>1&&a.push({points:t.raw,stroke:"#9e9e9e",width:1,alpha:.65,name:this._t("lbl.raw_socket",{},"Raw socket"),noScale:!0});const i=[];(t.restart_gaps||[]).forEach(l=>{if(!e.length)return;const c=t.cycle_start_iso;if(!c)return;const _=new Date(c).getTime(),p=Math.max(0,(new Date(l.start_ts).getTime()-_)/1e3),g=Math.max(p+1,(new Date(l.end_ts).getTime()-_)/1e3);i.push({x0:p,x1:g,fill:"rgba(96,125,139,.20)"})}),this._drawCurves("wd-status-canvas",{series:a,xMax:o,bands:i})}_attachHover(t){const e=this.shadowRoot,s=e&&e.getElementById(t);s&&(s.addEventListener("pointermove",r=>this._onGraphHover(r,t)),s.addEventListener("pointerleave",()=>this._hideGraphTip()),s.addEventListener("wheel",r=>{const n=s._wd;if(!n)return;r.preventDefault();const a=s.getBoundingClientRect(),o=n.cssToX(r.clientX-a.left),i=this._canvasZoom[t],l=n.xMax,c=i?i.xMin:0,p=(i?i.xMax:l)-c,g=p*(r.deltaY>0?1.3:.75);if(g>=l*.99)delete this._canvasZoom[t];else{const u=(o-c)/p,v=Math.min(l,o-u*g+g),b=Math.max(0,v-g);this._canvasZoom[t]={xMin:b,xMax:Math.min(l,b+g)}}this._redrawCanvas(t)},{passive:!1}),s.addEventListener("dblclick",()=>{delete this._canvasZoom[t],this._redrawCanvas(t)}))}_onGraphHover(t,e){const s=t.clientX,r=t.clientY;if(this._hoverRafId){this._hoverPending={px:s,py:r,id:e};return}this._hoverPending={px:s,py:r,id:e},this._hoverRafId=requestAnimationFrame(()=>{this._hoverRafId=null;const{px:n,py:a,id:o}=this._hoverPending||{};o&&this._onGraphHoverInner(n,a,o)})}_onGraphHoverInner(t,e,s){const r=this.shadowRoot&&this.shadowRoot.getElementById(s),n=r&&r._wd;if(!n)return;const a=r.getBoundingClientRect(),o=n.cssToX(t-a.left),i=(e-a.top)*n.dpr;this._redrawCanvas(s);const l=r.getContext("2d"),c=n.Xpx(o);l.save(),l.strokeStyle="rgba(140,140,140,.75)",l.lineWidth=n.dpr,l.setLineDash([3*n.dpr,3*n.dpr]),l.beginPath(),l.moveTo(c,n.padT),l.lineTo(c,n.ch-n.padB),l.stroke(),l.setLineDash([]);const _=v=>v.stroke==="primary"?n.primary:v.stroke,p=(v,b)=>{l.fillStyle=b,l.beginPath(),l.arc(c,n.Ypx(v),3.4*n.dpr,0,6.2832),l.fill()},g=[`${this._t("lbl.from_start",{},"From start")}: <b>${Mt(o)}</b>`,`${this._t("lbl.to_end",{},"To end")}: <b>${Mt(Math.max(0,n.xMax-o))}</b>`],u=n.series||[];if(this._hoverNearest=null,u.length>4){let v=null,b=1/0;if(u.forEach($=>{const k=Dt($.points,o);if(k==null)return;const S=Math.abs(n.Ypx(k)-i);S<b&&(b=S,v={s:$,v:k})}),v){const $=_(v.s);l.strokeStyle=$,l.lineWidth=3*n.dpr,l.beginPath(),(v.s.points||[]).forEach((k,S)=>S?l.lineTo(n.Xpx(k[0]),n.Ypx(k[1])):l.moveTo(n.Xpx(k[0]),n.Ypx(k[1]))),l.stroke(),p(v.v,$),g.push(`${d(v.s.name||"")}: <b>${v.v.toFixed(v.v<100?1:0)} W</b>`),v.s.cid&&(g.push(`<span style="opacity:.7">${this._t("lbl.click_to_select",{},"click to select")}</span>`),this._hoverNearest={id:s,cid:v.s.cid})}}else u.forEach(v=>{const b=Dt(v.points,o);b!=null&&(p(b,_(v)),g.push(`${d(v.name||this._t("lbl.power",{},"Power"))}: <b>${b.toFixed(b<100?1:0)} W</b>`))});if(n.band){const v=Dt(n.band.min,o),b=Dt(n.band.max,o);v!=null&&b!=null&&g.push(`${this._t("lbl.envelope",{},"Envelope")}: ${v.toFixed(0)}\u2013${b.toFixed(0)} W`)}(n.artifacts||[]).forEach(v=>{if(o>=v.start_s&&o<=v.end_s){const b=v.detail_key?this._t(v.detail_key,v.detail_params||{},v.detail||""):v.detail||"";g.push(`<span style="color:var(--warning-color,#ff9800)">\u26A0 ${d(Zt(v.type,($,k,S)=>this._t($,k,S)))}</span>: ${d(b)}`)}}),this._canvasZoom[s]&&g.push(`<span style="opacity:.45">${this._t("lbl.zoom_hint",{},"scroll to zoom \xB7 dblclick to reset")}</span>`),l.restore(),this._showGraphTip(t,e,g),this._syncSpagRowHighlight(this._hoverNearest?this._hoverNearest.cid:null)}_showGraphTip(t,e,s){const r=this._gtip;if(!r)return;r.innerHTML=s.join("<br>"),r.style.display="block";const n=r.offsetWidth,a=r.offsetHeight,o=16;let i=t+o,l=e+o;i+n>window.innerWidth-6&&(i=t-n-o),l+a>window.innerHeight-6&&(l=e-a-o),r.style.left=Math.max(6,i)+"px",r.style.top=Math.max(6,l)+"px"}_hideGraphTip(){this._hoverRafId&&(cancelAnimationFrame(this._hoverRafId),this._hoverRafId=null,this._hoverPending=null),this._gtip&&(this._gtip.style.display="none"),this._syncSpagRowHighlight(null)}_positionTip(t){const e=t.querySelector(".wd-tip-pop");if(!e)return;e.style.transform="";const s=!e.offsetWidth,r=s?e.style.cssText:"";s&&(e.style.visibility="hidden",e.style.display="block");const n=e.getBoundingClientRect();if(s&&(e.style.cssText=r),!n.width)return;const a=ue(t),o=6,i=a.left+o,l=a.right-o-n.width,c=l<i?i:Math.min(Math.max(n.left,i),l),_=Math.round(c-n.left);_&&(e.style.transform=`translateX(calc(-50% + ${_}px))`)}_syncSpagRowHighlight(t){if(t===this._spagHoverCid)return;this._spagHoverCid=t;const e=this.shadowRoot;e&&e.querySelectorAll("tr[data-cid]").forEach(s=>{s.style.backgroundColor=t&&s.dataset.cid===t?"var(--secondary-background-color,rgba(0,0,0,.06))":""})}_showToast(t,e="success",s={}){this._toastTimer&&clearTimeout(this._toastTimer);const r=s.duration||3500;this._toast={msg:t,cls:`wd-toast-${e}`,actionLabel:s.actionLabel||null,actionToken:s.actionToken||null},this._render(),this._toastTimer=setTimeout(()=>{this._toast=null,this._render()},r)}_profileOptions(t){return(this._profiles||[]).map(e=>`<option value="${d(e.name)}" ${String(t)===String(e.name)?"selected":""}>${d(e.name)}</option>`).join("")}_htmlModal(){const t=this._modal;if(t.type==="cycle-detail")return`<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlCycleModal(t)}</div></div>`;if(t.type==="profile-panel")return`<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlProfilePanel(t)}</div></div>`;if(t.type==="profile-group")return`<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlProfileGroupModal(t)}</div></div>`;if(t.type==="compare-cycles")return`<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlCompareModal(t)}</div></div>`;if(t.type==="gear-settings")return`<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlGearModal(t)}</div></div>`;if(t.type==="export-select")return`<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlExportSelectModal(t)}</div></div>`;if(t.type==="import-wizard")return`<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlImportWizardModal(t)}</div></div>`;if(t.type==="history-import")return`<div class="wd-overlay"><div class="wd-modal wd-modal-lg" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${this._htmlHistoryImportModal(t)}</div></div>`;let e="";if(t.type==="confirm")e=`<h2>${d(t.title)}</h2><p class="wd-info">${d(t.message)}</p>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-danger" data-maction="ok">${d(t.okLabel||this._t("btn.confirm",{},"Confirm"))}</button></div>`;else if(t.type==="label-cycle")e=`<h2>${this._t("modal.label_cycle",{},"Label Cycle")}</h2>
|
|
<div class="wd-field"><label>${this._t("lbl.select_profile",{},"Select Profile")}</label>
|
|
<select id="wd-label-profile"><option value="">${this._t("lbl.remove_label",{},"- Remove label -")}</option><option value="__create_new__">${this._t("lbl.create_new_profile",{},"+ Create new profile\u2026")}</option>${this._profileOptions()}</select></div>
|
|
<div id="wd-new-profile-row" class="wd-field" style="display:none"><label>${this._t("lbl.new_profile_name",{},"New Profile Name")}</label><input type="text" id="wd-new-profile-name" placeholder="${d(this._t("placeholder.profile_name",{},"e.g. Cotton 40\xB0C"))}"></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="label-ok">${this._t("btn.apply_label",{},"Apply Label")}</button></div>`;else if(t.type==="create-profile"){const s=(this._cycles||[]).slice(0,40).map(r=>`<option value="${d(r.id)}">${st(r.start_time)} - ${Math.round((r.duration||0)/60)}m - ${d(r.profile_name||this._t("lbl.unlabelled",{},"Unlabelled"))}</option>`).join("");e=`<h2>${this._t("modal.create_profile",{},"Create Profile")}</h2>
|
|
<div class="wd-field"><label>${this._t("lbl.profile_name",{},"Profile Name")}</label><input type="text" id="wd-cp-name" placeholder="${d(this._t("placeholder.profile_name",{},"e.g. Cotton 40\xB0C"))}" value="${d(t.prefillName||"")}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.ref_cycle",{},"Reference Cycle (optional)")}</label><select id="wd-cp-cycle"><option value="">None</option>${s}</select></div>
|
|
<div class="wd-field"><label>${this._t("lbl.manual_duration",{},"Manual Duration (min, optional)")}</label><input type="number" id="wd-cp-dur" min="0" max="600" value="0"><div class="wd-field-hint" id="wd-cp-dur-hint">${this._t("msg.manual_duration_ref_hint",{},"Only used when no reference cycle is selected \u2014 a reference cycle sets the duration from its own length.")}</div></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="create-profile-ok">${this._t("btn.create",{},"Create")}</button></div>`}else if(t.type==="create-phase")e=`<h2>${this._t("modal.new_phase",{},"New Phase")}</h2>
|
|
<div class="wd-field"><label>${this._t("lbl.phase_name",{},"Phase Name")}</label><input type="text" id="wd-ph-name" placeholder="${d(this._t("placeholder.phase_name",{},"e.g. Pre-wash"))}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.description",{},"Description")}</label><textarea id="wd-ph-desc" rows="3"></textarea></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="create-phase-ok">${this._t("btn.create",{},"Create")}</button></div>`;else if(t.type==="edit-phase"){const s=t.isDefault?`<p class="wd-info" style="margin:0 0 12px">${this._t("msg.edit_builtin_phase",{},"This is a built-in phase. Saving creates a custom override \u2014 the original is preserved and can be restored by deleting the override.")}</p>`:"";e=`<h2>${this._t("modal.edit_phase",{},"Edit Phase")} ${t.isDefault?`<span class="wd-tag">${this._t("badge.built_in_tag",{},"built-in")}</span>`:""}</h2>
|
|
${s}
|
|
<div class="wd-field"><label>${this._t("lbl.phase_name",{},"Phase Name")}</label><input type="text" id="wd-eph-name" value="${d(t.phaseName)}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.description",{},"Description")}</label><textarea id="wd-eph-desc" rows="3">${d(t.phaseDesc)}</textarea></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="edit-phase-ok">${this._t("btn.save",{},"Save")}</button></div>`}else if(t.type==="process-recording")e=`<h2>${this._t("modal.process_recording",{},"Process Recording")}</h2>
|
|
<div class="wd-field"><label>${this._t("lbl.save_mode",{},"Save Mode")}</label><select id="wd-pr-mode"><option value="new_profile">${this._t("lbl.mode_new_profile",{},"Create New Profile")}</option><option value="existing_profile">${this._t("lbl.mode_existing_profile",{},"Add to Existing Profile")}</option></select></div>
|
|
<div class="wd-field"><label>${this._t("lbl.profile_name",{},"Profile Name")}</label><input type="text" id="wd-pr-profile" placeholder="${d(this._t("placeholder.profile_name",{},"e.g. Cotton 40\xB0C"))}">
|
|
<div id="wd-pr-existing" style="display:none;margin-top:4px"><select id="wd-pr-profile-sel">${this._profileOptions()}</select></div></div>
|
|
<div class="wd-field"><label>${this._t("lbl.head_trim",{},"Head Trim (s)")}</label><input type="number" id="wd-pr-head" min="0" value="0" step="1"><div class="wd-field-hint">${d(this._t("msg.head_trim_hint",{},"Remove this many seconds from the start"))}</div></div>
|
|
<div class="wd-field"><label>${this._t("lbl.tail_trim",{},"Tail Trim (s)")}</label><input type="number" id="wd-pr-tail" min="0" value="0" step="1"><div class="wd-field-hint">${d(this._t("msg.tail_trim_hint",{},"Remove this many seconds from the end"))}</div></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="process-rec-ok">${this._t("btn.process_recording",{},"Save Recording")}</button></div>`;else if(t.type==="correct-feedback")e=`<h2>${this._t("modal.correct_feedback",{},"Correct Feedback")}</h2>
|
|
<p class="wd-info">WashData detected: <strong>${d(t.detectedProfile)}</strong></p>
|
|
<div class="wd-field"><label>${this._t("lbl.correct_profile",{},"Correct Profile")}</label><select id="wd-fb-profile">${this._profileOptions()}</select></div>
|
|
<div class="wd-field"><label>${this._t("lbl.correct_duration",{},"Correct Duration (min, optional)")}</label><input type="number" id="wd-fb-dur" min="0" value=""></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="correct-fb-ok">${this._t("btn.submit_correction",{},"Submit Correction")}</button></div>`;else if(t.type==="import-config")e=`<h2>${this._t("modal.import_config",{},"Import Configuration")}</h2>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.import_intro",{},"Load an exported file or paste a JSON payload below.")}</p>
|
|
<div class="wd-field"><label>${this._t("lbl.load_from_file",{},"Load from file")}</label><input type="file" id="wd-import-file" accept=".json,application/json"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.json_data",{},"JSON Data")}</label><textarea id="wd-import-json" style="min-height:150px;font-family:monospace;font-size:.78em" placeholder='{"profiles": [...], "cycles": [...]}'></textarea></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-danger" data-maction="import-ok">${this._t("btn.import_overwrite",{},"Import (overwrites data)")}</button></div>`;else if(t.type==="auto-label")e=`<h2>${this._t("modal.auto_label",{},"Auto-Label Cycles")}</h2>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.auto_label_intro",{},"Assign profiles to unlabelled cycles whose match confidence clears the threshold.")}</p>
|
|
<div class="wd-field"><label>${this._t("lbl.confidence_threshold",{},"Confidence threshold")}</label><input type="number" id="wd-al-thr" value="0.75" min="0.5" max="0.95" step="0.05"></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="auto-run">${this._t("btn.run_auto_label",{},"Run Auto-Label")}</button></div>`;else if(t.type==="merge-cycles")e=`<h2>${this._t("modal.merge_cycles",{n:t.ids.length},`Merge ${t.ids.length} Cycles`)}</h2>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.merge_intro",{},"The selected cycles are combined into one (chronological order; gaps filled with 0 W). Pick the resulting profile.")}</p>
|
|
<div class="wd-field"><label>${this._t("lbl.resulting_profile",{},"Resulting profile")}</label>
|
|
<select id="wd-merge-prof"><option value="">${this._t("lbl.unlabelled_paren",{},"(unlabelled)")}</option><option value="__create_new__">${this._t("lbl.create_new_profile",{},"+ Create new profile\u2026")}</option>${this._profileOptions()}</select></div>
|
|
<div id="wd-merge-new" class="wd-field" style="display:none"><label>${this._t("lbl.new_profile_name",{},"New profile name")}</label><input type="text" id="wd-merge-newname" placeholder="${d(this._t("placeholder.profile_name",{},"e.g. Cotton 40\xB0C"))}"></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="merge-ok">${this._t("btn.merge",{},"Merge")}</button></div>`;else if(t.type==="bulk-relabel")e=`<h2>${this._t("modal.relabel_cycles",{count:t.ids.length},`Relabel ${t.ids.length} cycles`)}</h2>
|
|
<div class="wd-field"><label>${this._t("lbl.select_profile",{},"Select Profile")}</label>
|
|
<select id="wd-relabel-profile"><option value="">${this._t("lbl.remove_label",{},"- Remove label -")}</option>${this._profileOptions()}</select></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="bulk-relabel-ok">${this._t("btn.apply_label",{},"Apply Label")}</button></div>`;else if(t.type==="store-import"){const s=t.mode!=="merge",r=`<select id="wd-store-import-target"><option value="">${this._t("lbl.select_profile",{},"Select Profile")}</option>${this._profileOptions()}</select>`;e=`<h2>${this._t("modal.store_import",{},"Import reference cycle")}</h2>
|
|
<div class="wd-mode-bar">
|
|
<button class="wd-btn wd-btn-sm ${s?"wd-btn-primary":"wd-btn-secondary"}" data-maction="store-import-mode-new">${this._t("store.import_new",{},"New profile")}</button>
|
|
<button class="wd-btn wd-btn-sm ${s?"wd-btn-secondary":"wd-btn-primary"}" data-maction="store-import-mode-merge">${this._t("store.import_merge",{},"Merge into existing")}</button>
|
|
</div>
|
|
${s?`<div class="wd-field"><label>${this._t("lbl.new_profile_name",{},"New Profile Name")}</label><input type="text" id="wd-store-import-name" value="${d(t.program||"")}"></div>`:`<div class="wd-field"><label>${this._t("store.merge_target",{},"Merge into profile")}</label>${r}</div>`}
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel" ${this._busy.has("store-import")?"disabled":""}>${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="store-import-ok" ${this._busy.has("store-import")?"disabled":""}>${this._busy.has("store-import")?'<span class="wd-spin"></span> ':""}${this._t("btn.import",{},"Import")}</button></div>`}else if(t.type==="store-share"){const s=Array.isArray(t.profiles)?t.profiles:[],r=[],n=new Set,a=l=>{const c=(l||"").toLowerCase();l&&!n.has(c)&&(n.add(c),r.push(l))};a(t.program),s.forEach(l=>a(l.program));const o=t.profiles==null?` <span class="wd-info" style="font-size:.85em">${this._t("msg.loading",{},"Loading\u2026")}</span>`:"",i=r.length?r.map(l=>`<option value="${d(l)}" ${l===t.program?"selected":""}>${d(l)}</option>`).join(""):`<option value="">${this._t("msg.no_profiles_yet",{},"(no profiles yet - add one)")}</option>`;e=`<h2>${this._t("modal.store_share",{},"Share to community store")}</h2>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.store_share_intro",{},"Upload this reference cycle so others with the same appliance can use it. It is reviewed before appearing publicly.")}</p>
|
|
<div class="wd-field"><label>${this._t("lbl.profile",{},"Profile")}${o}</label>
|
|
<div class="wd-combo-row">
|
|
<select id="wd-store-share-prog">${i}</select>
|
|
<button type="button" class="wd-addbtn" data-action="store-share-add-profile" title="${d(this._t("tip.add_profile",{},"Add a profile for this appliance on the community site"))}">+</button>
|
|
</div></div>
|
|
<div class="wd-field"><label>${this._t("store.description",{},"Description (optional)")}</label><textarea id="wd-store-share-desc" rows="2"></textarea></div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel" ${this._busy.has("store-share")?"disabled":""}>${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="store-share-ok" ${this._busy.has("store-share")?"disabled":""}>${this._busy.has("store-share")?'<span class="wd-spin"></span> ':""}${this._t("btn.share",{},"Share")}</button></div>`}else t.type==="store-share-device"&&(e=this._htmlShareDeviceModal(t));return`<div class="wd-overlay"><div class="wd-modal" role="dialog" aria-modal="true" aria-labelledby="wd-modal-title" tabindex="-1">${e}</div></div>`}_htmlShareDeviceModal(t){const e=this._shareableByProgram(),s=this._busy.has("store-share-device"),r=t.selected||new Set,n=!!t.consented,a=e.reduce((u,v)=>u+v.cycles.filter(b=>r.has(b.id)).length,0),o=e.some(u=>!u.noCycles);let i;t.loading?i=`<div class="wd-empty" style="padding:24px"><div class="wd-icon">\u23F3</div>${this._t("msg.loading",{},"Loading\u2026")}</div>`:e.length?i=e.map(u=>{if(u.noCycles)return`<div class="wd-sd-group wd-sd-group-nocyc">
|
|
<div class="wd-sd-prof wd-sd-prof-disabled">
|
|
<span class="wd-sd-prof-name">${d(u.program)}</span>
|
|
<span class="wd-sd-nocyc-note">${this._t("msg.share_profile_no_cycles",{},"No reference cycles \u2014 mark a cycle as \u2B50 in the Cycles tab to include this profile")}</span>
|
|
</div>
|
|
</div>`;const v=u.cycles.every(C=>r.has(C.id)),b=!v&&u.cycles.some(C=>r.has(C.id)),$=u.cycles.map(C=>{const R=C.start_time?st(C.start_time):"",B=C.duration!=null?ot(C.duration):"";return`<label class="wd-sd-cyc">
|
|
<input type="checkbox" data-maction="sd-toggle-cyc" data-cid="${d(C.id)}" ${r.has(C.id)?"checked":""} ${s?"disabled":""}>
|
|
<span class="wd-sd-cyc-meta">${d(R)} \xB7 ${d(B)} \u2B50</span>
|
|
</label>`}).join(""),S=(this._sharePhasePrograms||[]).includes(u.program)?`<label class="wd-sd-phase">
|
|
<input type="checkbox" data-maction="sd-toggle-phases" data-prog="${d(u.program)}" ${t.includePhases&&t.includePhases.has(u.program)?"checked":""} ${s?"disabled":""}>
|
|
<span>${this._t("lbl.include_phase_map",{},"Include phase map")}</span>
|
|
</label>`:"";return`<div class="wd-sd-group">
|
|
<label class="wd-sd-prof">
|
|
<input type="checkbox" data-maction="sd-toggle-prof" data-prog="${d(u.program)}" ${v?"checked":""} ${b?'data-indeterminate="1"':""} ${s?"disabled":""}>
|
|
<span class="wd-sd-prof-name">${d(u.program)}</span>
|
|
<span class="wd-sd-count">${u.cycles.filter(C=>r.has(C.id)).length}/${u.cycles.length}</span>
|
|
</label>
|
|
<div class="wd-sd-cycles">${$}</div>
|
|
${S}
|
|
</div>`}).join(""):i=`<div class="wd-empty" style="padding:24px"><div class="wd-icon">\u{1F4E4}</div>${this._t("msg.share_device_none",{},"No shareable cycles yet. Mark a recorded or hand-picked cycle as a reference cycle (\u2B50) in the Cycles tab first.")}</div>`;const l=d((this._opts.store_brand||"").trim()),c=d((this._opts.store_model||"").trim()),_=`<label class="wd-sd-settings">
|
|
<input type="checkbox" data-maction="sd-toggle-settings" ${t.includeSettings?"checked":""} ${s?"disabled":""}>
|
|
<span>${this._t("lbl.include_settings",{},"Include detection & matching settings")}</span>
|
|
${it(this._t("msg.include_settings_hint",{},"Share this device's recognition and matching thresholds (not your notifications, entities or energy price). Adopters choose whether to apply them."))}
|
|
</label>`,p=`<details class="wd-share-guide" ${t.guideOpen?"open":""}>
|
|
<summary data-maction="sd-toggle-guide">${this._t("msg.share_guidelines_title",{},"Before you share")}</summary>
|
|
<ul class="wd-share-guide-list">
|
|
<li>${this._t("msg.share_guideline_naming",{},"Name each profile exactly as shown on the appliance dial or display (e.g. 'Cotton 40', 'Eco 60').")}</li>
|
|
<li>${this._t("msg.share_guideline_quality",{},"Only share cycles that completed normally -- no mid-cycle interruptions, door-open events, or power blips.")}</li>
|
|
<li>${this._t("msg.share_guideline_review",{},"Your upload starts as pending and appears publicly once enough community members confirm it.")}</li>
|
|
</ul>
|
|
</details>`,g=o?`<label class="wd-sd-consent">
|
|
<input type="checkbox" data-maction="sd-toggle-consent" ${n?"checked":""} ${s?"disabled":""}>
|
|
<span>${this._t("lbl.share_consent",{},"I confirm these cycles ran to normal completion without interruption")}</span>
|
|
</label>`:"";return`<h2 id="wd-modal-title">${this._t("modal.store_share_device",{},"Share this device")}</h2>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.store_share_device_intro",{brand:l,model:c},`Upload ${l} ${c} with the reference cycles you select. Others with the same appliance can adopt your programs. Entries are reviewed before appearing publicly.`)}</p>
|
|
${p}
|
|
<div class="wd-sd-tree">${i}</div>
|
|
${o?_:""}
|
|
${g}
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel" ${s?"disabled":""}>${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="store-share-device-ok" ${s||!a||!n?"disabled":""}>${s?'<span class="wd-spin"></span> ':""}${this._t("btn.share_n",{n:a},`Share ${a} cycle(s)`)}</button>
|
|
</div>`}_wizCatOrder(){return["profiles","real_cycles","reference_cycles","custom_phases","profile_groups","settings","matching_config","ml_models","feedback","suggestions","maintenance_log","history_logs","lifetime_stats"]}_wizCatLabel(t){return{profiles:this._t("lbl.cat_profiles",{},"Profiles (programs)"),real_cycles:this._t("lbl.cat_real_cycles",{},"Cycles (run history)"),reference_cycles:this._t("lbl.cat_reference_cycles",{},"Reference cycles (imported)"),custom_phases:this._t("lbl.cat_custom_phases",{},"Custom phases"),profile_groups:this._t("lbl.cat_profile_groups",{},"Profile groups"),settings:this._t("lbl.cat_settings",{},"Detection & matching settings"),matching_config:this._t("lbl.cat_matching_config",{},"Matcher tuning"),ml_models:this._t("lbl.cat_ml_models",{},"ML models"),feedback:this._t("lbl.cat_feedback",{},"Feedback & review labels"),suggestions:this._t("lbl.cat_suggestions",{},"Suggestions"),maintenance_log:this._t("lbl.cat_maintenance_log",{},"Maintenance log"),history_logs:this._t("lbl.cat_history_logs",{},"History & change logs"),lifetime_stats:this._t("lbl.cat_lifetime_stats",{},"Lifetime totals")}[t]||t}_wizInitSel(t,e){const s=new Set,r=new Set,n=new Set,a=new Set,o=t&&t.categories||{},i=l=>!!(l&&l.present&&(!e||l.importable!==!1));return i(o.profiles)&&(o.profiles.items||[]).forEach(l=>r.add(l.name)),i(o.real_cycles)&&(o.real_cycles.groups||[]).forEach(l=>l.cycles.forEach(c=>{c.id!=null&&n.add(String(c.id))})),i(o.reference_cycles)&&(o.reference_cycles.groups||[]).forEach(l=>l.cycles.forEach(c=>{c.id!=null&&a.add(String(c.id))})),Object.keys(o).forEach(l=>{["profiles","real_cycles","reference_cycles"].includes(l)||i(o[l])&&s.add(l)}),{cats:s,profiles:r,realIds:n,refIds:a}}_wizSelectionPayload(t){const e=t.sel,s=[];e.profiles.size&&s.push("profiles"),e.realIds.size&&s.push("real_cycles"),e.refIds.size&&s.push("reference_cycles"),e.cats.forEach(n=>s.push(n));const r={categories:s};return e.profiles.size&&(r.profiles=Array.from(e.profiles)),e.realIds.size&&(r.real_cycle_ids=Array.from(e.realIds)),e.refIds.size&&(r.reference_cycle_ids=Array.from(e.refIds)),r}_wizGroupIds(t,e,s){const n=(((t.categories||{})[e]||{}).groups||[]).find(a=>a.profile===s);return n?n.cycles.filter(a=>a.id!=null).map(a=>String(a.id)):[]}_wizCatState(t,e,s){const r=t.sel,n=(s.categories||{})[e]||{};if(e==="profiles"){const o=n.items||[],i=o.length,l=o.filter(c=>r.profiles.has(c.name)).length;return{sel:l,total:i,state:l===0?"none":l===i?"all":"some"}}if(e==="real_cycles"||e==="reference_cycles"){const o=e==="real_cycles"?r.realIds:r.refIds,i=n.count||0;let l=0;return(n.groups||[]).forEach(c=>c.cycles.forEach(_=>{o.has(String(_.id))&&l++})),{sel:l,total:i,state:l===0?"none":l===i?"all":"some"}}const a=r.cats.has(e);return{sel:a?1:0,total:1,state:a?"all":"none"}}_htmlSelectionTree(t,e,s={}){const r=e&&e.categories||{},n=new Set;s.conflicts&&(r.profiles&&r.profiles.items||[]).forEach(_=>{_.conflict&&n.add(_.name)});const a=this._wizCatOrder().filter(_=>r[_]&&r[_].present);let o=!1,i=!0;a.forEach(_=>{if(s.importableOnly&&r[_].importable===!1)return;const p=this._wizCatState(t,_,e);p.state!=="none"&&(o=!0),p.state!=="all"&&(i=!1)});const l=`<label class="wd-sd-prof" style="border-bottom:1px solid var(--divider-color, #444)">
|
|
<input type="checkbox" data-maction="wiz-toggle-all" ${i?"checked":""} ${!i&&o?'data-indeterminate="1"':""}>
|
|
<span class="wd-sd-prof-name"><strong>${this._t("btn.select_all",{},"Select all")}</strong></span>
|
|
</label>`,c=a.map(_=>{const p=r[_],g=s.importableOnly&&p.importable===!1,u=this._wizCatState(t,_,e),b=["profiles","real_cycles","reference_cycles"].includes(_)?`${u.sel}/${p.count}`:"",$=`<label class="wd-sd-prof">
|
|
<input type="checkbox" data-maction="wiz-toggle-cat" data-cat="${_}" ${u.state==="all"?"checked":""} ${u.state==="some"?'data-indeterminate="1"':""} ${g?"disabled":""}>
|
|
<span class="wd-sd-prof-name">${d(this._wizCatLabel(_))}${g?` <span class="wd-tag">${this._t("badge.not_importable",{},"n/a here")}</span>`:""}</span>
|
|
<span class="wd-sd-count">${b}</span>
|
|
</label>`;let k="";if(!g&&_==="profiles")k=`<div class="wd-sd-cycles">${(p.items||[]).map(S=>`<label class="wd-sd-cyc">
|
|
<input type="checkbox" data-maction="wiz-toggle-profile" data-name="${d(S.name)}" ${t.sel.profiles.has(S.name)?"checked":""}>
|
|
<span class="wd-sd-cyc-meta">${d(S.name)} \xB7 ${(S.real_cycles||0)+(S.reference_cycles||0)} ${this._t("lbl.cycles_short",{},"cycles")}${n.has(S.name)?` <span class="wd-tag" style="color:var(--warning-color,#e6a700)">${this._t("badge.exists",{},"exists")}</span>`:""}</span>
|
|
</label>`).join("")}</div>`;else if(!g&&(_==="real_cycles"||_==="reference_cycles")){const S=_==="real_cycles"?t.sel.realIds:t.sel.refIds;k=`<div class="wd-sd-cycles">${(p.groups||[]).map(C=>{const R=C.cycles.map(M=>String(M.id)),B=R.filter(M=>S.has(M)).length,W=B===R.length&&R.length>0,O=B>0&&!W,D=`${_}:${C.profile}`,A=t.expanded&&t.expanded.has(D),E=A?C.cycles.map(M=>`<label class="wd-sd-cyc" style="margin-left:18px">
|
|
<input type="checkbox" data-maction="wiz-toggle-cyc" data-cat="${_}" data-cid="${d(String(M.id))}" ${S.has(String(M.id))?"checked":""}>
|
|
<span class="wd-sd-cyc-meta">${d(M.date?st(M.date):String(M.id))}${M.duration!=null?" \xB7 "+ot(M.duration):""}</span>
|
|
</label>`).join(""):"";return`<div>
|
|
<div class="wd-sd-cyc" style="display:flex;align-items:center">
|
|
<label style="flex:1;display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
<input type="checkbox" data-maction="wiz-toggle-cycgroup" data-cat="${_}" data-prof="${d(C.profile)}" ${W?"checked":""} ${O?'data-indeterminate="1"':""}>
|
|
<span class="wd-sd-cyc-meta"><strong>${d(C.profile||this._t("lbl.unlabelled",{},"Unlabelled"))}</strong> (${B}/${C.count})</span>
|
|
</label>
|
|
<button type="button" class="wd-linkbtn" data-maction="wiz-expand" data-key="${d(D)}" style="margin-left:auto;background:none;border:none;color:var(--primary-color);cursor:pointer">${A?"\u25BE":"\u25B8"}</button>
|
|
</div>
|
|
${E}
|
|
</div>`}).join("")}</div>`}return`<div class="wd-sd-group">${$}${k}</div>`}).join("");return`<div class="wd-sd-tree">${l}${c}</div>`}_htmlExportSelectModal(t){const e=this._busy.has("export-select");let s;t.loading||!t.inventory?s=`<div class="wd-empty" style="padding:24px"><div class="wd-icon">\u23F3</div>${this._t("msg.loading",{},"Loading\u2026")}</div>`:s=this._htmlSelectionTree(t,{categories:t.inventory},{importableOnly:!1});const r=t.sel&&(t.sel.profiles.size||t.sel.realIds.size||t.sel.refIds.size||t.sel.cats.size);return`<h2 id="wd-modal-title">${this._t("modal.export_select",{},"Export - choose data")}</h2>
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.export_select_intro",{},"Tick exactly what to include. Selecting profiles without their cycles still exports a matchable program (its learned shape travels along).")}</p>
|
|
${s}
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel" ${e?"disabled":""}>${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="export-generate" ${e||!r?"disabled":""}>${e?'<span class="wd-spin"></span> ':""}${this._t("btn.download_export",{},"Download export")}</button>
|
|
</div>`}_htmlImportWizardModal(t){const e=this._busy.has("import-wizard"),s=`<h2 id="wd-modal-title">${this._t("modal.import_wizard",{},"Import - choose data")}</h2>`;if(t.step==="input"||t.step==="analyze"){const $=t.step==="analyze";return`${s}
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.import_analyze_hint",{},"Load an exported file (or paste its JSON). WashData analyzes it and shows exactly what can be imported before anything changes.")}</p>
|
|
<div class="wd-field"><label>${this._t("lbl.load_from_file",{},"Load from file")}</label><input type="file" id="wd-import-file" accept=".json,application/json" ${$?"disabled":""}></div>
|
|
<div class="wd-field"><label>${this._t("lbl.json_data",{},"JSON Data")}</label><textarea id="wd-import-json" style="min-height:120px;font-family:monospace;font-size:.78em" ${$?"disabled":""}>${d(t.jsonText||"")}</textarea></div>
|
|
${t.error?`<p class="wd-info" style="color:var(--error-color)">${d(t.error)}</p>`:""}
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="import-analyze" ${$?"disabled":""}>${$?'<span class="wd-spin"></span> '+this._t("msg.analyzing",{},"Analyzing\u2026"):this._t("btn.analyze_import",{},"Analyze file")}</button>
|
|
</div>`}const r=t.manifest||{},n=r.device_type_match===!1,a=d(r.source_device_type||"?"),o=d(r.local_device_type||"?"),i=n?`<div class="wd-banner wd-banner-warn" style="margin-bottom:12px;padding:8px 12px;border-radius:8px;background:var(--warning-color,#e6a700);color:#111">
|
|
${this._t("msg.device_type_mismatch_warn",{src:a,local:o},"This export is from a different appliance type ("+a+" vs "+o+"). Programs and cycles can still be imported as reference data, but device-specific settings and real-history import are disabled.")}
|
|
</div>`:"",l=this._htmlSelectionTree(t,r,{importableOnly:!0,conflicts:!0}),c=`<div class="wd-field"><label>${this._t("lbl.import_mode",{},"How to combine")}</label>
|
|
<div class="wd-mode-bar" style="display:flex;gap:8px">
|
|
<button type="button" class="wd-btn ${t.mode==="merge"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="imp-mode-merge">${this._t("lbl.mode_merge",{},"Merge (keep mine)")}</button>
|
|
<button type="button" class="wd-btn ${t.mode==="replace"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="imp-mode-replace">${this._t("lbl.mode_replace",{},"Replace selected")}</button>
|
|
</div>
|
|
<div class="wd-field-hint">${t.mode==="replace"?this._t("msg.replace_warn",{},"Each ticked category is wiped and replaced from the file. Unticked categories are left untouched."):this._t("msg.merge_hint",{},"Imported items are added; nothing local is lost. Name clashes are resolved below.")}</div>
|
|
</div>`,_=r.real_history_allowed!==!1,p=`<div class="wd-field"><label>${this._t("lbl.cycle_destination",{},"Where should imported cycles go?")}</label>
|
|
<div class="wd-mode-bar" style="display:flex;gap:8px">
|
|
<button type="button" class="wd-btn ${t.cycleDest==="reference"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="imp-dest-reference">${this._t("lbl.dest_reference",{},"Reference (shape only)")}</button>
|
|
<button type="button" class="wd-btn ${t.cycleDest==="real_history"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="imp-dest-real" ${_?"":"disabled"}>${this._t("lbl.dest_real_history",{},"Real history (counts in stats)")}</button>
|
|
</div>
|
|
<div class="wd-field-hint">${t.cycleDest==="real_history"?this._t("msg.dest_real_history_hint",{},"Imported cycles count as this device's own history and feed energy/usage stats. Use for moving one appliance to a new install."):this._t("msg.dest_reference_hint",{},"Imported cycles only improve program matching and never affect usage/energy statistics.")}</div>
|
|
</div>`;let g="";const v=(r.categories&&r.categories.profiles&&r.categories.profiles.items||[]).filter($=>$.conflict&&t.sel.profiles.has($.name));t.mode==="merge"&&v.length&&(g=`<div class="wd-field"><label>${this._t("lbl.conflict_resolution",{},"Name clashes")}</label>
|
|
${v.map($=>`<div style="display:flex;align-items:center;gap:8px;margin:4px 0">
|
|
<span style="flex:1">${d($.name)}</span>
|
|
<select data-maction="imp-conflict" data-prof="${d($.name)}">
|
|
<option value="import_as_copy" ${(t.conflicts[$.name]||"import_as_copy")==="import_as_copy"?"selected":""}>${this._t("lbl.conflict_import_copy",{},"Import as copy")}</option>
|
|
<option value="keep_mine" ${t.conflicts[$.name]==="keep_mine"?"selected":""}>${this._t("lbl.conflict_keep_mine",{},"Keep mine")}</option>
|
|
<option value="overwrite" ${t.conflicts[$.name]==="overwrite"?"selected":""}>${this._t("lbl.conflict_overwrite",{},"Overwrite")}</option>
|
|
</select>
|
|
</div>`).join("")}
|
|
</div>`);const b=t.sel&&(t.sel.profiles.size||t.sel.realIds.size||t.sel.refIds.size||t.sel.cats.size);return`${s}
|
|
${i}
|
|
${l}
|
|
${c}
|
|
${p}
|
|
${g}
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="import-back" ${e?"disabled":""}>${this._t("btn.back",{},"Back")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="import-apply-ok" ${e||!b?"disabled":""}>${e?'<span class="wd-spin"></span> ':""}${this._t("btn.import_selected",{},"Import selected")}</button>
|
|
</div>`}_histSkipReason(t){return{idle:this._t("lbl.hist_skip_idle",{},"nothing running"),sparse:this._t("lbl.hist_skip_sparse",{},"readings too far apart"),too_few_samples:this._t("lbl.hist_skip_short",{},"too few readings"),too_long:this._t("lbl.hist_skip_long",{},"no break long enough to split on")}[t]||t||""}_histSegReason(t){return{shorter_than_minimum:this._t("lbl.hist_reason_short",{},"shorter than this appliance's shortest real cycle"),no_clean_end:this._t("lbl.hist_reason_no_end",{},"never ended cleanly")}[t]||""}_htmlHistoryImportModal(t){const e=`<h2 id="wd-modal-title">${this._t("modal.history_import",{},"Import power history")}</h2>`,s=t.error?`<p class="wd-info" style="color:var(--error-color)">${d(t.error)}</p>`:"";if(t.step==="input"){const $=this._busy.has("hist-import"),k=$?"disabled":"";return`${e}
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.hist_input_hint",{},"Upload a CSV downloaded from the History panel (entity, state, last changed), or let WashData read the sensor's history directly. Detection then runs over it exactly as it does live, and you choose which of the cycles it finds to keep.")}</p>
|
|
<div class="wd-field"><label>${this._t("lbl.load_from_file",{},"Load from file")}</label><input type="file" id="wd-hist-file" accept=".csv,text/csv,text/plain" ${k}></div>
|
|
<div class="wd-field"><label>${this._t("lbl.hist_csv_data",{},"CSV data")}</label><textarea id="wd-hist-csv" style="min-height:96px;font-family:monospace;font-size:.78em" placeholder="entity_id,state,last_changed" ${k}>${d(t.csvText||"")}</textarea></div>
|
|
<div class="wd-field">
|
|
<label>${this._t("lbl.hist_from_recorder",{},"Or read it from Home Assistant")}</label>
|
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
<span class="wd-info">${this._t("lbl.hist_since",{},"Since")}</span>
|
|
<input type="date" id="wd-hist-since" value="${d(t.since||zt())}" min="${d(ce())}" max="${d(de())}" ${k}>
|
|
<button class="wd-btn wd-btn-secondary" data-maction="hist-recorder" ${k}>${this._t("btn.hist_read_recorder",{},"Read from Home Assistant")}</button>
|
|
</div>
|
|
<div class="wd-field-hint">${this._t("msg.hist_recorder_hint",{},"Reads from the date you pick up to now. Home Assistant keeps detailed history for 10 days by default and only hourly averages after that, which are too coarse to detect cycles from - pick a date further back only if your recorder is set to keep more.")}</div>
|
|
</div>
|
|
${s}
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel" ${k}>${this._t("btn.cancel",{},"Cancel")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="hist-scan" ${k}>${$?'<span class="wd-spin"></span> ':""}${this._t("btn.hist_scan",{},"Scan for cycles")}</button>
|
|
</div>`}if(t.step==="scan"){const $=t.scanTaskId?(this._tasks||{})[t.scanTaskId]:null,k=$&&$.total>0?Math.round($.done/$.total*100):null;return`${e}
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.hist_scanning",{},"Replaying your history through the detector. This runs in the background - you can close this dialog and come back to it.")}</p>
|
|
<div class="wd-prog-bg" style="margin-bottom:8px"><div class="wd-prog-fill" style="width:${k??0}%"></div></div>
|
|
<p class="wd-info">${k==null?this._t("status.preparing",{},"Preparing\u2026"):`${k}%`}</p>
|
|
${s}
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="hist-cancel-scan">${this._t("btn.cancel",{},"Cancel")}</button>
|
|
</div>`}if(t.step==="done"){const $=t.done||{},k=[this._t("msg.hist_imported_count",{n:$.imported||0},`${$.imported||0} cycles imported.`),$.duplicates?this._t("msg.hist_duplicates",{n:$.duplicates},`${$.duplicates} were already imported and were skipped.`):"",$.capped?this._t("msg.hist_capped",{},"The per-device limit for imported cycles was reached; the rest were not stored."):""].filter(Boolean);return`${e}
|
|
${k.map(S=>`<p class="wd-info">${d(S)}</p>`).join("")}
|
|
<p class="wd-info">${this._t("msg.hist_next_step",{},"They are in your Cycles list, tagged as imported history. Open one and use Label to name the program it belongs to.")}</p>
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="hist-goto-cycles">${this._t("btn.hist_goto_cycles",{},"Show me the cycles")}</button>
|
|
</div>`}const r=t.result||{},n=r.segments||[],a=r.parse||{},o=this._busy.has("hist-apply"),i=t.accept||new Set,l=[];a.rows_total&&l.push(this._t("msg.hist_rows_read",{n:a.rows_total},`${a.rows_total} readings read`)),a.first&&a.last&&l.push(`${st(a.first)} \u2013 ${st(a.last)}`),a.breaks&&l.push(this._t("msg.hist_breaks",{n:a.breaks},`${a.breaks} gaps where the sensor was unavailable`)),a.rows_other_entity&&l.push(this._t("msg.hist_other_entity",{n:a.rows_other_entity},`${a.rows_other_entity} readings for other entities ignored`)),a.entity_substituted_from&&l.push(this._t("msg.hist_entity_substituted",{used:a.entity_id||"?",wanted:a.entity_substituted_from},`read ${a.entity_id||"?"} (this device is configured for ${a.entity_substituted_from})`));const c=r.skipped||[],_={};c.forEach($=>{_[$.reason]=(_[$.reason]||0)+1});const p=Object.entries(_).map(([$,k])=>`${k} \xD7 ${this._histSkipReason($)}`).join(", "),g=r.settings||{},u=g.min_power!=null?this._t("msg.hist_settings_used",{w:g.min_power,s:g.off_delay},`Detected using this device's current settings (minimum power ${g.min_power} W, off delay ${g.off_delay} s).`):"";if(!n.length)return`${e}
|
|
<p class="wd-info">${this._t("msg.hist_none_found",{},"No cycles could be detected in that history.")}</p>
|
|
${l.length?`<p class="wd-info">${d(l.join(" \xB7 "))}</p>`:""}
|
|
${p?`<p class="wd-info">${this._t("msg.hist_skipped_spans",{},"Skipped stretches")}: ${d(p)}</p>`:""}
|
|
${u?`<p class="wd-info">${d(u)}</p>`:""}
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="hist-back">${this._t("btn.back",{},"Back")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button>
|
|
</div>`;const v=n.map($=>{const k=i.has($.index),S=this._histSegReason($.reason);return`<tr data-hist-row="${$.index}" style="${k?"":"opacity:.55"}">
|
|
<td><input type="checkbox" data-hist-pick="${$.index}" ${k?"checked":""} aria-label="${d(this._t("lbl.hist_keep",{},"Keep this cycle"))}"></td>
|
|
<td>${d(st($.start_time))}</td>
|
|
<td>${d(ot($.duration_s))}</td>
|
|
<td>${$.energy_wh!=null?d(($.energy_wh/1e3).toFixed(2))+" kWh":"\u2013"}</td>
|
|
<td>${d(String(Math.round($.peak_w)))} W</td>
|
|
<td><canvas class="wd-prof-spark" data-hist-spark="${$.index}" width="64" height="20" aria-hidden="true"></canvas></td>
|
|
<td>${S?`<span title="${d(S)}" style="color:var(--warning-color,#ff9800)">\u26A0</span> <span class="wd-info">${d(S)}</span>`:`<span class="wd-info">${d(this._t("lbl.hist_looks_complete",{},"complete"))}</span>`}</td>
|
|
</tr>`}).join(""),b=n.every($=>i.has($.index));return`${e}
|
|
<p class="wd-info" style="margin-bottom:8px">${this._t("msg.hist_found",{n:n.length},`Found ${n.length} cycles. Untick anything that does not look like a real run - nothing is stored until you import.`)}</p>
|
|
${l.length?`<p class="wd-info" style="margin-bottom:4px">${d(l.join(" \xB7 "))}</p>`:""}
|
|
${p?`<p class="wd-info" style="margin-bottom:4px">${this._t("msg.hist_skipped_spans",{},"Skipped stretches")}: ${d(p)}</p>`:""}
|
|
${u?`<p class="wd-info" style="margin-bottom:12px">${d(u)}</p>`:""}
|
|
<div style="margin-bottom:8px"><button class="wd-btn wd-btn-secondary wd-btn-sm" data-maction="hist-toggle-all">${b?this._t("btn.select_none",{},"Select none"):this._t("btn.select_all",{},"Select all")}</button></div>
|
|
<div class="wd-table-wrap">
|
|
<table class="wd-table"><thead><tr>
|
|
<th></th>
|
|
<th>${this._t("lbl.date",{},"Date")}</th>
|
|
<th>${this._t("lbl.duration",{},"Duration")}</th>
|
|
<th>${this._t("lbl.energy",{},"Energy")}</th>
|
|
<th>${this._t("lbl.peak_power_short",{},"Peak")}</th>
|
|
<th>${this._t("lbl.shape",{},"Shape")}</th>
|
|
<th>${this._t("lbl.notes",{},"Notes")}</th>
|
|
</tr></thead><tbody>${v}</tbody></table>
|
|
</div>
|
|
${r.capped?`<p class="wd-info">${this._t("msg.hist_scan_capped",{n:r.found},`Only the first candidates are shown (${r.found} were found).`)}</p>`:""}
|
|
${s}
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="hist-back" ${o?"disabled":""}>${this._t("btn.back",{},"Back")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="hist-apply" ${o||!i.size?"disabled":""}>${o?'<span class="wd-spin"></span> ':""}${this._t("btn.hist_import_n",{n:i.size},`Import ${i.size} cycles`)}</button>
|
|
</div>`}_drawHistorySparklines(){const t=this.shadowRoot;if(!t)return;const e=this._modal,s=e&&e.result&&e.result.segments||[],r={};s.forEach(n=>{r[String(n.index)]=n.curve||[]}),t.querySelectorAll("canvas[data-hist-spark]").forEach(n=>{this._paintSparkline(n,r[n.dataset.histSpark]||[])})}_htmlCycleModal(t){if(!t.loaded)return`<h2>${this._t("modal.cycle",{},"Cycle")}</h2><div class="wd-empty" style="padding:32px"><div class="wd-icon">\u23F3</div>${this._t("msg.loading_curve",{},"Loading curve\u2026")}</div>
|
|
<div class="wd-modal-actions"><button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button></div>`;const e=t.curve||{},s=!!e.is_reference,r=e.labelable!==!1,n=e.editable!==!1,a=e.cycle_origin==="backfill",o=e.full_duration_s||e.duration||0,i=e.energy_kwh!=null?e.energy_kwh:null,l=t.ml||null;let c="";if(l&&l.ml_quality_score!=null){const E=l.ml_quality_label,M=E==="ok"?"var(--success-color,#4caf50)":E==="uncertain"?"var(--warning-color,#ff9800)":"var(--error-color,#f44336)",H=Math.round((1-l.ml_quality_score)*100);c=`<div class="wd-kv-item"><div class="wd-kv-val" style="font-size:.95em;color:${M}">${H}%</div><div class="wd-kv-lbl">${this._t("lbl.cycle_health",{},"Cycle health")}</div></div>`}const _=`<div class="wd-kv">
|
|
<div class="wd-kv-item"><div class="wd-kv-val">${ot(e.duration||o)}</div><div class="wd-kv-lbl">${this._t("lbl.duration",{},"Duration")}</div></div>
|
|
<div class="wd-kv-item"><div class="wd-kv-val">${gt(i)}</div><div class="wd-kv-lbl">${this._t("lbl.energy",{},"Energy")}</div></div>
|
|
<div class="wd-kv-item"><div class="wd-kv-val" style="font-size:.95em">${d(e.profile_name||this._t("lbl.unlabelled",{},"Unlabelled"))}</div><div class="wd-kv-lbl">${this._t("lbl.profile",{},"Profile")}</div></div>
|
|
<div class="wd-kv-item"><div class="wd-kv-val" style="font-size:.95em">${d(e.status||"-")}</div><div class="wd-kv-lbl">${this._t("lbl.status",{},"Status")}</div></div>
|
|
${c}
|
|
</div>`,p=l&&l.ml_review||{},g=(this._feedbacks||[]).some(E=>E.cycle_id===t.cycleId),u=l&&l.ml_quality_label,v=!p.reviewed_at&&(g||["uncertain","review"].includes(u)||["force_stopped","interrupted"].includes(e.status)),b=v&&t.mode!=="review"?` <span title="${this._t("hdr.automation_needs_review",{},"This cycle needs review")}" style="color:var(--warning-color,#ff9800);font-size:1.1em;line-height:0">\u25CF</span>`:"",$=this._canEdit()&&n?`<div class="wd-mode-bar">
|
|
<button class="wd-btn wd-btn-sm ${t.mode==="view"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="cyc-view">${this._t("btn.inspect",{},"Inspect")}</button>
|
|
<button class="wd-btn wd-btn-sm ${t.mode==="trim"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="cyc-trim">${this._t("btn.trim",{},"Trim")}</button>
|
|
<button class="wd-btn wd-btn-sm ${t.mode==="split"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="cyc-split">${this._t("btn.split",{},"Split")}</button>
|
|
<button class="wd-btn wd-btn-sm ${t.mode==="review"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="cyc-review" title="${v?this._t("hdr.automation_needs_review",{},"This cycle needs review"):this._t("hdr.automation_review_this_cycle",{},"Review this cycle")}">${this._t("btn.review",{},"Review")}${b}</button>
|
|
</div>`:s?`<div class="wd-info" style="margin:0 0 8px"><span style="color:var(--info-color,#2196f3)">\u{1F4E5}</span> ${a?this._t("msg.imported_history_readonly",{},"Detected in imported power history. It shapes program matching but is not counted in your statistics, and cannot be trimmed or split. Label it to name the program."):this._t("msg.imported_readonly",{},"Imported from the community store. Shown for reference and matching. It is not counted in your stats and cannot be edited.")}</div>`:"",k=this._canEdit()?(this._feedbacks||[]).find(E=>E.cycle_id===t.cycleId):null,S=k?k.detected_profile||k.profile_name||this._t("lbl.unknown",{},"Unknown"):"",C=k?`
|
|
<div class="wd-card" style="background:var(--secondary-background-color);border-left:3px solid var(--warning-color,#ff9800);margin:0 0 12px;padding:12px">
|
|
<div style="font-weight:600;margin-bottom:4px">\u26A0 ${this._t("msg.pending_feedback",{},"Pending detection feedback")}</div>
|
|
<p class="wd-info" style="margin:0 0 8px">${this._t("msg.unsure_detected_prefix",{},"WashData is unsure it detected")} <strong>${d(S)}</strong>${k.confidence!=null?` (${this._t("lbl.confidence",{},"confidence").toLowerCase()} ${(k.confidence*100).toFixed(0)}%)`:""}. ${this._t("msg.feedback_prompt",{},"Confirm it was right, correct the program, or ignore.")} ${this._t("msg.feedback_relabel_hint",{},"Re-labelling this cycle resolves it too.")}</p>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
|
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="fb-confirm" data-cid="${d(t.cycleId)}">${this._t("btn.confirm",{},"Confirm")}</button>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-correct" data-cid="${d(t.cycleId)}" data-prof="${d(S)}">${this._t("btn.correct",{},"Correct\u2026")}</button>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-ignore" data-cid="${d(t.cycleId)}">${this._t("btn.ignore",{},"Ignore")}</button>
|
|
</div>
|
|
</div>`:"";let R="";if(t.mode==="view"){const E=!!(l&&l.ml_review&&l.ml_review.golden),H=this._canEdit()&&this._onlineEnabled()&&this._storeConnected&&E?`<button class="wd-btn wd-btn-secondary" data-action="store-share-cycle" data-cid="${d(t.cycleId)}" data-prof="${d(e.profile_name||"")}">${this._t("btn.share_to_store",{},"Share to store")}</button>`:"",q=this._canEdit()?`<button class="wd-btn wd-btn-danger" data-maction="cyc-delete">${this._t("btn.delete",{},"Delete")}</button>${r?`
|
|
<button class="wd-btn wd-btn-primary" data-maction="cyc-label">${this._t("btn.label",{},"Label")}</button>`:""}`:"";R=`${C}<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button>
|
|
${H}
|
|
${q}</div>`}else if(t.mode==="trim"){const E=this._busy.has("cyc-trim-apply"),M=t.timeMode||"s",H=M==="clock"?this._offsetToClock(t.trim.start):Math.round(t.trim.start),q=M==="clock"?this._offsetToClock(t.trim.end):Math.round(t.trim.end),Q=M==="clock"?"time":"number",J=M==="clock"?'step="1"':`min="0" max="${Math.ceil(o)}" step="1"`,G=M==="clock"?"":" "+this._t("lbl.unit_s",{},"(s)");R=`<p class="wd-info" style="margin:4px 0 8px">${this._t("msg.trim_intro",{},"Drag the red handles, or enter values. Everything outside the window is removed.")}</p>
|
|
<div class="wd-mode-bar" style="margin-bottom:8px;align-items:center">
|
|
<span class="wd-info" style="margin:0">${this._t("lbl.input",{},"Input:")}</span>
|
|
<button class="wd-btn wd-btn-sm ${M==="s"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="trim-mode-s">${this._t("lbl.seconds_from_start",{},"Seconds from start")}</button>
|
|
<button class="wd-btn wd-btn-sm ${M==="clock"?"wd-btn-primary":"wd-btn-secondary"}" data-maction="trim-mode-clock">${this._t("lbl.clock_time",{},"Clock time")}</button>
|
|
</div>
|
|
<div class="wd-form-grid">
|
|
<div class="wd-field"><label>${this._t("lbl.start",{},"Start")}${G}</label><input type="${Q}" id="wd-trim-start" ${J} value="${H}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.end",{},"End")}${G}</label><input type="${Q}" id="wd-trim-end" ${J} value="${q}"></div>
|
|
</div>
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button>
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cyc-reset-trim">${this._t("btn.reset",{},"Reset")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="cyc-apply-trim" ${E?"disabled":""}>${E?'<span class="wd-spin"></span> '+this._t("status.trimming",{},"Trimming\u2026"):this._t("btn.apply_trim",{},"Apply Trim")}</button>
|
|
</div>`}else if(t.mode==="split"){const E=this._busy.has("cyc-split-apply"),M=(t.split.offsets||[]).slice().sort((Q,J)=>Q-J),H=[0,...M,o],q=H.slice(0,-1).map((Q,J)=>{const G=H[J+1];return`<div class="wd-seg-row"><span class="wd-swatch" style="background:${et[J%et.length]}"></span>
|
|
<span style="min-width:120px">${ot(Q)} \u2013 ${ot(G)}</span>
|
|
<select data-segidx="${J}"><option value="">${this._t("lbl.unlabelled_paren",{},"(unlabelled)")}</option>${this._profileOptions(t.split.profiles[J])}</select></div>`}).join("");R=`<p class="wd-info" style="margin:4px 0 8px">${this._t("msg.split_intro",{},"Click the graph to add or remove a split point, or auto-detect by idle gaps. Each resulting segment can get its own profile.")}</p>
|
|
<div class="wd-mode-bar">
|
|
<div class="wd-field" style="margin:0;display:flex;align-items:center;gap:6px"><label style="margin:0;text-transform:none;letter-spacing:0">${this._t("lbl.gap_s",{},"Gap (s)")}</label><input type="number" id="wd-split-gap" value="900" min="30" step="30" style="width:80px"></div>
|
|
<button class="wd-btn wd-btn-sm wd-btn-secondary" data-maction="cyc-auto-split">${this._t("btn.auto_detect_split",{},"Auto-detect")}</button>
|
|
<button class="wd-btn wd-btn-sm wd-btn-secondary" data-maction="cyc-clear-split">${this._t("btn.clear_splits",{},"Clear")}</button>
|
|
</div>
|
|
<div style="margin:10px 0">${M.length?q:`<p class="wd-info">${this._t("msg.no_split_points",{},"No split points yet.")}</p>`}</div>
|
|
<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="cyc-apply-split" ${E||!M.length?"disabled":""}>${E?'<span class="wd-spin"></span> '+this._t("status.splitting",{},"Splitting\u2026"):this._t("btn.apply_split",{},"Apply Split")}</button>
|
|
</div>`}else if(t.mode==="review"){const E=l&&l.ml_review||{},M=this._busy.has("cyc-review-save"),H=(f,m)=>`<option value="${f}" ${(E.quality||"")===f?"selected":""}>${m}</option>`,Q=[["late_start",this._t("tag.late_start",{},"Late start")],["early_end",this._t("tag.early_end",{},"Early end")],["merged",this._t("tag.merged",{},"Merged cycles")],["split",this._t("tag.split",{},"Split cycle")],["noise",this._t("tag.noise",{},"Noise")],["wrong_profile",this._t("tag.wrong_profile",{},"Wrong profile")],["sensor_gap",this._t("tag.sensor_gap",{},"Sensor gap")]].map(([f,m])=>`<label class="wd-rev-tag"><input type="checkbox" class="wd-cyc-rev-tag" value="${f}" ${(E.tags||[]).includes(f)?"checked":""}> ${m}</label>`).join(""),J=E.reviewed_at?`<span style="font-size:.75em;color:var(--secondary-text-color)">${this._t("lbl.reviewed_on",{date:new Date(E.reviewed_at).toLocaleDateString()},`reviewed ${new Date(E.reviewed_at).toLocaleDateString()}`)}</span>`:"",G=it(this._t("msg.review_profile_tip",{},"The program this cycle is labelled as. If the auto-detected program was wrong, correct it here - labelling teaches matching for future cycles.")),X=it(this._t("msg.review_quality_tip",{},"How clean this cycle is. Good = a textbook example of this program; Bad = detected but noisy or atypical; Unusable = mis-detected (merged, truncated or spurious). Drives the health score and which cycles are allowed to train the model.")),ct=it(this._t("msg.review_recorded_tip",{},'Mark this as a hand-picked reference cycle for its program - the same role as a manually recorded cycle. Reference cycles are always kept, seed the matching template, and are never dropped by cleanup. (This is the "golden"/recorded flag; both are the same thing.)')),U=it(this._t("msg.review_tags_tip",{},"Optional flags describing what went wrong with this cycle, so training and cleanup can account for it.")),at=it(this._t("msg.review_notes_tip",{},"Free-text notes for your own reference. Not used by matching or training."));R=`
|
|
${C}
|
|
<p style="font-size:.82em;color:var(--secondary-text-color);margin:8px 0 12px">
|
|
${this._t("msg.review_confirm_help",{},"Confirm whether this cycle was detected correctly. Your reviews train the model on your machine - the more cycles you confirm, the better matching and health scoring get. A quick Good/Bad is enough.")}
|
|
</p>
|
|
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin:6px 0">
|
|
<label style="display:inline-flex;align-items:center;gap:6px">${this._t("lbl.profile",{},"Profile")}${G}
|
|
<select id="wd-cyc-rev-label" class="wd-filter-select"><option value="">${this._t("lbl.unlabelled_paren",{},"(unlabelled)")}</option>${this._profileOptions(e.profile_name)}</select>
|
|
</label>
|
|
<label style="display:inline-flex;align-items:center;gap:6px">${this._t("lbl.quality",{},"Quality")}${X}
|
|
<select id="wd-cyc-rev-quality" class="wd-filter-select">${H("","-")}${H("good",this._t("quality.good",{},"Good"))}${H("bad",this._t("quality.bad",{},"Bad"))}${H("unusable",this._t("quality.unusable",{},"Unusable"))}</select>
|
|
</label>
|
|
<label style="display:inline-flex;align-items:center;gap:6px"><input type="checkbox" id="wd-cyc-rev-golden" ${E.golden?"checked":""}> ${this._t("badge.golden_cycle",{},"Recorded reference cycle")}${ct}</label>
|
|
${J}
|
|
</div>
|
|
<div class="wd-rev-sub">${this._t("lbl.compare_profiles",{},"Compare with profiles")}${it(this._t("msg.compare_profiles_tip",{},"Overlay other profile envelopes on the chart above to see which one best fits this cycle."))}</div>
|
|
<div class="wd-rev-tags">${(this._profiles||[]).map(f=>{const m=(t.overlays||[]).includes(f.name),x=m?`<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${et[Math.max(0,this._profiles.findIndex(L=>L.name===f.name))%et.length]};margin:0 2px"></span>`:"";return`<label class="wd-rev-tag"><input type="checkbox" class="wd-cyc-overlay" value="${d(f.name)}" ${m?"checked":""}> ${x}${d(f.name)}</label>`}).join("")||`<span class="wd-info">${this._t("msg.no_profiles_compare",{},"No profiles to compare.")}</span>`}</div>
|
|
<div class="wd-rev-sub">${this._t("lbl.tags",{},"Tags")}${U}</div>
|
|
<div class="wd-rev-tags">${Q}</div>
|
|
<div class="wd-rev-sub">${this._t("lbl.notes",{},"Notes")}${at}</div>
|
|
<textarea id="wd-cyc-rev-notes" class="wd-rev-notes" rows="3" placeholder="${this._t("msg.review_notes_placeholder",{},"Notes (optional)")}">${d(E.notes||"")}</textarea>
|
|
<div class="wd-modal-actions" style="margin-top:16px">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button>
|
|
<button class="wd-btn wd-btn-primary" data-maction="cyc-review-save" ${M?"disabled":""}>${M?'<span class="wd-spin"></span> '+this._t("status.saving",{},"Saving\u2026"):this._t("btn.save_review",{},"Save Review")}</button>
|
|
</div>`}let B="";const W=t.mode==="view"||t.mode==="review"?e.artifacts||[]:[];if(W.length){const E=W.map(M=>{const H=M.detail_key?this._t(M.detail_key,M.detail_params||{},M.detail||""):M.detail||"";return`<li><b>${d(Zt(M.type,(q,Q,J)=>this._t(q,Q,J)))}</b> ${this._t("lbl.at",{},"at")} ${Mt(M.start_s)}\u2013${Mt(M.end_s)} \u2014 ${d(H)}</li>`}).join("");B=`<div class="wd-card" style="margin:10px 0 0;padding:10px 12px;border-left:3px solid var(--warning-color,#ff9800)">
|
|
<div style="font-weight:600;font-size:.9em">\u26A0 ${this._t("msg.artifact_header",{n:W.length},`${W.length} anomal${W.length>1?"ies":"y"} detected during this cycle`)}</div>
|
|
<ul class="wd-info" style="margin:4px 0 0;padding-left:18px;font-size:.82em">${E}</ul>
|
|
<div class="wd-info" style="margin:6px 0 0;font-size:.75em">${this._t("msg.artifact_footer",{},"Highlighted on the graph above. These are transient artifacts (e.g. the door opened mid-cycle), not necessarily problems.")}</div>
|
|
</div>`}let O="";const D=t.mode==="view"||t.mode==="review"?e.restart_gaps||[]:[];if(D.length){const E=D.map(M=>{const H=Math.round((M.gap_seconds||0)/60),q=H>=1?`${H}m`:`${Math.round(M.gap_seconds||0)}s`,Q=M.match_confidence!=null?` \xB7 ${this._t("lbl.pct_match_confidence",{pct:Math.round(M.match_confidence*100)},`${Math.round(M.match_confidence*100)}% match confidence`)}`:"",J=M.profile?` (${d(M.profile)})`:"";return`<li>${this._t("msg.restart_gap_item",{dur:q},`${q} gap`)}: ${this._t("lbl.ha_restarted",{},"HA restarted")}${J}${Q}</li>`}).join("");O=`<div class="wd-card" style="margin:10px 0 0;padding:10px 12px;border-left:3px solid var(--info-color,#2196f3)">
|
|
<div style="font-weight:600;font-size:.9em">\u21BB ${this._t("msg.restart_gap_header",{n:D.length},`${D.length} HA restart gap${D.length>1?"s":""} during this cycle`)}</div>
|
|
<ul class="wd-info" style="margin:4px 0 0;padding-left:18px;font-size:.82em">${E}</ul>
|
|
<div class="wd-info" style="margin:6px 0 0;font-size:.75em">${this._t("msg.restart_gap_footer",{},"Highlighted on the graph. Power data is missing for these intervals \u2014 matching used only real readings.")}</div>
|
|
</div>`}let A="";if(e.decimated){const E=(e.samples||[]).length,M=e.sample_count||E;A=`<div class="wd-info" style="margin:4px 0 0;font-size:.72em;color:var(--secondary-text-color)">${this._t("msg.samples_decimated",{shown:E,total:M},`Showing ${E} of ${M} samples (thinned for display; peaks kept). A wide gap here is thinning, not missing data.`)}</div>`}return`<h2>${this._t("lbl.cycle",{},"Cycle")} \xB7 ${d(st(e.start_time))}</h2>
|
|
${_}${$}
|
|
<div class="wd-canvas-wrap"><canvas id="wd-cyc-canvas" role="img" aria-label="${d(this._t("lbl.aria_cycle_chart",{},"Cycle power trace"))}"></canvas></div>
|
|
${A}
|
|
${B}
|
|
${O}
|
|
${R}`}_htmlProfilePanel(t){const e=this._canEdit();t.tab==="danger"&&!e&&(t.tab="stats");const s=[["stats",this._t("tab.pp_overview",{},"Overview")],["phases",this._t("tab.pp_phases",{},"Phases")],["cleanup",this._t("tab.pp_cleanup",{},"Cleanup")]];e&&s.push(["danger",this._t("tab.pp_manage",{},"Manage")]);const r=s.map(([o,i])=>`<button class="wd-mini-tab ${t.tab===o?"active":""}" data-maction="pp-tab-${o}">${i}</button>`).join("");let n="";if(!t.loaded)n=`<div class="wd-empty" style="padding:32px"><div class="wd-icon">\u23F3</div>${this._t("msg.loading",{},"Loading\u2026")}</div>`;else if(t.tab==="stats"){const o=t.stats||{},i=t.env||{},l=this._hass&&this._hass.config&&this._hass.config.currency||"",c=o.avg_energy!=null&&o.cycle_count?o.avg_energy*o.cycle_count:null,_=b=>b?Math.round(b/60)+"m":"-",p=(this._profileHealth||{})[t.name],g=(this._profileTrends||{})[t.name],u=p&&p.health_status!=="unknown"?(()=>{const b={healthy:["var(--success-color,#4caf50)","rgba(76,175,80,.12)"],fair:["var(--warning-color,#ff9800)","rgba(255,152,0,.12)"],poor:["var(--error-color,#f44336)","rgba(244,67,54,.12)"]},[$,k]=b[p.health_status]||b.fair,S=Math.round((p.health_score||0)*100),C=p.duration_cv!=null?` \xB7 ${this._t("stat.duration_cv",{pct:Math.round(p.duration_cv*100)},`duration CV ${Math.round(p.duration_cv*100)}%`)}`:"",R=p.confidence_mean!=null?` \xB7 ${this._t("stat.avg_confidence",{pct:Math.round(p.confidence_mean*100)},`avg confidence ${Math.round(p.confidence_mean*100)}%`)}`:"";return`<div style="margin:8px 0 4px;padding:8px 12px;border-radius:6px;background:${k};border:1px solid color-mix(in srgb, ${$} 13%, transparent);display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
<span style="font-weight:600;color:${$}">${p.health_status==="poor"?this._t("health.poor",{},"\u26A0 Poor match fit"):p.health_status==="fair"?this._t("health.fair",{},"Fair match fit"):this._t("health.good",{},"\u2713 Good match fit")}</span>
|
|
<span style="font-size:.85em;opacity:.8">${this._t("stat.score",{pct:S},`score ${S}%`)}${C}${R}</span>
|
|
${p.health_status==="poor"?`<span style="font-size:.82em;opacity:.75;flex-basis:100%">${this._t("msg.profile_poor_health_detail",{},"Cycles assigned to this profile have inconsistent shapes or low confidence. Consider rebuilding the envelope or reviewing labelled cycles.")}</span>`:""}
|
|
</div>`})():"",v=g&&(g.duration_trend!=="stable"||g.energy_trend&&g.energy_trend!=="stable")?(()=>{const b=[];g.duration_trend==="up"?b.push(this._t("msg.trend_duration_longer",{pct:`${g.duration_slope_pct>0?"+":""}${g.duration_slope_pct}`,avg:`${Math.round(g.duration_recent_mean_s/60)}m`},`Duration trending longer (${g.duration_slope_pct>0?"+":""}${g.duration_slope_pct}%/cycle) \u2014 recent avg ${Math.round(g.duration_recent_mean_s/60)}m`)):g.duration_trend==="down"&&b.push(this._t("msg.trend_duration_shorter",{pct:`${g.duration_slope_pct}`,avg:`${Math.round(g.duration_recent_mean_s/60)}m`},`Duration trending shorter (${g.duration_slope_pct}%/cycle) \u2014 recent avg ${Math.round(g.duration_recent_mean_s/60)}m`)),g.energy_trend==="up"?b.push(this._t("msg.trend_energy_up",{pct:`${g.energy_slope_pct>0?"+":""}${g.energy_slope_pct}`,avg:gt(g.energy_recent_mean_wh)},`Energy trending up (${g.energy_slope_pct>0?"+":""}${g.energy_slope_pct}%/cycle) \u2014 recent avg ${gt(g.energy_recent_mean_wh)}`)):g.energy_trend==="down"&&b.push(this._t("msg.trend_energy_down",{pct:`${g.energy_slope_pct}`},`Energy trending down (${g.energy_slope_pct}%/cycle)`));const $=g.duration_trend==="up"||g.energy_trend==="up";return`<div style="margin:6px 0;padding:8px 12px;border-radius:6px;background:${$?"rgba(255,152,0,.10)":"rgba(33,150,243,.10)"};font-size:.88em">
|
|
<span style="font-weight:600;color:${$?"var(--warning-color,#ff9800)":"var(--info-color,#2196f3)"}">${this._t("msg.performance_trend",{n:g.cycle_count},`Performance trend (${g.cycle_count} cycles)`)}</span><br>
|
|
${b.map(C=>`<span>${C}</span>`).join("<br>")}
|
|
${$?`<br><span style="opacity:.75">${this._t("msg.maintenance_advisory",{},"Increasing duration/energy may indicate appliance maintenance needed (e.g. descaling, filter cleaning).")}</span>`:""}
|
|
</div>`})():"";n=`<div class="wd-sg-row">
|
|
<div class="wd-sg">
|
|
<div class="wd-sg-h">${this._t("lbl.duration",{},"Duration")}</div>
|
|
<div class="wd-sg-main">${_(o.avg_duration)}<span>${this._t("stat.avg",{},"avg")}</span></div>
|
|
<div class="wd-sg-sub">${this._t("stat.min",{v:_(o.min_duration)},`min ${_(o.min_duration)}`)} \xB7 ${this._t("stat.max",{v:_(o.max_duration)},`max ${_(o.max_duration)}`)}${i.duration_std_dev!=null?` \xB7 ${this._t("stat.consistency",{v:`${Math.round(i.duration_std_dev/60)}m`},`consistency \xB1${Math.round(i.duration_std_dev/60)}m`)}`:""}</div>
|
|
</div>
|
|
<div class="wd-sg">
|
|
<div class="wd-sg-h">${this._t("lbl.energy",{},"Energy")}</div>
|
|
<div class="wd-sg-main">${gt(o.avg_energy)}<span>${this._t("stat.avg",{},"avg")}</span></div>
|
|
<div class="wd-sg-sub">${this._t("stat.total",{v:gt(c)},`total ${gt(c)}`)}</div>
|
|
</div>
|
|
${o.avg_cost!=null?`<div class="wd-sg">
|
|
<div class="wd-sg-h">${this._t("lbl.avg_cost",{},"Avg cost")}</div>
|
|
<div class="wd-sg-main">${o.avg_cost.toFixed(2)}${l?" "+l:""}<span>${this._t("stat.avg",{},"avg")}</span></div>
|
|
<div class="wd-sg-sub">${this._t("stat.total",{v:o.total_cost!=null?o.total_cost.toFixed(2)+(l?" "+l:""):"-"},`total ${o.total_cost!=null?o.total_cost.toFixed(2)+(l?" "+l:""):"-"}`)}</div>
|
|
</div>`:""}
|
|
<div class="wd-sg">
|
|
<div class="wd-sg-h">${this._t("lbl.activity",{},"Activity")}</div>
|
|
<div class="wd-sg-main">${o.cycle_count||0}<span>${this._t("lbl.cycles_lc",{},"cycles")}</span></div>
|
|
<div class="wd-sg-sub">${this._t("stat.last_run",{v:o.last_run?st(o.last_run):"-"},`last run ${o.last_run?st(o.last_run):"-"}`)}</div>
|
|
</div>
|
|
</div>
|
|
${u}
|
|
${v}
|
|
${p&&p.shape_drift?(()=>{const b=p.shape_drift_correlation!=null?` (r=${Number(p.shape_drift_correlation).toFixed(2)})`:"";return`<div style="margin-top:8px;padding:8px 10px;background:color-mix(in srgb, var(--warning-color,#ff9800) 9%, transparent);border-radius:6px;border-left:3px solid var(--warning-color,#ff9800)">
|
|
<span style="font-weight:600;color:var(--warning-color,#ff9800)">${this._t("msg.shape_drift_advisory",{},"\u26A0 Shape drifting")}${d(b)}</span>
|
|
<span style="font-size:.82em;opacity:.75;display:block;margin-top:4px">${this._t("msg.shape_drift_detail",{},"The power pattern for this profile has shifted over time \u2014 possible appliance wear or maintenance needed (e.g. descaling, filter cleaning).")}</span>
|
|
</div>`})():""}
|
|
${(()=>{const b=(this._profileAdvisories||[]).find($=>$&&$.profile===t.name&&$.code==="phase_inconsistent");return b?`<div style="margin-top:8px;padding:8px 10px;background:color-mix(in srgb, var(--warning-color,#ff9800) 9%, transparent);border-radius:6px;border-left:3px solid var(--warning-color,#ff9800)">
|
|
<span style="font-weight:600;color:var(--warning-color,#ff9800)">${this._t("msg.advisory_phase_inconsistent_title",{},"\u26A0 Possibly mixed programs")}</span>
|
|
<span style="font-size:.82em;opacity:.85;display:block;margin-top:4px">${d(this._t(b.message_key,b.message_params,b.message))}</span>
|
|
</div>`:""})()}
|
|
${i.avg&&i.avg.length?`<div class="wd-canvas-wrap"><canvas id="wd-env-canvas" role="img" aria-label="${d(this._t("lbl.aria_envelope_chart",{},"Profile power envelope chart"))}"></canvas></div>`:`<p class="wd-info">${this._t("msg.no_envelope",{},"No envelope yet - rebuild after labelling cycles.")}</p>`}`}else if(t.tab==="phases"){const o=t.catalog||[],i=(t.phases||[]).map((c,_)=>{const p=o.map(g=>`<option value="${d(g)}" ${c.name===g?"selected":""}>${d(g)}</option>`).join("");return`<div class="wd-phase-row"><span class="wd-swatch" style="background:${et[_%et.length]}"></span>
|
|
<select data-phidx="${_}" data-phfield="name" style="min-width:130px"><option value="">${this._t("lbl.name_placeholder",{},"(name)")}</option>${p}</select>
|
|
<input type="number" data-phidx="${_}" data-phfield="start" value="${(c.start/60).toFixed(1)}" step="0.5" min="0" style="width:80px"> \u2013
|
|
<input type="number" data-phidx="${_}" data-phfield="end" value="${(c.end/60).toFixed(1)}" step="0.5" min="0" style="width:80px"><span class="wd-field-hint" style="margin:0">${this._t("lbl.timer_min",{},"min")}</span>
|
|
<button class="wd-btn wd-btn-danger wd-btn-sm" data-maction="pp-phase-rm" data-idx="${_}">\u2715</button></div>`}).join(""),l=this._busy.has("pp-phase-save");n=`<p class="wd-info" style="margin-bottom:10px">${this._t("msg.phase_ranges_intro",{},"Phase ranges (minutes from cycle start) overlaid on the average curve. Edit values to preview live.")}</p>
|
|
${t.env&&t.env.avg&&t.env.avg.length?`<div class="wd-canvas-wrap"><canvas id="wd-phase-canvas" role="img" aria-label="${d(this._t("lbl.aria_phase_chart",{},"Phase editor chart"))}"></canvas></div>`:`<p class="wd-info">${this._t("msg.no_envelope_overlay",{},"No envelope available to overlay.")}</p>`}
|
|
<div style="margin:10px 0">${i||`<p class="wd-info">${this._t("msg.no_phases_assigned",{},"No phases assigned.")}</p>`}</div>
|
|
${e?`<div class="wd-mode-bar">
|
|
<button class="wd-btn wd-btn-sm wd-btn-secondary" data-maction="pp-phase-add">${this._t("btn.add_phase",{},"+ Add phase")}</button>
|
|
<button class="wd-btn wd-btn-sm wd-btn-primary" data-maction="pp-phase-save" ${l?"disabled":""}>${l?'<span class="wd-spin"></span> '+this._t("status.saving",{},"Saving\u2026"):this._t("btn.save_phases",{},"Save phases")}</button>
|
|
</div>`:""}`}else if(t.tab==="cleanup"){const o=t.cleanup&&t.cleanup.cycles||[],i=t.cleanup&&t.cleanup.selected||new Set,{col:l,dir:c}=this._cleanupSort,_={date:b=>b.start_time?new Date(b.start_time).getTime():0,duration:b=>b.duration,energy:b=>b.energy_kwh,status:b=>b.status||""},g=Qt(o,_[l]||_.date,c).map((b,$)=>{const k=o.indexOf(b),S=e?`<td style="padding:4px 6px 4px 2px;white-space:nowrap">
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cleanup-edit-cycle" data-cid="${d(b.cycle_id)}">${this._t("btn.trim_split",{},"Trim / Split")}</button>
|
|
</td>`:"";return`<tr data-cid="${d(b.cycle_id)}" style="cursor:pointer">
|
|
<td style="width:26px;padding:6px 4px"><input type="checkbox" data-cleanidx="${k}" ${i.has(b.cycle_id)?"checked":""}></td>
|
|
<td style="width:10px;padding:6px 2px"><span class="wd-swatch" style="background:${et[k%et.length]}"></span></td>
|
|
<td class="wd-tc-date">${st(b.start_time)}</td>
|
|
<td class="wd-tc-num">${ot(b.duration)}</td>
|
|
<td class="wd-tc-num">${b.energy_kwh!=null?gt(b.energy_kwh):"-"}</td>
|
|
<td><span class="wd-pill">${d(b.status||"completed")}</span></td>
|
|
${S}
|
|
</tr>`}).join(""),u=`<thead><tr>
|
|
<th style="width:26px;padding:6px 4px"></th><th style="width:10px;padding:6px 2px"></th>
|
|
${ft(this._t("lbl.date",{},"Date"),"date",l==="date",c,"cleanupsort")}
|
|
${ft(this._t("lbl.duration",{},"Duration"),"duration",l==="duration",c,"cleanupsort","right")}
|
|
${ft(this._t("lbl.energy",{},"Energy"),"energy",l==="energy",c,"cleanupsort","right")}
|
|
${ft(this._t("lbl.status",{},"Status"),"status",l==="status",c,"cleanupsort")}
|
|
${e?"<th></th>":""}
|
|
</tr></thead>`,v=this._busy.has("pp-cleanup-del");n=`<p class="wd-info" style="margin-bottom:10px">${this._t("msg.cleanup_intro",{},"Every labelled cycle overlaid. Tick outliers and delete to clean up the profile.")}</p>
|
|
${o.length?`<div class="wd-canvas-wrap"><canvas id="wd-spag-canvas" role="img" aria-label="${d(this._t("lbl.aria_spaghetti_chart",{},"Overlaid cycle power traces"))}"></canvas></div>`:`<p class="wd-info">${this._t("msg.no_cycles_profile",{},"No cycles for this profile.")}</p>`}
|
|
${o.length?`<div class="wd-table-wrap" style="max-height:420px;overflow:auto;margin:10px 0"><table class="wd-table">${u}<tbody>${g}</tbody></table></div>`:""}
|
|
${e?`<div class="wd-modal-actions"><button class="wd-btn wd-btn-danger" data-maction="pp-cleanup-del" ${v||i.size===0?"disabled":""}>${v?'<span class="wd-spin"></span> '+this._t("status.deleting",{},"Deleting\u2026"):this._t("btn.delete_selected",{n:i.size},`Delete selected (${i.size})`)}</button></div>`:""}`}else if(t.tab==="danger"){const o=this._busy.has("pp-rebuild"),i=t.stats&&t.stats.avg_duration?Math.round(t.stats.avg_duration/60):0;n=`<div class="wd-field"><label>${this._t("lbl.rename_profile",{},"Rename Profile")}</label><input type="text" id="wd-pp-rename" value="${d(t.name)}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.expected_duration",{},"Expected Duration (min)")}</label><input type="number" id="wd-pp-dur" min="0" max="600" value="${i}">
|
|
<div class="wd-field-hint">${this._t("msg.manual_duration_hint",{},"The profile's average/expected cycle length, used for time-remaining estimates. Edit to set it; leaving it unchanged keeps the current value.")}</div></div>
|
|
<div class="wd-card-actions">
|
|
<button class="wd-btn wd-btn-primary" data-maction="pp-rename">${this._t("btn.save",{},"Save")}</button>
|
|
<button class="wd-btn wd-btn-secondary" data-maction="pp-rebuild" ${o?"disabled":""}>${o?'<span class="wd-spin"></span> '+this._t("status.rebuilding",{},"Rebuilding\u2026"):this._t("btn.rebuild_envelope",{},"Rebuild Envelope")}</button>
|
|
<button class="wd-btn wd-btn-danger" data-maction="pp-delete">${this._t("btn.delete_profile",{},"Delete Profile")}</button>
|
|
</div>`}const a=this._onlineEnabled()&&this._storeDeviceDeclared()?`<button class="wd-btn wd-btn-ghost wd-btn-sm" type="button" data-action="store-share-profile" data-prog="${d(t.name)}" title="${d(this._t("btn.share_device_tip",{},"Share this appliance and its recorded reference cycles to the community store so others with the same machine can adopt them"))}">\u2B06 ${this._t("btn.share_to_store",{},"Share to store")}</button>`:"";return`<h2>Profile \xB7 ${d(t.name)}</h2>
|
|
<div class="wd-mini-tabs">${r}</div>
|
|
${n}
|
|
<div class="wd-modal-actions" style="margin-top:14px">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button>
|
|
${a}
|
|
</div>`}_drawCycleEditor(){const t=this._modal;if(!t||t.type!=="cycle-detail"||!t.loaded)return;const e=t.curve||{},s=e.samples||[];if(!s.length)return;let r=e.full_duration_s||s[s.length-1][0]||1;const n=[],a=t.profileEnv;if((t.mode==="view"||t.mode==="review")&&a&&(a.avg||[]).length&&(n.push({points:a.avg,stroke:"#ff9800",width:2,alpha:.45,name:`${this._t("lbl.expected",{},"Expected")} (${e.profile_name||"profile"})`}),r=Math.max(r,a.target_duration||a.avg[a.avg.length-1][0]||0)),t.mode==="review"&&(t.overlays||[]).length){const c=this._profileEnvCache||{};(t.overlays||[]).forEach(_=>{const p=c[_];if(!p||!(p.avg||[]).length)return;const g=et[Math.max(0,(this._profiles||[]).findIndex(v=>v.name===_))%et.length];n.push({points:p.avg,stroke:g,width:1.6,alpha:.7,name:_});const u=p.avg[p.avg.length-1];r=Math.max(r,p.target_duration||(u?u[0]:0))})}n.push({points:s,stroke:"primary",fill:!0,width:2,name:this._t("lbl.power",{},"Power")});const o=[],i=[];let l=[];if(t.mode==="trim"){const c=t.trim.start,_=t.trim.end;o.push({x0:0,x1:c,fill:"rgba(244,67,54,.18)"}),o.push({x0:_,x1:r,fill:"rgba(244,67,54,.18)"}),i.push({x:c,color:"#f44336",label:"S"},{x:_,color:"#f44336",label:"E"})}else if(t.mode==="split")(t.split.offsets||[]).slice().sort((c,_)=>c-_).forEach((c,_)=>i.push({x:c,color:"#ff9800",label:"#"+(_+1)}));else{l=e.artifacts||[];const c={pause:"rgba(255,152,0,.22)",dip:"rgba(33,150,243,.18)",spike:"rgba(244,67,54,.18)"};l.forEach(p=>o.push({x0:p.start_s,x1:Math.max(p.end_s,p.start_s+1),fill:c[p.type]||"rgba(158,158,158,.18)"}));const _=e.start_time;(e.restart_gaps||[]).forEach(p=>{if(!_)return;const g=new Date(_).getTime(),u=Math.max(0,(new Date(p.start_ts).getTime()-g)/1e3),v=Math.max(u+1,(new Date(p.end_ts).getTime()-g)/1e3);o.push({x0:u,x1:v,fill:"rgba(96,125,139,.20)",label:"\u21BB"})})}this._drawCurves("wd-cyc-canvas",{series:n,xMax:r,bands:o,vlines:i,artifacts:l})}_htmlCompareModal(t){const e=t.ids||[],s={};(this._cycles||[]).forEach(o=>{s[o.id]=o});const r=t.hidden||new Set,n=e.map((o,i)=>{const l=s[o]||{},c=et[i%et.length],_=!r.has(o),p=!!(t.cycles&&t.cycles[o]),g=`${st(l.start_time)||String(o).slice(0,8)} \xB7 ${Math.round((l.duration||0)/60)}m \xB7 ${d(l.profile_name||this._t("lbl.unlabelled",{},"Unlabelled"))}`;return`<label class="wd-rev-tag"><input type="checkbox" class="wd-compare-cyc" value="${d(o)}" ${_?"checked":""} ${p?"":"disabled"}><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${c};margin:0 2px"></span>${g}${p?"":` <span class="wd-info">${this._t("lbl.loading_paren",{},"(loading\u2026)")}</span>`}</label>`}).join(""),a=(this._profiles||[]).map(o=>{const i=(t.overlays||[]).includes(o.name),l=et[Math.max(0,(this._profiles||[]).findIndex(_=>_.name===o.name))%et.length],c=i?`<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${l};margin:0 2px;opacity:.55"></span>`:"";return`<label class="wd-rev-tag"><input type="checkbox" class="wd-compare-overlay" value="${d(o.name)}" ${i?"checked":""}> ${c}${d(o.name)}</label>`}).join("")||`<span class="wd-info">${this._t("msg.no_profiles_overlay",{},"No profiles to overlay.")}</span>`;return`<h2>${this._t("msg.compare_cycles_title",{count:e.length},`Compare ${e.length} cycles`)}</h2>
|
|
${t.loaded?"":`<div class="wd-info" style="margin-bottom:6px">${this._t("msg.loading",{},"Loading\u2026")}</div>`}
|
|
<div class="wd-canvas-wrap"><canvas id="wd-compare-canvas" role="img" aria-label="${d(this._t("lbl.aria_compare_chart",{},"Cycle comparison chart"))}"></canvas></div>
|
|
<div class="wd-rev-sub" style="margin-top:10px">${this._t("msg.compare_selected_cycles",{},"Selected cycles (solid) \u2014 show / hide")}</div>
|
|
<div class="wd-rev-tags">${n}</div>
|
|
<div class="wd-rev-sub">${this._t("msg.compare_overlay_profiles",{},"Overlay profiles (faint)")}${it(this._t("msg.compare_overlay_tip",{},"Overlay learned profile envelopes to see which program each cycle resembles."))}</div>
|
|
<div class="wd-rev-tags">${a}</div>
|
|
<div class="wd-modal-actions" style="margin-top:16px">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button>
|
|
</div>`}_drawCompareCanvas(){const t=this._modal;if(!t||t.type!=="compare-cycles")return;const e=t.ids||[],s={};(this._cycles||[]).forEach(i=>{s[i.id]=i});const r=t.hidden||new Set,n=[];let a=0;const o=this._profileEnvCache||{};(t.overlays||[]).forEach(i=>{const l=o[i];if(!l||!(l.avg||[]).length)return;const c=et[Math.max(0,(this._profiles||[]).findIndex(p=>p.name===i))%et.length];n.push({points:l.avg,stroke:c,width:2,alpha:.4,name:i});const _=l.avg[l.avg.length-1];a=Math.max(a,l.target_duration||(_?_[0]:0))}),e.forEach((i,l)=>{if(r.has(i))return;const c=t.cycles&&t.cycles[i],_=c&&c.samples;if(!_||!_.length)return;const p=et[l%et.length],g=s[i]||{};n.push({points:_,stroke:p,width:1.8,alpha:.9,name:st(g.start_time)||String(i).slice(0,8)}),a=Math.max(a,c.full_duration_s||_[_.length-1][0]||0)}),this._drawCurves("wd-compare-canvas",{series:n,xMax:a||1})}_drawProfileEnvelope(){const t=this._modal;if(!t||!t.env||!(t.env.avg||[]).length)return;const e=t.env;this._drawCurves("wd-env-canvas",{series:[{points:e.avg,stroke:"primary",width:2,name:"Average"}],band:{min:e.min,max:e.max},xMax:e.target_duration||e.avg[e.avg.length-1][0]})}_drawPhaseEditor(){const t=this._modal;if(!t||!t.env||!(t.env.avg||[]).length)return;const e=t.env,s=e.target_duration||e.avg[e.avg.length-1][0],r=(t.phases||[]).map((a,o)=>({x0:a.start,x1:a.end,fill:St(et[o%et.length],.2)})),n=[];(t.phases||[]).forEach((a,o)=>{const i=et[o%et.length];n.push({x:a.start,color:i,label:a.name?a.name.slice(0,7):"",handle:!0}),n.push({x:a.end,color:i,handle:!0})}),this._drawCurves("wd-phase-canvas",{series:[{points:e.avg,stroke:"primary",width:2,name:"Average"}],band:{min:e.min,max:e.max},bands:r,vlines:n,xMax:s})}_drawSpaghetti(){const t=this._modal;if(!t||!t.cleanup||!(t.cleanup.cycles||[]).length)return;const e=t.cleanup.cycles,s=t.cleanup.selected||new Set,r=this._spagTableHoverCid||null;let n=1;e.forEach(o=>{const i=o.samples||[];i.length&&i[i.length-1][0]>n&&(n=i[i.length-1][0])});const a=e.map((o,i)=>{const l=s.has(o.cycle_id),c=r===o.cycle_id;return{points:o.samples||[],stroke:et[i%et.length],width:l||c?2.6:1,alpha:s.size?l?1:.22:r?c?1:.22:.7,name:st(o.start_time),cid:o.cycle_id}});this._drawCurves("wd-spag-canvas",{series:a,xMax:n})}_wire(){const t=this.shadowRoot;if(!t)return;const e=t.getElementById("wd-burger");e&&e.addEventListener("click",()=>{this.dispatchEvent(new CustomEvent("hass-toggle-menu",{bubbles:!0,composed:!0}))}),t.querySelectorAll(".wd-devcard[data-idx]").forEach(f=>f.addEventListener("click",()=>this._selectDevice(parseInt(f.dataset.idx,10)))),t.querySelectorAll("[data-tab]").forEach(f=>f.addEventListener("click",()=>{f.dataset.tab!=="settings"&&(this._pendingSettings={}),this._tab=f.dataset.tab,this._fetchTabData()})),t.querySelectorAll("[data-sec]").forEach(f=>f.addEventListener("click",()=>{this._snapshotFormToPending(t),this._settingsSec=f.dataset.sec,this._settingsSearch="",this._settingsSugOnly=!1,this._render()})),t.querySelectorAll("[data-ptab]").forEach(f=>f.addEventListener("click",()=>{const m=this._panelSubtab=f.dataset.ptab;this._render();const x=this._devices[this._selIdx];x&&(m==="diagnostics"&&!this._diag?this._fetchToolsData(x.entry_id).then(()=>{this._panelSubtab==="diagnostics"&&this._render()}):m==="logs"?this._fetchLogs().then(()=>{this._panelSubtab==="logs"&&this._render()}):m==="maintenance"?this._fetchMaintenance(x.entry_id).then(()=>{this._panelSubtab==="maintenance"&&this._render()}):m==="ml"&&this._fetchTabData())})),t.querySelectorAll("[data-gtab]").forEach(f=>f.addEventListener("click",()=>{this._gearTab=f.dataset.gtab,this._modal&&this._modal.type==="gear-settings"&&(this._modal.tab=this._gearTab);const m=this._devices[this._selIdx];this._gearTab==="online"&&m&&this._onlineEnabled()&&(this._ensureStoreConnectListener(),this._loadStoreStatus(m.entry_id).then(()=>{this._modal&&this._modal.type==="gear-settings"&&this._render()})),this._render()}));const s=t.getElementById("wd-store-brand");s&&s.addEventListener("change",()=>{const f=s.value.trim();this._opts={...this._opts,store_brand:f},delete this._pendingSettings.store_brand,this._catalog.forBrand=f,this._catalog.devices=void 0,this._render()});const r=t.getElementById("wd-store-model");r&&r.addEventListener("change",()=>{this._opts={...this._opts,store_model:r.value.trim()},delete this._pendingSettings.store_model,this._render()});const n=t.getElementById("wd-pg-canvas");if(n){const f=()=>{const I=n.getBoundingClientRect(),w=I.height||330,P=Bt+8,z=w-P-52;return{rect:I,padT:P,powerH:z}},m=(I,w)=>{const P=n.getBoundingClientRect(),z=I-P.left,F=w-P.top;if(F>Bt+4)return null;const K=this._pgEventHits||[];let Z=null,rt=1/0;for(const ht of K){const nt=(z-ht.cx)**2+(F-ht.cy)**2;nt<=(ht.r+4)**2&&nt<rt&&(Z=ht,rt=nt)}return Z},x=I=>{const{rect:w,padT:P,powerH:z}=f(),F=this._pgPowerPts;if(!F?.length)return 0;const K=Math.max(...F.map(Z=>Z.w),1);return Math.max(0,(1-Math.max(0,I-w.top-P)/z)*K)},L=I=>{const{rect:w,padT:P,powerH:z}=f(),F=this._pgPowerPts;if(!F?.length)return 0;const K=Math.max(...F.map(Z=>Z.w),1);return w.top+P+(1-Math.max(0,+I)/K)*z},j=I=>{const w=this._pgMap;if(!w)return null;const{rect:P}=f(),z=(I-P.left-w.padLpx)/Math.max(1,w.plotWpx);return w.vMin+Math.max(0,Math.min(1,z))*(w.vMax-w.vMin)};n.addEventListener("pointermove",I=>{if(this._pgDragging==="start_thr"){this._pgThreshStart=Math.max(0,x(I.clientY)),this._pgUpdateParamInput("start_threshold_w",this._pgThreshStart),this._pgDrawCanvas();return}if(this._pgDragging==="stop_thr"){this._pgThreshStop=Math.max(0,x(I.clientY)),this._pgUpdateParamInput("stop_threshold_w",this._pgThreshStop),this._pgDrawCanvas();return}if(this._pgDragging==="pan"&&this._pgPanStart){const Z=this._pgMap,rt=(I.clientX-this._pgPanStart.clientX)/Math.max(1,Z.plotWpx),ht=this._pgPanStart.vMax-this._pgPanStart.vMin;let nt=this._pgPanStart.vMin-rt*ht;nt=Math.max(0,Math.min(this._pgPanStart.totalDur-ht,nt)),this._pgView={min:nt,max:nt+ht},this._pgDrawCanvas();return}if(!this._pgPowerPts?.length)return;const w=m(I.clientX,I.clientY);if(w){n.style.cursor="pointer",this._pgHoverEvent={t:w.t,type:w.type},this._pgHoverT=null,this._pgDrawCanvas();return}this._pgHoverEvent&&(this._pgHoverEvent=null,this._pgDrawCanvas());const P=this._pgThreshStart??this._pgFieldVal("start_threshold_w",{})??50,z=this._pgThreshStop??this._pgFieldVal("stop_threshold_w",{})??5,F=Math.abs(I.clientY-L(P))<8||Math.abs(I.clientY-L(z))<8;n.style.cursor=F?"ns-resize":"crosshair";const K=j(I.clientX);K!=null&&(this._pgHoverT=K,this._pgUpdateStripAt(K),this._pgDrawCanvas())}),n.addEventListener("pointerdown",I=>{if(!this._pgPowerPts?.length||m(I.clientX,I.clientY))return;n.setPointerCapture(I.pointerId);const w=this._pgThreshStart??this._pgFieldVal("start_threshold_w",{})??50,P=this._pgThreshStop??this._pgFieldVal("stop_threshold_w",{})??5;Math.abs(I.clientY-L(w))<10?this._pgDragging="start_thr":Math.abs(I.clientY-L(P))<10?this._pgDragging="stop_thr":this._pgMap&&(this._pgDragging="pan",this._pgPanStart={clientX:I.clientX,vMin:this._pgMap.vMin,vMax:this._pgMap.vMax,totalDur:this._pgMap.totalDur},n.classList.add("wd-pg-panning"))}),n.addEventListener("pointerup",I=>{try{n.releasePointerCapture(I.pointerId)}catch{}const w=this._pgDragging==="start_thr"||this._pgDragging==="stop_thr";this._pgDragging=null,this._pgPanStart=null,n.classList.remove("wd-pg-panning"),w&&this._pgDrawCanvas()}),n.addEventListener("pointerleave",()=>{this._pgDragging||(this._pgHoverT=null,this._pgHoverEvent=null,this._pgUpdateStripAt(null),this._pgDrawCanvas())}),n.addEventListener("wheel",I=>{if(!this._pgPowerPts?.length||!this._pgMap)return;I.preventDefault();const w=this._pgMap,P=j(I.clientX);if(P==null)return;const z=w.vMax-w.vMin,F=I.deltaY>0?1.25:.8,K=Math.max(30,Math.min(w.totalDur,z*F)),Z=z>0?(P-w.vMin)/z:.5;let rt=P-Z*K;rt=Math.max(0,Math.min(w.totalDur-K,rt)),this._pgView=K>=w.totalDur-1?null:{min:rt,max:rt+K},this._pgDrawCanvas()},{passive:!1}),n.addEventListener("dblclick",()=>{this._pgView=null,this._pgDrawCanvas()})}t.querySelectorAll("input[data-pgkey]").forEach(f=>{f.addEventListener("input",()=>{const m=f.dataset.pgkey;if(f.dataset.pgtype==="bool"){const w=f.checked;m==="start_threshold_w"?this._pgThreshStart=w:m==="stop_threshold_w"?this._pgThreshStop=w:this._pgParamOverrides[m]=w,this._render();const P=t.querySelector(`input[data-pgkey="${m}"]`);P&&P.focus(),requestAnimationFrame(()=>this._pgDrawCanvas());return}const x=f.value.trim();if(!x)m==="start_threshold_w"?this._pgThreshStart=null:m==="stop_threshold_w"?this._pgThreshStop=null:delete this._pgParamOverrides[m];else{const w=parseFloat(x);isNaN(w)||(m==="start_threshold_w"?this._pgThreshStart=w:m==="stop_threshold_w"?this._pgThreshStop=w:this._pgParamOverrides[m]=w)}const L=f.selectionStart,j=f.value;this._render();const I=t.querySelector(`input[data-pgkey="${m}"]`);if(I){I.value=j,I.focus();try{I.setSelectionRange(L,L)}catch{}}requestAnimationFrame(()=>this._pgDrawCanvas())})});const a=t.getElementById("wd-pg-stress-toggle");a&&a.addEventListener("change",()=>{this._pgStressTail=a.checked,this._pgStressIdleW=null,this._render()});const o=t.getElementById("wd-pg-stress-idle-w");o&&o.addEventListener("input",()=>{const f=parseFloat(o.value);this._pgStressIdleW=Number.isFinite(f)&&f>=0?f:null});const i=t.getElementById("wd-pg-cyc-sel");i&&i.addEventListener("change",()=>this._pgSelectCycle(i.value));const l=t.getElementById("wd-pg-prof-sel");l&&l.addEventListener("change",()=>{this._pgProfileName=l.value,this._pgLoad()});const c=t.getElementById("wd-pg-preset-sel");c&&c.addEventListener("change",()=>{this._pgPresetSel=c.value,this._render()});const _=t.getElementById("wd-pg-preset-name");_&&_.addEventListener("input",()=>{this._pgPresetName=_.value;const f=(this._pgPresetName||"").trim(),m=this._pgPresetLimit>0&&(this._pgPresets||[]).length>=this._pgPresetLimit&&!(this._pgPresets||[]).some(j=>j.name===f),x=t.querySelector('[data-action="pg-preset-save"]');x&&(x.disabled=!(f&&!m));const L=t.getElementById("wd-pg-preset-limit-note");L&&(L.style.display=m?"":"none")});const p=t.getElementById("wd-pg-simn");p&&p.addEventListener("input",()=>{this._pgSimCycles=Math.max(1,Math.min(200,parseInt(p.value,10)||20))});const g=t.getElementById("wd-pg-sw-param");g&&g.addEventListener("change",()=>{this._pgSweepParam=g.value,this._pgSweepNew=null,this._render()});const u=t.getElementById("wd-pg-sw-obj");u&&u.addEventListener("change",()=>{this._pgSweepObjective=u.value,this._pgSweepNew=null,this._render()});const v=t.getElementById("wd-pg-sw-paramy"),b=t.getElementById("wd-pg-sw-from");b&&b.addEventListener("input",()=>{this._pgSweepFrom=b.value});const $=t.getElementById("wd-pg-sw-to");$&&$.addEventListener("input",()=>{this._pgSweepTo=$.value});const k=t.getElementById("wd-pg-sw-steps");k&&k.addEventListener("input",()=>{this._pgSweepSteps=parseInt(k.value,10)||5}),t.querySelectorAll("[data-statustoggle]").forEach(f=>f.addEventListener("change",async()=>{const m=f.dataset.statustoggle,x=f.checked;this._panelCfg||(this._panelCfg={}),this._panelCfg.prefs={...this._panelCfg.prefs||{},[m]:x},this._ws({type:`${y}/set_user_prefs`,prefs:{[m]:x}}).catch(()=>{});const L=this._devices[this._selIdx];if(L&&this._tab==="status")try{this._powerData=await this._ws({type:`${y}/get_power_history`,entry_id:L.entry_id,with_raw:this._pref("show_raw_active",!1)})}catch{}this._render()})),t.querySelectorAll("[data-sortact]").forEach(f=>f.addEventListener("click",()=>{const m=f.dataset.sortact,x=f.dataset.sortcol,L=j=>{j.col===x?j.dir*=-1:(j.col=x,j.dir=x==="date"?-1:1)};m==="cycsort"?L(this._cycleSort):m==="cleanupsort"&&L(this._cleanupSort),this._render()}));const S=t.getElementById("wd-cyc-filter-text");S&&S.addEventListener("input",f=>{const m=f.target.selectionStart;this._cycleFilter.text=S.value,this._render();const x=this.shadowRoot.getElementById("wd-cyc-filter-text");x&&(x.focus(),x.setSelectionRange(m,m))});const C=t.getElementById("wd-settings-search");C&&C.addEventListener("input",f=>{const m=f.target.selectionStart;this._settingsSearch=C.value,C.value.trim()&&(this._settingsSugOnly=!1),this._render();const x=this.shadowRoot.getElementById("wd-settings-search");x&&(x.focus(),x.setSelectionRange(m,m))});const R=t.getElementById("wd-cyc-filter-status");R&&R.addEventListener("change",()=>{this._cycleFilter.status=R.value,this._render()}),t.querySelectorAll(".wd-pillbox").forEach(f=>{const m=f.querySelector(".wd-pill-add"),x=j=>{const I=document.createElement("span");I.className="wd-pill",I.dataset.val=j,I.appendChild(document.createTextNode(j));const w=document.createElement("button");return w.type="button",w.className="wd-pill-x",w.setAttribute("aria-label","Remove"),w.textContent="\xD7",w.addEventListener("click",()=>I.remove()),I.appendChild(w),I},L=j=>{const I=String(j||"").trim();if(!I)return;Array.from(f.querySelectorAll(".wd-pill")).some(P=>P.dataset.val===I)||f.insertBefore(x(I),m.closest(".wd-combo")||m),m&&(m.value="")};f.querySelectorAll(".wd-pill-x").forEach(j=>j.addEventListener("click",()=>j.closest(".wd-pill")?.remove())),m&&(m.addEventListener("change",()=>L(m.value)),m.addEventListener("keydown",j=>{j.key==="Enter"&&(j.preventDefault(),L(m.value))}),m.addEventListener("blur",()=>L(m.value)))}),t.querySelectorAll(".wd-combo").forEach(f=>{const m=f.querySelector(".wd-combo-inp, .wd-pill-add"),x=f.querySelector(".wd-combo-drop");if(!m||!x)return;const L=f.classList.contains("wd-combo-pill"),j=m.dataset.opt||f.closest("[data-opt]")?.dataset.opt,I=P=>{this._ensureCatalogList(j,P);const z=(this._entityListCache||{})[j]||[],F=(P||"").toLowerCase(),K=F?z.filter(Z=>Z.toLowerCase().includes(F)).slice(0,40):z.slice(0,20);if(!K.length){x.hidden=!0;return}x.innerHTML=K.map(Z=>`<div class="wd-combo-item" data-val="${d(Z)}">${d(Z)}</div>`).join(""),x._kbd=-1,x.hidden=!1},w=P=>{if(P){if(L){const z=f.closest(".wd-pillbox");if(z&&!Array.from(z.querySelectorAll(".wd-pill")).some(F=>F.dataset.val===P)){const F=document.createElement("span");F.className="wd-pill",F.dataset.val=P,F.appendChild(document.createTextNode(P));const K=document.createElement("button");K.type="button",K.className="wd-pill-x",K.setAttribute("aria-label","Remove"),K.textContent="\xD7",K.addEventListener("click",()=>F.remove()),F.appendChild(K),z.insertBefore(F,f)}m.value=""}else m.value=P,m.dispatchEvent(new Event("change",{bubbles:!0}));x.hidden=!0}};m.addEventListener("focus",()=>I(m.value)),m.addEventListener("input",()=>I(m.value)),m.addEventListener("blur",()=>setTimeout(()=>{x.hidden=!0},150)),m.addEventListener("keydown",P=>{if(x.hidden&&P.key!=="ArrowDown")return;const z=x.querySelectorAll(".wd-combo-item");let F=x._kbd||-1;if(P.key==="ArrowDown"){if(P.preventDefault(),x.hidden){I(m.value);return}F=Math.min(F+1,z.length-1)}else if(P.key==="ArrowUp")P.preventDefault(),F=Math.max(F-1,0);else if(P.key==="Enter"&&!x.hidden){P.preventDefault(),F>=0?w(z[F].dataset.val):L&&m.value.trim()&&w(m.value.trim());return}else if(P.key==="Escape"){x.hidden=!0;return}else return;x._kbd=F,z.forEach((K,Z)=>K.classList.toggle("kbd",Z===F)),z[F]?.scrollIntoView({block:"nearest"})}),x.addEventListener("mousedown",P=>{const z=P.target.closest(".wd-combo-item");z&&(P.preventDefault(),w(z.dataset.val))})}),t.querySelectorAll(".wd-timerlist").forEach(f=>{const m=f.dataset.opt,x=()=>{if(!Array.isArray(this._pendingSettings[m])){const z=Array.isArray(this._opts&&this._opts[m])?this._opts[m]:[];this._pendingSettings[m]=z.map(F=>({...F}))}return this._pendingSettings[m]},L=z=>({offset_minutes:parseFloat(z.querySelector('[data-field="offset_minutes"]').value)||0,message:(z.querySelector('[data-field="message"]').value||"").trim(),auto_pause:z.querySelector('[data-field="auto_pause"]').checked}),j=z=>{const F=parseInt(z.dataset.tidx,10);if(isNaN(F))return;const K=x();K[F]=L(z)},I=z=>{const F=parseInt(z.dataset.tidx,10);isNaN(F)||(this._pendingSettings[m]=x().filter((K,Z)=>Z!==F)),z.remove(),f.querySelectorAll(".wd-timer-row").forEach((K,Z)=>{K.dataset.tidx=Z})},w=z=>{const F=z.querySelector(".wd-timer-remove");F&&F.addEventListener("click",()=>I(z)),z.querySelector('[data-field="auto_pause"]')?.addEventListener("change",()=>j(z)),z.querySelectorAll('[data-field="offset_minutes"],[data-field="message"]').forEach(K=>K.addEventListener("input",()=>j(z)))};f.querySelectorAll(".wd-timer-row").forEach(z=>w(z));const P=f.querySelector(".wd-timer-add");P&&P.addEventListener("click",()=>{const z=f.querySelectorAll(".wd-timer-row").length;x().push({offset_minutes:0,message:"",auto_pause:!1});const F=document.createElement("div");F.className="wd-timer-row",F.dataset.tidx=z,F.innerHTML=`<div class="wd-timer-top"><input type="number" min="1" placeholder="${this._t("lbl.timer_min",{},"min")}" data-field="offset_minutes"><textarea placeholder="${this._t("lbl.timer_msg_placeholder",{},"Message (optional, {device}/{program}/{minutes})")}" data-field="message"></textarea></div><div class="wd-timer-footer"><label class="wd-switch-lbl"><span class="wd-switch"><input type="checkbox" data-field="auto_pause"><span class="wd-switch-slider"></span></span><span class="wd-switch-text">${this._t("lbl.timer_auto_pause",{},"Auto-pause")}</span></label><button type="button" class="wd-btn wd-btn-sm wd-btn-danger wd-timer-remove">${this._t("btn.remove_timer",{},"Delete")}</button></div>`,w(F),f.insertBefore(F,P)})});const B=t.getElementById("wd-status-prog");B&&B.addEventListener("change",()=>{const f=this._devices[this._selIdx];if(!f)return;const m=B.value;this._ws({type:`${y}/set_program`,entry_id:f.entry_id,program:m}).then(()=>(this._showToast(m==="auto_detect"?this._t("msg.toast_auto_detect_enabled",{},"Auto-detect enabled"):this._t("msg.toast_program_set",{program:m},`Program set: ${m}`)),this._fetchAll())).catch(x=>this._showToast(this._t("msg.toast_failed",{error:x.message||x},"Failed: "+(x.message||x)),"error"))}),t.querySelectorAll("[data-cid]").forEach(f=>f.addEventListener("click",m=>{if(m.target.tagName==="INPUT"||f.dataset.action)return;const x=f.dataset.cid;f.dataset.selmode==="1"?(this._cycleSel.has(x)?this._cycleSel.delete(x):this._cycleSel.add(x),this._render()):this._onAction({dataset:{action:"open-cycle",cid:x}})})),t.querySelectorAll(".wd-cyc-overlay").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="cycle-detail")return;this._snapshotCycleReviewForm(t);const x=new Set(m.overlays||[]);f.checked?x.add(f.value):x.delete(f.value),m.overlays=[...x];const L=this._devices[this._selIdx];f.checked&&L?this._ensureProfileEnvs(L.entry_id,[f.value]).then(()=>this._render()):this._render()})),t.querySelectorAll(".wd-compare-cyc").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="compare-cycles")return;const x=m.hidden instanceof Set?m.hidden:new Set(m.hidden||[]);f.checked?x.delete(f.value):x.add(f.value),m.hidden=x,this._render()})),t.querySelectorAll(".wd-compare-overlay").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="compare-cycles")return;const x=new Set(m.overlays||[]);f.checked?x.add(f.value):x.delete(f.value),m.overlays=[...x];const L=this._devices[this._selIdx];f.checked&&L?this._ensureProfileEnvs(L.entry_id,[f.value]).then(()=>this._render()):this._render()})),t.querySelectorAll(".wd-pg-mem").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="profile-group")return;const x=t.getElementById("wd-pg-name");x&&(m.name=x.value);const L=new Set(m.members||[]);f.checked?L.add(f.value):L.delete(f.value),m.members=[...L],this._render()}));const W=t.getElementById("wd-pg-name");W&&W.addEventListener("input",()=>{const f=this._modal;f&&f.type==="profile-group"&&(f.name=W.value)}),t.querySelectorAll(".wd-csel").forEach(f=>f.addEventListener("change",()=>{const m=f.closest("[data-cid]"),x=m&&m.dataset.cid;x&&(f.checked?this._cycleSel.add(x):this._cycleSel.delete(x),this._render())}));const O=t.getElementById("wd-merge-prof");O&&O.addEventListener("change",()=>{const f=t.getElementById("wd-merge-new");f&&(f.style.display=O.value==="__create_new__"?"":"none")}),t.querySelectorAll(".wd-log-filter").forEach(f=>{const m=f.dataset.logfilter,x={level:"_logLevel",device:"_logDevice",component:"_logComponent",search:"_logSearch"}[m];if(!x)return;const L=m==="search"?"input":"change";f.addEventListener(L,()=>{this[x]=f.value,this._refreshLogViews(),this._syncLogFilters(f)})});const D=t.querySelector(".wd-log-resize");D&&D.addEventListener("pointerdown",f=>{f.preventDefault(),D.setPointerCapture(f.pointerId),D.classList.add("dragging");const m=t.querySelector(".wd-log-drawer"),x=f.clientX,L=m.offsetWidth,j=I=>{const w=Math.max(280,Math.min(900,L+(x-I.clientX)));m.style.width=w+"px",this._logDrawerWidth=w};D.addEventListener("pointermove",j),D.addEventListener("pointerup",()=>{D.removeEventListener("pointermove",j),D.classList.remove("dragging");try{localStorage.setItem("wd-log-width",String(this._logDrawerWidth))}catch{}},{once:!0})});const A=t.getElementById("wd-hist-file");A&&A.addEventListener("change",()=>{const f=A.files&&A.files[0];if(!f)return;const m=this._modal,x=new FileReader;x.onload=()=>{const L=t.getElementById("wd-hist-csv"),j=String(x.result||"");L&&(L.value=j),m&&m.type==="history-import"&&(m.csvText=j)},x.onerror=()=>this._showToast(this._t("toast.file_read_failed",{},"Could not read that file"),"error"),x.readAsText(f)}),t.querySelectorAll("[data-hist-pick]").forEach(f=>f.addEventListener("change",()=>{const m=this._modal;if(!m||m.type!=="history-import")return;const x=parseInt(f.dataset.histPick,10);f.checked?m.accept.add(x):m.accept.delete(x),this._render(),requestAnimationFrame(()=>this._drawHistorySparklines())}));const E=t.getElementById("wd-import-file");E&&E.addEventListener("change",()=>{const f=E.files&&E.files[0];if(!f)return;const m=new FileReader;m.onload=()=>{const x=t.getElementById("wd-import-json");x&&(x.value=String(m.result||""))},m.readAsText(f)}),t.querySelectorAll("[data-stab]").forEach(f=>f.addEventListener("click",()=>{this._toolsSubtab=f.dataset.stab;const m=this._devices[this._selIdx];m&&(this._tabLoading=!0,this._render(),this._fetchToolsData(m.entry_id).then(()=>{this._tabLoading=!1,this._render()}))})),t.querySelectorAll("[data-proftab]").forEach(f=>f.addEventListener("click",async()=>{this._profSubtab=f.dataset.proftab;const m=this._devices[this._selIdx];m&&this._profSubtab==="phase-catalog"&&!this._phases.length&&(this._tabLoading=!0,this._render(),await this._fetchPhases(m.entry_id),this._tabLoading=!1),this._render()}));const M=t.getElementById("wd-settings-save");M&&M.addEventListener("click",()=>this._saveSettings());const H=t.getElementById("wd-ml-save");H&&H.addEventListener("click",()=>this._saveSettings());const q=t.getElementById("wd-settings-form");q&&(q.addEventListener("submit",f=>f.preventDefault()),q.addEventListener("input",()=>this._liveValidateSettings(t)),q.addEventListener("change",()=>this._liveValidateSettings(t)),q.addEventListener("click",f=>{const m=f.target.closest(".wd-conflict-fix");if(!m)return;const x=m.dataset.ckey,L=parseFloat(m.dataset.cval),j=q.querySelector(`[data-opt="${x}"]`);j&&!isNaN(L)&&(j.value=L,this._cascadeConflictFix(t,q,x))}),this._liveValidateSettings(t));const Q=t.getElementById("wd-settings-revert");Q&&Q.addEventListener("click",async()=>{if(!this._prevOpts)return;const f=this._devices[this._selIdx];f&&await this._busyRun("save-settings",async()=>{try{const m=this._prevOpts;await this._ws({type:`${y}/set_options`,entry_id:f.entry_id,options:m}),this._opts={...m},this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null,this._pendingSettings={},this._showToast(this._t("toast.settings_reverted",{},"Settings reverted; integration reloading")),this._render()}catch(m){this._showToast(this._t("msg.toast_revert_failed",{error:m.message||m},"Revert failed: "+(m.message||m)),"error")}})});const J=t.getElementById("wd-settings-reload");J&&J.addEventListener("click",async()=>{const f=this._devices[this._selIdx];if(f){this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null,this._pendingSettings={};const m=await this._ws({type:`${y}/get_options`,entry_id:f.entry_id});this._opts=m.options||{},this._optDefaults=m.defaults||{},await this._fetchSuggestions(f.entry_id),this._render()}});const G=t.getElementById("wd-pref-fontscale");if(G){const f=t.getElementById("wd-pref-fontscale-val");G.addEventListener("input",()=>{const m=parseFloat(G.value)||1;this._applyFontScale(m),f&&(f.textContent=Math.round(m*100)+"%")}),G.addEventListener("change",()=>{this._setPref("font_scale",parseFloat(G.value)||1)})}t.querySelectorAll("[data-action]").forEach(f=>f.addEventListener("click",m=>this._onAction(m.currentTarget))),t.querySelectorAll("[data-maction]").forEach(f=>f.addEventListener("click",m=>this._onModalAction(m.currentTarget.dataset.maction,m.currentTarget))),t.querySelectorAll("select[data-maction]").forEach(f=>f.addEventListener("change",m=>this._onModalAction(m.currentTarget.dataset.maction,m.currentTarget))),t.querySelectorAll("input[data-indeterminate]").forEach(f=>{f.indeterminate=!0});const X=t.querySelector("[data-toast-undo]");X&&X.addEventListener("click",()=>this._undoDelete(X.dataset.toastUndo)),t.querySelectorAll(".wd-create-cluster").forEach(f=>f.addEventListener("click",()=>{const m=f.dataset.name||"";this._modal={type:"create-profile",prefillName:m},this._render()})),t.querySelectorAll("[data-suglock]").forEach(f=>f.addEventListener("click",async()=>{const m=f.dataset.suglock,x=this._devices[this._selIdx],L=x&&x.entry_id;if(!(!L||!m))try{if(await this._ws({type:`${y}/set_suggestion_lock`,entry_id:L,key:m,locked:!0}),!this._isActiveEntry(L))return;this._suggestions=(this._suggestions||[]).filter(j=>j.key!==m),(this._lockedSuggestions||[]).includes(m)||(this._lockedSuggestions=this._lockedSuggestions||[]).push(m),this._showToast(this._t("msg.sug_muted",{},"Won't suggest this setting again"),"info"),this._render()}catch{this._showToast(this._t("msg.sug_mute_failed",{},"Could not mute suggestion"),"error")}})),t.querySelectorAll("[data-sugkey]").forEach(f=>f.addEventListener("click",()=>{const m=f.dataset.sugkey,x=f.dataset.sugval,L=parseFloat(x);this._pendingSettings[m]=isNaN(L)?x:L,this._stagedSuggestions=!0,this._suggestions=this._suggestions.filter(w=>w.key!==m),this._showToast(this._t("msg.sug_staged",{key:m,val:x},`Set ${m} = ${x}. Save to apply.`),"info"),this._render();const j=this.shadowRoot,I=j?.getElementById("wd-settings-form");I&&this._cascadeConflictFix(j,I,m)}));const ct=t.getElementById("wd-label-profile");ct&&ct.addEventListener("change",()=>{const f=t.getElementById("wd-new-profile-row"),m=ct.value==="__create_new__";if(f&&(f.style.display=m?"":"none"),!m){const x=t.getElementById("wd-new-profile-name");x&&(x.value="")}});const U=t.getElementById("wd-cp-cycle");if(U){const f=()=>{const m=t.getElementById("wd-cp-dur");if(!m)return;const x=!!U.value;m.disabled=x,m.style.opacity=x?"0.5":""};U.addEventListener("change",f),f()}const at=t.getElementById("wd-pr-mode");at&&at.addEventListener("change",()=>{const f=t.getElementById("wd-pr-profile"),m=t.getElementById("wd-pr-existing");if(!f||!m)return;const x=at.value==="existing_profile";f.style.display=x?"none":"",m.style.display=x?"":"none"}),this._wireCycleCanvas(t),this._wirePhaseInputs(t),this._wirePhaseCanvas(t),this._wireCleanup(t),this._wireSplitSegments(t)}_syncTrimInputs(){const t=this.shadowRoot,e=this._modal,s=(e.timeMode||"s")==="clock",r=t.getElementById("wd-trim-start"),n=t.getElementById("wd-trim-end");r&&(r.value=s?this._offsetToClock(e.trim.start):Math.round(e.trim.start)),n&&(n.value=s?this._offsetToClock(e.trim.end):Math.round(e.trim.end))}_snapTrimBounds(){const t=this._modal,e=t.curve&&t.curve.samples||[];if(e.length<2)return;const s=r=>e.reduce((n,a)=>Math.abs(a[0]-r)<Math.abs(n-r)?a[0]:n,e[0][0]);if(t.trim.start=s(t.trim.start),t.trim.end=s(t.trim.end),t.trim.end<=t.trim.start){const r=e.findIndex(n=>n[0]===t.trim.start);r>=0&&r+1<e.length?t.trim.end=e[r+1][0]:r>0&&(t.trim.start=e[r-1][0])}}_offsetToClock(t){const e=this._modal,s=e&&e.curve&&e.curve.start_time;if(!s)return"";const r=new Date(new Date(s).getTime()+(t||0)*1e3);return`${String(r.getHours()).padStart(2,"0")}:${String(r.getMinutes()).padStart(2,"0")}:${String(r.getSeconds()).padStart(2,"0")}`}_clockToOffset(t){const e=this._modal,s=e&&e.curve&&e.curve.start_time;if(!s||!t)return null;const r=String(t).split(":").map(Number);if(!r.length||r.some(l=>!Number.isFinite(l)))return null;const n=new Date(s),a=new Date(n);a.setHours(r[0]||0,r[1]||0,r[2]||0,0);let o=(a-n)/1e3;const i=e.curve&&e.curve.full_duration_s||0;return o<-1&&o+86400<=i&&(o+=86400),Math.max(0,Math.min(i,o))}_trimInputToOffset(t){if(t===""||t==null)return null;if(this._modal.timeMode==="clock")return this._clockToOffset(t);const e=Jt(t,NaN);return Number.isFinite(e)?e:null}_toggleSplit(t){const e=this._modal,s=e.curve&&e.curve.full_duration_s||0,r=Math.max(20,s*.025),n=e.split.offsets,a=n.findIndex(o=>Math.abs(o-t)<r);a>=0?n.splice(a,1):n.push(Math.round(t)),n.sort((o,i)=>o-i),e.split.profiles=[],this._render()}_wireCycleCanvas(t){const e=this._modal;if(!e||e.type!=="cycle-detail"||!e.loaded)return;const s=t.getElementById("wd-cyc-canvas");if(s)if(e.mode==="trim"){const r=t.getElementById("wd-trim-start"),n=t.getElementById("wd-trim-end");r&&r.addEventListener("input",()=>{const i=this._trimInputToOffset(r.value);i!==null&&(e.trim.start=Math.max(0,Math.min(i,e.trim.end-1)),this._drawCycleEditor())}),n&&n.addEventListener("input",()=>{const i=this._trimInputToOffset(n.value);i!==null&&(e.trim.end=Math.min(e.curve.full_duration_s,Math.max(i,e.trim.start+1)),this._drawCycleEditor())});const a=()=>{this._snapTrimBounds(),this._syncTrimInputs(),this._drawCycleEditor()};r&&r.addEventListener("change",a),n&&n.addEventListener("change",a),s.addEventListener("pointerdown",i=>{const l=s._wd;if(!l)return;const c=s.getBoundingClientRect(),_=i.clientX-c.left;e.drag=Math.abs(_-l.xToCss(e.trim.start))<=Math.abs(_-l.xToCss(e.trim.end))?"start":"end",s.setPointerCapture(i.pointerId)}),s.addEventListener("pointermove",i=>{if(!e.drag)return;const l=s._wd;if(!l)return;const c=s.getBoundingClientRect(),_=l.cssToX(i.clientX-c.left);e.drag==="start"?e.trim.start=Math.min(_,e.trim.end-1):e.trim.end=Math.max(_,e.trim.start+1),this._syncTrimInputs(),this._drawCycleEditor()});const o=()=>{e.drag&&(this._snapTrimBounds(),this._syncTrimInputs(),this._drawCycleEditor()),e.drag=null};s.addEventListener("pointerup",o),s.addEventListener("pointercancel",o)}else e.mode==="split"&&s.addEventListener("pointerdown",r=>{const n=s._wd;if(!n)return;const a=s.getBoundingClientRect();this._toggleSplit(n.cssToX(r.clientX-a.left))})}_wireSplitSegments(t){const e=this._modal;!e||e.type!=="cycle-detail"||e.mode!=="split"||t.querySelectorAll("[data-segidx]").forEach(s=>s.addEventListener("change",()=>{e.split.profiles[+s.dataset.segidx]=s.value||null}))}_wirePhaseInputs(t){const e=this._modal;!e||e.type!=="profile-panel"||e.tab!=="phases"||t.querySelectorAll("[data-phidx]").forEach(s=>{const r=()=>{const n=+s.dataset.phidx,a=s.dataset.phfield,o=e.phases[n];o&&(a==="name"?o.name=s.value:o[a]=Math.max(0,Jt(s.value,0)*60),this._drawPhaseEditor())};s.addEventListener("input",r),s.addEventListener("change",r)})}_wirePhaseCanvas(t){const e=this._modal;if(!e||e.type!=="profile-panel"||e.tab!=="phases"||!e.env||!(e.env.avg||[]).length)return;const s=t.getElementById("wd-phase-canvas");if(!s)return;const r=e.env.target_duration||e.env.avg[e.env.avg.length-1][0],n=Math.max(5,r*.01),a=i=>{const l=s._wd;if(!l)return null;let c=null,_=12;return(e.phases||[]).forEach((p,g)=>{[["start",p.start],["end",p.end]].forEach(([u,v])=>{const b=Math.abs(i-l.xToCss(v));b<_&&(_=b,c={idx:g,edge:u})})}),c};s.addEventListener("pointerdown",i=>{const l=s.getBoundingClientRect();e.phaseDrag=a(i.clientX-l.left),e.phaseDrag&&s.setPointerCapture(i.pointerId)}),s.addEventListener("pointermove",i=>{if(!e.phaseDrag)return;const l=s._wd;if(!l)return;const c=s.getBoundingClientRect(),_=l.cssToX(i.clientX-c.left),p=e.phases[e.phaseDrag.idx];p&&(e.phaseDrag.edge==="start"?p.start=Math.max(0,Math.min(_,p.end-n)):p.end=Math.min(r,Math.max(_,p.start+n)),this._syncPhaseInputs(e.phaseDrag.idx),this._drawPhaseEditor())});const o=()=>{e.phaseDrag=null};s.addEventListener("pointerup",o),s.addEventListener("pointercancel",o)}_syncPhaseInputs(t){const e=this.shadowRoot,s=this._modal.phases[t];if(!s)return;const r=e.querySelector(`[data-phidx="${t}"][data-phfield="start"]`),n=e.querySelector(`[data-phidx="${t}"][data-phfield="end"]`);r&&(r.value=(s.start/60).toFixed(1)),n&&(n.value=(s.end/60).toFixed(1))}_wireCleanup(t){const e=this._modal;if(!e||e.type!=="profile-panel"||e.tab!=="cleanup"||!e.cleanup)return;t.querySelectorAll("[data-cleanidx]").forEach(r=>r.addEventListener("change",()=>{const n=e.cleanup.cycles[+r.dataset.cleanidx];if(!n)return;r.checked?e.cleanup.selected.add(n.cycle_id):e.cleanup.selected.delete(n.cycle_id);const a=e.cleanup.selected,o=t.querySelector('[data-maction="pp-cleanup-del"]');o&&!this._busy.has("pp-cleanup-del")&&(o.disabled=a.size===0,o.textContent=this._t("btn.delete_selected",{n:a.size},`Delete selected (${a.size})`)),this._drawSpaghetti()})),t.querySelectorAll("tr[data-cid]").forEach(r=>{r.addEventListener("mouseenter",()=>{this._spagTableHoverCid=r.dataset.cid,this._drawSpaghetti()}),r.addEventListener("mouseleave",()=>{this._spagTableHoverCid=null,this._drawSpaghetti()})});const s=t.getElementById("wd-spag-canvas");s&&s.addEventListener("pointerdown",r=>{this._onGraphHoverInner(r.clientX,r.clientY,"wd-spag-canvas");const n=this._hoverNearest;if(n&&n.cid){const a=e.cleanup.selected;a.has(n.cid)?a.delete(n.cid):a.add(n.cid),this._render()}})}_onAction(t){const e=t.dataset.action,s=this.shadowRoot;if(e==="open-settings"){this._modal={type:"gear-settings",tab:this._gearTab||"prefs"},this._render();return}const r=this._devices[this._selIdx];if(!r)return;const n=r.entry_id;if(e.startsWith("sug-"))return this._onActSuggestions(e,t,r,n,s);if(e.startsWith("ml-"))return this._onActMl(e,t,r,n);if(e.startsWith("store-"))return this._onActStore(e,t,r,n,s);if(e.startsWith("auto-"))return this._onActAuto(e,t,r,n,s);if(e.startsWith("maint-"))return this._onActMaintenance(e,t,r,n,s);if(e.startsWith("pg-")&&e!=="pg-new"&&e!=="pg-edit"&&e!=="pg-suggest")return this._onActPlayground(e,t,r,n);if(e==="open-cycle"){const a=t.dataset.cid,o=t.dataset.mode==="review"?"review":"view";this._modal={type:"cycle-detail",entryId:n,cycleId:a,loaded:!1,mode:o,curve:null,ml:(this._mlById||{})[a]||null,trim:{start:0,end:0},split:{offsets:[],profiles:[]},drag:null},this._profiles.length||this._fetchProfiles(n),this._render(),this._ws({type:`${y}/get_cycle_power_data`,entry_id:n,cycle_id:a}).then(i=>{this._modal&&this._modal.cycleId===a&&(this._modal.curve=i,this._modal.loaded=!0,this._modal.trim={start:0,end:i.full_duration_s||0},this._render(),i.profile_name&&this._fetchCycleProfileEnv(n,i.profile_name))}).catch(i=>this._showToast(this._t("toast.could_not_load_cycle",{error:i.message||i},"Could not load cycle: "+(i.message||i)),"error"))}else if(e==="cleanup-edit-cycle"){const a=t.dataset.cid;this._prevModal=this._modal,this._modal={type:"cycle-detail",entryId:n,cycleId:a,loaded:!1,mode:"view",curve:null,ml:(this._mlById||{})[a]||null,trim:{start:0,end:0},split:{offsets:[],profiles:[]},drag:null},this._profiles.length||this._fetchProfiles(n),this._render(),this._ws({type:`${y}/get_cycle_power_data`,entry_id:n,cycle_id:a}).then(o=>{this._modal&&this._modal.cycleId===a&&(this._modal.curve=o,this._modal.loaded=!0,this._modal.trim={start:0,end:o.full_duration_s||0},this._render(),o.profile_name&&this._fetchCycleProfileEnv(n,o.profile_name))}).catch(o=>this._showToast(this._t("toast.could_not_load_cycle",{error:o.message||o},"Could not load cycle: "+(o.message||o)),"error"))}else if(e==="open-profile"){const a=t.dataset.pname;this._prevModal=null;const o=(this._profiles||[]).find(i=>i.name===a)||{name:a};this._modal={type:"profile-panel",name:a,tab:"stats",loaded:!1,stats:o,env:null,phases:[],catalog:[],cleanup:null},this._render(),Promise.all([this._ws({type:`${y}/get_profile_envelope`,entry_id:n,profile_name:a}).catch(()=>({envelope:null})),this._ws({type:`${y}/get_profile_phases`,entry_id:n,profile_name:a}).catch(()=>({phases:[]})),this._ws({type:`${y}/get_phase_catalog`,entry_id:n}).catch(()=>({phases:[]}))]).then(([i,l,c])=>{!this._modal||this._modal.name!==a||(this._modal.env=i.envelope,this._modal.phases=(l.phases||[]).map(_=>({name:_.name,start:_.start,end:_.end})),this._modal.catalog=(c.phases||[]).map(_=>_.name),this._modal.loaded=!0,this._render())})}else if(e==="create-profile")this._modal={type:"create-profile"},this._render();else if(e==="setup-cta"){const a=t.dataset.ctaAction||"";let o={};try{o=JSON.parse(t.dataset.ctaParams||"{}")}catch{}this._dispatchSetupCta(a,o)}else if(e==="setup-skip"){const a=t.dataset.step,o=t.dataset.snooze;if(a){let i;if(o==="never")i="never";else{const l=new Date;l.setDate(l.getDate()+14),i=l.toISOString()}this._setPref(a,i),this._reloadSetupStatus()}}else if(e==="hide-setup-card")this._setPref("setup_card_dismissed",!0),this._render();else if(e==="expand-setup")this._setPref("setup_card_dismissed",!1),this._render();else if(e==="set-settings-level"){const a=(t.type==="checkbox"?t.checked:t.dataset.slevel==="advanced")?"advanced":"basic";a!==this._pref("settings_level","basic")&&(this._snapshotFormToPending(s),this._setPref("settings_level",a),this._render())}else if(e==="pg-new"||e==="pg-edit"||e==="pg-suggest"){if(e==="pg-new")this._modal={type:"profile-group",orig:null,name:"",members:[]};else if(e==="pg-edit"){const a=t.dataset.gname,o=((this._profileGroups||{}).groups||[]).find(i=>i.name===a);this._modal={type:"profile-group",orig:a,name:a,members:o?[...o.members||[]]:[]}}else{const a=((this._profileGroups||{}).suggestions||[])[parseInt(t.dataset.idx,10)]||null;if(!a)return;this._modal={type:"profile-group",orig:a.existing_group||null,name:a.existing_group||"",members:[...a.members||[]]}}this._render(),this._ensureProfileEnvs(n,(this._profiles||[]).map(a=>a.name)).then(()=>{this._modal&&this._modal.type==="profile-group"&&this._render()})}else if(e==="rebuild-envelopes")this._kickAndTrack({type:`${y}/rebuild_envelopes`,entry_id:n},"rebuild-envelopes",async()=>{this._showToast(this._t("toast.envelopes_rebuilt",{},"Envelopes rebuilt")),await this._fetchProfiles(n)});else if(e==="rec-start")this._ws({type:`${y}/start_recording`,entry_id:n}).then(()=>(this._showToast(this._t("toast.recording_started",{},"Recording started")),this._fetchRecState(n))).then(()=>this._render()).catch(a=>this._showToast(this._t("toast.start_failed",{error:a.message||a},"Start failed: "+(a.message||a)),"error"));else if(e==="rec-stop")this._ws({type:`${y}/stop_recording`,entry_id:n}).then(()=>(this._showToast(this._t("toast.recording_stopped",{},"Recording stopped")),this._fetchRecState(n))).then(()=>this._render()).catch(a=>this._showToast(this._t("toast.stop_failed",{error:a.message||a},"Stop failed: "+(a.message||a)),"error"));else if(e==="rec-process-open")this._fetchProfiles(n).then(()=>{this._modal={type:"process-recording"},this._render()});else if(e==="rec-discard")this._modal={type:"confirm",title:this._t("modal.discard_recording_title",{},"Discard Recording"),message:this._t("modal.discard_recording_msg",{},"Discard the saved recording? This cannot be undone."),okLabel:this._t("btn.discard",{},"Discard"),onOk:async()=>{try{await this._ws({type:`${y}/discard_recording`,entry_id:n}),this._showToast(this._t("toast.recording_discarded",{},"Recording discarded")),await this._fetchRecState(n)}catch(a){this._showToast(this._t("toast.discard_failed",{error:a.message||a},"Discard failed: "+(a.message||a)),"error")}}},this._render();else if(e==="fb-confirm")this._ws({type:`${y}/resolve_feedback`,entry_id:n,cycle_id:t.dataset.cid,action:"confirm"}).then(()=>(this._showToast(this._t("toast.feedback_confirmed",{},"Feedback confirmed")),this._fetchFeedbacks(n))).then(()=>this._render()).catch(a=>this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error"));else if(e==="fb-ignore")this._ws({type:`${y}/resolve_feedback`,entry_id:n,cycle_id:t.dataset.cid,action:"ignore"}).then(()=>(this._showToast(this._t("toast.feedback_dismissed",{},"Feedback dismissed")),this._fetchFeedbacks(n))).then(()=>this._render()).catch(a=>this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error"));else if(e==="fb-correct")this._fetchProfiles(n).then(()=>{this._modal={type:"correct-feedback",cycleId:t.dataset.cid,detectedProfile:t.dataset.prof},this._render()});else if(e==="fb-dismiss-all")this._modal={type:"confirm",title:this._t("modal.dismiss_all_title",{},"Dismiss All Feedbacks"),message:this._t("modal.dismiss_all_msg",{count:this._feedbacks.length},`Dismiss all ${this._feedbacks.length} pending feedback requests?`),okLabel:this._t("modal.dismiss_all_ok",{},"Dismiss All"),onOk:async()=>{try{await this._ws({type:`${y}/dismiss_all_feedbacks`,entry_id:n}),this._showToast(this._t("toast.feedback_all_dismissed",{},"All feedbacks dismissed")),await this._fetchFeedbacks(n)}catch(a){this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}}},this._render();else if(e==="create-phase")this._modal={type:"create-phase",deviceType:t.dataset.dtype},this._render();else if(e==="edit-phase")this._modal={type:"edit-phase",phaseId:t.dataset.pid,phaseName:t.dataset.pname,phaseDesc:t.dataset.pdesc,isDefault:t.dataset.pisdefault==="true"},this._render();else if(e==="del-phase"){const a=t.dataset.pname,o=t.dataset.pid;this._modal={type:"confirm",title:this._t("modal.delete_phase_title",{},"Delete Phase"),message:this._t("modal.delete_phase_msg",{name:a},`Delete phase "${a}"?`),okLabel:this._t("btn.delete",{},"Delete"),onOk:async()=>{try{await this._ws({type:`${y}/delete_phase`,entry_id:n,phase_id:o}),this._showToast(this._t("toast.phase_deleted",{name:a},`Phase "${a}" deleted`)),await this._fetchPhases(n)}catch(i){this._showToast(this._t("msg.toast_delete_failed",{error:i.message||i},"Delete failed: "+(i.message||i)),"error")}}},this._render()}else if(e==="diag-refresh")this._fetchToolsData(n).then(()=>this._render());else if(e==="reprocess-history")this._modal={type:"confirm",title:this._t("modal.process_history_title",{},"Process History"),message:this._t("modal.process_history_msg",{},"Re-run matching, refresh suggestions, retrain ML (if enabled) and recompute cycle health across all stored cycles. This may take a while."),okLabel:this._t("modal.process_history_ok",{},"Process"),onOk:()=>this._kickAndTrack({type:`${y}/reprocess_history`,entry_id:n},"reprocess",async a=>{const o=a.count||0,i=[this._t("toast.processed_cycles",{n:o},o+" cycles")];a.suggestions!=null&&i.push(this._t("toast.processed_suggestions",{n:a.suggestions},a.suggestions+" suggestion(s)"));const l=a.ml_training&&a.ml_training.ok&&(a.ml_training.promoted||[]).length||0;l&&i.push(this._t("toast.processed_models",{n:l},l+" model(s) promoted")),this._showToast(this._t("toast.processed",{bits:i.join(", ")},"Processed "+i.join(", "))),await this._fetchToolsData(n)})},this._render();else if(e==="clear-debug")this._modal={type:"confirm",title:this._t("modal.clear_debug_title",{},"Clear Debug Data"),message:this._t("modal.clear_debug_msg",{},"Delete all stored debug traces?"),okLabel:this._t("status.clear",{},"Clear"),onOk:()=>this._busyRun("clear-debug",async()=>{try{const a=await this._ws({type:`${y}/clear_debug_data`,entry_id:n});this._showToast(this._t("toast.debug_cleared",{count:a.count||0},`Cleared ${a.count||0} debug traces`)),await this._fetchToolsData(n)}catch(a){this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}})},this._render();else if(e==="wipe-history")this._modal={type:"confirm",title:this._t("modal.wipe_all_title",{},"Wipe All Data"),message:this._t("modal.wipe_all_msg",{},"\u26A0\uFE0F This permanently deletes ALL cycles and profiles. This cannot be undone."),okLabel:this._t("modal.wipe_all_ok",{},"Wipe Everything"),onOk:()=>this._busyRun("wipe",async()=>{try{await this._ws({type:`${y}/wipe_history`,entry_id:n}),this._showToast(this._t("toast.all_wiped",{},"All data wiped")),this._cycles=[],this._profiles=[],await this._fetchToolsData(n)}catch(a){this._showToast(this._t("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}})},this._render();else if(e==="export-config")this._ws({type:`${y}/export_config`,entry_id:n}).then(a=>{const o=new Blob([a.json_data],{type:"application/json"}),i=URL.createObjectURL(o),l=document.createElement("a");l.href=i,l.download=`washdata_export_${n.slice(0,8)}.json`,document.body.appendChild(l),l.click(),document.body.removeChild(l),URL.revokeObjectURL(i),this._showToast(this._t("toast.export_downloaded",{},"Export downloaded"))}).catch(a=>this._showToast(this._t("toast.export_failed",{error:a.message||a},"Export failed: "+(a.message||a)),"error"));else if(e==="export-select-open")this._modal={type:"export-select",inventory:null,loading:!0,sel:{cats:new Set,profiles:new Set,realIds:new Set,refIds:new Set},expanded:new Set},this._render(),(async()=>{let a=null;try{const o=await this._ws({type:`${y}/get_export_inventory`,entry_id:n});a=o&&o.manifest||null}catch(o){this._showToast(this._t("toast.export_failed",{error:o.message||o},"Export failed: "+(o.message||o)),"error")}if(!(!this._isActiveEntry(n)||!this._modal||this._modal.type!=="export-select")){if(!a){this._modal=null,this._render();return}this._modal.inventory=a,this._modal.sel=this._wizInitSel({categories:a},!1),this._modal.loading=!1,this._render()}})();else if(e==="cyc-select-toggle")this._selectMode=!this._selectMode,this._selectMode||this._cycleSel.clear(),this._render();else if(e==="cyc-auto-open")this._modal={type:"auto-label"},this._render();else if(e==="cyc-merge"){const a=Array.from(this._cycleSel);if(a.length<2)return;this._fetchProfiles(n).then(()=>{this._modal={type:"merge-cycles",ids:a},this._render()})}else if(e==="cyc-relabel"){const a=Array.from(this._cycleSel);if(!a.length)return;this._fetchProfiles(n).then(()=>{this._modal={type:"bulk-relabel",ids:a},this._render()})}else if(e==="cyc-load-more")this._busyRun("cyc-load-more",async()=>{try{await this._loadMoreCycles(n)}catch(a){this._showToast(this._t("toast.load_more_failed",{error:a.message||a},"Could not load more: "+(a.message||a)),"error")}});else if(e==="task-cancel"){const a=t.dataset.taskId;a&&(this._cancellingTasks.add(a),this._updateTaskPills(),this._ws({type:`${y}/cancel_task`,task_id:a}).catch(()=>{this._cancellingTasks.delete(a),this._updateTaskPills()}))}else if(e==="cyc-compare"){const a=Array.from(this._cycleSel);if(a.length<2)return;this._modal={type:"compare-cycles",ids:a,cycles:{},hidden:new Set,overlays:[],loaded:!1},this._profiles.length||this._fetchProfiles(n),this._render(),Promise.all(a.map(o=>this._ws({type:`${y}/get_cycle_power_data`,entry_id:n,cycle_id:o}).then(i=>({cid:o,r:i})).catch(()=>({cid:o,r:null})))).then(o=>{!this._modal||this._modal.type!=="compare-cycles"||(o.forEach(({cid:i,r:l})=>{l&&(this._modal.cycles[i]=l)}),this._modal.loaded=!0,this._render())})}else if(e==="cyc-bulk-del"){const a=Array.from(this._cycleSel);if(!a.length)return;this._deleteCyclesWithUndo(n,a)}else if(e==="retry-cycles")this._fetchCycles(n).then(()=>this._render());else if(e==="retry-profiles")Promise.all([this._fetchProfiles(n),this._fetchProfileGroups(n)]).then(()=>this._render());else if(e==="retry-suggestions")this._fetchSuggestions(n).then(()=>this._render());else if(e==="goto-suggestions")this._settingsSugOnly=!0,this._tab="settings",this._fetchTabData();else if(e==="goto-conflicts")this._tab="settings",this._fetchTabData();else if(e==="conf-goto-section"){const a=this._conflictKeysFromOpts();for(const o of mt)if((o.fields||(o.groups||[]).flatMap(l=>l.fields||[])).some(l=>a.has(l.key))){this._settingsSec=o.id,this._render();break}}else if(e==="toggle-settings-history")this._settingsHistoryOpen=!this._settingsHistoryOpen,this._render();else if(e==="settings-revert-key"){const a=t.dataset.key,o=JSON.parse(t.dataset.val);if(!a)return;const i=r.entry_id;this._ws({type:`${y}/set_options`,entry_id:i,options:{[a]:o}}).then(()=>this._ws({type:`${y}/get_options`,entry_id:i})).then(l=>(this._opts=l.options||{},this._optDefaults=l.defaults||{},this._fetchSettingsChangelog(i))).then(()=>{this._showToast(this._t("msg.toast_reverted",{key:this._t("setting."+a+".label",{},a)},"{key} reverted"),"success"),this._render()}).catch(l=>this._showToast(this._t("msg.toast_error",{error:l.message||l},"Error: "+(l.message||l)),"error"))}else if(e==="toggle-log-drawer"){this._logOpen=!this._logOpen;try{localStorage.setItem("wd-log-open",this._logOpen?"1":"0")}catch{}this._render(),this._logOpen&&this._fetchLogs().then(()=>{this._logOpen&&this._render()})}else if(e==="open-advanced"){const a=t.dataset.sub;a&&(this._panelSubtab=a),this._tab="advanced",this._render(),this._panelSubtab==="diagnostics"&&!this._diag?this._fetchToolsData(n).then(()=>{this._tab==="advanced"&&this._render()}):this._panelSubtab==="logs"?this._fetchLogs().then(()=>{this._tab==="advanced"&&this._render()}):this._panelSubtab==="maintenance"?this._fetchMaintenance(n).then(()=>{this._tab==="advanced"&&this._render()}):this._panelSubtab==="ml"&&this._fetchTabData()}else if(e==="add-device")this._navigate(`/config/integrations/integration/${y}`);else if(e==="goto-feedbacks")this._tab="history",this._cycleFilter={...this._cycleFilter,status:"needs_review"},this._fetchTabData();else if(e==="goto-recording")this._tab="status",this._fetchTabData();else if(e==="logs-refresh")this._fetchLogs().then(()=>this._render());else if(e==="logs-export")this._ws({type:`${y}/get_logs`,limit:500}).then(a=>{const o=(a.logs||[]).map(_=>`${new Date(_.ts*1e3).toISOString()} ${_.level} ${_.msg}`).join(`
|
|
`),i=new Blob([o],{type:"text/plain"}),l=URL.createObjectURL(i),c=document.createElement("a");c.href=l,c.download=`washdata_logs_${Date.now()}.txt`,document.body.appendChild(c),c.click(),document.body.removeChild(c),URL.revokeObjectURL(l),this._showToast(this._t("toast.logs_exported",{},"Logs exported"))}).catch(a=>this._showToast(this._t("toast.export_failed",{error:a.message||a},"Export failed: "+(a.message||a)),"error"));else if(e==="import-config-open")this._modal={type:"import-wizard",step:"input",jsonText:"",manifest:null,error:null,sel:{cats:new Set,profiles:new Set,realIds:new Set,refIds:new Set},expanded:new Set,mode:"merge",cycleDest:"reference",conflicts:{}},this._render();else if(e==="hist-import-open")this._modal={type:"history-import",step:"input",csvText:"",since:zt(),token:null,scanTaskId:null,applyTaskId:null,result:null,accept:new Set,done:null,error:null},this._render();else if(e==="import-config-raw")this._modal={type:"import-config"},this._render();else if(e==="save-prefs"){const a=s.getElementById("wd-pref-tab")?.value||"",o=!!s.getElementById("wd-pref-debug")?.checked,i=s.getElementById("wd-pref-expected")?!!s.getElementById("wd-pref-expected").checked:!0,l=!!s.getElementById("wd-pref-raw")?.checked,c=s.getElementById("wd-pref-datefmt")?.value||"relative",_=s.getElementById("wd-pref-lang")?.value||"",p=parseFloat(s.getElementById("wd-pref-fontscale")?.value)||1,g={default_tab:a,show_debug:o,show_expected:i,show_raw:l,date_format:c,lang_override:_,font_scale:p};this._busyRun("save-prefs",async()=>{try{await this._ws({type:`${y}/set_user_prefs`,prefs:g}),this._panelCfg&&(this._panelCfg.prefs={...this._panelCfg.prefs||{},...g});const u=_||this._hass&&this._hass.locale&&this._hass.locale.language;await this._loadPanelLang(u),this._render(),this._showToast(this._t("toast.preferences_saved",{},"Preferences saved"))}catch(u){this._showToast(this._t("toast.save_failed",{error:u.message||u},"Save failed: "+(u.message||u)),"error")}})}else if(e==="save-panel"){const a={default_tab:s.getElementById("wd-ps-deftab")?.value||"status",hidden_tabs:Array.from(s.querySelectorAll("[data-hidetab]")).filter(o=>o.checked).map(o=>o.dataset.hidetab)};this._busyRun("save-panel",async()=>{try{await this._ws({type:`${y}/set_panel_config`,panel:a}),this._panelCfg=await this._ws({type:`${y}/get_panel_config`}),this._tabInitialized=!0,this._applyPanelConfig(),this._showToast(this._t("toast.panel_settings_saved",{},"Panel settings saved"))}catch(o){this._showToast(this._t("msg.toast_save_failed",{error:o.message||o},"Save failed: "+(o.message||o)),"error")}})}else if(e==="pause-cycle")this._ws({type:`${y}/pause_cycle`,entry_id:n}).then(a=>{if(a&&a.ok===!1){this._showToast(this._t("toast.pause_no_cycle",{},"No active cycle to pause"),"error");return}return this._showToast(this._t("toast.cycle_paused",{},"Cycle paused")),this._fetchAll()}).catch(a=>this._showToast(this._t("toast.pause_failed",{error:a.message||a},"Pause failed: "+(a.message||a)),"error"));else if(e==="resume-cycle")this._ws({type:`${y}/resume_cycle`,entry_id:n}).then(a=>{if(a&&a.ok===!1){this._showToast(this._t("toast.resume_no_cycle",{},"No paused cycle to resume"),"error");return}return this._showToast(this._t("toast.cycle_resumed",{},"Cycle resumed")),this._fetchAll()}).catch(a=>this._showToast(this._t("msg.toast_resume_failed",{error:a.message||a},"Resume failed: "+(a.message||a)),"error"));else if(e==="terminate-cycle")this._modal={type:"confirm",title:this._t("modal.force_stop_title",{},"Force Stop Cycle"),message:this._t("modal.force_stop_msg",{},"Force-stop the active cycle now? The cycle will be saved as interrupted."),okLabel:this._t("btn.force_stop",{},"Force Stop"),onOk:async()=>{try{await this._ws({type:`${y}/terminate_cycle`,entry_id:n}),this._showToast(this._t("toast.cycle_force_stopped",{},"Cycle force-stopped")),await this._fetchAll()}catch(a){this._showToast(this._t("msg.toast_force_stop_failed",{error:a.message||a},"Force stop failed: "+(a.message||a)),"error")}}},this._render();else if(e==="save-rbac"){const a=!!s.getElementById("wd-rbac-enabled")?.checked,o=s.getElementById("wd-rbac-default")?.value||"none",i={};s.querySelectorAll("[data-rbacuser]").forEach(l=>{const c=l.dataset.rbacuser,_=l.dataset.rbacdev,p=l.value;i[c]||(i[c]={default:"none",devices:{}}),_==="__default__"?i[c].default=p:p&&p!=="inherit"&&(i[c].devices[_]=p)}),this._busyRun("save-rbac",async()=>{try{await this._ws({type:`${y}/set_panel_config`,rbac:{enabled:a,default_level:o,users:i}}),this._panelCfg=await this._ws({type:`${y}/get_panel_config`}),this._showToast(this._t("toast.access_saved",{},"Access control saved"))}catch(l){this._showToast(this._t("msg.toast_save_failed",{error:l.message||l},"Save failed: "+(l.message||l)),"error")}})}}_onActSuggestions(t,e,s,r,n){if(t==="sug-apply-all"){const a=this._suggestions.map(o=>o.key);this._busyRun("save-settings",async()=>{try{await this._ws({type:`${y}/apply_suggestions`,entry_id:r,keys:a}),this._showToast(this._t("toast.suggestions_applied",{},"Suggestions applied; integration reloading")),await this._fetchSuggestions(r);const o=await this._ws({type:`${y}/get_options`,entry_id:r});this._opts=o.options||{},this._optDefaults=o.defaults||{},this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null}catch(o){this._showToast(this._t("toast.apply_failed",{error:o.message||o},"Apply failed: "+(o.message||o)),"error")}})}else t==="sug-show-all"?(this._settingsSugOnly=!1,this._render()):t==="sug-dismiss"?(this._settingsSugOnly=!1,this._busyRun("save-settings",async()=>{try{await this._ws({type:`${y}/clear_suggestions`,entry_id:r}),this._suggestions=[],this._showToast(this._t("toast.suggestions_dismissed",{},"Suggestions dismissed"))}catch(a){this._showToast(this._t("toast.error",{error:a.message||a},"Error: "+(a.message||a)),"error")}})):t==="sug-unmute-all"?this._busyRun("save-settings",async()=>{try{const a=[...this._lockedSuggestions||[]],o=await Promise.allSettled(a.map(c=>this._ws({type:`${y}/set_suggestion_lock`,entry_id:r,key:c,locked:!1})));if(!this._isActiveEntry(r))return;const i=o.filter(c=>c.status==="rejected").length,l=o.slice().reverse().find(c=>c.status==="fulfilled");this._lockedSuggestions=l&&l.value&&l.value.locked_suggestions||[],await this._fetchSuggestions(r),i?this._showToast(this._t("toast.error",{error:`${i} suggestion(s) failed to unlock`},`${i} suggestion(s) failed to unlock`),"error"):this._showToast(this._t("msg.sug_unmuted_all",{},"Muted suggestions reset"),"success")}catch(a){this._showToast(this._t("toast.error",{error:a.message||a},"Error: "+(a.message||a)),"error")}}):t==="sug-analyze"&&this._busyRun("sug-analyze",async()=>{try{const a=await this._ws({type:`${y}/run_suggestion_analysis`,entry_id:r}),o=a&&a.count||0;this._showToast(o?this._t("toast.analysis_complete",{count:o},`Analysis complete: ${o} suggestion(s)`):this._t("toast.analysis_complete_none",{},"Analysis complete: no new suggestions")),await this._fetchSuggestions(r)}catch(a){this._showToast(this._t("toast.analysis_failed",{error:a.message||a},"Analysis failed: "+(a.message||a)),"error")}})}_onActMl(t,e,s,r){t==="ml-train-now"?this._kickAndTrack({type:`${y}/trigger_ml_training`,entry_id:r},"ml-train-now:"+r,async n=>{if(n&&n.ok){const a=(n.promoted||[]).length;this._showToast(a?this._t("toast.ml_training_promoted",{count:a},`Training complete: promoted ${a} model(s)`):this._t("toast.ml_training_no_improvement",{},"Training complete: baseline kept (no improvement)"))}else this._showToast(this._t("toast.ml_training_no_improvement",{},"Training complete: baseline kept (no improvement)"),"info");await this._loadMlTrainingStatus(r)}):t==="ml-revert-match"?this._busyRun("ml-revert-match",async()=>{try{await this._ws({type:`${y}/revert_matching_config`,entry_id:r}),this._showToast(this._t("toast.matching_reverted",{},"Matching weights reverted to defaults")),await this._loadMlTrainingStatus(r)}catch(n){this._showToast(this._t("toast.revert_failed",{error:n.message||n},"Revert failed: "+(n.message||n)),"error")}}):t==="ml-revert-models"&&this._busyRun("ml-revert-models",async()=>{try{await this._ws({type:`${y}/revert_ml_models`,entry_id:r}),this._showToast(this._t("toast.models_reverted",{},"On-device models reverted to baseline")),await this._loadMlTrainingStatus(r)}catch(n){this._showToast(this._t("msg.toast_revert_failed",{error:n.message||n},"Revert failed: "+(n.message||n)),"error")}})}_onActStore(t,e,s,r,n){if(t==="store-toggle-online"){const a=!!e.checked;this._busyRun("store-account",async()=>{try{const o=await this._ws({type:`${y}/store_set_online`,entry_id:r,enabled:a});this._constants.storeOnlineEnabled=!!(o&&o.enabled),this._constants.storeOnlineEnabled?(await this._loadStoreStatus(r),this._ensureStoreConnectListener()):(this._storeStatus={enabled:!1},this._storeConnected=!1)}catch(o){this._showToast(this._t("toast.store_error",{error:o.message||o},"Error: "+(o.message||o)),"error")}})}else if(t==="store-toggle-pref"){const a=e.dataset.pref,o=!!e.checked;if(!a)return;this._busyRun("store-account",async()=>{try{const i=await this._ws({type:`${y}/store_set_prefs`,entry_id:r,prefs:{[a]:o}});i&&i.prefs&&(this._constants={...this._constants,storePrefs:i.prefs})}catch(i){this._showToast(this._t("toast.store_error",{error:i.message||i},"Error: "+(i.message||i)),"error")}})}else if(t==="store-goto-identity")this._tab="settings",this._settingsSec="basic",this._fetchTabData(),requestAnimationFrame(()=>requestAnimationFrame(()=>{const a=this.shadowRoot&&this.shadowRoot.getElementById("wd-store-brand");a&&(a.focus(),a.scrollIntoView({block:"center"}))}));else if(t==="store-refresh-catalog")this._busyRun("store-refresh-catalog",async()=>{try{await this._ws({type:`${y}/store_refresh_catalog`,entry_id:r}),clearTimeout(this._brandSearchTimer),this._brandSearchTimer=null,this._catalog.brands=void 0,this._catalog.devices=void 0,this._catalog.forBrand=null,this._catalog.brandsFull=!1,this._catalog.brandPrefixes=[],this._catalogEntry=null,this._entityListCache&&(delete this._entityListCache.store_brand,delete this._entityListCache.store_model),this._showToast(this._t("toast.catalog_refreshed",{},"Community catalog refreshed"))}catch(a){this._showToast(this._t("toast.store_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}});else if(t==="store-connect"){const a=this._constants.storeWebOrigin;if(!a){this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener(),window.open(a+"/connect.html?origin="+encodeURIComponent(location.origin),"washdata_connect","width=480,height=640")}else if(t==="store-disconnect")this._busyRun("store-account",async()=>{try{await this._ws({type:`${y}/store_disconnect`,entry_id:r}),await this._loadStoreStatus(r),this._showToast(this._t("toast.store_disconnected",{},"Disconnected from the community store"))}catch(a){this._showToast(this._t("toast.store_error",{error:a.message||a},"Error: "+(a.message||a)),"error")}});else if(t==="store-add-appliance"){const a=this._constants.storeWebOrigin;if(!a){this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener();const o=n.getElementById("wd-store-model"),i=n.getElementById("wd-store-brand"),l=new URLSearchParams({mode:"device",type:this._storeApplianceType(),brand:i&&i.value||this._opts.store_brand||"",model:o&&o.value||this._opts.store_model||"",origin:location.origin}).toString();window.open(a+"/create.html?"+l,"washdata_create","width=560,height=760")}else if(t==="store-add-brand"){const a=this._constants.storeWebOrigin;if(!a){this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener();const o=n.getElementById("wd-store-brand"),i=new URLSearchParams({mode:"brand",brand:o&&o.value||this._opts.store_brand||"",origin:location.origin}).toString();window.open(a+"/create.html?"+i,"washdata_create","width=560,height=760")}else if(t==="store-confirm-device"){const a=e.dataset.deviceId;this._busyRun("store-account",async()=>{try{const o=await this._ws({type:`${y}/store_confirm_device`,entry_id:r,device_id:a});if(o&&o.error){this._showToast(this._t("toast.store_error",{error:o.error},"Error: "+o.error),"error");return}const i=[...this._catalog.devices||[],...this._storeDevices||[],this._catalogEntry&&this._catalogEntry.device||null];for(const l of i)o&&l&&String(l.id)===String(a)&&(l.confirmCount=o.confirmCount,l.status=o.status);this._showToast(o&&o.status==="approved"?this._t("toast.device_approved",{},"Approved by the community"):this._t("toast.thanks_confirming",{},"Thanks for confirming")),this._render()}catch(o){this._showToast(this._t("toast.store_error",{error:o.message||o},"Error: "+(o.message||o)),"error")}})}else if(t==="store-rate-device"){const a=e.dataset.deviceId,o=parseInt(e.dataset.rating,10);if(!(o>=1&&o<=5))return;this._busyRun("store-account",async()=>{try{const i=await this._ws({type:`${y}/store_rate_device`,entry_id:r,device_id:a,rating:o});if(i&&i.error){this._showToast(this._t("toast.store_error",{error:i.error},"Error: "+i.error),"error");return}this._showToast(this._t("toast.rating_saved",{},"Quality rating saved"))}catch(i){this._showToast(this._t("toast.store_error",{error:i.message||i},"Error: "+(i.message||i)),"error")}})}else if(t==="store-search"){const a=n.getElementById("wd-store-q");this._storeSearch(a?a.value:"")}else if(t==="store-nav"){const a=e.dataset.view;a==="brands"?(this._storeView="brands",this._storeDevice=null,this._storeProfile=null,this._render()):a==="device"&&(this._storeView="device",this._storeProfile=null,this._render())}else if(t==="store-open-device"){const a=e.dataset.deviceId,o=(this._storeDevices||[]).find(i=>String(i.id)===String(a));if(!o)return;this._storeDevice=o,this._storeProfile=null,this._storeView="device",this._storeProfiles=[],this._storeCycles=[],this._storeLoading=!0,this._render(),this._ws({type:`${y}/store_get_profiles`,entry_id:r,device_id:o.id}).then(i=>{!this._isActiveEntry(r)||this._storeView!=="device"||(this._storeProfiles=i&&i.items||[])}).catch(()=>{this._isActiveEntry(r)&&(this._storeProfiles=[])}).finally(()=>{this._isActiveEntry(r)&&(this._storeLoading=!1,this._render())})}else if(t==="store-open-profile"){const a=e.dataset.profileId,o=(this._storeProfiles||[]).find(i=>String(i.id)===String(a));if(!o)return;this._storeProfile=o,this._storeView="profile",this._storeCycles=[],this._storeLoading=!0,this._render(),this._ws({type:`${y}/store_get_cycles`,entry_id:r,profile_id:o.id}).then(i=>{!this._isActiveEntry(r)||this._storeView!=="profile"||(this._storeCycles=i&&i.items||[])}).catch(()=>{this._isActiveEntry(r)&&(this._storeCycles=[])}).finally(()=>{this._isActiveEntry(r)&&(this._storeLoading=!1,this._render())})}else if(t==="store-onboard")this._storeQuery=(this._opts.store_brand||"").trim(),this._storeView="brands",this._storeDevice=null,this._storeProfile=null,this._tab="store",this._fetchTabData();else if(t==="store-toggle-dl-settings")this._dlSettings=!!e.checked;else if(t==="store-download-device"){const a=e.dataset.deviceId;if(!a)return;const o=!!this._dlSettings;this._busyRun("store-download-device",async()=>{try{const i=await this._ws({type:`${y}/store_download_device`,entry_id:r,device_id:a,include_settings:o});if(i&&(i.error||i.disabled)){const g=i.error||"unavailable";this._showToast(this._t("toast.store_download_failed",{error:g},"Download failed: "+g),"error");return}const l=i&&i.profiles_adopted||0,c=i&&i.cycles_imported||0,_=i&&i.settings_applied||0;if(!l&&!c&&!_){this._showToast(this._t("toast.store_download_nothing",{},"Nothing new to download - this setup is already on your device."),"info");return}await this._fetchProfiles(r),await this._fetchCycles(r);const p=i&&i.phases_applied||0;_?this._showToast(this._t("toast.store_device_downloaded_settings",{p:l,c,ph:p,s:_},`${l} program(s), ${c} recording(s), ${p} phase map(s), ${_} setting(s) added`)):p?this._showToast(this._t("toast.store_device_downloaded_phases",{p:l,c,ph:p},`${l} program(s), ${c} recording(s), ${p} phase map(s) added`)):this._showToast(this._t("toast.store_device_downloaded",{p:l,c},`${l} program(s), ${c} recording(s) added`))}catch(i){this._showToast(this._t("toast.store_download_failed",{error:i.message||i},"Download failed: "+(i.message||i)),"error")}})}else if(t==="store-import"){const a=e.dataset.cycleId,o=this._storeProfile&&this._storeProfile.program||"";this._modal={type:"store-import",cycleId:a,program:o,mode:"new"},this._render()}else if(t==="store-share-cycle"){const a=e.dataset.cid,o=e.dataset.prof||"";this._modal={type:"store-share",cycleId:a,program:o,profiles:null,deviceId:null},this._render(),this._loadShareProfiles()}else if(t==="store-share-device")this._modal={type:"store-share-device",selected:new Set,includePhases:new Set,includeSettings:!1,loading:!0},this._render(),(async()=>{try{const o=await this._ws({type:`${y}/get_shareable_cycles`,entry_id:r});this._shareableCycles=o&&o.items||[],this._sharePhasePrograms=o&&o.phase_programs||[],this._shareAllPrograms=o&&o.all_programs||[]}catch{this._shareableCycles=[],this._sharePhasePrograms=[],this._shareAllPrograms=[]}if(!this._isActiveEntry(r)||!this._modal||this._modal.type!=="store-share-device")return;const a=new Set;this._shareableByProgram().forEach(o=>o.cycles.forEach(i=>a.add(i.id))),this._modal.selected=a,this._modal.includePhases=new Set(this._sharePhasePrograms),this._modal.loading=!1,this._render()})();else if(t==="store-share-profile"){const a=e.dataset.prog||"";this._modal={type:"store-share-device",selected:new Set,includePhases:new Set,includeSettings:!1,loading:!0,focusProfile:a},this._render(),(async()=>{try{const i=await this._ws({type:`${y}/get_shareable_cycles`,entry_id:r});this._shareableCycles=i&&i.items||[],this._sharePhasePrograms=i&&i.phase_programs||[],this._shareAllPrograms=i&&i.all_programs||[]}catch{this._shareableCycles=[],this._sharePhasePrograms=[],this._shareAllPrograms=[]}if(!this._isActiveEntry(r)||!this._modal||this._modal.type!=="store-share-device")return;const o=new Set;this._shareableByProgram().filter(i=>i.program===a).forEach(i=>i.cycles.forEach(l=>o.add(l.id))),this._modal.selected=o,this._modal.includePhases=new Set((this._sharePhasePrograms||[]).filter(i=>i===a)),this._modal.loading=!1,this._render()})()}else if(t==="store-share-add-profile"){const a=this._modal,o=this._constants.storeWebOrigin;if(!o||!a){o||this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener();const i=new URLSearchParams({mode:"profile",device:a.deviceId||"",type:this._storeApplianceType(),brand:this._opts.store_brand||"",model:this._opts.store_model||"",origin:location.origin}).toString();window.open(o+"/create.html?"+i,"washdata_create","width=560,height=760")}}_onActAuto(t,e,s,r,n){if(t==="auto-new")this._navigate("/config/automation/edit/new");else if(t==="auto-new-started")this._newAutomationFromEvent("started");else if(t==="auto-new-finished")this._newAutomationFromEvent("finished");else if(t==="auto-delete"){const a=e.dataset.autoid,o=e.dataset.autoname||"this automation";this._modal={type:"confirm",title:this._t("modal.delete_automation_title",{},"Delete Automation"),message:this._t("modal.delete_automation_msg",{name:o},`Delete the automation "${o}" from Home Assistant? This cannot be undone.`),okLabel:this._t("btn.delete",{},"Delete"),onOk:async()=>{try{await this._hass.callApi("DELETE","config/automation/config/"+a),this._showToast(this._t("toast.automation_deleted",{},"Automation deleted")),await this._loadDeviceAutomations(r)}catch(i){this._showToast(this._t("toast.delete_failed",{error:i.message||i},"Delete failed: "+(i.message||i)),"error")}}},this._render()}else if(t==="auto-convert-legacy")this._convertLegacyActions();else if(t==="auto-remove-legacy")this._modal={type:"confirm",title:this._t("modal.remove_legacy_title",{},"Remove Legacy Actions"),message:this._t("modal.remove_legacy_msg",{},"Remove the legacy custom actions? They will stop firing on cycle events. This cannot be undone from the panel."),okLabel:this._t("btn.remove",{},"Remove"),onOk:async()=>{try{await this._ws({type:`${y}/set_options`,entry_id:r,options:{notify_actions:[]}}),this._opts={...this._opts,notify_actions:[]},this._showToast(this._t("toast.legacy_removed",{},"Legacy actions removed"))}catch(a){this._showToast(this._t("toast.delete_failed",{error:a.message||a},"Remove failed: "+(a.message||a)),"error")}}},this._render();else if(t==="auto-label"){const a=parseFloat(n.getElementById("wd-auto-label-threshold")?.value||"0.75");this._busyRun("auto-label",async()=>{try{await this._ws({type:`${y}/auto_label_cycles`,entry_id:r,confidence_threshold:a}),this._showToast(this._t("toast.auto_label_complete",{},"Auto-label complete")),await this._fetchCycles(r)}catch(o){this._showToast(this._t("toast.auto_label_failed",{error:o.message||o},"Auto-label failed: "+(o.message||o)),"error")}})}}_onActMaintenance(t,e,s,r,n){if(t==="maint-add"){const a=n.getElementById("wd-maint-type")?.value||"",o=n.getElementById("wd-maint-date")?.value||"",i=(n.getElementById("wd-maint-notes")?.value||"").trim();if(!a){this._showToast(this._t("toast.maint_add_failed",{error:this._t("lbl.event_type",{},"Event type")},"Could not add event: Event type"),"error");return}this._busyRun("maint-add",async()=>{try{const l={type:`${y}/add_maintenance_event`,entry_id:r,event_type:a};o&&(l.date=o),i&&(l.notes=i),await this._ws(l),await this._fetchMaintenance(r),this._showToast(this._t("toast.maint_added",{},"Maintenance event added")),this._render()}catch(l){this._showToast(this._t("toast.maint_add_failed",{error:l.message||l},"Could not add event: "+(l.message||l)),"error")}})}else if(t==="maint-delete"){const a=e.dataset.mid;this._modal={type:"confirm",title:this._t("modal.delete_maintenance_title",{},"Delete Maintenance Event"),message:this._t("modal.delete_maintenance_msg",{},"Delete this maintenance record? This cannot be undone."),okLabel:this._t("btn.delete",{},"Delete"),onOk:()=>this._busyRun("maint-delete",async()=>{try{await this._ws({type:`${y}/delete_maintenance_event`,entry_id:r,event_id:a}),await this._fetchMaintenance(r),this._showToast(this._t("toast.maint_deleted",{},"Maintenance event deleted"))}catch(o){this._showToast(this._t("toast.maint_delete_failed",{error:o.message||o},"Could not delete event: "+(o.message||o)),"error")}})},this._render()}else if(t==="maint-save-reminders"){const a={};n.querySelectorAll("[data-maint-rem]").forEach(o=>{const i=o.dataset.maintRem,l=parseInt(o.value,10);a[i]=!isNaN(l)&&l>0?l:0}),this._busyRun("maint-save-reminders",async()=>{try{await this._ws({type:`${y}/set_options`,entry_id:r,options:{maintenance_reminder_cycles:a}}),await this._fetchMaintenance(r),this._showToast(this._t("toast.reminders_saved",{},"Service reminders saved")),this._render()}catch(o){this._showToast(this._t("toast.reminders_save_failed",{error:o.message||o},"Could not save reminders: "+(o.message||o)),"error")}})}}_onActPlayground(t,e,s,r){if(t==="pg-analysis-tab"){const n=e.dataset.subtab||"history";n!==this._pgAnalysisTab&&(this._pgAnalysisTab=n,this._render(),requestAnimationFrame(()=>this._drawPlaygroundCanvases()))}else if(t==="pg-run-history")this._pgRunHistory();else if(t==="pg-batch-cancel"){this._pgBatchCancel=!0;const n=this._pgHistoryTaskId||this._pgSweepTaskId;n&&this._ws({type:`${y}/cancel_task`,task_id:n}).catch(()=>{})}else if(t==="pg-load-run"){const n=e.dataset.taskId;if(!n)return;const a=this._tasks[n];if(!a||a.kind!=="pg_history"&&a.kind!=="pg_sweep"){this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info");return}const o=a.entry_id;this._ws({type:`${y}/get_task_result`,task_id:n}).then(i=>{if(!this._isActiveEntry(o))return;const l=i&&i.result;if(!l){this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info");return}a.kind==="pg_history"?(this._pgHistory=l,this._pgAnalysisTab="history"):(this._pgSweepNew=l.error?null:l,this._pgAnalysisTab="sweep"),this._render()}).catch(()=>this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info"))}else if(t==="pg-open-cycle")this._pgSelectCycle(e.dataset.cid);else if(t==="pg-sweep-run2")this._pgRunSweep2();else if(t==="pg-sweep-apply2")this._pgApplySweepValue(e.dataset.val);else if(t==="pg-run"||t==="pg-load")this._pgLoad();else if(t==="pg-cancel-run")this._pgCancelRun();else if(t==="pg-reset-params")this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={},this._pgStressTail=!1,this._pgStressIdleW=null,this._render(),requestAnimationFrame(()=>this._pgDrawCanvas());else if(t==="pg-apply-settings")this._pgApplyToSettings();else if(t==="pg-load-live")this._pgLoadLive();else if(t==="pg-load-suggested")this._pgLoadSuggested("classic");else if(t==="pg-load-calibrated")this._pgLoadSuggested("ml");else if(t==="pg-preset-save")this._pgSavePreset();else if(t==="pg-preset-load"){const n=(this._pgPresets||[]).find(a=>a.name===this._pgPresetSel);n&&(this._pgApplyPresetValues(n.values),this._showToast(this._t("toast.pg_preset_loaded",{name:n.name},`Preset "${n.name}" loaded`)),this._render(),requestAnimationFrame(()=>this._pgDrawCanvas()))}else t==="pg-preset-delete"?this._pgDeletePreset():t==="pg-publish-one"&&this._pgPublishOne(e.dataset.pgkey)}async _onModalAction(t,e){const s=this.shadowRoot,r=this._devices[this._selIdx],n=r?r.entry_id:null,a=this._modal;if(t==="cancel"){if(a&&a.type==="cycle-detail"&&this._prevModal){const o=this._devices[this._selIdx];o?await this._closeCycleDetail(o.entry_id):(this._modal=null,this._render())}else this._modal=null,this._render();return}if(t==="ok"&&a&&a.onOk){const o=a.onOk;this._modal=null,this._render(),await o(),this._render();return}if(a&&a.type==="profile-group"){if(t==="pg-save"){const o=s.getElementById("wd-pg-name")?.value?.trim(),i=Array.from(s.querySelectorAll(".wd-pg-mem")).filter(l=>l.checked).map(l=>l.value);if(!o){this._showToast(this._t("toast.group_name_required",{},"Group name is required"),"error");return}if(i.length<2){this._showToast(this._t("toast.min_2_profiles",{},"Select at least 2 profiles for a group"),"error");return}await this._busyRun("pg-save",async()=>{try{a.orig&&a.orig!==o&&await this._ws({type:`${y}/rename_profile_group`,entry_id:n,name:a.orig,new_name:o}),await this._ws({type:`${y}/save_profile_group`,entry_id:n,name:o,members:i}),this._showToast(this._t("toast.group_saved",{},"Group saved")),this._modal=null,await this._fetchProfileGroups(n)}catch(l){this._showToast(this._t("msg.toast_save_failed",{error:l.message||l},"Save failed: "+(l.message||l)),"error")}});return}if(t==="pg-delete"&&a.orig){await this._busyRun("pg-save",async()=>{try{await this._ws({type:`${y}/delete_profile_group`,entry_id:n,name:a.orig}),this._showToast(this._t("toast.group_deleted",{},"Group deleted")),this._modal=null,await this._fetchProfileGroups(n)}catch(o){this._showToast(this._t("msg.toast_delete_failed",{error:o.message||o},"Delete failed: "+(o.message||o)),"error")}});return}}if(t.startsWith("store-import-")||t==="store-share-ok"||t.startsWith("wiz-")||t.startsWith("imp-")||t==="import-back"||t==="import-analyze"||t==="import-apply-ok")return this._onMActImport(t,e,a,n,s);if(t.startsWith("hist-"))return this._onMActHistoryImport(t,e,a,n,s);if(t.startsWith("sd-")||t==="store-share-device-ok")return this._onMActStoreShare(t,e,a,n);if(a&&a.type==="export-select"&&t==="export-generate"&&n){const o=this._wizSelectionPayload(a);await this._busyRun("export-select",async()=>{try{const i=await this._ws({type:`${y}/export_config_selective`,entry_id:n,selection:o}),l=new Blob([i.json_data],{type:"application/json"}),c=URL.createObjectURL(l),_=document.createElement("a");_.href=c,_.download=`washdata_export_${n.slice(0,8)}.json`,document.body.appendChild(_),_.click(),document.body.removeChild(_),URL.revokeObjectURL(c),this._modal=null,this._showToast(this._t("toast.export_selective_done",{},"Export downloaded"))}catch(i){this._showToast(this._t("toast.export_failed",{error:i.message||i},"Export failed: "+(i.message||i)),"error")}});return}if(t.startsWith("cyc-")||t.startsWith("trim-mode-"))return this._onMActCycleDetail(t,a,n,s);if(a&&a.type==="profile-panel"){if(t.indexOf("pp-tab-")===0){const o=t.slice(7);a.tab=o,this._render(),o==="cleanup"&&!a.cleanup&&this._ws({type:`${y}/get_profile_cycles`,entry_id:n,profile_name:a.name}).then(i=>{this._modal&&this._modal.name===a.name&&(this._modal.cleanup={cycles:i.cycles||[],selected:new Set},this._render())}).catch(()=>{this._modal&&(this._modal.cleanup={cycles:[],selected:new Set},this._render())});return}return this._onMActProfilePanel(t,e,a,n,s)}if(t==="label-ok"&&n){const o=s.getElementById("wd-label-profile"),i=o?o.value:"",l=i||null,c=i==="__create_new__"&&s.getElementById("wd-new-profile-name")?.value?.trim()||null;this._modal=null;try{await this._ws({type:`${y}/label_cycle`,entry_id:n,cycle_id:a.cycleId,profile_name:l||null,new_profile_name:c}),this._showToast(this._t("toast.cycle_labelled",{},"Cycle labelled")),await this._fetchCycles(n),await this._fetchProfiles(n),await this._fetchFeedbacks(n)}catch(_){this._showToast(this._t("toast.label_failed",{error:_.message||_},"Label failed: "+(_.message||_)),"error")}this._render()}else if(t==="create-profile-ok"&&n){const o=s.getElementById("wd-cp-name")?.value?.trim(),i=s.getElementById("wd-cp-cycle")?.value||null,l=parseFloat(s.getElementById("wd-cp-dur")?.value||0);if(this._modal=null,!o){this._showToast(this._t("toast.profile_name_required",{},"Profile name is required"),"error"),this._render();return}const c=!i&&l>0?l:null;try{await this._ws({type:`${y}/create_profile`,entry_id:n,name:o,reference_cycle:i||null,manual_duration_min:c}),this._showToast(this._t("toast.profile_created",{name:o},`Profile "${o}" created`)),await this._fetchProfiles(n)}catch(_){this._showToast(this._t("toast.create_failed",{error:_.message||_},"Create failed: "+(_.message||_)),"error")}this._render()}else if(t==="create-phase-ok"&&n){const o=s.getElementById("wd-ph-name")?.value?.trim(),i=s.getElementById("wd-ph-desc")?.value?.trim()||"";if(this._modal=null,!o){this._showToast(this._t("toast.phase_name_required",{},"Phase name is required"),"error"),this._render();return}try{await this._ws({type:`${y}/create_phase`,entry_id:n,device_type:a.deviceType||"",name:o,description:i}),this._showToast(this._t("toast.phase_created",{name:o},`Phase "${o}" created`)),await this._fetchPhases(n)}catch(l){this._showToast(this._t("msg.toast_create_failed",{error:l.message||l},"Create failed: "+(l.message||l)),"error")}this._render()}else if(t==="edit-phase-ok"&&n){const o=s.getElementById("wd-eph-name")?.value?.trim(),i=s.getElementById("wd-eph-desc")?.value?.trim()||"";if(this._modal=null,!o){this._showToast(this._t("toast.name_required",{},"Name required"),"error"),this._render();return}try{await this._ws({type:`${y}/update_phase`,entry_id:n,phase_id:a.phaseId,new_name:o,description:i}),this._showToast(this._t("toast.phase_updated",{},"Phase updated")),await this._fetchPhases(n)}catch(l){this._showToast(this._t("toast.update_failed",{error:l.message||l},"Update failed: "+(l.message||l)),"error")}this._render()}else if(t==="process-rec-ok"&&n){const o=s.getElementById("wd-pr-mode")?.value;let i=s.getElementById("wd-pr-profile")?.value?.trim();o==="existing_profile"&&(i=s.getElementById("wd-pr-profile-sel")?.value||i);const l=parseFloat(s.getElementById("wd-pr-head")?.value||0),c=parseFloat(s.getElementById("wd-pr-tail")?.value||0);if(this._modal=null,!i){this._showToast(this._t("msg.toast_profile_name_required",{},"Profile name is required"),"error"),this._render();return}try{await this._ws({type:`${y}/process_recording`,entry_id:n,profile_name:i,save_mode:o,head_trim:l,tail_trim:c}),this._showToast(this._t("toast.recording_saved",{},"Recording saved to profile")),await this._fetchRecState(n),await this._fetchProfiles(n)}catch(_){this._showToast(this._t("msg.toast_save_failed",{error:_.message||_},"Save failed: "+(_.message||_)),"error")}this._render()}else if(t==="correct-fb-ok"&&n){const o=s.getElementById("wd-fb-profile")?.value,i=parseFloat(s.getElementById("wd-fb-dur")?.value||0)||null;this._modal=null;try{await this._ws({type:`${y}/resolve_feedback`,entry_id:n,cycle_id:a.cycleId,action:"correct",corrected_profile:o,corrected_duration_min:i}),this._showToast(this._t("toast.correction_submitted",{},"Correction submitted")),await this._fetchFeedbacks(n)}catch(l){this._showToast(this._t("msg.toast_error",{error:l.message||l},"Error: "+(l.message||l)),"error")}this._render()}else if(t==="import-ok"&&n){const o=s.getElementById("wd-import-json")?.value;if(this._modal=null,!o?.trim()){this._showToast(this._t("toast.json_required",{},"JSON data is required"),"error"),this._render();return}try{await this._ws({type:`${y}/import_config`,entry_id:n,json_data:o}),this._showToast(this._t("toast.import_successful",{},"Import successful; integration reloading")),await this._fetchCycles(n)}catch(i){this._showToast(this._t("toast.import_failed",{error:i.message||i},"Import failed: "+(i.message||i)),"error")}this._render()}else if(t==="auto-run"&&n){const o=parseFloat(s.getElementById("wd-al-thr")?.value||"0.75");this._modal=null,this._render(),await this._busyRun("auto-label",async()=>{try{await this._ws({type:`${y}/auto_label_cycles`,entry_id:n,confidence_threshold:o}),this._showToast(this._t("msg.toast_auto_label_complete",{},"Auto-label complete")),await this._fetchCycles(n)}catch(i){this._showToast(this._t("msg.toast_auto_label_failed",{error:i.message||i},"Auto-label failed: "+(i.message||i)),"error")}})}else if(t==="merge-ok"&&n){const o=s.getElementById("wd-merge-prof")?.value||"",i=s.getElementById("wd-merge-newname")?.value?.trim()||null,l=a.ids||[];this._modal=null,this._render(),this._kickAndTrack({type:`${y}/apply_merge`,entry_id:n,cycle_ids:l,target_profile:o||null,new_profile_name:i},"cyc-merge",async()=>{this._showToast(this._t("toast.cycles_merged",{},"Cycles merged")),this._cycleSel.clear(),this._selectMode=!1,await this._fetchCycles(n),await this._fetchProfiles(n)})}else if(t==="bulk-relabel-ok"&&n){const o=s.getElementById("wd-relabel-profile")?.value||"",i=(a.ids||[]).slice();if(this._modal=null,this._render(),!i.length)return;await this._busyRun("cyc-relabel",async()=>{try{for(const l of i)await this._ws({type:`${y}/label_cycle`,entry_id:n,cycle_id:l,profile_name:o||null});this._showToast(this._t("toast.relabel_done",{count:i.length},`Relabelled ${i.length} cycle(s)`)),this._cycleSel.clear(),this._selectMode=!1,await this._fetchCycles(n),await this._fetchProfiles(n),await this._fetchFeedbacks(n)}catch(l){this._showToast(this._t("toast.relabel_failed",{error:l.message||l},"Relabel failed: "+(l.message||l)),"error")}})}}async _histUpload(t,e){const s=await this._ws({type:`${y}/history_import_begin`,entry_id:t}),r=Math.max(4096,s.chunk_bytes||512*1024);let n=0,a=0;for(;a<e.length;){let o=Math.min(e.length,a+r);if(o<e.length){const i=e.lastIndexOf(`
|
|
`,o);i>a&&(o=i+1)}await this._ws({type:`${y}/history_import_chunk`,entry_id:t,token:s.token,seq:n,text:e.slice(a,o)}),n+=1,a=o}return s.token}async _histStartScan(t,e,s){e.token=s,e.step="scan",e.error=null,this._render();const r=await this._ws({type:`${y}/start_history_import_scan`,entry_id:t,token:s});e.scanTaskId=r.task_id,this._addProvisionalTask(r.task_id,"history_import",t,0),this._tasksSubscribed||this._pollTaskGeneric(r.task_id),this._histAdopt(r.task_id)}async _histAdopt(t){if(!t)return;let e=(this._tasks||{})[t];if(!e||e.state==="running")try{e=await this._ws({type:`${y}/get_task_result`,task_id:t})}catch{return}e&&e.state&&e.state!=="running"&&this._histTaskFinished(e)}async _histTaskFinished(t){const e=this._modal;if(!(!e||e.type!=="history-import")&&!(t.id!==e.scanTaskId&&t.id!==e.applyTaskId)&&(this._histSettled=this._histSettled||new Set,!this._histSettled.has(t.id))){if(this._histSettled.add(t.id),t.id===e.scanTaskId){if(t.state==="error"){e.step="input",e.error=t.error||this._t("msg.hist_scan_failed",{},"Scanning failed."),this._render();return}try{const s=t.result?t:await this._ws({type:`${y}/get_task_result`,task_id:t.id});e.result=s.result||{},e.accept=new Set((e.result.segments||[]).filter(r=>r.accept).map(r=>r.index)),e.step="review",this._render(),requestAnimationFrame(()=>this._drawHistorySparklines())}catch(s){e.step="input",e.error=String(s&&s.message||s),this._render()}return}if(t.id===e.applyTaskId){if(t.state==="error"){e.error=t.error==="scan_expired"?this._t("msg.hist_scan_expired",{},"That scan is no longer available. Please scan again."):t.error||this._t("msg.hist_import_failed",{},"Import failed."),e.step="review",this._render();return}try{const r=t.result?t:await this._ws({type:`${y}/get_task_result`,task_id:t.id});e.done=r.result||{}}catch{e.done={}}e.step="done",this._render();const s=this._devices[this._selIdx];s&&(this._fetchCycles(s.entry_id),this._fetchProfiles(s.entry_id))}}}async _onMActHistoryImport(t,e,s,r,n){if(!(!s||s.type!=="history-import"||!r)){if(t==="hist-scan"){const a=n.getElementById("wd-hist-csv"),o=a?a.value:s.csvText||"";if(!o.trim()){this._showToast(this._t("toast.hist_csv_required",{},"Load a CSV file or paste its contents first"),"error");return}s.csvText=o,await this._busyRun("hist-import",async()=>{try{const i=await this._histUpload(r,o);await this._histStartScan(r,s,i)}catch(i){s.error=String(i&&i.message||i),this._render()}});return}if(t==="hist-recorder"){const a=n.getElementById("wd-hist-since"),o=a&&a.value||zt();s.since=o,await this._busyRun("hist-import",async()=>{try{const i=await this._ws({type:`${y}/history_import_recorder`,entry_id:r,start_date:o});if(!i.rows){s.error=this._t("msg.hist_recorder_empty",{},"Home Assistant has no detailed history for this sensor in that window."),this._render();return}await this._histStartScan(r,s,i.token)}catch(i){s.error=String(i&&i.message||i),this._render()}});return}if(t==="hist-cancel-scan"){if(s.scanTaskId)try{await this._ws({type:`${y}/cancel_task`,task_id:s.scanTaskId})}catch{}s.step="input",s.scanTaskId=null,this._render();return}if(t==="hist-back"){s.step="input",s.result=null,s.error=null,this._render();return}if(t==="hist-toggle-all"){const a=s.result&&s.result.segments||[],o=a.every(i=>s.accept.has(i.index));s.accept=o?new Set:new Set(a.map(i=>i.index)),this._render(),requestAnimationFrame(()=>this._drawHistorySparklines());return}if(t==="hist-apply"){if(!s.accept||!s.accept.size)return;await this._busyRun("hist-apply",async()=>{try{const a=await this._ws({type:`${y}/apply_history_import`,entry_id:r,scan_task_id:s.scanTaskId,accept:[...s.accept].sort((o,i)=>o-i)});s.applyTaskId=a.task_id,this._addProvisionalTask(a.task_id,"history_import_apply",r,s.accept.size),this._tasksSubscribed||this._pollTaskGeneric(a.task_id),this._histAdopt(a.task_id)}catch(a){s.error=String(a&&a.message||a),this._render()}});return}if(t==="hist-goto-cycles"){this._modal=null,this._tab="history",this._cycleFilter={...this._cycleFilter||{},status:"imported"},this._fetchTabData();return}}}async _onMActImport(t,e,s,r,n){if(s&&s.type==="store-import"){if(t==="store-import-mode-new"){s.mode="new",this._render();return}if(t==="store-import-mode-merge"){s.mode="merge",this._render();return}if(t==="store-import-ok"){const a={type:`${y}/store_import_cycle`,entry_id:r,cycle_id:s.cycleId};if(s.mode==="merge"){const o=n.getElementById("wd-store-import-target")?.value||"";if(!o){this._showToast(this._t("toast.store_pick_profile",{},"Pick a profile to merge into"),"error");return}a.target_profile=o}else{const o=(n.getElementById("wd-store-import-name")?.value||"").trim()||s.program;if(!o){this._showToast(this._t("toast.store_name_required",{},"Enter a profile name"),"error");return}a.new_profile_name=o}await this._busyRun("store-import",async()=>{try{const o=await this._ws(a);if(o&&o.error){this._showToast(this._t("toast.store_import_failed",{error:o.error},"Import failed: "+o.error),"error");return}this._modal=null,this._showToast(this._t("toast.store_imported",{profile:o&&o.profile||""},`Imported into ${o&&o.profile||"profile"}`)),await this._fetchProfiles(r)}catch(o){this._showToast(this._t("toast.store_import_failed",{error:o.message||o},"Import failed: "+(o.message||o)),"error")}});return}}if(s&&s.type==="store-share"&&t==="store-share-ok"){const a=(n.getElementById("wd-store-share-prog")?.value||"").trim(),o=(n.getElementById("wd-store-share-desc")?.value||"").trim();if(!a){this._showToast(this._t("toast.store_pick_profile",{},"Pick a profile to share into"),"error");return}await this._busyRun("store-share",async()=>{try{const i=await this._ws({type:`${y}/store_upload_cycle`,entry_id:r,local_cycle_id:s.cycleId,program:a,description:o});if(i&&i.error){if(i.error==="no_appliance_declared")this._showToast(this._t("toast.store_no_appliance",{},"Set your appliance brand and model in Settings first."),"error");else{const l=i.detail?`${i.error} - ${i.detail}`:i.error;this._showToast(this._t("toast.store_share_failed",{error:l},"Share failed: "+l),"error")}return}this._modal=null,this._showToast(this._t("toast.store_shared",{},"Shared to the community store - pending review."))}catch(i){this._showToast(this._t("toast.store_share_failed",{error:i.message||i},"Share failed: "+(i.message||i)),"error")}});return}if(s&&(s.type==="export-select"||s.type==="import-wizard")){const a=s.type==="export-select"?{categories:s.inventory||{}}:s.manifest||{categories:{}};if(t==="wiz-toggle-all"){const o=a.categories||{},i=s.type==="import-wizard";let l=!0;this._wizCatOrder().filter(c=>o[c]&&o[c].present).forEach(c=>{i&&o[c].importable===!1||this._wizCatState(s,c,a).state!=="all"&&(l=!1)}),s.sel=l?{cats:new Set,profiles:new Set,realIds:new Set,refIds:new Set}:this._wizInitSel(a,i),this._render();return}if(t==="wiz-toggle-cat"){const o=e.dataset.cat,l=this._wizCatState(s,o,a).state!=="all";if(o==="profiles"){const c=a.categories.profiles&&a.categories.profiles.items||[];s.sel.profiles=new Set(l?c.map(_=>_.name):[])}else if(o==="real_cycles"||o==="reference_cycles"){const c=new Set;l&&(a.categories[o]&&a.categories[o].groups||[]).forEach(_=>_.cycles.forEach(p=>{p.id!=null&&c.add(String(p.id))})),o==="real_cycles"?s.sel.realIds=c:s.sel.refIds=c}else l?s.sel.cats.add(o):s.sel.cats.delete(o);this._render();return}if(t==="wiz-toggle-profile"){const o=e.dataset.name;s.sel.profiles.has(o)?s.sel.profiles.delete(o):s.sel.profiles.add(o),this._render();return}if(t==="wiz-toggle-cycgroup"){const o=e.dataset.cat,i=e.dataset.prof,l=o==="real_cycles"?s.sel.realIds:s.sel.refIds,c=this._wizGroupIds(a,o,i),_=c.length>0&&c.every(p=>l.has(p));c.forEach(p=>{_?l.delete(p):l.add(p)}),this._render();return}if(t==="wiz-toggle-cyc"){const o=e.dataset.cat,i=e.dataset.cid,l=o==="real_cycles"?s.sel.realIds:s.sel.refIds;l.has(i)?l.delete(i):l.add(i),this._render();return}if(t==="wiz-expand"){const o=e.dataset.key;s.expanded||(s.expanded=new Set),s.expanded.has(o)?s.expanded.delete(o):s.expanded.add(o),this._render();return}}if(s&&s.type==="import-wizard"){if(t==="import-back"){s.step="input",s.error=null,this._render();return}if(t==="imp-mode-merge"){s.mode="merge",this._render();return}if(t==="imp-mode-replace"){s.mode="replace",this._render();return}if(t==="imp-dest-reference"){s.cycleDest="reference",this._render();return}if(t==="imp-dest-real"){(s.manifest||{}).real_history_allowed!==!1&&(s.cycleDest="real_history",this._render());return}if(t==="imp-conflict"){s.conflicts[e.dataset.prof]=e.value;return}if(t==="import-analyze"&&r){const a=n.getElementById("wd-import-json"),o=a?a.value:s.jsonText||"";if(s.jsonText=o,!o.trim()){this._showToast(this._t("toast.json_required",{},"JSON data is required"),"error");return}s.step="analyze",s.error=null,this._render();try{const i=await this._ws({type:`${y}/analyze_import`,entry_id:r,json_data:o});if(!this._isActiveEntry(r)||!this._modal||this._modal.type!=="import-wizard")return;const l=i&&i.manifest||{};if(l.error){s.step="input",s.error=l.error,this._render();return}s.manifest=l,s.sel=this._wizInitSel(l,!0),s.conflicts={},(l.categories&&l.categories.profiles&&l.categories.profiles.items||[]).forEach(c=>{c.conflict&&(s.conflicts[c.name]="import_as_copy")}),s.step="select",this._render()}catch(i){if(!this._isActiveEntry(r)||!this._modal||this._modal.type!=="import-wizard")return;s.step="input",s.error=i&&i.message||String(i),this._render()}return}if(t==="import-apply-ok"&&r){const a=this._wizSelectionPayload(s);await this._busyRun("import-wizard",async()=>{try{const o=await this._ws({type:`${y}/import_config_selective`,entry_id:r,json_data:s.jsonText,selection:a,mode:s.mode,conflict_resolutions:s.conflicts,cycle_destination:s.cycleDest,apply_settings:!0}),i=o&&o.summary||{};this._modal=null,this._showToast(this._t("toast.import_selective_done",{profiles:i.profiles_imported||0,cycles:(i.real_cycles_imported||0)+(i.reference_cycles_imported||0)},`Imported ${i.profiles_imported||0} profile(s) and ${(i.real_cycles_imported||0)+(i.reference_cycles_imported||0)} cycle(s)`)),await this._fetchCycles(r),await this._fetchProfiles(r)}catch(o){this._showToast(this._t("toast.import_failed",{error:o.message||o},"Import failed: "+(o.message||o)),"error")}});return}}}async _onMActStoreShare(t,e,s,r){if(s&&s.type==="store-share-device"){if(t==="sd-toggle-cyc"){const n=e.dataset.cid;s.selected.has(n)?s.selected.delete(n):s.selected.add(n),this._render();return}if(t==="sd-toggle-prof"){const n=e.dataset.prog,a=this._shareableByProgram().find(o=>o.program===n);if(a){const o=a.cycles.every(i=>s.selected.has(i.id));a.cycles.forEach(i=>{o?s.selected.delete(i.id):s.selected.add(i.id)})}this._render();return}if(t==="sd-toggle-phases"){const n=e.dataset.prog;s.includePhases||(s.includePhases=new Set),s.includePhases.has(n)?s.includePhases.delete(n):s.includePhases.add(n),this._render();return}if(t==="sd-toggle-settings"){s.includeSettings=!s.includeSettings,this._render();return}if(t==="sd-toggle-consent"){s.consented=!s.consented,this._render();return}if(t==="sd-toggle-guide"){s.guideOpen=!s.guideOpen,this._render();return}if(t==="store-share-device-ok"){const n=new Map;(this._shareableCycles||[]).forEach(l=>n.set(l.id,(l.profile_name||"").trim()));const a=Array.from(s.selected).map(l=>({local_cycle_id:l,program:n.get(l)||""})).filter(l=>l.program);if(!a.length){this._showToast(this._t("toast.share_device_none_sel",{},"Select at least one cycle to share"),"error");return}const o=new Set(a.map(l=>l.program)),i=Array.from(s.includePhases||[]).filter(l=>o.has(l));await this._busyRun("store-share-device",async()=>{try{const l=await this._ws({type:`${y}/store_upload_device`,entry_id:r,items:a,include_phases:i,include_settings:!!s.includeSettings});if(l&&l.error){if(l.error==="no_appliance_declared")this._showToast(this._t("toast.store_no_appliance",{},"Set your appliance brand and model in Settings first."),"error");else{const u=l.detail?`${l.error} - ${l.detail}`:l.error;this._showToast(this._t("toast.store_share_failed",{error:u},"Share failed: "+u),"error")}return}const c=l&&l.cycle_ids&&l.cycle_ids.length||0,_=l&&l.errors&&l.errors.length||0,p=l&&l.duplicates||0,g=l&&l.created!=null?l.created:c;if(!c){const u=l&&l.errors&&l.errors[0]||l&&l.detail||"upload_failed";this._showToast(this._t("toast.store_share_failed",{error:u},"Share failed: "+u),"error");return}this._modal=null,_?this._showToast(this._t("toast.store_device_shared_partial",{n:c,failed:_},`Shared ${c} cycle(s); ${_} could not be uploaded.`),"info"):p&&!g?this._showToast(this._t("toast.store_device_shared_all_dup",{n:p},`All ${p} cycle(s) were already in the community store.`),"info"):p?this._showToast(this._t("toast.store_device_shared_some_dup",{created:g,dup:p},`Shared ${g} cycle(s); ${p} were already in the store.`)):this._showToast(this._t("toast.store_device_shared",{n:g},`Shared ${g} cycle(s) to the community store - pending review.`))}catch(l){this._showToast(this._t("toast.store_share_failed",{error:l.message||l},"Share failed: "+(l.message||l)),"error")}});return}}}async _onMActCycleDetail(t,e,s,r){if(e&&e.type==="cycle-detail"){if(t==="cyc-view"){e.mode="view",this._render();return}if(t==="cyc-trim"){e.mode="trim",(!e.trim||e.trim.end<=0)&&(e.trim={start:0,end:e.curve&&e.curve.full_duration_s||0}),this._render();return}if(t==="cyc-split"){e.mode="split",this._render();return}if(t==="cyc-review"){e.mode="review",this._render();return}if(t==="cyc-review-save"){const n=e.cycleId,a=r.getElementById("wd-cyc-rev-quality")?.value||"",o=!!r.getElementById("wd-cyc-rev-golden")?.checked,i=r.getElementById("wd-cyc-rev-notes")?.value||"",l=Array.from(r.querySelectorAll(".wd-cyc-rev-tag")).filter(p=>p.checked).map(p=>p.value),c=r.getElementById("wd-cyc-rev-label")?.value??"",_=e.curve&&e.curve.profile_name||"";await this._busyRun("cyc-review-save",async()=>{try{await this._ws({type:`${y}/set_ml_review`,entry_id:s,cycle_id:n,quality:a,golden:o,tags:l,notes:i}),c!==_&&await this._ws({type:`${y}/label_cycle`,entry_id:s,cycle_id:n,profile_name:c||null}),this._showToast(this._t("toast.review_saved",{},"Review saved")),await this._fetchCycles(s),c!==_&&await this._fetchFeedbacks(s),await this._loadMlIndex(s),this._modal&&this._modal.cycleId===n&&(this._modal.ml=(this._mlById||{})[n]||this._modal.ml)}catch(p){this._showToast(this._t("msg.toast_save_failed",{error:p.message||p},"Save failed: "+(p.message||p)),"error")}});return}if(t==="trim-mode-s"){e.timeMode="s",this._render();return}if(t==="trim-mode-clock"){e.timeMode="clock",this._render();return}if(t==="cyc-reset-trim"){e.trim={start:0,end:e.curve&&e.curve.full_duration_s||0},this._render();return}if(t==="cyc-clear-split"){e.split={offsets:[],profiles:[]},this._render();return}if(t==="cyc-label"){this._profiles.length||await this._fetchProfiles(s),this._modal={type:"label-cycle",cycleId:e.cycleId},this._render();return}if(t==="cyc-delete"){const n=e.cycleId;this._modal=null,this._render(),this._deleteCyclesWithUndo(s,[n]);return}if(t==="cyc-auto-split"){const n=parseInt(r.getElementById("wd-split-gap")?.value||"900",10);await this._busyRun("cyc-auto",async()=>{try{const a=await this._ws({type:`${y}/analyze_split`,entry_id:s,cycle_id:e.cycleId,gap_seconds:n});e.split.offsets=(a.split_offsets||[]).slice(),e.split.profiles=[],e.split.offsets.length||this._showToast(this._t("toast.no_split_found",{},"No idle gaps found to split on"),"info")}catch(a){this._showToast(this._t("toast.auto_detect_failed",{error:a.message||a},"Auto-detect failed: "+(a.message||a)),"error")}});return}if(t==="cyc-apply-trim"){const n=e.cycleId,a=e.trim.start,o=e.trim.end,i=e.curve&&e.curve.full_duration_s||0,l=i>0?Math.max(0,Math.round((o-a)/i*100)):100;if(l<50&&!confirm(this._t("msg.trim_destructive_confirm",{pct:l},`This keeps only ${l}% of the cycle and cannot be undone. Continue?`)))return;this._kickAndTrack({type:`${y}/trim_cycle`,entry_id:s,cycle_id:n,start_s:a,end_s:o},"cyc-trim-apply",async()=>{this._showToast(this._t("toast.cycle_trimmed",{},"Cycle trimmed")),await this._closeCycleDetail(s),await this._fetchCycles(s)});return}if(t==="cyc-apply-split"){const n=e.cycleId,a=e.split.offsets.slice(),o=e.split.profiles.slice();this._kickAndTrack({type:`${y}/apply_split`,entry_id:s,cycle_id:n,split_offsets:a,segment_profiles:o},"cyc-split-apply",async i=>{this._showToast(this._t("toast.split_complete",{count:(i.new_ids||[]).length},`Split into ${(i.new_ids||[]).length} cycles`)),await this._closeCycleDetail(s),await this._fetchCycles(s),await this._fetchProfiles(s)});return}}}async _onMActProfilePanel(t,e,s,r,n){if(s&&s.type==="profile-panel"){if(t==="pp-phase-add"){const a=s.env&&s.env.target_duration||(s.env&&s.env.avg&&s.env.avg.length?s.env.avg[s.env.avg.length-1][0]:600),o=s.phases.length?s.phases[s.phases.length-1].end:0,i=Math.min(o,a);s.phases.push({name:s.catalog[0]||"",start:i,end:Math.min(i+Math.max(60,a*.1),a)}),this._render();return}if(t==="pp-phase-rm"){const a=+(e&&e.dataset.idx||-1);a>=0&&(s.phases.splice(a,1),this._render());return}if(t==="pp-phase-save"){const a=s.phases.filter(o=>o.name).map(o=>({name:o.name,start:o.start,end:o.end}));await this._busyRun("pp-phase-save",async()=>{try{await this._ws({type:`${y}/set_profile_phases`,entry_id:r,profile_name:s.name,phases:a}),this._showToast(this._t("toast.phases_saved",{},"Phases saved"))}catch(o){this._showToast(this._t("msg.toast_save_failed",{error:o.message||o},"Save failed: "+(o.message||o)),"error")}});return}if(t==="pp-cleanup-del"){const a=s.cleanup?Array.from(s.cleanup.selected):[];if(!a.length)return;await this._busyRun("pp-cleanup-del",async()=>{try{for(const i of a)await this._ws({type:`${y}/delete_cycle`,entry_id:r,cycle_id:i});this._showToast(this._t("toast.cycles_deleted",{count:a.length},`Deleted ${a.length} cycle(s)`));const o=await this._ws({type:`${y}/get_profile_cycles`,entry_id:r,profile_name:s.name});this._modal&&(this._modal.cleanup={cycles:o.cycles||[],selected:new Set}),await this._fetchProfiles(r)}catch(o){this._showToast(this._t("msg.toast_delete_failed",{error:o.message||o},"Delete failed: "+(o.message||o)),"error")}});return}if(t==="pp-rename"){const a=n.getElementById("wd-pp-rename")?.value?.trim(),o=parseFloat(n.getElementById("wd-pp-dur")?.value||"0");if(!a){this._showToast(this._t("msg.toast_name_required",{},"Name required"),"error");return}try{await this._ws({type:`${y}/rename_profile`,entry_id:r,profile_name:s.name,new_name:a,manual_duration_min:o>0?o:null}),this._showToast(this._t("toast.profile_renamed",{},"Profile renamed")),s.name=a,await Promise.all([this._fetchProfiles(r),this._fetchProfileGroups(r)]),s.stats=(this._profiles||[]).find(i=>i.name===a)||s.stats,this._render()}catch(i){this._showToast(this._t("toast.rename_failed",{error:i.message||i},"Rename failed: "+(i.message||i)),"error")}return}if(t==="pp-rebuild"){this._kickAndTrack({type:`${y}/rebuild_envelopes`,entry_id:r},"pp-rebuild",async()=>{try{const a=await this._ws({type:`${y}/get_profile_envelope`,entry_id:r,profile_name:s.name});this._modal&&(this._modal.env=a.envelope)}catch{}this._showToast(this._t("toast.envelope_rebuilt",{},"Envelope rebuilt"))});return}if(t==="pp-delete"){this._deleteProfileWithUndo(r,s.name);return}}}_snapshotFormToPending(t){t&&t.querySelectorAll("#wd-settings-form [data-opt], #wd-ml-form [data-opt]").forEach(e=>{const s=e.dataset.opt,r=kt[s],n=r&&r.type||e.dataset.ftype||"text";if(e.type==="checkbox"){this._pendingSettings[s]=e.checked;return}if(n==="checkboxlist"){this._pendingSettings[s]=this._collectCheckboxlist(e,s);return}if(n==="entitylist"){this._pendingSettings[s]=Array.from(e.querySelectorAll(".wd-pill")).map(a=>a.dataset.val).filter(Boolean);return}if(n==="timerlist"){this._pendingSettings[s]=Array.from(e.querySelectorAll(".wd-timer-row")).map(a=>({offset_minutes:parseFloat(a.querySelector('[data-field="offset_minutes"]').value)||0,message:(a.querySelector('[data-field="message"]').value||"").trim(),auto_pause:a.querySelector('[data-field="auto_pause"]').checked})).filter(a=>a.offset_minutes>0);return}if(n==="number"){const a=String(e.value).trim();if(a===""){r&&r.clearable?this._pendingSettings[s]=null:delete this._pendingSettings[s];return}const o=parseFloat(a);isNaN(o)||(this._pendingSettings[s]=o);return}if(n==="list"){this._pendingSettings[s]=String(e.value).split(",").map(a=>a.trim()).filter(Boolean);return}if(n==="intlist"){this._pendingSettings[s]=ee(e.value);return}if(n==="json"){const a=String(e.value).trim();if(!a){this._pendingSettings[s]=[];return}try{this._pendingSettings[s]=JSON.parse(a)}catch{}return}if(n==="entity"||n==="device"){const a=String(e.value).trim();this._pendingSettings[s]=a||null;return}this._pendingSettings[s]=e.value})}_collectCheckboxlist(t,e){const s=Array.from(t.querySelectorAll("[data-choice]")).filter(n=>n.checked).map(n=>n.dataset.choice);if(s.length)return s;const r=kt[e];return r&&Array.isArray(r.def)&&r.def.length?r.def.slice():s}_conflictKeysForOpts(t,e){const s=Object.assign({},e||{},t),r=new Set;for(const n of Vt)if(n.check(s))for(const a of Object.keys(n.fieldErrors(s)))r.add(a);return r}_conflictCountForOpts(t,e){return this._conflictKeysForOpts(t,e).size}_conflictKeysFromOpts(){return this._conflictKeysForOpts(Object.assign({},this._opts,this._pendingSettings),this._optDefaults)}_readSettingsFormValues(t){const e=Object.assign({},this._optDefaults,this._opts,this._pendingSettings);return t&&t.querySelectorAll("#wd-settings-form [data-opt]").forEach(s=>{const r=s.dataset.opt;if(s.type==="checkbox"){e[r]=s.checked;return}if(s.dataset.ftype==="checkboxlist"){e[r]=this._collectCheckboxlist(s,r);return}const n=parseFloat(s.value);isNaN(n)?s.value!==""&&(e[r]=s.value):e[r]=n}),e}_liveValidateSettings(t){if(!t)return{};const e=this._readSettingsFormValues(t),s=t.getElementById("wd-settings-form");if(!s)return{};const r={};for(const a of this._suggestions||[])a.key!=null&&a.suggested!=null&&(r[a.key]=+a.suggested);const n={};for(const a of Vt){if(!a.check(e))continue;const o=a.fieldErrors(e);for(const[i,l]of Object.entries(o)){const c=r[i],_=c!=null&&!a.check({...e,[i]:c})?{...l,suggFix:c}:l;(n[i]=n[i]||[]).push(_)}}return s.querySelectorAll("[data-cerr]").forEach(a=>{const o=a.dataset.cerr,i=n[o],l=s.querySelector(`.wd-field[data-field="${o}"]`);!i||!i.length?(a.hidden=!0,a.innerHTML="",l&&l.classList.remove("wd-has-conflict")):(a.hidden=!1,l&&l.classList.add("wd-has-conflict"),a.innerHTML=i.map(c=>{const _=this._t(c.msgKey,c.msgVars,c.msgFb);let p="";if(c.suggFix!=null){const g=+c.suggFix.toFixed(2);p=`<span class="wd-conflict-sug-note">${this._t("conflict.suggestion_resolves",{val:g},`Stage the pending suggestion (${g}) below to fix this`)}</span>`}else if(c.fixVal!=null&&!isNaN(+c.fixVal)){const g=Number.isInteger(c.fixVal)?c.fixVal:+c.fixVal.toFixed(2);p=`<button type="button" class="wd-conflict-fix" data-ckey="${o}" data-cval="${c.fixVal}">${this._t("conflict.use_fix",{val:g},`Use ${g}`)}</button>`}return`<div class="wd-conflict-row">\u26A0 ${d(_)}${p}</div>`}).join(""))}),n}_cascadeConflictFix(t,e,s){const r=new Set;for(let n=0;n<10;n++){const a=this._liveValidateSettings(t);let o=!1;for(const[i,l]of Object.entries(a)){if(i===s)continue;const c=l.find(p=>p.fixVal!=null&&!isNaN(+p.fixVal)&&+p.fixVal>0);if(!c)continue;const _=e.querySelector(`[data-opt="${i}"]`);_?_.value=c.fixVal:(this._preCascadeOpts==null&&(this._preCascadeOpts=JSON.parse(JSON.stringify(this._opts||{}))),this._opts={...this._opts,[i]:c.fixVal},(this._cascadePending??={})[i]=c.fixVal),r.add(i),o=!0;break}if(!o)break}if(this._liveValidateSettings(t),this._snapshotFormToPending(t),r.size>0){const n=r.size,a=n>1?"s":"";this._showToast(this._t("conflict.cascade_toast",{n},`Other settings adjusted for consistency: ${n}`),"success")}}async _saveSettings(){const t=this.shadowRoot,e=this._devices[this._selIdx];if(!e)return;const s=Object.assign({},this._pendingSettings,this._cascadePending);this._invalidJson=null,t.querySelectorAll("[data-opt]").forEach(o=>{const i=o.dataset.opt,l=kt[i],c=l&&l.type||o.dataset.ftype||"text";if(o.type==="checkbox"){s[i]=o.checked;return}if(c==="checkboxlist"){s[i]=this._collectCheckboxlist(o,i);return}if(c==="entitylist"){s[i]=Array.from(o.querySelectorAll(".wd-pill")).map(p=>p.dataset.val).filter(Boolean);return}if(c==="timerlist"){s[i]=Array.from(o.querySelectorAll(".wd-timer-row")).map(p=>({offset_minutes:parseFloat(p.querySelector('[data-field="offset_minutes"]').value)||0,message:(p.querySelector('[data-field="message"]').value||"").trim(),auto_pause:p.querySelector('[data-field="auto_pause"]').checked})).filter(p=>p.offset_minutes>0);return}const _=o.value;if(c==="number"){const p=String(_).trim();if(p===""){l&&l.clearable?s[i]=null:delete s[i];return}const g=parseFloat(p);isNaN(g)||(s[i]=g);return}if(c==="list"){s[i]=String(_).split(",").map(p=>p.trim()).filter(Boolean);return}if(c==="intlist"){s[i]=ee(_);return}if(c==="json"){const p=String(_).trim();if(!p){s[i]=[];return}try{s[i]=JSON.parse(p)}catch{this._invalidJson=i}return}if(c==="entity"||c==="device"){const p=String(_).trim();s[i]=p||null;return}s[i]=_});const r=this._optDefaults||{};for(const o of Object.keys(r))!(o in this._opts)&&o in s&&JSON.stringify(s[o])===JSON.stringify(r[o])&&delete s[o];if(this._invalidJson){this._showToast(this._t("toast.invalid_json",{key:this._invalidJson},`"${this._invalidJson}" is not valid JSON - fix it or clear the field before saving.`),"error");return}const n=this._liveValidateSettings(t),a=new Set(Object.keys(n));if(a.size>0){const o={},i={};for(const[l,c]of Object.entries(s)){if(a.has(l)){i[l]=c;continue}JSON.stringify(c)!==JSON.stringify((this._opts||{})[l])&&(o[l]=c)}this._pendingSettings={...this._pendingSettings,...i},Object.keys(o).length?await this._busyRun("save-settings",async()=>{try{await this._ws({type:`${y}/set_options`,entry_id:e.entry_id,options:o}),this._opts={...this._opts,...o},this._showToast(this._t("toast.saved_except_conflicts",{},"Saved. Fix the highlighted conflicts to save the rest."),"info")}catch(l){this._showToast(this._t("msg.toast_save_failed",{error:l.message||l},"Save failed: "+(l.message||l)),"error")}}):this._showToast(this._t("toast.settings_conflicts",{},"Fix the highlighted setting conflicts before saving."),"error");return}await this._busyRun("save-settings",async()=>{try{const o=JSON.parse(JSON.stringify(this._preCascadeOpts||this._opts||{}));if(await this._ws({type:`${y}/set_options`,entry_id:e.entry_id,options:s}),this._opts={...this._opts,...s},this._prevOpts=o,this._cascadePending={},this._preCascadeOpts=null,this._pendingSettings={},this._stagedSuggestions){try{await this._ws({type:`${y}/clear_suggestions`,entry_id:e.entry_id})}catch{}this._stagedSuggestions=!1,this._suggestions=[]}this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading"))}catch(o){this._showToast(this._t("msg.toast_save_failed",{error:o.message||o},"Save failed: "+(o.message||o)),"error")}})}}customElements.get("ha-washdata-panel")||customElements.define("ha-washdata-panel",me);
|