2123 lines
606 KiB
JavaScript
2123 lines
606 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 w="ha_washdata",Zt=(()=>{try{return new URL(import.meta.url).searchParams.get("v")||""}catch{return""}})(),ve=2e4,xe=6e3,zt=25,St=50,$e="https://github.com/iluebbe/maintenance_supporter",Mt=["running","starting","paused","user_paused","ending","anti_wrinkle"],ke=[{key:"show_contributor",def:!0,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.'},{key:"share_name",def:!1,labelKey:"lbl.share_name",labelFb:"Show my name on what I share",docKey:"setting.share_name.doc",docFb:"Show your account name as the uploader of cycles you share. Off: shared without a name."}],Bt=34,Qt=.5,Se=200,Te=5,Tt=22,Ft=[0,5,10,30,60],Ee=10,Ce=320,Q=["#e6194B","#3cb44b","#4363d8","#f58231","#911eb4","#42d4f4","#f032e6","#bfef45","#fabed4","#469990","#dcbeff","#9A6324","#800000","#aaffc3","#808000","#ffd8b1","#000075","#a9a9a9"],Pe="{device}, {duration}, {minutes}, {program}, {energy_kwh}, {cost}, {time_finished}, {vs_typical}, {cycle_count}",yt=[{id:"basic",label:"Basic",intro:"Core identity and the essentials most setups need.",groups:[{sub:"Device info",fields:[{key:"name",label:"Device Name",basic:!0,type:"text",doc:"Name shown in Home Assistant for this device."},{key:"device_type",label:"Device Type",basic:!0,type:"devicetype",doc:"Sets detection defaults (thresholds, off delay, end handling) for this kind of appliance. Change it only if the device was set up as the wrong type."},{key:"store_brand",label:"Appliance Brand",type:"storebrand",optional:!0,basic:!0,doc:"Optional. Your appliance brand from the community catalog, used to find and share reference recordings. Leave blank if you do not use online features."},{key:"store_model",label:"Appliance Model",type:"storemodel",optional:!0,basic:!0,doc:"Optional. Your appliance model from the community catalog (pick a brand first). If it is not listed, you can add it."}]},{sub:"Basic configuration",fields:[{key:"power_sensor",label:"Power Sensor",basic:!0,type:"entity",domain:"sensor",doc:"The sensor that reports this appliance's live power in watts (e.g. sensor.washer_power). All detection is based on it."},{key:"min_power",label:"Minimum Power",unit:"W",type:"number",step:.1,min:0,def:2,basic:!0,doc:"Readings below this count as 0 W (standby). Filters out the idle draw of smart plugs and standby LEDs."},{key:"off_delay",label:"Off Delay",unit:"s",type:"number",min:0,def:180,basic:!0,doc:"How long power must stay low before the cycle is finished. Power coming back within this time continues the cycle. Dishwashers can be idle 20-60 min while drying, so set it longer than that."},{key:"linked_device",label:"Group Under Device",type:"device",doc:'Optional. Show this WashData device under another device (e.g. the smart plug) in Home Assistant, as "Connected via ...".'}]},{sub:"Energy",fields:[{key:"energy_sensor",label:"Energy Meter Entity",type:"entity",domain:"sensor",optional:!0,doc:"Optional kWh or Wh counter (total_increasing, e.g. the plug's own meter). Cycle energy is then read from it, which is more accurate than computing it from a slow power sensor. If its reading is unusable, the computed value is used."},{key:"energy_price_entity",label:"Energy Price Entity",type:"entity",domain:"sensor",basic:!0,notPrice:!0,doc:"Sensor with the current price per kWh (e.g. a dynamic tariff). It overrides the static price below, so it must be a price, not the plug's power or energy sensor."},{key:"energy_price_static",label:"Static Energy Price (per kWh)",type:"number",step:.001,min:0,basic:!0,doc:"Fixed price per kWh, used when no price entity is set."},{key:"energy_price_dynamic",label:"Time-Weighted Cost",type:"checkbox",def:!0,basic:!0,doc:"Charge each cycle at the price at each moment it ran, instead of the price when it finished. Needs a price entity. Process History (Diagnostics) recosts older cycles."}]}]},{id:"detection",label:"Detection",intro:"How a cycle is detected as starting, running and finishing.",groups:[{sub:"Thresholds & Gap",fields:[{key:"start_threshold_w",label:"Start Threshold",unit:"W",type:"number",step:1,min:0,doc:"Power must rise above this to start a cycle. Too low: standby power causes false starts. Too high: slow-starting programs (cold fill) are missed."},{key:"stop_threshold_w",label:"Stop Threshold",unit:"W",type:"number",step:.1,min:0,doc:"Power must fall below this before the off delay starts counting. Keep it below the Start Threshold. Too high, and low-power phases (rinse holds, anti-crease) start the end countdown by mistake."},{key:"min_off_gap",label:"Min Off Gap",unit:"s",type:"number",min:0,doc:"An off period shorter than this does not split a cycle, so soak pauses stay in one wash. Keep it shorter than the gap between back-to-back loads, or those merge into one cycle."}]},{sub:"Cycle Start",fields:[{key:"start_duration_threshold",label:"Start Duration",internal:!0,unit:"s",type:"number",min:0,def:5,doc:"Power must stay above the Start Threshold this long before a cycle starts, so brief on/off blips are ignored."},{key:"start_energy_threshold",label:"Start Energy",internal:!0,unit:"Wh",type:"number",step:.01,min:0,def:.2,doc:"Energy the appliance must use before the cycle counts as running. A short power spike uses little energy, so it does not start a cycle."},{key:"completion_min_seconds",label:"Min Cycle Duration",unit:"s",type:"number",min:0,def:600,doc:"Cycles shorter than this (test runs, opening the door to add a sock) are saved as Interrupted and get no finish notification."},{key:"curve_preroll_seconds",label:"Curve Pre-roll",unit:"s",type:"number",step:10,min:0,max:600,def:0,doc:"Adds readings from failed start attempts to the front of a cycle, for machines that probe before starting (door lock, first fill). 300 s covers most; 0 = off. Recorded starts move earlier, so learned durations drift for a while."}]},{sub:"Cycle End",fields:[{key:"end_energy_threshold",label:"End Energy",unit:"Wh",type:"number",step:.001,min:0,def:.05,doc:"Energy allowed during the off delay. More than this and the cycle keeps going, so tumbles and drying tails stay attached. Lower it if cycles end during a tail; raise it if finished cycles keep waiting."},{key:"smart_termination_duration_ratio",label:"Smart Termination Ratio",type:"number",step:.01,min:.5,max:1,doc:"Fraction of the program's average duration a cycle must reach before Smart Termination may end it early. If run times vary (water temperature, sensor-dry, load size), lower it (e.g. 0.85). Leave empty for 0.98 (0.99 on dishwashers)."}]},{sub:"Power Off",fields:[{key:"power_off_threshold_w",label:"Power Off Threshold",unit:"W",type:"number",step:.1,min:0,def:0,doc:"After a cycle, power below this for the Power Off Delay sets the state to Off, instead of the Progress Reset Delay doing it. Set it above the switched-off draw, below standby and the Stop Threshold. 0 disables it (default)."},{key:"power_off_delay",label:"Power Off Delay",unit:"s",type:"number",min:0,def:30,doc:"How long power must stay below the Power Off Threshold after a cycle before the state returns to Off. Only used when that threshold is above 0. Checked periodically, so the real wait can be slightly longer."}]},{sub:"Signal Processing",fields:[{key:"sampling_interval",label:"Sampling Interval",internal:!0,unit:"s",type:"number",min:1,def:30,doc:"Minimum time between processed readings. Readings that arrive sooner are skipped (low readings during a cycle are always kept). Set it to your plug's reporting interval or lower; a higher value only throws readings away."}]}]},{id:"matching",label:"Matching",intro:"How finished cycles are matched to learned profiles and labelled.",notDeviceTypes:["other"],groups:[{sub:"Match Scoring",fields:[{key:"profile_match_threshold",label:"Match Threshold",type:"number",step:.01,min:0,max:1,def:.4,doc:"Live match score a cycle needs before Smart Termination or the anti-crease finish may end it early. It does not change the shown program. Raise it if weak matches end cycles early; lower it if clear matches wait the off delay."},{key:"profile_unmatch_threshold",label:"Unmatch Threshold",internal:!0,type:"number",step:.01,min:0,max:1,def:.35,doc:"If the live match score drops below this, the current program guess is cleared. Keep it a little below the Match Threshold so a brief dip does not reset it."},{key:"profile_match_interval",label:"Match Interval",internal:!0,unit:"s",type:"number",min:0,doc:"How often to match a running cycle against profiles. Default 300 s (5 min)."}]},{sub:"Duration Gates",fields:[{key:"profile_match_min_duration_ratio",label:"Min Duration Ratio",internal:!0,type:"number",step:.01,min:0,max:1,def:.1,doc:"A program is only considered after the cycle has run this fraction of its typical duration. 0.10: a 2-hour program becomes a candidate after 12 minutes. Not applied in a cycle's first 15 minutes."},{key:"profile_match_max_duration_ratio",label:"Max Duration Ratio",internal:!0,type:"number",step:.01,min:0,def:1.8,doc:"A program is dropped once the cycle has run longer than this multiple of its typical duration. 1.8: a cycle 80% longer than the program no longer matches it."},{key:"duration_tolerance",label:"Estimate Tolerance",internal:!0,type:"number",step:.01,min:0,max:1,def:.1,doc:"Learning feedback only: a cycle within +/-X% of its estimated duration counts as a good estimate. Does not affect matching."}]},{sub:"Profile Evidence",fields:[{key:"profile_evidence_sources",label:"Cycles that shape a program",type:"checkboxlist",def:["real_cycles","reference_cycles","backfill_cycles"],choices:[["real_cycles","Cycles this machine ran"],["reference_cycles","Downloaded from the community store"],["backfill_cycles","Found in imported power history"]],doc:"Which kinds of cycles shape each program's curve and matching. Unticking a kind deletes nothing; useful if you do not trust imported data. Statistics always count only this machine's cycles. Unticking all is ignored."}]},{sub:"Auto-Labeling",fields:[{key:"auto_label_confidence",label:"Auto-Label Confidence",type:"number",step:.01,min:0,max:1,def:.9,doc:"Match score at cycle end at or above which the program is labelled without asking. Raise it for more certainty; lower it to label more cycles automatically. Keep it above Learning Confidence."},{key:"learning_confidence",label:"Learning Confidence",internal:!0,type:"number",step:.01,min:0,max:1,def:.6,doc:"Lowest match score for labelling a finished cycle. A clear winner is labelled; a close call goes to the review queue in Cycles. Below this, nothing is labelled or asked. Keep it below Auto-Label Confidence."}]}]},{id:"timing",label:"Timing & Watchdog",intro:"Background cadence, the offline watchdog and housekeeping.",groups:[{sub:"Watchdog",fields:[{key:"watchdog_interval",label:"Watchdog Interval",internal:!0,unit:"s",type:"number",min:1,def:30,doc:"How often the background check looks for stalled sensors and timeouts. Default 30 s."},{key:"no_update_active_timeout",label:"No-Update Timeout",internal:!0,unit:"s",type:"number",min:0,def:600,doc:"If no power update arrives for this long during a cycle, assume the plug went offline and force-stop the cycle. The default 600 s allows for cloud or mesh delays."}]},{sub:"Housekeeping",fields:[{key:"progress_reset_delay",label:"Progress Reset Delay",unit:"s",type:"number",min:0,def:1800,doc:"After a cycle, keep progress at 100% this long before resetting to Idle."},{key:"auto_maintenance",label:"Auto Maintenance (nightly cleanup)",type:"checkbox",def:!0,doc:"Nightly cleanup: label old cycles that now clearly match a program, rebuild program curves, refresh anomaly marks and cycle health, and remove debug traces if Save Debug Traces is off. Cycles and their power traces are always kept."},{key:"power_profile_interval_min",label:"Power Profile Interval",unit:"min",type:"number",min:1,def:15,doc:"Bucket size of the power_profile attribute (average watts per slot) used by planners such as EMHASS and tibber_prices. Smaller keeps short spikes; larger smooths the curve. Default 15 min. Does not affect detection."}]},{sub:"Debug",fields:[{key:"expose_debug_entities",label:"Expose Debug Entities",internal:!0,type:"checkbox",doc:"Add diagnostic entities (match confidence, ambiguity, internal state). Off keeps the entity list clean."},{key:"save_debug_traces",label:"Save Debug Traces",type:"checkbox",doc:"Also store the matcher's debug data (every candidate's scores) with each cycle, for troubleshooting. Adds about 20 KB per cycle."}]}]},{id:"anti_wrinkle",label:"Anti-Wrinkle",intro:"Keeps a dryer's low-power tumble pulses after the main phase attached to the finished cycle.",onlyDeviceTypes:["washing_machine","dryer","washer_dryer"],fields:[{key:"anti_wrinkle_enabled",label:"Enable Anti-Wrinkle Detection",basic:!0,type:"checkbox",doc:"Keep a dryer's short low-power tumble pulses after the main heat phase with the finished cycle, instead of reading them as new cycles."},{key:"anti_wrinkle_max_power",label:"Max Anti-Wrinkle Power",unit:"W",type:"number",step:10,min:0,def:400,doc:"A pulse above this power counts as a real new cycle, not a tumble. Set it just above your tumble-pulse power."},{key:"anti_wrinkle_max_duration",label:"Max Duration",unit:"s",type:"number",min:0,def:60,doc:"A pulse longer than this counts as a real cycle, not an anti-wrinkle tumble."},{key:"anti_wrinkle_exit_power",label:"Exit Power Threshold",internal:!0,unit:"W",type:"number",step:.1,min:0,def:.8,doc:"In anti-wrinkle mode, power below this counts as quiet between tumble pulses, and quiet for longer than the Max Pulse Gap ends the mode. The Stop Threshold is used when it is higher, so this only changes anything when set above the Stop Threshold."},{key:"anti_wrinkle_idle_timeout",label:"Max Pulse Gap",unit:"s",type:"number",step:30,min:0,def:120,doc:"How long the dryer may stay quiet between tumble pulses before anti-wrinkle mode ends. Set it above your dryer's longest gap, or later pulses are read as false starts."},{key:"anti_crease_finalize_ratio",label:"Anti-Crease Finalize Ratio",type:"number",step:.01,min:.5,max:1,def:.98,doc:"Fraction of the program's average duration a cycle must reach before its low-power tumbling counts as the anti-crease tail. Lower it (e.g. 0.75) on load-sensing dryers. Keep washers at the default (0.98), or a wash can end halfway."}]},{id:"dishwasher",label:"Dishwasher",intro:"End handling for dishwashers, which often dry quietly for a long time before a short final drain.",onlyDeviceTypes:["dishwasher"],fields:[{key:"dishwasher_end_spike_quiet_release",label:"Passive-Dry Quiet Release",unit:"s",type:"number",step:60,min:0,def:600,doc:"After the expected duration, how long the dishwasher must stay below the Stop Threshold before WashData stops waiting for the final drain. Raise it if a late drain after a long dry is missed. The wait never exceeds 30 minutes."}]},{id:"delay",label:"Delay Start",intro:"Detects an appliance that is on and waiting for a delayed start.",fields:[{key:"delay_start_detect_enabled",label:"Enable Delay-Start Detection",type:"checkbox",doc:"Detect an appliance that is on and waiting for a delayed start, so its standby draw is not taken for a running cycle."},{key:"delay_confirm_seconds",label:"Confirm Seconds",unit:"s",type:"number",min:0,def:60,doc:"Power must stay in the standby band this long before the appliance counts as waiting to start."},{key:"delay_timeout_hours",label:"Timeout Hours",unit:"h",type:"number",step:.5,min:0,def:8,doc:"Give up waiting for a delayed start after this many hours and return to idle."}]},{id:"triggers",label:"Triggers & Door",intro:"Optional extras: end trigger, door sensor, pause switch and unload reminder.",groups:[{sub:"External End Trigger",fields:[{key:"external_end_trigger_enabled",label:"Enable External End Trigger",type:"checkbox",doc:"Let a binary sensor signal the end of a cycle, in addition to power-based detection."},{key:"external_end_trigger",label:"External Trigger Entity",type:"entity",domain:"binary_sensor",doc:'Binary sensor whose change marks the cycle end (e.g. a "finished" contact or another integration).'},{key:"external_end_trigger_inverted",label:"Invert External Trigger (trigger on OFF)",type:"checkbox",doc:"Treat the sensor turning OFF (not ON) as the end signal."}]},{sub:"Door & Pause",fields:[{key:"door_sensor_entity",label:"Door Sensor Entity",basic:!0,type:"entity",domain:"binary_sensor",doc:"Optional door sensor. Shows when the appliance was opened to unload after a cycle."},{key:"door_opens_at_end",label:"Door Opens Automatically At End",type:"checkbox",doc:"For dishwashers that open the door at the end to dry (e.g. AirDry). A door left open for the dwell time below ends the cycle instead of pausing it; a brief open is ignored. Needs a Door Sensor Entity."},{key:"door_end_dwell_seconds",label:"Door-Open End Dwell",unit:"s",type:"number",min:1,def:60,doc:'With "Door Opens Automatically At End" on: how long the door must stay open before the cycle ends. Long enough to ignore adding a dish (default 60 s).'},{key:"pause_cuts_power",label:"Pause Also Cuts Power (via switch)",type:"checkbox",doc:"When a cycle is paused, also switch off the Switch Entity below. Only for appliances that can safely lose power mid-cycle."},{key:"switch_entity",label:"Switch Entity",type:"entity",domain:"switch",doc:'Optional switch turned off on pause and back on when resuming, with "Pause Also Cuts Power".'}]},{sub:"Unload Reminder",fields:[{key:"notify_unload_delay_minutes",label:"Unload Nag Delay",unit:"min",type:"number",min:0,def:60,basic:!0,doc:'Minutes after a cycle ends before the "unload the machine" reminder. 0 disables it.'},{key:"notify_unload_repeat",label:"Repeat Until Unloaded",type:"checkbox",doc:'Resend the unload reminder every "Unload Nag Delay" minutes until the unload is confirmed or you tap "Stop reminding" (mobile app). Needs a Door Sensor Entity, an Unload Confirmation Entity, or "Confirm Unload Manually".'},{key:"unload_confirm_entity",label:"Unload Confirmation Entity",type:"entity",domains:["event","button","input_button","binary_sensor","sensor","input_boolean","switch","scene","tag"],doc:"Optional. Any entity that confirms the load was taken out, for machines without a door sensor (a button, input_button, motion sensor, scene). Every state change counts except turning off or unavailable. Also enables the unload reminder."},{key:"unload_track_without_door",label:"Confirm Unload Manually",type:"checkbox",doc:'Run the unload reminder without a door sensor or confirmation entity. Confirm the unload from your own automation with the "Mark Unloaded" button or the ha_washdata.mark_unloaded service. Ignored when a Door Sensor Entity is set.'},{key:"pump_stuck_duration",label:"Pump Stuck Duration",unit:"s",type:"number",min:0,def:1800,onlyDeviceType:"pump",doc:"How long a pump may run nonstop before the stuck-pump event fires."}]}]},{id:"notifications",label:"Notifications",groups:[{sub:"Services",fields:[{key:"notify_start_services",label:"Start Services",type:"entitylist",domain:"notify",placeholder:"add a notify service\u2026",basic:!0,doc:"notify.* services to call when a cycle starts (phone, dashboard, ...). Leave empty for none."},{key:"notify_finish_services",label:"Finish Services",type:"entitylist",domain:"notify",placeholder:"add a notify service\u2026",basic:!0,doc:"notify.* services to call when a cycle finishes. Leave empty for none."},{key:"notify_live_services",label:"Live Progress Services",basic:!0,type:"entitylist",domain:"notify",placeholder:"add a notify service\u2026",doc:"notify.* services for live progress updates while a cycle runs. Leave empty to turn them off."},{key:"notify_people",label:"People (for Only When Home)",type:"entitylist",domain:"person",placeholder:"add a person\u2026",doc:'person.* entities that "Notify Only When Home" checks.'},{key:"notify_only_when_home",label:"Notify Only When Home",type:"checkbox",doc:"Only notify when at least one of the people above is home."},{key:"notify_fire_events",label:"Fire HA Events for Notifications",type:"checkbox",def:!0,doc:"Also fire ha_washdata_* events on cycle start and finish for your own automations."}]},{sub:"Timing",fields:[{key:"notify_before_end_minutes",label:"Pre-End Alert",unit:"min",type:"number",min:0,def:0,doc:"Send an Almost Done alert when estimated time remaining drops below this. 0 disables it."},{key:"notify_live_interval_seconds",label:"Live Update Interval",unit:"s",type:"number",min:30,def:300,doc:"How often live-progress notifications are refreshed while a cycle runs."},{key:"notify_live_overrun_percent",label:"Live Overrun % Before Alert",unit:"%",type:"number",min:0,def:20,doc:"Live updates stop once a cycle runs this percent past its expected duration (20 = up to 1.2x the expected time)."},{key:"notify_live_chronometer",label:"Use Live Chronometer",type:"checkbox",doc:"Show a live countdown timer in the notification (where supported) instead of a fixed estimate."},{key:"notify_live_silent",label:"Silent Live Updates",type:"checkbox",def:!0,doc:"iOS only. Update live progress silently, at a battery-saving priority that iOS may batch. The first update and the start/finish notifications still alert. Off: every update alerts."},{key:"notify_live_sticky",label:"Keep Live Notification On Tap",type:"checkbox",doc:"Android only. Keep the live progress notification when tapped. Off: a tap dismisses it."},{key:"notify_live_click_action",label:"Notification Tap Target",type:"text",optional:!0,doc:`What a tap on this appliance's notification opens. Blank: the WashData panel on this appliance. Enter a dashboard path (e.g. /lovelace/laundry) or URL, or "none" for no tap action.`},{key:"notify_timeout_seconds",label:"Auto-Dismiss After",unit:"s",type:"number",min:0,def:0,doc:"Dismiss the notification after this many seconds (where supported). 0 keeps it until you dismiss it."}]},{sub:"Messages",fields:[{key:"notify_title",label:"Notification Title",type:"text",def:"WashData: {device}",doc:"Notification title. Only {device} is substituted; any other variable falls back to the default title."},{key:"notify_icon",label:"Notification Icon",type:"text",def:"",doc:"Small icon on the notification (e.g. mdi:washing-machine). Sent only to mobile_app targets, not notify groups. On iOS it replaces the app icon and needs companion app 2026.8 or newer. Blank: platform default."},{key:"notify_icon_color",label:"Notification Colour",type:"color",def:"",placeholder:"#03A9F4",doc:"Accent colour for this appliance's notifications, so washer, dryer and dishwasher are easy to tell apart. On iOS it fills the circle behind the icon and colours the Live Activity bar. Many Android phones (Samsung especially) ignore it. Use hex, e.g. #03A9F4. Blank: platform default."},{key:"notify_start_message",label:"Start Message",type:"textarea",def:"{device} started.",doc:"Body sent when a cycle starts. Template variables: {device}, {program}. Any other variable falls back to the default text."},{key:"notify_finish_message",label:"Finish Message",type:"textarea",def:"{device} finished. Duration: {duration} min.",basic:!0,doc:"Body sent when a cycle finishes. Variables: {device}, {program}, {duration} (minutes), {duration_hm} (1 h 05 min), {energy_kwh}, {cost}, {time_finished}, {vs_typical}, {cycle_count}, {status} (completed or force_stopped; interrupted cycles send nothing). Any other variable falls back to the default text."},{key:"notify_pre_complete_message",label:"Pre-Complete Message",type:"textarea",def:"{device}: Less than {minutes} minutes remaining.",doc:"Text of each live progress update. Template variables: {device}, {program}, {minutes} (minutes left). Any other variable falls back to the default text."},{key:"notify_reminder_message",label:"Reminder Message",type:"textarea",def:"",doc:"Text of the one-time Pre-End Alert. Template variables: {device}, {program}, {minutes} (the Pre-End Alert setting). Any other variable falls back to the default text."},{key:"notify_channel",label:"Android Channel (start/live)",type:"text",def:"",placeholder:"e.g. WashData",suggestions:["WashData","WashData Status","Appliance Status"],doc:"Android channel for start and live messages; the channel sets sound and priority. Blank: app default."},{key:"notify_finish_channel",label:"Android Channel (finish)",type:"text",def:"",placeholder:"e.g. WashData Finished",suggestions:["WashData Finished","WashData Alerts","Appliance Finished"],doc:"Android channel for the finish message. Blank reuses the start/live channel."},{key:"peak_rate_threshold",label:"Peak-Rate Threshold (per kWh)",type:"number",step:.001,min:0,def:0,clearable:!0,doc:"If the price per kWh is at or above this when a cycle starts, add a peak-rate tip to the start notification. 0 or blank disables it."},{key:"peak_rate_message",label:"Peak-Rate Message",type:"text",def:"",placeholder:"Running at peak rate ({price}/kWh).",doc:"Custom text for the peak-rate tip. Template variables: {device}, {price}. Blank uses the default."}]},{sub:"Cycle Timers",fields:[{key:"notify_cycle_timers",label:"Cycle Timers",type:"timerlist",doc:"Notifications at set minutes into a cycle (e.g. to add softener). Message supports {device}, {program}, {minutes}. With Auto-pause, the cycle also pauses there and the notification gets a Resume button."}]},{sub:"Quiet Hours & Milestones",fields:[{key:"notify_quiet_start_hour",label:"Quiet Hours Start",basic:!0,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 in this window are held until it ends. Can cross midnight (e.g. 22 to 7). Blank disables it."},{key:"notify_quiet_end_hour",label:"Quiet Hours End",basic:!0,unit:"h",type:"number",min:0,max:23,clearable:!0,doc:"End of the do-not-disturb window (0-23); held notifications are sent then. Blank disables it."},{key:"notify_milestones",label:"Cycle Milestones",type:"intlist",def:"50, 100, 500, 1000",placeholder:"50, 100, 500, 1000",doc:"Cycle counts that trigger a one-off celebration notification, comma-separated (e.g. 50, 100, 500, 1000). Blank disables them."},{key:"notify_milestone_message",label:"Milestone Message",type:"textarea",def:"{device} has completed {cycle_count} cycles!",doc:"Message for the milestone notification. Template variables: {device}, {cycle_count}."}]}]},{id:"ml_training",label:"ML Training",fields:[{key:"enable_ml_models",label:"Apply smart models during a cycle",type:"checkbox",def:!1,doc:"Use a model trained on this machine to predict a running cycle's energy and cost, and let a well-known program that stops unusually early finish without the full off delay. Off: power-based logic only."},{key:"ml_training_enabled",label:"Learn from this machine",type:"checkbox",def:!1,doc:"Retrain the energy model on this machine's finished cycles from time to time. A new model is kept only if it beats the simple estimate on test cycles."},{key:"ml_training_hour",label:"Learn at hour",unit:"h",type:"number",min:0,max:23,def:2,doc:"Hour of day (0-23) for overnight training. Pick a quiet hour, e.g. 2."},{key:"ml_training_min_cycles",label:"Cycles needed first",type:"number",min:5,def:30,doc:"Wait for at least this many recorded cycles before training."},{key:"ml_training_interval_days",label:"Check at most every",unit:"days",type:"number",min:1,def:7,doc:"Retrain at most once every this many days."}]}],wt={};for(const h of yt){const t=h.groups||[{fields:h.fields}];for(const e of t)for(const s of e.fields||[])wt[s.key]=s}const te={profile_match_min_duration_ratio:.1,profile_match_max_duration_ratio:1.8},Ae=[{keys:["off_delay","min_off_gap"],check:h=>h.off_delay!=null&&h.min_off_gap!=null&&h.min_off_gap>h.off_delay,fieldNotes:h=>({off_delay:{msgKey:"note.off_delay.effective",msgVars:{eff:h.min_off_gap,gap:h.min_off_gap,delay:h.off_delay},msgFb:`A finished cycle actually waits ${h.min_off_gap} s, not ${h.off_delay} s: Min Off Gap (${h.min_off_gap} s) is longer and takes precedence.`}})}],Me=new Set(["energy","energy_storage","power","gas","water","current","voltage"]),Le=new Set(["w","kw","mw","wh","kwh","mwh","va","kva","varh","a","ma","v","mv"]);function Nt(h,t,e){if(!h)return null;if(h===t.power_sensor)return{msgKey:"conflict.price_entity.power_sensor",msgVars:{},msgFb:"This is the device's Power Sensor, not a price. Cycles would be costed in watts per kWh; clear this field."};if(h===t.energy_sensor)return{msgKey:"conflict.price_entity.energy_sensor",msgVars:{},msgFb:"This is the device's Energy Meter, not a price. Cycles would be costed at the meter reading per kWh; clear this field."};const s=e&&e.stateOf?e.stateOf(h):null;if(!s)return null;const a=s.attributes||{},r=String(a.device_class==null?"":a.device_class).trim().toLowerCase();if(Me.has(r))return{msgKey:"conflict.price_entity.device_class",msgVars:{dc:r},msgFb:`This sensor measures ${r}, not a price per kWh. Clear this field or pick a tariff sensor.`};const i=String(a.unit_of_measurement==null?"":a.unit_of_measurement).trim();return Le.has(i.toLowerCase())?{msgKey:"conflict.price_entity.unit",msgVars:{unit:i},msgFb:`This sensor reads in ${i}, not a price per kWh. Clear this field or pick a tariff sensor.`}:null}const ee=[{keys:["energy_price_entity","energy_sensor","power_sensor"],check:(h,t)=>Nt(h.energy_price_entity,h,t)!=null,fieldErrors:(h,t)=>({energy_price_entity:Nt(h.energy_price_entity,h,t)})},{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_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 below No-Update Timeout (${h.no_update_active_timeout} s) so the stuck alarm can fire first`,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 can fire first`,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,p:+(h.end_energy_threshold*3600/h.off_delay).toFixed(2)},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}})}],De=`
|
|
:host {
|
|
/* The panel owns the full height it is given and scrolls INTERNALLY (.wd-main).
|
|
HA renders a non-iframe custom panel into a plain container that sets only
|
|
display/background and the safe-area padding, and no height at all
|
|
(frontend src/panels/custom/ha-panel-custom.ts), so the old min-height: 100%
|
|
resolved against an auto-height parent and the panel simply grew with its
|
|
content: the DOCUMENT scrolled, .wd-main's overflow never engaged, and a
|
|
dropdown opened near the bottom was cut off by the window edge with no way
|
|
to reach the rest of it (scrolling the page moves the anchor and closes it).
|
|
The insets are subtracted because that same container adds them as padding
|
|
around us, so 100dvh alone would overflow by exactly that much. */
|
|
/* NOT display:flex, and the layout must not depend on it: a rule in the embedding
|
|
document targeting our tag beats any :host rule regardless of specificity, and
|
|
both HA and the E2E fixture set display:block on the panel element. The height
|
|
survives that because _syncPanelHeight writes it inline. .wd-root below takes
|
|
height:100% of it and is the flex column everything else resolves against. */
|
|
display: block;
|
|
height: calc(100dvh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px));
|
|
overflow: hidden;
|
|
background: var(--primary-background-color);
|
|
color: var(--primary-text-color);
|
|
font-family: var(--paper-font-body1_-_font-family, Roboto, sans-serif);
|
|
--wd-radius-sm: 4px;
|
|
--wd-radius-md: 8px;
|
|
--wd-radius-lg: 12px;
|
|
--wd-space-xs: 4px;
|
|
--wd-space-sm: 6px;
|
|
--wd-space-md: 10px;
|
|
--wd-space-lg: 16px;
|
|
--wd-space-xl: 24px;
|
|
--wd-font-sm: 0.75em;
|
|
--wd-font-xs: 0.7em;
|
|
--wd-white: #fff;
|
|
--wd-tint-xs: rgba(0,0,0,0.04);
|
|
--wd-tint-sm: rgba(0,0,0,0.08);
|
|
--wd-tint-md: rgba(0,0,0,0.12);
|
|
}
|
|
.wd-header {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 14px 24px;
|
|
background: var(--app-header-background-color, var(--primary-color));
|
|
color: var(--app-header-text-color, #fff);
|
|
position: sticky; top: 0; z-index: 20;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,.25);
|
|
}
|
|
.wd-header h1 { margin: 0; font-size: 1.25em; font-weight: 600; letter-spacing: .01em; }
|
|
.wd-logo { flex-shrink: 0; opacity: .95; }
|
|
.wd-burger { display: none; align-items: center; justify-content: center; background: transparent; border: none; color: inherit; cursor: pointer; padding: 5px; margin: -2px 2px -2px -4px; border-radius: var(--wd-radius-md); flex-shrink: 0; }
|
|
.wd-burger:hover { background: rgba(255,255,255,.16); }
|
|
.wd-gear-btn { background: transparent; border: none; color: inherit; cursor: pointer; padding: 5px; margin-left: 4px; border-radius: var(--wd-radius-md); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; opacity: .8; }
|
|
.wd-gear-btn:hover { background: rgba(255,255,255,.16); opacity: 1; }
|
|
@media (max-width: 870px) { .wd-burger { display: inline-flex; } }
|
|
.wd-burger.wd-burger--force { display: inline-flex; }
|
|
.wd-header .wd-sub { font-size: .72em; opacity: .75; margin-top: 2px; }
|
|
.wd-header .wd-ts { margin-left: auto; font-size: .7em; opacity: .65; white-space: nowrap; }
|
|
.wd-body { max-width: 1160px; margin: 0 auto; padding: 20px 16px 60px; }
|
|
.wd-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
|
.wd-chip {
|
|
padding: 5px 16px; border-radius: 16px;
|
|
border: 1px solid var(--divider-color, rgba(0,0,0,.12));
|
|
background: var(--card-background-color); color: var(--primary-text-color);
|
|
cursor: pointer; font-size: .85em; transition: background .15s, color .15s;
|
|
}
|
|
.wd-chip:hover { background: var(--secondary-background-color); }
|
|
.wd-chip.active { background: var(--primary-color); color: var(--wd-white); border-color: var(--primary-color); }
|
|
.wd-tabs {
|
|
display: flex; gap: 2px;
|
|
border-bottom: 1px solid var(--divider-color, rgba(0,0,0,.1));
|
|
margin-bottom: 20px; overflow-x: auto;
|
|
}
|
|
/* The device list and the tab bar are the app's navigation: they stay put while only
|
|
the pane below them scrolls. Sticky rather than hoisted out of .wd-main, so the DOM
|
|
order and the tablist/tabpanel relationship are untouched. The negative margins
|
|
bleed the opaque background over .wd-body's own padding, so a card scrolling past
|
|
cannot show through above or beside the nav; the padding puts that space back
|
|
inside the sticky box. */
|
|
.wd-nav {
|
|
position: sticky; top: 0; z-index: 40;
|
|
background: var(--primary-background-color);
|
|
margin: -20px -16px 20px; padding: 20px 16px 0;
|
|
}
|
|
.wd-nav .wd-tabs { margin-bottom: 0; }
|
|
.wd-tab {
|
|
padding: 10px 22px; border: none; background: transparent;
|
|
color: var(--secondary-text-color); font-size: .8em; font-weight: 600;
|
|
letter-spacing: .07em; text-transform: uppercase; cursor: pointer;
|
|
border-bottom: 2px solid transparent; transition: color .15s, border-color .15s;
|
|
white-space: nowrap;
|
|
}
|
|
.wd-tab:hover { color: var(--primary-text-color); }
|
|
.wd-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
|
|
.wd-pane { display: none; }
|
|
.wd-pane.active { display: block; }
|
|
.wd-card {
|
|
background: var(--card-background-color); border-radius: var(--wd-radius-lg);
|
|
padding: 20px 22px; margin-bottom: 16px;
|
|
box-shadow: var(--ha-card-box-shadow, 0 2px 6px rgba(0,0,0,.08));
|
|
}
|
|
.wd-card-title {
|
|
margin: 0 0 14px; font-size: .72em; font-weight: 600;
|
|
letter-spacing: .09em; text-transform: uppercase;
|
|
color: var(--secondary-text-color);
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.wd-card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; align-items: center; }
|
|
.wd-badge {
|
|
display: inline-flex; align-items: center; gap: 7px;
|
|
padding: 5px 14px; border-radius: 20px; font-size: .85em; font-weight: 500;
|
|
margin-bottom: 18px;
|
|
}
|
|
.wd-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
|
.wd-running .wd-dot { animation: wd-pulse 1.4s ease-in-out infinite; }
|
|
@keyframes wd-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
|
|
.wd-stats {
|
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
|
gap: 12px; margin-bottom: 18px;
|
|
}
|
|
.wd-stat { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 14px 10px; text-align: center; }
|
|
.wd-stat-val { font-size: 1.5em; font-weight: 600; line-height: 1.1; }
|
|
.wd-stat-lbl { margin-top: 5px; font-size: .72em; color: var(--secondary-text-color); }
|
|
.wd-prog-bg { background: var(--secondary-background-color); border-radius: 6px; height: 10px; overflow: hidden; }
|
|
.wd-prog-fill { height: 100%; background: var(--primary-color); border-radius: 6px; transition: width .6s ease; }
|
|
.wd-prog-row { display: flex; justify-content: space-between; margin-top: 6px; font-size: .78em; color: var(--secondary-text-color); }
|
|
/* D1: compact phase timeline below the progress bar */
|
|
.wd-ptl-wrap { margin-top: 8px; }
|
|
.wd-ptl { position: relative; height: 12px; border-radius: 6px; overflow: hidden; background: var(--secondary-background-color); }
|
|
.wd-ptl-seg { position: absolute; top: 0; bottom: 0; }
|
|
.wd-ptl-seg-lbl { position: absolute; left: 4px; top: 50%; transform: translateY(-50%); font-size: 8px; line-height: 1; color: var(--wd-white); white-space: nowrap; overflow: hidden; max-width: calc(100% - 6px); text-shadow: 0 0 2px rgba(0,0,0,.55); pointer-events: none; }
|
|
.wd-ptl-cursor { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--primary-text-color, #111); box-shadow: 0 0 0 1px rgba(255,255,255,.6); }
|
|
.wd-ptl-cur { margin-top: 5px; font-size: .74em; color: var(--secondary-text-color); }
|
|
.wd-cycle-ctrl { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
|
.wd-table { width: 100%; border-collapse: collapse; font-size: .875em; }
|
|
.wd-table th {
|
|
text-align: left; padding: 8px 12px;
|
|
color: var(--secondary-text-color); font-weight: 600; font-size: .72em;
|
|
letter-spacing: .07em; text-transform: uppercase;
|
|
border-bottom: 1px solid var(--divider-color);
|
|
}
|
|
.wd-table td { padding: 10px 12px; border-bottom: 1px solid var(--divider-color, rgba(0,0,0,.05)); vertical-align: middle; }
|
|
.wd-table tr:last-child td { border-bottom: none; }
|
|
.wd-table tbody tr:hover td { background: var(--secondary-background-color); }
|
|
.wd-table-wrap { overflow-x: auto; }
|
|
/* Audit UI-20: a horizontal scroller that hides content past an edge gets a shadow on
|
|
that edge, so on a phone the tab strip and wide tables visibly continue instead of
|
|
looking cut off. A shadow rather than a fade: past a table's last visible column
|
|
there is usually only whitespace, where a fade shows nothing. _syncScrollAffordances
|
|
sets the classes only while there really is more content on that side; backgrounds
|
|
do not affect layout, and nothing overflows at desktop widths, so the desktop view
|
|
is unchanged. The background of a scroll container does not scroll with it, so the
|
|
shadow stays on the visible edge. Mid-grey reads on light and dark themes alike. */
|
|
.wd-ovf-l, .wd-ovf-r { background-repeat: no-repeat; background-size: 22px 100%; }
|
|
.wd-ovf-r { background-image: linear-gradient(to left, rgba(127,127,127,.42), rgba(127,127,127,0)); background-position: right center; }
|
|
.wd-ovf-l { background-image: linear-gradient(to right, rgba(127,127,127,.42), rgba(127,127,127,0)); background-position: left center; }
|
|
.wd-ovf-l.wd-ovf-r { background-image: linear-gradient(to right, rgba(127,127,127,.42), rgba(127,127,127,0)), linear-gradient(to left, rgba(127,127,127,.42), rgba(127,127,127,0)); background-position: left center, right center; }
|
|
.wd-th-sort { cursor: pointer; user-select: none; white-space: nowrap; }
|
|
.wd-th-sort:hover { color: var(--primary-color); }
|
|
.wd-tc-date { white-space: nowrap; color: var(--secondary-text-color); font-size: .82em; }
|
|
.wd-tc-num { white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
|
|
/* Dedicated flags/icons column: keep every badge on one line so review/anomaly/source
|
|
icons never overwrite each other or spill into the profile name. */
|
|
.wd-tc-flags { white-space: nowrap; font-size: .9em; }
|
|
th.wd-tc-flags { color: var(--secondary-text-color); font-weight: 500; }
|
|
.wd-filter-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
.wd-filter-input { flex: 1; min-width: 120px; padding: 5px 10px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size: .84em; }
|
|
.wd-filter-select { padding: 5px 8px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); font-size: .84em; }
|
|
.wd-row-link { cursor: pointer; }
|
|
.wd-pill { display: inline-block; padding: 2px 9px; border-radius: var(--wd-radius-sm); background: var(--secondary-background-color); color: var(--secondary-text-color); font-size: .78em; }
|
|
.wd-tag { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 10px; font-size: .72em; font-weight: 600; vertical-align: middle; background: var(--secondary-background-color); color: var(--secondary-text-color); margin-left: 4px; }
|
|
.wd-btn {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer;
|
|
font-size: .85em; font-weight: 500; transition: opacity .15s;
|
|
white-space: nowrap;
|
|
}
|
|
.wd-btn:hover { opacity: .85; }
|
|
.wd-btn:disabled { opacity: .55; cursor: default; }
|
|
.wd-btn-primary { background: var(--primary-color); color: var(--wd-white); }
|
|
.wd-btn-secondary { background: var(--secondary-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); }
|
|
.wd-btn-danger { background: var(--error-color, #f44336); color: var(--wd-white); }
|
|
.wd-btn-sm { padding: 4px 10px; font-size: .78em; }
|
|
.wd-btn-xs { padding: 2px 8px; font-size: .72em; }
|
|
.wd-spin {
|
|
display: inline-block; width: 13px; height: 13px;
|
|
border: 2px solid currentColor; border-right-color: transparent;
|
|
border-radius: 50%; animation: wd-rot .7s linear infinite; vertical-align: -2px;
|
|
}
|
|
@keyframes wd-rot { to { transform: rotate(360deg); } }
|
|
.wd-field { margin-bottom: 16px; }
|
|
.wd-field label { display: block; font-size: .82em; font-weight: 600; margin-bottom: 5px; color: var(--secondary-text-color); letter-spacing: .04em; text-transform: uppercase; }
|
|
.wd-field input[type=text], .wd-field input[type=number], .wd-field select, .wd-field textarea {
|
|
width: 100%; box-sizing: border-box; padding: 8px 10px; border-radius: 6px;
|
|
border: 1px solid var(--divider-color, rgba(0,0,0,.2));
|
|
background: var(--secondary-background-color);
|
|
color: var(--primary-text-color); font-size: .9em; font-family: inherit;
|
|
}
|
|
.wd-field textarea { min-height: 64px; resize: vertical; }
|
|
.wd-field input[type=checkbox] { width: auto; margin-right: 8px; }
|
|
/* Switch-style boolean settings (replaces the old plain checkbox). Scoped under
|
|
.wd-field-switch so the switch label wins over the generic ".wd-field label"
|
|
(display:block, higher specificity) rule and stays a centered flex row. */
|
|
.wd-field-switch label { margin: 0; }
|
|
.wd-field-switch .wd-switch-row { display: flex; align-items: center; gap: 10px; min-height: 22px; }
|
|
/* Switch label: a flex row [toggle][text], vertically centred. The .wd-field
|
|
.wd-switch-lbl selector is needed to beat .wd-field label (display:block +
|
|
.82em + uppercase, specificity 0,1,1) which otherwise leaks in and both breaks
|
|
the vertical centring (align-items is a no-op on a block) and shrinks the label.
|
|
The bare .wd-switch-lbl covers inline use outside a .wd-field (e.g. adopt). */
|
|
.wd-switch-lbl,
|
|
.wd-field .wd-switch-lbl {
|
|
display: inline-flex; align-items: center; gap: 10px; cursor: pointer;
|
|
min-width: 0; margin: 0; font-size: 1rem; font-weight: 400;
|
|
letter-spacing: normal; text-transform: none;
|
|
}
|
|
/* Match the switch label to every other setting name (see .wd-field label). */
|
|
.wd-switch-text { font-size: .82em; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--secondary-text-color); }
|
|
#wd-settings-form .wd-switch-text, #wd-ml-form .wd-switch-text { color: var(--primary-text-color); }
|
|
/* Normal-case, readable label for switches outside the Settings form (store /
|
|
panel prefs / access control), whose labels are descriptive sentences. */
|
|
.wd-switch-text--plain { font-size: .9em; font-weight: 600; line-height: 1.3; letter-spacing: normal; text-transform: none; color: var(--primary-text-color); }
|
|
.wd-switch { position: relative; display: inline-flex; flex: 0 0 auto; width: 40px; height: 22px; }
|
|
.wd-switch input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
|
|
.wd-switch-slider { position: absolute; inset: 0; border-radius: 22px; background: var(--switch-unchecked-track-color, rgba(120,120,120,.5)); transition: background .2s; }
|
|
.wd-switch-slider::before { content: ""; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px; border-radius: 50%; background: var(--switch-unchecked-button-color, #fafafa); box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .2s; }
|
|
.wd-switch input:checked + .wd-switch-slider { background: var(--switch-checked-track-color, var(--primary-color, #03a9f4)); }
|
|
/* Force a light thumb: some themes set --switch-checked-button-color to the accent,
|
|
which made the knob vanish into the (also-accent) track. A white thumb reads on
|
|
any track colour. */
|
|
.wd-switch input:checked + .wd-switch-slider::before { transform: translateX(18px); background: #fff; }
|
|
.wd-switch input:focus-visible + .wd-switch-slider { outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; }
|
|
/* A11y: a shared keyboard focus ring for all interactive controls (many HA themes
|
|
suppress the UA default outline). */
|
|
.wd-tab:focus-visible, .wd-btn:focus-visible, .wd-chip:focus-visible,
|
|
.wd-sec-btn:focus-visible, .wd-subtab:focus-visible, .wd-mini-tab:focus-visible,
|
|
.wd-devcard:focus-visible, [tabindex]:focus-visible, a:focus-visible, select:focus-visible {
|
|
outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; border-radius: var(--wd-radius-sm);
|
|
}
|
|
/* A11y: honor the user's reduced-motion preference \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; }
|
|
/* Derived-value note (#445): informational, never an error - no red, no fix button. */
|
|
.wd-setting-note { font-size: .78em; color: var(--secondary-text-color); margin-top: 4px;
|
|
padding: 5px 9px; border-radius: var(--wd-radius-sm);
|
|
background: var(--secondary-background-color); border-left: 3px solid var(--primary-color); }
|
|
/* Entity-pill multi-picker (compact chips + inline add input) */
|
|
.wd-pillbox { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; padding: 5px 6px; min-height: 34px;
|
|
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--card-background-color); }
|
|
.wd-pillbox:focus-within { border-color: var(--primary-color); }
|
|
.wd-pillbox .wd-pill { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; padding: 2px 4px 2px 9px;
|
|
font-size: .82em; line-height: 1.4; border-radius: var(--wd-radius-lg); background: var(--primary-color); color: var(--wd-white);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
/* The label shrinks and ellipsizes; the remove button never does (#467: a long
|
|
notify service pushed it out of the clipped pill). */
|
|
.wd-pill-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.wd-pill-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0;
|
|
border: 0; border-radius: 50%; background: rgba(255,255,255,.25); color: var(--wd-white); font-size: 13px; line-height: 1;
|
|
cursor: pointer; flex: none; }
|
|
.wd-pill-x:hover { background: rgba(255,255,255,.45); }
|
|
.wd-pill-add { flex: 1; min-width: 90px; border: 0 !important; background: transparent !important; padding: 3px 4px !important;
|
|
font-size: .88em; color: var(--primary-text-color); outline: none; }
|
|
.wd-form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0 20px; }
|
|
/* Cycle timer list */
|
|
.wd-timerlist { display: flex; flex-direction: column; gap: 8px; }
|
|
.wd-timer-row { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px;
|
|
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--card-background-color); }
|
|
.wd-timer-top { display: flex; align-items: center; gap: 8px; }
|
|
.wd-timer-top input[type="number"] { width: 70px; flex: 0 0 auto; }
|
|
.wd-timer-top textarea { flex: 1 1 auto; min-width: 0; box-sizing: border-box; resize: vertical; min-height: 32px; height: 34px; }
|
|
.wd-timer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
.wd-timer-footer .wd-switch-lbl { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
|
.wd-timer-add { align-self: flex-start; margin-top: 4px; }
|
|
/* Roomier Settings layout (scoped so modals keep their compact spacing) */
|
|
#wd-settings-form .wd-form-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 18px 28px; align-items: start; }
|
|
#wd-settings-form .wd-field { margin-bottom: 0; background: var(--secondary-background-color); border-radius: 10px; padding: 12px 14px; }
|
|
#wd-settings-form .wd-field label { color: var(--primary-text-color); }
|
|
#wd-settings-form .wd-field input[type=text], #wd-settings-form .wd-field input[type=number], #wd-settings-form .wd-field select, #wd-settings-form .wd-field textarea { background: var(--card-background-color); padding: 9px 11px; }
|
|
#wd-settings-form .wd-subhead { margin: 22px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--divider-color); }
|
|
.wd-sec-intro { font-size: .85em; color: var(--secondary-text-color); margin: 0 0 16px; line-height: 1.5; }
|
|
.wd-label-row { display: flex; align-items: center; }
|
|
.wd-tip {
|
|
display: inline-flex; width: 15px; height: 15px; border-radius: 50%;
|
|
align-items: center; justify-content: center; font-size: 10px; font-style: italic;
|
|
background: var(--secondary-background-color); color: var(--secondary-text-color);
|
|
cursor: help; margin-left: 6px; position: relative; border: 1px solid var(--divider-color);
|
|
font-weight: 700; text-transform: none; letter-spacing: normal;
|
|
}
|
|
.wd-tip-pop {
|
|
display: none; position: absolute; bottom: 150%; left: 50%; transform: translateX(-50%);
|
|
width: 264px; background: var(--card-background-color); color: var(--primary-text-color);
|
|
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 10px 12px;
|
|
box-shadow: 0 4px 18px rgba(0,0,0,.35); z-index: 60;
|
|
text-align: left; font-weight: 400; text-transform: none; letter-spacing: normal;
|
|
}
|
|
.wd-tip:hover .wd-tip-pop, .wd-tip:focus .wd-tip-pop { display: block; }
|
|
.wd-tip:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 1px; }
|
|
.wd-tip-txt { font-size: 12px; line-height: 1.5; display: block; }
|
|
.wd-dg { display: block; width: 100%; height: auto; margin-bottom: 8px; background: var(--secondary-background-color); border-radius: 6px; }
|
|
.wd-dg .ln { fill: none; stroke: var(--primary-color); stroke-width: 2.5; }
|
|
.wd-dg .ln2 { fill: none; stroke: var(--secondary-text-color); stroke-width: 1.5; opacity: .7; }
|
|
.wd-dg .ok { fill: none; stroke: var(--success-color, #4caf50); stroke-width: 2; }
|
|
.wd-dg .bad { fill: none; stroke: var(--error-color, #f44336); stroke-width: 2; }
|
|
.wd-dg .dash { stroke-dasharray: 4 3; }
|
|
.wd-dg .fz { fill: var(--primary-color); opacity: .18; }
|
|
.wd-dg .fw { fill: var(--warning-color, #ff9800); opacity: .2; }
|
|
.wd-dg .fb { fill: var(--error-color, #f44336); opacity: .14; }
|
|
.wd-dg text { fill: var(--secondary-text-color); font-size: 9px; }
|
|
.wd-dg .ax { stroke: var(--divider-color); stroke-width: 1; }
|
|
.wd-sug {
|
|
display: flex; align-items: center; gap: 8px; margin-top: 6px;
|
|
padding: 6px 10px; border-radius: var(--wd-radius-md); font-size: .82em;
|
|
background: rgba(255,152,0,.10); border: 1px solid rgba(255,152,0,.40);
|
|
box-sizing: border-box; flex-wrap: wrap;
|
|
}
|
|
.wd-sug.wd-sug-split { flex-direction: column; align-items: stretch; gap: 0; padding: 0; overflow: hidden; }
|
|
.wd-sug-opt { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 10px; }
|
|
.wd-sug-opt:not(:last-child) { border-bottom: 1px solid rgba(255,152,0,.28); }
|
|
.wd-sug-chip {
|
|
display: inline-flex; align-items: center; gap: 3px; flex-shrink: 0;
|
|
font-size: .75em; font-weight: 700; letter-spacing: .04em;
|
|
padding: 2px 7px; border-radius: 10px; white-space: nowrap;
|
|
}
|
|
.wd-sug-chip-obs { background: rgba(255,152,0,.22); }
|
|
.wd-sug-val { font-weight: 700; flex-shrink: 0; }
|
|
.wd-sug-impact-line { flex-basis: 100%; font-size: .86em; opacity: .70; font-style: italic; margin-top: 2px; }
|
|
.wd-confirm-list { margin: 0 0 12px; padding-left: 18px; max-height: 50vh; overflow-y: auto; }
|
|
.wd-confirm-list li { margin: 4px 0; }
|
|
.wd-sug-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.wd-sug-sep { display: none; }
|
|
.wd-sug-impact { display: none; }
|
|
.wd-sug-use { border: none; background: var(--warning-color, #ff9800); color: var(--wd-white); border-radius: var(--wd-radius-sm); padding: 2px 8px; font-size: .92em; cursor: pointer; flex-shrink: 0; }
|
|
.wd-sug-lock { border: none; background: transparent; color: var(--secondary-text-color); border-radius: var(--wd-radius-sm); padding: 2px 6px; font-size: .92em; cursor: pointer; flex-shrink: 0; opacity: .65; }
|
|
.wd-sug-lock:hover { opacity: 1; background: rgba(255,152,0,.15); }
|
|
.wd-conflict-err { display: flex; flex-direction: column; gap: 4px; margin-top: 5px; }
|
|
.wd-conflict-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: .8em; color: var(--error-color, #b71c1c); padding: 5px 9px; border-left: 3px solid var(--error-color, #b71c1c); background: rgba(183,28,28,.07); border-radius: 0 5px 5px 0; }
|
|
.wd-conflict-fix { border: 1px solid var(--error-color, #b71c1c); background: none; color: var(--error-color, #b71c1c); border-radius: var(--wd-radius-sm); padding: 1px 7px; font-size: .92em; cursor: pointer; white-space: nowrap; flex: none; }
|
|
.wd-conflict-fix:hover { background: var(--error-color, #b71c1c); color: var(--wd-white); }
|
|
.wd-conflict-sug-note { font-style: italic; opacity: 0.85; flex: none; }
|
|
#wd-settings-form .wd-field.wd-has-conflict { outline: 2px solid var(--error-color, #b71c1c); outline-offset: -1px; }
|
|
.wd-rev-sub { display: flex; align-items: center; gap: 6px; margin: 14px 0 6px; font-size: .85em; font-weight: 600; color: var(--primary-text-color); }
|
|
.wd-rev-tags { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
|
.wd-rev-tag { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--divider-color); font-size: .85em; cursor: pointer; }
|
|
.wd-rev-tag input { margin: 0; }
|
|
.wd-rev-notes { width: 100%; box-sizing: border-box; background: var(--card-background-color); color: var(--primary-text-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); padding: 9px 11px; font: inherit; resize: vertical; }
|
|
.wd-sug-banner {
|
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
|
padding: 12px 16px; border-radius: 10px; margin-bottom: 16px;
|
|
background: rgba(255,152,0,.12); border: 1px solid rgba(255,152,0,.4);
|
|
}
|
|
.wd-subhead { font-size: .76em; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--primary-color); margin: 8px 0 10px; grid-column: 1 / -1; }
|
|
.wd-section-nav { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 0; }
|
|
.wd-sec-btn {
|
|
padding: 5px 14px; border-radius: 14px; border: 1px solid var(--divider-color);
|
|
background: transparent; color: var(--secondary-text-color); font-size: .8em; cursor: pointer; transition: background .15s;
|
|
}
|
|
.wd-sec-btn.active { background: var(--primary-color); color: var(--wd-white); border-color: var(--primary-color); }
|
|
.wd-level-toggle { display: inline-flex; gap: 4px; }
|
|
.wd-sec-btn { position: relative; }
|
|
/* Basic/Advanced slide toggle */
|
|
.wd-mode-switch { display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font-size: .82em; user-select: none; white-space: nowrap; }
|
|
.wd-mode-switch-label { color: var(--secondary-text-color); transition: color .15s; }
|
|
.wd-mode-switch-label.active { color: var(--primary-color); font-weight: 600; }
|
|
.wd-toggle-track { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; }
|
|
.wd-toggle-track input { opacity: 0; width: 0; height: 0; position: absolute; }
|
|
.wd-toggle-knob { position: absolute; inset: 0; border-radius: 20px; background: var(--divider-color); transition: background .2s; }
|
|
.wd-toggle-knob::after { content: ''; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .2s; }
|
|
.wd-toggle-track input:checked + .wd-toggle-knob { background: var(--primary-color); }
|
|
.wd-toggle-track input:checked + .wd-toggle-knob::after { transform: translateX(16px); }
|
|
.wd-sec-sug-dot { position: absolute; top: 2px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--warning-color, #ff9800); display: inline-block; pointer-events: none; }
|
|
.wd-sec-conf-dot { position: absolute; top: 2px; right: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--error-color, #b71c1c); display: inline-block; pointer-events: none; }
|
|
.wd-subtabs { display: flex; gap: 2px; border-bottom: 1px solid var(--divider-color); margin-bottom: 18px; flex-wrap: wrap; }
|
|
.wd-subtab { padding: 8px 18px; border: none; background: transparent; color: var(--secondary-text-color); font-size: .8em; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; transition: color .15s; }
|
|
.wd-subtab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
|
|
.wd-profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
|
.wd-profile-card {
|
|
background: var(--card-background-color); border-radius: 10px; padding: 16px;
|
|
border: 1px solid var(--divider-color, rgba(0,0,0,.08)); cursor: pointer; transition: border-color .15s, transform .1s;
|
|
}
|
|
.wd-profile-card:hover { border-color: var(--primary-color); transform: translateY(-1px); }
|
|
/* #444: these are the only cards rendered as a button element, and resetting
|
|
font without color left them on the UA buttontext system color - which follows
|
|
the BROWSER/OS color scheme, not the HA theme. On a light HA theme served to a
|
|
dark-mode browser (Catppuccin Auto Latte Macchiato, the reporter's setup) the
|
|
profile titles came out near-white on a light card. Everything beside them was
|
|
readable because it names a theme token explicitly; only the inherited color
|
|
was wrong. (No backticks in this block: it lives inside a JS template literal.) */
|
|
button.wd-attn-card, button.wd-profile-card { appearance: none; font: inherit; color: inherit; text-align: left; width: 100%; }
|
|
button.wd-profile-card { display: block; }
|
|
.wd-prof-wrap { position: relative; }
|
|
.wd-profile-name { font-weight: 600; font-size: 1em; margin-bottom: 6px; }
|
|
.wd-profile-meta { font-size: .8em; color: var(--secondary-text-color); }
|
|
/* Profile-card header: name on the left, mini power-signature sparkline on the
|
|
right, both on one line and vertically aligned regardless of badge count. */
|
|
.wd-profile-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
.wd-profile-head .wd-profile-name { margin: 0; flex: 1 1 auto; min-width: 0; }
|
|
/* Status-pill row: wraps cleanly on its own line below the name, and every pill
|
|
(health / trend / warm-up / imported) shares one uniform compact pill shape. */
|
|
.wd-profile-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px; }
|
|
.wd-profile-badges .wd-badge { margin: 0; padding: 2px 9px; border-radius: 999px; font-size: .72em; font-weight: 600; gap: 4px; }
|
|
/* D2: mini duration sparkline on profile cards */
|
|
.wd-prof-spark { width: 64px; height: 20px; display: block; flex-shrink: 0; }
|
|
/* Community Store */
|
|
.wd-store-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; font-size: .85em; }
|
|
.wd-crumb { background: none; border: none; color: var(--primary-color); cursor: pointer; padding: 2px 4px; font: inherit; }
|
|
.wd-crumb:hover { text-decoration: underline; }
|
|
.wd-crumb.active { color: var(--primary-text-color); font-weight: 700; cursor: default; }
|
|
.wd-crumb-sep { color: var(--secondary-text-color); }
|
|
.wd-store-search { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
|
|
.wd-store-search input { flex: 1; min-width: 180px; padding: 8px 11px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); font-size: .9em; }
|
|
/* The box is a combobox (#416), so the flex child is the wrapper, not the input. */
|
|
.wd-store-search .wd-combo { flex: 1 1 auto; min-width: 180px; }
|
|
.wd-store-list { display: flex; flex-direction: column; gap: 8px; }
|
|
/* Browse rows (appliances / programs): tappable list rows with a hover affordance
|
|
and a chevron, instead of flat cards. */
|
|
.wd-store-rows { display: flex; flex-direction: column; gap: 6px; }
|
|
.wd-store-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; text-align: left; width: 100%; cursor: pointer; padding: 11px 14px; background: var(--secondary-background-color); border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); color: var(--primary-text-color); transition: border-color .12s ease, background .12s ease; }
|
|
.wd-store-row:hover { border-color: var(--primary-color); background: var(--card-background-color); }
|
|
.wd-store-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
.wd-store-row-title { font-weight: 600; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.wd-store-row-sub { display: flex; align-items: center; gap: 10px; font-size: .8em; color: var(--secondary-text-color); }
|
|
.wd-store-chip { background: var(--accent-dim, rgba(0,180,216,.14)); color: var(--primary-color); border-radius: 999px; padding: 1px 8px; font-size: .92em; text-transform: capitalize; }
|
|
.wd-store-fav { color: var(--warning-color, #f0b429); }
|
|
.wd-store-row-arrow { color: var(--secondary-text-color); font-size: 1.3em; flex-shrink: 0; }
|
|
.wd-store-cycle-top { display: flex; align-items: center; gap: 12px; }
|
|
.wd-store-cycle-stats { flex: 1; min-width: 0; }
|
|
.wd-store-spark { width: 120px; height: 36px; flex-shrink: 0; display: block; background: var(--secondary-background-color); border-radius: 6px; }
|
|
.wd-store-conn { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
|
.wd-store-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
|
.wd-tag-pending { background: rgba(56,139,253,.18); color: var(--info-color, #58a6ff); }
|
|
.wd-tag-approved { background: rgba(63,185,80,.18); color: var(--success-color, #3fb950); }
|
|
.wd-store-picker-detail { margin-top: 6px; font-size: .85em; color: var(--secondary-text-color); display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
|
|
.wd-store-picker-actions { display: flex; align-items: center; gap: 8px; flex-basis: 100%; margin-top: 4px; flex-wrap: wrap; }
|
|
.wd-star-row { display: inline-flex; gap: 2px; }
|
|
.wd-star-btn { background: none; border: none; cursor: pointer; color: var(--warning-color, #f0b429); font-size: 1.1em; padding: 0 1px; line-height: 1; }
|
|
.wd-star-btn:hover { transform: scale(1.15); }
|
|
/* Share-device selection tree (profile -> its reference cycles). */
|
|
.wd-sd-tree { display: flex; flex-direction: column; gap: 8px; max-height: 44vh; overflow-y: auto; margin-bottom: 16px; }
|
|
/* #460: a group's overflow:hidden drops its automatic min-height to 0, so past the
|
|
max-height above the groups shrank and clipped their own rows instead of the tree
|
|
scrolling (a phone hits the cap at once). Rows keep their height; the tree scrolls. */
|
|
.wd-sd-tree > * { flex-shrink: 0; }
|
|
.wd-sd-group { border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); background: var(--secondary-background-color); overflow: hidden; }
|
|
.wd-sd-prof { display: flex; align-items: center; gap: 8px; padding: 9px 12px; cursor: pointer; font-weight: 600; }
|
|
.wd-sd-prof-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.wd-sd-count { font-size: .8em; font-weight: 400; color: var(--secondary-text-color); }
|
|
.wd-sd-cycles { display: flex; flex-direction: column; border-top: 1px solid var(--divider-color); }
|
|
.wd-sd-cyc { display: flex; align-items: center; gap: 8px; padding: 6px 12px 6px 28px; cursor: pointer; font-size: .9em; }
|
|
.wd-sd-cyc:hover, .wd-sd-prof:hover { background: var(--card-background-color); }
|
|
.wd-sd-cyc-meta { color: var(--secondary-text-color); }
|
|
.wd-sd-phase { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-top: 1px solid var(--divider-color); cursor: pointer; font-size: .85em; color: var(--secondary-text-color); }
|
|
.wd-sd-phase:hover { background: var(--card-background-color); }
|
|
.wd-sd-settings { display: flex; align-items: center; gap: 8px; padding: 10px 2px 2px; cursor: pointer; font-size: .9em; }
|
|
.wd-sd-consent { display: flex; align-items: flex-start; gap: 8px; padding: 10px 2px 2px; cursor: pointer; font-size: .9em; }
|
|
.wd-sd-group-nocyc { opacity: .65; }
|
|
.wd-sd-prof-disabled { display: flex; align-items: baseline; gap: 8px; padding: 9px 12px; font-weight: 600; flex-wrap: wrap; }
|
|
.wd-sd-nocyc-note { font-size: .8em; font-weight: 400; color: var(--secondary-text-color); }
|
|
.wd-share-guide { margin-bottom: 10px; border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md); overflow: hidden; }
|
|
.wd-share-guide > summary { padding: 8px 12px; cursor: pointer; font-size: .85em; font-weight: 600; color: var(--secondary-text-color); list-style: none; }
|
|
.wd-share-guide > summary::-webkit-details-marker { display: none; }
|
|
.wd-share-guide > summary::before { content: '\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-stale { margin: 6px 0 0; padding: 6px 12px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--warning-color, #ffa600); color: var(--primary-text-color); font-size: .85em; }
|
|
.wd-error-state { display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin-bottom: 10px; border-radius: var(--wd-radius-md); background: var(--secondary-background-color); border: 1px solid var(--divider-color); color: var(--error-color, #b71c1c); font-size: .9em; }
|
|
.wd-info { font-size: .9em; color: var(--secondary-text-color); line-height: 1.6; margin: 0; }
|
|
.wd-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
|
.wd-modal { background: var(--card-background-color); border-radius: var(--wd-radius-lg); padding: 24px; max-width: 480px; width: calc(100% - 32px); max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.3); }
|
|
.wd-modal-lg { max-width: 880px; }
|
|
.wd-modal h2 { margin: 0 0 16px; font-size: 1.1em; display: flex; align-items: center; gap: 10px; }
|
|
.wd-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; flex-wrap: wrap; }
|
|
.wd-canvas-wrap { position: relative; margin: 10px 0; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 6px; }
|
|
/* touch-action: pan-y is load-bearing, not cosmetic. With "none" (the pre-0.5.6
|
|
value) a one-finger swipe that started on a graph was swallowed, so every chart
|
|
was a 160-240px band the user could not scroll past on a phone (issue #413).
|
|
With pan-y the browser keeps the vertical swipe (page scrolls) while still
|
|
handing us the horizontal drag AND both pointers of a two-finger pinch, which
|
|
is what makes touch zoom possible at all. Mirrors what Home Assistant's own
|
|
ha-chart-base does via moveOnMouseMove/preventDefaultMouseMove. */
|
|
.wd-canvas-wrap canvas { width: 100%; height: 240px; display: block; touch-action: pan-y; cursor: crosshair; }
|
|
/* Zoom reset affordance, mirroring HA's mdiRestart chart button: only rendered
|
|
while the canvas actually has a zoom viewport. */
|
|
.wd-zoom-reset {
|
|
position: absolute; top: 10px; right: 12px; z-index: 2; display: none;
|
|
align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0;
|
|
border-radius: 50%; border: 1px solid var(--divider-color);
|
|
background: var(--card-background-color); color: var(--primary-text-color);
|
|
cursor: pointer; font-size: 15px; line-height: 1; opacity: .92;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,.25);
|
|
}
|
|
.wd-zoom-reset.wd-zoom-reset--on { display: inline-flex; }
|
|
.wd-zoom-reset:hover { opacity: 1; border-color: var(--primary-color); }
|
|
.wd-mode-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
.wd-mini-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--divider-color); margin-bottom: 16px; flex-wrap: wrap; }
|
|
.wd-mini-tab { padding: 7px 16px; border: none; background: transparent; color: var(--secondary-text-color); font-size: .82em; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; }
|
|
.wd-mini-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
|
|
.wd-kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin: 4px 0 14px; }
|
|
.wd-kv-item { background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 10px; text-align: center; }
|
|
.wd-kv-val { font-size: 1.25em; font-weight: 700; }
|
|
.wd-kv-lbl { font-size: .7em; color: var(--secondary-text-color); margin-top: 3px; }
|
|
.wd-seg-row, .wd-phase-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
|
.wd-swatch { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; display: inline-block; }
|
|
.wd-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 200; padding: 10px 20px; border-radius: var(--wd-radius-md); font-size: .9em; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,.25); animation: wd-toast-in .2s ease; }
|
|
@keyframes wd-toast-in { from { opacity: 0; transform: translateX(-50%) translateY(10px); } }
|
|
.wd-toast-success { background: var(--success-color, #4caf50); color: var(--wd-white); }
|
|
.wd-toast-error { background: var(--error-color, #f44336); color: var(--wd-white); }
|
|
.wd-toast-info { background: var(--info-color, #2196f3); color: var(--wd-white); }
|
|
/* D4: undo toast \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; }
|
|
.wd-gtip--pinned { border-color: var(--primary-color); }
|
|
.wd-gtip-dismiss { display: block; margin-top: 4px; opacity: .55; font-size: 11px; }
|
|
/* Status chart legend + toggles */
|
|
.wd-leg { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; font-size: .8em; color: var(--secondary-text-color); }
|
|
.wd-leg-i { display: inline-flex; align-items: center; gap: 6px; }
|
|
.wd-leg-i input { margin: 0 2px 0 0; width: auto; }
|
|
.wd-leg-sw { width: 16px; height: 3px; border-radius: 2px; display: inline-block; }
|
|
/* Status program selector */
|
|
.wd-prog-ctl { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
.wd-prog-ctl label { font-size: .72em; text-transform: uppercase; letter-spacing: .08em; color: var(--secondary-text-color); margin: 0; }
|
|
.wd-prog-ctl select { padding: 8px 11px; border-radius: 6px; border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); min-width: 200px; font-size: .9em; }
|
|
.wd-prog-tag { font-size: .78em; padding: 3px 9px; border-radius: 10px; }
|
|
.wd-prog-tag.auto { background: rgba(76,175,80,.18); color: var(--success-color, #4caf50); }
|
|
.wd-prog-tag.manual { background: rgba(255,152,0,.2); color: var(--warning-color, #ff9800); }
|
|
/* Status-rich device selector */
|
|
.wd-devbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
|
|
.wd-devcard { display: flex; align-items: center; gap: 9px; padding: 9px 13px; border-radius: var(--wd-radius-lg); border: 1px solid var(--divider-color); background: var(--card-background-color); color: var(--primary-text-color); cursor: pointer; font-size: .9em; }
|
|
.wd-devcard.active { border-color: var(--primary-color); box-shadow: 0 0 0 1px var(--primary-color); }
|
|
.wd-devadd { border-style: dashed; color: var(--secondary-text-color); }
|
|
.wd-devadd:hover { border-color: var(--primary-color); color: var(--primary-color); }
|
|
.wd-devdot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
.wd-devdot.run { animation: wd-pulse 1.4s ease-in-out infinite; }
|
|
.wd-devname { font-weight: 600; }
|
|
.wd-devsub { font-size: .72em; color: var(--secondary-text-color); }
|
|
.wd-dbadge { font-size: .72em; padding: 1px 7px; border-radius: 10px; background: var(--secondary-background-color); }
|
|
.wd-dbadge.rec { background: var(--error-color, #f44336); color: var(--wd-white); }
|
|
.wd-dbadge.fb { background: rgba(33,150,243,.22); }
|
|
.wd-dbadge.conf { background: rgba(183,28,28,.18); color: var(--error-color, #b71c1c); }
|
|
/* Attention cards (status dashboard) */
|
|
.wd-attn { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; margin-bottom: 16px; }
|
|
.wd-attn-card { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; background: var(--card-background-color); border: 1px solid var(--divider-color); cursor: pointer; transition: border-color .15s; }
|
|
.wd-attn-card:hover { border-color: var(--primary-color); }
|
|
.wd-attn-icon { font-size: 1.5em; line-height: 1; }
|
|
.wd-attn-body { flex: 1; min-width: 0; }
|
|
.wd-attn-title { font-weight: 600; }
|
|
.wd-attn-sub { font-size: .76em; color: var(--secondary-text-color); }
|
|
/* F1 first-run onboarding card (Status power-chart area) */
|
|
.wd-onboard { margin-top: 12px; padding: 16px; border-radius: 10px; border: 1px dashed var(--divider-color); background: var(--secondary-background-color); }
|
|
.wd-onboard .wd-card-title { margin-top: 0; }
|
|
.wd-onboard-skip { font-size: .8em; color: var(--secondary-text-color); text-decoration: underline; cursor: pointer; }
|
|
.wd-onboard-skip:hover { color: var(--primary-color); }
|
|
/* Setup card (replaces getting-started card, phases 0-3) and phase-4 chip */
|
|
.wd-setup-card { margin-top: 12px; padding: 16px; border-radius: 10px; border: 1px solid var(--primary-color, #03a9f4); background: color-mix(in srgb, var(--primary-color, #03a9f4) 8%, var(--card-background-color, var(--ha-card-background))); }
|
|
.wd-setup-card .wd-card-title { margin-top: 0; }
|
|
.wd-setup-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 20px; font-size: .82em; font-weight: 600; cursor: pointer; border: 1px solid var(--divider-color); background: var(--secondary-background-color); margin-top: 12px; }
|
|
.wd-setup-chip--healthy { border-color: var(--success-color, #4caf50); background: color-mix(in srgb, var(--success-color, #4caf50) 10%, var(--card-background-color, transparent)); }
|
|
.wd-setup-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.wd-setup-dot--green { background: var(--success-color, #4caf50); }
|
|
.wd-link { background: none; border: none; padding: 0; color: var(--primary-color); cursor: pointer; font: inherit; text-decoration: underline; display: inline; }
|
|
.wd-link:hover { opacity: .8; }
|
|
/* Logs page */
|
|
.wd-logbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
|
.wd-logs { font-family: monospace; font-size: .76em; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 10px; height: 56vh; min-height: 140px; overflow: auto; resize: vertical; }
|
|
#wd-log-lines-page { height: auto; min-height: 200px; resize: none; }
|
|
/* Grouped stat blocks (profile overview) */
|
|
.wd-sg-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin: 4px 0 16px; }
|
|
.wd-sg { background: var(--secondary-background-color); border-radius: 10px; padding: 14px; }
|
|
.wd-sg-h { font-size: .7em; text-transform: uppercase; letter-spacing: .08em; color: var(--secondary-text-color); margin-bottom: 6px; }
|
|
.wd-sg-main { font-size: 1.55em; font-weight: 700; line-height: 1.1; }
|
|
.wd-sg-main span { font-size: .5em; font-weight: 400; color: var(--secondary-text-color); margin-left: 4px; }
|
|
.wd-sg-sub { font-size: .78em; color: var(--secondary-text-color); margin-top: 5px; line-height: 1.5; }
|
|
.wd-logline { padding: 2px 0; border-bottom: 1px solid var(--divider-color); white-space: pre-wrap; word-break: break-word; }
|
|
.wd-logline:last-child { border-bottom: none; }
|
|
/* Entity combobox */
|
|
.wd-combo { position: relative; width: 100%; }
|
|
.wd-combo-drop { position: absolute; top: 100%; left: 0; right: 0; z-index: 60;
|
|
background: var(--card-background-color,#fff); border: 1px solid var(--divider-color);
|
|
border-radius: 6px; box-shadow: 0 4px 14px rgba(0,0,0,.18);
|
|
max-height: 220px; overflow-y: auto; margin-top: 3px; }
|
|
/* Flipped above the input when there is more room there (_positionComboDrop). */
|
|
.wd-combo-drop.wd-drop-up { top: auto; bottom: 100%; margin-top: 0; margin-bottom: 3px; }
|
|
.wd-combo-item { padding: 7px 12px; cursor: pointer; font-size: .86em; white-space: nowrap;
|
|
overflow: hidden; text-overflow: ellipsis; }
|
|
.wd-combo-item:hover, .wd-combo-item.kbd { background: var(--secondary-background-color); }
|
|
.wd-combo-row { display: flex; gap: 6px; align-items: center; }
|
|
.wd-combo-row .wd-combo { flex: 1 1 auto; }
|
|
/* Colour field: swatch + hex text box + clear (#454) */
|
|
.wd-colorfield { display: flex; gap: 6px; align-items: center; width: 100%; }
|
|
.wd-colorfield input[type="text"] { flex: 1 1 auto; min-width: 0; }
|
|
.wd-color-sw { flex: 0 0 auto; width: 34px; height: 34px; padding: 2px; cursor: pointer;
|
|
border: 1px solid var(--divider-color); border-radius: var(--wd-radius-md);
|
|
background: var(--secondary-background-color); }
|
|
.wd-color-sw::-webkit-color-swatch-wrapper { padding: 0; }
|
|
.wd-color-sw::-webkit-color-swatch { border: none; border-radius: 4px; }
|
|
.wd-color-sw::-moz-color-swatch { border: none; border-radius: 4px; }
|
|
.wd-color-clear { flex: 0 0 auto; width: 30px; height: 34px; border-radius: var(--wd-radius-md);
|
|
border: 1px solid var(--divider-color); background: var(--secondary-background-color);
|
|
color: var(--secondary-text-color); font-size: 1.1em; line-height: 1; cursor: pointer; }
|
|
.wd-color-clear:hover { color: var(--primary-text-color); }
|
|
.wd-addbtn { flex: 0 0 auto; width: 34px; height: 34px; border-radius: var(--wd-radius-md); border: 1px solid var(--divider-color); background: var(--secondary-background-color); color: var(--primary-text-color); font-size: 1.25em; line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
|
.wd-addbtn:hover { background: var(--primary-color); color: #fff; border-color: var(--primary-color); }
|
|
.wd-loglvl { font-weight: 700; margin-right: 6px; }
|
|
.wd-logcomp { display: inline-block; font-size: .72em; color: var(--secondary-text-color); background: var(--secondary-background-color); border-radius: 4px; padding: 0 5px; margin-right: 6px; }
|
|
.wd-logdev { display: inline-block; font-size: .72em; color: var(--primary-color); margin-right: 6px; }
|
|
.wd-logts { color: var(--secondary-text-color); margin-right: 6px; }
|
|
.wd-lvl-ERROR, .wd-lvl-CRITICAL { color: var(--error-color, #f44336); }
|
|
.wd-lvl-WARNING { color: var(--warning-color, #ff9800); }
|
|
.wd-lvl-INFO { color: var(--info-color, #2196f3); }
|
|
.wd-lvl-DEBUG { color: var(--secondary-text-color); }
|
|
/* Compact cycle list */
|
|
.wd-clist { display: flex; flex-direction: column; }
|
|
.wd-crow { display: flex; align-items: center; gap: 10px; padding: 9px 6px; border-bottom: 1px solid var(--divider-color); cursor: pointer; }
|
|
.wd-crow:hover { background: var(--secondary-background-color); }
|
|
.wd-crow:last-child { border-bottom: none; }
|
|
.wd-cmain { flex: 1; min-width: 0; overflow: hidden; }
|
|
.wd-cprog { font-weight: 600; }
|
|
.wd-cdate { font-size: .74em; color: var(--secondary-text-color); }
|
|
.wd-cmeta { text-align: right; font-size: .76em; color: var(--secondary-text-color); white-space: nowrap; }
|
|
/* Responsive / touch (portrait, phones, side panel) */
|
|
@media (max-width: 680px) {
|
|
.wd-body { padding: 12px 10px 64px; }
|
|
.wd-nav { margin: -12px -10px 12px; padding: 12px 10px 0; }
|
|
.wd-card { padding: 14px; margin-bottom: 12px; }
|
|
.wd-form-grid { grid-template-columns: 1fr; }
|
|
.wd-stats { grid-template-columns: repeat(2, 1fr); }
|
|
.wd-kv { grid-template-columns: repeat(2, 1fr); }
|
|
.wd-tab { padding: 9px 13px; }
|
|
.wd-modal { padding: 16px; width: calc(100% - 18px); }
|
|
.wd-modal-lg { max-width: 100%; }
|
|
.wd-sd-prof-name { white-space: normal; overflow-wrap: anywhere; } /* #460: wrap, never ellipsise a choice */
|
|
.wd-canvas-wrap canvas { height: 200px; }
|
|
.wd-zoom-reset { width: 36px; height: 36px; top: 8px; right: 8px; font-size: 17px; }
|
|
.wd-gtip { white-space: normal; max-width: calc(100vw - 24px); }
|
|
.wd-header { padding: 12px 14px; }
|
|
.wd-btn { padding: 9px 15px; } /* larger touch targets */
|
|
.wd-tip-pop { width: 210px; }
|
|
.wd-pg-lane-lbl { flex: 0 1 120px; max-width: 120px; }
|
|
#wd-settings-form .wd-form-grid { grid-template-columns: 1fr; gap: 12px 0; }
|
|
}
|
|
/* Log drawer */
|
|
/* The render target between :host and .wd-shell (created in _boot). It must be a
|
|
full-height flex column of its own: an unstyled wrapper is a block box sized by its
|
|
content, so .wd-shell's flex:1 had no flex container to resolve against and the whole
|
|
chain down to .wd-main fell back to content height. .wd-main then never overflowed
|
|
(scrollHeight == clientHeight, so no scrollbar) and instead spilled out of the
|
|
clipped host, stranding the bottom of every long tab where no scrolling reaches it. */
|
|
.wd-root { height: 100%; display: flex; flex-direction: column; }
|
|
/* min-height:0 so the row below can actually shrink and hand its overflow to
|
|
.wd-main, instead of the shell growing to fit and pushing past the host. */
|
|
.wd-shell { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
.wd-content-row { display: flex; flex: 1; overflow: hidden; min-height: 0; }
|
|
.wd-main { flex: 1; overflow-y: auto; min-width: 0; }
|
|
.wd-log-drawer {
|
|
position: relative; width: 0; overflow: hidden;
|
|
transition: width .28s cubic-bezier(.4,0,.2,1);
|
|
border-left: 1px solid var(--divider-color);
|
|
display: flex; flex-direction: column;
|
|
background: var(--primary-background-color);
|
|
}
|
|
.wd-log-drawer.open { width: 380px; }
|
|
.wd-log-resize {
|
|
position: absolute; left: 0; top: 0; bottom: 0; width: 6px; cursor: ew-resize; z-index: 2;
|
|
transition: background .15s;
|
|
}
|
|
.wd-log-resize:hover, .wd-log-resize.dragging { background: var(--primary-color, #03a9f4); opacity: .35; }
|
|
.wd-log-drawer-head {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 10px 14px; border-bottom: 1px solid var(--divider-color);
|
|
font-weight: 600; font-size: .9em; flex-shrink: 0; white-space: nowrap;
|
|
}
|
|
.wd-log-drawer-body { flex: 1; overflow-y: auto; padding: 10px 14px; min-width: 0; }
|
|
.wd-log-close-btn {
|
|
background: none; border: none; cursor: pointer; color: inherit; opacity: .65;
|
|
padding: 3px 6px; border-radius: var(--wd-radius-sm); font-size: 1.1em; line-height: 1;
|
|
}
|
|
.wd-log-close-btn:hover { opacity: 1; background: var(--secondary-background-color); }
|
|
.wd-gear-btn.log-active { background: rgba(255,255,255,.22); }
|
|
@media (max-width: 680px) {
|
|
.wd-log-drawer.open { width: 100vw !important; position: fixed; top: 0; right: 0; bottom: 0; z-index: 30; border-left: none; }
|
|
.wd-log-resize { display: none; }
|
|
}
|
|
.wd-pg-delta-up { color: var(--success-color, #4caf50); font-weight: 700; }
|
|
.wd-pg-delta-down { color: var(--error-color, #f44336); font-weight: 700; }
|
|
.wd-pg-delta-flat { color: var(--secondary-text-color); }
|
|
/* F3: Unified Playground */
|
|
.wd-pg-canvas-wrap { position: relative; width: 100%; }
|
|
/* Same pan-y contract as .wd-canvas-wrap canvas above: page keeps the vertical
|
|
swipe, we keep horizontal drags and pinch. This canvas previously had no
|
|
touch-action at all (default auto), so on touch the browser cancelled every
|
|
gesture and none of the pan / threshold-drag / hover features worked. */
|
|
#wd-pg-canvas { display: block; width: 100%; height: 330px; cursor: crosshair; touch-action: pan-y; border-radius: 6px; background: var(--secondary-background-color); margin: 10px 0 0; }
|
|
.wd-pg-strip { display: flex; align-items: center; gap: 10px; padding: 8px 2px; font-size: .88em; font-variant-numeric: tabular-nums; flex-wrap: wrap; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.2)); margin-bottom: 12px; }
|
|
.wd-pg-strip-state { padding: 2px 10px; border-radius: 20px; font-weight: 700; font-size: .83em; white-space: nowrap; }
|
|
.wd-pg-strip-pbar { display: inline-flex; align-items: center; gap: 5px; }
|
|
.wd-pg-strip-track { width: 60px; height: 6px; background: var(--secondary-background-color); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
|
.wd-pg-strip-fill { height: 100%; background: var(--primary-color); border-radius: 3px; transition: width .15s; }
|
|
.wd-pg-params { display: flex; flex-direction: column; gap: 2px; }
|
|
.wd-pg-param-row { display: flex; align-items: center; gap: 6px; }
|
|
.wd-pg-param-lbl { flex: 1; font-size: .83em; color: var(--secondary-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.wd-pg-param-inp { width: 76px; flex: 0 0 76px; }
|
|
.wd-pg-param-drag { font-size: .75em; color: var(--primary-color); cursor: default; flex: 0 0 12px; }
|
|
.wd-pg-cand-row { display: flex; align-items: center; gap: 6px; font-size: .82em; margin: 3px 0; }
|
|
.wd-pg-cand-name { flex: 0 0 110px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.wd-pg-cand-track { flex: 1; height: 7px; background: var(--secondary-background-color); border-radius: var(--wd-radius-sm); overflow: hidden; }
|
|
.wd-pg-cand-fill { height: 100%; border-radius: var(--wd-radius-sm); }
|
|
.wd-pg-cand-pct { flex: 0 0 34px; text-align: right; color: var(--secondary-text-color); }
|
|
/* Playground: settings control panel (live-settings source + named presets) */
|
|
.wd-pg-ctrl { border: 1px solid var(--divider-color, rgba(127,127,127,.25)); border-radius: var(--wd-radius-md, 10px); padding: 8px 10px; margin: 0 0 10px; background: var(--secondary-background-color); }
|
|
.wd-pg-ctrl .wd-btn { white-space: nowrap; }
|
|
.wd-pg-preset-sel { min-width: 150px; max-width: 220px; font-size: .82em; }
|
|
.wd-pg-preset-name { flex: 1 1 150px; min-width: 120px; max-width: 220px; font-size: .82em; }
|
|
/* Per-setting publish arrow. The empty slot keeps every row's value column aligned
|
|
whether or not that row currently has a publishable change. */
|
|
.wd-pg-pub { width: 20px; height: 20px; flex: 0 0 20px; padding: 0; border: none; border-radius: 5px; cursor: pointer; background: var(--primary-color); color: var(--text-primary-color, #fff); font-size: .8em; line-height: 1; }
|
|
.wd-pg-pub:hover { filter: brightness(1.15); }
|
|
.wd-pg-pub-slot { width: 20px; flex: 0 0 20px; display: inline-block; }
|
|
/* Playground: unified workbench (graph+settings always on top) + "Across your
|
|
cycles" drawer with History/Optimize sub-tabs. */
|
|
.wd-pg-drawer { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--divider-color, rgba(127,127,127,.25)); }
|
|
.wd-pg-drawer-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; }
|
|
.wd-pg-subtabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: var(--secondary-background-color); }
|
|
.wd-pg-subtab { border: none; background: transparent; color: var(--secondary-text-color); font: inherit; font-size: .84em; font-weight: 600; padding: 5px 13px; border-radius: 8px; cursor: pointer; }
|
|
.wd-pg-subtab:hover { color: var(--primary-text-color); }
|
|
.wd-pg-subtab.active { background: var(--card-background-color, var(--primary-background-color)); color: var(--primary-color); box-shadow: 0 1px 3px rgba(0,0,0,.12); }
|
|
.wd-pg-hrow { cursor: pointer; }
|
|
.wd-pg-hrow:hover td { background: var(--secondary-background-color); }
|
|
.wd-pg-hrow.selected td { background: color-mix(in srgb, var(--primary-color) 14%, transparent); box-shadow: inset 2px 0 0 var(--primary-color); }
|
|
.wd-pg-sim-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; margin-top: 4px; }
|
|
.wd-pg-sim-main, .wd-pg-sim-side { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
.wd-pg-simbar { height: 6px; border-radius: 3px; background: var(--secondary-background-color); overflow: hidden; margin: 6px 0 0; }
|
|
.wd-pg-simbar-fill { height: 100%; width: 40%; border-radius: 3px; background: var(--primary-color); animation: wd-pg-indeterminate 1.1s ease-in-out infinite; }
|
|
@keyframes wd-pg-indeterminate { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
|
|
.wd-pg-batchbar-fill { height: 100%; width: 0%; border-radius: 3px; background: var(--primary-color); transition: width .18s ease; }
|
|
/* Header activity pills (background-task registry) */
|
|
.wd-task-pills { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 0 0 0 12px; }
|
|
.wd-task-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 4px 3px 9px; border-radius: 12px; background: rgba(255,255,255,.16); color: var(--app-header-text-color, #fff); font-size: .78em; line-height: 1; }
|
|
.wd-task-pill-lbl { font-weight: 600; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.wd-task-pill-pct { font-variant-numeric: tabular-nums; opacity: .95; }
|
|
.wd-task-pill-eta { opacity: .75; }
|
|
.wd-task-pill-x { border: none; background: rgba(0,0,0,.18); color: inherit; width: 16px; height: 16px; border-radius: 50%; cursor: pointer; font-size: .9em; line-height: 1; display: inline-flex; align-items: center; justify-content: center; padding: 0; }
|
|
.wd-task-pill-x:hover { background: rgba(0,0,0,.32); }
|
|
.wd-task-pill--cancelling { background: rgba(255,160,0,.28); }
|
|
.wd-task-pill-cancelling { opacity: .9; font-style: italic; }
|
|
.wd-task-pill-x--cancelling { opacity: .4; cursor: default; pointer-events: none; }
|
|
.wd-task-spin { width: 10px; height: 10px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: wd-spin-kf .8s linear infinite; opacity: .9; }
|
|
@keyframes wd-spin-kf { to { transform: rotate(360deg); } }
|
|
.wd-pg-batchrow { display: flex; align-items: center; gap: 10px; margin: 6px 0 8px; }
|
|
.wd-pg-batchrow .wd-pg-simbar { flex: 1; margin: 0; }
|
|
#wd-pg-canvas.wd-pg-panning { cursor: grabbing; }
|
|
.wd-pg-alerts-card { background: var(--secondary-background-color); border-radius: 10px; padding: 12px; }
|
|
.wd-pg-outcome-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
.wd-pg-outcome-item { text-align: center; background: var(--card-background-color, var(--primary-background-color)); border-radius: 8px; padding: 8px 4px; }
|
|
.wd-pg-outcome-val { font-size: 1.05em; font-weight: 700; }
|
|
.wd-pg-outcome-lbl { font-size: .68em; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: .04em; margin-top: 2px; }
|
|
.wd-pg-alert { border-left: 3px solid var(--info-color, #2196f3); padding: 4px 0 4px 10px; }
|
|
/* History table */
|
|
.wd-pg-htable { width: 100%; border-collapse: collapse; font-size: .84em; }
|
|
.wd-pg-htable th { text-align: left; font-weight: 600; color: var(--secondary-text-color); padding: 6px 8px; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.2)); font-size: .82em; }
|
|
.wd-pg-htable td { padding: 6px 8px; border-bottom: 1px solid var(--divider-color, rgba(127,127,127,.12)); }
|
|
.wd-pg-htable tr[data-action] { cursor: pointer; }
|
|
.wd-pg-htable tr[data-action]:hover { background: var(--secondary-background-color); }
|
|
.wd-pg-diffbadge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: .82em; font-weight: 600; margin: 0 6px 6px 0; }
|
|
/* Sweep heatmap */
|
|
@media (max-width: 720px) {
|
|
.wd-pg-sim-grid { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 640px) {
|
|
.wd-pg-strip { gap: 7px; font-size: .82em; }
|
|
}
|
|
`;function ht(h){const t=Number(h);if(h==null||!isFinite(t)||t<0)return"-";h=t;const e=Math.floor(h/3600),s=Math.floor(h%3600/60),a=Math.floor(h%60);return e>0?`${e}h ${s}m`:s>0?`${s}m ${a}s`:`${a}s`}function Lt(h){const t=Number(h);return h==null||!isFinite(t)?"-":t>=100?`${it(Math.round(t),0)} W`:`${it(t,1)} W`}function Ht(h,t){const e=Math.abs(t||h||0),s=e>0?Math.min(4,Math.max(0,Math.ceil(-Math.log10(e))+1)):0;return`${Number(h.toFixed(s))}W`}function Ie(h,t){const e=Math.abs(h)/Math.max(1,t);if(!(e>0)||!isFinite(e))return 1;const s=Math.pow(10,Math.floor(Math.log10(e))),a=e/s;return(a<=1?1:a<=2?2:a<=2.5?2.5:a<=5?5:10)*s}function gt(h){return h==null||!isFinite(Number(h))?"-":`${it(h,2)} kWh`}let se="relative",Et,jt,Wt=!0,qt;const ie=new Map;function Re(h,t){const e=t||{};Et=h||void 0;const s=e.number_format;jt=s==="comma_decimal"?["en-US","en"]:s==="decimal_comma"?["de","es","it"]:s==="space_comma"?["fr","sv","cs"]:s==="quote_decimal"?["de-CH"]:s==="system"?void 0:h||void 0,Wt=s!=="none",qt=e.time_format==="12"?!0:e.time_format==="24"?!1:void 0}function ae(h){const t=JSON.stringify([jt,Wt,h]);let e=ie.get(t);if(!e){const s=Object.assign({useGrouping:Wt},h);try{e=new Intl.NumberFormat(jt,s)}catch{e=new Intl.NumberFormat(void 0,s)}ie.set(t,e)}return e}function it(h,t=0){const e=Number(h);return isFinite(e)?ae({minimumFractionDigits:t,maximumFractionDigits:t}).format(e):"-"}function Dt(h){const t=Number(h);return isFinite(t)&&t>0?Math.floor(t):0}function It(h,t){const e=Number(h);if(h==null||!isFinite(e))return"-";if(t)try{return ae({style:"currency",currency:t}).format(e)}catch{}return`${it(e,2)}${t?" "+t:""}`}function ne(h){return qt===void 0?h:Object.assign({hour12:qt},h)}function re(h){const t=ne({hour:"2-digit",minute:"2-digit",second:"2-digit"});try{return h.toLocaleTimeString(Et,t)}catch{return h.toLocaleTimeString(void 0,t)}}function Oe(h){const t=new Date(h);try{return t.toLocaleDateString(Et)}catch{return t.toLocaleDateString()}}const ze=3700;function oe(h){const t=e=>String(e).padStart(2,"0");return`${h.getFullYear()}-${t(h.getMonth()+1)}-${t(h.getDate())}`}function le(h){const t=new Date;return t.setDate(t.getDate()-h),oe(t)}function Be(){return oe(new Date)}function Ut(){return le(10)}function Fe(){return le(ze-1)}function Ne(h){const t=Math.round((h-Date.now())/1e3);let e;try{e=new Intl.RelativeTimeFormat(Et,{numeric:"auto"})}catch{return de(h)}const s=Math.abs(t),a=[["year",31536e3],["month",2592e3],["week",604800],["day",86400],["hour",3600],["minute",60]];for(const[r,i]of a)if(s>=i)return e.format(Math.round(t/i),r);return e.format(t,"second")}function de(h){const t=ne({month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"});try{return new Date(h).toLocaleString(Et,t)}catch{return new Date(h).toLocaleString(void 0,t)}}function et(h,t){if(!h)return"-";let e;if(typeof h=="number")e=h>=1e12?h:h*1e3;else{const s=String(h),a=/^(\d{4})-(\d{2})-(\d{2})$/.exec(s);e=a?new Date(+a[1],+a[2]-1,+a[3]).getTime():new Date(s).getTime()}return isNaN(e)?"-":(t||se)==="relative"?Ne(e):de(e)}function Ct(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,"'")}class ce{constructor(t){this.html=String(t??"")}toString(){return this.html}}function vt(h){return new ce(h)}const he=new Map;function He(h,t){let e=he.get(h);if(e===void 0){try{e=new Intl.PluralRules(h||"en")}catch{e=null}he.set(h,e)}return e?e.select(t):t===1?"one":"other"}function je(h){const t=String(h??"").trim();return/^https?:\/\//i.test(t)?t:""}function Pt(h,t){const e=String(h??"").trim();let s=e.match(/^#([0-9a-fA-F]{3,8})$/);if(s){let a=s[1];if((a.length===3||a.length===4)&&(a=a.split("").map(r=>r+r).join("")),a.length===6||a.length===8){const r=parseInt(a.slice(0,2),16),i=parseInt(a.slice(2,4),16),o=parseInt(a.slice(4,6),16);return`rgba(${r}, ${i}, ${o}, ${t})`}}if(s=e.match(/^rgba?\(([^)]+)\)$/i),s){const a=s[1].split(",").map(r=>r.trim());if(a.length>=3)return`rgba(${a[0]}, ${a[1]}, ${a[2]}, ${t})`}return e}function pe(h,t){const e=parseFloat(h);return isNaN(e)?t:e}const We=["data-action","data-cid","data-idx","data-tab","data-sec","data-gtab","data-ptab","data-hsub","data-proftab","data-opt","data-pgkey","data-gname","data-sugkey"];function qe(h){if(!h||!h.getAttribute||h.tagName==="BODY")return null;const t=s=>window.CSS&&CSS.escape?CSS.escape(s):String(s).replace(/["\\]/g,"\\$&");if(h.id)return"#"+t(h.id);const e=We.filter(s=>h.hasAttribute(s)).map(s=>`[${s}="${t(h.getAttribute(s))}"]`);return e.length?h.tagName.toLowerCase()+e.join(""):null}function Vt(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 xt(h){return h==null||h===""?"(none)":h===!0?"on":h===!1?"off":Array.isArray(h)?h.join(", ")||"(none)":String(h)}function _e(h,t,e){return h.slice().sort((s,a)=>{const r=t(s),i=t(a);return r==null&&i==null?0:r==null?1:i==null?-1:(r<i?-1:r>i?1:0)*e})}function mt(h,t,e,s,a,r,i){const o=e?s===1?" \u25B2":" \u25BC":' <span style="opacity:.35">\u2195</span>',n=r==="right"?"text-align:right;":"",l=i?` title="${d(i)}"`:"";return`<th class="wd-th-sort" data-sortcol="${t}" data-sortact="${a}" style="cursor:pointer;user-select:none;${n}"${l}>${h}${o}</th>`}function $t(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 ue(h,t){const e={pause:["lbl.artifact_interruption","Interruption"],dip:["lbl.artifact_low_power","Low power"],spike:["lbl.artifact_high_power","High power"]},[s,a]=e[h]||["lbl.artifact_anomaly","Anomaly"];return t?t(s,{},a):a}function ge(h){return h.toLowerCase().replace(/[\s&/\-]+/g,"_").replace(/^_+|_+$/g,"").replace(/_+/g,"_")}function fe(h){let t=String(h??"").trim().toLowerCase();try{t=t.normalize("NFKD").replace(/[\u0300-\u036f]/g,"")}catch{}return t.replace(/[^\p{L}\p{N}]+/gu,"-").replace(/^-+|-+$/g,"")}function me(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 Ue(h,t){if(!h||!h.length)return null;for(let e=0;e<h.length;e++){const s=h[e];if(s[0]<t)continue;if(s[0]===t)return s[1]==null?null:s[1];if(e===0)return null;const a=h[e-1];if(a[1]==null||s[1]==null)return null;const r=s[0]-a[0]||1;return a[1]+(s[1]-a[1])*((t-a[0])/r)}return null}function Gt(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],a=h[e],r=a[0]-s[0]||1;return s[1]+(a[1]-s[1])*((t-s[0])/r)}return h[h.length-1][1]}const Kt=/^#[0-9a-fA-F]{6}$/,Ve="#03A9F4";function ye(h){return Kt.test(String(h||""))?String(h):Ve}function Ge(h,t,e){e=e||{};const s=h.key,a=h.unit?`${h.label} (${h.unit})`:h.label,r=h.unit?` ${h.unit}`:"",i=h.doc?ot(h.doc,h.diagram||Ye[s],e.tText):"",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}${i}</div>${h.hint?`<div class="wd-field-hint">${d(h.hint)}</div>`:""}</div>`;let n="";const l=t??"";if(h.type==="select"||h.type==="devicetype"||h.type==="device"){const b=(e.opts||[]).map(([v,T])=>`<option value="${d(v)}" ${String(l)===String(v)?"selected":""}>${d(T)}</option>`).join("");n=`<select data-opt="${s}" data-ftype="${h.type}">${b}</select>`}else if(h.type==="textarea")n=`<textarea data-opt="${s}" data-ftype="textarea">${d(l)}</textarea>`;else if(h.type==="json"){const u=l===""||l==null?"":typeof l=="string"?l:JSON.stringify(l,null,2);n=`<textarea data-opt="${s}" data-ftype="json" rows="3" placeholder='${d(e.t("placeholder.json_buttons",{},'[{"action":"ID","title":"Label"}]'))}'>${d(u)}</textarea>`}else if(h.type==="checkboxlist"){const u=Array.isArray(t)&&t.length?t.map(String):Array.isArray(h.def)?h.def.map(String):[];n=`<div data-opt="${s}" data-ftype="checkboxlist" class="wd-checkboxlist">`+(h.choices||[]).map(([b,v])=>`<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(b)}" ${u.includes(String(b))?"checked":""}><span class="wd-switch-slider"></span></span><span class="wd-switch-text">${d(e.t?e.t("lbl.evidence_"+b,{},v):v)}</span></label>`).join("")+"</div>"}else if(h.type==="entitylist"){const b=(Array.isArray(t)?t:t?[t]:[]).map(v=>`<span class="wd-pill" data-val="${d(v)}" title="${d(v)}"><span class="wd-pill-txt">${d(v)}</span><button type="button" class="wd-pill-x" aria-label="${d(e.t?e.t("btn.remove",{},"Remove"):"Remove")}">\xD7</button></span>`).join("");n=`<div class="wd-pillbox" data-opt="${s}" data-ftype="entitylist">${b}<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 u=Array.isArray(l)?l:[],b=e.t?e.t("lbl.timer_min",{},"min"):"min",v=e.t?e.t("lbl.timer_msg_placeholder",{},"Message (optional, {device}/{program}/{minutes})"):"Message (optional, {device}/{program}/{minutes})",T=e.t?e.t("lbl.timer_auto_pause",{},"Auto-pause"):"Auto-pause",C=e.t?e.t("btn.remove_timer",{},"Delete"):"Delete",x=e.t?e.t("btn.add_timer",{},"+ Add timer"):"+ Add timer",$=(D,B)=>{const U=D&&D.offset_minutes?String(D.offset_minutes):"",z=D&&D.message?d(D.message):"",M=D&&D.auto_pause?" checked":"";return`<div class="wd-timer-row" data-tidx="${B}"><div class="wd-timer-top"><input type="number" min="1" placeholder="${d(b)}" data-field="offset_minutes" value="${d(U)}"><textarea placeholder="${d(v)}" data-field="message">${z}</textarea></div><div class="wd-timer-footer"><label class="wd-switch-lbl"><span class="wd-switch"><input type="checkbox" data-field="auto_pause"${M}><span class="wd-switch-slider"></span></span><span class="wd-switch-text">${d(T)}</span></label><button type="button" class="wd-btn wd-btn-sm wd-btn-danger wd-timer-remove">${d(C)}</button></div></div>`},L=u.map((D,B)=>$(D,B)).join("");n=`<div class="wd-timerlist" data-opt="${s}" data-ftype="timerlist">${L}<button type="button" class="wd-btn wd-btn-sm wd-btn-secondary wd-timer-add">${d(x)}</button></div>`}else if(h.type==="color"){const u=h.placeholder?` placeholder="${d(h.placeholder)}"`:"",b=Kt.test(String(l))?String(l):ye(h.placeholder),v=e.t?e.t("btn.clear",{},"Clear"):"Clear";n=`<div class="wd-colorfield"><input type="color" class="wd-color-sw" value="${d(b)}" aria-label="${d(h.label)}" tabindex="-1"><input type="text" data-opt="${s}" data-ftype="color" value="${d(l)}" autocomplete="off" spellcheck="false"${u}><button type="button" class="wd-color-clear" aria-label="${d(v)}" title="${d(v)}">\xD7</button></div>`}else if(h.type==="entity"){const u=h.placeholder?` placeholder="${d(h.placeholder)}"`:"";n=`<div class="wd-combo"><input type="text" class="wd-combo-inp" data-opt="${s}" data-ftype="entity" value="${d(l)}" autocomplete="off" spellcheck="false"${u}><div class="wd-combo-drop" hidden></div></div>`}else if(h.type==="list"){const u=Array.isArray(l)?l.join(", "):d(l);n=`<input type="text" data-opt="${s}" data-ftype="list" value="${d(u)}" placeholder="${d(e.t("placeholder.notify_services_list",{},"notify.mobile_app_phone, ..."))}">`}else if(h.type==="intlist"){const u=Array.isArray(l)?l.join(", "):String(l??""),b=h.placeholder?` placeholder="${d(h.placeholder)}"`:"";n=`<input type="text" data-opt="${s}" data-ftype="intlist" value="${d(u)}"${b}>`}else{const u=h.type==="number"?"number":"text",b=e.datalistId?` list="${e.datalistId}"`:"",v=h.step!=null?` step="${h.step}"`:"",T=h.min!=null?` min="${h.min}"`:"",C=h.max!=null?` max="${h.max}"`:"",x=h.placeholder?` placeholder="${d(h.placeholder)}"`:"";n=`<input type="${u}" data-opt="${s}" data-ftype="${h.type}" value="${d(l)}"${v}${T}${C}${b}${x}>${e.datalist||""}`}const c=e.suggestion,p=c&&c.suggested!=null&&!Ke(c.suggested,t)?c.suggested:null,_=e.t;let f="";if(p!=null){const u=e.tText||_,b=c.reason_key?u(c.reason_key,c.reason_params||{},c.reason||""):c.reason||"",v=b?ot(b):"",T=t!=null&&t!==""?` <span style="opacity:.6;font-size:.9em">${_("suggestion.now_value",{v:`${t}${r}`},"(now {v})")}</span>`:"",C=we(_,s,p,t),x=`<button type="button" class="wd-sug-use" data-sugkey="${s}" data-sugval="${d(p)}">${e.useBtnLabel||"Use"}</button>`,$=d(_("btn.mute_suggestion",{},"Stop suggesting this setting")),L=s?`<button type="button" class="wd-sug-lock" data-suglock="${s}" title="${$}" aria-label="${$}">\u{1F515}</button>`:"";f=`<div class="wd-sug"><span class="wd-sug-chip wd-sug-chip-obs">\u{1F4A1} ${d(_("suggestion.observed_label",{},"Observed"))}</span><span class="wd-sug-val">${d(p)}${r}</span>${T}${x}${v}${L}${C?`<div class="wd-sug-impact-line">${d(C)}</div>`:""}</div>`}return a&&n&&(n=n.replace(/<(input|select|textarea)\b(?![^>]*\baria-label=)/,`<$1 aria-label="${d(a)}"`)),`<div class="wd-field" data-field="${s}"><div class="wd-label-row"><label style="margin:0">${d(a)}</label>${o}${i}</div>${n}${h.hint?`<div class="wd-field-hint">${d(h.hint)}</div>`:""}<div class="wd-conflict-err" data-cerr="${s}" hidden></div><div class="wd-setting-note" data-cnote="${s}" hidden></div>${f}</div>`}function Ke(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)}function we(h,t,e,s){const a=parseFloat(e),r=parseFloat(s);return isNaN(a)||isNaN(r)||a===r?"":h(`suggestion.impact.${t}.${a>r?"higher":"lower"}`,{},"")}const Ye={min_power:"min_power",off_delay:"off_delay",start_threshold_w:"hysteresis",stop_threshold_w:"hysteresis",start_energy_threshold:"start_energy",running_dead_zone:"dead_zone",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",duration_tolerance:"duration_tolerance",start_duration_threshold:"start_duration",end_energy_threshold:"end_energy_thresh",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",anti_crease_finalize_ratio:"anti_wrinkle",sampling_interval:"sampling"};function be(h){let t=0,e=window.innerWidth,s=0,a=window.innerHeight;for(let r=h.parentElement;r;r=r.parentElement){const i=getComputedStyle(r),o=i.overflowX!=="visible",n=i.overflowY!=="visible";if(!o&&!n)continue;const l=r.getBoundingClientRect();o&&(l.left>t&&(t=l.left),l.right<e&&(e=l.right)),n&&(l.top>s&&(s=l.top),l.bottom<a&&(a=l.bottom))}return{left:t,right:e,top:s,bottom:a}}const Xe=".wd-tabs, .wd-table-wrap";function Yt(h){const t=h.scrollWidth-h.clientWidth,e=Math.abs(h.scrollLeft),s=t<=1||e<=1,a=t<=1||e>=t-1,r=getComputedStyle(h).direction==="rtl";h.classList.toggle(r?"wd-ovf-r":"wd-ovf-l",!s),h.classList.toggle(r?"wd-ovf-l":"wd-ovf-r",!a)}function ot(h,t,e){const s=t?Je(t,e):"";return`<span class="wd-tip" tabindex="0" role="note" aria-label="${d(h)}">i<span class="wd-tip-pop">${s}<span class="wd-tip-txt">${d(h)}</span></span></span>`}function Xt(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 kt(h,t,e="",s=""){return`<div class="wd-field wd-field-switch"><div class="wd-switch-row">${Xt(h,t,e)}</div>${s?`<div class="wd-field-hint">${d(s)}</div>`:""}</div>`}function Je(h,t){const e=t||((r,i,o)=>o),s=r=>`<svg class="wd-dg" viewBox="0 0 200 90" preserveAspectRatio="xMidYMid meet">${r}</svg>`,a='<line class="ax" x1="8" y1="78" x2="192" y2="78"/>';switch(h){case"min_power":return s(`${a}
|
|
<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">${d(e("diagram.min",{},"min"))}</text><text x="10" y="14">${d(e("diagram.below_off",{},"below = off"))}</text>`);case"hysteresis":return s(`${a}
|
|
<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">${d(e("diagram.start",{},"start"))}</text><text x="10" y="70">${d(e("diagram.stop",{},"stop"))}</text>`);case"start_energy":return s(`${a}
|
|
<polyline class="bad" points="40,78 41,26 42,78"/>
|
|
<text x="14" y="20">${d(e("diagram.spike_ignored",{},"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">${d(e("diagram.energy_counts",{},"energy counts"))}</text>`);case"off_delay":return s(`${a}
|
|
<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">${d(e("diagram.off_delay_wait",{},"off-delay wait"))}</text>`);case"duration_tolerance":return s(`${a}
|
|
<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">${d(e("diagram.minus_tol",{},"-tol"))}</text><text x="120" y="30">${d(e("diagram.plus_tol",{},"+tol"))}</text><text x="84" y="74">${d(e("diagram.profile",{},"profile"))}</text>`);case"match_ratios":return s(`${a}
|
|
<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">${d(e("diagram.min",{},"min"))}</text><text x="120" y="28">${d(e("diagram.max",{},"max"))}</text>`);case"dead_zone":return s(`${a}
|
|
<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">${d(e("diagram.ignored",{},"ignored"))}</text>`);case"progress_reset":return s(`${a}
|
|
<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">${d(e("diagram.held_at_100",{},"held at 100%"))}</text>`);case"min_duration":return s(`${a}
|
|
<polyline class="bad" points="30,78 34,50 38,78"/>
|
|
<text x="20" y="44">${d(e("diagram.too_short",{},"too short"))}</text>
|
|
<polyline class="ln" points="90,78 100,40 150,40 160,78"/>
|
|
<text x="106" y="34">${d(e("diagram.kept",{},"kept"))}</text>`);case"min_off_gap":return s(`${a}
|
|
<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">${d(e("diagram.off_gap",{},"off-gap"))}</text>
|
|
<text x="9" y="14">${d(e("diagram.gap_below_min",{},"gap below min = one cycle"))}</text>`);case"start_duration":return s(`${a}
|
|
<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">${d(e("diagram.spike_ignored_short",{},"spike: ignored"))}</text>
|
|
<text x="114" y="46">${d(e("diagram.confirmed",{},"confirmed"))}</text>`);case"end_energy_thresh":return s(`${a}
|
|
<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">${d(e("diagram.tail_energy_resets",{},"tail energy above thresh: timer resets"))}</text>
|
|
<text x="78" y="56">${d(e("diagram.accum",{},"accum"))}</text>
|
|
<text x="168" y="65">${d(e("diagram.thr",{},"thr"))}</text>`);case"confidence":return s(`
|
|
<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">${d(e("diagram.no_match",{},"no match"))}</text>
|
|
<text x="70" y="36">${d(e("diagram.feedback",{},"feedback"))}</text>
|
|
<text x="156" y="36">${d(e("diagram.auto",{},"auto"))}</text>
|
|
<text x="8" y="74">0.0</text>
|
|
<text x="178" y="74">1.0</text>`);case"watchdog_timeout":return s(`${a}
|
|
<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="36">${d(e("diagram.no_updates",{},"no updates"))}</text>
|
|
<text x="10" y="14">${d(e("diagram.sensor_offline",{},"sensor offline: force-stop"))}</text>`);case"anti_wrinkle":return s(`${a}
|
|
<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">${d(e("diagram.heat_phase",{},"heat phase"))}</text>
|
|
<text x="82" y="40">${d(e("diagram.tumble_kept",{},"tumble pulses kept"))}</text>`);case"sampling":return s(`${a}
|
|
<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">${d(e("diagram.si",{},"SI"))}</text>
|
|
<text x="74" y="50">${d(e("diagram.si",{},"SI"))}</text>
|
|
<text x="112" y="50">${d(e("diagram.si",{},"SI"))}</text>
|
|
<text x="10" y="14">${d(e("diagram.reading_interval",{},"typical reading interval"))}</text>`);default:return""}}class Ze 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,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:[],min_cohesion:.85},this._profileEnvCache={},this._suggestions=[],this._lockedSuggestions=[],this._feedbacks=[],this._diag=null,this._importUndo=null,this._phases=[],this._recState=null,this._opts={},this._optDefaults={},this._mlComparison=null,this._mlById={},this._mlLoading=!1,this._mlTrainingStatus=null,this._setupStatus=null,this._selIdx=0,this._deepLinkApplied=null,this._tab="status",this._settingsSec="basic",this._settingsSearch="",this._settingsSugOnly=!1,this._settingsHistoryOpen=!1,this._canvasZoom={},this._gAxisHandle={},this._gtipPinned=!1,this._gtipLines=null,this._gPinchRaf=null,this._gPinchPending=null,this._toolsSubtab="recording",this._loading=!0,this._tabLoading=!1,this._scrollNavKey=null,this._scrollCarry=null,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._dirtyOptKeys=new Set,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=ve,this._panelSubtab="diagnostics",this._historySub="cycles",this._gearTab="prefs",this._catalog={brands:void 0,devices:void 0,forBrand:null,approvedOnly:!1,brandsFull:!1,brandPrefixes:[],typeDevices:void 0,typeDevicesFor:null,modelIndex:new Map},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._importedOffset=0,this._importedHasMore=!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._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._pgView=null,this._pgHoverT=null,this._pgMap=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()},xe))}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.shadowRoot&&!this._gtipDismissHandler&&(this._gtipDismissHandler=t=>this._maybeDismissGraphTip(t),this.shadowRoot.addEventListener("pointerdown",this._gtipDismissHandler))),this._onResize=()=>{this._syncPanelHeight(),this._resizeLogsPage()},window.addEventListener("resize",this._onResize),this._onLocChanged=()=>this._onLocationChanged(),window.addEventListener("location-changed",this._onLocChanged),window.addEventListener("popstate",this._onLocChanged),this._onOutboundClick=t=>this._maybeGuardOutboundClick(t),window.addEventListener("click",this._onOutboundClick,!0),this._onBeforeUnload=t=>{this._unsavedSettingsCount()&&(t.preventDefault(),t.returnValue="")},window.addEventListener("beforeunload",this._onBeforeUnload),window.visualViewport&&window.visualViewport.addEventListener("resize",this._onResize),requestAnimationFrame(()=>this._syncPanelHeight())}_syncPanelHeight(){if(!this.isConnected)return;const e=this.getBoundingClientRect().top+(window.scrollY||document.documentElement.scrollTop||0),s=Math.round((window.innerHeight||0)-e);if(!(s>0))return;const a=`${Math.max(Ce,s)}px`;this.style.height!==a&&(this.style.height=a)}disconnectedCallback(){this._ovfRO&&(this._ovfRO.disconnect(),this._ovfRO=null),this._onResize&&(window.removeEventListener("resize",this._onResize),window.visualViewport&&window.visualViewport.removeEventListener("resize",this._onResize),this._onResize=null),this._onLocChanged&&(window.removeEventListener("location-changed",this._onLocChanged),window.removeEventListener("popstate",this._onLocChanged),this._onLocChanged=null),this._onOutboundClick&&(window.removeEventListener("click",this._onOutboundClick,!0),this._onOutboundClick=null),this._onBeforeUnload&&(window.removeEventListener("beforeunload",this._onBeforeUnload),this._onBeforeUnload=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._gtipDismissHandler&&this.shadowRoot&&(this.shadowRoot.removeEventListener("pointerdown",this._gtipDismissHandler),this._gtipDismissHandler=null),this._storeConnectListener&&(window.removeEventListener("message",this._storeConnectListener),this._storeConnectListener=null)}_boot(){const t=this.attachShadow({mode:"open"}),e=document.createElement("style");e.textContent=De,t.appendChild(e),this._container=document.createElement("div"),this._container.className="wd-root",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 a=s.target,r=a&&a.closest?a.closest(".wd-tip"):null;r&&this._positionTip(r)},t.addEventListener("pointerover",this._tipHandler),this._gtipDismissHandler=s=>this._maybeDismissGraphTip(s),t.addEventListener("pointerdown",this._gtipDismissHandler),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 a=s.data||{};if(a.entry_id&&(this._fetchAll(),s.event_type==="ha_washdata_cycle_ended")){const r=this._devices[this._selIdx];r&&r.entry_id===a.entry_id&&this._fetchCycles(a.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(a=>{this._evtUnsubs.push(a)}).catch(()=>{});t.subscribeMessage(s=>this._onTaskEvent(s),{type:`${w}/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 a=t.state!=="running"&&t.finished_at&&Date.now()/1e3-t.finished_at<30;t.state!=="running"&&!a||(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(r=>r.kind===t&&r.entry_id===e.entry_id&&r.state!=="running"&&r.has_result).sort((r,i)=>(i.finished_at||0)-(r.finished_at||0)).slice(0,8);if(!s.length)return"";const a=s.map(r=>{const i=r.finished_at?et(r.finished_at*1e3):"",o=r.state==="cancelled"?" \u26A0":"";return`<button class="wd-btn wd-btn-sm" data-action="pg-load-run" data-task-id="${d(r.id)}" title="${d(this._t("lbl.pg_load_run",{},"Reload this run"))}">${d(i)}${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">${a}</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,a=()=>{this._isActiveEntry(s)&&this._render()};t.kind==="ml_training"?this._loadMlTrainingStatus(s).finally(a):this._fetchToolsData(s).finally(a)}async _kickAndTrack(t,e,s){if(this._busy.has(e))return;this._busy.add(e),this._render();let a;try{const i=await this._ws(t);if(a=i&&i.task_id,!a)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(a,o,t.entry_id,0)}catch(i){this._busy.delete(e),this._showToast(this._tText("msg.toast_error",{error:i.message||i},"Error: "+(i.message||i)),"error"),this._render();return}this._taskCallbacks[a]=async i=>{if(this._busy.delete(e),i.state==="error"){this._showToast(this._tText("msg.toast_error",{error:i.error||""},"Error: "+(i.error||"")),"error"),this._render();return}if(i.state==="cancelled"){this._showToast(this._t("toast.task_cancelled",{},"Cancelled."),"info"),this._render();return}let o=null;try{const n=await this._ws({type:`${w}/get_task_result`,task_id:i.id});o=n&&n.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,i.state)}catch{}this._render()};const r=this._tasks[a];if(r&&r.state!=="running"){this._settleTaskCallback(r);return}this._tasksSubscribed||this._pollTaskGeneric(a)}_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 a;try{a=await this._ws({type:`${w}/get_task_result`,task_id:t}),e=0}catch{if(++e>=5){this._finalizeTaskError(t,"lost connection");return}await new Promise(i=>setTimeout(i,1200));continue}if(!a){this._finalizeTaskError(t,"no result");return}if(this._tasks[t]=a,this._updateTaskPills(),a.state!=="running"){this._settleTaskCallback(a);return}await new Promise(r=>setTimeout(r,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,a])=>`${a} ${this._t("suggestion.exclusions.reason."+s,{},String(s).replace(/_/g," "))}`).join(", ");return" "+this._tText("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 a=this._cancellingTasks.has(s.id),r=this._deviceName(s.entry_id),i=s.label_key?this._tText(s.label_key,s.label_params||{},s.label||this._taskActionLabel(s.kind)):this._taskActionLabel(s.kind),o=(r?r+" \xB7 ":"")+i,n=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"),p="wd-task-pill"+(a?" wd-task-pill--cancelling":""),_=a?" \xB7 "+c:n?" "+n:"";return`<span class="${p}" title="${d(o+_)}"><span class="wd-task-spin"></span><span class="wd-task-pill-lbl">${d(o)}</span>`+(a?`<span class="wd-task-pill-cancelling">${d(c)}</span>`:(n?`<span class="wd-task-pill-pct">${n}</span>`:"")+(l?`<span class="wd-task-pill-eta">${d(l)}</span>`:""))+`<button class="wd-task-pill-x${a?" wd-task-pill-x--cancelling":""}" data-action="task-cancel" data-task-id="${d(s.id)}" ${a?"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,a){!t||this._tasks[t]||(this._tasks[t]={id:t,entry_id:s,kind:e,label:this._taskActionLabel(e),state:"running",done:0,total:a||0,progress:a?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 a=await this._ws({type:`${w}/get_task_result`,task_id:t.id});s=a&&a.result}}catch{}this._isActiveEntry(t.entry_id)&&(t.state==="error"?this._showToast(this._tText("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 _awaitTask(t){for(let e=0;e<7200&&t;e++){const s=(this._tasks||{})[t];let a=s&&s.state!=="running"?s:null;if(a||(a=await this._ws({type:`${w}/get_task_result`,task_id:t})),a&&a.state&&a.state!=="running"){if(a.state==="error")throw new Error(a.error||"error");return a}await new Promise(r=>setTimeout(r,1e3))}return null}async _pgPollTask(t){for(let e=0;e<3600&&(t===this._pgHistoryTaskId||t===this._pgSweepTaskId);e++){let s;try{s=await this._ws({type:`${w}/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(a=>setTimeout(a,1200))}}_panelTransUrl(t){const e=`/ha_washdata/panel-translations/${encodeURIComponent(t)}.json`;return Zt?`${e}?v=${encodeURIComponent(Zt)}`:e}async _fetchPanelLang(t){if(!t)return null;const e=[t],s=t.indexOf("-");s>0&&e.push(t.slice(0,s));for(const a of e)try{const r=await fetch(this._panelTransUrl(a));if(r.ok){const i=await r.json();if(i&&typeof i=="object")return i}}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){try{return await this._hass.connection.sendMessagePromise(t)}catch(e){if(e&&e.code==="invalid_threshold_pair"){const s=/\(([-\d.]+) W\)[^(]*\(([-\d.]+) W\)/.exec(String(e.message||""));e.message=this._tText("msg.invalid_threshold_pair",{stop:s?s[1]:"?",start:s?s[2]:"?"},e.message)}throw e}}async _fetchAll(){if(!this._hass)return;const t=this._loading;let e=!1;try{if(!this._constantsLoaded){const[l,c]=await Promise.all([this._ws({type:`${w}/get_constants`}).catch(()=>null),this._ws({type:`${w}/get_panel_config`}).catch(()=>null)]);if(l&&(this._constants={stateColors:l.state_colors||{},deviceTypes:l.device_types||[],mlLabEnabled:!!l.ml_lab_enabled,mlTrainingAvailable:!!l.ml_training_available,storeOnlineAvailable:!!l.store_online_available,storeOnlineEnabled:!!l.store_online_enabled,storeWebOrigin:l.store_web_origin||"",storePrefs:l.store_prefs||{},pgMatchDefaults:l.pg_match_defaults||{},PROFILE_MIN_WARMUP_CYCLES:l.PROFILE_MIN_WARMUP_CYCLES,version:l.version||"",iconUrl:l.icon_url||""}),c)try{this._panelCfg=c,this._applyPanelConfig()}catch{}this._constantsLoaded=!0}const s=await this._ws({type:`${w}/get_devices`});this._lastFetchErr=null,this._fetchFailed=!1,this._devices=s.devices||[],this._lastRefresh=new Date;let a=!1;const r=this._deepLinkToken();if(r&&r!==this._deepLinkApplied&&this._devices.length){this._deepLinkApplied=r;const l=this._deepLinkIdx(r);l>=0?(this._selIdx=l,a=!0,this._rememberDevice(l)):console.warn(`[WashData panel] ?device=${r} matches no WashData device`)}if(!a&&this._selIdx===0&&this._devices.length>1){const l=localStorage.getItem("wd-last-device");if(l){const c=this._devices.findIndex(p=>p.entry_id===l);c>0&&(this._selIdx=c)}}const i=this._devices[this._selIdx],o=async()=>{if(i&&this._tab==="status"){try{this._powerData=await this._ws({type:`${w}/get_power_history`,entry_id:i.entry_id,with_raw:this._pref("show_raw_active",!1)})}catch{}if(this._pref("show_debug",!1))try{this._matchDebug=await this._ws({type:`${w}/get_match_debug`,entry_id:i.entry_id})}catch{}if(this._canEdit()&&i.recording)try{this._recState=await this._ws({type:`${w}/get_recording_state`,entry_id:i.entry_id})}catch{}}},n=async()=>{if(i&&i.current_program){if(this._statusEnvName!==i.current_program){this._statusEnvName=i.current_program;try{const l=await this._ws({type:`${w}/get_profile_envelope`,entry_id:i.entry_id,profile_name:i.current_program});this._statusEnv=l.envelope||null}catch{this._statusEnv=null}}await this._ensureStatusPhases(i.entry_id,i.current_program)}else this._statusEnv=null,this._statusEnvName=null,this._statusPhases=[],this._statusPhasesName=null};if(await Promise.all([o(),n()]),t&&i){const l=i.entry_id,c=Promise.all([this._fetchCycles(l),this._fetchSuggestions(l),this._fetchProfiles(l),this._tab==="status"?this._ws({type:`${w}/get_setup_status`,entry_id:l}).then(p=>{this._isActiveEntry(l)&&(this._setupStatus=p)},()=>{this._isActiveEntry(l)&&(this._setupStatus=null)}):null,this._constants.storeOnlineAvailable?(async()=>{try{const p=await this._ws({type:`${w}/get_options`,entry_id:l});this._isActiveEntry(l)&&(this._opts=p.options||{})}catch{}this._onlineEnabled()&&await this._loadStoreStatus(l)})():null]);this._tab==="status"&&(this._loading=!1,e=!0),this._render(),await c}this._logOpen&&this._isAdmin()&&this._fetchLogs().then(()=>this._refreshLogDrawer()).catch(()=>{})}catch(s){const a=Ct(s);a!==this._lastFetchErr&&(this._lastFetchErr=a,console.warn("[WashData panel] fetch error -",a,s)),this._fetchFailed=!0}finally{this._loading=!1,this._refreshStaleChip();const s=this.shadowRoot,a=s&&s.activeElement,r=!!(a&&["SELECT","INPUT","TEXTAREA","OPTION"].includes(a.tagName));t&&!e?this._render():t&&!this._modal&&!r?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:`${w}/get_device_cycles`,entry_id:t,limit:zt,offset:0,imported_offset:0});if(!this._isActiveEntry(t))return;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,this._importedOffset=this._refCycles.length,this._importedHasMore=!!e.imported_has_more}catch{if(!this._isActiveEntry(t))return;this._cyclesError=!0,this._cycles=[],this._refCycles=[],this._cycleOffset=0,this._cyclesTotal=0,this._cyclesHasMore=!1,this._importedOffset=0,this._importedHasMore=!1}}async _loadMoreCycles(t){const e=await this._ws({type:`${w}/get_device_cycles`,entry_id:t,limit:zt,offset:this._cycleOffset,imported_offset:this._importedOffset});if(!this._isActiveEntry(t))return;const s=e.cycles||[],a=new Set(this._cycles.map(o=>o.id));for(const o of s)a.has(o.id)||this._cycles.push(o);this._cycleOffset+=s.length,this._cyclesTotal=e.total!=null?e.total:this._cyclesTotal,this._cyclesHasMore=e.has_more!=null?!!e.has_more:s.length>=zt;const r=[...e.reference_cycles||[],...e.backfill_cycles||[]],i=new Set((this._refCycles||[]).map(o=>o.id));this._refCycles=(this._refCycles||[]).concat(r.filter(o=>!i.has(o.id))),this._importedOffset+=r.length,this._importedHasMore=!!e.imported_has_more}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:`${w}/get_profile_phases`,entry_id:t,profile_name:e});this._statusPhases=(s.phases||[]).map(a=>({name:a.name,start:a.start,end:a.end}))}catch{this._statusPhases=[]}}}async _fetchSettingsChangelog(t){try{const s=await this._ws({type:`${w}/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=(a,r)=>{const i=this._devices[this._selIdx],o=i&&i.entry_id;if(e.eid===o){try{e.restore(a)}catch{}this._showToast(r,"error"),this._render()}};try{const a=await e.commit();a&&a.length&&s(a,this._t("toast.delete_partial_failed",{},"Some items could not be deleted and were restored"))}catch(a){s(null,this._tText("toast.delete_failed",{error:a&&a.message||a},"Delete failed: "+(a&&a.message||a)))}}_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),a=[];if(this._cycles=(this._cycles||[]).filter((n,l)=>s.has(n.id)?(a.push({idx:l,rec:n,ref:!1}),!1):!0),this._refCycles=(this._refCycles||[]).filter((n,l)=>s.has(n.id)?(a.push({idx:l,rec:n,ref:!0}),!1):!0),!a.length)return;this._cycleSel.clear(),this._selectMode=!1,this._render();const r=n=>{const l=n&&n.length?n:a,c=this._cycles.slice(),p=this._refCycles.slice();l.slice().sort((_,f)=>_.idx-f.idx).forEach(({idx:_,rec:f,ref:u})=>{const b=u?p:c;b.splice(Math.min(_,b.length),0,f)}),this._cycles=c,this._refCycles=p},i=async()=>{const n=[];for(const l of a)try{await this._ws({type:`${w}/delete_cycle`,entry_id:t,cycle_id:l.rec.id}),this._isActiveEntry(t)&&(l.ref?this._importedOffset=Math.max(0,this._importedOffset-1):this._cycleOffset=Math.max(0,this._cycleOffset-1))}catch{n.push(l)}if(n.length<a.length&&this._isActiveEntry(t))try{await this._fetchProfiles(t)}catch{}return n},o=this._registerUndo({eid:t,restore:r,commit:i});this._showToast(this._tText("msg.cycles_deleted",{count:a.length},`${a.length} cycle(s) deleted`),"success",{actionLabel:this._t("btn.undo",{},"Undo"),actionToken:o,duration:1e4})}_deleteProfileWithUndo(t,e){const s=(this._profiles||[]).findIndex(n=>n.name===e),a=s>=0?this._profiles[s]:{name:e};s>=0&&(this._profiles=this._profiles.filter(n=>n.name!==e)),this._modal=null,this._render();const r=n=>{const l=this._profiles.slice();l.splice(Math.min(s<0?l.length:s,l.length),0,a),this._profiles=l},i=async()=>{try{await this._ws({type:`${w}/delete_profile`,entry_id:t,profile_name:e,unlabel_cycles:!0})}catch{return[{idx:s,rec:a}]}try{await this._fetchProfiles(t),this._tab==="profiles"&&this._render()}catch{}return[]},o=this._registerUndo({eid:t,restore:r,commit:i});this._showToast(this._tText("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 a=Vt(s);if(a.length){const r=e.activeElement,i=a.indexOf(r);t.shiftKey?i<=0&&(t.preventDefault(),a[a.length-1].focus()):(i===-1||i===a.length-1)&&(t.preventDefault(),a[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 a=[...e.querySelectorAll("button.wd-tab")],r=a.indexOf(s);if(r===-1)return;let i=r;t.key==="ArrowRight"?i=(r+1)%a.length:t.key==="ArrowLeft"?i=(r-1+a.length)%a.length:t.key==="Home"?i=0:t.key==="End"&&(i=a.length-1),t.preventDefault(),a[i].click(),a[i].focus()}}}}async _loadMlIndex(t){if(this._mlById=this._mlById||{},!!this._constants.mlLabEnabled)try{const e=await this._ws({type:`${w}/get_ml_comparison`,entry_id:t});if(!this._isActiveEntry(t))return;this._mlComparison=e;const s={};for(const a of e&&e.cycles||[])s[a.id]=a;this._mlById=s}catch{}}async _loadMlTrainingStatus(t){if(this._constants.mlTrainingAvailable)try{const e=await this._ws({type:`${w}/get_ml_training_status`,entry_id:t});if(!this._isActiveEntry(t))return;this._mlTrainingStatus=e}catch{}}_cycleOverlayPlan(t,e){const s=e&&e.profile_name;if(s)return{name:s,suspected:!1,runnerUp:null};const a=(this._feedbacks||[]).find(o=>o.cycle_id===t),r=a&&typeof a.detected_profile=="string"?a.detected_profile:"";if(!r)return null;const i=(Array.isArray(a.ranking)?a.ranking:[]).find(o=>o&&typeof o.name=="string"&&o.name&&o.name!==r);return{name:r,suspected:!0,runnerUp:i?i.name:null}}_loadCycleOverlays(t,e,s){const a=this._modal;!a||a.type!=="cycle-detail"||a.cycleId!==e||(a.overlayPlan=this._cycleOverlayPlan(e,s),a.overlayPlan&&(this._fetchCycleProfileEnv(t,a.overlayPlan.name),a.overlayPlan.runnerUp&&this._fetchCycleProfileEnv(t,a.overlayPlan.runnerUp,"runnerUpEnv")))}async _fetchCycleProfileEnv(t,e,s="profileEnv"){if(!e)return;const a=this._modal;a&&a.type==="cycle-detail"&&((a.envFor=a.envFor||{})[s]=e);try{const r=await this._ws({type:`${w}/get_profile_envelope`,entry_id:t,profile_name:e}),i=this._modal;i&&i.type==="cycle-detail"&&i.entryId===t&&i.envFor&&i.envFor[s]===e&&(i[s]=r.envelope||null,this._render())}catch{}}async _fetchSuggestions(t){this._suggestionsError=!1;try{const e=await this._ws({type:`${w}/get_suggestions`,entry_id:t});if(!this._isActiveEntry(t))return;this._suggestions=e.suggestions||[],this._lockedSuggestions=e.locked_suggestions||[]}catch{this._isActiveEntry(t)&&(this._suggestionsError=!0,this._suggestions=[])}}async _fetchProfiles(t){this._profilesError=!1;try{const e=await this._ws({type:`${w}/get_profiles`,entry_id:t});if(!this._isActiveEntry(t))return this._profiles;this._profiles=e.profiles||[],this._profileHealth=e.profile_health||{},this._profileTrends=e.profile_trends||{},this._coverageGaps=e.coverage_gaps||{},this._profileAdvisories=e.profile_advisories||[],this._profileTerminal=e.profile_terminal||{},this._profileMatcherCounts=e.profile_matcher_counts||{}}catch{this._profilesError=!0}return this._profiles}async _ensureProfileEnvs(t,e){this._profileEnvCache=this._profileEnvCache||{};const s=[...new Set(e)].filter(a=>a&&!(a in this._profileEnvCache));return s.length?(await Promise.all(s.map(async a=>{try{const r=await this._ws({type:`${w}/get_profile_envelope`,entry_id:t,profile_name:a});this._profileEnvCache[a]=r&&r.envelope||null}catch{this._profileEnvCache[a]=null}})),this._profileEnvCache):this._profileEnvCache}async _fetchProfileGroups(t){this._profileGroupsError=!1;try{const e=await this._ws({type:`${w}/get_profile_groups`,entry_id:t});this._profileGroups={groups:e.groups||[],min_cohesion:e.min_cohesion||.85}}catch{this._profileGroupsError=!0,this._profileGroups={groups:[],min_cohesion:.85}}return this._profileGroups}_deepLinkToken(){try{const t=new URLSearchParams(window.location.search||"").get("device");return t?t.trim():""}catch{return""}}_deepLinkIdx(t){const e=this._devices||[],s=String(t||"").trim();if(!s||!e.length)return-1;const a=s.toLowerCase();let r=e.findIndex(i=>String(i.entry_id||"").toLowerCase()===a);if(r<0&&(r=e.findIndex(i=>String(i.title||"").trim().toLowerCase()===a)),r<0){const i=fe(s);i&&(r=e.findIndex(o=>fe(o.title)===i))}return r}_rememberDevice(t){const e=(this._devices||[])[t];if(e)try{localStorage.setItem("wd-last-device",e.entry_id)}catch{}}_unsavedSettingsCount(){return this._tab!=="settings"||!this.shadowRoot?0:(this._snapshotFormToPending(this.shadowRoot),Object.keys(this._changedOptions(this._pendingSettings||{})).length)}_guardUnsaved(t){const e=this._unsavedSettingsCount();if(!e){t();return}this._modal={type:"confirm",title:this._t("modal.discard_settings_title",{},"Discard unsaved changes?"),message:this._tText("modal.discard_settings_msg",{n:e},`${e} setting change(s) are not saved yet. Leave Settings and discard them?`),okLabel:this._t("btn.discard",{},"Discard"),onOk:()=>{this._pendingSettings={},this._dirtyOptKeys=new Set,t()}},this._render()}_maybeGuardOutboundClick(t){if(t.defaultPrevented||t.button!==0||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey)return;const s=(typeof t.composedPath=="function"?t.composedPath():[]).find(i=>i&&i.tagName==="A"&&i.getAttribute&&i.getAttribute("href"));if(!s||s.target&&s.target!=="_self"||s.hasAttribute("download"))return;let a;try{a=new URL(s.getAttribute("href"),window.location.href)}catch{return}if(a.origin!==window.location.origin)return;const r=this._panel&&this._panel.url_path;r&&a.pathname.split("/").filter(Boolean)[0]===r||this._unsavedSettingsCount()&&(t.preventDefault(),t.stopImmediatePropagation(),this._guardUnsaved(()=>this._navigate(a.pathname+a.search+a.hash)))}_onOwnPath(){const t=this._panel&&this._panel.url_path;return t?(window.location.pathname||"").split("/").filter(Boolean)[0]===t:!0}_onLocationChanged(){if(!this._initialized||!this._devices.length||!this._onOwnPath())return;const t=this._deepLinkToken();if(!t)return;this._deepLinkApplied=t;const e=this._deepLinkIdx(t);if(e<0){console.warn(`[WashData panel] ?device=${t} matches no WashData device`);return}e!==this._selIdx&&this._guardUnsaved(()=>this._selectDevice(e))}async _selectDevice(t){if(t===this._selIdx)return;await this._flushPendingDeletes(),this._selIdx=t,this._rememberDevice(t),this._pendingSettings={},this._dirtyOptKeys=new Set,this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null,this._stagedSuggestions=!1,this._mlComparison=null,this._mlById={},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._importedOffset=0,this._importedHasMore=!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._profileTerminal={},this._profileMatcherCounts={},this._opts={},this._optDefaults={},this._suggestions=[],this._lockedSuggestions=[],this._cycles=[],this._refCycles=[],this._recState=null,this._diag=null,this._importUndo=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._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._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._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 e=this._devices[this._selIdx];e&&await this._fetchSuggestions(e.entry_id),this._fetchTabData()}_htmlStaleChip(){const t=this._lastRefresh,e=t&&Date.now()-t.getTime()>2.5*this._pollMs;if(!this._devices.length||!(this._fetchFailed||e))return'<div id="wd-stale" hidden></div>';const s=t?re(t):"";return`<div id="wd-stale" class="wd-stale" role="status">${d(this._tText("msg.connection_lost",{time:s},`Connection lost - showing data from ${s}.`))}</div>`}_refreshStaleChip(){const t=this.shadowRoot&&this.shadowRoot.getElementById("wd-stale");t&&(t.outerHTML=this._htmlStaleChip())}_refreshDeviceBar(){const t=this.shadowRoot;if(!t)return;const e=t.querySelector(".wd-devbar"),s=this._htmlDeviceBar();if(e&&s){const a=document.createElement("div");a.innerHTML=s;const r=a.firstElementChild;r&&(e.replaceWith(r),r.querySelectorAll(".wd-devcard[data-idx]").forEach(i=>i.addEventListener("click",()=>this._guardUnsaved(()=>this._selectDevice(parseInt(i.dataset.idx,10))))))}}_refreshLogDrawer(){this._logOpen&&(this._refreshLogViews(),this._refreshLogFilterOptions())}_refreshLogFilterOptions(){const t=this.shadowRoot;if(!t)return;const e=(r,i,o)=>`<option value="">${d(r)}</option>`+i.map(n=>`<option value="${d(n)}" ${n===o?"selected":""}>${d(n)}</option>`).join(""),s=e(this._t("log.all_devices",{},"All devices"),this._logDevices(),this._logDevice),a=e(this._t("log.all_components",{},"All components"),this._logComponents(),this._logComponent);t.querySelectorAll('.wd-log-filter[data-logfilter="device"]').forEach(r=>{r.innerHTML=s,r.value=this._logDevice||""}),t.querySelectorAll('.wd-log-filter[data-logfilter="component"]').forEach(r=>{r.innerHTML=a,r.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:`${w}/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:`${w}/get_match_debug`,entry_id:e})}catch{}if(this._canEdit())try{this._recState=await this._ws({type:`${w}/get_recording_state`,entry_id:e})}catch{}try{this._setupStatus=await this._ws({type:`${w}/get_setup_status`,entry_id:e})}catch{this._setupStatus=null}}else if(this._tab==="history"&&this._historySub==="maintenance")this._maintenance||await this._fetchMaintenance(e);else if(this._tab==="history"){await this._fetchCycles(e),this._profiles.length||await this._fetchProfiles(e);try{const s=await this._ws({type:`${w}/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:`${w}/get_phase_catalog`,entry_id:e});this._phases=s.phases||[]}catch{}}else if(this._tab==="settings"){const s=await this._ws({type:`${w}/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.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:`${w}/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:`${w}/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:`${w}/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==="diagnostics"&&!this._diag&&this._fetchToolsData(e).then(()=>{this._tab==="advanced"&&this._render()});this._tabError=null}catch(s){console.warn("[WashData panel] tab data fetch error -",Ct(s),s),this._tabError=this._tab}finally{this._tabLoading=!1,this._render()}}async _fetchToolsData(t){try{const e=await this._ws({type:`${w}/get_diagnostics`,entry_id:t});if(!this._isActiveEntry(t))return;this._diag=e.stats||{},this._importUndo=e.import_undo||null}catch(e){console.warn("[WashData panel] tools fetch error -",Ct(e),e),this._diag={_error:String(e&&e.message||e)}}}async _fetchMaintenance(t){try{const e=await this._ws({type:`${w}/get_maintenance_log`,entry_id:t});this._maintenance=e||{}}catch(e){console.warn("[WashData panel] maintenance fetch error -",Ct(e),e),this._maintenance={_error:String(e&&e.message||e)}}}async _fetchLogs(){try{const t=await this._ws({type:`${w}/get_logs`,level:null,limit:500});this._logs=t.logs||[]}catch(t){console.warn("[WashData panel] logs fetch error -",Ct(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,a=this._logComponent,r=(this._logSearch||"").trim().toLowerCase();return(this._logs||[]).filter(i=>!(e&&(t[i.level]||0)<e||s&&i.device!==s||a&&i.logger!==a||r&&!((i.msg||"").toLowerCase().includes(r)||(i.logger||"").toLowerCase().includes(r))))}_logLinesHtml(){const t=this._filteredLogRecords();return t.length?t.slice().reverse().map(e=>{const s=re(new Date(e.ts*1e3)),a=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>${a}${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(i=>`<option value="${i}" ${this._logLevel===i?"selected":""}>${i||this._t("log.all_levels",{},"All levels")}</option>`).join(""),a=`<option value="">${d(this._t("log.all_devices",{},"All devices"))}</option>`+this._logDevices().map(i=>`<option value="${d(i)}" ${this._logDevice===i?"selected":""}>${d(i)}</option>`).join(""),r=`<option value="">${d(this._t("log.all_components",{},"All components"))}</option>`+this._logComponents().map(i=>`<option value="${d(i)}" ${this._logComponent===i?"selected":""}>${d(i)}</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">${a}</select>
|
|
<select class="wd-log-filter" data-logfilter="component" data-ctx="${t}" style="font-size:.8em;padding:2px 4px">${r}</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 a=t.getElementById(s);a&&(a.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:`${w}/get_recording_state`,entry_id:t})}catch{}}async _fetchFeedbacks(t){try{const e=await this._ws({type:`${w}/get_feedbacks`,entry_id:t});this._feedbacks=e.feedbacks||[]}catch{}}async _fetchPhases(t){try{const e=await this._ws({type:`${w}/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 a=t.split(".").reduce((r,i)=>r&&r[i]!==void 0?r[i]:null,s);return a&&typeof a=="string"?a:null}_uiLang(){return this._panelCfg&&this._panelCfg.prefs&&this._panelCfg.prefs.lang_override||this._hass&&this._hass.locale&&this._hass.locale.language||""}_tResolve(t,e,s){if(s!=null){const a=He(e,s),r=this._tLookup(`${t}_${a}`,e)||(a!=="other"?this._tLookup(`${t}_other`,e):null);if(r)return r}return this._tLookup(t,e)}_t(t,e={},s=""){return this._tr(t,e,s,!0)}_tText(t,e={},s=""){return this._tr(t,e,s,!1)}_tr(t,e,s,a){let r;e=e||{};const i=this._uiLang(),o=typeof e.count=="number"?e.count:e.n,n=typeof o=="number"&&isFinite(o)?o:null;if(this._panelTrans?r=i&&this._tResolve(t,i,n)||this._tResolve(t,"en",n)||s:r=this._localize(`component.${w}.panel.${t}`,s),typeof r!="string")return r;for(const[l,c]of Object.entries(e)){const p=`{${l}}`;if(!r.includes(p))continue;const _=c instanceof ce?c.html:a?d(String(c)):String(c);r=r.split(p).join(_)}return r}_stateColor(t){const e=this._constants.stateColors||{};return e[t]||e.unknown||"var(--disabled-color, #bdbdbd)"}_cycleStatusLabel(t){return{completed:this._tText("status.completed",{},"Completed"),interrupted:this._tText("status.interrupted",{},"Interrupted"),force_stopped:this._tText("status.force_stopped",{},"Force stopped"),active:this._tText("status.active",{},"Active")}[t]||t}_stateLabel(t){const e=(t||"unknown").replace(/_/g," ").replace(/\b\w/g,s=>s.toUpperCase());return this._localize(`component.${w}.entity.sensor.washer_state.state.${t}`,e)}_deviceTypeLabel(t){const e=(this._constants.deviceTypes||[]).find(a=>a.id===t),s=e?e.label:(t||"").replace(/_/g," ");return this._localize(`component.${w}.selector.device_type.options.${t}`,s)}_deviceTypeOpts(t){const e=(this._constants.deviceTypes||[]).map(s=>[s.id,this._deviceTypeLabel(s.id)]);return t&&!e.some(([s])=>String(s)===String(t))&&e.push([t,this._deviceTypeLabel(t)]),e}_deviceOpts(t){const e=[["","- None -"]],s=this._hass&&this._hass.devices?this._hass.devices:{},a=this._ownDeviceId();return Object.values(s).forEach(r=>{if(a&&String(r.id)===a)return;const i=r.name_by_user||r.name||r.id;e.push([r.id,i])}),t&&String(t)!==a&&!e.some(([r])=>String(r)===String(t))&&e.push([t,this._tText("lbl.device_unresolved",{id:t},`Unavailable device (${t})`)]),e}_ownDeviceId(){const t=this._devices&&this._devices[this._selIdx],e=t&&t.entry_id;if(!e)return"";const s=this._hass&&this._hass.devices?this._hass.devices:{};for(const a of Object.values(s)){if(!a||!a.id)continue;if((Array.isArray(a.identifiers)?a.identifiers:[]).some(i=>Array.isArray(i)&&i[0]===w&&String(i[1])===String(e))||Array.isArray(a.config_entries)&&a.config_entries.map(String).includes(String(e)))return String(a.id)}return""}_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 a=t.prefs&&t.prefs.default_tab||s.default_tab;a&&["status","history","profiles","settings","playground"].includes(a)&&(this._tab=a),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"),this._canEdit()&&s.push("advanced"),s.filter(a=>t||!e.includes(a))}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:`${w}/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()}_SCROLLERS=[".wd-main",".wd-modal",".wd-logs",".wd-log-drawer-body",".wd-table-wrap",".wd-sd-tree"];_SCROLLERS_CHROME=[".wd-tabs"];_MODAL_KEY_PREFIX="modal:";_eachScroller(t,e){const s=this.shadowRoot;if(s)for(const a of t){const r=Array.from(s.querySelectorAll(a)),i=o=>a!==".wd-modal"&&!!o.closest(".wd-modal");for(const o of[!1,!0]){const n=r.filter(c=>i(c)===o),l=o?this._MODAL_KEY_PREFIX:"";n.forEach((c,p)=>e(c,`${l}${a}|${p}/${n.length}`))}}}_captureScroll(){if(!this.shadowRoot)return null;const t={};return this._eachScroller([...this._SCROLLERS,...this._SCROLLERS_CHROME],(e,s)=>{(e.scrollTop||e.scrollLeft)&&(t[s]={top:e.scrollTop,left:e.scrollLeft})}),t}_scrollChromeOnly(t){if(!t)return t;const e={};for(const[s,a]of Object.entries(t))this._SCROLLERS_CHROME.some(r=>s.startsWith(`${r}|`))&&(e[s]=a);return e}_restoreScroll(t){!t||!this.shadowRoot||this._eachScroller([...this._SCROLLERS,...this._SCROLLERS_CHROME],(e,s)=>{const a=t[s];if(!a)return;const r=Math.max(0,e.scrollHeight-e.clientHeight),i=Math.max(0,e.scrollWidth-e.clientWidth);e.scrollTop=Math.min(a.top,r),e.scrollLeft=Math.min(a.left,i)})}_navKey(){return[this._tab,this._selIdx,this._panelSubtab,this._profSubtab,this._toolsSubtab,this._settingsSec,this._storeView].join("|")}_modalNavKey(){const t=this._modal;if(!t)return"";const e=t.type==="profile-group"?t.orig||"":t.name||t.cycleId||"";return[t.type,e,t.tab||t.mode||t.step||""].join("|")}_render(){if(!this._container)return;se=this._pref("date_format","relative"),Re(this._uiLang(),this._hass&&this._hass.locale);const t=this.shadowRoot,e=t&&(t.activeElement||this.getRootNode()&&this.getRootNode().activeElement)||null,s=!this._modalFocusActive&&t&&t.activeElement?qe(t.activeElement):null;this._hideGraphTip();const a=this._navKey(),r=this._scrollNavKey!=null&&this._scrollNavKey!==a;this._scrollNavKey=a;let i=this._scrollCarry||this._captureScroll();r&&(i=this._scrollChromeOnly(i));const o=this._modalNavKey();if(this._scrollModalKey!==void 0&&o!==this._scrollModalKey&&i&&(i=Object.fromEntries(Object.entries(i).filter(([n])=>!n.startsWith(".wd-modal|")&&!n.startsWith(this._MODAL_KEY_PREFIX)))),this._scrollModalKey=o,this._container.innerHTML=this._buildHtml(),this._wire(),this._tabLoading?(this._scrollCarry=i,this._restoreScroll(this._scrollChromeOnly(i))):(this._scrollCarry=null,this._restoreScroll(i)),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(n=>this._attachGraphGestures(n)),this._syncScrollAffordances(),this._syncModalFocus(e),s&&!this._modalFocusActive){const n=this.shadowRoot.querySelector(s);if(n&&this.shadowRoot.activeElement!==n)try{n.focus({preventScroll:!0})}catch{}}requestAnimationFrame(()=>this._resizeLogsPage())}_syncScrollAffordances(){const t=this.shadowRoot;if(!t)return;const e=t.querySelectorAll(Xe);this._ovfRO?this._ovfRO.disconnect():typeof ResizeObserver=="function"&&(this._ovfRO=new ResizeObserver(s=>s.forEach(a=>Yt(a.target)))),e.forEach(s=>{s.addEventListener("scroll",()=>Yt(s),{passive:!0}),Yt(s),this._ovfRO&&this._ovfRO.observe(s)})}_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 a=s.querySelector("h2");if(a&&!a.id&&(a.id="wd-modal-title"),this._modalFocusActive){const r=e.activeElement||this.getRootNode()&&this.getRootNode().activeElement||null;if(!r||!s.contains(r)){const i=Vt(s);try{(i[0]||s).focus({preventScroll:!0})}catch{}}}else{this._modalFocusActive=!0;const r=t||null;r&&!s.contains(r)&&(this._modalReturnFocus=r);const i=Vt(s);try{(i[0]||s).focus()}catch{}}}else if(this._modalFocusActive){this._modalFocusActive=!1;const a=this._modalReturnFocus;if(this._modalReturnFocus=null,a&&a.isConnected&&typeof a.focus=="function")try{a.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"),a=t.getElementById("wd-cyc-rev-golden"),r=t.getElementById("wd-cyc-rev-notes"),i=t.getElementById("wd-cyc-rev-label");if(!s&&!a&&!r&&!i)return;e.ml||(e.ml={}),e.ml.ml_review||(e.ml.ml_review={});const o=e.ml.ml_review;s&&(o.quality=s.value||""),a&&(o.golden=!!a.checked),r&&(o.notes=r.value||""),o.tags=Array.from(t.querySelectorAll(".wd-cyc-rev-tag")).filter(n=>n.checked).map(n=>n.value),i&&(e.reviewLabel=i.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(n=>!(n==="pg-sweep"||n==="pg-history"||n==="reprocess"||n.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,a=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>`,r=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>`}
|
|
${a}
|
|
<div><h1>WashData</h1><div class="wd-sub">${r?`v${d(r)} · `:""}${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",{},"Preferences"))}" aria-label="${d(this._t("settings.gear.title",{},"Preferences"))}"><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&&this._fetchFailed)return`<div class="wd-error-state" role="alert"><span>${this._t("msg.fetch_error",{},"Failed to load data.")}</span><button class="wd-btn" type="button" data-action="retry-load">${this._t("btn.retry",{},"Retry")}</button></div>`;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._conflictKeysFromOpts().size>0?" \u26A0":"",s=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")+t,playground:this._t("tab.playground",{},"Playground")+s,store:this._t("tab.store",{},"Store"),advanced:this._t("tab.advanced",{},"Advanced")},r=this._visibleTabIds();r.includes(this._tab)||(this._tab="status");const i=r.map(n=>`<button class="wd-tab ${this._tab===n?"active":""}" role="tab" id="wd-tab-${n}" aria-selected="${this._tab===n}" tabindex="${this._tab===n?"0":"-1"}" data-tab="${n}">${a[n]}</button>`).join(""),o=(n,l)=>r.includes(n)?`<div class="wd-pane ${this._tab===n&&!this._tabLoading?"active":""}" role="tabpanel" aria-labelledby="wd-tab-${n}">${this._tab===n?l():""}</div>`:"";return`
|
|
<div class="wd-nav">
|
|
${this._htmlDeviceBar()}
|
|
<div class="wd-tabs" role="tablist">${i}</div>
|
|
${this._htmlStaleChip()}
|
|
</div>
|
|
${this._tabLoading?`<div class="wd-empty" style="padding:24px"><div class="wd-icon">\u23F3</div>${this._t("msg.loading",{},"Loading\u2026")}</div>`:""}
|
|
${o("status",()=>this._htmlStatus())}
|
|
${o("history",()=>this._htmlHistoryTab())}
|
|
${o("profiles",()=>this._htmlProfiles())}
|
|
${o("settings",()=>this._htmlSettings())}
|
|
${o("playground",()=>this._htmlPlayground())}
|
|
${o("store",()=>this._htmlStore())}
|
|
${o("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 a=e.is_user_paused?"user_paused":e.detector_state||"unknown",r=Mt.includes(a),i=!!e.recording,o=i?"var(--error-color, #f44336)":this._stateColor(a),n=i?this._t("status.recording",{},"Recording"):this._stateLabel(a),l=[],c=this._conflictCountForOpts(e.options||{},e.option_defaults||{});return c&&l.push(`<span class="wd-dbadge conf">\u26A0 ${c}</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 ${i||r?"run":""}" style="background:${o}"></span>
|
|
<span><span class="wd-devname">${d(e.title)}</span> <span class="wd-devsub">${d(n)}</span></span>
|
|
${l.join("")}
|
|
</button>`}).join("")}${t}</div>`}_htmlMatchUncertainty(t,e,s){const a=t&&t.match_uncertainty;if(!a||!e||s||!a.top)return"";const r=a.runner_up?this._t("status.match_uncertain",{top:a.top,runner:a.runner_up},"Uncertain: {top} or {runner}"):this._t("status.match_uncertain_single",{top:a.top},"Uncertain: maybe {top}"),i=Number(a.sure_pct),o=a.sure_pct!=null&&isFinite(i)?` \xB7 ${this._t("status.match_sure",{pct:i},"~{pct}% sure")}`:"",n=ot(this._tText("status.match_uncertain_tip",{top:a.top},"WashData has not settled on a program yet. The figure is how often the leading guess, {top}, was right in past cycles when it led by this much. Information only."));return`<span class="wd-prog-unc" style="font-size:.82em;color:var(--warning-color,#ff9800)">${r}${o}</span>${n}`}_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",a=!!t.recording,r=a?"var(--error-color, #f44336)":this._stateColor(s),i=a?this._t("status.recording",{},"Recording"):this._stateLabel(s),o=a||Mt.includes(s),n=t.cycle_progress_pct,l=t.time_remaining_s,c=t.current_program,p=!!t.manual_program,_=c?null:t.armed_program||null,f=c||_||"auto_detect",u=(this._profiles||[]).map(P=>P.name);c&&!u.includes(c)&&u.unshift(c),_&&!u.includes(_)&&u.unshift(_);const b=u.map(P=>`<option value="${d(P)}" ${f===P?"selected":""}>${d(P)}</option>`).join(""),v=c?p?this._t("badge.manual",{},"(manually selected)"):this._t("badge.auto",{},"(auto-detected)"):_?this._t("badge.armed",{},"(applies to the next cycle)"):"",C=v?`<span class="wd-prog-tag ${c?p?"manual":"auto":"manual"}">${v}</span>`:"",x=`<div class="wd-prog-ctl"><label>${this._t("lbl.program",{},"Program")}</label>${ot(this._t("lbl.program_tip",{},"Choose which program the current cycle is matched to. Auto-detect picks the best match. Pin a program when auto-detect is wrong; pick one before starting and it applies to the next cycle."))}
|
|
<select id="wd-status-prog">
|
|
<option value="auto_detect" ${f==="auto_detect"?"selected":""}>${this._t("status.auto_detect",{},"Auto-detect")}</option>
|
|
${b}
|
|
</select>${C}${this._htmlMatchUncertainty(t,o,p)}</div>`,$=[];t.recording&&this._canEdit()&&$.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()&&$.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 L=this._conflictKeysFromOpts();if(L.size&&this._canEdit()){const P=L.size,X=P>1?"s":"";$.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:P},`Setting conflicts: ${P}`)}</div><div class="wd-attn-sub">${this._t("conflict.attn_sub",{},"Fix conflicts before saving")}</div></div></button>`)}const D=this._sugCountForDevice(t);D&&this._canEdit()&&$.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:D},`${D} tuning suggestion${D>1?"s":""}`)}</div><div class="wd-attn-sub">${this._t("msg.review_in_settings",{},"Review in Settings")}</div></div></button>`);const B=t.standby_above_stop;B&&this._canEdit()&&$.push(`<button class="wd-attn-card" type="button" style="border-color:var(--warning-color,#ff9800)" data-action="goto-standby"><span class="wd-attn-icon">\u26A0</span><div class="wd-attn-body"><div class="wd-attn-title" style="color:var(--warning-color,#ff9800)">${this._t("msg.standby_above_stop_title",{idle:B.idle_w,stop:B.stop_threshold_w},`Still drawing ${B.idle_w} W when the cycle ended`)}</div><div class="wd-attn-sub">${this._t("msg.standby_above_stop_sub",{n:B.cycles_above,total:B.cycles_checked,stop:B.stop_threshold_w},`${B.cycles_above} of the last ${B.cycles_checked} cycles. The off delay only starts below the Stop Threshold (${B.stop_threshold_w} W), so cycles finish late.`)}</div></div></button>`);const U=$.length?`<div class="wd-attn">${$.join("")}</div>`:"",z=t.envelope_position,M=z!=null?Math.round(z*100):null,A=M!=null?` <span style="opacity:.75" title="${d(this._tText("lbl.envelope_position_tip",{pct:M},`Position on the matched program's recorded curve: ${M}%. Found by aligning the cycle to the profile, so it stays right when a run is longer or shorter. Updates while the appliance is quiet, so it can lag.`))}">${this._t("lbl.envelope_position",{pct:M},`curve ${M}%`)}</span>`:"",O=o&&n!=null?`
|
|
<div class="wd-prog-bg"><div class="wd-prog-fill" style="width:${Math.min(100,n)}%"></div></div>
|
|
<div class="wd-prog-row"><span>${it(n,1)}%${A}</span>${l!=null?`<span>${this._t("lbl.time_remaining",{v:ht(l)},`~${ht(l)} remaining`)}</span>`:""}</div>
|
|
`:"",I=((this._powerData||{}).live||[]).length>1,R=this._pref("show_expected",!0),G=this._pref("show_raw_active",!1),W=`<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" ${R?"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" ${G?"checked":""}><span class="wd-leg-sw" style="background:#9e9e9e"></span> ${this._t("lbl.raw_socket",{},"Raw socket")}</label>`:""}
|
|
</div>`,J=this._cyclesTotal||0,Y=(this._profiles||[]).length,nt=this._pref("setup_card_dismissed",!1),_t=this._setupStatus,ct=_t&&!o,dt=ct?this._htmlSetupCard(_t,nt):"",ut=I?`<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>${W}`:"",m=ct?dt+ut:ut||`<p class="wd-info" style="margin-top:12px">${this._t("msg.live_chart_loading",{},"Live power chart appears as readings arrive.")}</p>`,g=this._pref("show_debug",!1);let k="";if(g){const P=this._matchDebug||{},X=P.confidence!=null?`${(P.confidence*100).toFixed(1)}%`:"-",q=(P.candidates||[]).map(j=>`<tr><td>${d(j.profile_name)}</td><td>${j.confidence_pct}%</td><td>${j.mae}</td><td>${j.correlation}</td><td>${j.duration_ratio>=0?"+":""}${j.duration_ratio}%</td></tr>`).join("");k=`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.live_match_debug",{},"Live Match Debug")} ${ot(this._tText("msg.live_match_debug_tip",{},"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">${X}</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:${P.ambiguous?"var(--warning-color,#ff9800)":"var(--success-color,#4caf50)"}">${P.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>
|
|
${q?`<table class="wd-table"><thead><tr><th>${this._t("lbl.profile",{},"Profile")}</th><th>${this._t("col.dbg_conf",{},"Conf")}</th><th>${this._t("col.dbg_mae",{},"MAE")}</th><th>${this._t("col.dbg_corr",{},"Corr")}</th><th>${this._t("lbl.duration",{},"Duration")}</th></tr></thead><tbody>${q}</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 y=(()=>{if(!this._canEdit())return"";const P=Mt.includes(s),X=P&&!e,q=e,j=P||e;return!X&&!q&&!j?"":`<div class="wd-cycle-ctrl" style="margin-top:0">
|
|
${q?`<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>`:""}
|
|
${X?`<button class="wd-btn wd-btn-sm" data-action="pause-cycle" title="${d(this._t("btn.pause_cycle_tip",{},"Pause the running cycle \u2013 the appliance will resume where it left off"))}">${this._t("btn.pause_cycle",{},"Pause")}</button>`:""}
|
|
${j?`<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`
|
|
${U}
|
|
<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>
|
|
${y}
|
|
</div>
|
|
<div class="wd-badge ${o?"wd-running":""}" style="color:${r};background:color-mix(in srgb, ${r} 13%, transparent);">
|
|
<span class="wd-dot"></span>${d(i)}
|
|
${!a&&t.sub_state&&t.sub_state.toLowerCase()!==s?`<span style="opacity:.7;font-size:.85em">(${d(t.sub_state==="Stalled"?this._tText("status.stalled",{},"Stalled"):t.sub_state)})</span>`:""}
|
|
</div>
|
|
${x}
|
|
<div class="wd-stats">
|
|
<div class="wd-stat"><div class="wd-stat-val">${Lt(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">${n!=null?it(n,0)+"%":"-"}</div><div class="wd-stat-lbl">${this._t("lbl.progress",{},"Progress")}</div></div>
|
|
<div class="wd-stat"><div class="wd-stat-val">${ht(l)}</div><div class="wd-stat-lbl">${this._t("lbl.remaining",{},"Remaining")}</div></div>
|
|
</div>
|
|
${O}
|
|
${this._htmlPhaseTimeline(t,n,o)}
|
|
${ct?"":`<div class="wd-card-title" style="margin-top:18px">${this._t("hdr.live_power",{},"Live Power")}</div>`}
|
|
${m}
|
|
</div>
|
|
${this._canEdit()?this._htmlRecordingWidget():""}
|
|
${k}
|
|
`}_htmlSetupCard(t,e=!1){if(!t)return"";const{phase:s,message_key:a,message_params:r,cta_label_key:i,cta_action:o,secondary_label_key:n,secondary_action:l,skippable:c,dismissible:p,step_key:_}=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 f=this._tText(a,r||{},""),u=this._t(i,{},"Continue"),b=n?this._t(n,{},""):null,v=c?`
|
|
<span style="display:flex;gap:12px;margin-top:6px">
|
|
<a class="wd-link" data-action="setup-skip" data-step="${d(_||"")}" 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(_||"")}" data-snooze="never"
|
|
style="font-size:.85em;opacity:.7">${this._t("setup.cta.skip_forever",{},"Don't show again")}</a>
|
|
</span>`:"",T=p?`
|
|
<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(f)}</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(r||{}))}">
|
|
${d(u)}
|
|
</button>
|
|
${b?`<a class="wd-link" data-action="setup-cta"
|
|
data-cta-action="${d(l||"")}"
|
|
data-cta-params="${d(JSON.stringify(r||{}))}">
|
|
${d(b)}
|
|
</a>`:""}
|
|
</div>
|
|
${v}
|
|
${T}
|
|
</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",this._historySub="cycles",t==="open_cycles_unlabeled"&&(this._cycleFilter={...this._cycleFilter||{},status:"unlabeled"}),this._fetchTabData();return}if(t==="open_profiles"){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._historySub="cycles",this._fetchTabData();return}}}async _reloadSetupStatus(){const t=this._devices[this._selIdx];if(t){try{this._setupStatus=await this._ws({type:`${w}/get_setup_status`,entry_id:t.entry_id})}catch{this._setupStatus=null}this._render()}}_htmlPhaseTimeline(t,e,s){const a=this._statusPhases||[];if(!s||!a.length||!t.current_program)return"";const r=Math.max(0,...a.map(u=>u.end||0)),i=Number(t.expected_duration_s),o=Math.max(r,Number.isFinite(i)?i:0);if(o<=0)return"";const n=e!=null?Math.min(1,Math.max(0,e/100)):null;let l="",c=!1;const p=a.map((u,b)=>{const v=Math.max(0,Math.min(1,(u.start||0)/o)),T=Math.max(0,Math.min(1,(u.end||0)/o)),C=Math.max(0,(T-v)*100),x=Q[b%Q.length],$=n==null?!0:v<=n;!c&&n!=null&&T>v&&n>=v&&(n<T||n>=1&&T>=1)&&(c=!0,l=u.name||"");const L=u.name&&C>12?`<span class="wd-ptl-seg-lbl">${d(u.name)}</span>`:"";return`<div class="wd-ptl-seg" style="left:${(v*100).toFixed(2)}%;width:${C.toFixed(2)}%;background:${x};opacity:${$?.85:.28}" title="${d(u.name||"")}">${L}</div>`}).join(""),_=n!=null?`<div class="wd-ptl-cursor" style="left:${(n*100).toFixed(2)}%"></div>`:"",f=l?`<div class="wd-ptl-cur">${this._t("lbl.current_phase",{},"Current phase")}: <b>${d(l)}</b></div>`:"";return`<div class="wd-ptl-wrap">
|
|
<div class="wd-ptl" role="img" aria-label="${d(this._t("lbl.phase_timeline",{},"Phase timeline"))}">${p}${_}</div>
|
|
${f}
|
|
</div>`}_htmlRecordingWidget(){const t=this._recState,e=this._devices[this._selIdx],a=!!(e&&e.recording)?"recording":t?t.state:"idle",r=a==="recording"?"wd-rec-active":a==="stopped"?"wd-rec-ready":"wd-rec-idle",i=a==="recording"?this._t("status.recording",{},"Recording\u2026"):a==="stopped"?this._t("status.ready",{},"Ready to process"):this._t("status.idle",{},"Idle");let o="";const n=c=>this._t("lbl.n_samples",{n:c},`${c} samples`);a==="recording"?o=t?`${ht(t.duration_s)} \xB7 ${n(t.sample_count||0)}`:"":a==="stopped"&&(o=`${n(t.sample_count||0)} \xB7 ${ht(t.duration_s)}`);const l=a==="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>`:a==="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 \u2013 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 ${r}"></div>
|
|
<div><strong>${this._t("hdr.manual_recording",{},"Manual Recording")}</strong>${ot(this._t("hdr.manual_recording_tip",{},"Record a cycle on purpose: press Start just before the appliance starts and Stop when it finishes, then Process to save it as a profile."))}${o?`<span class="wd-field-hint" style="margin-left:8px">${o}</span>`:""}</div>
|
|
</div>
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap">${l}</div>
|
|
</div>
|
|
</div>`}_htmlHistoryTab(){const t=this._historySub==="maintenance"?"maintenance":"cycles";return`<div class="wd-subtabs" role="tablist">${[["cycles",this._t("tab.history",{},"Cycles")],["maintenance",this._t("tab.maintenance",{},"Maintenance")]].map(([s,a])=>`<button class="wd-subtab ${t===s?"active":""}" role="tab" aria-selected="${t===s?"true":"false"}" data-hsub="${s}">${a}</button>`).join("")}</div>${t==="maintenance"?this._htmlMaintenance():this._htmlHistory()}`}_htmlHistory(){const t=this._cycles||[],e=this._refCycles||[],s=e.concat(t),a=this._canEdit(),r=this._selectMode&&a,i=this._cycleSel,{col:o,dir:n}=this._cycleSort,{text:l,status:c}=this._cycleFilter,p=this._mlById||{},_=new Set((this._feedbacks||[]).map(y=>y.cycle_id)),f=y=>p[y.id],u=y=>{const P=f(y);return!!(P&&P.ml_review&&P.ml_review.reviewed_at)},b=y=>{const P=f(y);return!!(P&&P.ml_review&&P.ml_review.golden)},v=y=>_.has(y.id),T=s.filter(v).length;let C=s;if(l){const y=l.toLowerCase();C=C.filter(P=>(P.profile_name||P.matched_profile||"").toLowerCase().includes(y))}c==="unlabelled"?C=C.filter(y=>!y.profile_name&&!y.matched_profile):c==="needs_review"?C=C.filter(v):c==="imported"?C=C.filter(y=>y.is_reference):c&&(C=C.filter(y=>(y.status||"completed")===c));const x={date:y=>y.start_time?new Date(y.start_time).getTime():0,confidence:y=>y.match_confidence,duration:y=>y.duration,energy:y=>y.energy_kwh!=null?y.energy_kwh:y.energy_wh!=null?y.energy_wh/1e3:null,cost:y=>y.cost!=null?y.cost:-1,status:y=>y.status||"completed",profile:y=>(y.profile_name||y.matched_profile||"\uFFFF").toLowerCase()};C=_e(C,x[o]||x.date,n);const $=y=>y==="completed"?"var(--success-color, #4caf50)":y==="interrupted"?"var(--error-color, #f44336)":y==="force_stopped"?"var(--warning-color, #ff9800)":"var(--secondary-text-color)",L=y=>{if(!y.is_reference)return"";const P=y.cycle_origin==="backfill",X=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(X)}" style="color:var(--info-color,#2196f3)">${P?"\u{1F557}":"\u{1F4E5}"}</span>`},D=y=>b(y)?' <span title="'+d(this._t("badge.golden_cycle",{},"Recorded reference cycle"))+'" style="color:var(--warning-color,#ff9800)">\u2B50</span>':_.has(y.id)?' <span title="'+d(this._t("badge.feedback_requested",{},"Feedback requested"))+'" style="color:var(--info-color,#2196f3)">\u{1F4AC}</span>':u(y)?' <span title="'+d(this._t("badge.reviewed",{},"Reviewed"))+'" style="color:var(--success-color,#4caf50)">\u2713</span>':v(y)?' <span title="'+d(this._t("badge.needs_review",{},"Needs review"))+'" style="color:var(--error-color,#f44336)">\u25CF</span>':"",B=y=>{if(y.anomaly!=="overrun")return"";const P=y.overrun_ratio?" "+this._tText("badge.overrun_ratio",{x:Number(y.overrun_ratio).toFixed(1)},`(${Number(y.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>`},U=y=>{if(y.anomaly!=="underrun")return"";const P=y.underrun_ratio?" "+this._tText("badge.underrun_ratio",{pct:Math.round(y.underrun_ratio*100)},`(${Math.round(y.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>`},z=y=>{if(!y.energy_anomaly||y.energy_anomaly==="none")return"";const P=y.energy_anomaly==="energy_spike",X=y.energy_z_score!=null?` (${y.energy_z_score>0?"+":""}${Number(y.energy_z_score).toFixed(1)}\u03C3)`:"",q=P?"badge.energy_spike":"badge.energy_low",j=P?"Higher energy than usual":"Lower energy than usual",V=P?"\u{1F53A}":"\u{1F53B}",E=P?"var(--error-color,#f44336)":"var(--info-color,#2196f3)";return` <span title="${d(this._t(q,{},j))}${d(X)}" style="color:${E}">${V}</span>`},M=y=>{const P=Array.isArray(y.artifacts)?y.artifacts.length:0;return P?` <span title="${d(this._tText("badge.artifact_tip",{n:P},`${P} anomal${P>1?"ies":"y"} detected (e.g. door opened mid-cycle) - open to see them on the graph`))}" style="color:var(--warning-color,#ff9800)">\u26A0</span>`:""},A=y=>{const P=Array.isArray(y.restart_gaps)?y.restart_gaps.length:0;return P?` <span title="${d(this._tText("badge.restart_gap_tip",{n:P},`${P} HA restart gap${P>1?"s":""} during this cycle - power trace has a hole`))}" style="color:var(--info-color,#2196f3)">\u21BB</span>`:""},O=this._hass&&this._hass.config&&this._hass.config.currency||"",K=y=>{if(y.cost==null)return"-";const P=It(y.cost,O);if(y.energy_price_mode==="dynamic"&&y.energy_price!=null){const X=this._tText("col.cost_dynamic_tip",{price:it(y.energy_price,4),cur:O},"Time-weighted: charged at the price in force during the cycle (effective {price} {cur}/kWh).");return`<span title="${d(X)}" style="border-bottom:1px dotted var(--secondary-text-color)">${P}</span>`}return P},I=C.map(y=>{const P=y.profile_name||y.matched_profile,X=y.match_confidence!=null?y.match_confidence*100:null,q=y.status||"completed",j=y.energy_kwh!=null?y.energy_kwh:y.energy_wh!=null?y.energy_wh/1e3:null,V=r,E=V?`<input type="checkbox" class="wd-csel" ${i.has(y.id)?"checked":""} style="width:auto;margin:0">`:`<span class="wd-devdot" style="background:${$(q)}" title="${d(q)}"></span>`,S=this._cycleStatusLabel(q),F=`${L(y)}${D(y)}${B(y)}${U(y)}${z(y)}${M(y)}${A(y)}`.trim();return`<tr data-cid="${d(y.id)}" data-selmode="${V?1:0}" tabindex="0" role="button" style="cursor:pointer">
|
|
<td style="width:26px;padding:6px 4px 6px 8px">${E}</td>
|
|
<td>${P?d(P):`<span style="color:var(--secondary-text-color)">${this._t("lbl.unlabelled",{},"Unlabelled")}</span>`}</td>
|
|
<td class="wd-tc-flags">${F}</td>
|
|
<td><span style="color:${$(q)};font-size:.9em">${d(S)}</span></td>
|
|
<td class="wd-tc-date">${et(y.start_time)}</td>
|
|
<td class="wd-tc-num">${ht(y.duration)}</td>
|
|
<td class="wd-tc-num">${j!=null?gt(j):"-"}</td>
|
|
<td class="wd-tc-num">${K(y)}</td>
|
|
<td class="wd-tc-num">${X!=null?it(X,0)+"%":"-"}</td>
|
|
</tr>`}).join(""),R=`<thead><tr>
|
|
<th style="width:26px;padding:6px 4px 6px 8px"></th>
|
|
${mt(this._t("lbl.profile",{},"Profile"),"profile",o==="profile",n,"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>
|
|
${mt(this._t("lbl.status",{},"Status"),"status",o==="status",n,"cycsort","",this._t("col.status_tip",{},"Cycle outcome: Completed (natural end), Interrupted (abrupt power drop), Force Stopped (manual), or Needs Review (feedback pending)."))}
|
|
${mt(this._t("lbl.date",{},"Date"),"date",o==="date",n,"cycsort","",this._t("col.date_tip",{},"Date and time the cycle started."))}
|
|
${mt(this._t("lbl.duration",{},"Duration"),"duration",o==="duration",n,"cycsort","right",this._t("col.duration_tip",{},"Total cycle run time from start to end."))}
|
|
${mt(this._t("lbl.energy",{},"Energy"),"energy",o==="energy",n,"cycsort","right",this._t("col.energy_tip",{},"Total energy used (kWh), computed from power over time."))}
|
|
${mt(this._t("lbl.cost",{},"Cost"),"cost",o==="cost",n,"cycsort","right",this._t("col.cost_tip",{},"Energy cost of this cycle, fixed when it finished. With Time-Weighted Cost on, each moment is charged at its own price; otherwise at the price at the end. Set a price in Settings to fill this in."))}
|
|
${mt(this._t("lbl.confidence",{},"Confidence"),"confidence",o==="confidence",n,"cycsort","right",this._t("col.confidence_tip",{},"Profile match confidence (0-100%). How closely the cycle power curve matched the identified program."))}
|
|
</tr></thead>`,G=`<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")}${T?` (${T})`:""}</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>`,W=C.length!==s.length?this._t("lbl.n_shown",{n:C.length},`, ${C.length} shown`):"",J=e.length?this._t("lbl.n_imported_note",{n:e.length},`, ${e.length} imported`):"",Y=this._t("lbl.cycles_title",{n:`${t.length}${J}${W}`},`Cycles (${t.length}${J}${W})`),nt=a?`<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",{},"Name unlabelled cycles whose match confidence clears the threshold."))}">${this._t("btn.auto_label_cycles",{},"Auto-label cycles")}</button>
|
|
<button class="wd-btn ${r?"wd-btn-primary":"wd-btn-secondary"} wd-btn-sm" data-action="cyc-select-toggle">${r?this._t("btn.done",{},"Done"):this._t("btn.select",{},"Select")}</button>
|
|
</div>`:"",_t=new Set(e.map(y=>y.id)),ct=[...i].some(y=>_t.has(y)),dt=r?`<div class="wd-card-actions" style="margin:0 0 10px">
|
|
<span class="wd-info" style="margin:0">${this._t("lbl.n_selected",{n:i.size},`${i.size} selected`)}</span>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cyc-compare" ${i.size<2?"disabled":""}>${this._t("btn.compare",{},"Compare")}${i.size>=2?` (${i.size})`:""}</button>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cyc-merge" ${i.size<2||ct?"disabled":""}${ct?` 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")}${i.size>=2?` (${i.size})`:""}</button>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cyc-relabel" ${i.size<1?"disabled":""}>${this._t("btn.relabel",{count:i.size},`Relabel (${i.size})`)}</button>
|
|
<button class="wd-btn wd-btn-danger wd-btn-sm" data-action="cyc-bulk-del" ${i.size<1?"disabled":""}>${this._t("btn.delete",{},"Delete")}${i.size>=1?` (${i.size})`:""}</button>
|
|
</div>`:"",ut=this._busy.has("cyc-load-more"),m=this._cyclesHasMore||this._importedHasMore?`<div style="text-align:center;margin-top:12px">
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="cyc-load-more" ${ut?"disabled":""}>${ut?'<span class="wd-spin"></span> ':""}${this._t("btn.load_more",{},"Load more")}</button>
|
|
</div>`:"",g=`
|
|
<div class="wd-card">
|
|
<div class="wd-card-title">${Y}</div>
|
|
${G}
|
|
${nt}${dt}
|
|
${C.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">${R}<tbody>${I}</tbody></table></div>`}
|
|
${m}
|
|
</div>`;return(this._cyclesError?`<div class="wd-error-state" role="alert"><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>`:"")+g}_profileAdvisory(t,e){return(this._profileAdvisories||[]).find(s=>s&&s.profile===t&&s.code===e)}_advisoryText(t){return this._tText(t.message_key,t.message_params||{},t.message||"")}_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 ${this._t("lbl.energy_per_cycle",{v:gt(t.avg_energy)},"{v}/cycle")}`:"",a=t.avg_energy!=null&&t.cycle_count?` \xB7 ${this._t("lbl.energy_total",{v:vt(`<strong>${d(gt(t.avg_energy*t.cycle_count))}</strong>`)},"{v} total")}`:"",r=this._hass&&this._hass.config&&this._hass.config.currency||"",i=t.avg_cost!=null?` \xB7 ${this._t("lbl.avg_cost",{},"Avg")} ${It(t.avg_cost,r)}/${this._t("lbl.per_cycle_short",{},"cycle")}`:"",o=(this._profileHealth||{})[t.name],n=(this._profileTrends||{})[t.name],l=this._profileAdvisory(t.name,"poor_health"),c=l?`<span class="wd-badge" style="color:var(--error-color,#f44336);background:rgba(244,67,54,.12)" title="${d(this._advisoryText(l))}">\u26A0 ${this._t("badge.poor_fit",{},"poor fit")}</span>`:"",p=this._profileAdvisory(t.name,"duration_trend_up")||this._profileAdvisory(t.name,"energy_trend_up"),_=p?`<span class="wd-badge" style="color:var(--secondary-text-color,#888)" title="${d(this._advisoryText(p))}">${this._trendIcon("up")}</span>`:"",f=this._constants&&this._constants.PROFILE_MIN_WARMUP_CYCLES||5,u=o&&o.cycle_count||0,v=u<f&&!t.is_imported?`<span class="wd-badge" title="${d(this._tText("msg.warmup_detail",{needed:f},`Matching already works. WashData asks you to confirm the first ${f} cycles of this profile before labelling on its own.`))}" style="background:var(--info-color,#2196f3);color:#fff">${this._t("msg.warmup_badge",{done:u,needed:f},`Still learning (${u}/${f} cycles)`)}</span>`:"",T=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>`:"",C=t.is_imported?Number((this._profileMatcherCounts||{})[t.name]||0):null,x=C==null?"":`<span class="wd-badge wd-matcher-used" data-used="${C}" style="color:var(--secondary-text-color,#888)" title="${d(this._tText("badge.matcher_used_tip",{},"Cycles on this appliance that WashData matched to this imported program on its own. An import that never matches may not fit your appliance; you can remove it. Counted locally; nothing is sent to the store."))}">${C>0?this._t("badge.matcher_used",{n:C},`Matched ${C} of your cycles`):this._t("badge.matcher_unused",{},"Not matched to your cycles yet")}</span>`,$=this._profileAdvisory(t.name,"unmatchable"),L=$?`<span class="wd-badge" style="color:var(--error-color,#f44336);background:rgba(244,67,54,.12)" title="${d(this._tText($.message_key,$.message_params,$.message))}">\u26A0 ${this._t("badge.unmatchable",{},"can't be matched")}</span>`:"",D=(this._profileTerminal||{})[t.name];let B="";if(D&&D.quiet_before_s>0&&D.seen_in>=2){const O=Math.max(1,Math.round(D.quiet_before_s/60)),K=Number(D.event_watts||0).toFixed(0),I=Math.round(D.event_seconds||0),R=this._tText("badge.quiet_tail_tip",{mins:O,secs:I,watts:K,seen:D.seen_in,measured:D.measured},`Near the end this program goes quiet for about ${O} min, then draws about ${K} W for ${I} s. Seen in ${D.seen_in} of ${D.measured} measured cycles.`);B=`<span class="wd-badge" style="color:var(--secondary-text-color,#888)" title="${d(R)}">${this._t("badge.quiet_tail",{mins:O},`~${O}m quiet tail`)}</span>`}const U=this._profileAdvisory(t.name,"duration_outlier"),z=U?`<span class="wd-badge" style="color:var(--warning-color,#ff9800);background:rgba(255,152,0,.12)" title="${d(this._tText(U.message_key,U.message_params,U.message))}">\u26A0 ${this._t("badge.duration_outlier",{n:(U.message_params||{}).n||1},`${(U.message_params||{}).n||1} odd-length cycle(s)`)}</span>`:"",M=[L,z,c,_,B,v,T,x].filter(Boolean).join(" "),A=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._tText("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>
|
|
${A}
|
|
</div>
|
|
${M?`<div class="wd-profile-badges">${M}</div>`:""}
|
|
<div class="wd-profile-meta">${this._t("lbl.n_cycles",{n:t.cycle_count||0},"{n} cycles")} \xB7 ${e}${s}${a}${i}</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",a=window.devicePixelRatio||1,r=t.getBoundingClientRect(),i=t.width=Math.max(1,Math.round((r.width||64)*a)),o=t.height=Math.max(1,Math.round((r.height||20)*a)),n=t.getContext("2d");n.clearRect(0,0,i,o);const l=Math.max(...e,1),c=2*a,p=f=>c+(e.length===1?0:f/(e.length-1)*(i-2*c)),_=f=>o-c-Math.max(0,f)/l*(o-2*c);n.beginPath(),e.forEach((f,u)=>{const b=p(u),v=_(f);u===0?n.moveTo(b,v):n.lineTo(b,v)}),n.strokeStyle=s,n.lineWidth=1.5*a,n.lineJoin="round",n.lineCap="round",n.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 a of this._profiles||[])s[a.name]=a;e.forEach(a=>{const r=s[a.dataset.sparkProf];this._paintSparkline(a,r&&r.signature_curve||[])})}_htmlProfiles(){const t=this._busy.has("rebuild-envelopes"),e=this._canEdit(),s={};this._profiles.forEach(v=>{s[v.name]=v});const a=this._profileGroups||{groups:[]},r=new Set;a.groups.forEach(v=>(v.members||[]).forEach(T=>r.add(T)));const i=a.groups.map(v=>{const T=(v.members||[]).map(D=>s[D]?this._profileCardHtml(s[D]):"").join(""),C=Math.round((v.cohesion!=null?v.cohesion:1)*100),x=v.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:C},"cohesion "+C+"%")}</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:C},"\u26A0 low cohesion "+C+"%")}</span>`,$=v.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(v.name)}">\u{1F517} ${d(v.name)}</button>`:`<span style="font-size:1.05em;font-weight:600">\u{1F517} ${d(v.name)}</span>`}
|
|
${x}<span style="flex:1"></span>
|
|
${e?`<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="pg-edit" data-gname="${d(v.name)}">${this._t("btn.manage",{},"Manage")}</button>`:""}
|
|
</div>
|
|
${$}
|
|
<div class="wd-profiles-grid">${T}</div>
|
|
</div>`}).join(""),o=this._profiles.filter(v=>!r.has(v.name)),n=o.map(v=>this._profileCardHtml(v)).join(""),l=this._profiles.length===0&&!(this._cycles||[]).length&&!(this._refCycles||[]).length,c=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>`:"",_=(e?((this._coverageGaps||{}).profile_suggestions||[]).slice(0,2):[]).map(v=>{const T=Math.round((v.avg_duration_s||0)/60),C=(v.cycle_ids||[])[0]||"";return`<div class="wd-sug-banner">
|
|
<span>\u{1F9E9} ${this._t("msg.coverage_gap_cluster",{count:v.count,min:T},`${v.count} recent unlabelled cycles of about ${T} min look like one program you have not created yet.`)}</span>
|
|
<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="coverage-create" data-cid="${d(C)}" data-min="${T}">${this._t("btn.create_profile_from_gap",{},"Create profile")}</button>
|
|
</div>`}).join(""),f=c+_+`
|
|
<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, different temperature or spin) so matching can tell them apart."))}">${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",{},"Rebuild every profile's expected power band from its labelled cycles. Run after labelling or correcting cycles."))}">${t?'<span class="wd-spin"></span> '+this._t("status.rebuilding",{},"Rebuilding\u2026"):this._t("btn.rebuild",{},"Rebuild Envelopes")}</button>
|
|
</div>`:""}
|
|
</div>
|
|
${i}
|
|
${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?`${i?`<div class="wd-card-title" style="margin:6px 0 8px">${this._t("lbl.ungrouped",{},"Ungrouped")}</div>`:""}<div class="wd-profiles-grid">${n}</div>`:""}`,u=[["profiles",this._t("tab.subtab_profiles",{},"Profiles")],["phase-catalog",this._t("tab.subtab_phase_catalog",{},"Phase Catalog")]].map(([v,T])=>`<button class="wd-subtab ${this._profSubtab===v?"active":""}" role="tab" aria-selected="${this._profSubtab===v?"true":"false"}" data-proftab="${v}">${T}</button>`).join(""),b=this._profilesError||this._profileGroupsError?`<div class="wd-error-state" role="alert"><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" role="tablist">${u}</div>
|
|
${this._profSubtab==="phase-catalog"?this._htmlPhases():b+f}
|
|
`}_htmlProfileGroupModal(t){const e=this._busy.has("pg-save"),s=this._profileEnvCache||{},a=_=>Q[Math.max(0,this._profiles.findIndex(f=>f.name===_))%Q.length],r=t.members||[],i=this._profiles.map(_=>{const f=r.includes(_.name),u=_.avg_duration?`~${Math.round(_.avg_duration/60)}m`:"",b=_.avg_energy!=null?` \xB7 ${gt(_.avg_energy)}`:"",v=f?`<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${a(_.name)};margin:0 2px"></span>`:"";return`<label class="wd-rev-tag"><input type="checkbox" class="wd-pg-mem" value="${d(_.name)}" ${f?"checked":""}> ${v}${d(_.name)} <span style="color:var(--secondary-text-color);font-size:.85em">${u}${b}</span></label>`}).join(""),o=r.filter(_=>s[_]&&(s[_].avg||[]).length),n=o.length?`<div class="wd-leg">${o.map(_=>`<span class="wd-leg-i"><span class="wd-leg-sw" style="background:${a(_)}"></span> ${d(_)}</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>${n}`:`<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(_=>_.name===t.orig),p=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">${p}</div>
|
|
${l}
|
|
<div class="wd-rev-sub">${this._t("lbl.members",{},"Members")}${r.length?` (${r.length})`:""}</div>
|
|
<div class="wd-rev-tags">${i||`<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 or spin. Matching scores the group as one, 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=i=>Q[Math.max(0,this._profiles.findIndex(o=>o.name===i))%Q.length];let a=0;const r=(t.members||[]).filter(i=>e[i]&&(e[i].avg||[]).length).map(i=>{const o=e[i],n=o.avg[o.avg.length-1];return a=Math.max(a,o.target_duration||(n?n[0]:0)),{points:o.avg,stroke:s(i),width:2,alpha:.9,name:i}});r.length&&this._drawCurves("wd-pgroup-canvas",{series:r,xMax:a})}_settingsLevel(){return this._pref("settings_level","basic")==="advanced"?"advanced":"basic"}_settingFieldVisible(t){return t.internal&&!this._pref("show_internal",!1)&&!(this._internalReveal||new Set).has(t.key)?!1:this._settingsLevel()==="advanced"||!!t.basic}_computeInternalReveal(t){const e=new Set,s=this._optDefaults||{},a=this._conflictKeysFromOpts(),r=new Set((this._suggestions||[]).map(i=>i.key));for(const i of yt)for(const o of i.fields||(i.groups||[]).flatMap(n=>n.fields||[])){if(!o.internal)continue;const n=(this._opts||{})[o.key],l=s[o.key]!==void 0?s[o.key]:o.def;(n!=null&&n!==""&&String(n)!==String(l)||a.has(o.key)||r.has(o.key)||Object.prototype.hasOwnProperty.call(this._pendingSettings||{},o.key))&&e.add(o.key)}return e}_secHasBasicFields(t){return(t.fields||(t.groups||[]).flatMap(s=>s.fields||[])).some(s=>s.basic)}_htmlSettings(){const t=this._editedOpts();if(!Object.keys(t).length&&this._tabError==="settings")return`<div class="wd-error-state" role="alert"><span>${this._t("msg.fetch_error",{},"Failed to load data.")}</span><button class="wd-btn" type="button" data-action="retry-tab">${this._t("btn.retry",{},"Retry")}</button></div>`;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" role="alert"><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>`:"",a=this._settingsLevel()==="basic";this._internalReveal=this._computeInternalReveal(t);const r=new Set((this._suggestions||[]).map(W=>W.key)),i=W=>(W.fields||(W.groups||[]).flatMap(Y=>Y.fields||[])).some(Y=>r.has(Y.key)),o=this._conflictKeysFromOpts(),n=W=>(W.fields||(W.groups||[]).flatMap(Y=>Y.fields||[])).some(Y=>o.has(Y.key)),l=this._opts&&this._opts.device_type||"",c=yt.filter(W=>!(W.id==="ml_training"||l&&W.notDeviceTypes&&W.notDeviceTypes.includes(l)||l&&W.onlyDeviceTypes&&!W.onlyDeviceTypes.includes(l)||a&&!this._secHasBasicFields(W))),p=(c.find(W=>W.id===this._settingsSec)||c[0]||{}).id,_=c.map(W=>{const J=i(W),Y=n(W);return`<button class="wd-sec-btn ${p===W.id?"active":""}" data-sec="${W.id}">${d(this._t("section."+W.id+".label",{},W.label))}${Y?'<span class="wd-sec-conf-dot"></span>':J?'<span class="wd-sec-sug-dot"></span>':""}</button>`}).join(""),f=`<label class="wd-mode-switch" title="${d(this._t("lbl.settings_detail_level",{},"Settings detail level"))}">
|
|
<span class="wd-mode-switch-label ${a?"active":""}">${this._t("lbl.settings_basic",{},"Basic")}</span>
|
|
<span class="wd-toggle-track">
|
|
<input type="checkbox" id="wd-settings-level-chk" ${a?"":"checked"} data-action="set-settings-level">
|
|
<span class="wd-toggle-knob"></span>
|
|
</span>
|
|
<span class="wd-mode-switch-label ${a?"":"active"}">${this._t("lbl.settings_advanced",{},"Advanced")}</span>
|
|
</label>`,u=a?`<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>`:"",b=a?"":`<label class="wd-check-inline" style="font-size:.82em;display:inline-flex;align-items:center;gap:6px" title="${d(this._t("lbl.show_internal_tip",{},"Settings WashData tunes for you (sampling, watchdog, match cadence, thresholds). Shown when this device has its own value, a conflict or a suggestion."))}">
|
|
<input type="checkbox" id="wd-settings-internal-chk" data-action="set-settings-internal" ${this._pref("show_internal",!1)?"checked":""}>
|
|
${this._t("lbl.show_internal",{},"Show internal settings")}</label>`,v=this._busy.has("save-settings"),T=o.size,C=T!==1?"s":"",x=T?`
|
|
<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:T},`Setting conflicts: ${T}. 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>`:"",$=this._suggestions.length,L=this._settingsSugOnly&&!this._settingsSearch,D=$?`<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="sug-apply-all">${this._t("btn.apply_all",{},"Apply all")}</button>`:"",B=$?`<button class="wd-btn wd-btn-sm wd-btn-secondary" data-action="sug-dismiss">${this._t("btn.dismiss",{},"Dismiss")}</button>`:"",U=$?L?`
|
|
<div class="wd-sug-banner">
|
|
<span>\u{1F4A1} ${this._t("msg.showing_suggestions",{count:$},`Showing ${$} setting${$>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:$},`${$} tuning suggestion${$>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}
|
|
${B}
|
|
</div>`:"",z=this._busy.has("sug-analyze"),M=`<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="sug-analyze" ${z?"disabled":""} title="${d(this._t("btn.run_analysis_tip",{},"Analyze your recorded cycles now and refresh tuning suggestions"))}">${z?'<span class="wd-spin"></span> '+this._t("status.analyzing",{},"Analyzing\u2026"):this._t("btn.run_analysis",{},"\u{1F50D} Run suggestion analysis")}</button>`,A=this._settingsSearch||"",O=A.trim().toLowerCase(),K=`<input type="text" id="wd-settings-search" class="wd-filter-input" placeholder="${this._t("msg.search_placeholder",{},"Search settings\u2026")}" value="${d(A)}" autocomplete="off" style="flex:0 0 auto;width:200px;max-width:40%">`,I=O?this._htmlSettingsSearch(t,O):L?this._htmlSettingsSugOnly(t):this._htmlSettingsSection(t),R=(this._lockedSuggestions||[]).length,G=R?`
|
|
<div class="wd-sug-banner" style="opacity:.92">
|
|
<span>\u{1F515} ${this._t("msg.n_suggestions_muted",{count:R},`${R} suggestion${R>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")}</div>
|
|
${M}
|
|
</div>
|
|
${x}${U}${G}${u}
|
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
|
|
${K}
|
|
<div class="wd-section-nav" style="flex:1;margin:0;margin-bottom:0">${_}</div>
|
|
${f}
|
|
${b}
|
|
</div>
|
|
<div class="wd-card">
|
|
<form id="wd-settings-form">${I}</form>
|
|
<div class="wd-card-actions" style="margin-top:20px">
|
|
<button class="wd-btn wd-btn-primary" id="wd-settings-save" ${v?"disabled":""}>${v?'<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 reloads the integration. 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(),a=t.slice(0,100).map(r=>{const i=s?`<button class="wd-btn wd-btn-secondary wd-btn-xs" data-action="settings-revert-key" data-key="${d(r.key)}" data-val="${d(JSON.stringify(r.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."+r.key+".label",{},r.key))}</td>
|
|
<td>${d(xt(r.old))} \u2192 ${d(xt(r.new))}</td>
|
|
<td class="wd-tc-date">${et(r.timestamp)}</td>
|
|
<td style="text-align:right">${i}</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>${a}</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 n=this._optDefaults;s=n&&n[t.key]!=null?n[t.key]:t.def}const a={};if(t.type==="devicetype")a.opts=this._deviceTypeOpts(s||e.device_type);else if(t.type==="device")a.opts=this._deviceOpts(s);else if(t.type==="select")a.opts=t.opts||[];else if(t.type==="entity"){const n=this._hass&&this._hass.states?this._hass.states:{},l=Array.isArray(t.domains)?t.domains:t.domain==="binary_sensor"?["binary_sensor","sensor"]:t.domain?[t.domain]:null,c=Object.keys(n).filter(p=>!l||l.some(_=>p.startsWith(_+"."))).filter(p=>!t.notPrice||Nt(p,e,this._conflictCtx())==null).sort().slice(0,500);this._entityListCache||(this._entityListCache={}),this._entityListCache[t.key]=c}else if(t.type==="entitylist"){const n=this._hass&&this._hass.states?this._hass.states:{},l=Object.keys(n).filter(c=>!t.domain||c.startsWith(t.domain+".")).sort();if(t.domain==="notify"&&this._hass&&this._hass.services&&this._hass.services.notify){const c=Object.keys(this._hass.services.notify).map(p=>`notify.${p}`);a.entities=[...new Set([...l,...c])].sort().slice(0,500)}else a.entities=l.slice(0,500);this._entityListCache||(this._entityListCache={}),this._entityListCache[t.key]=a.entities}else if(Array.isArray(t.suggestions)&&t.suggestions.length){const n=`wd-dl-${t.key}`;a.datalistId=n,a.datalist=`<datalist id="${n}">${t.suggestions.map(l=>`<option value="${d(l)}">`).join("")}</datalist>`}const r=this._suggestions.find(n=>n.key===t.key);if(r){const n=Object.assign({},r.reason_params||{});r.exclusions&&r.exclusions.total&&(n.excl=this._exclNote(r.exclusions)),a.suggestion={suggested:r.suggested,current:r.current,reason:r.reason,reason_key:r.reason_key,reason_params:n}}a.useBtnLabel=this._t("btn.use",{},"Use"),a.t=this._t.bind(this),a.tText=this._tText.bind(this);const i=(this._settingsChangeByKey||{})[t.key];i&&(a.changed=this._tText("msg.setting_changed",{old:xt(i.old),new:xt(i.new),date:et(i.timestamp)},`Changed from ${xt(i.old)} to ${xt(i.new)} on ${et(i.timestamp)}`));const o=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 Ge(o,s,a)}_renderStorePicker(t,e){const s=t.type==="storebrand",a=t.key,r=String(e[a]==null?"":e[a]),i=this._t("setting."+a+".label",{},t.label||""),o=this._t("setting."+a+".doc",{},t.doc||""),n=d(this._t("placeholder."+a,{},s?"e.g. Bosch":"e.g. WAT28660"));return this._onlineEnabled()?s?this._renderBrandPicker(a,r,i,o,n):this._renderModelPicker(a,e,r,i,o,n):`<div class="wd-field"><label>${d(i)}</label>
|
|
<input type="text" data-opt="${a}" data-ftype="text" value="${d(r)}" placeholder="${n}">
|
|
<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,a,r){this._ensureCatalogEntry();const i=Array.isArray(this._catalog.brands)?this._catalog.brands:[];this._entityListCache=this._entityListCache||{},this._entityListCache[t]=i.map(l=>l.brand).filter(Boolean);const o=this._statusTag(this._catalogEntryFor(e,"brand")),n=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)} ${a?ot(a):""}${o}${n}</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="${r}" 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("btn.add_brand",{},"Add brand"))}">+</button>
|
|
</div></div>`}_renderModelPicker(t,e,s,a,r,i){const o=String(e.store_brand||"");if(!o)return`<div class="wd-field"><label>${d(a)}</label>
|
|
<input type="text" id="wd-store-model" data-opt="${t}" data-ftype="text" value="${d(s)}" placeholder="${i}" 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 n=Array.isArray(this._catalog.devices)?this._catalog.devices:[];this._entityListCache=this._entityListCache||{},this._entityListCache[t]=n.map(u=>u.model).filter(Boolean);const l=this._catalogEntryFor(s,"device"),c=this._statusTag(l),p=this._catalog.devices===null?` <span class="wd-info" style="font-size:.85em">${this._t("msg.loading",{},"Loading\u2026")}</span>`:"";let _="";if(l){const u=[],b=je(l.manualUrl);b&&u.push(`<a href="${d(b)}" 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:l.createdByName||this._tText("lbl.anonymous",{},"Anonymous")},"by {name}"));const v=!!(this._storeStatus&&this._storeStatus.connected);let T="";if(v){const C=[1,2,3,4,5].map(x=>`<button type="button" class="wd-star-btn" data-action="store-rate-device" data-device-id="${d(l.id)}" data-rating="${x}" aria-label="${this._t("lbl.rate_n",{n:x},`${x} stars`)}">\u2605</button>`).join("");T=`<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"))}">${C}</span>`}else T=`<span class="wd-info" style="font-size:.85em">${this._t("msg.connect_to_confirm",{},"Connect in the settings gear to confirm or rate.")}</span>`;_=`<div class="wd-store-picker-detail">${u.join(" \xB7 ")}<div class="wd-store-picker-actions">${T}</div></div>`}const f=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 reference cycles to the community store so owners of 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",{},"Add every shared program and its reference cycles to your device. Your own cycles and stats are not affected."))}">${this._t("btn.download_device",{},"Download this setup")}</button>
|
|
</div>`:"";return`<div class="wd-field"><label>${d(a)} ${r?ot(r):""}${c}${p}</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="${i}" 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("btn.add_appliance",{},"Add appliance"))}">+</button>
|
|
</div>
|
|
${_}${f}</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 a=(s.profile_name||"").trim();a&&(t.has(a)||t.set(a,[]),t.get(a).push(s))}const e=Array.from(t.entries()).map(([s,a])=>({program:s,cycles:a})).sort((s,a)=>s.program.localeCompare(a.program));for(const s of this._shareAllPrograms||[])t.has(s)||e.push({program:s,cycles:[],noCycles:!0});return e}_editedOpts(){return Object.assign({},this._opts,this._pendingSettings)}_catalogEntryKey(){const t=this._editedOpts();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 a=e==="brand"?s.brand:s.device;if(!a)return null;const r=e==="brand"?a.brand:a.model;return String(r||"").toLowerCase()===String(t||"").trim().toLowerCase()?a:null}async _loadCatalogEntry(t){const e=this._devices[this._selIdx],s=this._editedOpts(),a=(s.store_brand||"").trim(),r=(s.store_model||"").trim();if(!e||!this._onlineEnabled()||!a||!r){this._catalogEntry&&this._catalogEntry.key===t&&(this._catalogEntry.loading=!1);return}let i=null;try{i=await this._ws({type:`${w}/store_get_catalog_entry`,entry_id:e.entry_id,brand:a,model:r,appliance_type:s.device_type||""})}catch{}!this._catalogEntry||this._catalogEntry.key!==t||(this._catalogEntry={key:t,loading:!1,brand:i&&i.brand||null,device:i&&i.device||null,deviceId:i&&i.device_id||null},this._isActiveEntry(e.entry_id)&&this._renderPreservingFormEdits())}_refreshComboAfterLoad(t,e,s=!0){const a=this.shadowRoot;for(const r of Array.isArray(t)?t:[t]){const i=a&&a.getElementById(r);if(i&&a.activeElement===i){if(s){i._wdSyntheticInput=!0;try{i.dispatchEvent(new Event("input",{bubbles:!0}))}finally{i._wdSyntheticInput=!1}}return}}this._isActiveEntry(e)&&this._render()}_ensureCatalogList(t,e,s=!1){if(this._onlineEnabled()){if(t==="store_brand"){this._ensureBrandCandidates(e);return}if(t==="store_search"){this._ensureBrandCandidates(e),s&&String(e||"").trim().length>=2&&this._ensureStoreTypeDevices();return}if(t==="store_model"){const a=String(this._editedOpts().store_brand||"").trim();if(!a)return;(this._catalog.forBrand!==a||this._catalog.devices===void 0)&&(this._catalog.forBrand=a,this._catalog.devices=null,this._loadCatalogDevices(a))}}}_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(a=>e.startsWith(a))||(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,a)=>String(s.brand||"").localeCompare(String(a.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 a=await this._ws({type:`${w}/store_list_brands`,entry_id:s.entry_id,query:t||null,include_pending:!this._catalog.approvedOnly}),r=a&&a.items||[];t?(this._mergeBrandCandidates(r),(this._catalog.brandPrefixes||(this._catalog.brandPrefixes=[])).push(t)):(this._catalog.brands=r,this._catalog.brandsFull=!0)}catch{Array.isArray(this._catalog.brands)||(this._catalog.brands=[]),e=!0}this._entityListCache.store_brand=(this._catalog.brands||[]).map(a=>a.brand).filter(Boolean),this._syncStoreSearchCandidates(),this._refreshComboAfterLoad(["wd-store-brand","wd-store-q"],s.entry_id,!e)}}_syncStoreSearchCandidates(){this._entityListCache=this._entityListCache||{};const t=new Map,e=[];for(const a of this._catalog.typeDevices||[]){const r=String(a&&a.model||"").trim();if(!r)continue;const i=a.brand?`${r} \xB7 ${a.brand}`:r;t.has(i)||(t.set(i,a),e.push(i))}this._catalog.modelIndex=t;const s=(this._catalog.brands||[]).map(a=>a.brand).filter(Boolean);this._entityListCache.store_search=s.concat(e)}_dropModelCandidates(){this._catalog.typeDevices=void 0,this._catalog.typeDevicesFor=null,this._catalog.modelIndex=new Map,this._entityListCache&&delete this._entityListCache.store_search}_ensureStoreTypeDevices(){const t=this._devices[this._selIdx];if(!t||!this._onlineEnabled())return;const e=this._storeApplianceType();this._catalog.typeDevicesFor===e&&this._catalog.typeDevices!==void 0||(this._catalog.typeDevicesFor=e,this._catalog.typeDevices=null,this._ws({type:`${w}/store_search_devices`,entry_id:t.entry_id,query:null,appliance_type:e,include_pending:!0}).then(s=>{this._catalog.typeDevicesFor===e&&(this._catalog.typeDevices=s&&s.items||[])}).catch(()=>{this._catalog.typeDevicesFor===e&&(this._catalog.typeDevices=[])}).finally(()=>{this._syncStoreSearchCandidates(),this._refreshComboAfterLoad(["wd-store-q"],t.entry_id,!!(this._catalog.typeDevices||[]).length)}))}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:`${w}/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(),a=(this._opts.store_model||"").trim();if(!s||!a){e.profiles=[],this._modal===e&&this._render();return}try{const r=await this._ws({type:`${w}/store_get_device_profiles`,entry_id:t.entry_id,brand:s,model:a,appliance_type:this._opts.device_type||""});if(this._modal!==e)return;e.profiles=r&&r.items||[],e.deviceId=r&&r.device_id||null,e.storeUnreachable=!!(r&&r.error==="store_unreachable")}catch{this._modal===e&&(e.profiles=[])}this._modal===e&&this._render()}_htmlAutomations(){const t=this._deviceAutomations||[],e=t.length?t.map(r=>`<span class="wd-auto-pill"><a class="wd-auto-pill-link" href="/config/automation/edit/${encodeURIComponent(r.id)}" target="_top" title="${this._t("hdr.automation_open",{},"Open in the automation editor")}">\u{1F517} ${d(r.name)}${r.enabled?"":` <span style="opacity:.6">${this._t("lbl.automation_off",{},"(off)")}</span>`}</a><button type="button" class="wd-auto-pill-x" data-action="auto-delete" data-autoid="${d(r.id)}" data-autoname="${d(r.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:[],a=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:vt("<code>ha_washdata_cycle_started</code>"),end:vt("<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>
|
|
${a}
|
|
<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 a=e.devices||{};for(const n of Object.values(a))if((n.config_entries||[]).includes(t)){s=n.id;break}const r=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 i=r&&r.automation||[],o=e.states||{};this._deviceAutomations=i.map(n=>{const l=o[n]&&o[n].attributes||{};return{entity_id:n,id:l.id,name:l.friendly_name||n,enabled:o[n]?o[n].state==="on":!0}}).filter(n=>n.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,a=t==="started"?"ha_washdata_cycle_started":"ha_washdata_cycle_ended",r=e.title||"WashData",i={alias:t==="started"?this._tText("msg.automation_alias_started",{device:r},"{device}: cycle started"):this._tText("msg.automation_alias_finished",{device:r},"{device}: cycle finished"),description:t==="started"?this._tText("msg.automation_desc_started",{device:r},"Runs when a {device} cycle starts. Add your actions (notify, lights, ...)."):this._tText("msg.automation_desc_finished",{device:r},"Runs when a {device} cycle finishes. Add your actions (notify, lights, ...)."),mode:"single",trigger:[{platform:"event",event_type:a,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,i),this._navigate("/config/automation/edit/"+o)):this._navigate("/config/automation/edit/new")}catch(n){this._showToast(this._tText("msg.toast_automation_failed",{error:n.message||n},"Could not create automation: "+(n.message||n)),"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 a={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},r="washdata_"+Date.now().toString(36);if(!e||!e.callApi){this._showToast(this._t("msg.toast_no_automation",{},"Cannot create automation here"),"error");return}let i=!1;try{await e.callApi("POST","config/automation/config/"+r,a),i=!0,await this._ws({type:`${w}/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/"+r)}catch(o){const n=o.message||o;if(!i){this._showToast(this._tText("msg.toast_convert_failed",{error:n},"Convert failed: "+n),"error");return}let l=null;try{const c=await this._ws({type:`${w}/get_options`,entry_id:t.entry_id}),p=c&&c.options||{},_=Array.isArray(p.notify_actions)?p.notify_actions:[];l=_.length>0,this._isActiveEntry(t.entry_id)&&(this._opts={...this._opts,notify_actions:_})}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/"+r);return}if(l===!0){try{await e.callApi("DELETE","config/automation/config/"+r),this._showToast(this._tText("msg.toast_convert_rolled_back",{error:n},"Migration failed and was rolled back (no automation left behind): "+n),"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",a=l=>!(l.id==="ml_training"||e&&l.notDeviceTypes&&l.notDeviceTypes.includes(e)||e&&l.onlyDeviceTypes&&!l.onlyDeviceTypes.includes(e)||s&&!this._secHasBasicFields(l)),r=yt.find(l=>l.id===this._settingsSec&&a(l))||yt.find(l=>a(l))||yt[0],i=r.intro||this._t("section."+r.id+".intro",{},"")?`<p class="wd-sec-intro">${d(this._t("section."+r.id+".intro",{},r.intro||""))}</p>`:"",o="";if(r.id==="notifications"){const l=`<p class="wd-info" style="margin-bottom:16px">${this._t("msg.notify_services_hint",{entity:vt("<code>notify.<name></code>"),vars:vt("<code>"+d(Pe)+"</code>")},"Use {entity} service IDs (comma-separated for multiple). Template variables: {vars}.")}</p>`,c=r.groups.map(_=>{const f=(_.fields||[]).filter(u=>this._settingFieldVisible(u)).map(u=>this._renderField(u,t)).filter(Boolean).join("");return f?`<div class="wd-subhead">${d(this._t("setting_group."+ge(_.sub)+".label",{},_.sub))}</div><div class="wd-form-grid">${f}</div>`:""}).join("");return`${s?"":this._htmlAutomations()}${l}${c}`}if(r.groups){const l=r.groups.map(c=>{const p=c.sub?`<div class="wd-subhead">${d(this._t("setting_group."+ge(c.sub)+".label",{},c.sub))}</div>`:"",_=(c.fields||[]).filter(f=>this._settingFieldVisible(f)).map(f=>this._renderField(f,t)).filter(Boolean).join("");return _?`${p}<div class="wd-form-grid">${_}</div>`:""}).join("");return`${i}${o}${l}`}const n=(r.fields||[]).filter(l=>this._settingFieldVisible(l)).map(l=>this._renderField(l,t)).filter(Boolean).join("");return`${i}${o}<div class="wd-form-grid">${n}</div>`}_htmlSettingsSearch(t,e){const s=this._opts&&this._opts.device_type||"",a=yt.filter(n=>!(n.id==="ml_training"||s&&n.notDeviceTypes&&n.notDeviceTypes.includes(s)||s&&n.onlyDeviceTypes&&!n.onlyDeviceTypes.includes(s))),r=n=>{const l=n.key?`${this._tText("setting."+n.key+".label",{},"")} ${n.doc!=null?this._tText("setting."+n.key+".doc",{},""):""}`:"";return`${n.label||""} ${n.key||""} ${n.doc||""} ${n.hint||""} ${l}`.toLowerCase().includes(e)};let i="",o=0;for(const n of a){const c=(n.fields||(n.groups||[]).flatMap(_=>_.fields||[])).filter(r);if(!c.length)continue;const p=c.map(_=>this._renderField(_,t)).filter(Boolean).join("");p&&(o+=c.length,i+=`<div class="wd-subhead">${d(this._t("section."+n.id+".label",{},n.label))}</div><div class="wd-form-grid">${p}</div>`)}return o?i:`<p class="wd-info" style="padding:12px">${this._t("msg.no_settings_match",{q:e},'No settings match "{q}"')}</p>`}_sugCountForDevice(t){return t?Array.isArray(t.suggestion_keys)?t.suggestion_keys.length:t.suggestions_count||0:0}_htmlSettingsSugOnly(t){const e=new Set((this._suggestions||[]).map(i=>i.key));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||"",a=yt.filter(i=>!(i.id==="ml_training"||s&&i.notDeviceTypes&&i.notDeviceTypes.includes(s)||s&&i.onlyDeviceTypes&&!i.onlyDeviceTypes.includes(s)));let r="";for(const i of a){const n=(i.fields||(i.groups||[]).flatMap(c=>c.fields||[])).filter(c=>e.has(c.key));if(!n.length)continue;const l=n.map(c=>this._renderField(c,t)).filter(Boolean).join("");l&&(r+=`<div class="wd-subhead">${d(this._t("section."+i.id+".label",{},i.label))}</div><div class="wd-form-grid">${l}</div>`)}return r||`<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],a=s&&s.entry_id,r=yt.find(n=>n.id==="ml_training"),i=r?(r.fields||[]).map(n=>this._renderField(n,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,a)}
|
|
|
|
<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">${i}</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 reloads the integration. Entities may briefly show as unavailable.")}</p>
|
|
</div>
|
|
|
|
${this._htmlMlLearnedSection(e)}
|
|
`}_htmlMlStatusSection(t,e){const s=e&&this._busy.has("ml-train-now:"+e)||t&&t.running,a=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 r=Object.keys(t.on_device_models||{}).length,i=r?`<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:r},"(fine-tuned models: "+r+")")}</span>`:`<span style="color:var(--secondary-text-color)">${this._t("ml.builtin_models",{},"\u25CF Using built-in models")}</span>`,o=t.cycle_count||0,n=t.min_cycles||0,l=o>=n,c=n>0?Math.min(100,Math.round(o/n*100)):100,p=l?"var(--success-color,#4caf50)":"var(--warning-color,#ff9800)",_=Math.max(0,n-o),f=l?this._t("msg.enough_data",{current:o,min:n},`Enough data to learn from (${o}/${n} cycles).`):this._t("msg.collecting_data",{need:_,current:o,min:n},`Collecting data. Cycles still needed before fine-tuning can start: ${_} (${o}/${n}).`),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:${p}"></div></div>`,b=t.last_trained?et(t.last_trained):this._tText("ml.never",{},"never"),v=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>${a}
|
|
</div>
|
|
<div style="margin:8px 0 2px">${i}</div>
|
|
<p class="wd-info" style="margin:0">${f}</p>
|
|
${u}
|
|
<p class="wd-info" style="margin:0">${this._t("lbl.last_checked",{},"Last checked:")} <strong>${d(b)}</strong> \xB7 ${v}</p>
|
|
</div>`}_htmlMlLearnedSection(t){if(!t)return"";const e=t.on_device_models||{},s=Object.keys(e),a=this._busy.has("ml-revert-models"),r=t.last_run||{};let i;if(s.length){const o=s.map(l=>{const c=e[l]||{},p=c.trained_at?et(c.trained_at):this._tText("ml.trained_unknown",{},"at an unknown time");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(c.label_key?this._t(c.label_key,{},c.label||l):c.label||l)}${this._mlTrendBadge(c.trend)}</div>
|
|
<div class="wd-info" style="font-size:.8em;margin:0">${d(c.blurb_key?this._t(c.blurb_key,{},c.blurb||""):c.blurb||"")} \xB7 ${this._t("ml.fine_tuned_at",{when:p},"fine-tuned {when}")}</div>
|
|
${this._mlLastRunLine(r[l])}
|
|
</div>
|
|
${this._mlQualityChip(c)}
|
|
</div>`}).join(""),n=this._canEdit()?`<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="ml-revert-models" ${a?"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">${a?'<span class="wd-spin"></span> '+this._t("status.resetting",{},"Resetting\u2026"):this._t("btn.reset_to_builtin",{},"Reset to built-in models")}</button>`:"";i=`<div>${o}</div>${n}`}else{const o=Object.keys(r).map(n=>this._mlLastRunLine(r[n])).filter(Boolean).join("");i=`<p class="wd-info" style="margin:0">${this._t("msg.no_fine_tuned",{},"Nothing fine-tuned yet - WashData is using its built-in models.")}</p>${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>
|
|
${i}
|
|
</div>`}_mlQualityChip(t){let e=0,s="",a=t.metric_key?this._tText(t.metric_key,t.metric_params||{},t.metric||""):t.metric||"";if(t.model_mae!=null&&t.naive_mae!=null&&t.naive_mae>0){const i=Math.max(0,(t.naive_mae-t.model_mae)/t.naive_mae);e=Math.min(1,i)*100,s=i>=.5?this._t("ml.fit_strong",{},"Strong"):i>=.2?this._t("ml.fit_good",{},"Good"):this._t("ml.fit_slight",{},"Slight"),a=this._tText("ml.better_than_baseline",{pct:(i*100).toFixed(0),metric:a},`${(i*100).toFixed(0)}% better than the baseline estimate (${a})`)}else return"";Number.isInteger(t.held_out_cycles)&&(a+=" \xB7 "+this._tText("ml.held_out_count",{count:t.held_out_cycles},`measured on ${t.held_out_cycles} held-out cycles`));const r=e>=70?"var(--success-color,#4caf50)":e>=40?"var(--warning-color,#ff9800)":"var(--secondary-text-color)";return`<div title="${d(a)}" style="text-align:right;flex:0 0 auto">
|
|
<div style="font-size:.8em;font-weight:600;color:${r}">${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:${r}"></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 \u2013 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>`:""}_mlSkipReason(t){const e={insufficient_rows:"Not enough usable cycles yet: {count} labelled, clean cycles so far.",holdout_too_small:"Too few cycles to test a new model fairly: {held_out} could be set aside for testing, {min} are needed.",not_better_than_naive:"The new model was not clearly better than the simple estimate (error {model} vs {naive}).",not_better_than_incumbent:"The new model was not better than the one in use (error {model} vs {incumbent}), so the current one was kept.",fit_failed:"Training failed: {error}",unknown:"Nothing new was learnt."},s=Object.prototype.hasOwnProperty.call(e,t&&t.reason_code)?t.reason_code:"unknown",a=t&&t.reason_params||{},r=s==="insufficient_rows"?{count:Number(a.cycles)||0}:{...a};return[s,this._tText(`ml.skip_reason.${s}`,r,e[s])]}_mlLastRunLine(t){if(!t||t.promoted!==!1)return"";const[e,s]=this._mlSkipReason(t),a=t.ts?et(t.ts):this._tText("ml.trained_unknown",{},"at an unknown time");return`<div class="wd-info wd-ml-last-run" data-reason="${d(e)}" style="font-size:.8em;margin:4px 0 0">${this._t("ml.last_run_not_promoted",{when:a,reason:s},"Last check ({when}): {reason}")}</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"],["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","Quiet level between tumble pulses; only takes effect above the Stop Threshold","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 program's average duration a cycle must reach before Smart Termination may end it early; lower it if run times vary","advanced"],["anti_crease_finalize_ratio","Anti-Crease Finalize Ratio","","Fraction of the program's average duration a cycle must reach before the anti-crease tail may finish it; lower it on dryers, keep washers at the default","advanced"],["curve_preroll_seconds","Curve Pre-roll","s","Seconds of readings from aborted start attempts carried into the front of a cycle's curve; 0 = off","timing"],["profile_match_min_duration_ratio","Min Duration Ratio","","Minimum cycle length as a fraction of the profile's duration; default 0.1 (10%)","matching"],["profile_match_max_duration_ratio","Max Duration Ratio","","Maximum cycle length as a fraction of the profile's duration; default 1.8 (180%)","matching"]]}_pgFieldVal(t,e){const s=e||{};if(s[t]!==void 0)return s[t];const a=this._pgEffective||{};if(a[t]!==void 0&&a[t]!==null)return a[t];const r=this._opts||{};if(r[t]!==void 0&&r[t]!==null)return r[t];const i=wt[t]||{};if(i.def!==void 0)return i.def;const o=this._constants&&this._constants.pgMatchDefaults||{};return o[t]!==void 0?o[t]:te[t]!==void 0?te[t]:""}async _pgFetchSettings(t,e=!0){try{const s=await this._ws({type:`${w}/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(a=>a.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:{}}async _pgFetchSuggestions(t){try{const e=await this._ws({type:`${w}/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 a=this._pgStagedVal(s);if(a==null)continue;const r=t[s];r!=null&&this._pgSameVal(a,r)||e.push(s)}return e}_pgSameVal(t,e){if(typeof t=="boolean"||typeof e=="boolean")return!!t==!!e;const s=parseFloat(t),a=parseFloat(e);return!isNaN(s)&&!isNaN(a)?Math.abs(s-a)<1e-9:String(t)===String(e)}_pgIsPublishable(t){return Array.isArray(this._pgPublishable)?this._pgPublishable.includes(t):!!wt[t]}_htmlPgControlPanel(){const t=this._pgChangedKeys(),e=this._canEdit(),s=!!this._pgEffective,a=t.filter(b=>this._pgIsPublishable(b)),r=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, showing defaults.")}</span>`,i=this._pgPresets||[],o=`<option value="">${d(this._tText("lbl.pg_preset_none",{},"Select a preset\u2026"))}</option>`+i.map(b=>`<option value="${d(b.name)}" ${this._pgPresetSel===b.name?"selected":""}>${d(b.name)}</option>`).join(""),n=!!this._pgPresetSel,l=this._pgPresetAtLimit((this._pgPresetName||"").trim()),c=`<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._tText("lbl.pg_preset",{},"Playground preset"))}">${o}</select>
|
|
<button class="wd-btn wd-btn-sm" data-action="pg-preset-load" ${n?"":"disabled"} title="${d(this._tText("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" ${n?"":"disabled"} aria-label="${d(this._tText("btn.pg_preset_delete",{},"Delete preset"))}" title="${d(this._tText("btn.pg_preset_delete",{},"Delete preset"))}">\u{1F5D1}</button>`:""}
|
|
</div>`,p=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._tText("lbl.pg_preset_name",{},"New preset name"))}" aria-label="${d(this._tText("lbl.pg_preset_name",{},"New preset name"))}">
|
|
<button class="wd-btn wd-btn-sm" data-action="pg-preset-save" ${(this._pgPresetName||"").trim()&&!l?"":"disabled"} title="${d(this._tText("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)${l?"":";display:none"}">${this._t("msg.pg_preset_limit",{n:this._pgPresetLimit},"Preset limit reached ({n})")}</span>
|
|
</div>`:"",_=e&&a.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:a.length},"Publish "+a.length+" to integration")}</button>`:"",f=Object.keys(this._pgSuggClassic||{}).length,u=f>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:f},"Load suggested ("+f+")")}</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">${r}</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}
|
|
${_}
|
|
</div>
|
|
${c}
|
|
${p}
|
|
</div>`}_pgPresetAtLimit(t){const e=this._pgPresets||[];return this._pgPresetLimit>0&&e.length>=this._pgPresetLimit&&!e.some(s=>s.name===t)}_pgApplyPresetValues(t){this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={};const e=this._pgEffective||{};for(const[s,a]of Object.entries(t||{})){if(a==null)continue;const r=e[s];r!=null&&this._pgSameVal(a,r)||this._pgSetStaged(s,a)}}_pgLoadPreset(){const t=(this._pgPresets||[]).find(e=>e.name===this._pgPresetSel);t&&(this._pgApplyPresetValues(t.values),this._showToast(this._tText("toast.pg_preset_loaded",{name:t.name},`Preset "${t.name}" loaded`)),this._render(),requestAnimationFrame(()=>this._pgDrawCanvas()))}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._tText("msg.pg_preset_overwrite",{name:e},`Overwrite the preset "${e}"?`))||(await this._busyRun("pg-preset-save",async()=>{try{const s=await this._ws({type:`${w}/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._tText("toast.pg_preset_saved",{name:e},`Preset "${e}" saved`))}catch(s){this._showToast(this._tText("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._tText("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:`${w}/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(a=>a.name!==e),this._pgPresetSel=""}catch(s){this._showToast(this._tText("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._tText("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(){const t=this._pgSuggClassic||{},e=this._pgEffective||{};let s=0;for(const[a,r]of Object.entries(t)){if(r==null)continue;const i=e[a];i!=null&&this._pgSameVal(r,i)||(this._pgSetStaged(a,r),s++)}s===0?this._showToast(this._t("msg.pg_sugg_none",{},"All suggestions already match the current settings")):this._showToast(this._tText("toast.pg_sugg_loaded",{n:s},`Staged ${s} suggested value(s) - run the Playground to compare`)),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 a=this._t("setting."+t+".label",{},t);confirm(this._tText("msg.pg_publish_one_confirm",{label:a,value:s},`Save ${a} = ${s} to this device's settings?`))&&(await this._busyRun("pg-publish-"+t,async()=>{try{if(await this._ws({type:`${w}/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(r){this._showToast(this._tText("msg.toast_save_failed",{error:r.message||r},"Save failed: "+(r.message||r)),"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||[],a=e.map(u=>{const b=u.profile_name||u.matched_profile||this._t("lbl.unlabelled",{},"Unlabelled"),v=u.duration?` \xB7 ${Math.round(u.duration/60)} ${this._tText("lbl.timer_min",{},"min")}`:"",T=u.start_time?` \xB7 ${et(u.start_time)}`:"";return`<option value="${d(u.id)}" ${this._pgCycleId===u.id?"selected":""}>${d(b+v+T)}</option>`}).join(""),r=`<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(""),i=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" ${i?"disabled":""}>${a||'<option value="">\u2014</option>'}</select></div>
|
|
<div class="wd-field" style="min-width:160px;margin:0" title="${d(this._tText("msg.pg_compare_profile_tip",{},"Only picks the profile drawn over the graph. The simulation always matches the cycle on its own, as the live integration does."))}"><label for="wd-pg-prof-sel">${this._t("lbl.pg_compare_profile",{},"Compare with profile")}</label><select id="wd-pg-prof-sel" ${i?"disabled":""}>${r}</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" ${i?"disabled":""} style="min-width:72px">\u25B6 ${this._t("btn.run",{},"Run")}</button>
|
|
${i?`<button class="wd-btn" data-action="pg-cancel-run" style="min-width:72px">\u2715 ${this._t("btn.cancel",{},"Cancel")}</button>`:""}
|
|
</div>
|
|
</div>`,n=i?`<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&&!i?`<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_empty3",{},"Pick a cycle above and press Run to simulate it. Then hover to read values, scroll or pinch to zoom, and drag to pan.")}</div>
|
|
</div>`:"",c=`${n}<div class="wd-pg-canvas-wrap" style="position:relative"><canvas id="wd-pg-canvas" role="img" aria-label="${d(this._t("lbl.aria_playground_chart3",{},"Interactive cycle power graph: hover to read time/power, scroll or pinch to zoom, drag to pan"))}"></canvas>${l}</div>`,p=this._htmlPgStrip(),_=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>`:"",f=`${o}${c}${p}
|
|
<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",{},"Try settings on your real cycle data. Nothing here changes your live settings until you apply it.")}</p>
|
|
${_}
|
|
${f}
|
|
${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(([r,i])=>`<button role="tab" aria-selected="${t===r}" class="wd-pg-subtab${t===r?" active":""}" data-action="pg-analysis-tab" data-subtab="${r}">${d(i)}</button>`).join("")}
|
|
</div>`,a=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>
|
|
${a}
|
|
</section>`}_htmlPgParamRows(){const t=this._pgOverrideFields(),e=new Set(["start_threshold_w","stop_threshold_w"]),s={detection:"#2a78d6",timing:"#1baf7a",advanced:"#eda100",matching:"#a05cd6"},a={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 r="";const i=t.map(([o,n,l,c,p,_])=>{const f=this._t("setting."+o+".label",{},n),u=this._pgFieldVal(o,{});let b;o==="start_threshold_w"?b=this._pgThreshStart??u:o==="stop_threshold_w"?b=this._pgThreshStop??u:b=this._pgParamOverrides[o]??u;const v=_==="bool",T=e.has(o),C=l||"",x=s[p]||"#2a78d6",$=a[p]||"";let L="";p&&p!==r&&(L=`<div style="display:flex;align-items:center;gap:8px;margin:${r?"12px":"2px"} 0 5px">
|
|
<div style="width:3px;height:14px;border-radius:2px;background:${x};flex-shrink:0"></div>
|
|
<span style="font-size:.7em;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--secondary-text-color)">${d($)}</span>
|
|
</div>`,r=p);const D=this._pgStagedVal(o),B=D!=null&&!(u!==""&&u!==null&&u!==void 0&&this._pgSameVal(D,u)),U=this._canEdit()&&B&&this._pgIsPublishable(o)?`<button class="wd-pg-pub" data-action="pg-publish-one" data-pgkey="${d(o)}" aria-label="${d(this._tText("btn.pg_publish_one",{label:f},"Publish "+f+" to the integration"))}" title="${d(this._tText("btn.pg_publish_one",{label:f},"Publish "+f+" to the integration"))}">\u2191</button>`:'<span class="wd-pg-pub-slot" aria-hidden="true"></span>';return`${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(f)}${T?` <span style="color:${x};font-size:.85em" title="${d(this._t("lbl.pg_drag_hint",{},"Drag line on graph"))}">\u2195</span>`:""}</div>
|
|
${c?`<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${d(this._t("pg_desc."+o,{},c))}</div>`:""}
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
|
${v?`<input class="wd-pg-param-chk" type="checkbox" data-pgkey="${d(o)}" data-pgtype="bool" aria-label="${d(f)}" ${b?"checked":""} style="width:18px;height:18px;margin:1px 27px 0 27px">`:`<input class="wd-pg-param-inp" type="text" inputmode="decimal" data-pgkey="${d(o)}" value="${b!==""?d(String(b)):""}" placeholder="${u!==""?d(String(u)):""}" aria-label="${d(f)}" style="width:72px">`}
|
|
${C?`<span style="font-size:.75em;color:var(--secondary-text-color);min-width:14px">${d(C)}</span>`:""}
|
|
${U}
|
|
</div>
|
|
</div>`}).join("");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()}
|
|
${i}
|
|
<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)"},a=Array.isArray(t.alerts)?t.alerts:[],r=a.length?a.map(f=>`<div class="wd-pg-alert" style="border-left-color:${s[f.severity]||s.info}">
|
|
<span style="font-weight:600">${d(this._pgAlertLabel(f.code))}</span>
|
|
<div style="font-size:.78em;color:var(--secondary-text-color)">${d(f.detail_key?this._tText(f.detail_key,f.detail_params||{},f.detail||""):f.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>`,i=e.termination_reason?String(e.termination_reason):"\u2014",o=e.final_duration_s?Math.round(e.final_duration_s/60)+" min":"\u2014",n=e.projected_energy_wh!=null?e.projected_energy_wh>=1e3?it(e.projected_energy_wh/1e3,2)+" kWh":it(Math.round(e.projected_energy_wh),0)+" Wh":"\u2014",l=(t.events||[]).some(f=>String(f.type||"").startsWith("notify_")),c=(f,u)=>`<div class="wd-pg-outcome-item"><div class="wd-pg-outcome-val">${d(u)}</div><div class="wd-pg-outcome-lbl">${d(f)}</div></div>`,_=this._pgDetailSig!=null&&this._pgDetailSig!==this._pgOverrideSig(this._pgCurrentOverride())?`<div class="wd-pg-stale wd-info" role="status" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 8px;color:var(--warning-color,#ff9800)">
|
|
<span>\u26A0 ${this._t("msg.pg_outcome_stale",{},"Settings changed since this simulation ran. It still shows the old result.")}</span>
|
|
<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="pg-rerun" ${this._pgDetailBusy?"disabled":""}>\u25B6 ${this._t("btn.pg_rerun",{},"Update simulation")}</button>
|
|
</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">
|
|
${c(this._t("lbl.pg_ended",{},"Ended"),i)}
|
|
${c(this._t("lbl.duration",{},"Duration"),o)}
|
|
${c(this._t("lbl.pg_proj_energy",{},"Proj. energy"),n)}
|
|
${e.label_reason?c(this._t("lbl.pg_autolabel",{},"Auto-label"),this._pgLabelVerdict(e).short):""}
|
|
</div>
|
|
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">${r}</div>
|
|
${l?`<p class="wd-info" style="margin:8px 0 0;font-size:.72em">\u{1F514} ${this._t("msg.pg_notify_partial",{},"Markers show start, almost-done, finish, milestone and quiet-hours notifications only. Live updates, unload reminders and overrun alerts are not simulated.")}</p>`:""}
|
|
</div>`}_pgLabelVerdict(t){if(t.would_label&&t.label_profile)return{ok:!0,short:String(t.label_profile),tip:this._tText("msg.pg_label_yes",{name:t.label_profile},`Would be labelled "${t.label_profile}" at cycle end`)};const s={no_winner:this._tText("pg_label_reason.no_winner",{},"No program fit"),below_floor:this._tText("pg_label_reason.below_floor",{},"Below the learning confidence"),ambiguous:this._tText("pg_label_reason.ambiguous",{},"Flagged ambiguous"),margin:this._tText("pg_label_reason.margin",{},"Too close to the runner-up"),unknown_profile:this._tText("pg_label_reason.unknown_profile",{},"Program no longer exists")}[t.label_reason]||this._tText("pg_label_reason.not_evaluated",{},"Not evaluated");return{ok:!1,short:this._tText("lbl.pg_label_review",{},"Asks for review"),tip:this._tText("msg.pg_label_no",{reason:s},`Would ask for review: ${s}`)}}_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")}[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,a=`<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="${St}" 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>`,r=t?this._htmlPgBatchBar():"",i=`<p class="wd-sec-intro" style="margin:0 0 8px">${this._t("msg.pg_history_intro2",{},"Replay recent cycles through the real detector and matcher with the settings above. Click a row to load it in the graph; change a setting to compare before and after.")}</p>`;if(!e||!Array.isArray(e.rows))return`${i}${a}${this._htmlPgRecentRuns("pg_history")}${r}${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=(b,v,T)=>`<span class="wd-pg-diffbadge" style="background:${v}22;color:${v}">${b} ${d(T)}</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 n=e.summary||{},l=`<div style="font-size:.82em;color:var(--secondary-text-color);margin-bottom:8px">${this._t("msg.pg_history_summary",{detected:n.detected,correct:n.match_correct,total:n.cycles},`${n.detected}/${n.cycles} detected \xB7 ${n.match_correct} matched correctly`)}</div>`,c=u=>(this._cycles||[]).find(b=>b.id===u),p={};(e.baseline_rows||[]).forEach(u=>{p[u.cycle_id]=u});const _=e.rows.map(u=>{const b=c(u.cycle_id),v=b&&b.start_time?et(b.start_time):u.start_time?et(u.start_time):(u.cycle_id||"").slice(0,8),T=u.match_correct===!0?"\u2713":u.match_correct===!1?"\u2717":"\u2014",C=u.match_correct===!0?"var(--success-color,#4caf50)":u.match_correct===!1?"var(--error-color,#f44336)":"var(--secondary-text-color)",x=u.matched_profile||this._t("lbl.unlabelled",{},"Unlabelled"),$=u.duration_s?Math.round(u.duration_s/60)+"m":"\u2014",L=u.overrun_ratio!=null?` (${Math.round(u.overrun_ratio*100)}%)`:"",D=u.label_reason?this._pgLabelVerdict(u):null,B=D?` <span class="wd-pg-label-glyph" title="${d(D.tip)}" style="color:${D.ok?"var(--success-color,#4caf50)":"var(--warning-color,#ff9800)"}">${D.ok?"\u{1F3F7}":"?"}</span>`:"",U=u.alerts&&u.alerts.length?` <span title="${d(u.alerts.join(", "))}" style="color:var(--warning-color,#ff9800)">\u26A0</span>`:"",z=p[u.cycle_id];let M="";return z&&(z.match_correct!==!0&&u.match_correct===!0?M=`<span style="color:var(--success-color,#4caf50)">\u25B2 ${this._t("lbl.pg_fixed",{},"fixed")}</span>`:z.match_correct===!0&&u.match_correct!==!0?M=`<span style="color:var(--error-color,#f44336)">\u25BC ${this._t("lbl.pg_broke",{},"broke")}</span>`:String(z.termination_reason)!==String(u.termination_reason)&&(M=`<span style="color:var(--warning-color,#ff9800)">${d(String(z.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(v)}</td>
|
|
<td><span style="color:${C};font-weight:700">${T}</span> ${d(x)}${B}</td>
|
|
<td>${d(String(u.termination_reason||"\u2014"))}</td>
|
|
<td style="font-variant-numeric:tabular-nums">${$}${L}${U}</td>
|
|
${e.diff?`<td>${M}</td>`:""}
|
|
</tr>`}).join(""),f=`<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>${_}</tbody></table>`;return`${i}${a}${this._htmlPgRecentRuns("pg_history")}${o}${l}${this._htmlPgInSampleNote()}${f}`}_htmlPgInSampleNote(){return`<p class="wd-pg-insample wd-info" style="font-size:.78em;margin:0 0 8px">${this._t("msg.pg_in_sample_note",{},"These cycles helped build the profiles they are matched against, so results are optimistic: expect slightly fewer correct matches on new cycles.")}</p>`}_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 a=s.getElementById("wd-pg-batch-fill"),r=s.getElementById("wd-pg-batch-count"),i=e?Math.round(100*t/e):0;a&&(a.style.width=i+"%"),r&&(r.textContent=String(t))}async _pgRunHistory(){const t=this._devices[this._selIdx];if(!t)return;if(!(this._cycles||[]).length){this._showToast(this._t("msg.no_cycles_selected",{},"No cycles available."),"error");return}const e=Math.max(1,Math.min(St,this._pgSimCycles||20)),s=this._pgCurrentOverride();this._pgBatchProgress={done:0,total:e},this._busy.add("pg-history"),this._render();try{const a=await this._ws({type:`${w}/start_playground_history`,entry_id:t.entry_id,count:e,settings_override:s});if(this._pgHistoryTaskId=a&&a.task_id,this._pgNeedsRestart=!1,!this._pgHistoryTaskId)throw new Error("no task id");this._addProvisionalTask(this._pgHistoryTaskId,"pg_history",t.entry_id,e),this._tasksSubscribed||this._pgPollTask(this._pgHistoryTaskId)}catch(a){this._busy.delete("pg-history"),this._pgBatchProgress=null,this._pgRunFailed(a),this._render()}}_pgRunFailed(t){if(this._pgIsUnknownCmd(t)){this._pgNeedsRestart=!0;return}if((t&&(t.code||t.error&&t.error.code)||"")==="task_busy"){this._showToast(this._t("msg.pg_task_busy",{},"A Test-on-history or Optimize run is already in progress for this device. Wait for it or cancel it."),"error");return}this._showToast(this._tText("msg.toast_error",{error:t.message||t},"Error: "+(t.message||t)),"error")}_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],["end_lag",this._t("lbl.pg_obj_endlag",{},"End delay (median)"),!0],["early_end_rate",this._t("lbl.pg_obj_early",{},"Early-end rate"),!0],["split_rate",this._t("lbl.pg_obj_split",{},"Split rate"),!0]]}_htmlPgSweepMode(){const t=this._busy.has("pg-sweep"),s=this._pgOverrideFields().filter(([,,,,,n])=>n!=="bool").map(([n,l])=>`<option value="${n}" ${this._pgSweepParam===n?"selected":""}>${d(this._t("setting."+n+".label",{},l))}</option>`).join(""),a=this._pgSweepObjectives().map(([n,l])=>`<option value="${n}" ${this._pgSweepObjective===n?"selected":""}>${d(l)}</option>`).join(""),r=`<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>`,i=`<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">${a}</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>
|
|
<div class="wd-field" style="margin:0"><label>${this._t("lbl.pg_sweep_cycles",{},"Recent cycles")}</label><input type="number" id="wd-pg-sw-n" value="${this._pgSimCycles}" min="1" max="${St}" style="width:56px"></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`${r}${i}${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(x=>x[0]===t.objective),s=t.lower_is_better!=null?!!t.lower_is_better:e?e[2]:!1,a=t.points.filter(x=>x.metric!=null).map(x=>x.metric);if(!a.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 r=Math.min(...a),i=Math.max(...a),o=t.best_value!==void 0,n=s?r:i,l=o?t.best_value:(t.points.find(x=>x.metric===n)||{}).value,c=o?t.best_metric:n,p=!!t.keep_current,_=(x,$)=>x!=null&&$!=null&&Math.abs(Number(x)-Number($))<1e-6,f=x=>x==null?"-":t.objective==="median_overrun"?Math.round(x*100)+"% off":t.objective==="end_lag"?`${it(x/60,1)} ${this._tText("lbl.timer_min",{},"min")}`:Math.round(x*100)+"%",u=t.points.map(x=>{const $=i>r&&x.metric!=null?(x.metric-r)/(i-r):x.metric!=null?1:0,L=!p&&_(x.value,l),D=_(x.value,t.current_value),B=L?"var(--success-color,#4caf50)":"var(--primary-color)",U=x.guarded?` <span class="wd-pg-sweep-guarded" title="${d(this._tText("msg.pg_sweep_guarded",{},"Ends more cycles early, splits more of them, or misses cycles the current setting catches, so it is never recommended."))}" style="color:var(--warning-color,#ff9800)">\u26A0</span>`:"";return`<div class="wd-pg-sweep-row" data-value="${d(String(x.value))}" 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(x.value))}${D?" \u25C0":""}${U}</span>
|
|
<div style="flex:1;height:16px;border-radius:4px;background:var(--secondary-background-color);overflow:hidden"><div style="height:100%;width:${Math.round((x.metric!=null?.15+.85*$:0)*100)}%;background:${B};border-radius:4px"></div></div>
|
|
<span style="flex:0 0 46px;text-align:right;font-variant-numeric:tabular-nums">${f(x.metric)}</span>
|
|
</div>`}).join(""),b=!p&&l!=null&&!_(l,t.current_value),v=this._canEdit()&&b?`<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="pg-sweep-apply2" data-val="${d(String(l))}">${this._t("btn.pg_apply_best",{},"Apply best")}</button>`:"",T=t.cycles?` \xB7 ${this._t("msg.pg_sweep_n_cycles",{count:t.cycles},`over ${t.cycles} cycles`)}`:"";return`<div class="wd-pg-sweep-head" style="font-size:.82em;color:var(--secondary-text-color);margin:4px 0 6px">${p?`<span class="wd-pg-sweep-keep">${this._t("msg.pg_sweep_keep_current",{value:String(t.current_value??"-"),metric:f(c)},`Keep the current value (${t.current_value??"-"}, ${f(c)}): no value did better by at least one cycle.`)}</span>`:`${this._t("lbl.pg_best_value",{},"Best value found")}: <strong style="color:var(--primary-text-color)">${d(String(l))}</strong> \xB7 ${d(f(c))}`}${T} \xB7 <span>\u25C0 ${this._t("lbl.pg_current_value",{},"Current value:")}</span></div>
|
|
${u}
|
|
<div style="margin-top:8px">${this._htmlPgInSampleNote()}${v}</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_range_required",{},"Set a valid From/To range."),"error");return}const a=Math.max(2,Math.min(12,this._pgSweepSteps||5)),r=Array.from({length:a},(c,p)=>+(e+(s-e)*p/(a-1)).toFixed(3)),i=this._pgSweepParam||"off_delay",o=this._pgSweepObjective||"match_accuracy",n=Math.max(1,Math.min(St,this._pgSimCycles||20)),l={type:`${w}/start_playground_sweep`,entry_id:t.entry_id,param:i,values:r,objective:o,count:n};this._pgBatchProgress={done:0,total:r.length+1},this._busy.add("pg-sweep"),this._render();try{const c=await this._ws(l);if(this._pgSweepTaskId=c&&c.task_id,this._pgNeedsRestart=!1,!this._pgSweepTaskId)throw new Error("no task id");this._addProvisionalTask(this._pgSweepTaskId,"pg_sweep",t.entry_id,r.length+1),this._tasksSubscribed||this._pgPollTask(this._pgSweepTaskId)}catch(c){this._busy.delete("pg-sweep"),this._pgBatchProgress=null,this._pgRunFailed(c),this._render()}}async _pgApplyToSettings(){const t=this._devices[this._selIdx];if(!t||!this._canEdit())return;const e={};for(const r of this._pgChangedKeys())this._pgIsPublishable(r)&&(e[r]=this._pgStagedVal(r));const s=Object.keys(e);if(!s.length)return;const a=s.map(r=>this._t("setting."+r+".label",{},r)).join(", ");confirm(this._tText("msg.pg_apply_settings_confirm",{n:s.length,list:a},`Save these ${s.length} setting(s) to this device? ${a}`))&&(await this._busyRun("pg-apply-settings",async()=>{try{if(await this._ws({type:`${w}/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 r of s)this._pgClearStaged(r);this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading"))}catch(r){this._showToast(this._tText("msg.toast_save_failed",{error:r.message||r},"Save failed: "+(r.message||r)),"error")}}),this._render())}async _pgApplySweepValue(t){const e=this._devices[this._selIdx];if(!e||!this._canEdit()||t==null)return;const s=this._pgSweepParam,a=this._t("setting."+s+".label",{},s);confirm(this._tText("msg.pg_apply_confirm",{label:a,value:t},"Apply best value: "+a+" = "+t+"?"))&&await this._busyRun("pg-sweep-apply",async()=>{try{await this._ws({type:`${w}/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(r){this._showToast(this._tText("msg.toast_save_failed",{error:r.message||r},"Save failed: "+(r.message||r)),"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",{},"Estimated from the program's phases, not a fixed 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._pgDetail&&this._pgDetail.outcome,e=t&&t.matched_profile||this._pgProfileName||(this._cycles||[]).find(o=>o.id===this._pgCycleId)?.profile_name||"",s=t&&t.confidence;if(!t)return`<div><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>`;let a="\u2014",r="var(--secondary-text-color)";s!=null&&(s>=.7?(a="\u2705 "+this._t("lbl.pg_strong_match",{},"Strong match"),r="var(--success-color, #4caf50)"):s>=.4?(a="\u26A0 "+this._t("lbl.pg_weak_match",{},"Weak match"),r="var(--warning-color, #ff9800)"):(a="\u274C "+this._t("lbl.pg_poor_match",{},"Poor match"),r="var(--error-color, #f44336)"));let i=`<div style="font-weight:700;font-size:.9em;color:${r};margin-bottom:8px">${a}</div>`;if(e&&s!=null){const o=Math.round(Math.max(0,Math.min(1,s))*100);i+=`<div class="wd-subhead" style="margin:0 0 4px">${d(this._t("lbl.pg_match_confidence",{},"Match confidence"))}</div>`,i+=`<div class="wd-pg-cand-row">
|
|
<span class="wd-pg-cand-name" title="${d(e)}">${d(e)}</span>
|
|
<div class="wd-pg-cand-track"><div class="wd-pg-cand-fill" style="width:${o}%;background:var(--primary-color)"></div></div>
|
|
<span class="wd-pg-cand-pct">${o}%</span>
|
|
</div>`}return`<div>${i}</div>`}async _pgFetchEnvelope(t,e){try{const s=await this._ws({type:`${w}/get_profile_envelope`,entry_id:t,profile_name:e});this._pgEnvData=s.envelope||null}catch{this._pgEnvData=null}}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._pgDetailBusy=!1,this._pgView=null,this._pgHoverT=null,this._pgPowerPts=null,this._pgEnvData=null,this._pgDetail=null;const s=++this._pgLoadSeq;this._render();try{const a=await this._ws({type:`${w}/get_cycle_power_data`,entry_id:t.entry_id,cycle_id:e}),r=a.samples||[],i=[];for(const l of r){if(!Array.isArray(l)||l.length<2)continue;const c=+l[0],p=+l[1];!isNaN(c)&&!isNaN(p)&&i.push({t:c,w:p})}if(this._pgPowerPts=i.length?i:null,typeof a.full_duration_s=="number"&&a.full_duration_s>0){const l=(this._cycles||[]).find(c=>c.id===e);l&&(l._pg_duration=a.full_duration_s)}const o=this._pgProfileName||(this._cycles||[]).find(l=>l.id===e)?.profile_name||"";if(o&&await this._pgFetchEnvelope(t.entry_id,o),s!==this._pgLoadSeq)return;await this._pgLoadDetail(t.entry_id,e,s);const n=this._pgDetail&&this._pgDetail.outcome&&this._pgDetail.outcome.matched_profile;!o&&n&&s===this._pgLoadSeq&&await this._pgFetchEnvelope(t.entry_id,n)}catch(a){this._showToast(this._tText("msg.toast_error",{error:a.message||a},"Error: "+(a.message||a)),"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._pgDetailBusy=!1,this._pgCancelDetailTask(),this._render()}_pgCancelDetailTask(){const t=this._pgDetailTaskId;this._pgDetailTaskId=null,t&&this._ws({type:`${w}/cancel_task`,task_id:t}).catch(()=>{})}_pgCurrentOverride(){const t={...this._pgParamOverrides};return this._pgThreshStart!=null&&(t.start_threshold_w=this._pgThreshStart),this._pgThreshStop!=null&&(t.stop_threshold_w=this._pgThreshStop),t}_pgOverrideSig(t){return JSON.stringify(Object.keys(t).sort().map(e=>[e,t[e]]))}_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,s){const a=this._pgCurrentOverride(),r=this._pgOverrideSig(a);this._pgCancelDetailTask();try{const i=await this._ws({type:`${w}/start_playground_cycle_detail`,entry_id:t,cycle_id:e,settings_override:a}),o=i&&i.task_id;if(!o)throw new Error("no task id");this._pgDetailTaskId=o,this._addProvisionalTask(o,"pg_detail",t,0);const n=await new Promise(l=>{this._taskCallbacks[o]=async p=>{if(p.state==="done"||p.state==="cancelled")try{const _=await this._ws({type:`${w}/get_task_result`,task_id:p.id});l(_&&_.result)}catch{l(null)}else l(null)};const c=this._tasks[o];c&&c.state!=="running"?this._settleTaskCallback(c):this._tasksSubscribed||this._pollTaskGeneric(o)});if(this._pgDetailTaskId===o&&(this._pgDetailTaskId=null),!this._isActiveEntry(t)||s!=null&&s!==this._pgLoadSeq)return;this._pgDetail=n&&!n.error?n:null,this._pgDetailSig=this._pgDetail?r:null,this._pgNeedsRestart=!1}catch(i){if(s!=null&&s!==this._pgLoadSeq)return;this._pgIsUnknownCmd(i)&&(this._pgNeedsRestart=!0),this._pgDetail=null,this._pgDetailSig=null}}_pgRerunDetail(){const t=this._devices[this._selIdx];!t||!this._pgCycleId||this._pgLoading||(clearTimeout(this._pgDetailDebounceTimer),this._pgDetailDebounceTimer=setTimeout(async()=>{const e=++this._pgLoadSeq;this._pgDetailBusy=!0,this._render(),await this._pgLoadDetail(t.entry_id,this._pgCycleId,e),e===this._pgLoadSeq&&(this._pgDetailBusy=!1,this._render(),requestAnimationFrame(()=>this._pgDrawCanvas()))},220))}_pgMapState(t,e){return e?"stalled":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 a of e)if(a.t<=t)s=a;else break;return s}_pgStateSegsFromSeries(t){const e=this._pgDetail&&this._pgDetail.series;if(!e||!e.length)return[];const s=[];let a=null;for(const r of e){const i=this._pgMapState(r.state,r.stalled);!a||a.state!==i?(a={start:r.t,end:r.t,state:i},s.push(a)):a.end=r.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,a=e.getBoundingClientRect(),r=window.devicePixelRatio||1,i=Math.max(1,Math.round(a.width*r)),o=Math.max(1,Math.round((a.height||280)*r));(e.width!==i||e.height!==o)&&(e.width=i,e.height=o);const n=e.getContext("2d"),l=getComputedStyle(this),c=(l.getPropertyValue("--primary-color")||"#03a9f4").trim(),p=(l.getPropertyValue("--divider-color")||"rgba(127,127,127,.2)").trim(),_=(l.getPropertyValue("--secondary-text-color")||"#888").trim(),f=(l.getPropertyValue("--secondary-background-color")||"#1a1a1a").trim();n.clearRect(0,0,i,o);const u=34*r,b=14*r,v=Bt*r,T=44*r,C=8*r,x=v+8*r,$=u+b+4*r,L=o-x-$;if(!s||!s.length){this._pgLoading&&(n.fillStyle=_,n.font=`${12*r}px sans-serif`,n.textAlign="center",n.textBaseline="middle",n.fillText(this._t("msg.loading",{},"Loading\u2026"),i/2,o/2));return}const D=(this._cycles||[]).find(E=>E.id===this._pgCycleId)?._pg_duration||s[s.length-1].t||1,B=Math.max(...s.map(E=>E.w),1),U=this._pgThreshStart??this._pgFieldVal("start_threshold_w",{})??50,z=this._pgThreshStop??this._pgFieldVal("stop_threshold_w",{})??5;let M=0,A=D;this._pgView&&this._pgView.max-this._pgView.min>1&&(M=Math.max(0,this._pgView.min),A=Math.min(D,this._pgView.max),A-M<=1&&(M=0,A=D));const O=Math.max(1e-6,A-M),K=i-T-C,I=E=>T+(E-M)/O*K,R=E=>x+(1-Math.max(0,E)/B)*L;this._pgMap={vMin:M,vMax:A,totalDur:D,padLpx:T/r,plotWpx:K/r},n.strokeStyle="rgba(127,127,127,0.12)",n.lineWidth=r,n.setLineDash([]);const G=Ie(B,5),W=[];for(let E=1;E*G<=B*1.0001;E++)W.push(E*G);W.forEach(E=>{const S=R(E);S<x||S>x+L||(n.beginPath(),n.moveTo(T,S),n.lineTo(i-C,S),n.stroke(),n.fillStyle=_,n.font=`${9*r}px sans-serif`,n.textAlign="right",n.textBaseline="middle",n.fillText(Ht(E,G),T-4*r,S))}),n.save(),n.beginPath(),n.rect(T,x,K,L),n.clip();const J=this._pgEnvData,Y=J&&Array.isArray(J.avg)?J.avg.filter(E=>Array.isArray(E)&&E.length>=2):[];if(Y.length){const E=Math.max(...Y.map(H=>H[0]))||1,S=H=>I(H/E*D),F=Array.isArray(J.min)?J.min.filter(H=>Array.isArray(H)&&H.length>=2):[],N=Array.isArray(J.max)?J.max.filter(H=>Array.isArray(H)&&H.length>=2):[];if(F.length&&N.length){n.beginPath(),N.forEach((H,Z)=>Z?n.lineTo(S(H[0]),R(H[1])):n.moveTo(S(H[0]),R(H[1])));for(let H=F.length-1;H>=0;H--)n.lineTo(S(F[H][0]),R(F[H][1]));n.closePath(),n.fillStyle="#eda10012",n.fill()}else n.beginPath(),n.strokeStyle="#eda100",n.lineWidth=2*r,n.setLineDash([5*r,4*r]),Y.forEach((H,Z)=>Z?n.lineTo(S(H[0]),R(H[1])):n.moveTo(S(H[0]),R(H[1]))),n.stroke(),n.setLineDash([])}n.beginPath(),n.moveTo(I(0),R(0)),s.forEach(E=>n.lineTo(I(E.t),R(E.w))),n.lineTo(I(s[s.length-1].t),R(0)),n.closePath(),n.fillStyle=Pt(c,.1),n.fill(),n.beginPath(),n.strokeStyle=c,n.lineWidth=2*r,s.forEach((E,S)=>S?n.lineTo(I(E.t),R(E.w)):n.moveTo(I(E.t),R(E.w))),n.stroke(),n.restore();const nt=(E,S,F)=>{const N=R(E);N<x-2||N>x+L+2||(n.save(),n.strokeStyle=S,n.lineWidth=2*r,n.setLineDash([8*r,4*r]),n.beginPath(),n.moveTo(T,N),n.lineTo(i-C,N),n.stroke(),n.setLineDash([]),n.fillStyle=S,n.beginPath(),n.arc(T+(i-T-C)*.06,N,5*r,0,Math.PI*2),n.fill(),n.fillStyle=S,n.font=`bold ${9*r}px sans-serif`,n.textAlign="left",n.textBaseline="bottom",n.fillText(F+" "+Ht(E),T+14*r,N-2*r),n.restore())};nt(+U,"#2a78d6",this._t("lbl.start",{},"Start")),nt(+z,"#e34948",this._t("btn.stop",{},"Stop"));const _t={idle:f,detecting:"#42a5f566",running:"#66bb6a66",ending:"#ef535066",anti_wrinkle:"#ab47bc66",stalled:"#eda10066"},ct={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"),stalled:this._t("status.stalled",{},"Stalled")},dt=o-u-b;n.fillStyle=f,n.fillRect(T,dt,i-T-C,u),n.save(),n.beginPath(),n.rect(T,dt,K,u),n.clip(),this._pgStateSegsFromSeries(D).forEach(E=>{const S=I(E.start),F=I(E.end);n.fillStyle=_t[E.state]||p,n.fillRect(S,dt,Math.max(1,F-S),u),F-S>50*r&&(n.fillStyle=_,n.font=`${8*r}px sans-serif`,n.textAlign="center",n.textBaseline="middle",n.fillText((ct[E.state]||E.state).toUpperCase(),(S+F)/2,dt+u/2))}),n.restore();const m=o-b,g=[];for(const E of this._pgDetail&&this._pgDetail.series||[]){const S=g[g.length-1];if(S&&S.name===(E.phase||null)){S.end=E.t;continue}S&&(S.end=E.t),g.push({name:E.phase||null,start:E.t,end:E.t})}g.some(E=>E.name)&&(n.save(),n.beginPath(),n.rect(T,m,K,b),n.clip(),g.filter(E=>E.name).forEach((E,S)=>{const F=I(E.start),N=I(E.end),H=S*47%360;n.fillStyle=`hsla(${H},60%,55%,0.55)`,n.fillRect(F,m,Math.max(1,N-F),b),N-F>40*r&&(n.fillStyle="#fff",n.font=`${7*r}px sans-serif`,n.textAlign="center",n.textBaseline="middle",n.fillText(E.name,(F+N)/2,m+b/2))}),n.restore()),this._pgEventHits=[];const k=this._pgDetail&&Array.isArray(this._pgDetail.events)?this._pgDetail.events.filter(E=>E.type!=="state"&&E.t>=M&&E.t<=A).slice().sort((E,S)=>E.t-S.t):[],y=11*r,P=x-v/2-2*r,X=y*2+3*r;let q=-1/0;const j=this._pgHoverEvent;if(k.forEach(E=>{const S=this._pgEventMeta(E.type),F=I(E.t);let N=Math.max(F,q+X);N=Math.max(T+y,Math.min(i-C-y,N)),q=N;const H=j&&j.t===E.t&&j.type===E.type;n.save(),n.strokeStyle=S.color,n.globalAlpha=H?.9:.5,n.lineWidth=(H?2:1)*r,n.beginPath(),n.moveTo(N,P+y),n.lineTo(F,x),n.stroke(),H&&(n.globalAlpha=.3,n.setLineDash([2*r,3*r]),n.beginPath(),n.moveTo(F,x),n.lineTo(F,o-b),n.stroke(),n.setLineDash([])),n.restore(),n.save(),n.beginPath(),n.arc(N,P,y,0,Math.PI*2),n.fillStyle=H?S.color:f||"#1a1a1a",n.fill(),n.lineWidth=(H?2:1.5)*r,n.strokeStyle=S.color,n.stroke(),n.fillStyle=H?"#fff":S.color,n.font=`${14*r}px sans-serif`,n.textAlign="center",n.textBaseline="middle",n.fillText(S.glyph,N,P+r),n.restore(),this._pgEventHits.push({cx:N/r,cy:P/r,r:y/r,type:E.type,label:S.label,detail:E.detail,t:E.t})}),j){const E=this._pgEventHits.find(S=>S.t===j.t&&S.type===j.type);if(E){const S=this._pgEventMeta(E.type),F=ft=>`${Math.floor(ft/60)}:${String(Math.round(ft%60)).padStart(2,"0")}`,N=this._pgEventDescription(E.type),H=[{t:E.label,bold:!0,col:S.color},...N?[{t:N}]:[],...E.detail?[{t:E.detail,dim:!0}]:[],{t:`${this._t("lbl.from_start",{},"From start")} ${F(E.t)}`,dim:!0}];n.save(),n.font=`${10*r}px sans-serif`;const Z=Math.min(220*r,i-2*T),tt=[];H.forEach(ft=>{const At=String(ft.t).split(" ");let bt="";At.forEach(Ot=>{const Jt=bt?bt+" "+Ot:Ot;n.measureText(Jt).width>Z&&bt?(tt.push({...ft,t:bt}),bt=Ot):bt=Jt}),tt.push({...ft,t:bt})});const st=14*r,rt=Math.min(Z+14*r,i-8*r),lt=tt.length*st+10*r;let pt=E.cx*r-rt/2;pt=Math.max(4*r,Math.min(i-rt-4*r,pt));const Rt=P+y+6*r;n.fillStyle=f,n.globalAlpha=.97,n.fillRect(pt,Rt,rt,lt),n.globalAlpha=1,n.strokeStyle=S.color,n.lineWidth=r,n.strokeRect(pt,Rt,rt,lt),n.textAlign="left",n.textBaseline="top",tt.forEach((ft,At)=>{n.font=`${ft.bold?"600 ":""}${10*r}px sans-serif`,n.fillStyle=ft.col||(ft.dim?_:(l.getPropertyValue("--primary-text-color")||"#ddd").trim()),n.fillText(ft.t,pt+7*r,Rt+5*r+At*st)}),n.restore()}}if(this._pgHoverT!=null&&this._pgHoverT>=M&&this._pgHoverT<=A){const E=I(this._pgHoverT);n.save(),n.strokeStyle="#e34948",n.lineWidth=1.5*r,n.setLineDash([4*r,3*r]),n.beginPath(),n.moveTo(E,x),n.lineTo(E,o-b),n.stroke(),n.setLineDash([]);const S=this._pgInterpPower(s,this._pgHoverT),F=R(S);n.fillStyle="#e34948",n.beginPath(),n.arc(E,F,3.5*r,0,Math.PI*2),n.fill();const N=at=>`${Math.floor(at/60)}:${String(Math.round(at%60)).padStart(2,"0")}`,H=at=>at>=1e3?it(at/1e3,2)+" kW":Lt(at),Z=[`${this._t("lbl.from_start",{},"From start")} ${N(this._pgHoverT)}`,`${this._t("lbl.to_end",{},"To end")} ${N(Math.max(0,D-this._pgHoverT))}`,`${this._t("lbl.power",{},"Power")} ${H(S)}`];n.font=`${9.5*r}px sans-serif`;const tt=Math.max(...Z.map(at=>n.measureText(at).width))+12*r,st=Z.length*13*r+8*r;let rt=E+8*r;rt+tt>i-C&&(rt=E-tt-8*r);const lt=x+4*r;n.fillStyle=f,n.globalAlpha=.95,n.fillRect(rt,lt,tt,st),n.globalAlpha=1,n.strokeStyle=p,n.lineWidth=r,n.strokeRect(rt,lt,tt,st),n.fillStyle=_,n.textAlign="left",n.textBaseline="top",Z.forEach((at,pt)=>n.fillText(at,rt+6*r,lt+5*r+pt*13*r)),n.restore()}n.fillStyle=_,n.font=`${9*r}px sans-serif`,n.textAlign="center",n.textBaseline="top";const V=E=>`${Math.floor(E/60)}:${String(Math.round(E%60)).padStart(2,"0")}`;for(let E=0;E<=4;E++){const S=M+O*E/4,F=I(S);n.textAlign=E===0?"left":E===4?"right":"center",n.fillText(V(S),Math.max(T,Math.min(i-C,F)),dt+u+1*r)}O<D-1&&(n.fillStyle=_,n.font=`${8*r}px sans-serif`,n.textAlign="right",n.textBaseline="top",n.fillText(this._t("lbl.pg_zoom_hint",{},"double-click to reset zoom"),i-C,x))}_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")],match_reverted:["\u21BA","#9e9e9e",this._t("lbl.pg_ev_match_reverted",{},"Match reverted")],verified_pause:["\u23F8","#7e57c2",this._t("lbl.pg_ev_verified_pause",{},"Verified pause")],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")],stalled:["\u23F8","#eda100",this._t("status.stalled",{},"Stalled")]}[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."),match_reverted:this._t("pg_evd.match_reverted",{},"The program fell back to detecting: its score dropped well below its own peak."),verified_pause:this._t("pg_evd.verified_pause",{},"The live alignment confirmed (or released) a pause inside the program. While it holds, the cycle is not ended."),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."),stalled:this._t("pg_evd.stalled",{},"The cycle sat at standby draw longer than this program ever pauses there, with work left. Shown as Paused (Stalled); the cycle stays open.")}[t]||""}_pgUpdateParamInput(t,e){const s=this.shadowRoot,a=s&&s.querySelector(`input[data-pgkey="${t}"]`);a&&(a.dataset.pgtype==="bool"?a.checked=!!e:a.value=typeof e=="number"?Math.round(e):e)}_pgUpdateStripAt(t){const e=this._pgPowerPts;if(!e?.length)return;const a=(this._cycles||[]).find(z=>z.id===this._pgCycleId)?._pg_duration||e[e.length-1].t||1,r=t??a,i=this._pgInterpPower(e,r),o=this._pgSeriesAt(r),n=o?this._pgMapState(o.state,o.stalled):"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"],stalled:[this._t("status.stalled",{},"Stalled"),"#eda100"]},[c,p]=l[n]||l.idle,_=o&&o.progress!=null?Math.round(o.progress):null,f=o&&o.remaining_s!=null?o.remaining_s:null,u=o&&o.energy_wh!=null?o.energy_wh:this._pgTrapEnergy(e,r),b=o&&o.confidence!=null?Math.round(o.confidence*100)+"%":"\u2014",v=o&&o.phase?o.phase:"\u2014",T=z=>Math.floor(z/60)+":"+String(Math.round(z%60)).padStart(2,"0"),C=z=>z>=1e3?it(z/1e3,2)+" kWh":it(z,0)+" Wh",x=z=>z>=1e3?it(z/1e3,1)+" kW":Lt(z),$=this.shadowRoot,L=z=>$&&$.getElementById(z),D=(z,M)=>{const A=L(z);A&&(A.textContent=M)},B=(z,M,A)=>{const O=L(z);O&&(O.style[M]=A)},U=L("wd-pg-state-badge");U&&(U.textContent=c,U.style.background=p,U.style.color=p.includes("var")?"":"#fff"),D("wd-pg-power",x(i)),D("wd-pg-pct",_!=null?_+"%":"\u2014%"),B("wd-pg-pbar","width",(_??0)+"%"),D("wd-pg-rem",f!=null?T(f):"\u2014"),D("wd-pg-energy",C(u)),D("wd-pg-conf",b),D("wd-pg-phase",v)}_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 a=t[s].t-t[s-1].t;if(a<=0)return t[s].w;const r=(e-t[s-1].t)/a;return t[s-1].w+r*(t[s].w-t[s-1].w)}return t[t.length-1].w}_pgTrapEnergy(t,e){let s=0;for(let a=1;a<t.length;a++){const r=t[a-1].t,i=t[a-1].w,o=Math.min(t[a].t,e),n=this._pgInterpPower(t,o);if(!(o<=r)&&(s+=(i+n)/2*(o-r)/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(),a=this._phases.map(o=>{const n=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="${n}">${this._t("btn.edit",{},"Edit")}</button>
|
|
${o.is_override?`<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="del-phase" data-reset="1" data-pid="${d(o.id)}" data-pname="${d(o.name)}" style="margin-left:4px" title="${d(this._t("btn.reset_phase_tip",{},"Undo your edit and restore the built-in phase"))}">${this._t("btn.reset",{},"Reset")}</button>`:n?"":`<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)} ${n?`<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(""),r=s?`<th>${this._t("lbl.actions",{},"Actions")}</th>`:"",i=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>
|
|
${i}
|
|
${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.name",{},"Name")}</th><th>${this._t("lbl.description",{},"Description")}</th>${r}</tr></thead><tbody>${a}</tbody></table>`}
|
|
</div>`}_htmlImportUndo(){const t=this._importUndo;if(!t||!t.created_at||!this._isAdmin())return"";const e=t.counts||{},s=(e.real_cycles||0)+(e.reference_cycles||0)+(e.backfill_cycles||0),a=this._busy.has("import-undo");return`<div class="wd-import-undo" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--divider-color,#e0e0e0)">
|
|
<p class="wd-info" style="margin:0 0 8px">${this._t("msg.import_undo_hint",{when:et(t.created_at),profiles:e.profiles||0,cycles:s},"A restore point from before the last import ({when}) is kept: {profiles} profile(s) and {cycles} cycle(s).")}</p>
|
|
<button class="wd-btn wd-btn-secondary" data-action="import-undo" ${a?"disabled":""}>${a?'<span class="wd-spin"></span> ':""}${this._t("btn.undo_import",{},"Undo last import")}</button>
|
|
</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:t._error},"Could not load diagnostics: {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 suggestions, retrain ML models (if enabled), recost cycles from recorded prices and recompute cycle health. Run it 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>
|
|
</div>
|
|
${this._htmlImportUndo()}
|
|
</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",{},"Had a smart plug before WashData? Upload its power history or read it from Home Assistant. Detection runs over it, and past cycles appear 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,e){const s={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"),salt:this._t("maint.salt",{},"Refill salt"),rinse_aid:this._t("maint.rinse_aid",{},"Refill rinse aid"),lint_filter:this._t("maint.lint_filter",{},"Clean lint filter"),condenser_clean:this._t("maint.condenser_clean",{},"Clean condenser")};if(s[t])return s[t];const r=(this._maintenance&&this._maintenance.custom_tasks||[]).find(i=>i&&i.id===t);return r&&r.name?r.name:e||t}_maintStatusRows(t,e){if(Array.isArray(t.status))return t.status;const s=t.reminders||{},a=t.cycles_since||{};return e.map(r=>{const i=parseInt(s[r],10)||0,o=Math.max(0,parseInt(a[r],10)||0);return{id:r,custom:!1,cycles_interval:i,days_interval:0,cycles_since:o,days_since:null,due:i>0&&o>=i}}).filter(r=>r.cycles_interval>0)}_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: {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"],a=e.due||[],r=e.log||[],i=e.reminders||{},o=Array.isArray(e.custom_tasks)?e.custom_tasks:[],n=e.limits||{},l=parseInt(n.name_max,10)||60,c=parseInt(n.tasks_max,10)||20,p=Math.max(0,parseInt(e.lifetime_cycle_count,10)||0),_=this._canFull(),f=a.length?(()=>{const M=a.map(O=>this._maintLabel(O)).join(", "),A=t?`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px">${a.map(O=>`<button class="wd-btn wd-btn-sm" data-action="maint-log-done" data-mtype="${d(O)}">${this._t("btn.log_done_task",{task:this._maintLabel(O)},"Log done: {task}")}</button>`).join("")}</div>`:"";return`<div class="wd-maint-due" 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:M},"Maintenance due: {items}")}</span>
|
|
${A}
|
|
</div>`})():"",u=new Date().toISOString().slice(0,10),b=s.map(M=>`<option value="${d(M)}">${d(this._maintLabel(M))}</option>`).join("")+o.map(M=>`<option value="${d(M.id)}">${d(M.name||M.id)}</option>`).join(""),v=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="${u}" max="${u}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.event_type",{},"Event type")}</label><select id="wd-maint-type">${b}</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>`:"",T=r.length?r.map(M=>{const A=t?`<button class="wd-btn wd-btn-danger wd-btn-sm" data-action="maint-delete" data-mid="${d(M.id)}">${this._t("btn.delete",{},"Delete")}</button>`:"",O=M.notes?`<div class="wd-info" style="margin-top:2px">${d(M.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(M.event_type,M.task_name))}</div>
|
|
<div class="wd-info" style="margin-top:2px">${et(M.date)}</div>
|
|
${O}
|
|
</div>
|
|
${A}
|
|
</div>
|
|
</div>`}).join(""):`<p class="wd-info">${this._t("msg.no_maintenance",{},"No maintenance recorded yet.")}</p>`,C=(M,A,O,K)=>`<input type="number" min="0" max="${K}" step="1" ${M}="${d(A)}" value="${O?d(O):""}" placeholder="0">`,x=o.map(M=>`<div class="wd-form-grid wd-maint-task" data-mtask-row="${d(M.id)}" style="align-items:end;margin-top:8px;padding-top:8px;border-top:1px solid var(--divider-color)">
|
|
<div class="wd-field"><label>${this._t("lbl.task_name",{},"Task")}</label><input type="text" maxlength="${l}" data-mtask-name="${d(M.id)}" value="${d(M.name||"")}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.every_cycles",{},"Every (cycles)")}</label>${C("data-mtask-cycles",M.id,M.cycles,n.cycles_max||1e5)}</div>
|
|
<div class="wd-field"><label>${this._t("lbl.every_days",{},"Every (days)")}</label>${C("data-mtask-days",M.id,M.days,n.days_max||3650)}</div>
|
|
<div class="wd-field"><button class="wd-btn wd-btn-danger wd-btn-sm" data-action="maint-task-remove" data-mtask="${d(M.id)}">${this._t("btn.remove",{},"Remove")}</button></div>
|
|
</div>`).join(""),$=o.length<c?`<div class="wd-form-grid" style="align-items:end;margin-top:12px">
|
|
<div class="wd-field"><label>${this._t("lbl.new_task_name",{},"New task")}</label><input type="text" id="wd-mtask-new-name" maxlength="${l}" placeholder="${d(this._t("placeholder.custom_task",{},"e.g. Clean door seal"))}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.every_cycles",{},"Every (cycles)")}</label><input type="number" min="0" step="1" id="wd-mtask-new-cycles" placeholder="0"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.every_days",{},"Every (days)")}</label><input type="number" min="0" step="1" id="wd-mtask-new-days" placeholder="0"></div>
|
|
<div class="wd-field"><button class="wd-btn wd-btn-sm" data-action="maint-task-add">${this._t("btn.add_task",{},"Add task")}</button></div>
|
|
</div>`:`<p class="wd-info" style="margin-top:8px">${this._t("msg.custom_task_limit",{max:c},"You have reached the limit of {max} tasks.")}</p>`,L=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(M=>`<div class="wd-field"><label>${d(this._maintLabel(M))}</label><input type="number" min="0" step="1" data-maint-rem="${d(M)}" value="${i[M]!=null?d(i[M]):""}" placeholder="${d(this._t("lbl.reminder_every",{},"Remind every (cycles)"))}"></div>`).join("")}
|
|
</div>
|
|
<div class="wd-subhead" style="margin-top:16px">${this._t("hdr.custom_tasks",{},"Your own tasks")}</div>
|
|
<p class="wd-info">${this._t("msg.custom_tasks_intro",{},"Add anything else you want to be reminded of. A task is due when either interval is reached; leave one blank or 0 to use only the other.")}</p>
|
|
${x}
|
|
${$}
|
|
<p class="wd-info" style="margin-top:12px">${this._t("msg.maintenance_due_sensor_hint",{},"The Maintenance due binary sensor is on while any task is due, for your own notification automations.")}</p>
|
|
<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>`:"",D=this._maintStatusRows(e,s).map(M=>{const A=parseInt(M.cycles_interval,10)||0,O=parseInt(M.days_interval,10)||0,K=Math.max(0,parseInt(M.cycles_since,10)||0),I=M.days_since==null?null:Math.max(0,parseInt(M.days_since,10)||0),R=[];let G=0;if(A>0&&(R.push(this._t("lbl.cycles_since_service",{since:K,total:A},K+" / "+A+" cycles")),G=Math.max(G,K/A)),O>0){const Y=I??0;R.push(this._t("lbl.days_since_service",{since:Y,total:O},Y+" / "+O+" days")),G=Math.max(G,Y/O)}const W=Math.max(0,Math.min(100,Math.round(G*100))),J=M.due?"var(--warning-color,#ff9800)":"var(--primary-color)";return`<div style="margin-top:10px" data-mstatus="${d(M.id)}">
|
|
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:8px">
|
|
<span style="font-weight:600">${d(M.custom?M.name||M.id:this._maintLabel(M.id))}</span>
|
|
<span class="wd-info">${R.join(" \xB7 ")}</span>
|
|
</div>
|
|
<div style="height:6px;margin-top:4px;border-radius:3px;background:var(--divider-color);overflow:hidden">
|
|
<div style="height:100%;border-radius:3px;width:${W}%;background:${J}"></div>
|
|
</div>
|
|
</div>`}).join(""),B=_?`<div style="margin-top:16px;padding-top:14px;border-top:1px solid var(--divider-color)">
|
|
<div class="wd-field">
|
|
<label>${this._t("lbl.correct_total_cycles",{},"Correct the total")}</label>
|
|
<input type="number" min="0" step="1" id="wd-maint-odometer" value="${p}">
|
|
</div>
|
|
<p class="wd-info" style="margin-top:6px">${this._t("msg.odometer_correct_hint",{},"Set this if WashData counted a run that never happened, or the appliance ran cycles before WashData was installed.")}</p>
|
|
<div class="wd-card-actions"><button class="wd-btn" data-action="maint-save-odometer">${this._t("btn.save_total_cycles",{},"Save total")}</button></div>
|
|
</div>`:"",U=`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.service_status",{},"Service Status")}</div>
|
|
<div style="display:flex;align-items:baseline;gap:8px">
|
|
<span style="font-size:26px;font-weight:700">${p}</span>
|
|
<span class="wd-info">${this._t("lbl.total_cycles_run",{},"cycles run in total")}</span>
|
|
</div>
|
|
<p class="wd-info" style="margin-top:6px">${this._t("msg.odometer_intro",{},"This total only goes up. Deleting a cycle does not lower it, so service reminders stay correct.")}</p>
|
|
${D||`<p class="wd-info" style="margin-top:10px">${this._t("msg.no_reminders_set",{},"No service reminders are set yet.")}</p>`}
|
|
${B}
|
|
</div>`,z=`<div class="wd-card wd-maint-supporter">
|
|
<div class="wd-card-title">${this._t("hdr.maintenance_supporter",{},"Need more?")}</div>
|
|
<p class="wd-info">${this._t("msg.maintenance_supporter_intro",{},"Maintenance Supporter is a separate integration that does much more: any appliance or task, schedules by time, usage or a sensor, notifications and a history. It can use the WashData cycle counter.")}</p>
|
|
<p style="margin-top:8px"><a href="${$e}" target="_blank" rel="noopener noreferrer" style="color:var(--primary-color);text-decoration:none;font-weight:500">${this._t("link.maintenance_supporter",{},"Maintenance Supporter on GitHub \u2197")}</a></p>
|
|
</div>`;return`${f}
|
|
${U}
|
|
${v}
|
|
<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 on this appliance and get reminded when each task is due again.")}</p>
|
|
<div style="display:flex;flex-direction:column;gap:8px">${T}</div>
|
|
</div>
|
|
${L}
|
|
${z}`}_htmlPanel(){const t=this._canEdit(),e=t&&this._constants&&this._constants.mlTrainingAvailable,s=new Set(["diagnostics"]);e&&s.add("ml");let a=this._panelSubtab;s.has(a)||(a=this._panelSubtab="diagnostics");const r=[["diagnostics",this._t("tab.diagnostics",{},"Diagnostics")]];e&&r.push(["ml",this._t("tab.ml",{},"ML Training")]);const i=r.map(([n,l])=>`<button class="wd-subtab ${a===n?"active":""}" role="tab" aria-selected="${a===n?"true":"false"}" data-ptab="${n}">${l}</button>`).join("");if(!t)return"";const o=a==="ml"&&e?this._htmlMlTab():this._htmlDiagnostics();return`<div class="wd-subtabs" role="tablist">${i}</div>${o}`}_levelSelect(t,e,s){const a=(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}>${a.map(([r,i])=>`<option value="${r}" ${e===r?"selected":""}>${i}</option>`).join("")}</select>`}_htmlPanelPrefs(){const t=this._panelCfg&&this._panelCfg.prefs||{},e=this._hass&&this._hass.locale&&this._hass.locale.language||"en",a=[["",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(""),i=[["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||"",n=[["",this._tText("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">${a}</select></div>
|
|
<div class="wd-field"><label>${this._t("lbl.cycle_date_display",{},"Cycle date display")}</label><select id="wd-pref-datefmt">${i}</select></div>
|
|
<div class="wd-field"><label>${this._t("lbl.panel_language",{},"Panel language")}</label><select id="wd-pref-lang">${n}</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>
|
|
${kt(`id="wd-pref-expected" ${t.show_expected!==!1?"checked":""}`,this._t("lbl.show_expected",{},"Show expected curve overlay (matched profile, orange)"))}
|
|
${kt(`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>
|
|
${kt(`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(([i,o])=>`<option value="${i}" ${(t.default_tab||"status")===i?"selected":""}>${d(o)}</option>`).join(""),a=t.hidden_tabs||[],r=e.filter(([i])=>i!=="status").map(([i,o])=>Xt(`data-hidetab="${i}" ${a.includes(i)?"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">${r}</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||[],a=e.filter(i=>!i.is_admin).map(i=>{const o=(t.users||{})[i.id]||{default:"none",devices:{}},n=s.map(l=>`<div class="wd-seg-row"><span style="min-width:160px">${d(l.title)}</span>${this._levelSelect(`data-rbacuser="${d(i.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(i.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(i.id)}" data-rbacdev="__default__"`,o.default||"none",!1)}</div>
|
|
${n}
|
|
</div>`}).join(""),r=e.filter(i=>i.is_admin).map(i=>`<span class="wd-pill">${d(i.name)} - full (admin)</span>`).join(" ");return`<div class="wd-card">
|
|
<div class="wd-card-title">${this._t("hdr.access_control",{},"Access Control")}</div>
|
|
${kt(`id="wd-rbac-enabled" ${t.enabled?"checked":""}`,this._t("lbl.enable_access_control",{},"Enable per-user access control"),"",this._t("msg.rbac_hint",{},"Off: every Home Assistant user has full access (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>
|
|
${r?`<div class="wd-field"><label>${this._t("lbl.administrators",{},"Administrators")}</label><div>${r}</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>
|
|
${a||`<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",{},"Turn on online features in Preferences > Online & Community to browse and import community reference cycles.")}</p></div>`;let a;return this._storeView==="device"?a=this._htmlStoreDevice():this._storeView==="profile"?a=this._htmlStoreProfile():a=this._htmlStoreBrands(),`<div class="wd-card">
|
|
${s}
|
|
${this._htmlStoreCrumbs()}
|
|
${a}
|
|
</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 n=`${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>`:"",p=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>`:"",_=Dt(o.profileCount),f=_>0?`<span class="wd-store-chip">${this._t("store.programs_count",{n:_},`Programs: ${_}`)}</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">${n}${p}${this._statusTag(o)}</span>
|
|
<span class="wd-store-row-sub">${l}${f}<span class="wd-store-fav" title="${d(this._tText("store.favorites",{},"Favourites"))}">\u2605 ${it(Dt(o.favoriteCount),0)}</span></span>
|
|
|
|
</span>
|
|
<span class="wd-store-row-arrow" aria-hidden="true">\u203A</span>
|
|
</button>`}).join("");if(!this._storeBrandScope())return`
|
|
${this._storeSearchHtml()}
|
|
<p class="wd-info" style="margin-bottom:10px">${this._t("msg.store_declare_appliance",{},"Tell WashData which appliance you own to see setups others shared for it. Or 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 a=`<p class="wd-info">${this._t("store.no_results",{},"No matching appliances found. Try a different search.")}</p>`,r=this._storeLoading?this._htmlStoreLoading():t.length?`<div class="wd-store-rows">${s}</div>`:a,i=!this._storeLoading&&t.length>1?`<p class="wd-info" style="margin-bottom:8px">${this._t("msg.store_sibling_hint",{},"Nothing shared for your model? A related model from the same brand is usually a good start.")}</p>`:"";return`
|
|
${this._storeSearchHtml()}
|
|
${i}
|
|
${r}`}_storeSearchHtml(){return`
|
|
<div class="wd-store-search">
|
|
<div class="wd-combo">
|
|
<input type="text" id="wd-store-q" class="wd-combo-inp" data-catalog="store_search"
|
|
placeholder="${d(this._t("store.search_ph2",{},"Search by brand or model\u2026"))}"
|
|
value="${d(this._storeQuery)}" autocomplete="off" spellcheck="false">
|
|
<div class="wd-combo-drop" hidden></div>
|
|
</div>
|
|
</div>`}_htmlStoreUnreachable(){return`<p class="wd-error-state" role="alert">${this._t("store.unreachable",{},"Couldn't reach the community store. Check your connection and try again.")}</p>`}_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>`:this._storeBrowseErr?this._htmlStoreUnreachable():`<p class="wd-info">${this._t("store.no_programs",{},"No shared programs for this appliance yet.")}</p>`,a=this._storeDevice,r=this._busy.has("store-download-device");return(this._canEdit()&&a&&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",{},"Add every shared program and its reference cycles to your device. Your own cycles and stats are not affected.")}</span>
|
|
${Xt(`data-action="store-toggle-dl-settings" ${this._dlSettings?"checked":""} ${r?"disabled":""}`,this._t("lbl.adopt_settings",{},"Also adopt settings"),ot(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(a.id)}" ${r?"disabled":""}>${r?'<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(a=>{const r=a.stats&&typeof a.stats=="object"?a.stats:{},i=this._storeSparkline(a.trace&&a.trace.points||[]),o=ht(r.duration),n=gt(r.energy_wh!=null?Number(r.energy_wh)/1e3:null),l=Lt(r.peak_w),c=typeof a.uploaderName=="string"&&a.uploaderName||this._tText("store.anon",{},"anonymous"),p=this._statusTag(a),_=a.rating&&typeof a.rating=="object"?a.rating:{},f=Number(_.avg),u=Dt(_.count),b=_.avg!=null&&isFinite(f)&&u>0?this._t("store.rating_summary",{avg:it(f,1),n:u},"\u2605 {avg} ({n})"):this._t("store.no_ratings",{},"No ratings yet");return`<div class="wd-card">
|
|
<div class="wd-store-cycle-top">
|
|
${i}
|
|
<div class="wd-store-cycle-stats">
|
|
<div><b>${o}</b> \xB7 ${n} \xB7 ${l} ${p}</div>
|
|
<div class="wd-info">${this._t("store.uploaded_by",{name:c},"Shared by {name}")} \xB7 \u2B07 ${it(Dt(a.downloads),0)} \xB7 ${b}</div>
|
|
</div>
|
|
<button class="wd-btn wd-btn-primary wd-btn-sm" data-action="store-import" data-cycle-id="${d(a.id)}">${this._t("btn.import",{},"Import")}</button>
|
|
</div>
|
|
</div>`}).join("");return`<div class="wd-store-list">${this._storeLoading?this._htmlStoreLoading():t.length?e:this._storeBrowseErr?this._htmlStoreUnreachable():`<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).map(u=>[Number(u[0]),Number(u[1])]).filter(u=>isFinite(u[0])&&isFinite(u[1])):[];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]),a=e.map(u=>u[1]),r=Math.min(...s),i=Math.max(...s),o=Math.max(1,...a),n=120,l=36,c=2,p=u=>c+(i>r?(u-r)/(i-r):0)*(n-2*c),_=u=>l-c-Math.max(0,u)/o*(l-2*c),f=e.map(u=>`${p(u[0]).toFixed(1)},${_(u[1]).toFixed(1)}`).join(" ");return`<svg class="wd-store-spark" viewBox="0 0 ${n} ${l}" preserveAspectRatio="none" aria-hidden="true"><polyline fill="none" stroke="var(--primary-color)" stroke-width="1.5" points="${f}"/></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_tab",{},"Online & Community")]);let a=t.tab;s.some(([o])=>o===a)||(a=t.tab="prefs");const r=s.map(([o,n])=>`<button class="wd-subtab ${a===o?"active":""}" role="tab" aria-selected="${a===o?"true":"false"}" data-gtab="${o}">${n}</button>`).join(""),i=a==="panel"&&e?this._htmlPanelSettings():a==="access"&&e?this._htmlPanelAccess():a==="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",{},"Preferences")}<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" role="tablist">${r}</div>
|
|
<div class="wd-gear-body">${i}</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),a=this._busy.has("store-account"),r=t?s?`<div class="wd-store-conn">
|
|
<span class="wd-info">${this._t("store.connected_as",{name:e.name||e.uid||""},"Connected as {name}")}</span>
|
|
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="store-disconnect" ${a?"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. One connection covers all your WashData devices; brand and model are set per device under Basic. Online features are off until you turn them on.")}</p>
|
|
${kt(`data-action="store-toggle-online" ${t?"checked":""} ${a?"disabled":""}`,this._t("lbl.enable_online",{},"Enable online features"))}
|
|
${t?this._htmlStorePrefs(a):""}
|
|
${t?r:""}
|
|
</div>`}_htmlStorePrefs(t){const e=this._constants&&this._constants.storePrefs||{},s=ke.map(r=>{const i=e[r.key],o=i===void 0?r.def!==!1:i!==!1;return kt(`data-action="store-toggle-pref" data-pref="${d(r.key)}" ${o?"checked":""} ${t?"disabled":""}`,this._t(r.labelKey,{},r.labelFb),ot(this._t(r.docKey,{},r.docFb)))}).join(""),a=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" ${a||t?"disabled":""}>${a?'<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",{},"Community brand and appliance lists are cached to save the store's daily budget. Refresh to see entries others added or approved.")}</span>
|
|
</div>`}async _loadStoreStatus(t){if(!this._onlineEnabled()){this._storeStatus={enabled:!1},this._storeConnected=!1;return}try{const e=await this._ws({type:`${w}/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()}_renderKeepingStoreQFocus(){const t=this.shadowRoot,e=t&&t.getElementById("wd-store-q"),s=!!e&&t.activeElement===e,a=s?e.selectionStart:0;if(this._render(),!s)return;const r=t.getElementById("wd-store-q");if(!r)return;r.focus();try{r.setSelectionRange(a,a)}catch{}const i=r.parentElement&&r.parentElement.querySelector(".wd-combo-drop");i&&(i.hidden=!0)}_storeOpenDevice(t){const e=this._devices[this._selIdx];if(!e||!t)return;const s=e.entry_id;this._storeDevice=t,this._storeProfile=null,this._storeView="device",this._storeProfiles=[],this._storeCycles=[],this._storeLoading=!0,this._storeBrowseErr=!1,this._render(),this._ws({type:`${w}/store_get_profiles`,entry_id:s,device_id:t.id}).then(a=>{!this._isActiveEntry(s)||this._storeView!=="device"||(this._storeProfiles=a&&a.items||[],this._storeBrowseErr=!!(a&&a.error))}).catch(()=>{this._isActiveEntry(s)&&(this._storeProfiles=[],this._storeBrowseErr=!0)}).finally(()=>{this._isActiveEntry(s)&&(this._storeLoading=!1,this._render())})}async _storeOpenModel(t){if(!t)return;await this._storeSearch(String(t.brand||""));const e=(this._storeDevices||[]).find(s=>String(s.id)===String(t.id))||t;this._storeOpenDevice(e)}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 a=this._storeBrandScope();if(!a){this._storeDevices=[],this._storeLoading=!1,this._renderKeepingStoreQFocus();return}this._storeLoading=!0,this._renderKeepingStoreQFocus();try{const r=await this._ws({type:`${w}/store_search_devices`,entry_id:s,query:a,appliance_type:this._storeApplianceType(),include_pending:!0});if(!this._isActiveEntry(s))return;r&&r.disabled?(this._storeStatus={enabled:!1},this._storeDevices=[]):this._storeDevices=this._sortStoreDevices(r&&r.items||[])}catch(r){this._isActiveEntry(s)&&(this._storeDevices=[],this._showToast(this._tText("toast.store_search_failed",{error:r.message||r},"Search failed: "+(r.message||r)),"error"))}finally{this._isActiveEntry(s)&&(this._storeLoading=!1,this._renderKeepingStoreQFocus())}}_sortStoreDevices(t){const e=String((this._opts||{}).store_model||"").trim().toLowerCase(),s=a=>e&&String(a.model||"").toLowerCase()===e?0:this._storeItemHasContent(a)?1:2;return t.map((a,r)=>({d:a,i:r})).sort((a,r)=>s(a.d)-s(r.d)||a.i-r.i).map(a=>a.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 a=s.data;if(!a)return;const r=this._devices[this._selIdx];if(!r)return;const i=r.entry_id;if(a.type==="washdata-device-created"){const o={};a.brand&&(o.store_brand=a.brand),a.model&&(o.store_model=a.model),this._catalog.brands=void 0,this._catalog.devices=void 0,this._catalog.forBrand=null,this._catalog.brandsFull=!1,this._catalog.brandPrefixes=[],this._dropModelCandidates(),this._catalogEntry=null;const n=this._storeContribEid||i,l=Object.keys(o).length>0,c=this._isActiveEntry(n);this._showToast(l&&!c?this._t("toast.contrib_device_changed",{},"Added to the community store, but the device changed while the form was open. Select it in Settings."):this._t("toast.appliance_added",{},"Appliance added - awaiting approval")),l&&c&&await this._saveStoreOptions(o,{silent:!0}),this._render();return}if(a.type==="washdata-brand-created"){this._catalog.brands=void 0,this._catalog.brandsFull=!1,this._catalog.brandPrefixes=[],this._dropModelCandidates(),this._catalogEntry=null;const o=this._isActiveEntry(this._storeContribEid||i);this._showToast(a.brand&&!o?this._t("toast.contrib_device_changed",{},"Added to the community store, but the device changed while the form was open. Select it in Settings."):this._t("toast.brand_added",{},"Brand added - awaiting approval")),a.brand&&o&&await this._saveStoreOptions({store_brand:a.brand,store_model:""},{silent:!0}),this._render();return}if(a.type==="washdata-profile-created"){const o=this._modal;o&&o.type==="store-share"&&(o.program=a.program||o.program,this._loadShareProfiles()),this._showToast(this._t("toast.profile_added",{},"Profile added - awaiting approval"));return}if(a.type==="washdata-connect")try{const o=await this._ws({type:`${w}/store_connect`,entry_id:i,refresh_token:a.refreshToken,uid:a.uid,name:a.displayName});if(o&&o.error){this._showToast(this._tText("toast.store_connect_failed",{error:o.error},"Connect failed: "+o.error),"error");return}if(await this._loadStoreStatus(i),!this._isActiveEntry(i))return;this._showToast(this._t("toast.store_connected",{},"Connected to the community store")),this._render()}catch(o){this._showToast(this._tText("toast.store_connect_failed",{error:o.message||o},"Connect failed: "+(o.message||o)),"error")}},window.addEventListener("message",this._storeConnectListener)}async _saveStoreOptions(t,{silent:e=!1}={}){const s=this._devices[this._selIdx];if(!s)return!1;const a=s.entry_id;try{if(await this._ws({type:`${w}/set_options`,entry_id:a,options:t}),!this._isActiveEntry(a))return!0;this._opts={...this._opts,...t};for(const r of Object.keys(t))delete this._pendingSettings[r],this._dirtyOptKeys.delete(r);return e||this._showToast(this._t("toast.settings_saved",{},"Settings saved; integration reloading")),!0}catch(r){return this._showToast(this._tText("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 a=s.getBoundingClientRect(),r=window.devicePixelRatio||1,i=Math.max(1,Math.round(a.width*r)),o=Math.max(1,Math.round((a.height||240)*r));(s.width!==i||s.height!==o)&&(s.width=i,s.height=o);const n=s.getContext("2d"),l=getComputedStyle(this),c=(l.getPropertyValue("--primary-color")||"#03a9f4").trim()||"#03a9f4",p=(l.getPropertyValue("--divider-color")||"rgba(127,127,127,.3)").trim()||"rgba(127,127,127,.3)",_=(l.getPropertyValue("--secondary-text-color")||"#888").trim()||"#888",f=44*r,u=12*r,b=12*r,v=22*r,T=e.series||[];let C=e.xMax||0;C||(T.forEach(A=>(A.points||[]).forEach(O=>{O[0]>C&&(C=O[0])})),e.band&&(e.band.max||[]).forEach(A=>{A[0]>C&&(C=A[0])})),C=C||1;const x=Math.min(0,Number(e.xStart)||0),$=this._canvasZoom&&this._canvasZoom[t],L=$?$.xMin:x,D=$?$.xMax:C;let B=e.yMax||0;if(!B){const A=O=>{const K=O||[];for(let I=0;I<K.length;I++){const R=K[I][0];R<L&&!(I+1<K.length&&K[I+1][0]>=L)||R>D&&!(I>0&&K[I-1][0]<=D)||K[I][1]>B&&(B=K[I][1])}};T.forEach(O=>{O.noScale||A(O.points)}),e.band&&A(e.band.max),B=(B||10)*1.08}const U=i-f-u,z=A=>f+(A-L)/(D-L)*U,M=A=>o-v-A/B*(o-b-v);n.clearRect(0,0,i,o),n.strokeStyle=p,n.lineWidth=r,n.fillStyle=_,n.font=`${11*r}px sans-serif`,n.textAlign="right",n.textBaseline="middle";for(let A=0;A<=2;A++){const O=b+A/2*(o-b-v);n.beginPath(),n.moveTo(f,O),n.lineTo(i-u,O),n.stroke(),n.fillText(Ht(B*(1-A/2),B/2),f-4*r,O)}if(n.save(),n.beginPath(),n.rect(f,b,U,o-b),n.clip(),(e.bands||[]).forEach(A=>{n.fillStyle=A.fill;const O=z(A.x0),K=z(A.x1);n.fillRect(Math.min(O,K),b,Math.abs(K-O),o-b-v)}),e.band&&(e.band.min||[]).length&&(e.band.max||[]).length){n.beginPath(),e.band.max.forEach((A,O)=>O?n.lineTo(z(A[0]),M(A[1])):n.moveTo(z(A[0]),M(A[1])));for(let A=e.band.min.length-1;A>=0;A--)n.lineTo(z(e.band.min[A][0]),M(e.band.min[A][1]));n.closePath(),n.fillStyle=e.band.fill||Pt(c,.13),n.fill()}return T.forEach(A=>{const O=A.points||[];if(!O.length)return;const K=A.stroke==="primary"?c:A.stroke;if(A.fill){n.beginPath(),O.forEach((G,W)=>W?n.lineTo(z(G[0]),M(G[1])):n.moveTo(z(G[0]),M(G[1]))),n.lineTo(z(O[O.length-1][0]),M(0)),n.lineTo(z(O[0][0]),M(0)),n.closePath();const R=n.createLinearGradient(0,b,0,o-v);R.addColorStop(0,Pt(K,.33)),R.addColorStop(1,Pt(K,.03)),n.fillStyle=R,n.fill()}n.beginPath();let I=!1;O.forEach(R=>{if(R[1]==null){I=!1;return}I?n.lineTo(z(R[0]),M(R[1])):(n.moveTo(z(R[0]),M(R[1])),I=!0)}),n.strokeStyle=K,n.lineWidth=(A.width||1.5)*r,n.lineJoin="round",A.dash&&n.setLineDash([6*r,4*r]),n.globalAlpha=A.alpha!=null?A.alpha:1,n.stroke(),n.globalAlpha=1,A.dash&&n.setLineDash([])}),n.textAlign="center",n.textBaseline="top",(e.vlines||[]).forEach(A=>{const O=z(A.x);n.beginPath(),n.moveTo(O,b),n.lineTo(O,o-v),n.strokeStyle=A.color,n.lineWidth=2*r,n.setLineDash([4*r,3*r]),n.stroke(),n.setLineDash([]),A.handle&&(n.fillStyle=A.color,n.beginPath(),n.arc(O,b+4*r,4.5*r,0,Math.PI*2),n.fill()),A.label&&(n.fillStyle=A.color,n.fillText(A.label,O,b+(A.handle?12*r:2*r)))}),n.restore(),n.fillStyle=_,n.font=`${11*r}px sans-serif`,n.textBaseline="bottom",($||x<0)&&(n.textAlign="left",n.fillText((L/60).toFixed($?1:0)+" min",f,o-2*r)),n.textAlign="right",n.fillText((D/60).toFixed(0)+" min",i-u,o-2*r),s._wd={xMax:C,xMin:L,xViewMax:D,yMax:B,dpr:r,padT:b,padB:v,padL:f,ch:o,primary:c,xStart:x,cycleSpan:e.cycleSpan||null,plotWcss:U/r,Xpx:z,Ypx:M,xToCss:A=>z(A)/r,cssToX:A=>Math.max(L,Math.min(D,L+(A*r-f)/U*(D-L))),series:(e.series||[]).map(A=>({points:A.points,stroke:A.stroke,name:A.name,cid:A.cid,bounded:A.bounded,aside:A.aside,kind:A.kind})),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:`${w}/set_user_prefs`,prefs:{[t]:e}}).catch(()=>{})}_drawStatusCurve(){const t=this._powerData||{},e=t.live||[];if(e.length<2)return;const s=this._statusEnv,a=this._pref("show_expected",!0),r=this._pref("show_raw_active",!1),i=[];let o=e[e.length-1][0];if(t.cycle_active&&s&&(s.avg||[]).length&&a){const l=s.target_duration||s.avg[s.avg.length-1][0];i.push({points:s.avg,stroke:"#ff9800",width:2,alpha:.4,name:this._t("lbl.expected",{},"Expected")}),o=Math.max(o,l)}i.push({points:e,stroke:"primary",fill:!0,width:2,name:this._t("lbl.power",{},"Power")}),r&&(t.raw||[]).length>1&&i.push({points:t.raw,stroke:"#9e9e9e",width:1,alpha:.65,name:this._t("lbl.raw_socket",{},"Raw socket"),noScale:!0});const n=[];(t.restart_gaps||[]).forEach(l=>{if(!e.length)return;const c=t.cycle_start_iso;if(!c)return;const p=new Date(c).getTime(),_=Math.max(0,(new Date(l.start_ts).getTime()-p)/1e3),f=Math.max(_+1,(new Date(l.end_ts).getTime()-p)/1e3);n.push({x0:_,x1:f,fill:"rgba(96,125,139,.20)"})}),this._drawCurves("wd-status-canvas",{series:i,xMax:o,bands:n})}_attachGraphGestures(t){const e=this.shadowRoot,s=e&&e.getElementById(t);if(!s)return;this._syncZoomReset(t),this._attachTouchOwnershipGuard(s);const a=new Map;let r=null;const i=n=>n-s.getBoundingClientRect().left;s.addEventListener("pointerdown",n=>{if(a.set(n.pointerId,{x:n.clientX,y:n.clientY}),a.size===1){if(n.pointerType==="touch"&&this._unpinGraphTip(),n.pointerType==="touch"&&this._onAxisHandle(s,t,n)){s._wdOwnGesture=!0;try{s.setPointerCapture(n.pointerId)}catch{}}return}if(a.size!==2)return;s._wdPinching=!0,s._wdOwnGesture=!1,s._wdAbortEdit&&s._wdAbortEdit(),this._hideGraphTip();const[l,c]=[...a.values()];r={dist:Math.max(1,Math.abs(l.x-c.x)),centerCss:i((l.x+c.x)/2)}}),s.addEventListener("pointermove",n=>{if(a.has(n.pointerId)&&a.set(n.pointerId,{x:n.clientX,y:n.clientY}),s._wdPinching){r&&a.size>=2&&this._onGraphPinch(s,t,a,r,i);return}this._onGraphHover(n,t)});const o=n=>{a.delete(n.pointerId),a.size<2&&(r=null,s._wdPinching=!1),a.size===0&&(s._wdOwnGesture=!1)};s.addEventListener("pointerup",n=>{const l=s._wdPinching;o(n),n.pointerType==="touch"&&!l&&this._pinGraphTip()}),s.addEventListener("pointercancel",n=>{o(n),this._hideGraphTip()}),s.addEventListener("pointerleave",n=>{n.pointerType!=="touch"&&this._hideGraphTip()}),s.addEventListener("wheel",n=>{const l=s._wd;if(!l)return;n.preventDefault();const c=n.ctrlKey?Math.min(4,Math.max(.25,Math.exp(n.deltaY*.01))):n.deltaY>0?1.3:.75;this._zoomCanvasAbout(t,l.cssToX(i(n.clientX)),c)},{passive:!1}),s.addEventListener("dblclick",n=>{const l=s._wd;if(l){if(this._canvasZoom[t]){this._resetCanvasZoom(t),this._hideGraphTip();return}this._zoomCanvasAbout(t,l.cssToX(i(n.clientX)),Qt)}})}_attachTouchOwnershipGuard(t){t._wdTouchGuard||(t._wdTouchGuard=!0,t.addEventListener("touchmove",e=>{(e.touches.length>=2||t._wdOwnGesture)&&e.preventDefault()},{passive:!1}))}_onAxisHandle(t,e,s){const a=this._gAxisHandle?this._gAxisHandle[e]:null,r=t._wd;if(a==null||!r)return!1;const i=t.getBoundingClientRect(),o=(r.ch-r.padB)/r.dpr;return Math.abs(s.clientX-i.left-a)<=Tt&&Math.abs(s.clientY-i.top-o)<=Tt}_onGraphPinch(t,e,s,a,r){const i=t._wd;if(!i)return;const[o,n]=[...s.values()],l=Math.max(1,Math.abs(o.x-n.x)),c=r((o.x+n.x)/2),p=this._gPinchPending&&this._gPinchPending.id===e?this._gPinchPending:null,_=p?{xMin:p.lo,xMax:p.hi}:this._canvasZoom[e],f=_?_.xMin:i.xStart||0,u=(_?_.xMax:i.xMax||1)-f,b=Math.max(1,i.plotWcss),v=Math.max(0,Math.min(1,c/b)),T=f+v*u,C=u*(a.dist/l),x=T-v*C-(c-a.centerCss)/b*C;a.dist=l,a.centerCss=c,this._gPinchPending={id:e,lo:x,hi:x+C},!this._gPinchRaf&&(this._gPinchRaf=requestAnimationFrame(()=>{this._gPinchRaf=null;const $=this._gPinchPending;this._gPinchPending=null,$&&this._setCanvasViewport($.id,$.lo,$.hi)}))}_setCanvasViewport(t,e,s){const a=this.shadowRoot&&this.shadowRoot.getElementById(t),r=a&&a._wd;if(!r)return;const i=r.xStart||0,o=(r.xMax||1)-i,n=Math.min(o,Math.max(o/Se,Te)),l=Math.min(o,Math.max(n,s-e));if(l>=o*.99){this._resetCanvasZoom(t);return}const c=Math.max(i,Math.min(i+o-l,e)),p=this._canvasZoom[t];p&&Math.abs(p.xMin-c)<1e-6&&Math.abs(p.xMax-c-l)<1e-6||(this._canvasZoom[t]={xMin:c,xMax:c+l},this._redrawCanvas(t),this._syncZoomReset(t))}_zoomCanvasAbout(t,e,s){const a=this.shadowRoot&&this.shadowRoot.getElementById(t),r=a&&a._wd;if(!r)return;const i=r.xMax||1,o=this._canvasZoom[t],n=o?o.xMin:r.xStart||0,l=Math.max(1e-6,(o?o.xMax:i)-n),c=Math.max(0,Math.min(1,(e-n)/l)),p=l*s;this._setCanvasViewport(t,e-c*p,e+(1-c)*p)}_resetCanvasZoom(t){if(!this._canvasZoom[t]){this._syncZoomReset(t);return}delete this._canvasZoom[t],this._redrawCanvas(t),this._syncZoomReset(t)}_ensureZoomReset(t,e){const s=e.parentElement;if(!s)return null;let a=s.querySelector(".wd-zoom-reset");a||(a=document.createElement("button"),a.type="button",a.className="wd-zoom-reset",a.textContent="\u21BB",a.addEventListener("click",i=>{i.stopPropagation(),this._resetCanvasZoom(t)}),s.appendChild(a));const r=this._t("btn.reset_zoom",{},"Reset zoom");return a.title!==r&&(a.title=r,a.setAttribute("aria-label",r)),a}_syncZoomReset(t){const e=this.shadowRoot&&this.shadowRoot.getElementById(t);if(!e)return;const s=this._ensureZoomReset(t,e);s&&s.classList.toggle("wd-zoom-reset--on",!!this._canvasZoom[t])}_onGraphHover(t,e){const s=t.clientX,a=t.clientY,r=t.pointerType==="touch";if(this._hoverRafId){this._hoverPending={px:s,py:a,id:e,touch:r};return}this._hoverPending={px:s,py:a,id:e,touch:r},this._hoverRafId=requestAnimationFrame(()=>{this._hoverRafId=null;const{px:i,py:o,id:n,touch:l}=this._hoverPending||{};n&&this._onGraphHoverInner(i,o,n,{touch:l})})}_onGraphHoverInner(t,e,s,a={}){const r=this.shadowRoot&&this.shadowRoot.getElementById(s),i=r&&r._wd;if(!i)return;const o=r.getBoundingClientRect(),n=i.cssToX(t-o.left),l=(e-o.top)*i.dpr;this._redrawCanvas(s);const c=r.getContext("2d"),p=i.Xpx(n);c.save(),c.strokeStyle="rgba(140,140,140,.75)",c.lineWidth=i.dpr,c.setLineDash([3*i.dpr,3*i.dpr]),c.beginPath(),c.moveTo(p,i.padT),c.lineTo(p,i.ch-i.padB),c.stroke(),c.setLineDash([]);const _=$=>$.stroke==="primary"?i.primary:$.stroke,f=($,L)=>{c.fillStyle=L,c.beginPath(),c.arc(p,i.Ypx($),3.4*i.dpr,0,6.2832),c.fill()},u=i.cycleSpan,b=u&&n<u[0]?[`${this._t("lbl.before_start",{},"Before start")}: <b>${$t(u[0]-n)}</b>`]:u&&n>u[1]?[`${this._t("lbl.after_end",{},"After end")}: <b>${$t(n-u[1])}</b>`]:[`${this._t("lbl.from_start",{},"From start")}: <b>${$t(n)}</b>`,`${this._t("lbl.to_end",{},"To end")}: <b>${$t(Math.max(0,(u?u[1]:i.xMax)-n))}</b>`],v=i.series||[],T=$=>$.bounded?Ue($.points,n):Gt($.points,n);if(this._hoverNearest=null,v.filter($=>!$.aside).length>4){let $=null,L=1/0;if(v.forEach(D=>{const B=T(D);if(B==null)return;const U=Math.abs(i.Ypx(B)-l);U<L&&(L=U,$={s:D,v:B})}),$){const D=_($.s);c.strokeStyle=D,c.lineWidth=3*i.dpr,c.beginPath(),($.s.points||[]).forEach((B,U)=>U?c.lineTo(i.Xpx(B[0]),i.Ypx(B[1])):c.moveTo(i.Xpx(B[0]),i.Ypx(B[1]))),c.stroke(),f($.v,D),b.push(`${d($.s.name||"")}: <b>${it($.v,$.v<100?1:0)} W</b>`),$.s.cid&&(b.push(`<span style="opacity:.7">${this._t("lbl.click_to_select",{},"click to select")}</span>`),this._hoverNearest={id:s,cid:$.s.cid})}}else v.forEach($=>{const L=T($);L!=null&&(f(L,_($)),b.push(`${d($.name||this._t("lbl.power",{},"Power"))}: <b>${it(L,L<100?1:0)} W</b>`))});if(i.band){const $=Gt(i.band.min,n),L=Gt(i.band.max,n);$!=null&&L!=null&&b.push(`${this._t("lbl.envelope",{},"Envelope")}: ${it($,$<100?1:0)}\u2013${it(L,L<100?1:0)} W`)}(i.artifacts||[]).forEach($=>{if(n>=$.start_s&&n<=$.end_s){const L=$.detail_key?this._tText($.detail_key,$.detail_params||{},$.detail||""):$.detail||"";b.push(`<span style="color:var(--warning-color,#ff9800)">\u26A0 ${d(ue($.type,(D,B,U)=>this._t(D,B,U)))}</span>: ${d(L)}`)}});const C=!!this._canvasZoom[s];let x=null;if(a.touch?x=C?this._t("lbl.zoom_hint_touch_zoomed",{},"pinch to zoom \xB7 double-tap to reset"):this._t("lbl.zoom_hint_touch",{},"pinch to zoom"):C&&(x=this._t("lbl.zoom_hint",{},"scroll to zoom \xB7 dblclick to reset")),x&&b.push(`<span style="opacity:.45">${d(x)}</span>`),a.touch){const $=i.ch-i.padB;c.beginPath(),c.arc(p,$,Tt*.41*i.dpr,0,6.2832),c.fillStyle=i.primary,c.fill(),c.strokeStyle="rgba(255,255,255,.92)",c.lineWidth=1.6*i.dpr,c.stroke(),this._gAxisHandle||(this._gAxisHandle={}),this._gAxisHandle[s]=i.xToCss(n)}else this._gAxisHandle&&delete this._gAxisHandle[s];c.restore(),this._showGraphTip(t,e,b,{touch:a.touch,rect:o}),this._syncSpagRowHighlight(this._hoverNearest?this._hoverNearest.cid:null)}_showGraphTip(t,e,s,a={}){const r=this._gtip;if(!r)return;this._gtipLines=s,r.innerHTML=s.join("<br>"),r.classList.remove("wd-gtip--pinned"),this._gtipPinned=!1,r.style.display="block";const i=r.offsetWidth,o=r.offsetHeight,n=16;let l,c;if(a.touch&&a.rect){const p=a.rect,_=8;p.top-o-_>=6?c=p.top-o-_:p.bottom+_+o<=window.innerHeight-6?c=p.bottom+_:c=e>p.top+p.height*.5?p.top+6:p.bottom-o-6,l=t-i/2}else l=t+n,c=e+n,c+o>window.innerHeight-6&&(c=e-o-n),l+i>window.innerWidth-6&&(l=t-i-n);r.style.left=Math.max(6,Math.min(l,window.innerWidth-i-6))+"px",r.style.top=Math.max(6,Math.min(c,window.innerHeight-o-6))+"px"}_pinGraphTip(){const t=this._gtip;if(!t||t.style.display==="none"||this._gtipPinned)return;this._gtipPinned=!0,t.classList.add("wd-gtip--pinned");const e=this._t("lbl.tap_to_dismiss",{},"tap the chart to dismiss");t.innerHTML=(this._gtipLines||[]).join("<br>")+`<span class="wd-gtip-dismiss">${d(e)}</span>`}_maybeDismissGraphTip(t){if(!this._gtipPinned)return;const e=t.target;e&&e.closest&&e.closest(".wd-canvas-wrap")||this._unpinGraphTip()}_unpinGraphTip(){this._gtipPinned&&(this._gtipPinned=!1,this._gtip&&(this._gtip.classList.remove("wd-gtip--pinned"),this._gtip.style.display="none"))}_hideGraphTip(){this._hoverRafId&&(cancelAnimationFrame(this._hoverRafId),this._hoverRafId=null,this._hoverPending=null),this._gtipPinned=!1,this._gtip&&(this._gtip.classList.remove("wd-gtip--pinned"),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,a=s?e.style.cssText:"";s&&(e.style.visibility="hidden",e.style.display="block");const r=e.getBoundingClientRect();if(s&&(e.style.cssText=a),!r.width)return;const i=be(t),o=6,n=i.left+o,l=i.right-o-r.width,c=l<n?n:Math.min(Math.max(r.left,n),l),p=Math.round(c-r.left);p&&(e.style.transform=`translateX(calc(-50% + ${p}px))`)}_positionComboDrop(t,e){const s=t.getBoundingClientRect();if(!s.height)return;const a=be(t),r=8,i=a.bottom-s.bottom-r,o=s.top-a.top-r,n=i<120&&o>i,l=Math.max(64,Math.floor(n?o:i));e.classList.toggle("wd-drop-up",n),e.style.maxHeight=`${Math.min(220,l)}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 a=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()},a)}async _openAutoLabelModal(t){let e=this._opts?this._opts.auto_label_confidence:void 0;if(e==null&&t)try{const r=await this._ws({type:`${w}/get_options`,entry_id:t});if(!this._isActiveEntry(t))return;e=(r&&r.options||{}).auto_label_confidence}catch{}e==null&&(e=(wt.auto_label_confidence||{}).def);const s=Number(e),a=Number.isFinite(s)?+Math.min(.95,Math.max(.5,s)).toFixed(2):.9;this._modal={type:"auto-label",threshold:a},this._render()}_autoLabelToast(t){const e=t&&t.result;return!e||typeof e.labeled!="number"?this._tText("msg.toast_auto_label_complete",{},"Auto-label complete"):t.state==="cancelled"?this._tText("toast.auto_label_cancelled",{count:e.labeled},"Auto-label cancelled after labelling {count} cycles."):this._tText("toast.auto_label_done",{count:e.labeled,skipped:e.skipped||0},"Auto-label complete: {count} cycles labelled, {skipped} left unlabelled.")}_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"){const s=(t.items||[]).map(a=>`<li>${d(a.text)}${a.sub?`<div class="wd-sug-impact-line">${d(a.sub)}</div>`:""}</li>`).join("");e=`<h2>${d(t.title)}</h2><p class="wd-info">${d(t.message)}</p>${s?`<ul class="wd-confirm-list">${s}</ul>`:""}
|
|
<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 ${t.okPrimary?"wd-btn-primary":"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);let a=s.map(r=>`<option value="${d(r.id)}" ${t.prefillCycle===r.id?"selected":""}>${et(r.start_time)} - ${Math.round((r.duration||0)/60)}m - ${d(r.profile_name||this._t("lbl.unlabelled",{},"Unlabelled"))}</option>`).join("");t.prefillCycle&&!s.some(r=>r.id===t.prefillCycle)&&(a=`<option value="${d(t.prefillCycle)}" selected>${d(t.prefillCycleMin?t.prefillCycleMin+"m":"")} - ${d(this._t("lbl.unlabelled",{},"Unlabelled"))}</option>`+a),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="">${this._t("lbl.ref_cycle_none",{},"None")}</option>${a}</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 without a reference cycle; 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 copy; delete the copy to restore the original.")}</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">${this._t("msg.feedback_detected",{name:vt(`<strong>${d(t.detectedProfile)}</strong>`)},"WashData detected: {name}")}</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==="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="${d(String(t.threshold!=null?t.threshold:.9))}" min="0.5" max="0.95" step="0.01"></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",a=`<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>${a}</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:[],a=[],r=new Set,i=l=>{const c=(l||"").toLowerCase();l&&!r.has(c)&&(r.add(c),a.push(l))};i(t.program),s.forEach(l=>i(l.program));const o=t.profiles==null?` <span class="wd-info" style="font-size:.85em">${this._t("msg.loading",{},"Loading\u2026")}</span>`:"",n=a.length?a.map(l=>`<option value="${d(l)}" ${l===t.program?"selected":""}>${d(l)}</option>`).join(""):t.storeUnreachable?'<option value=""></option>':`<option value="">${this._t("msg.no_profiles_option",{},"(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>
|
|
${t.storeUnreachable?this._htmlStoreUnreachable():""}
|
|
<div class="wd-field"><label>${this._t("lbl.profile",{},"Profile")}${o}</label>
|
|
<div class="wd-combo-row">
|
|
<select id="wd-store-share-prog">${n}</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"),a=t.selected||new Set,r=!!t.consented,i=e.reduce((u,b)=>u+b.cycles.filter(v=>a.has(v.id)).length,0),o=e.some(u=>!u.noCycles);let n;t.loading?n=`<div class="wd-empty" style="padding:24px"><div class="wd-icon">\u23F3</div>${this._t("msg.loading",{},"Loading\u2026")}</div>`:e.length?n=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 \u2013 mark a cycle as \u2B50 in the Cycles tab to include this profile")}</span>
|
|
</div>
|
|
</div>`;const b=u.cycles.every($=>a.has($.id)),v=!b&&u.cycles.some($=>a.has($.id)),T=u.cycles.map($=>{const L=$.start_time?et($.start_time):"",D=$.duration!=null?ht($.duration):"";return`<label class="wd-sd-cyc">
|
|
<input type="checkbox" data-maction="sd-toggle-cyc" data-cid="${d($.id)}" ${a.has($.id)?"checked":""} ${s?"disabled":""}>
|
|
<span class="wd-sd-cyc-meta">${d(L)} \xB7 ${d(D)} \u2B50</span>
|
|
</label>`}).join(""),x=(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)}" ${b?"checked":""} ${v?'data-indeterminate="1"':""} ${s?"disabled":""}>
|
|
<span class="wd-sd-prof-name">${d(u.program)}</span>
|
|
<span class="wd-sd-count">${u.cycles.filter($=>a.has($.id)).length}/${u.cycles.length}</span>
|
|
</label>
|
|
<div class="wd-sd-cycles">${T}</div>
|
|
${x}
|
|
</div>`}).join(""):n=`<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=(this._opts.store_brand||"").trim(),c=(this._opts.store_model||"").trim(),p=`<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>
|
|
${ot(this._t("msg.include_settings_hint",{},"Share this device's detection and matching thresholds (not notifications, entities or energy price). Others choose whether to apply them."))}
|
|
</label>`,_=`<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 finished normally: no interruptions, door openings 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>`,f=o?`<label class="wd-sd-consent">
|
|
<input type="checkbox" data-maction="sd-toggle-consent" ${r?"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 {brand} {model} with the reference cycles you select. Others with the same appliance can adopt your programs. Entries are reviewed before appearing publicly.")}</p>
|
|
${_}
|
|
<div class="wd-sd-tree">${n}</div>
|
|
${o?p:""}
|
|
${f}
|
|
<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||!i||!r?"disabled":""}>${s?'<span class="wd-spin"></span> ':""}${this._t("btn.share_n",{n:i},`Share ${i} cycle(s)`)}</button>
|
|
</div>`}_wizCatOrder(){return["profiles","real_cycles","reference_cycles","backfill_cycles","custom_phases","profile_groups","settings","ml_models","feedback","suggestions","maintenance_log","history_logs","lifetime_stats"]}_wizCycleCats(){return["real_cycles","reference_cycles","backfill_cycles"]}_wizIdSet(t,e){return e==="real_cycles"?t.realIds:e==="backfill_cycles"?t.bfIds:t.refIds}_wizEmptySel(){return{cats:new Set,profiles:new Set,realIds:new Set,refIds:new Set,bfIds:new Set}}_wizAnySel(t){return!!(t&&(t.profiles.size||t.realIds.size||t.refIds.size||t.bfIds.size||t.cats.size))}_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)"),backfill_cycles:this._t("lbl.cat_backfill_cycles",{},"Cycles found in imported power history"),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"),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=this._wizEmptySel(),a=t&&t.categories||{},r=i=>!!(i&&i.present&&(!e||i.importable!==!1));return r(a.profiles)&&(a.profiles.items||[]).forEach(i=>s.profiles.add(i.name)),this._wizCycleCats().forEach(i=>{const o=this._wizIdSet(s,i);r(a[i])&&(a[i].groups||[]).forEach(n=>n.cycles.forEach(l=>{l.id!=null&&o.add(String(l.id))}))}),Object.keys(a).forEach(i=>{i==="profiles"||this._wizCycleCats().includes(i)||r(a[i])&&s.cats.add(i)}),s}_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.bfIds.size&&s.push("backfill_cycles"),e.cats.forEach(r=>s.push(r));const a={categories:s};return e.profiles.size&&(a.profiles=Array.from(e.profiles)),e.realIds.size&&(a.real_cycle_ids=Array.from(e.realIds)),e.refIds.size&&(a.reference_cycle_ids=Array.from(e.refIds)),e.bfIds.size&&(a.backfill_cycle_ids=Array.from(e.bfIds)),a}_wizGroupIds(t,e,s){const r=(((t.categories||{})[e]||{}).groups||[]).find(i=>i.profile===s);return r?r.cycles.filter(i=>i.id!=null).map(i=>String(i.id)):[]}_wizCatState(t,e,s){const a=t.sel,r=(s.categories||{})[e]||{};if(e==="profiles"){const o=r.items||[],n=o.length,l=o.filter(c=>a.profiles.has(c.name)).length;return{sel:l,total:n,state:l===0?"none":l===n?"all":"some"}}if(this._wizCycleCats().includes(e)){const o=this._wizIdSet(a,e),n=r.count||0;let l=0;return(r.groups||[]).forEach(c=>c.cycles.forEach(p=>{o.has(String(p.id))&&l++})),{sel:l,total:n,state:l===0?"none":l===n?"all":"some"}}const i=a.cats.has(e);return{sel:i?1:0,total:1,state:i?"all":"none"}}_htmlSelectionTree(t,e,s={}){const a=e&&e.categories||{},r=new Set;s.conflicts&&(a.profiles&&a.profiles.items||[]).forEach(p=>{p.conflict&&r.add(p.name)});const i=this._wizCatOrder().filter(p=>a[p]&&a[p].present);let o=!1,n=!0;i.forEach(p=>{if(s.importableOnly&&a[p].importable===!1)return;const _=this._wizCatState(t,p,e);_.state!=="none"&&(o=!0),_.state!=="all"&&(n=!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" ${n?"checked":""} ${!n&&o?'data-indeterminate="1"':""}>
|
|
<span class="wd-sd-prof-name"><strong>${this._t("btn.select_all",{},"Select all")}</strong></span>
|
|
</label>`,c=i.map(p=>{const _=a[p],f=s.importableOnly&&_.importable===!1,u=this._wizCatState(t,p,e),v=p==="profiles"||this._wizCycleCats().includes(p)?`${u.sel}/${_.count}`:"",T=`<label class="wd-sd-prof">
|
|
<input type="checkbox" data-maction="wiz-toggle-cat" data-cat="${p}" ${u.state==="all"?"checked":""} ${u.state==="some"?'data-indeterminate="1"':""} ${f?"disabled":""}>
|
|
<span class="wd-sd-prof-name">${d(this._wizCatLabel(p))}${f?` <span class="wd-tag">${this._t("badge.not_importable",{},"n/a here")}</span>`:""}</span>
|
|
<span class="wd-sd-count">${v}</span>
|
|
</label>`;let C="";if(!f&&p==="profiles")C=`<div class="wd-sd-cycles">${(_.items||[]).map(x=>`<label class="wd-sd-cyc">
|
|
<input type="checkbox" data-maction="wiz-toggle-profile" data-name="${d(x.name)}" ${t.sel.profiles.has(x.name)?"checked":""}>
|
|
<span class="wd-sd-cyc-meta">${d(x.name)} \xB7 ${(x.real_cycles||0)+(x.reference_cycles||0)+(x.backfill_cycles||0)} ${this._t("lbl.cycles_short",{},"cycles")}${r.has(x.name)?` <span class="wd-tag" style="color:var(--warning-color,#e6a700)">${this._t("badge.exists",{},"exists")}</span>`:""}</span>
|
|
</label>`).join("")}</div>`;else if(!f&&this._wizCycleCats().includes(p)){const x=this._wizIdSet(t.sel,p);C=`<div class="wd-sd-cycles">${(_.groups||[]).map($=>{const L=$.cycles.map(O=>String(O.id)),D=L.filter(O=>x.has(O)).length,B=D===L.length&&L.length>0,U=D>0&&!B,z=`${p}:${$.profile}`,M=t.expanded&&t.expanded.has(z),A=M?$.cycles.map(O=>`<label class="wd-sd-cyc" style="margin-left:18px">
|
|
<input type="checkbox" data-maction="wiz-toggle-cyc" data-cat="${p}" data-cid="${d(String(O.id))}" ${x.has(String(O.id))?"checked":""}>
|
|
<span class="wd-sd-cyc-meta">${d(O.date?et(O.date):String(O.id))}${O.duration!=null?" \xB7 "+ht(O.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="${p}" data-prof="${d($.profile)}" ${B?"checked":""} ${U?'data-indeterminate="1"':""}>
|
|
<span class="wd-sd-cyc-meta"><strong>${d($.profile||this._t("lbl.unlabelled",{},"Unlabelled"))}</strong> (${D}/${$.count})</span>
|
|
</label>
|
|
<button type="button" class="wd-linkbtn" data-maction="wiz-expand" data-key="${d(z)}" style="margin-left:auto;background:none;border:none;color:var(--primary-color);cursor:pointer">${M?"\u25BE":"\u25B8"}</button>
|
|
</div>
|
|
${A}
|
|
</div>`}).join("")}</div>`}return`<div class="wd-sd-group">${T}${C}</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 a=this._wizAnySel(t.sel);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 what to include. Profiles without their cycles still export a matchable program.")}</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||!a?"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=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 shows 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" ${T?"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" ${T?"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" ${T?"disabled":""}>${T?'<span class="wd-spin"></span> '+this._t("msg.analyzing",{},"Analyzing\u2026"):this._t("btn.analyze_import",{},"Analyze file")}</button>
|
|
</div>`}const a=t.manifest||{},r=a.device_type_match===!1,i=a.source_device_type||"?",o=a.local_device_type||"?",n=r?`<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:i,local:o},"This export is from a different appliance type ({src} vs {local}). Programs and cycles can still be imported as reference data, but not device settings or real history.")}
|
|
</div>`:"",l=this._htmlSelectionTree(t,a,{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.replace_undo_hint",{},"A restore point is saved first, so you can undo this import from Export / Import."):this._t("msg.merge_hint",{},"Imported items are added; nothing local is lost. Name clashes are resolved below.")}</div>
|
|
</div>`,p=a.real_history_allowed!==!1,_=`<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" ${p?"":"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 statistics. Use this to move an 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 f="";const b=(a.categories&&a.categories.profiles&&a.categories.profiles.items||[]).filter(T=>T.conflict&&t.sel.profiles.has(T.name));t.mode==="merge"&&b.length&&(f=`<div class="wd-field"><label>${this._t("lbl.conflict_resolution",{},"Name clashes")}</label>
|
|
${b.map(T=>`<div style="display:flex;align-items:center;gap:8px;margin:4px 0">
|
|
<span style="flex:1">${d(T.name)}</span>
|
|
<select data-maction="imp-conflict" data-prof="${d(T.name)}">
|
|
<option value="import_as_copy" ${(t.conflicts[T.name]||"import_as_copy")==="import_as_copy"?"selected":""}>${this._t("lbl.conflict_import_copy",{},"Import as copy")}</option>
|
|
<option value="keep_mine" ${t.conflicts[T.name]==="keep_mine"?"selected":""}>${this._t("lbl.conflict_keep_mine",{},"Keep mine")}</option>
|
|
<option value="overwrite" ${t.conflicts[T.name]==="overwrite"?"selected":""}>${this._t("lbl.conflict_overwrite",{},"Overwrite")}</option>
|
|
</select>
|
|
</div>`).join("")}
|
|
</div>`);const v=this._wizAnySel(t.sel);return`${s}
|
|
${n}
|
|
${l}
|
|
${c}
|
|
${_}
|
|
${f}
|
|
<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||!v?"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"),already_recorded:this._t("lbl.hist_reason_already_recorded",{},"overlaps a cycle WashData already recorded")}[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 x=this._busy.has("hist-import"),$=x?"disabled":"";return`${e}
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.hist_input_hint",{},"Upload a CSV from the History panel (entity, state, last changed), or let WashData read the sensor's history directly. Live detection runs over it and you choose which cycles 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" ${$}></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" ${$}>${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||Ut())}" min="${d(Fe())}" max="${d(Be())}" ${$}>
|
|
<button class="wd-btn wd-btn-secondary" data-maction="hist-recorder" ${$}>${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 until now. Home Assistant keeps detailed history for 10 days by default; older hourly averages are too coarse for cycle detection.")}</div>
|
|
</div>
|
|
${s}
|
|
<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="hist-scan" ${$}>${x?'<span class="wd-spin"></span> ':""}${this._t("btn.hist_scan",{},"Scan for cycles")}</button>
|
|
</div>`}if(t.step==="scan"){const x=t.scanTaskId?(this._tasks||{})[t.scanTaskId]:null,$=x&&x.total>0?Math.round(x.done/x.total*100):null;return`${e}
|
|
<p class="wd-info" style="margin-bottom:12px">${this._t("msg.hist_scanning",{},"Running your history through the detector in the background. You can close this dialog and come back.")}</p>
|
|
<div class="wd-prog-bg" style="margin-bottom:8px"><div class="wd-prog-fill" style="width:${$??0}%"></div></div>
|
|
<p class="wd-info">${$==null?this._t("status.preparing",{},"Preparing\u2026"):`${$}%`}</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 x=t.done||{},$=[this._tText("msg.hist_imported_count",{n:x.imported||0},`${x.imported||0} cycles imported.`),x.duplicates?this._tText("msg.hist_duplicates",{n:x.duplicates},`${x.duplicates} were already imported and were skipped.`):"",x.capped?this._t("msg.hist_capped",{},"The per-device limit for imported cycles was reached; the rest were not stored."):""].filter(Boolean);return`${e}
|
|
${$.map(L=>`<p class="wd-info">${d(L)}</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 its program.")}</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 a=t.result||{},r=a.segments||[],i=a.parse||{},o=this._busy.has("hist-apply"),n=t.accept||new Set,l=[];i.rows_total&&l.push(this._tText("msg.hist_rows_read",{n:i.rows_total},`${i.rows_total} readings read`)),i.first&&i.last&&l.push(`${et(i.first)} \u2013 ${et(i.last)}`),i.breaks&&l.push(this._tText("msg.hist_breaks",{n:i.breaks},`${i.breaks} gaps where the sensor was unavailable`)),i.rows_other_entity&&l.push(this._tText("msg.hist_other_entity",{n:i.rows_other_entity},`${i.rows_other_entity} readings for other entities ignored`)),i.truncated&&l.push(this._tText("msg.hist_truncated",{n:i.rows_total||0},`stopped at the ${i.rows_total||0}-reading limit, so the rest of the history was not scanned`)),i.entity_substituted_from&&l.push(this._tText("msg.hist_entity_substituted",{used:i.entity_id||"?",wanted:i.entity_substituted_from},`read ${i.entity_id||"?"} (this device is configured for ${i.entity_substituted_from})`));const c={looks_like_kw:this._tText("msg.hist_warn_kw",{w:i.peak_w},`The highest reading is only ${i.peak_w}. This sensor seems to report kilowatts; WashData needs watts, so no cycles can be detected.`),naive_timestamps:this._tText("msg.hist_warn_naive_time",{n:i.rows_naive_time||0},`${i.rows_naive_time||0} timestamps have no time zone and were read as UTC. If the file uses local time, cycles are shifted by your UTC offset and readings near a DST change may be dropped.`)},p=(i.warnings||[]).map(x=>c[x]).filter(Boolean).map(x=>`<p class="wd-info wd-hist-warn" style="color:var(--warning-color,#ff9800)">\u26A0 ${d(x)}</p>`).join(""),_=a.skipped||[],f={};_.forEach(x=>{f[x.reason]=(f[x.reason]||0)+1});const u=Object.entries(f).map(([x,$])=>`${$} \xD7 ${this._histSkipReason(x)}`).join(", "),b=a.settings||{},v=b.min_power!=null?this._tText("msg.hist_settings_used",{w:b.min_power,s:b.off_delay},`Detected using this device's current settings (minimum power ${b.min_power} W, off delay ${b.off_delay} s).`):"";if(!r.length)return`${e}
|
|
<p class="wd-info">${this._t("msg.hist_none_found",{},"No cycles could be detected in that history.")}</p>
|
|
${p}
|
|
${l.length?`<p class="wd-info">${d(l.join(" \xB7 "))}</p>`:""}
|
|
${u?`<p class="wd-info">${this._t("msg.hist_skipped_spans",{},"Skipped stretches")}: ${d(u)}</p>`:""}
|
|
${v?`<p class="wd-info">${d(v)}</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 T=r.map(x=>{const $=n.has(x.index),L=this._histSegReason(x.reason);return`<tr data-hist-row="${x.index}" style="${$?"":"opacity:.55"}">
|
|
<td><input type="checkbox" data-hist-pick="${x.index}" ${$?"checked":""} aria-label="${d(this._t("lbl.hist_keep",{},"Keep this cycle"))}"></td>
|
|
<td>${d(et(x.start_time))}</td>
|
|
<td${x.banked_tail_s?` title="${d(this._tText("msg.hist_tail_trimmed",{min:Math.round(x.banked_tail_s/60)},`${Math.round(x.banked_tail_s/60)} min of waiting after the cycle ended were trimmed, as the live integration trims them`))}"`:""}>${d(ht(x.duration_s))}${x.banked_tail_s?" \u2702":""}</td>
|
|
<td>${x.energy_wh!=null?d(it(x.energy_wh/1e3,2))+" kWh":"\u2013"}</td>
|
|
<td>${d(String(Math.round(x.peak_w)))} W</td>
|
|
<td><canvas class="wd-prof-spark" data-hist-spark="${x.index}" width="64" height="20" aria-hidden="true"></canvas></td>
|
|
<td>${L?`<span title="${d(L)}" style="color:var(--warning-color,#ff9800)">\u26A0</span> <span class="wd-info">${d(L)}</span>`:`<span class="wd-info">${d(this._t("lbl.hist_looks_complete",{},"complete"))}</span>`}</td>
|
|
</tr>`}).join(""),C=r.every(x=>n.has(x.index));return`${e}
|
|
<p class="wd-info" style="margin-bottom:8px">${this._t("msg.hist_found",{n:r.length},`Found ${r.length} cycles. Untick anything that does not look like a real run - nothing is stored until you import.`)}</p>
|
|
${p}
|
|
${l.length?`<p class="wd-info" style="margin-bottom:4px">${d(l.join(" \xB7 "))}</p>`:""}
|
|
${u?`<p class="wd-info" style="margin-bottom:4px">${this._t("msg.hist_skipped_spans",{},"Skipped stretches")}: ${d(u)}</p>`:""}
|
|
${v?`<p class="wd-info" style="margin-bottom:12px">${d(v)}</p>`:""}
|
|
<div style="margin-bottom:8px"><button class="wd-btn wd-btn-secondary wd-btn-sm" data-maction="hist-toggle-all">${C?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>${T}</tbody></table>
|
|
</div>
|
|
${a.capped?`<p class="wd-info">${this._t("msg.hist_scan_capped",{n:a.found},`Only the first candidates are shown (${a.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||!n.size?"disabled":""}>${o?'<span class="wd-spin"></span> ':""}${this._t("btn.hist_import_n",{n:n.size},`Import ${n.size} cycles`)}</button>
|
|
</div>`}_drawHistorySparklines(){const t=this.shadowRoot;if(!t)return;const e=this._modal,s=e&&e.result&&e.result.segments||[],a={};s.forEach(r=>{a[String(r.index)]=r.curve||[]}),t.querySelectorAll("canvas[data-hist-spark]").forEach(r=>{this._paintSparkline(r,a[r.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,a=e.labelable!==!1,r=e.editable!==!1,i=e.cycle_origin==="backfill",o=e.full_duration_s||e.duration||0,n=e.energy_kwh!=null?e.energy_kwh:null,l=t.ml||null,c=`<div class="wd-kv">
|
|
<div class="wd-kv-item"><div class="wd-kv-val">${ht(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(n)}</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?this._cycleStatusLabel(e.status):"-")}</div><div class="wd-kv-lbl">${this._t("lbl.status",{},"Status")}</div></div>
|
|
</div>`,p=l&&l.ml_review||{},f=(this._feedbacks||[]).some(I=>I.cycle_id===t.cycleId),u=f&&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>`:"",b=this._canEdit()&&r?`<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="${f?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")}${u}</button>
|
|
</div>`:s?`<div class="wd-info" style="margin:0 0 8px"><span style="color:var(--info-color,#2196f3)">\u{1F4E5}</span> ${i?this._t("msg.imported_history_readonly",{},"Found in imported power history. It helps 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. Used for reference and matching, not counted in your stats, and cannot be edited.")}</div>`:"",v=this._canEdit()?(this._feedbacks||[]).find(I=>I.cycle_id===t.cycleId):null,T=v?v.detected_profile||v.profile_name||this._t("lbl.unknown",{},"Unknown"):"",C=v?`
|
|
<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(T)}</strong>${v.confidence!=null?` (${this._t("lbl.confidence",{},"confidence").toLowerCase()} ${(v.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(T)}">${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 x="";if(t.mode==="view"){const I=!!(l&&l.ml_review&&l.ml_review.golden),G=this._canEdit()&&this._onlineEnabled()&&this._storeConnected&&I?`<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>`:"",W=this._canEdit()?`<button class="wd-btn wd-btn-danger" data-maction="cyc-delete">${this._t("btn.delete",{},"Delete")}</button>${a?`
|
|
<button class="wd-btn wd-btn-primary" data-maction="cyc-label">${this._t("btn.label",{},"Label")}</button>`:""}`:"";x=`${C}<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t("btn.close",{},"Close")}</button>
|
|
${G}
|
|
${W}</div>`}else if(t.mode==="trim"){const I=this._busy.has("cyc-trim-apply"),R=t.timeMode||"s",G=R==="clock"?this._offsetToClock(t.trim.start):Math.round(t.trim.start),W=R==="clock"?this._offsetToClock(t.trim.end):Math.round(t.trim.end),J=R==="clock"?"time":"number",Y=R==="clock"?'step="1"':`min="0" max="${Math.ceil(o)}" step="1"`,nt=R==="clock"?"":" "+this._t("lbl.unit_s",{},"(s)");x=`<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 ${R==="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 ${R==="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")}${nt}</label><input type="${J}" id="wd-trim-start" ${Y} value="${G}"></div>
|
|
<div class="wd-field"><label>${this._t("lbl.end",{},"End")}${nt}</label><input type="${J}" id="wd-trim-end" ${Y} value="${W}"></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" ${I?"disabled":""}>${I?'<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 I=this._busy.has("cyc-split-apply"),R=(t.split.offsets||[]).slice().sort((J,Y)=>J-Y),G=[0,...R,o],W=G.slice(0,-1).map((J,Y)=>{const nt=G[Y+1];return`<div class="wd-seg-row"><span class="wd-swatch" style="background:${Q[Y%Q.length]}"></span>
|
|
<span style="min-width:120px">${ht(J)} \u2013 ${ht(nt)}</span>
|
|
<select data-segidx="${Y}"><option value="">${this._t("lbl.unlabelled_paren",{},"(unlabelled)")}</option>${this._profileOptions(t.split.profiles[Y])}</select></div>`}).join("");x=`<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 part 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">${R.length?W:`<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" ${I||!R.length?"disabled":""}>${I?'<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 I=l&&l.ml_review||{},R=this._busy.has("cyc-review-save"),G=(m,g)=>`<option value="${m}" ${(I.quality||"")===m?"selected":""}>${g}</option>`,J=[["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(([m,g])=>`<label class="wd-rev-tag"><input type="checkbox" class="wd-cyc-rev-tag" value="${m}" ${(I.tags||[]).includes(m)?"checked":""}> ${g}</label>`).join(""),Y=I.reviewed_at?`<span style="font-size:.75em;color:var(--secondary-text-color)">${this._t("lbl.reviewed_on",{date:Oe(I.reviewed_at)},"reviewed {date}")}</span>`:"",nt=ot(this._t("msg.review_profile_tip",{},"The program this cycle is labelled as. If it is wrong, correct it here; labels teach matching.")),_t=ot(this._t("msg.review_quality_tip",{},"How clean this cycle is. Good: a textbook run of this program. Bad: detected, but noisy or unusual. Unusable: mis-detected (merged, cut short or spurious). Affects the health score and which cycles train the model.")),ct=ot(this._t("msg.review_recorded_tip",{},"Mark this as a hand-picked reference cycle for its program, like a manually recorded cycle. Reference cycles are always kept, seed the matching template, and are never removed by cleanup.")),dt=ot(this._t("msg.review_tags_tip",{},"Optional flags for what went wrong, so training and cleanup can allow for it.")),ut=ot(this._t("msg.review_notes_tip",{},"Free-text notes for your own reference. Not used by matching or training."));x=`
|
|
${C}
|
|
<p style="font-size:.82em;color:var(--secondary-text-color);margin:8px 0 12px">
|
|
${this._t("msg.review_confirm_help",{},"Was this cycle detected correctly? Confirming cycles improves matching for your machine. 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")}${nt}
|
|
<select id="wd-cyc-rev-label" class="wd-filter-select"><option value="">${this._t("lbl.unlabelled_paren",{},"(unlabelled)")}</option>${this._profileOptions(t.reviewLabel!==void 0?t.reviewLabel:e.profile_name)}</select>
|
|
</label>
|
|
<label style="display:inline-flex;align-items:center;gap:6px">${this._t("lbl.quality",{},"Quality")}${_t}
|
|
<select id="wd-cyc-rev-quality" class="wd-filter-select">${G("","-")}${G("good",this._t("quality.good",{},"Good"))}${G("bad",this._t("quality.bad",{},"Bad"))}${G("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" ${I.golden?"checked":""}> ${this._t("badge.golden_cycle",{},"Recorded reference cycle")}${ct}</label>
|
|
${Y}
|
|
</div>
|
|
<div class="wd-rev-sub">${this._t("lbl.compare_profiles",{},"Compare with profiles")}${ot(this._t("msg.compare_profiles_tip",{},"Overlay other profiles on the chart to see which fits this cycle best."))}</div>
|
|
<div class="wd-rev-tags">${(this._profiles||[]).map(m=>{const g=(t.overlays||[]).includes(m.name),k=g?`<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${Q[Math.max(0,this._profiles.findIndex(y=>y.name===m.name))%Q.length]};margin:0 2px"></span>`:"";return`<label class="wd-rev-tag"><input type="checkbox" class="wd-cyc-overlay" value="${d(m.name)}" ${g?"checked":""}> ${k}${d(m.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")}${dt}</div>
|
|
<div class="wd-rev-tags">${J}</div>
|
|
<div class="wd-rev-sub">${this._t("lbl.notes",{},"Notes")}${ut}</div>
|
|
<textarea id="wd-cyc-rev-notes" class="wd-rev-notes" rows="3" placeholder="${this._t("msg.review_notes_placeholder",{},"Notes (optional)")}">${d(I.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" ${R?"disabled":""}>${R?'<span class="wd-spin"></span> '+this._t("status.saving",{},"Saving\u2026"):this._t("btn.save_review",{},"Save Review")}</button>
|
|
</div>`}let $="";const L=t.mode==="view"||t.mode==="review"?e.artifacts||[]:[];if(L.length){const I=L.map(R=>{const G=R.detail_key?this._tText(R.detail_key,R.detail_params||{},R.detail||""):R.detail||"";return`<li><b>${d(ue(R.type,(W,J,Y)=>this._t(W,J,Y)))}</b> ${this._t("lbl.at",{},"at")} ${$t(R.start_s)}\u2013${$t(R.end_s)} \u2014 ${d(G)}</li>`}).join("");$=`<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:L.length},`${L.length} anomal${L.length>1?"ies":"y"} detected during this cycle`)}</div>
|
|
<ul class="wd-info" style="margin:4px 0 0;padding-left:18px;font-size:.82em">${I}</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 D="";const B=t.mode==="view"||t.mode==="review"?e.restart_gaps||[]:[];if(B.length){const I=B.map(R=>{const G=Math.round((R.gap_seconds||0)/60),W=G>=1?`${G}m`:`${Math.round(R.gap_seconds||0)}s`,J=R.match_confidence!=null?` \xB7 ${this._t("lbl.pct_match_confidence",{pct:Math.round(R.match_confidence*100)},`${Math.round(R.match_confidence*100)}% match confidence`)}`:"",Y=R.profile?` (${d(R.profile)})`:"";return`<li>${this._t("msg.restart_gap_item",{dur:W},`${W} gap`)}: ${this._t("lbl.ha_restarted",{},"HA restarted")}${Y}${J}</li>`}).join("");D=`<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:B.length},`${B.length} HA restart gap${B.length>1?"s":""} during this cycle`)}</div>
|
|
<ul class="wd-info" style="margin:4px 0 0;padding-left:18px;font-size:.82em">${I}</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; matching used only real readings.")}</div>
|
|
</div>`}let U="";if(e.decimated){const I=(e.samples||[]).length,R=e.sample_count||I;U=`<div class="wd-info" style="margin:4px 0 0;font-size:.72em;color:var(--secondary-text-color)">${this._t("msg.samples_decimated",{shown:I,total:R},`Showing ${I} of ${R} samples (thinned for display; peaks kept). A wide gap here is thinning, not missing data.`)}</div>`}const z=this._cycleProfileOverlays(t),M=I=>I.dash?`background:repeating-linear-gradient(90deg,${I.stroke} 0 5px,transparent 5px 8px)`:`background:${I.stroke}`,A=this._cycleContextSeries(t),O=A?[...z,A.series]:z,K=O.length?`<div class="wd-leg" data-cyc-legend style="margin:8px 0 12px">
|
|
<span class="wd-leg-i"><span class="wd-leg-sw" style="background:var(--primary-color)"></span> ${this._t("lbl.power",{},"Power")}</span>
|
|
${O.map(I=>`<span class="wd-leg-i" data-leg="${I.kind}"><span class="wd-leg-sw" style="${M(I)};opacity:.8"></span> ${d(I.name)}</span>`).join("")}
|
|
</div>`:"";return`<h2>${this._t("lbl.cycle",{},"Cycle")} \xB7 ${d(et(e.start_time))}</h2>
|
|
${c}${b}
|
|
<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>
|
|
${this._htmlCycleContextBar(t,e)}
|
|
${K}
|
|
${U}
|
|
${$}
|
|
${D}
|
|
${x}`}_htmlCycleContextBar(t,e){if(t.mode!=="view"&&t.mode!=="review"||e.is_reference||!(e.samples||[]).length)return"";const s=t.ctx||{minutes:this._cycleContextMinutes(t.entryId),loading:!1,data:null},a=Ft.map(n=>`<option value="${n}" ${n===s.minutes?"selected":""}>${n?this._t("lbl.cycle_context_minutes",{min:n},`${n} min`):this._t("lbl.cycle_context_off",{},"Off")}</option>`).join("");let r="",i="";s.minutes&&s.loading?(r="loading",i=this._t("msg.cycle_context_loading",{},"Loading recorder history\u2026")):s.minutes&&s.data&&this._cycleContextSeries(t)?(r="shown",i=this._t("msg.cycle_context_note",{},"Grey: the power sensor's recorder history around this cycle. It is not part of the cycle and does not affect duration, energy or matching.")):s.minutes&&s.data&&(r="none",i=this._t("msg.cycle_context_none",{},"The recorder holds no readings around this cycle (Home Assistant keeps 10 days of history by default)."));const o=ot(this._tText("msg.cycle_context_tip",{},"Display only: shows the power sensor's recorder history around this cycle, if still kept. To add failed start attempts to future cycles, use Curve Pre-roll (Settings > Detection > Cycle Start), which changes stored durations."));return`<div class="wd-cyc-ctx" data-cyc-ctx style="display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin:6px 0 0;font-size:.8em;color:var(--secondary-text-color)">
|
|
<label style="display:inline-flex;align-items:center;gap:6px;margin:0;text-transform:none;letter-spacing:0">${this._t("lbl.cycle_context",{},"Recorder history around the cycle")}
|
|
<select id="wd-cyc-ctx" class="wd-filter-select" data-maction="cyc-ctx">${a}</select></label>${o}
|
|
${i?`<span data-cyc-ctx-note="${r}">${i}</span>`:""}
|
|
</div>`}_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 a=s.map(([o,n])=>`<button class="wd-mini-tab ${t.tab===o?"active":""}" data-maction="pp-tab-${o}">${n}</button>`).join("");let r="";if(!t.loaded)r=`<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||{},n=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,p=v=>v?Math.round(v/60)+"m":"-",_=(this._profileHealth||{})[t.name],f=(this._profileTrends||{})[t.name],u=_&&_.health_status!=="unknown"?(()=>{const v={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)"]},[T,C]=v[_.health_status]||v.fair,x=Math.round((_.health_score||0)*100),$=_.duration_cv!=null?` \xB7 ${this._t("stat.duration_cv",{pct:Math.round(_.duration_cv*100)},`duration CV ${Math.round(_.duration_cv*100)}%`)}`:"",L=_.confidence_mean!=null?` \xB7 ${this._t("stat.avg_confidence",{pct:Math.round(_.confidence_mean*100)},`avg confidence ${Math.round(_.confidence_mean*100)}%`)}`:"";return`<div style="margin:8px 0 4px;padding:8px 12px;border-radius:6px;background:${C};border:1px solid color-mix(in srgb, ${T} 13%, transparent);display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
<span style="font-weight:600;color:${T}">${_.health_status==="poor"?this._t("health.poor",{},"\u26A0 Poor match fit"):_.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:x},`score ${x}%`)}${$}${L}</span>
|
|
${this._profileAdvisory(t.name,"poor_health")?`<span style="font-size:.82em;opacity:.75;flex-basis:100%">${d(this._advisoryText(this._profileAdvisory(t.name,"poor_health")))}</span>`:""}
|
|
</div>`})():"",b=f&&(f.duration_trend!=="stable"||f.energy_trend&&f.energy_trend!=="stable")?(()=>{const v=[];f.duration_trend==="up"?v.push(this._t("msg.trend_duration_longer",{pct:`${f.duration_slope_pct>0?"+":""}${f.duration_slope_pct}`,avg:`${Math.round(f.duration_recent_mean_s/60)}m`},`Duration trending longer (${f.duration_slope_pct>0?"+":""}${f.duration_slope_pct}%/cycle) \u2014 recent avg ${Math.round(f.duration_recent_mean_s/60)}m`)):f.duration_trend==="down"&&v.push(this._t("msg.trend_duration_shorter",{pct:`${f.duration_slope_pct}`,avg:`${Math.round(f.duration_recent_mean_s/60)}m`},`Duration trending shorter (${f.duration_slope_pct}%/cycle) \u2014 recent avg ${Math.round(f.duration_recent_mean_s/60)}m`)),f.energy_trend==="up"?v.push(this._t("msg.trend_energy_up",{pct:`${f.energy_slope_pct>0?"+":""}${f.energy_slope_pct}`,avg:gt(f.energy_recent_mean_wh)},`Energy trending up (${f.energy_slope_pct>0?"+":""}${f.energy_slope_pct}%/cycle) \u2014 recent avg ${gt(f.energy_recent_mean_wh)}`)):f.energy_trend==="down"&&v.push(this._t("msg.trend_energy_down",{pct:`${f.energy_slope_pct}`},`Energy trending down (${f.energy_slope_pct}%/cycle)`));const T=f.duration_trend==="up"||f.energy_trend==="up",C=this._profileAdvisory(t.name,"duration_trend_up")||this._profileAdvisory(t.name,"energy_trend_up");return`<div style="margin:6px 0;padding:8px 12px;border-radius:6px;background:${T?"rgba(255,152,0,.10)":"rgba(33,150,243,.10)"};font-size:.88em">
|
|
<span style="font-weight:600;color:${T?"var(--warning-color,#ff9800)":"var(--info-color,#2196f3)"}">${this._t("msg.performance_trend",{n:f.cycle_count},`Performance trend (${f.cycle_count} cycles)`)}</span><br>
|
|
${v.map(L=>`<span>${L}</span>`).join("<br>")}
|
|
${C?`<br><span style="opacity:.75">${d(this._advisoryText(C))}</span>`:""}
|
|
</div>`})():"";r=`<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">${p(o.avg_duration)}<span>${this._t("stat.avg",{},"avg")}</span></div>
|
|
<div class="wd-sg-sub">${this._t("stat.min",{v:p(o.min_duration)},`min ${p(o.min_duration)}`)} \xB7 ${this._t("stat.max",{v:p(o.max_duration)},`max ${p(o.max_duration)}`)}${n.duration_std_dev!=null?` \xB7 ${this._t("stat.consistency",{v:`${Math.round(n.duration_std_dev/60)}m`},`consistency \xB1${Math.round(n.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">${It(o.avg_cost,l)}<span>${this._t("stat.avg",{},"avg")}</span></div>
|
|
<div class="wd-sg-sub">${this._t("stat.total",{v:It(o.total_cost,l)},"total {v}")}</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?et(o.last_run):"-"},`last run ${o.last_run?et(o.last_run):"-"}`)}</div>
|
|
</div>
|
|
</div>
|
|
${u}
|
|
${b}
|
|
${this._profileAdvisory(t.name,"shape_drift")?`<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)">\u26A0 ${d(this._advisoryText(this._profileAdvisory(t.name,"shape_drift")))}</span>
|
|
</div>`:""}
|
|
${n.avg&&n.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||[],n=(t.phases||[]).map((c,p)=>{const _=o.map(f=>`<option value="${d(f)}" ${c.name===f?"selected":""}>${d(f)}</option>`).join("");return`<div class="wd-phase-row"><span class="wd-swatch" style="background:${Q[p%Q.length]}"></span>
|
|
<select data-phidx="${p}" data-phfield="name" style="min-width:130px"><option value="">${this._t("lbl.name_placeholder",{},"(name)")}</option>${_}</select>
|
|
<input type="number" data-phidx="${p}" data-phfield="start" value="${(c.start/60).toFixed(1)}" step="0.5" min="0" style="width:80px"> \u2013
|
|
<input type="number" data-phidx="${p}" 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="${p}">\u2715</button></div>`}).join(""),l=this._busy.has("pp-phase-save");r=`<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">${n||`<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||[],n=t.cleanup&&t.cleanup.selected||new Set,{col:l,dir:c}=this._cleanupSort,p={date:C=>C.start_time?new Date(C.start_time).getTime():0,duration:C=>C.duration,energy:C=>C.energy_kwh,status:C=>C.status||""},f=_e(o,p[l]||p.date,c).map((C,x)=>{const $=o.indexOf(C),L=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(C.cycle_id)}">${this._t("btn.trim_split",{},"Trim / Split")}</button>
|
|
</td>`:"";return`<tr data-cid="${d(C.cycle_id)}" style="cursor:pointer">
|
|
<td style="width:26px;padding:6px 4px"><input type="checkbox" data-cleanidx="${$}" ${n.has(C.cycle_id)?"checked":""}></td>
|
|
<td style="width:10px;padding:6px 2px"><span class="wd-swatch" style="background:${Q[$%Q.length]}"></span></td>
|
|
<td class="wd-tc-date">${et(C.start_time)}</td>
|
|
<td class="wd-tc-num">${ht(C.duration)}</td>
|
|
<td class="wd-tc-num">${C.energy_kwh!=null?gt(C.energy_kwh):"-"}</td>
|
|
<td><span class="wd-pill">${d(C.status||"completed")}</span></td>
|
|
${L}
|
|
</tr>`}).join(""),u=`<thead><tr>
|
|
<th style="width:26px;padding:6px 4px"></th><th style="width:10px;padding:6px 2px"></th>
|
|
${mt(this._t("lbl.date",{},"Date"),"date",l==="date",c,"cleanupsort")}
|
|
${mt(this._t("lbl.duration",{},"Duration"),"duration",l==="duration",c,"cleanupsort","right")}
|
|
${mt(this._t("lbl.energy",{},"Energy"),"energy",l==="energy",c,"cleanupsort","right")}
|
|
${mt(this._t("lbl.status",{},"Status"),"status",l==="status",c,"cleanupsort")}
|
|
${e?"<th></th>":""}
|
|
</tr></thead>`,b=this._busy.has("pp-cleanup-del"),v=this._busy.has("pp-cleanup-unlabel"),T=b||v;r=`<p class="wd-info" style="margin-bottom:10px">${this._t("msg.cleanup_intro",{},"All labelled cycles overlaid. Tick outliers, then unlabel them (kept in history, no longer shaping this profile) or delete them.")}</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>${f}</tbody></table></div>`:""}
|
|
${e?`<div class="wd-modal-actions">
|
|
<button class="wd-btn wd-btn-secondary" data-maction="pp-cleanup-unlabel" ${T||n.size===0?"disabled":""} title="${d(this._t("btn.unlabel_selected_tip",{},"Remove the label from the selected cycles. They stay in your history but stop shaping this profile."))}">${v?'<span class="wd-spin"></span> '+this._t("status.unlabelling",{},"Unlabelling\u2026"):this._t("btn.unlabel_selected",{n:n.size},`Unlabel selected (${n.size})`)}</button>
|
|
<button class="wd-btn wd-btn-danger" data-maction="pp-cleanup-del" ${T||n.size===0?"disabled":""}>${b?'<span class="wd-spin"></span> '+this._t("status.deleting",{},"Deleting\u2026"):this._t("btn.delete_selected",{n:n.size},`Delete selected (${n.size})`)}</button>
|
|
</div>`:""}`}else if(t.tab==="danger"){const o=this._busy.has("pp-rebuild"),n=t.stats&&t.stats.avg_duration?Math.round(t.stats.avg_duration/60):0,l=!!(t.stats&&t.stats.duration_learned),c=l?this._t("msg.duration_learned_hint",{},"Worked out from this program's cycles (outliers left out) and updated after each new one. To change it, trim or remove the cycles that are off."):this._t("msg.manual_duration_hint",{},"The profile's expected cycle length, used for time-remaining estimates. Leave it unchanged to keep the current value.");r=`<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="${n}" data-orig="${n}" ${l?"disabled":""}>
|
|
<div class="wd-field-hint">${c}</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 i=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 reference cycles to the community store so owners of the same machine can adopt them."))}">\u2B06 ${this._t("btn.share_to_store",{},"Share to store")}</button>`:"";return`<h2>${this._t("modal.profile_title",{name:t.name},"Profile \xB7 {name}")}</h2>
|
|
<div class="wd-mini-tabs">${a}</div>
|
|
${r}
|
|
<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>
|
|
${i}
|
|
</div>`}_cycleProfileOverlays(t){const e=[];if(!t||t.mode!=="view"&&t.mode!=="review")return e;const s=t.curve||{},a=t.overlayPlan||null,r=!!(a&&a.suspected),i=a&&a.name||s.profile_name||"profile",o=r?this._tText("lbl.suspected_profile",{name:i},"{name} (suspected)"):`${this._tText("lbl.expected",{},"Expected")} (${i})`,n=t.profileEnv,l=p=>p.target_duration||p.avg[p.avg.length-1][0]||0;!r&&(s.expected||[]).length>1?e.push({kind:"expected",points:s.expected,stroke:"#ff9800",width:2,alpha:.45,name:o}):n&&(n.avg||[]).length&&e.push({kind:r?"suspected":"expected",points:n.avg,stroke:"#ff9800",width:2,alpha:.45,dash:r,name:o,xEnd:l(n)});const c=r&&a.runnerUp?t.runnerUpEnv:null;return c&&(c.avg||[]).length&&e.push({kind:"runner_up",points:c.avg,stroke:"#9c27b0",width:1.6,alpha:.55,dash:!0,xEnd:l(c),name:this._tText("lbl.runner_up_profile",{name:a.runnerUp},"{name} (runner-up)")}),e}_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 a=e.full_duration_s||s[s.length-1][0]||1;const r=[];if(this._cycleProfileOverlays(t).forEach(c=>{r.push(c),a=Math.max(a,c.xEnd||0)}),t.mode==="review"&&(t.overlays||[]).length){const c=this._profileEnvCache||{};(t.overlays||[]).forEach(p=>{const _=c[p];if(!_||!(_.avg||[]).length)return;const f=Q[Math.max(0,(this._profiles||[]).findIndex(b=>b.name===p))%Q.length];r.push({points:_.avg,stroke:f,width:1.6,alpha:.7,name:p});const u=_.avg[_.avg.length-1];a=Math.max(a,_.target_duration||(u?u[0]:0))})}const i=this._cycleContextSeries(t);i&&(r.forEach(c=>{c.bounded=!0}),r.push(i.series),a=Math.max(a,i.xEnd)),r.push({points:s,stroke:"primary",fill:!0,width:2,name:this._t("lbl.power",{},"Power"),bounded:!!i});const o=[],n=[];if(i){const c="rgba(127,127,127,.10)";i.xStart<i.traceStart&&o.push({x0:i.xStart,x1:i.traceStart,fill:c}),i.xEnd>i.traceEnd&&o.push({x0:i.traceEnd,x1:i.xEnd,fill:c})}let l=[];if(t.mode==="trim"){const c=t.trim.start,p=t.trim.end;o.push({x0:0,x1:c,fill:"rgba(244,67,54,.18)"}),o.push({x0:p,x1:a,fill:"rgba(244,67,54,.18)"}),n.push({x:c,color:"#f44336",label:"S"},{x:p,color:"#f44336",label:"E"})}else if(t.mode==="split")(t.split.offsets||[]).slice().sort((c,p)=>c-p).forEach((c,p)=>n.push({x:c,color:"#ff9800",label:"#"+(p+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(_=>o.push({x0:_.start_s,x1:Math.max(_.end_s,_.start_s+1),fill:c[_.type]||"rgba(158,158,158,.18)"}));const p=e.start_time;(e.restart_gaps||[]).forEach(_=>{if(!p)return;const f=new Date(p).getTime(),u=Math.max(0,(new Date(_.start_ts).getTime()-f)/1e3),b=Math.max(u+1,(new Date(_.end_ts).getTime()-f)/1e3);o.push({x0:u,x1:b,fill:"rgba(96,125,139,.20)",label:"\u21BB"})})}this._drawCurves("wd-cyc-canvas",{series:r,xMax:a,bands:o,vlines:n,artifacts:l,...i?{xStart:i.xStart,cycleSpan:[i.traceStart,i.traceEnd]}:{}})}_cycleContextMinutes(t){const e=this._pref("cycle_context_min",null),s=e&&typeof e=="object"?e[t]:void 0;return Ft.includes(s)?s:Ee}async _loadCycleContext(t,e,s){const a=this._modal;if(!a||a.type!=="cycle-detail"||a.cycleId!==e)return;const r=this._cycleContextMinutes(t);if(!r||!s||s.is_reference||!(s.samples||[]).length){a.ctx={minutes:r,loading:!1,data:null};return}a.ctx={minutes:r,loading:!0,data:null};let i=null;try{i=await this._ws({type:`${w}/get_cycle_context`,entry_id:t,cycle_id:e,before_s:r*60,after_s:r*60})}catch{i={available:!1,reason:"no_history"}}const o=this._modal;!o||o.type!=="cycle-detail"||o.cycleId!==e||o.entryId!==t||!o.ctx||o.ctx.minutes!==r||(o.ctx={minutes:r,loading:!1,data:i},this._render())}_cycleContextSeries(t){if(!t||t.mode!=="view"&&t.mode!=="review")return null;const e=t.ctx&&t.ctx.data,s=t.curve&&t.curve.samples||[];if(!e||!e.available||!s.length)return null;const a=s[0],r=s[s.length-1],i=[];let o=null;const n=(u,b)=>{if(o!=null&&i.push([u,o]),b==null){i.push([u,null]),o=null;return}i.push([u,b]),o=b},l=(u,b)=>(Array.isArray(u)?u:[]).filter(v=>Array.isArray(v)&&Number.isFinite(v[0])&&b(v[0])),c=l(e.before,u=>u<a[0]),p=l(e.after,u=>u>=r[0]);if(c.forEach(([u,b])=>n(u,b)),o!=null&&(i.push([a[0],o]),i.push([a[0],a[1]])),p.length){i.length&&i.push([r[0],null]),o=null,n(r[0],r[1]),p.forEach(([b,v])=>n(b,v));const u=Number(e.after_end_s)||0;o!=null&&u>i[i.length-1][0]&&i.push([u,o])}if(!i.some(u=>u[1]!=null))return null;const _=c.length?Math.min(c[0][0],-(Number(e.before_s)||0)):a[0],f=p.length?Math.max(i[i.length-1][0],Number(e.after_end_s)||0):r[0];return{series:{kind:"context",points:i,stroke:"#9e9e9e",width:2,alpha:1,bounded:!0,aside:!0,name:this._tText("lbl.cycle_context_series",{},"Recorder history")},xStart:Math.min(0,_),xEnd:f,traceStart:a[0],traceEnd:r[0]}}_htmlCompareModal(t){const e=t.ids||[],s={};(this._cycles||[]).forEach(o=>{s[o.id]=o});const a=t.hidden||new Set,r=e.map((o,n)=>{const l=s[o]||{},c=Q[n%Q.length],p=!a.has(o),_=!!(t.cycles&&t.cycles[o]),f=`${et(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)}" ${p?"checked":""} ${_?"":"disabled"}><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${c};margin:0 2px"></span>${f}${_?"":` <span class="wd-info">${this._t("lbl.loading_paren",{},"(loading\u2026)")}</span>`}</label>`}).join(""),i=(this._profiles||[]).map(o=>{const n=(t.overlays||[]).includes(o.name),l=Q[Math.max(0,(this._profiles||[]).findIndex(p=>p.name===o.name))%Q.length],c=n?`<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)}" ${n?"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) \u2013 show / hide")}</div>
|
|
<div class="wd-rev-tags">${r}</div>
|
|
<div class="wd-rev-sub">${this._t("msg.compare_overlay_profiles",{},"Overlay profiles (faint)")}${ot(this._t("msg.compare_overlay_tip",{},"Overlay learned profile envelopes to see which program each cycle resembles."))}</div>
|
|
<div class="wd-rev-tags">${i}</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(n=>{s[n.id]=n});const a=t.hidden||new Set,r=[];let i=0;const o=this._profileEnvCache||{};(t.overlays||[]).forEach(n=>{const l=o[n];if(!l||!(l.avg||[]).length)return;const c=Q[Math.max(0,(this._profiles||[]).findIndex(_=>_.name===n))%Q.length];r.push({points:l.avg,stroke:c,width:2,alpha:.4,name:n});const p=l.avg[l.avg.length-1];i=Math.max(i,l.target_duration||(p?p[0]:0))}),e.forEach((n,l)=>{if(a.has(n))return;const c=t.cycles&&t.cycles[n],p=c&&c.samples;if(!p||!p.length)return;const _=Q[l%Q.length],f=s[n]||{};r.push({points:p,stroke:_,width:1.8,alpha:.9,name:et(f.start_time)||String(n).slice(0,8)}),i=Math.max(i,c.full_duration_s||p[p.length-1][0]||0)}),this._drawCurves("wd-compare-canvas",{series:r,xMax:i||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],a=(t.phases||[]).map((i,o)=>({x0:i.start,x1:i.end,fill:Pt(Q[o%Q.length],.2)})),r=[];(t.phases||[]).forEach((i,o)=>{const n=Q[o%Q.length];r.push({x:i.start,color:n,label:i.name?i.name.slice(0,7):"",handle:!0}),r.push({x:i.end,color:n,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:a,vlines:r,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,a=this._spagTableHoverCid||null;let r=1;e.forEach(o=>{const n=o.samples||[];n.length&&n[n.length-1][0]>r&&(r=n[n.length-1][0])});const i=e.map((o,n)=>{const l=s.has(o.cycle_id),c=a===o.cycle_id;return{points:o.samples||[],stroke:Q[n%Q.length],width:l||c?2.6:1,alpha:s.size?l?1:.22:a?c?1:.22:.7,name:et(o.start_time),cid:o.cycle_id}});this._drawCurves("wd-spag-canvas",{series:i,xMax:r})}_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(m=>m.addEventListener("click",()=>this._guardUnsaved(()=>this._selectDevice(parseInt(m.dataset.idx,10))))),t.querySelectorAll("[data-tab]").forEach(m=>m.addEventListener("click",()=>{const g=m.dataset.tab,k=()=>{g!=="settings"&&(this._pendingSettings={},this._dirtyOptKeys=new Set),this._tab=g,this._fetchTabData()};if(this._tab==="settings"&&g!=="settings"){this._guardUnsaved(k);return}k()})),t.querySelectorAll("[data-sec]").forEach(m=>m.addEventListener("click",()=>{this._snapshotFormToPending(t),this._settingsSec=m.dataset.sec,this._settingsSearch="",this._settingsSugOnly=!1,this._render()})),t.querySelectorAll("[data-ptab]").forEach(m=>m.addEventListener("click",()=>{const g=this._panelSubtab=m.dataset.ptab;this._render();const k=this._devices[this._selIdx];k&&(g==="diagnostics"&&!this._diag?this._fetchToolsData(k.entry_id).then(()=>{this._panelSubtab==="diagnostics"&&this._render()}):g==="logs"?this._fetchLogs().then(()=>{this._panelSubtab==="logs"&&this._render()}):g==="ml"&&this._fetchTabData())})),t.querySelectorAll("[data-hsub]").forEach(m=>m.addEventListener("click",()=>{this._historySub=m.dataset.hsub,this._render();const g=this._devices[this._selIdx];this._historySub==="maintenance"&&g?this._fetchMaintenance(g.entry_id).then(()=>{this._tab==="history"&&this._render()}):this._fetchTabData()})),t.querySelectorAll("[data-gtab]").forEach(m=>m.addEventListener("click",()=>{this._gearTab=m.dataset.gtab,this._modal&&this._modal.type==="gear-settings"&&(this._modal.tab=this._gearTab);const g=this._devices[this._selIdx];this._gearTab==="online"&&g&&this._onlineEnabled()&&(this._ensureStoreConnectListener(),this._loadStoreStatus(g.entry_id).then(()=>{this._modal&&this._modal.type==="gear-settings"&&this._render()})),this._render()}));const s=(m,g)=>{this._pendingSettings[m]=g,this._dirtyOptKeys.add(m)},a=t.getElementById("wd-store-brand");a&&a.addEventListener("change",()=>{const m=a.value.trim();if(m!==String(this._editedOpts().store_brand||"").trim()){s("store_model","");const g=t.getElementById("wd-store-model");g&&(g.value="")}s("store_brand",m),this._catalog.forBrand=m,this._catalog.devices=void 0,this._renderPreservingFormEdits()});const r=t.getElementById("wd-store-model");r&&r.addEventListener("change",()=>{s("store_model",r.value.trim()),this._renderPreservingFormEdits()});const i=t.getElementById("wd-pg-canvas");if(i){const m=()=>{const S=i.getBoundingClientRect(),F=S.height||330,N=Bt+8,H=F-N-52;return{rect:S,padT:N,powerH:H}},g=(S,F)=>{const N=i.getBoundingClientRect(),H=S-N.left,Z=F-N.top;if(Z>Bt+4)return null;const tt=this._pgEventHits||[];let st=null,rt=1/0;for(const lt of tt){const at=(H-lt.cx)**2+(Z-lt.cy)**2;at<=(lt.r+4)**2&&at<rt&&(st=lt,rt=at)}return st},k=S=>{const{rect:F,padT:N,powerH:H}=m(),Z=this._pgPowerPts;if(!Z?.length)return 0;const tt=Math.max(...Z.map(st=>st.w),1);return Math.max(0,(1-Math.max(0,S-F.top-N)/H)*tt)},y=S=>{const{rect:F,padT:N,powerH:H}=m(),Z=this._pgPowerPts;if(!Z?.length)return 0;const tt=Math.max(...Z.map(st=>st.w),1);return F.top+N+(1-Math.max(0,+S)/tt)*H},P=S=>{const F=this._pgMap;if(!F)return null;const{rect:N}=m(),H=(S-N.left-F.padLpx)/Math.max(1,F.plotWpx);return F.vMin+Math.max(0,Math.min(1,H))*(F.vMax-F.vMin)};i.addEventListener("pointermove",S=>{if(q.has(S.pointerId)&&q.set(S.pointerId,{x:S.clientX,y:S.clientY}),i._wdPinching){E();return}if(this._pgDragging==="start_thr"){this._pgThreshStart=Math.max(0,k(S.clientY)),this._pgUpdateParamInput("start_threshold_w",this._pgThreshStart),this._pgDrawCanvas();return}if(this._pgDragging==="stop_thr"){this._pgThreshStop=Math.max(0,k(S.clientY)),this._pgUpdateParamInput("stop_threshold_w",this._pgThreshStop),this._pgDrawCanvas();return}if(this._pgDragging==="pan"&&this._pgPanStart){const st=this._pgMap,rt=(S.clientX-this._pgPanStart.clientX)/Math.max(1,st.plotWpx),lt=this._pgPanStart.vMax-this._pgPanStart.vMin;let at=this._pgPanStart.vMin-rt*lt;at=Math.max(0,Math.min(this._pgPanStart.totalDur-lt,at)),this._pgView={min:at,max:at+lt},this._pgDrawCanvas();return}if(!this._pgPowerPts?.length)return;const F=g(S.clientX,S.clientY);if(F){i.style.cursor="pointer",this._pgHoverEvent={t:F.t,type:F.type},this._pgHoverT=null,this._pgDrawCanvas();return}this._pgHoverEvent&&(this._pgHoverEvent=null,this._pgDrawCanvas());const N=this._pgThreshStart??this._pgFieldVal("start_threshold_w",{})??50,H=this._pgThreshStop??this._pgFieldVal("stop_threshold_w",{})??5,Z=Math.abs(S.clientY-y(N))<8||Math.abs(S.clientY-y(H))<8;i.style.cursor=Z?"ns-resize":"crosshair";const tt=P(S.clientX);tt!=null&&(this._pgHoverT=tt,this._pgUpdateStripAt(tt),this._pgDrawCanvas())});const X=(S,F)=>{const N=this._pgMap;if(!N||S==null)return;const H=N.vMax-N.vMin,Z=Math.max(30,Math.min(N.totalDur,H*F)),tt=H>0?Math.max(0,Math.min(1,(S-N.vMin)/H)):.5;let st=S-tt*Z;st=Math.max(0,Math.min(N.totalDur-Z,st)),this._pgView=Z>=N.totalDur-1?null:{min:st,max:st+Z},this._pgDrawCanvas()},q=new Map;let j=null;i.addEventListener("pointerdown",S=>{if(!this._pgPowerPts?.length)return;if(q.set(S.pointerId,{x:S.clientX,y:S.clientY}),q.size===2&&this._pgMap){i._wdPinching=!0,i._wdOwnGesture=!1,this._pgDragging=null,this._pgPanStart=null,i.classList.remove("wd-pg-panning");const[Z,tt]=[...q.values()];j={dist:Math.max(1,Math.abs(Z.x-tt.x)),centerX:(Z.x+tt.x)/2};return}if(q.size!==1||g(S.clientX,S.clientY))return;i.setPointerCapture(S.pointerId);const F=this._pgThreshStart??this._pgFieldVal("start_threshold_w",{})??50,N=this._pgThreshStop??this._pgFieldVal("stop_threshold_w",{})??5,H=S.pointerType==="touch"?18:10;Math.abs(S.clientY-y(F))<H?this._pgDragging="start_thr":Math.abs(S.clientY-y(N))<H?this._pgDragging="stop_thr":this._pgMap&&(this._pgDragging="pan",this._pgPanStart={clientX:S.clientX,vMin:this._pgMap.vMin,vMax:this._pgMap.vMax,totalDur:this._pgMap.totalDur},i.classList.add("wd-pg-panning")),i._wdOwnGesture=this._pgDragging==="start_thr"||this._pgDragging==="stop_thr"});const V=S=>{try{i.releasePointerCapture(S.pointerId)}catch{}q.delete(S.pointerId),q.size<2&&(j=null,i._wdPinching=!1),q.size===0&&(i._wdOwnGesture=!1);const F=this._pgDragging==="start_thr"||this._pgDragging==="stop_thr";return this._pgDragging=null,this._pgPanStart=null,i.classList.remove("wd-pg-panning"),F};i.addEventListener("pointerup",S=>{V(S)&&this._pgDrawCanvas()}),i.addEventListener("pointercancel",S=>{V(S)&&this._pgDrawCanvas(),this._pgHoverT=null,this._pgHoverEvent=null,this._pgUpdateStripAt(null),this._pgDrawCanvas()}),i.addEventListener("pointerleave",S=>{this._pgDragging||S.pointerType==="touch"||(this._pgHoverT=null,this._pgHoverEvent=null,this._pgUpdateStripAt(null),this._pgDrawCanvas())}),i.addEventListener("wheel",S=>{if(!this._pgPowerPts?.length||!this._pgMap)return;S.preventDefault();const F=S.ctrlKey?Math.min(4,Math.max(.25,Math.exp(S.deltaY*.01))):S.deltaY>0?1.25:.8;X(P(S.clientX),F)},{passive:!1}),i.addEventListener("dblclick",S=>{if(this._pgView){this._pgView=null,this._pgDrawCanvas();return}X(P(S.clientX),Qt)});const E=()=>{const S=this._pgMap;if(!S||!j||q.size<2)return;const[F,N]=[...q.values()],H=Math.max(1,Math.abs(F.x-N.x)),Z=(F.x+N.x)/2,tt=S.vMax-S.vMin,st=Math.max(30,Math.min(S.totalDur,tt*(j.dist/H))),rt=P(Z),lt=(Z-j.centerX)/Math.max(1,S.plotWpx)*st;if(j.dist=H,j.centerX=Z,rt==null)return;const at=tt>0?Math.max(0,Math.min(1,(rt-S.vMin)/tt)):.5;let pt=rt-at*st-lt;pt=Math.max(0,Math.min(S.totalDur-st,pt)),this._pgView=st>=S.totalDur-1?null:{min:pt,max:pt+st},this._pgDrawCanvas()};this._attachTouchOwnershipGuard(i)}t.querySelectorAll("input[data-pgkey]").forEach(m=>{m.addEventListener("input",()=>{const g=m.dataset.pgkey;if(m.dataset.pgtype==="bool"){const q=m.checked;g==="start_threshold_w"?this._pgThreshStart=q:g==="stop_threshold_w"?this._pgThreshStop=q:this._pgParamOverrides[g]=q,this._render();const j=t.querySelector(`input[data-pgkey="${g}"]`);j&&j.focus({preventScroll:!0}),requestAnimationFrame(()=>this._pgDrawCanvas());return}const k=m.value.trim();if(!k)g==="start_threshold_w"?this._pgThreshStart=null:g==="stop_threshold_w"?this._pgThreshStop=null:delete this._pgParamOverrides[g];else{const q=parseFloat(k);isNaN(q)||(g==="start_threshold_w"?this._pgThreshStart=q:g==="stop_threshold_w"?this._pgThreshStop=q:this._pgParamOverrides[g]=q)}const y=m.selectionStart,P=m.value;this._render();const X=t.querySelector(`input[data-pgkey="${g}"]`);if(X){X.value=P,X.focus({preventScroll:!0});try{X.setSelectionRange(y,y)}catch{}}requestAnimationFrame(()=>this._pgDrawCanvas())})});const o=t.getElementById("wd-pg-cyc-sel");o&&o.addEventListener("change",()=>this._pgSelectCycle(o.value));const n=t.getElementById("wd-pg-prof-sel");n&&n.addEventListener("change",()=>{this._pgProfileName=n.value,this._pgLoad()});const l=t.getElementById("wd-pg-preset-sel");l&&l.addEventListener("change",()=>{this._pgPresetSel=l.value,this._render()});const c=t.getElementById("wd-pg-preset-name");c&&c.addEventListener("input",()=>{this._pgPresetName=c.value;const m=(this._pgPresetName||"").trim(),g=this._pgPresetAtLimit(m),k=t.querySelector('[data-action="pg-preset-save"]');k&&(k.disabled=!(m&&!g));const y=t.getElementById("wd-pg-preset-limit-note");y&&(y.style.display=g?"":"none")}),t.querySelectorAll("#wd-pg-simn, #wd-pg-sw-n").forEach(m=>m.addEventListener("input",()=>{this._pgSimCycles=Math.max(1,Math.min(St,parseInt(m.value,10)||20))}));const p=t.getElementById("wd-pg-sw-param");p&&p.addEventListener("change",()=>{this._pgSweepParam=p.value,this._pgSweepNew=null,this._render()});const _=t.getElementById("wd-pg-sw-obj");_&&_.addEventListener("change",()=>{this._pgSweepObjective=_.value,this._pgSweepNew=null,this._render()});const f=t.getElementById("wd-pg-sw-paramy"),u=t.getElementById("wd-pg-sw-from");u&&u.addEventListener("input",()=>{this._pgSweepFrom=u.value});const b=t.getElementById("wd-pg-sw-to");b&&b.addEventListener("input",()=>{this._pgSweepTo=b.value});const v=t.getElementById("wd-pg-sw-steps");v&&v.addEventListener("input",()=>{this._pgSweepSteps=parseInt(v.value,10)||5}),t.querySelectorAll("[data-statustoggle]").forEach(m=>m.addEventListener("change",async()=>{const g=m.dataset.statustoggle,k=m.checked;this._panelCfg||(this._panelCfg={}),this._panelCfg.prefs={...this._panelCfg.prefs||{},[g]:k},this._ws({type:`${w}/set_user_prefs`,prefs:{[g]:k}}).catch(()=>{});const y=this._devices[this._selIdx];if(y&&this._tab==="status")try{this._powerData=await this._ws({type:`${w}/get_power_history`,entry_id:y.entry_id,with_raw:this._pref("show_raw_active",!1)})}catch{}this._render()})),t.querySelectorAll("[data-sortact]").forEach(m=>m.addEventListener("click",()=>{const g=m.dataset.sortact,k=m.dataset.sortcol,y=P=>{P.col===k?P.dir*=-1:(P.col=k,P.dir=k==="date"?-1:1)};g==="cycsort"?y(this._cycleSort):g==="cleanupsort"&&y(this._cleanupSort),this._render()}));const T=t.getElementById("wd-cyc-filter-text");T&&T.addEventListener("input",m=>{const g=m.target.selectionStart;this._cycleFilter.text=T.value,this._render();const k=this.shadowRoot.getElementById("wd-cyc-filter-text");k&&(k.focus({preventScroll:!0}),k.setSelectionRange(g,g))});const C=t.getElementById("wd-settings-search");C&&C.addEventListener("input",m=>{const g=m.target.selectionStart;this._settingsSearch=C.value,C.value.trim()&&(this._settingsSugOnly=!1),this._render();const k=this.shadowRoot.getElementById("wd-settings-search");k&&(k.focus({preventScroll:!0}),k.setSelectionRange(g,g))});const x=t.getElementById("wd-cyc-filter-status");x&&x.addEventListener("change",()=>{this._cycleFilter.status=x.value,this._render()});const $=m=>{const g=document.createElement("span");g.className="wd-pill",g.dataset.val=m,g.title=m;const k=document.createElement("span");k.className="wd-pill-txt",k.textContent=m,g.appendChild(k);const y=document.createElement("button");return y.type="button",y.className="wd-pill-x",y.setAttribute("aria-label",this._tText("btn.remove",{},"Remove")),y.textContent="\xD7",y.addEventListener("click",()=>g.remove()),g.appendChild(y),g};t.querySelectorAll(".wd-pillbox").forEach(m=>{const g=m.querySelector(".wd-pill-add"),k=y=>{const P=String(y||"").trim();if(!P)return;Array.from(m.querySelectorAll(".wd-pill")).some(q=>q.dataset.val===P)||m.insertBefore($(P),g.closest(".wd-combo")||g),g&&(g.value="")};m.querySelectorAll(".wd-pill-x").forEach(y=>y.addEventListener("click",()=>y.closest(".wd-pill")?.remove())),g&&(g.addEventListener("change",()=>k(g.value)),g.addEventListener("keydown",y=>{y.key==="Enter"&&(y.preventDefault(),k(g.value))}),g.addEventListener("blur",()=>k(g.value)))}),t.querySelectorAll(".wd-combo").forEach(m=>{const g=m.querySelector(".wd-combo-inp, .wd-pill-add"),k=m.querySelector(".wd-combo-drop");if(!g||!k)return;const y=m.classList.contains("wd-combo-pill"),P=g.dataset.catalog||g.dataset.opt||m.closest("[data-opt]")?.dataset.opt,X=(j,V=!1)=>{this._ensureCatalogList(P,j,V);const E=(this._entityListCache||{})[P]||[],S=(j||"").toLowerCase(),F=S?E.filter(N=>String(N).toLowerCase().includes(S)).slice(0,40):E.slice(0,20);if(!F.length){k.hidden=!0;return}k.innerHTML=F.map(N=>`<div class="wd-combo-item" data-val="${d(N)}">${d(N)}</div>`).join(""),k._kbd=-1,k.hidden=!1,this._positionComboDrop(g,k)},q=j=>{if(j){if(y){const V=m.closest(".wd-pillbox");V&&!Array.from(V.querySelectorAll(".wd-pill")).some(E=>E.dataset.val===j)&&V.insertBefore($(j),m),g.value=""}else g.value=j,g.dispatchEvent(new Event("change",{bubbles:!0}));k.hidden=!0}};g.addEventListener("focus",()=>X(g.value)),g.addEventListener("input",()=>X(g.value,!g._wdSyntheticInput)),g.addEventListener("blur",()=>setTimeout(()=>{k.hidden=!0},150)),g.addEventListener("keydown",j=>{if(k.hidden&&j.key!=="ArrowDown")return;const V=k.querySelectorAll(".wd-combo-item");let E=k._kbd||-1;if(j.key==="ArrowDown"){if(j.preventDefault(),k.hidden){X(g.value);return}E=Math.min(E+1,V.length-1)}else if(j.key==="ArrowUp")j.preventDefault(),E=Math.max(E-1,0);else if(j.key==="Enter"&&!k.hidden){j.preventDefault(),E>=0?q(V[E].dataset.val):y&&g.value.trim()&&q(g.value.trim());return}else if(j.key==="Escape"){k.hidden=!0;return}else return;k._kbd=E,V.forEach((S,F)=>S.classList.toggle("kbd",F===E)),V[E]?.scrollIntoView({block:"nearest"})}),k.addEventListener("mousedown",j=>{const V=j.target.closest(".wd-combo-item");V&&(j.preventDefault(),q(V.dataset.val))})});const L=t.getElementById("wd-store-q");if(L){const m=()=>{const g=String(L.value||"").trim(),k=(this._catalog.modelIndex||new Map).get(g);if(k){this._storeOpenModel(k);return}g.toLowerCase()!==String(this._storeQuery||"").trim().toLowerCase()&&this._storeSearch(g)};L.addEventListener("change",m),L.addEventListener("keydown",g=>{if(g.key!=="Enter")return;const k=L.parentElement&&L.parentElement.querySelector(".wd-combo-drop");k&&!k.hidden&&k._kbd>=0||(g.preventDefault(),m())}),L.addEventListener("input",()=>{L._wdSyntheticInput||(clearTimeout(this._storeQTimer),this._storeQTimer=setTimeout(()=>{const g=String(L.value||"").trim();if(!g)return;((this._entityListCache||{}).store_brand||[]).some(y=>String(y).toLowerCase()===g.toLowerCase())&&m()},450))})}t.querySelectorAll(".wd-timerlist").forEach(m=>{const g=m.dataset.opt,k=()=>{if(!Array.isArray(this._pendingSettings[g])){const V=Array.isArray(this._opts&&this._opts[g])?this._opts[g]:[];this._pendingSettings[g]=V.map(E=>({...E}))}return this._pendingSettings[g]},y=V=>({offset_minutes:parseFloat(V.querySelector('[data-field="offset_minutes"]').value)||0,message:(V.querySelector('[data-field="message"]').value||"").trim(),auto_pause:V.querySelector('[data-field="auto_pause"]').checked}),P=V=>{const E=parseInt(V.dataset.tidx,10);if(isNaN(E))return;const S=k();S[E]=y(V)},X=V=>{const E=parseInt(V.dataset.tidx,10);isNaN(E)||(this._pendingSettings[g]=k().filter((S,F)=>F!==E)),V.remove(),m.querySelectorAll(".wd-timer-row").forEach((S,F)=>{S.dataset.tidx=F})},q=V=>{const E=V.querySelector(".wd-timer-remove");E&&E.addEventListener("click",()=>X(V)),V.querySelector('[data-field="auto_pause"]')?.addEventListener("change",()=>P(V)),V.querySelectorAll('[data-field="offset_minutes"],[data-field="message"]').forEach(S=>S.addEventListener("input",()=>P(V)))};m.querySelectorAll(".wd-timer-row").forEach(V=>q(V));const j=m.querySelector(".wd-timer-add");j&&j.addEventListener("click",()=>{const V=m.querySelectorAll(".wd-timer-row").length;k().push({offset_minutes:0,message:"",auto_pause:!1});const E=document.createElement("div");E.className="wd-timer-row",E.dataset.tidx=V,E.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>`,q(E),m.insertBefore(E,j)})});const D=t.getElementById("wd-status-prog");D&&D.addEventListener("change",()=>{const m=this._devices[this._selIdx];if(!m)return;const g=D.value,k=!Mt.includes(m.detector_state||"");this._ws({type:`${w}/set_program`,entry_id:m.entry_id,program:g}).then(()=>(this._showToast(g==="auto_detect"?this._t("msg.toast_auto_detect_enabled",{},"Auto-detect enabled"):k?this._tText("msg.toast_program_armed",{program:g},`Program armed for the next cycle: ${g}`):this._tText("msg.toast_program_set",{program:g},`Program set: ${g}`)),this._fetchAll())).catch(y=>this._showToast(this._tText("msg.toast_failed",{error:y.message||y},"Failed: "+(y.message||y)),"error"))}),t.querySelectorAll("[data-cid]").forEach(m=>m.addEventListener("click",g=>{if(g.target.tagName==="INPUT"||m.dataset.action)return;const k=m.dataset.cid;m.dataset.selmode==="1"?(this._cycleSel.has(k)?this._cycleSel.delete(k):this._cycleSel.add(k),this._render()):this._onAction({dataset:{action:"open-cycle",cid:k}})})),t.querySelectorAll('tr[data-cid][role="button"]').forEach(m=>m.addEventListener("keydown",g=>{g.target!==m||g.key!=="Enter"&&g.key!==" "||(g.preventDefault(),m.click())})),t.querySelectorAll(".wd-cyc-overlay").forEach(m=>m.addEventListener("change",()=>{const g=this._modal;if(!g||g.type!=="cycle-detail")return;this._snapshotCycleReviewForm(t);const k=new Set(g.overlays||[]);m.checked?k.add(m.value):k.delete(m.value),g.overlays=[...k];const y=this._devices[this._selIdx];m.checked&&y?this._ensureProfileEnvs(y.entry_id,[m.value]).then(()=>this._render()):this._render()})),t.querySelectorAll(".wd-compare-cyc").forEach(m=>m.addEventListener("change",()=>{const g=this._modal;if(!g||g.type!=="compare-cycles")return;const k=g.hidden instanceof Set?g.hidden:new Set(g.hidden||[]);m.checked?k.delete(m.value):k.add(m.value),g.hidden=k,this._render()})),t.querySelectorAll(".wd-compare-overlay").forEach(m=>m.addEventListener("change",()=>{const g=this._modal;if(!g||g.type!=="compare-cycles")return;const k=new Set(g.overlays||[]);m.checked?k.add(m.value):k.delete(m.value),g.overlays=[...k];const y=this._devices[this._selIdx];m.checked&&y?this._ensureProfileEnvs(y.entry_id,[m.value]).then(()=>this._render()):this._render()})),t.querySelectorAll(".wd-pg-mem").forEach(m=>m.addEventListener("change",()=>{const g=this._modal;if(!g||g.type!=="profile-group")return;const k=t.getElementById("wd-pg-name");k&&(g.name=k.value);const y=new Set(g.members||[]);m.checked?y.add(m.value):y.delete(m.value),g.members=[...y],this._render()}));const B=t.getElementById("wd-pg-name");B&&B.addEventListener("input",()=>{const m=this._modal;m&&m.type==="profile-group"&&(m.name=B.value)}),t.querySelectorAll(".wd-csel").forEach(m=>m.addEventListener("change",()=>{const g=m.closest("[data-cid]"),k=g&&g.dataset.cid;k&&(m.checked?this._cycleSel.add(k):this._cycleSel.delete(k),this._render())}));const U=t.getElementById("wd-merge-prof");U&&U.addEventListener("change",()=>{const m=t.getElementById("wd-merge-new");m&&(m.style.display=U.value==="__create_new__"?"":"none")}),t.querySelectorAll(".wd-log-filter").forEach(m=>{const g=m.dataset.logfilter,k={level:"_logLevel",device:"_logDevice",component:"_logComponent",search:"_logSearch"}[g];if(!k)return;const y=g==="search"?"input":"change";m.addEventListener(y,()=>{this[k]=m.value,this._refreshLogViews(),this._syncLogFilters(m)})});const z=t.querySelector(".wd-log-resize");z&&z.addEventListener("pointerdown",m=>{m.preventDefault(),z.setPointerCapture(m.pointerId),z.classList.add("dragging");const g=t.querySelector(".wd-log-drawer"),k=m.clientX,y=g.offsetWidth,P=X=>{const q=Math.max(280,Math.min(900,y+(k-X.clientX)));g.style.width=q+"px",this._logDrawerWidth=q};z.addEventListener("pointermove",P),z.addEventListener("pointerup",()=>{z.removeEventListener("pointermove",P),z.classList.remove("dragging");try{localStorage.setItem("wd-log-width",String(this._logDrawerWidth))}catch{}},{once:!0})});const M=t.getElementById("wd-hist-file");M&&M.addEventListener("change",()=>{const m=M.files&&M.files[0];if(!m)return;const g=this._modal,k=new FileReader;k.onload=()=>{const y=t.getElementById("wd-hist-csv"),P=String(k.result||"");y&&(y.value=P),g&&g.type==="history-import"&&(g.csvText=P)},k.onerror=()=>this._showToast(this._t("toast.file_read_failed",{},"Could not read that file"),"error"),k.readAsText(m)}),t.querySelectorAll("[data-hist-pick]").forEach(m=>m.addEventListener("change",()=>{const g=this._modal;if(!g||g.type!=="history-import")return;const k=parseInt(m.dataset.histPick,10);m.checked?g.accept.add(k):g.accept.delete(k),this._render(),requestAnimationFrame(()=>this._drawHistorySparklines())}));const A=t.getElementById("wd-import-file");A&&A.addEventListener("change",()=>{const m=A.files&&A.files[0];if(!m)return;const g=new FileReader;g.onload=()=>{const k=t.getElementById("wd-import-json");k&&(k.value=String(g.result||""))},g.readAsText(m)}),t.querySelectorAll("[data-stab]").forEach(m=>m.addEventListener("click",()=>{this._toolsSubtab=m.dataset.stab;const g=this._devices[this._selIdx];g&&(this._tabLoading=!0,this._render(),this._fetchToolsData(g.entry_id).then(()=>{this._tabLoading=!1,this._render()}))})),t.querySelectorAll("[data-proftab]").forEach(m=>m.addEventListener("click",async()=>{this._profSubtab=m.dataset.proftab;const g=this._devices[this._selIdx];g&&this._profSubtab==="phase-catalog"&&!this._phases.length&&(this._tabLoading=!0,this._render(),await this._fetchPhases(g.entry_id),this._tabLoading=!1),this._render()}));const O=t.getElementById("wd-settings-save");O&&O.addEventListener("click",()=>this._saveSettings());const K=t.getElementById("wd-ml-save");K&&K.addEventListener("click",()=>this._saveSettings());const I=t.getElementById("wd-settings-form"),R=m=>{const g=m.target&&m.target.closest?m.target.closest("[data-opt]"):null;g&&g.dataset.opt&&this._dirtyOptKeys.add(g.dataset.opt)},G=m=>{const g=m.target;if(!g||!g.closest)return;const k=g.closest(".wd-colorfield");if(!k)return;const y=k.querySelector("input[data-opt]"),P=k.querySelector(".wd-color-sw");!y||!P||(g===P?(y.value=String(P.value).toUpperCase(),this._dirtyOptKeys.add(y.dataset.opt)):g===y&&Kt.test(y.value.trim())&&(P.value=y.value.trim().toLowerCase()))};I&&(I.addEventListener("submit",m=>m.preventDefault()),I.addEventListener("input",m=>{G(m),R(m),this._liveValidateSettings(t)}),I.addEventListener("change",m=>{G(m),R(m),this._liveValidateSettings(t)}),I.addEventListener("click",m=>{const g=m.target.closest&&m.target.closest(".wd-color-clear");if(!g)return;const k=g.parentElement&&g.parentElement.querySelector("input[data-opt]");if(!k)return;k.value="";const y=g.parentElement.querySelector(".wd-color-sw");y&&(y.value=ye(k.placeholder)),this._dirtyOptKeys.add(k.dataset.opt),this._liveValidateSettings(t)}),I.addEventListener("click",m=>{const g=m.target.closest(".wd-conflict-fix");if(!g)return;const k=g.dataset.ckey,y=parseFloat(g.dataset.cval),P=I.querySelector(`[data-opt="${k}"]`);P&&!isNaN(y)&&(P.value=y,this._dirtyOptKeys.add(k),this._cascadeConflictFix(t,I,k))}),this._liveValidateSettings(t));const W=t.getElementById("wd-ml-form");W&&(W.addEventListener("input",R),W.addEventListener("change",R));const J=t.getElementById("wd-settings-revert");J&&J.addEventListener("click",async()=>{if(!this._prevOpts)return;const m=this._devices[this._selIdx];m&&await this._busyRun("save-settings",async()=>{try{const g=this._prevOpts;await this._ws({type:`${w}/set_options`,entry_id:m.entry_id,options:g}),this._opts={...g},this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null,this._pendingSettings={},this._dirtyOptKeys=new Set,this._showToast(this._t("toast.settings_reverted",{},"Settings reverted; integration reloading")),this._render()}catch(g){this._showToast(this._tText("msg.toast_revert_failed",{error:g.message||g},"Revert failed: "+(g.message||g)),"error")}})});const Y=t.getElementById("wd-settings-reload");Y&&Y.addEventListener("click",async()=>{const m=this._devices[this._selIdx];if(m){this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null,this._pendingSettings={},this._dirtyOptKeys=new Set;const g=await this._ws({type:`${w}/get_options`,entry_id:m.entry_id});this._opts=g.options||{},this._optDefaults=g.defaults||{},await this._fetchSuggestions(m.entry_id),this._render()}});const nt=t.getElementById("wd-pref-fontscale");if(nt){const m=t.getElementById("wd-pref-fontscale-val");nt.addEventListener("input",()=>{const g=parseFloat(nt.value)||1;this._applyFontScale(g),m&&(m.textContent=Math.round(g*100)+"%")}),nt.addEventListener("change",()=>{this._setPref("font_scale",parseFloat(nt.value)||1)})}t.querySelectorAll("[data-action]").forEach(m=>m.addEventListener("click",g=>this._onAction(g.currentTarget))),t.querySelectorAll("[data-maction]").forEach(m=>m.addEventListener("click",g=>this._onModalAction(g.currentTarget.dataset.maction,g.currentTarget))),t.querySelectorAll("select[data-maction]").forEach(m=>m.addEventListener("change",g=>this._onModalAction(g.currentTarget.dataset.maction,g.currentTarget))),t.querySelectorAll("input[data-indeterminate]").forEach(m=>{m.indeterminate=!0});const _t=t.querySelector("[data-toast-undo]");_t&&_t.addEventListener("click",()=>this._undoDelete(_t.dataset.toastUndo)),t.querySelectorAll("[data-suglock]").forEach(m=>m.addEventListener("click",async()=>{const g=m.dataset.suglock,k=this._devices[this._selIdx],y=k&&k.entry_id;if(!(!y||!g))try{if(await this._ws({type:`${w}/set_suggestion_lock`,entry_id:y,key:g,locked:!0}),!this._isActiveEntry(y))return;this._suggestions=(this._suggestions||[]).filter(P=>P.key!==g),(this._lockedSuggestions||[]).includes(g)||(this._lockedSuggestions=this._lockedSuggestions||[]).push(g),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(m=>m.addEventListener("click",()=>{const g=m.dataset.sugkey,k=m.dataset.sugval,y=parseFloat(k);this._pendingSettings[g]=isNaN(y)?k:y,this._dirtyOptKeys.add(g),this._stagedSuggestions=!0,this._suggestions=this._suggestions.filter(q=>q.key!==g),this._showToast(this._tText("msg.sug_staged",{key:g,val:k},`Set ${g} = ${k}. Save to apply.`),"info"),this._render();const P=this.shadowRoot,X=P?.getElementById("wd-settings-form");X&&this._cascadeConflictFix(P,X,g)}));const ct=t.getElementById("wd-label-profile");ct&&ct.addEventListener("change",()=>{const m=t.getElementById("wd-new-profile-row"),g=ct.value==="__create_new__";if(m&&(m.style.display=g?"":"none"),!g){const k=t.getElementById("wd-new-profile-name");k&&(k.value="")}});const dt=t.getElementById("wd-cp-cycle");if(dt){const m=()=>{const g=t.getElementById("wd-cp-dur");if(!g)return;const k=!!dt.value;g.disabled=k,g.style.opacity=k?"0.5":""};dt.addEventListener("change",m),m()}const ut=t.getElementById("wd-pr-mode");ut&&ut.addEventListener("change",()=>{const m=t.getElementById("wd-pr-profile"),g=t.getElementById("wd-pr-existing");if(!m||!g)return;const k=ut.value==="existing_profile";m.style.display=k?"none":"",g.style.display=k?"":"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",a=t.getElementById("wd-trim-start"),r=t.getElementById("wd-trim-end");a&&(a.value=s?this._offsetToClock(e.trim.start):Math.round(e.trim.start)),r&&(r.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=a=>e.reduce((r,i)=>Math.abs(i[0]-a)<Math.abs(r-a)?i[0]:r,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 a=e.findIndex(r=>r[0]===t.trim.start);a>=0&&a+1<e.length?t.trim.end=e[a+1][0]:a>0&&(t.trim.start=e[a-1][0])}}_offsetToClock(t){const e=this._modal,s=e&&e.curve&&e.curve.start_time;if(!s)return"";const a=new Date(new Date(s).getTime()+(t||0)*1e3);return`${String(a.getHours()).padStart(2,"0")}:${String(a.getMinutes()).padStart(2,"0")}:${String(a.getSeconds()).padStart(2,"0")}`}_clockToOffset(t){const e=this._modal,s=e&&e.curve&&e.curve.start_time;if(!s||!t)return null;const a=String(t).split(":").map(Number);if(!a.length||a.some(l=>!Number.isFinite(l)))return null;const r=new Date(s),i=new Date(r);i.setHours(a[0]||0,a[1]||0,a[2]||0,0);let o=(i-r)/1e3;const n=e.curve&&e.curve.full_duration_s||0;return o<-1&&o+86400<=n&&(o+=86400),Math.max(0,Math.min(n,o))}_trimInputToOffset(t){if(t===""||t==null)return null;if(this._modal.timeMode==="clock")return this._clockToOffset(t);const e=pe(t,NaN);return Number.isFinite(e)?e:null}_toggleSplit(t){const e=this._modal,s=e.curve&&e.curve.full_duration_s||0,a=Math.max(20,s*.025),r=e.split.offsets,i=r.findIndex(o=>Math.abs(o-t)<a);i>=0?r.splice(i,1):r.push(Math.round(t)),r.sort((o,n)=>o-n),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 a=t.getElementById("wd-trim-start"),r=t.getElementById("wd-trim-end");a&&a.addEventListener("input",()=>{const n=this._trimInputToOffset(a.value);n!==null&&(e.trim.start=Math.max(0,Math.min(n,e.trim.end-1)),this._drawCycleEditor())}),r&&r.addEventListener("input",()=>{const n=this._trimInputToOffset(r.value);n!==null&&(e.trim.end=Math.min(e.curve.full_duration_s,Math.max(n,e.trim.start+1)),this._drawCycleEditor())});const i=()=>{this._snapTrimBounds(),this._syncTrimInputs(),this._drawCycleEditor()};a&&a.addEventListener("change",i),r&&r.addEventListener("change",i);const o=()=>{e.drag&&(this._snapTrimBounds(),this._syncTrimInputs(),this._drawCycleEditor()),e.drag=null,s._wdOwnGesture=!1};s._wdAbortEdit=()=>{e.drag=null,s._wdOwnGesture=!1},s.addEventListener("pointerdown",n=>{const l=s._wd;if(!l)return;const c=s.getBoundingClientRect(),p=n.clientX-c.left,_=Math.abs(p-l.xToCss(e.trim.start)),f=Math.abs(p-l.xToCss(e.trim.end));if(n.pointerType==="touch"&&Math.min(_,f)>Tt){e.drag=null;return}e.drag=_<=f?"start":"end",s._wdOwnGesture=!0,s.setPointerCapture(n.pointerId)}),s.addEventListener("pointermove",n=>{if(!e.drag||s._wdPinching)return;const l=s._wd;if(!l)return;const c=s.getBoundingClientRect(),p=l.cssToX(n.clientX-c.left);e.drag==="start"?e.trim.start=Math.min(p,e.trim.end-1):e.trim.end=Math.max(p,e.trim.start+1),this._syncTrimInputs(),this._drawCycleEditor()}),s.addEventListener("pointerup",o),s.addEventListener("pointercancel",o)}else e.mode==="split"&&s.addEventListener("pointerdown",a=>{const r=s._wd;if(!r||s._wdPinching)return;const i=s.getBoundingClientRect();this._toggleSplit(r.cssToX(a.clientX-i.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 a=()=>{const r=+s.dataset.phidx,i=s.dataset.phfield,o=e.phases[r];o&&(i==="name"?o.name=s.value:o[i]=Math.max(0,pe(s.value,0)*60),this._drawPhaseEditor())};s.addEventListener("input",a),s.addEventListener("change",a)})}_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 a=e.env.target_duration||e.env.avg[e.env.avg.length-1][0],r=Math.max(5,a*.01),i=(n,l)=>{const c=s._wd;if(!c)return null;let p=null,_=l==="touch"?Tt:12;return(e.phases||[]).forEach((f,u)=>{[["start",f.start],["end",f.end]].forEach(([b,v])=>{const T=Math.abs(n-c.xToCss(v));T<_&&(_=T,p={idx:u,edge:b})})}),p},o=()=>{e.phaseDrag=null,s._wdOwnGesture=!1};s._wdAbortEdit=o,s.addEventListener("pointerdown",n=>{const l=s.getBoundingClientRect();e.phaseDrag=i(n.clientX-l.left,n.pointerType),e.phaseDrag&&(s._wdOwnGesture=!0,s.setPointerCapture(n.pointerId))}),s.addEventListener("pointermove",n=>{if(!e.phaseDrag||s._wdPinching)return;const l=s._wd;if(!l)return;const c=s.getBoundingClientRect(),p=l.cssToX(n.clientX-c.left),_=e.phases[e.phaseDrag.idx];_&&(e.phaseDrag.edge==="start"?_.start=Math.max(0,Math.min(p,_.end-r)):_.end=Math.min(a,Math.max(p,_.start+r)),this._syncPhaseInputs(e.phaseDrag.idx),this._drawPhaseEditor())}),s.addEventListener("pointerup",o),s.addEventListener("pointercancel",o)}_syncPhaseInputs(t){const e=this.shadowRoot,s=this._modal.phases[t];if(!s)return;const a=e.querySelector(`[data-phidx="${t}"][data-phfield="start"]`),r=e.querySelector(`[data-phidx="${t}"][data-phfield="end"]`);a&&(a.value=(s.start/60).toFixed(1)),r&&(r.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(a=>a.addEventListener("change",()=>{const r=e.cleanup.cycles[+a.dataset.cleanidx];if(!r)return;a.checked?e.cleanup.selected.add(r.cycle_id):e.cleanup.selected.delete(r.cycle_id);const i=e.cleanup.selected;if(!this._busy.has("pp-cleanup-del")&&!this._busy.has("pp-cleanup-unlabel")){const o=[["pp-cleanup-unlabel","btn.unlabel_selected",`Unlabel selected (${i.size})`],["pp-cleanup-del","btn.delete_selected",`Delete selected (${i.size})`]];for(const[n,l,c]of o){const p=t.querySelector(`[data-maction="${n}"]`);p&&(p.disabled=i.size===0,p.textContent=this._tText(l,{n:i.size},c))}}this._drawSpaghetti()})),t.querySelectorAll("tr[data-cid]").forEach(a=>{a.addEventListener("mouseenter",()=>{this._spagTableHoverCid=a.dataset.cid,this._drawSpaghetti()}),a.addEventListener("mouseleave",()=>{this._spagTableHoverCid=null,this._drawSpaghetti()})});const s=t.getElementById("wd-spag-canvas");s&&s.addEventListener("pointerdown",a=>{if(s._wdPinching)return;this._onGraphHoverInner(a.clientX,a.clientY,"wd-spag-canvas",{touch:a.pointerType==="touch"});const r=this._hoverNearest;if(r&&r.cid){const i=e.cleanup.selected;i.has(r.cid)?i.delete(r.cid):i.add(r.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}if(e==="retry-load"){this._loading=!0,this._fetchAll();return}const a=this._devices[this._selIdx];if(!a)return;const r=a.entry_id;if(e.startsWith("sug-"))return this._onActSuggestions(e,t,a,r,s);if(e.startsWith("ml-"))return this._onActMl(e,t,a,r);if(e.startsWith("store-"))return this._onActStore(e,t,a,r,s);if(e.startsWith("auto-"))return this._onActAuto(e,t,a,r,s);if(e.startsWith("maint-"))return this._onActMaintenance(e,t,a,r,s);if(e.startsWith("pg-")&&e!=="pg-new"&&e!=="pg-edit")return this._onActPlayground(e,t,a,r);if(e==="open-cycle"){const i=t.dataset.cid,o=t.dataset.mode==="review"?"review":"view";this._modal={type:"cycle-detail",entryId:r,cycleId:i,loaded:!1,mode:o,curve:null,ml:(this._mlById||{})[i]||null,trim:{start:0,end:0},split:{offsets:[],profiles:[]},drag:null},this._profiles.length||this._fetchProfiles(r),this._render(),this._ws({type:`${w}/get_cycle_power_data`,entry_id:r,cycle_id:i}).then(n=>{this._modal&&this._modal.cycleId===i&&(this._modal.curve=n,this._modal.loaded=!0,this._modal.trim={start:0,end:n.full_duration_s||0},this._loadCycleOverlays(r,i,n),this._loadCycleContext(r,i,n),this._render())}).catch(n=>this._showToast(this._tText("toast.could_not_load_cycle",{error:n.message||n},"Could not load cycle: "+(n.message||n)),"error"))}else if(e==="cleanup-edit-cycle"){const i=t.dataset.cid;this._prevModal=this._modal,this._modal={type:"cycle-detail",entryId:r,cycleId:i,loaded:!1,mode:"view",curve:null,ml:(this._mlById||{})[i]||null,trim:{start:0,end:0},split:{offsets:[],profiles:[]},drag:null},this._profiles.length||this._fetchProfiles(r),this._render(),this._ws({type:`${w}/get_cycle_power_data`,entry_id:r,cycle_id:i}).then(o=>{this._modal&&this._modal.cycleId===i&&(this._modal.curve=o,this._modal.loaded=!0,this._modal.trim={start:0,end:o.full_duration_s||0},this._loadCycleOverlays(r,i,o),this._loadCycleContext(r,i,o),this._render())}).catch(o=>this._showToast(this._tText("toast.could_not_load_cycle",{error:o.message||o},"Could not load cycle: "+(o.message||o)),"error"))}else if(e==="open-profile"){const i=t.dataset.pname;this._prevModal=null;const o=(this._profiles||[]).find(n=>n.name===i)||{name:i};this._modal={type:"profile-panel",name:i,tab:"stats",loaded:!1,stats:o,env:null,phases:[],catalog:[],cleanup:null},this._render(),Promise.all([this._ws({type:`${w}/get_profile_envelope`,entry_id:r,profile_name:i}).catch(()=>({envelope:null})),this._ws({type:`${w}/get_profile_phases`,entry_id:r,profile_name:i}).catch(()=>({phases:[]})),this._ws({type:`${w}/get_phase_catalog`,entry_id:r}).catch(()=>({phases:[]}))]).then(([n,l,c])=>{!this._modal||this._modal.name!==i||(this._modal.env=n.envelope,this._modal.phases=(l.phases||[]).map(p=>({name:p.name,start:p.start,end:p.end})),this._modal.catalog=(c.phases||[]).map(p=>p.name),this._modal.loaded=!0,this._render())})}else if(e==="create-profile")this._modal={type:"create-profile"},this._render();else if(e==="coverage-create")this._modal={type:"create-profile",prefillCycle:t.dataset.cid||"",prefillCycleMin:t.dataset.min||""},this._render();else if(e==="setup-cta"){const i=t.dataset.ctaAction||"";let o={};try{o=JSON.parse(t.dataset.ctaParams||"{}")}catch{}this._dispatchSetupCta(i,o)}else if(e==="setup-skip"){const i=t.dataset.step,o=t.dataset.snooze;if(i){let n;if(o==="never")n="never";else{const l=new Date;l.setDate(l.getDate()+14),n=l.toISOString()}this._setPref(i,n),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-internal")this._snapshotFormToPending(s),this._setPref("show_internal",!!t.checked),this._render();else if(e==="set-settings-level"){const i=(t.type==="checkbox"?t.checked:t.dataset.slevel==="advanced")?"advanced":"basic";i!==this._pref("settings_level","basic")&&(this._snapshotFormToPending(s),this._setPref("settings_level",i),this._render())}else if(e==="pg-new"||e==="pg-edit"){if(e==="pg-new")this._modal={type:"profile-group",orig:null,name:"",members:[]};else{const i=t.dataset.gname,o=((this._profileGroups||{}).groups||[]).find(n=>n.name===i);this._modal={type:"profile-group",orig:i,name:i,members:o?[...o.members||[]]:[]}}this._render(),this._ensureProfileEnvs(r,(this._profiles||[]).map(i=>i.name)).then(()=>{this._modal&&this._modal.type==="profile-group"&&this._render()})}else if(e==="rebuild-envelopes")this._kickAndTrack({type:`${w}/rebuild_envelopes`,entry_id:r},"rebuild-envelopes",async()=>{this._showToast(this._t("toast.envelopes_rebuilt",{},"Envelopes rebuilt")),await this._fetchProfiles(r)});else if(e==="rec-start")this._ws({type:`${w}/start_recording`,entry_id:r}).then(()=>(this._showToast(this._t("toast.recording_started",{},"Recording started")),this._fetchRecState(r))).then(()=>this._render()).catch(i=>this._showToast(this._tText("toast.start_failed",{error:i.message||i},"Start failed: "+(i.message||i)),"error"));else if(e==="rec-stop")this._ws({type:`${w}/stop_recording`,entry_id:r}).then(()=>(this._showToast(this._t("toast.recording_stopped",{},"Recording stopped")),this._fetchRecState(r))).then(()=>this._render()).catch(i=>this._showToast(this._tText("toast.stop_failed",{error:i.message||i},"Stop failed: "+(i.message||i)),"error"));else if(e==="rec-process-open")this._fetchProfiles(r).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:`${w}/discard_recording`,entry_id:r}),this._showToast(this._t("toast.recording_discarded",{},"Recording discarded")),await this._fetchRecState(r)}catch(i){this._showToast(this._tText("toast.discard_failed",{error:i.message||i},"Discard failed: "+(i.message||i)),"error")}}},this._render();else if(e==="fb-confirm"){const i=t.dataset.cid;this._ws({type:`${w}/resolve_feedback`,entry_id:r,cycle_id:i,action:"confirm"}).then(()=>(this._showToast(this._t("toast.feedback_confirmed",{},"Feedback confirmed")),this._fetchFeedbacks(r))).then(()=>{const o=this._modal;o&&o.type==="cycle-detail"&&o.cycleId===i&&o.overlayPlan&&o.overlayPlan.suspected&&(o.curve&&(o.curve.profile_name=o.overlayPlan.name),o.overlayPlan={name:o.overlayPlan.name,suspected:!1,runnerUp:null}),this._render()}).catch(o=>this._showToast(this._tText("msg.toast_error",{error:o.message||o},"Error: "+(o.message||o)),"error"))}else if(e==="fb-ignore")this._ws({type:`${w}/resolve_feedback`,entry_id:r,cycle_id:t.dataset.cid,action:"ignore"}).then(()=>(this._showToast(this._t("toast.feedback_dismissed",{},"Feedback dismissed")),this._fetchFeedbacks(r))).then(()=>this._render()).catch(i=>this._showToast(this._tText("msg.toast_error",{error:i.message||i},"Error: "+(i.message||i)),"error"));else if(e==="fb-correct")this._fetchProfiles(r).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._tText("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:`${w}/dismiss_all_feedbacks`,entry_id:r}),this._showToast(this._t("toast.feedback_all_dismissed",{},"All feedbacks dismissed")),await this._fetchFeedbacks(r)}catch(i){this._showToast(this._tText("msg.toast_error",{error:i.message||i},"Error: "+(i.message||i)),"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 i=t.dataset.pname,o=t.dataset.pid,n=t.dataset.reset==="1",l=n?this._tText("modal.reset_phase_msg",{name:i},`Undo your edit of "${i}" and restore the built-in phase? Programs keep their ranges.`):this._tText("modal.delete_phase_msg",{name:i},`Delete phase "${i}"?`),c=n?this._tText("toast.phase_reset",{name:i},`"${i}" restored to the built-in phase`):this._tText("toast.phase_deleted",{name:i},`Phase "${i}" deleted`);this._modal={type:"confirm",title:n?this._t("modal.reset_phase_title",{},"Reset Phase"):this._t("modal.delete_phase_title",{},"Delete Phase"),message:l,okLabel:n?this._t("btn.reset",{},"Reset"):this._t("btn.delete",{},"Delete"),onOk:async()=>{try{await this._ws({type:`${w}/delete_phase`,entry_id:r,phase_id:o}),this._showToast(c),await this._fetchPhases(r)}catch(p){this._showToast(this._tText("msg.toast_delete_failed",{error:p.message||p},"Delete failed: "+(p.message||p)),"error")}}},this._render()}else if(e==="diag-refresh")this._fetchToolsData(r).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), recost cycles against recorded energy prices 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:`${w}/reprocess_history`,entry_id:r},"reprocess",async i=>{const o=i.count||0,n=[this._tText("toast.processed_cycles",{n:o},o+" cycles")];i.suggestions!=null&&n.push(this._tText("toast.processed_suggestions",{n:i.suggestions},i.suggestions+" suggestion(s)"));const l=i.ml_training&&i.ml_training.ok&&(i.ml_training.promoted||[]).length||0;l&&n.push(this._tText("toast.processed_models",{n:l},l+" model(s) promoted")),this._showToast(this._tText("toast.processed",{bits:n.join(", ")},"Processed "+n.join(", "))),await this._fetchToolsData(r)})},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("btn.clear",{},"Clear"),onOk:()=>this._busyRun("clear-debug",async()=>{try{const i=await this._ws({type:`${w}/clear_debug_data`,entry_id:r});this._showToast(this._tText("toast.debug_cleared",{count:i.count||0},`Cleared ${i.count||0} debug traces`)),await this._fetchToolsData(r)}catch(i){this._showToast(this._tText("msg.toast_error",{error:i.message||i},"Error: "+(i.message||i)),"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:`${w}/wipe_history`,entry_id:r}),this._showToast(this._t("toast.all_wiped",{},"All data wiped")),this._cycles=[],this._profiles=[],await this._fetchToolsData(r)}catch(i){this._showToast(this._tText("msg.toast_error",{error:i.message||i},"Error: "+(i.message||i)),"error")}})},this._render();else if(e==="import-undo"){const i=this._importUndo||{};this._modal={type:"confirm",title:this._tText("modal.undo_import_title",{},"Undo last import"),message:this._tText("modal.undo_import_msg",{when:et(i.created_at)},`This device's data and settings go back to how they were before the last import (${et(i.created_at)}). Cycles recorded and changes made since then are lost.`),okLabel:this._tText("modal.undo_import_ok",{},"Undo import"),onOk:()=>this._busyRun("import-undo",async()=>{try{const o=await this._ws({type:`${w}/undo_import`,entry_id:r}),n=(o&&o.summary||{}).counts||{},l=(n.real_cycles||0)+(n.reference_cycles||0)+(n.backfill_cycles||0);this._showToast(this._tText("toast.import_undone",{profiles:n.profiles||0,cycles:l},`Import undone: ${n.profiles||0} profile(s) and ${l} cycle(s) restored`))}catch(o){this._showToast(this._tText("toast.import_undo_failed",{error:o.message||o},"Could not undo the import: "+(o.message||o)),"error")}this._isActiveEntry(r)&&(await this._fetchToolsData(r),await this._fetchCycles(r),await this._fetchProfiles(r))})},this._render()}else if(e==="export-config")this._ws({type:`${w}/export_config`,entry_id:r}).then(i=>{const o=new Blob([i.json_data],{type:"application/json"}),n=URL.createObjectURL(o),l=document.createElement("a");l.href=n,l.download=`washdata_export_${r.slice(0,8)}.json`,document.body.appendChild(l),l.click(),document.body.removeChild(l),URL.revokeObjectURL(n),this._showToast(this._t("toast.export_downloaded",{},"Export downloaded"))}).catch(i=>this._showToast(this._tText("toast.export_failed",{error:i.message||i},"Export failed: "+(i.message||i)),"error"));else if(e==="export-select-open")this._modal={type:"export-select",inventory:null,loading:!0,sel:this._wizEmptySel(),expanded:new Set},this._render(),(async()=>{let i=null;try{const o=await this._ws({type:`${w}/get_export_inventory`,entry_id:r});i=o&&o.manifest||null}catch(o){this._showToast(this._tText("toast.export_failed",{error:o.message||o},"Export failed: "+(o.message||o)),"error")}if(!(!this._isActiveEntry(r)||!this._modal||this._modal.type!=="export-select")){if(!i){this._modal=null,this._render();return}this._modal.inventory=i,this._modal.sel=this._wizInitSel({categories:i},!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._openAutoLabelModal(r);else if(e==="cyc-merge"){const i=Array.from(this._cycleSel);if(i.length<2)return;this._fetchProfiles(r).then(()=>{this._modal={type:"merge-cycles",ids:i},this._render()})}else if(e==="cyc-relabel"){const i=Array.from(this._cycleSel);if(!i.length)return;this._fetchProfiles(r).then(()=>{this._modal={type:"bulk-relabel",ids:i},this._render()})}else if(e==="cyc-load-more")this._busyRun("cyc-load-more",async()=>{try{await this._loadMoreCycles(r)}catch(i){this._showToast(this._tText("toast.load_more_failed",{error:i.message||i},"Could not load more: "+(i.message||i)),"error")}});else if(e==="task-cancel"){const i=t.dataset.taskId;i&&(this._cancellingTasks.add(i),this._updateTaskPills(),this._ws({type:`${w}/cancel_task`,task_id:i}).catch(()=>{this._cancellingTasks.delete(i),this._updateTaskPills()}))}else if(e==="cyc-compare"){const i=Array.from(this._cycleSel);if(i.length<2)return;this._modal={type:"compare-cycles",ids:i,cycles:{},hidden:new Set,overlays:[],loaded:!1},this._profiles.length||this._fetchProfiles(r),this._render(),Promise.all(i.map(o=>this._ws({type:`${w}/get_cycle_power_data`,entry_id:r,cycle_id:o}).then(n=>({cid:o,r:n})).catch(()=>({cid:o,r:null})))).then(o=>{!this._modal||this._modal.type!=="compare-cycles"||(o.forEach(({cid:n,r:l})=>{l&&(this._modal.cycles[n]=l)}),this._modal.loaded=!0,this._render())})}else if(e==="cyc-bulk-del"){const i=Array.from(this._cycleSel);if(!i.length)return;this._deleteCyclesWithUndo(r,i)}else if(e==="retry-tab")this._fetchTabData();else if(e==="retry-cycles")this._fetchCycles(r).then(()=>this._render());else if(e==="retry-profiles")Promise.all([this._fetchProfiles(r),this._fetchProfileGroups(r)]).then(()=>this._render());else if(e==="retry-suggestions")this._fetchSuggestions(r).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==="goto-standby")this._settingsSec="detection",this._settingsSearch="",this._settingsSugOnly=!1,this._tab="settings",this._fetchTabData();else if(e==="conf-goto-section"){const i=this._conflictKeysFromOpts();for(const o of yt)if((o.fields||(o.groups||[]).flatMap(l=>l.fields||[])).some(l=>i.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 i=t.dataset.key,o=JSON.parse(t.dataset.val);if(!i)return;const n=a.entry_id;this._ws({type:`${w}/set_options`,entry_id:n,options:{[i]:o}}).then(()=>this._ws({type:`${w}/get_options`,entry_id:n})).then(l=>(this._opts=l.options||{},this._optDefaults=l.defaults||{},this._fetchSettingsChangelog(n))).then(()=>{this._showToast(this._tText("msg.toast_reverted",{key:this._t("setting."+i+".label",{},i)},"{key} reverted"),"success"),this._render()}).catch(l=>this._showToast(this._tText("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==="add-device")this._navigate(`/config/integrations/integration/${w}`);else if(e==="goto-feedbacks")this._tab="history",this._historySub="cycles",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:`${w}/get_logs`,limit:500}).then(i=>{const o=(i.logs||[]).map(p=>`${new Date(p.ts*1e3).toISOString()} ${p.level} ${p.msg}`).join(`
|
|
`),n=new Blob([o],{type:"text/plain"}),l=URL.createObjectURL(n),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(i=>this._showToast(this._tText("toast.export_failed",{error:i.message||i},"Export failed: "+(i.message||i)),"error"));else if(e==="import-config-open")this._modal={type:"import-wizard",step:"input",jsonText:"",manifest:null,error:null,sel:this._wizEmptySel(),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:Ut(),token:null,scanTaskId:null,applyTaskId:null,result:null,accept:new Set,done:null,error:null},this._render();else if(e==="save-prefs"){const i=s.getElementById("wd-pref-tab")?.value||"",o=!!s.getElementById("wd-pref-debug")?.checked,n=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",p=s.getElementById("wd-pref-lang")?.value||"",_=parseFloat(s.getElementById("wd-pref-fontscale")?.value)||1,f={default_tab:i,show_debug:o,show_expected:n,show_raw:l,date_format:c,lang_override:p,font_scale:_};this._busyRun("save-prefs",async()=>{try{await this._ws({type:`${w}/set_user_prefs`,prefs:f}),this._panelCfg&&(this._panelCfg.prefs={...this._panelCfg.prefs||{},...f});const u=p||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._tText("toast.save_failed",{error:u.message||u},"Save failed: "+(u.message||u)),"error")}})}else if(e==="save-panel"){const i={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:`${w}/set_panel_config`,panel:i}),this._panelCfg=await this._ws({type:`${w}/get_panel_config`}),this._tabInitialized=!0,this._applyPanelConfig(),this._showToast(this._t("toast.panel_settings_saved",{},"Panel settings saved"))}catch(o){this._showToast(this._tText("msg.toast_save_failed",{error:o.message||o},"Save failed: "+(o.message||o)),"error")}})}else if(e==="pause-cycle")this._ws({type:`${w}/pause_cycle`,entry_id:r}).then(i=>{if(i&&i.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(i=>this._showToast(this._tText("toast.pause_failed",{error:i.message||i},"Pause failed: "+(i.message||i)),"error"));else if(e==="resume-cycle")this._ws({type:`${w}/resume_cycle`,entry_id:r}).then(i=>{if(i&&i.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(i=>this._showToast(this._tText("msg.toast_resume_failed",{error:i.message||i},"Resume failed: "+(i.message||i)),"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:`${w}/terminate_cycle`,entry_id:r}),this._showToast(this._t("toast.cycle_force_stopped",{},"Cycle force-stopped")),await this._fetchAll()}catch(i){this._showToast(this._tText("msg.toast_force_stop_failed",{error:i.message||i},"Force stop failed: "+(i.message||i)),"error")}}},this._render();else if(e==="save-rbac"){const i=!!s.getElementById("wd-rbac-enabled")?.checked,o=s.getElementById("wd-rbac-default")?.value||"none",n={};s.querySelectorAll("[data-rbacuser]").forEach(l=>{const c=l.dataset.rbacuser,p=l.dataset.rbacdev,_=l.value;n[c]||(n[c]={default:"none",devices:{}}),p==="__default__"?n[c].default=_:_&&_!=="inherit"&&(n[c].devices[p]=_)}),this._busyRun("save-rbac",async()=>{try{await this._ws({type:`${w}/set_panel_config`,rbac:{enabled:i,default_level:o,users:n}}),this._panelCfg=await this._ws({type:`${w}/get_panel_config`}),this._showToast(this._t("toast.access_saved",{},"Access control saved"))}catch(l){this._showToast(this._tText("msg.toast_save_failed",{error:l.message||l},"Save failed: "+(l.message||l)),"error")}})}}_applyAllSuggestions(t,e){this._busyRun("save-settings",async()=>{try{await this._ws({type:`${w}/apply_suggestions`,entry_id:t,keys:e}),this._showToast(this._t("toast.suggestions_applied",{},"Suggestions applied; integration reloading")),await this._fetchSuggestions(t);const s=await this._ws({type:`${w}/get_options`,entry_id:t});this._opts=s.options||{},this._optDefaults=s.defaults||{},this._prevOpts=null,this._cascadePending={},this._preCascadeOpts=null}catch(s){this._showToast(this._tText("toast.apply_failed",{error:s.message||s},"Apply failed: "+(s.message||s)),"error")}})}_onActSuggestions(t,e,s,a,r){if(t==="sug-apply-all"){const i=this._suggestions.map(n=>n.key),o=this._suggestions.map(n=>{const l=wt[n.key]||{},c=l.unit?` ${l.unit}`:"",p=n.current==null||n.current===""?"-":`${n.current}${c}`;return{text:`${this._t("setting."+n.key+".label",{},l.label||n.key)}: ${p} \u2192 ${n.suggested}${c}`,sub:we(this._t.bind(this),n.key,n.suggested,n.current)}});this._modal={type:"confirm",okPrimary:!0,items:o,title:this._t("modal.apply_suggestions_title",{},"Apply suggestions"),message:this._tText("modal.apply_suggestions_msg",{n:o.length},`Change these ${o.length} settings? The integration reloads after saving.`),okLabel:this._t("btn.apply_all",{},"Apply all"),onOk:()=>this._applyAllSuggestions(a,i)},this._render()}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:`${w}/clear_suggestions`,entry_id:a}),this._suggestions=[],this._showToast(this._t("toast.suggestions_dismissed",{},"Suggestions dismissed"))}catch(i){this._showToast(this._tText("toast.error",{error:i.message||i},"Error: "+(i.message||i)),"error")}})):t==="sug-unmute-all"?this._busyRun("save-settings",async()=>{try{const i=[...this._lockedSuggestions||[]],o=await Promise.allSettled(i.map(c=>this._ws({type:`${w}/set_suggestion_lock`,entry_id:a,key:c,locked:!1})));if(!this._isActiveEntry(a))return;const n=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(a),n?this._showToast(this._tText("toast.error",{error:`${n} suggestion(s) failed to unlock`},`${n} suggestion(s) failed to unlock`),"error"):this._showToast(this._t("msg.sug_unmuted_all",{},"Muted suggestions reset"),"success")}catch(i){this._showToast(this._tText("toast.error",{error:i.message||i},"Error: "+(i.message||i)),"error")}}):t==="sug-analyze"&&this._busyRun("sug-analyze",async()=>{try{const i=await this._ws({type:`${w}/run_suggestion_analysis`,entry_id:a}),o=i&&i.count||0;this._showToast(o?this._tText("toast.analysis_complete",{count:o},`Analysis complete: ${o} suggestion(s)`):this._t("toast.analysis_complete_none",{},"Analysis complete: no new suggestions")),await this._fetchSuggestions(a)}catch(i){this._showToast(this._tText("toast.analysis_failed",{error:i.message||i},"Analysis failed: "+(i.message||i)),"error")}})}_onActMl(t,e,s,a){t==="ml-train-now"?this._kickAndTrack({type:`${w}/trigger_ml_training`,entry_id:a},"ml-train-now:"+a,async r=>{if(r&&r.ok){const i=(r.promoted||[]).length,o=!i&&(r.results||[]).find(n=>n&&n.promoted===!1&&n.reason_code);this._showToast(i?this._tText("toast.ml_training_promoted",{count:i},`Training complete: promoted ${i} model(s)`):o?this._mlSkipReason(o)[1]: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(a)}):t==="ml-revert-models"&&this._busyRun("ml-revert-models",async()=>{try{await this._ws({type:`${w}/revert_ml_models`,entry_id:a}),this._showToast(this._t("toast.models_reverted",{},"On-device models reverted to baseline")),await this._loadMlTrainingStatus(a)}catch(r){this._showToast(this._tText("msg.toast_revert_failed",{error:r.message||r},"Revert failed: "+(r.message||r)),"error")}})}_onActStore(t,e,s,a,r){if(t==="store-toggle-online"){const i=!!e.checked;this._busyRun("store-account",async()=>{try{const o=await this._ws({type:`${w}/store_set_online`,entry_id:a,enabled:i});this._constants.storeOnlineEnabled=!!(o&&o.enabled),this._constants.storeOnlineEnabled?(await this._loadStoreStatus(a),this._ensureStoreConnectListener()):(this._storeStatus={enabled:!1},this._storeConnected=!1)}catch(o){this._showToast(this._tText("toast.store_error",{error:o.message||o},"Error: "+(o.message||o)),"error")}})}else if(t==="store-toggle-pref"){const i=e.dataset.pref,o=!!e.checked;if(!i)return;this._busyRun("store-account",async()=>{try{const n=await this._ws({type:`${w}/store_set_prefs`,entry_id:a,prefs:{[i]:o}});n&&n.prefs&&(this._constants={...this._constants,storePrefs:n.prefs})}catch(n){this._showToast(this._tText("toast.store_error",{error:n.message||n},"Error: "+(n.message||n)),"error")}})}else if(t==="store-goto-identity")this._tab="settings",this._settingsSec="basic",this._fetchTabData(),requestAnimationFrame(()=>requestAnimationFrame(()=>{const i=this.shadowRoot&&this.shadowRoot.getElementById("wd-store-brand");i&&(i.focus(),i.scrollIntoView({block:"center"}))}));else if(t==="store-refresh-catalog")this._busyRun("store-refresh-catalog",async()=>{try{await this._ws({type:`${w}/store_refresh_catalog`,entry_id:a}),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._dropModelCandidates(),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(i){this._showToast(this._tText("toast.store_error",{error:i.message||i},"Error: "+(i.message||i)),"error")}});else if(t==="store-connect"){const i=this._constants.storeWebOrigin;if(!i){this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener(),window.open(i+"/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:`${w}/store_disconnect`,entry_id:a}),await this._loadStoreStatus(a),this._showToast(this._t("toast.store_disconnected",{},"Disconnected from the community store"))}catch(i){this._showToast(this._tText("toast.store_error",{error:i.message||i},"Error: "+(i.message||i)),"error")}});else if(t==="store-add-appliance"){const i=this._constants.storeWebOrigin;if(!i){this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener();const o=r.getElementById("wd-store-model"),n=r.getElementById("wd-store-brand"),l=new URLSearchParams({mode:"device",type:this._storeApplianceType(),brand:n&&n.value||this._opts.store_brand||"",model:o&&o.value||this._opts.store_model||"",origin:location.origin}).toString();this._storeContribEid=a,window.open(i+"/create.html?"+l,"washdata_create","width=560,height=760")}else if(t==="store-add-brand"){const i=this._constants.storeWebOrigin;if(!i){this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener();const o=r.getElementById("wd-store-brand"),n=new URLSearchParams({mode:"brand",brand:o&&o.value||this._opts.store_brand||"",origin:location.origin}).toString();this._storeContribEid=a,window.open(i+"/create.html?"+n,"washdata_create","width=560,height=760")}else if(t==="store-confirm-device"){const i=e.dataset.deviceId;this._busyRun("store-account",async()=>{try{const o=await this._ws({type:`${w}/store_confirm_device`,entry_id:a,device_id:i});if(o&&o.error){this._showToast(this._tText("toast.store_error",{error:o.error},"Error: "+o.error),"error");return}const n=[...this._catalog.devices||[],...this._storeDevices||[],this._catalogEntry&&this._catalogEntry.device||null];for(const l of n)o&&l&&String(l.id)===String(i)&&(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._tText("toast.store_error",{error:o.message||o},"Error: "+(o.message||o)),"error")}})}else if(t==="store-rate-device"){const i=e.dataset.deviceId,o=parseInt(e.dataset.rating,10);if(!(o>=1&&o<=5))return;this._busyRun("store-account",async()=>{try{const n=await this._ws({type:`${w}/store_rate_device`,entry_id:a,device_id:i,rating:o});if(n&&n.error){this._showToast(this._tText("toast.store_error",{error:n.error},"Error: "+n.error),"error");return}this._showToast(this._t("toast.rating_saved",{},"Quality rating saved"))}catch(n){this._showToast(this._tText("toast.store_error",{error:n.message||n},"Error: "+(n.message||n)),"error")}})}else if(t==="store-nav"){const i=e.dataset.view;i==="brands"?(this._storeView="brands",this._storeDevice=null,this._storeProfile=null,this._render()):i==="device"&&(this._storeView="device",this._storeProfile=null,this._render())}else if(t==="store-open-device"){const i=e.dataset.deviceId,o=(this._storeDevices||[]).find(n=>String(n.id)===String(i));o&&this._storeOpenDevice(o)}else if(t==="store-open-profile"){const i=e.dataset.profileId,o=(this._storeProfiles||[]).find(n=>String(n.id)===String(i));if(!o)return;this._storeProfile=o,this._storeView="profile",this._storeCycles=[],this._storeLoading=!0,this._storeBrowseErr=!1,this._render(),this._ws({type:`${w}/store_get_cycles`,entry_id:a,profile_id:o.id}).then(n=>{!this._isActiveEntry(a)||this._storeView!=="profile"||(this._storeCycles=n&&n.items||[],this._storeBrowseErr=!!(n&&n.error))}).catch(()=>{this._isActiveEntry(a)&&(this._storeCycles=[],this._storeBrowseErr=!0)}).finally(()=>{this._isActiveEntry(a)&&(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 i=e.dataset.deviceId;if(!i)return;const o=!!this._dlSettings;this._busyRun("store-download-device",async()=>{try{const n=await this._ws({type:`${w}/store_download_device`,entry_id:a,device_id:i,include_settings:o}),l=n&&n.task_id?(await this._awaitTask(n.task_id)||{}).result:n;if(l&&(l.error||l.disabled)){const x=l.error||"unavailable";this._showToast(this._tText("toast.store_download_failed",{error:x},"Download failed: "+x),"error");return}const c=l&&l.profiles_adopted||0,p=l&&l.cycles_imported||0,_=l&&l.settings_applied||0,f=l&&l.cycles_skipped||0,u=f?this._tText("toast.store_download_skipped",{n:f},`${f} recording(s) skipped: too short, too gappy or already on your device.`):"";if(!c&&!p&&!_){this._showToast(u||this._t("toast.store_download_nothing",{},"Nothing new to download - this setup is already on your device."),"info");return}await this._fetchProfiles(a),await this._fetchCycles(a);const b=l&&l.phases_applied||0;let v;_?v=this._tText("toast.store_device_downloaded_settings",{p:c,c:p,ph:b,s:_},`${c} program(s), ${p} recording(s), ${b} phase map(s), ${_} setting(s) added`):b?v=this._tText("toast.store_device_downloaded_phases",{p:c,c:p,ph:b},`${c} program(s), ${p} recording(s), ${b} phase map(s) added`):v=this._tText("toast.store_device_downloaded",{p:c,c:p},`${c} program(s), ${p} recording(s) added`);const T=l&&l.partial&&l.failed_profiles||0,C=T?this._tText("toast.store_download_partial",{n:T},`${T} program(s) could not be downloaded; try again later.`):"";this._showToast([v,u,C].filter(Boolean).join(". "),T?"info":void 0)}catch(n){this._showToast(this._tText("toast.store_download_failed",{error:n.message||n},"Download failed: "+(n.message||n)),"error")}})}else if(t==="store-import"){const i=e.dataset.cycleId,o=this._storeProfile&&this._storeProfile.program||"";this._modal={type:"store-import",cycleId:i,program:o,mode:"new"},this._render()}else if(t==="store-share-cycle"){const i=e.dataset.cid,o=e.dataset.prof||"";this._modal={type:"store-share",cycleId:i,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:`${w}/get_shareable_cycles`,entry_id:a});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(a)||!this._modal||this._modal.type!=="store-share-device")return;const i=new Set;this._shareableByProgram().forEach(o=>o.cycles.forEach(n=>i.add(n.id))),this._modal.selected=i,this._modal.includePhases=new Set(this._sharePhasePrograms),this._modal.loading=!1,this._render()})();else if(t==="store-share-profile"){const i=e.dataset.prog||"";this._modal={type:"store-share-device",selected:new Set,includePhases:new Set,includeSettings:!1,loading:!0,focusProfile:i},this._render(),(async()=>{try{const n=await this._ws({type:`${w}/get_shareable_cycles`,entry_id:a});this._shareableCycles=n&&n.items||[],this._sharePhasePrograms=n&&n.phase_programs||[],this._shareAllPrograms=n&&n.all_programs||[]}catch{this._shareableCycles=[],this._sharePhasePrograms=[],this._shareAllPrograms=[]}if(!this._isActiveEntry(a)||!this._modal||this._modal.type!=="store-share-device")return;const o=new Set;this._shareableByProgram().filter(n=>n.program===i).forEach(n=>n.cycles.forEach(l=>o.add(l.id))),this._modal.selected=o,this._modal.includePhases=new Set((this._sharePhasePrograms||[]).filter(n=>n===i)),this._modal.loading=!1,this._render()})()}else if(t==="store-share-add-profile"){const i=this._modal,o=this._constants.storeWebOrigin;if(!o||!i){o||this._showToast(this._t("toast.store_unavailable",{},"The community store is not available."),"error");return}this._ensureStoreConnectListener();const n=new URLSearchParams({mode:"profile",device:i.deviceId||"",type:this._storeApplianceType(),brand:this._opts.store_brand||"",model:this._opts.store_model||"",origin:location.origin}).toString();window.open(o+"/create.html?"+n,"washdata_create","width=560,height=760")}}_onActAuto(t,e,s,a,r){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 i=e.dataset.autoid,o=e.dataset.autoname||"this automation";this._modal={type:"confirm",title:this._t("modal.delete_automation_title",{},"Delete Automation"),message:this._tText("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/"+i),this._showToast(this._t("toast.automation_deleted",{},"Automation deleted")),await this._loadDeviceAutomations(a)}catch(n){this._showToast(this._tText("toast.delete_failed",{error:n.message||n},"Delete failed: "+(n.message||n)),"error")}}},this._render()}else t==="auto-convert-legacy"?this._convertLegacyActions():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:`${w}/set_options`,entry_id:a,options:{notify_actions:[]}}),this._opts={...this._opts,notify_actions:[]},this._showToast(this._t("toast.legacy_removed",{},"Legacy actions removed"))}catch(i){this._showToast(this._tText("toast.remove_failed",{error:i.message||i},"Remove failed: "+(i.message||i)),"error")}}},this._render())}_onActMaintenance(t,e,s,a,r){if(t==="maint-add"){const i=r.getElementById("wd-maint-type")?.value||"",o=r.getElementById("wd-maint-date")?.value||"",n=(r.getElementById("wd-maint-notes")?.value||"").trim();if(!i){this._showToast(this._tText("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:`${w}/add_maintenance_event`,entry_id:a,event_type:i};o&&(l.date=o),n&&(l.notes=n),await this._ws(l),await this._fetchMaintenance(a),this._showToast(this._t("toast.maint_added",{},"Maintenance event added")),this._render()}catch(l){this._showToast(this._tText("toast.maint_add_failed",{error:l.message||l},"Could not add event: "+(l.message||l)),"error")}})}else if(t==="maint-delete"){const i=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:`${w}/delete_maintenance_event`,entry_id:a,event_id:i}),await this._fetchMaintenance(a),this._showToast(this._t("toast.maint_deleted",{},"Maintenance event deleted"))}catch(o){this._showToast(this._tText("toast.maint_delete_failed",{error:o.message||o},"Could not delete event: "+(o.message||o)),"error")}})},this._render()}else if(t==="maint-save-odometer"){const i=r.getElementById("wd-maint-odometer")?.value,o=parseInt(i,10);if(isNaN(o)||o<0){this._showToast(this._tText("toast.odometer_save_failed",{error:this._t("lbl.total_cycles_run",{},"cycles run in total")},"Could not save total: cycles run in total"),"error");return}this._busyRun("maint-save-odometer",async()=>{try{await this._ws({type:`${w}/set_lifetime_cycle_count`,entry_id:a,count:o}),await this._fetchMaintenance(a),this._showToast(this._t("toast.odometer_saved",{},"Total cycles saved")),this._render()}catch(n){this._showToast(this._tText("toast.odometer_save_failed",{error:n.message||n},"Could not save total: "+(n.message||n)),"error")}})}else if(t==="maint-save-reminders"){const i=this._maintenance||{},o=i.reminders||{},n={};r.querySelectorAll("[data-maint-rem]").forEach(_=>{const f=_.dataset.maintRem,u=parseInt(_.value,10);n[f]=!isNaN(u)&&u>0?u:0});const l=Object.keys(n).some(_=>n[_]!==(parseInt(o[_],10)||0)),c=[];for(const _ of Array.isArray(i.custom_tasks)?i.custom_tasks:[]){const f=r.querySelector(`[data-mtask-name="${CSS.escape(_.id)}"]`);if(!f)continue;const u=f.value.trim();if(!u){this._showToast(this._tText("toast.task_name_required",{},"Give the task a name"),"error");return}const b=this._maintInterval(r.querySelector(`[data-mtask-cycles="${CSS.escape(_.id)}"]`)),v=this._maintInterval(r.querySelector(`[data-mtask-days="${CSS.escape(_.id)}"]`)),T={};u!==_.name&&(T.name=u),b!==(_.cycles||0)&&(T.cycles=b),v!==(_.days||0)&&(T.days=v),Object.keys(T).length&&c.push({task_id:_.id,...T})}const p=this._maintNewTaskInput(r);this._busyRun("maint-save-reminders",async()=>{try{l&&await this._ws({type:`${w}/set_options`,entry_id:a,options:{maintenance_reminder_cycles:n}});for(const _ of c)await this._ws({type:`${w}/update_maintenance_task`,entry_id:a,..._});p.name&&await this._maintAddTask(a,p),await this._fetchMaintenance(a),this._showToast(this._t("toast.reminders_saved",{},"Service reminders saved")),this._render()}catch(_){await this._fetchMaintenance(a).catch(()=>{}),this._render(),this._showToast(this._tText("toast.reminders_save_failed",{error:_.message||_},"Could not save reminders: "+(_.message||_)),"error")}})}else if(t==="maint-task-add"){const i=this._maintNewTaskInput(r);if(!i.name){this._showToast(this._tText("toast.task_name_required",{},"Give the task a name"),"error");return}this._busyRun("maint-task-add",async()=>{try{await this._maintAddTask(a,i),await this._fetchMaintenance(a),this._showToast(this._t("toast.task_added",{},"Task added")),this._render()}catch(o){this._showToast(this._tText("toast.task_save_failed",{error:o.message||o},"Could not save task: "+(o.message||o)),"error")}})}else if(t==="maint-task-remove"){const i=e.dataset.mtask,o=(this._maintenance&&this._maintenance.custom_tasks||[]).find(l=>l.id===i),n=o?o.name:i;this._modal={type:"confirm",title:this._t("modal.remove_task_title",{},"Remove Task"),message:this._tText("modal.remove_task_msg",{task:n},'Remove the reminder "{task}"? Its entries in the maintenance log are kept.'),okLabel:this._t("btn.remove",{},"Remove"),onOk:()=>this._busyRun("maint-task-remove",async()=>{try{await this._ws({type:`${w}/delete_maintenance_task`,entry_id:a,task_id:i}),await this._fetchMaintenance(a),this._showToast(this._t("toast.task_removed",{},"Task removed"))}catch(l){this._showToast(this._tText("toast.task_save_failed",{error:l.message||l},"Could not save task: "+(l.message||l)),"error")}})},this._render()}else if(t==="maint-log-done"){const i=e.dataset.mtype;if(!i)return;const o=this._maintLabel(i);this._busyRun("maint-log-done",async()=>{try{await this._ws({type:`${w}/add_maintenance_event`,entry_id:a,event_type:i}),await this._fetchMaintenance(a),this._showToast(this._tText("toast.maint_logged",{task:o},"Logged as done: {task}")),this._render()}catch(n){this._showToast(this._tText("toast.maint_add_failed",{error:n.message||n},"Could not add event: "+(n.message||n)),"error")}})}}_maintInterval(t){const e=String(t&&t.value||"").trim();if(!e)return 0;const s=Number(e);return isNaN(s)?e:s}_maintNewTaskInput(t){return{name:(t.getElementById("wd-mtask-new-name")?.value||"").trim(),cycles:this._maintInterval(t.getElementById("wd-mtask-new-cycles")),days:this._maintInterval(t.getElementById("wd-mtask-new-days"))}}async _maintAddTask(t,e){const s={type:`${w}/add_maintenance_task`,entry_id:t,name:e.name};return e.cycles&&(s.cycles=e.cycles),e.days&&(s.days=e.days),this._ws(s)}_onActPlayground(t,e,s,a){if(t==="pg-analysis-tab"){const r=e.dataset.subtab||"history";r!==this._pgAnalysisTab&&(this._pgAnalysisTab=r,this._render(),requestAnimationFrame(()=>this._drawPlaygroundCanvases()))}else if(t==="pg-run-history")this._pgRunHistory();else if(t==="pg-batch-cancel"){this._pgBatchCancel=!0;const r=this._pgHistoryTaskId||this._pgSweepTaskId;r&&this._ws({type:`${w}/cancel_task`,task_id:r}).catch(()=>{})}else if(t==="pg-load-run"){const r=e.dataset.taskId;if(!r)return;const i=this._tasks[r];if(!i||i.kind!=="pg_history"&&i.kind!=="pg_sweep"){this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info");return}const o=i.entry_id;this._ws({type:`${w}/get_task_result`,task_id:r}).then(n=>{if(!this._isActiveEntry(o))return;const l=n&&n.result;if(!l){this._showToast(this._t("toast.pg_run_gone",{},"That run is no longer available."),"info");return}i.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 t==="pg-open-cycle"?this._pgSelectCycle(e.dataset.cid):t==="pg-sweep-run2"?this._pgRunSweep2():t==="pg-sweep-apply2"?this._pgApplySweepValue(e.dataset.val):t==="pg-run"||t==="pg-load"?this._pgLoad():t==="pg-rerun"?this._pgRerunDetail():t==="pg-cancel-run"?this._pgCancelRun():t==="pg-reset-params"?(this._pgThreshStart=null,this._pgThreshStop=null,this._pgParamOverrides={},this._render(),requestAnimationFrame(()=>this._pgDrawCanvas())):t==="pg-apply-settings"?this._pgApplyToSettings():t==="pg-load-live"?this._pgLoadLive():t==="pg-load-suggested"?this._pgLoadSuggested():t==="pg-preset-save"?this._pgSavePreset():t==="pg-preset-load"?this._pgLoadPreset():t==="pg-preset-delete"?this._pgDeletePreset():t==="pg-publish-one"&&this._pgPublishOne(e.dataset.pgkey)}async _onModalAction(t,e){const s=this.shadowRoot,a=this._devices[this._selIdx],r=a?a.entry_id:null,i=this._modal;if(t==="cancel"){if(i&&i.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"&&i&&i.onOk){const o=i.onOk;this._modal=null,this._render(),await o(),this._render();return}if(i&&i.type==="profile-group"){if(t==="pg-save"){const o=s.getElementById("wd-pg-name")?.value?.trim(),n=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(n.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{i.orig&&i.orig!==o&&await this._ws({type:`${w}/rename_profile_group`,entry_id:r,name:i.orig,new_name:o}),await this._ws({type:`${w}/save_profile_group`,entry_id:r,name:o,members:n}),this._showToast(this._t("toast.group_saved",{},"Group saved")),this._modal=null,await this._fetchProfileGroups(r)}catch(l){this._showToast(this._tText("msg.toast_save_failed",{error:l.message||l},"Save failed: "+(l.message||l)),"error")}});return}if(t==="pg-delete"&&i.orig){await this._busyRun("pg-save",async()=>{try{await this._ws({type:`${w}/delete_profile_group`,entry_id:r,name:i.orig}),this._showToast(this._t("toast.group_deleted",{},"Group deleted")),this._modal=null,await this._fetchProfileGroups(r)}catch(o){this._showToast(this._tText("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,i,r,s);if(t.startsWith("hist-"))return this._onMActHistoryImport(t,e,i,r,s);if(t.startsWith("sd-")||t==="store-share-device-ok")return this._onMActStoreShare(t,e,i,r);if(i&&i.type==="export-select"&&t==="export-generate"&&r){const o=this._wizSelectionPayload(i);await this._busyRun("export-select",async()=>{try{const n=await this._ws({type:`${w}/export_config_selective`,entry_id:r,selection:o}),l=new Blob([n.json_data],{type:"application/json"}),c=URL.createObjectURL(l),p=document.createElement("a");p.href=c,p.download=`washdata_export_${r.slice(0,8)}.json`,document.body.appendChild(p),p.click(),document.body.removeChild(p),URL.revokeObjectURL(c),this._modal=null,this._showToast(this._t("toast.export_selective_done",{},"Export downloaded"))}catch(n){this._showToast(this._tText("toast.export_failed",{error:n.message||n},"Export failed: "+(n.message||n)),"error")}});return}if(t.startsWith("cyc-")||t.startsWith("trim-mode-"))return this._onMActCycleDetail(t,i,r,s);if(i&&i.type==="profile-panel"){if(t.indexOf("pp-tab-")===0){const o=t.slice(7);i.tab=o,this._render(),o==="cleanup"&&!i.cleanup&&this._ws({type:`${w}/get_profile_cycles`,entry_id:r,profile_name:i.name}).then(n=>{this._modal&&this._modal.name===i.name&&(this._modal.cleanup={cycles:n.cycles||[],selected:new Set},this._render())}).catch(()=>{this._modal&&(this._modal.cleanup={cycles:[],selected:new Set},this._render())});return}return this._onMActProfilePanel(t,e,i,r,s)}if(t==="label-ok"&&r){const o=s.getElementById("wd-label-profile"),n=o?o.value:"",l=n||null,c=n==="__create_new__"&&s.getElementById("wd-new-profile-name")?.value?.trim()||null;this._modal=null;try{await this._ws({type:`${w}/label_cycle`,entry_id:r,cycle_id:i.cycleId,profile_name:l||null,new_profile_name:c}),this._showToast(this._t("toast.cycle_labelled",{},"Cycle labelled")),await this._fetchCycles(r),await this._fetchProfiles(r),await this._fetchFeedbacks(r)}catch(p){this._showToast(this._tText("toast.label_failed",{error:p.message||p},"Label failed: "+(p.message||p)),"error")}this._render()}else if(t==="create-profile-ok"&&r){const o=s.getElementById("wd-cp-name")?.value?.trim(),n=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=!n&&l>0?l:null;try{await this._ws({type:`${w}/create_profile`,entry_id:r,name:o,reference_cycle:n||null,manual_duration_min:c}),this._showToast(this._tText("toast.profile_created",{name:o},`Profile "${o}" created`)),await this._fetchProfiles(r)}catch(p){this._showToast(this._tText("toast.create_failed",{error:p.message||p},"Create failed: "+(p.message||p)),"error")}this._render()}else if(t==="create-phase-ok"&&r){const o=s.getElementById("wd-ph-name")?.value?.trim(),n=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:`${w}/create_phase`,entry_id:r,device_type:i.deviceType||"",name:o,description:n}),this._showToast(this._tText("toast.phase_created",{name:o},`Phase "${o}" created`)),await this._fetchPhases(r)}catch(l){this._showToast(this._tText("msg.toast_create_failed",{error:l.message||l},"Create failed: "+(l.message||l)),"error")}this._render()}else if(t==="edit-phase-ok"&&r){const o=s.getElementById("wd-eph-name")?.value?.trim(),n=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:`${w}/update_phase`,entry_id:r,phase_id:i.phaseId,new_name:o,description:n}),this._showToast(this._t("toast.phase_updated",{},"Phase updated")),await this._fetchPhases(r)}catch(l){this._showToast(this._tText("toast.update_failed",{error:l.message||l},"Update failed: "+(l.message||l)),"error")}this._render()}else if(t==="process-rec-ok"&&r){const o=s.getElementById("wd-pr-mode")?.value;let n=s.getElementById("wd-pr-profile")?.value?.trim();o==="existing_profile"&&(n=s.getElementById("wd-pr-profile-sel")?.value||n);const l=parseFloat(s.getElementById("wd-pr-head")?.value||0),c=parseFloat(s.getElementById("wd-pr-tail")?.value||0);if(this._modal=null,!n){this._showToast(this._t("msg.toast_profile_name_required",{},"Profile name is required"),"error"),this._render();return}try{await this._ws({type:`${w}/process_recording`,entry_id:r,profile_name:n,save_mode:o,head_trim:l,tail_trim:c}),this._showToast(this._t("toast.recording_saved",{},"Recording saved to profile")),await this._fetchRecState(r),await this._fetchProfiles(r)}catch(p){this._showToast(this._tText("msg.toast_save_failed",{error:p.message||p},"Save failed: "+(p.message||p)),"error")}this._render()}else if(t==="correct-fb-ok"&&r){const o=s.getElementById("wd-fb-profile")?.value,n=parseFloat(s.getElementById("wd-fb-dur")?.value||0)||null;this._modal=null;try{await this._ws({type:`${w}/resolve_feedback`,entry_id:r,cycle_id:i.cycleId,action:"correct",corrected_profile:o,corrected_duration_min:n}),this._showToast(this._t("toast.correction_submitted",{},"Correction submitted")),await this._fetchFeedbacks(r)}catch(l){this._showToast(this._tText("msg.toast_error",{error:l.message||l},"Error: "+(l.message||l)),"error")}this._render()}else if(t==="auto-run"&&r){const o=parseFloat(s.getElementById("wd-al-thr")?.value||""),n={type:`${w}/auto_label_cycles`,entry_id:r};Number.isFinite(o)&&(n.confidence_threshold=Math.min(.95,Math.max(.5,o))),this._modal=null,this._render(),await this._busyRun("auto-label",async()=>{try{const l=await this._ws(n),c=l&&l.task_id?await this._awaitTask(l.task_id):null;this._showToast(this._autoLabelToast(c)),await this._fetchCycles(r)}catch(l){this._showToast(this._tText("msg.toast_auto_label_failed",{error:l.message||l},"Auto-label failed: "+(l.message||l)),"error")}})}else if(t==="merge-ok"&&r){const o=s.getElementById("wd-merge-prof")?.value||"",n=s.getElementById("wd-merge-newname")?.value?.trim()||null,l=i.ids||[];this._modal=null,this._render(),this._kickAndTrack({type:`${w}/apply_merge`,entry_id:r,cycle_ids:l,target_profile:o||null,new_profile_name:n},"cyc-merge",async()=>{this._showToast(this._t("toast.cycles_merged",{},"Cycles merged")),this._cycleSel.clear(),this._selectMode=!1,await this._fetchCycles(r),await this._fetchProfiles(r)})}else if(t==="bulk-relabel-ok"&&r){const o=s.getElementById("wd-relabel-profile")?.value||"",n=(i.ids||[]).slice();if(this._modal=null,this._render(),!n.length)return;await this._busyRun("cyc-relabel",async()=>{try{for(const l of n)await this._ws({type:`${w}/label_cycle`,entry_id:r,cycle_id:l,profile_name:o||null});this._showToast(this._tText("toast.relabel_done",{count:n.length},`Relabelled ${n.length} cycle(s)`)),this._cycleSel.clear(),this._selectMode=!1,await this._fetchCycles(r),await this._fetchProfiles(r),await this._fetchFeedbacks(r)}catch(l){this._showToast(this._tText("toast.relabel_failed",{error:l.message||l},"Relabel failed: "+(l.message||l)),"error")}})}}async _histUpload(t,e){const s=await this._ws({type:`${w}/history_import_begin`,entry_id:t}),a=Math.max(4096,s.chunk_bytes||512*1024);let r=0,i=0;for(;i<e.length;){let o=Math.min(e.length,i+a);if(o<e.length){const n=e.lastIndexOf(`
|
|
`,o);n>i&&(o=n+1)}await this._ws({type:`${w}/history_import_chunk`,entry_id:t,token:s.token,seq:r,text:e.slice(i,o)}),r+=1,i=o}return s.token}async _histStartScan(t,e,s){e.token=s,e.step="scan",e.error=null,this._render();const a=await this._ws({type:`${w}/start_history_import_scan`,entry_id:t,token:s});e.scanTaskId=a.task_id,this._addProvisionalTask(a.task_id,"history_import",t,0),this._tasksSubscribed||this._pollTaskGeneric(a.task_id),this._histAdopt(a.task_id)}async _histAdopt(t){if(!t)return;let e=(this._tasks||{})[t];if(!e||e.state==="running")try{e=await this._ws({type:`${w}/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:`${w}/get_task_result`,task_id:t.id});e.result=s.result||{},e.accept=new Set((e.result.segments||[]).filter(a=>a.accept).map(a=>a.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. Scan again."):t.error||this._t("msg.hist_import_failed",{},"Import failed."),e.step="review",this._render();return}try{const a=t.result?t:await this._ws({type:`${w}/get_task_result`,task_id:t.id});e.done=a.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,a,r){if(!(!s||s.type!=="history-import"||!a)){if(t==="hist-scan"){const i=r.getElementById("wd-hist-csv"),o=i?i.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 n=await this._histUpload(a,o);await this._histStartScan(a,s,n)}catch(n){s.error=String(n&&n.message||n),this._render()}});return}if(t==="hist-recorder"){const i=r.getElementById("wd-hist-since"),o=i&&i.value||Ut();s.since=o,await this._busyRun("hist-import",async()=>{try{const n=await this._ws({type:`${w}/history_import_recorder`,entry_id:a,start_date:o});if(!n.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(a,s,n.token)}catch(n){s.error=String(n&&n.message||n),this._render()}});return}if(t==="hist-cancel-scan"){if(s.scanTaskId)try{await this._ws({type:`${w}/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 i=s.result&&s.result.segments||[],o=i.every(n=>s.accept.has(n.index));s.accept=o?new Set:new Set(i.map(n=>n.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 i=await this._ws({type:`${w}/apply_history_import`,entry_id:a,scan_task_id:s.scanTaskId,accept:[...s.accept].sort((o,n)=>o-n)});s.applyTaskId=i.task_id,this._addProvisionalTask(i.task_id,"history_import_apply",a,s.accept.size),this._tasksSubscribed||this._pollTaskGeneric(i.task_id),this._histAdopt(i.task_id)}catch(i){s.error=String(i&&i.message||i),this._render()}});return}if(t==="hist-goto-cycles"){this._modal=null,this._tab="history",this._historySub="cycles",this._cycleFilter={...this._cycleFilter||{},status:"imported"},this._fetchTabData();return}}}async _onMActImport(t,e,s,a,r){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 i={type:`${w}/store_import_cycle`,entry_id:a,cycle_id:s.cycleId};if(s.mode==="merge"){const o=r.getElementById("wd-store-import-target")?.value||"";if(!o){this._showToast(this._t("toast.store_pick_profile",{},"Pick a profile to merge into"),"error");return}i.target_profile=o}else{const o=(r.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}i.new_profile_name=o}await this._busyRun("store-import",async()=>{try{const o=await this._ws(i);if(o&&o.error==="low_quality"){this._showToast(this._t("toast.store_import_low_quality",{},"This recording is too short, too gappy or implausible to import."),"error");return}if(o&&o.error==="duplicate"){this._showToast(this._t("toast.store_import_duplicate",{},"This recording is already on your device."),"info");return}if(o&&o.error){this._showToast(this._tText("toast.store_import_failed",{error:o.error},"Import failed: "+o.error),"error");return}this._modal=null,this._showToast(this._tText("toast.store_imported",{profile:o&&o.profile||""},`Imported into ${o&&o.profile||"profile"}`)),await this._fetchProfiles(a)}catch(o){this._showToast(this._tText("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 i=(r.getElementById("wd-store-share-prog")?.value||"").trim(),o=(r.getElementById("wd-store-share-desc")?.value||"").trim();if(!i){this._showToast(this._t("toast.store_pick_profile_share",{},"Pick a profile to share into"),"error");return}await this._busyRun("store-share",async()=>{try{const n=await this._ws({type:`${w}/store_upload_cycle`,entry_id:a,local_cycle_id:s.cycleId,program:i,description:o});if(n&&n.error){if(n.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=n.detail?`${n.error} - ${n.detail}`:n.error;this._showToast(this._tText("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(n){this._showToast(this._tText("toast.store_share_failed",{error:n.message||n},"Share failed: "+(n.message||n)),"error")}});return}if(s&&(s.type==="export-select"||s.type==="import-wizard")){const i=s.type==="export-select"?{categories:s.inventory||{}}:s.manifest||{categories:{}};if(t==="wiz-toggle-all"){const o=i.categories||{},n=s.type==="import-wizard";let l=!0;this._wizCatOrder().filter(c=>o[c]&&o[c].present).forEach(c=>{n&&o[c].importable===!1||this._wizCatState(s,c,i).state!=="all"&&(l=!1)}),s.sel=l?this._wizEmptySel():this._wizInitSel(i,n),this._render();return}if(t==="wiz-toggle-cat"){const o=e.dataset.cat,l=this._wizCatState(s,o,i).state!=="all";if(o==="profiles"){const c=i.categories.profiles&&i.categories.profiles.items||[];s.sel.profiles=new Set(l?c.map(p=>p.name):[])}else if(this._wizCycleCats().includes(o)){const c=this._wizIdSet(s.sel,o);c.clear(),l&&(i.categories[o]&&i.categories[o].groups||[]).forEach(p=>p.cycles.forEach(_=>{_.id!=null&&c.add(String(_.id))}))}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,n=e.dataset.prof,l=this._wizIdSet(s.sel,o),c=this._wizGroupIds(i,o,n),p=c.length>0&&c.every(_=>l.has(_));c.forEach(_=>{p?l.delete(_):l.add(_)}),this._render();return}if(t==="wiz-toggle-cyc"){const o=e.dataset.cat,n=e.dataset.cid,l=this._wizIdSet(s.sel,o);l.has(n)?l.delete(n):l.add(n),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"&&a){const i=r.getElementById("wd-import-json"),o=i?i.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 n=await this._ws({type:`${w}/analyze_import`,entry_id:a,json_data:o});if(!this._isActiveEntry(a)||!this._modal||this._modal.type!=="import-wizard")return;const l=n&&n.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(n){if(!this._isActiveEntry(a)||!this._modal||this._modal.type!=="import-wizard")return;s.step="input",s.error=n&&n.message||String(n),this._render()}return}if(t==="import-apply-ok"&&a){const i=this._wizSelectionPayload(s);await this._busyRun("import-wizard",async()=>{try{const o=await this._ws({type:`${w}/import_config_selective`,entry_id:a,json_data:s.jsonText,selection:i,mode:s.mode,conflict_resolutions:s.conflicts,cycle_destination:s.cycleDest,apply_settings:!0}),n=o&&o.summary||{};this._modal=null;const l=(n.real_cycles_imported||0)+(n.reference_cycles_imported||0)+(n.backfill_cycles_imported||0);(s.mode==="replace"||Object.values(s.conflicts||{}).includes("overwrite"))&&n.restore_point_saved===!1?this._showToast(this._tText("toast.import_no_restore_point",{},"Imported, but no restore point could be saved, so this import cannot be undone"),"error"):this._showToast(this._tText("toast.import_selective_done",{profiles:n.profiles_imported||0,cycles:l},`Imported ${n.profiles_imported||0} profile(s) and ${l} cycle(s)`)),await this._fetchCycles(a),await this._fetchProfiles(a),n.restore_point_saved&&await this._fetchToolsData(a)}catch(o){this._showToast(this._tText("toast.import_failed",{error:o.message||o},"Import failed: "+(o.message||o)),"error")}});return}}}async _onMActStoreShare(t,e,s,a){if(s&&s.type==="store-share-device"){if(t==="sd-toggle-cyc"){const r=e.dataset.cid;s.selected.has(r)?s.selected.delete(r):s.selected.add(r),this._render();return}if(t==="sd-toggle-prof"){const r=e.dataset.prog,i=this._shareableByProgram().find(o=>o.program===r);if(i){const o=i.cycles.every(n=>s.selected.has(n.id));i.cycles.forEach(n=>{o?s.selected.delete(n.id):s.selected.add(n.id)})}this._render();return}if(t==="sd-toggle-phases"){const r=e.dataset.prog;s.includePhases||(s.includePhases=new Set),s.includePhases.has(r)?s.includePhases.delete(r):s.includePhases.add(r),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 r=new Map;(this._shareableCycles||[]).forEach(l=>r.set(l.id,(l.profile_name||"").trim()));const i=Array.from(s.selected).map(l=>({local_cycle_id:l,program:r.get(l)||""})).filter(l=>l.program);if(!i.length){this._showToast(this._t("toast.share_device_none_sel",{},"Select at least one cycle to share"),"error");return}const o=new Set(i.map(l=>l.program)),n=Array.from(s.includePhases||[]).filter(l=>o.has(l));await this._busyRun("store-share-device",async()=>{try{const l=await this._ws({type:`${w}/store_upload_device`,entry_id:a,items:i,include_phases:n,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._tText("toast.store_share_failed",{error:u},"Share failed: "+u),"error")}return}const c=l&&l.cycle_ids&&l.cycle_ids.length||0,p=l&&l.errors&&l.errors.length||0,_=l&&l.duplicates||0,f=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._tText("toast.store_share_failed",{error:u},"Share failed: "+u),"error");return}this._modal=null,p?this._showToast(this._tText("toast.store_device_shared_partial",{n:c,failed:p},`Shared ${c} cycle(s); ${p} could not be uploaded.`),"info"):_&&!f?this._showToast(this._tText("toast.store_device_shared_all_dup",{n:_},`All ${_} cycle(s) were already in the community store.`),"info"):_?this._showToast(this._tText("toast.store_device_shared_some_dup",{created:f,dup:_},`Shared ${f} cycle(s); ${_} were already in the store.`)):this._showToast(this._tText("toast.store_device_shared",{n:f},`Shared ${f} cycle(s) to the community store - pending review.`))}catch(l){this._showToast(this._tText("toast.store_share_failed",{error:l.message||l},"Share failed: "+(l.message||l)),"error")}});return}}}async _onMActCycleDetail(t,e,s,a){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-ctx"){const r=Number(a.getElementById("wd-cyc-ctx")?.value),i=e.entryId||s;if(!Ft.includes(r)||e.ctx&&e.ctx.minutes===r)return;const o=this._pref("cycle_context_min",null);this._setPref("cycle_context_min",{...o&&typeof o=="object"?o:{},[i]:r}),delete this._canvasZoom["wd-cyc-canvas"],this._loadCycleContext(i,e.cycleId,e.curve),this._render();return}if(t==="cyc-review-save"){const r=e.cycleId,i=a.getElementById("wd-cyc-rev-quality")?.value||"",o=!!a.getElementById("wd-cyc-rev-golden")?.checked,n=a.getElementById("wd-cyc-rev-notes")?.value||"",l=Array.from(a.querySelectorAll(".wd-cyc-rev-tag")).filter(_=>_.checked).map(_=>_.value),c=a.getElementById("wd-cyc-rev-label")?.value??"",p=e.curve&&e.curve.profile_name||"";await this._busyRun("cyc-review-save",async()=>{try{await this._ws({type:`${w}/set_ml_review`,entry_id:s,cycle_id:r,quality:i,golden:o,tags:l,notes:n}),c!==p&&await this._ws({type:`${w}/label_cycle`,entry_id:s,cycle_id:r,profile_name:c||null}),this._showToast(this._t("toast.review_saved",{},"Review saved")),await this._fetchCycles(s),c!==p&&await this._fetchFeedbacks(s),await this._loadMlIndex(s);const _=this._modal;_&&_.type==="cycle-detail"&&_.cycleId===r&&(_.ml=(this._mlById||{})[r]||_.ml,delete _.reviewLabel,c!==p&&_.curve&&(_.curve.profile_name=c||null,_.profileEnv=null,_.runnerUpEnv=null,this._loadCycleOverlays(s,r,_.curve)))}catch(_){this._showToast(this._tText("msg.toast_save_failed",{error:_.message||_},"Save failed: "+(_.message||_)),"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 r=e.cycleId;this._modal=null,this._render(),this._deleteCyclesWithUndo(s,[r]);return}if(t==="cyc-auto-split"){const r=parseInt(a.getElementById("wd-split-gap")?.value||"900",10);await this._busyRun("cyc-auto",async()=>{try{const i=await this._ws({type:`${w}/analyze_split`,entry_id:s,cycle_id:e.cycleId,gap_seconds:r});e.split.offsets=(i.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(i){this._showToast(this._tText("toast.auto_detect_failed",{error:i.message||i},"Auto-detect failed: "+(i.message||i)),"error")}});return}if(t==="cyc-apply-trim"){const r=e.cycleId,i=e.trim.start,o=e.trim.end,n=e.curve&&e.curve.full_duration_s||0,l=n>0?Math.max(0,Math.round((o-i)/n*100)):100;if(l<50&&!confirm(this._tText("msg.trim_destructive_confirm",{pct:l},`This keeps only ${l}% of the cycle and cannot be undone. Continue?`)))return;this._kickAndTrack({type:`${w}/trim_cycle`,entry_id:s,cycle_id:r,start_s:i,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 r=e.cycleId,i=e.split.offsets.slice(),o=e.split.profiles.slice();this._kickAndTrack({type:`${w}/apply_split`,entry_id:s,cycle_id:r,split_offsets:i,segment_profiles:o},"cyc-split-apply",async n=>{this._showToast(this._tText("toast.split_complete",{count:(n.new_ids||[]).length},`Split into ${(n.new_ids||[]).length} cycles`)),await this._closeCycleDetail(s),await this._fetchCycles(s),await this._fetchProfiles(s)});return}}}async _onMActProfilePanel(t,e,s,a,r){if(s&&s.type==="profile-panel"){if(t==="pp-phase-add"){const i=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,n=Math.min(o,i);s.phases.push({name:s.catalog[0]||"",start:n,end:Math.min(n+Math.max(60,i*.1),i)}),this._render();return}if(t==="pp-phase-rm"){const i=+(e&&e.dataset.idx||-1);i>=0&&(s.phases.splice(i,1),this._render());return}if(t==="pp-phase-save"){const i=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:`${w}/set_profile_phases`,entry_id:a,profile_name:s.name,phases:i}),this._showToast(this._t("toast.phases_saved",{},"Phases saved"))}catch(o){this._showToast(this._tText("msg.toast_save_failed",{error:o.message||o},"Save failed: "+(o.message||o)),"error")}});return}if(t==="pp-cleanup-del"){const i=s.cleanup?Array.from(s.cleanup.selected):[];if(!i.length)return;await this._busyRun("pp-cleanup-del",async()=>{try{for(const n of i)await this._ws({type:`${w}/delete_cycle`,entry_id:a,cycle_id:n});this._showToast(this._tText("toast.cycles_deleted",{count:i.length},`Deleted ${i.length} cycle(s)`));const o=await this._ws({type:`${w}/get_profile_cycles`,entry_id:a,profile_name:s.name});this._modal&&(this._modal.cleanup={cycles:o.cycles||[],selected:new Set}),await this._fetchProfiles(a)}catch(o){this._showToast(this._tText("msg.toast_delete_failed",{error:o.message||o},"Delete failed: "+(o.message||o)),"error")}});return}if(t==="pp-cleanup-unlabel"){const i=s.cleanup?Array.from(s.cleanup.selected):[];if(!i.length)return;await this._busyRun("pp-cleanup-unlabel",async()=>{try{for(const n of i)await this._ws({type:`${w}/label_cycle`,entry_id:a,cycle_id:n,profile_name:null});this._showToast(this._tText("toast.unlabel_done",{count:i.length},`Unlabelled ${i.length} cycle(s)`));const o=await this._ws({type:`${w}/get_profile_cycles`,entry_id:a,profile_name:s.name});this._modal===s&&(s.cleanup={cycles:o.cycles||[],selected:new Set}),await this._fetchProfiles(a),await this._fetchCycles(a),await this._fetchFeedbacks(a)}catch(o){this._showToast(this._tText("toast.unlabel_failed",{error:o.message||o},"Unlabel failed: "+(o.message||o)),"error")}});return}if(t==="pp-rename"){const i=r.getElementById("wd-pp-rename")?.value?.trim(),o=r.getElementById("wd-pp-dur"),n=parseFloat(o?.value||"0"),l=!!o&&!o.disabled&&n>0&&n!==parseFloat(o.dataset.orig||"0");if(!i){this._showToast(this._t("msg.toast_name_required",{},"Name required"),"error");return}try{await this._ws({type:`${w}/rename_profile`,entry_id:a,profile_name:s.name,new_name:i,manual_duration_min:l?n:null}),this._showToast(this._t("toast.profile_renamed",{},"Profile renamed")),s.name=i,await Promise.all([this._fetchProfiles(a),this._fetchProfileGroups(a)]),s.stats=(this._profiles||[]).find(c=>c.name===i)||s.stats,this._render()}catch(c){this._showToast(this._tText("toast.rename_failed",{error:c.message||c},"Rename failed: "+(c.message||c)),"error")}return}if(t==="pp-rebuild"){this._kickAndTrack({type:`${w}/rebuild_envelopes`,entry_id:a},"pp-rebuild",async()=>{try{const i=await this._ws({type:`${w}/get_profile_envelope`,entry_id:a,profile_name:s.name});this._modal&&(this._modal.env=i.envelope)}catch{}this._showToast(this._t("toast.envelope_rebuilt",{},"Envelope rebuilt"))});return}if(t==="pp-delete"){this._deleteProfileWithUndo(a,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;if(!this._dirtyOptKeys.has(s))return;const a=wt[s],r=a&&a.type||e.dataset.ftype||"text";if(e.type==="checkbox"){this._pendingSettings[s]=e.checked;return}if(r==="checkboxlist"){this._pendingSettings[s]=this._collectCheckboxlist(e,s);return}if(r==="entitylist"){this._pendingSettings[s]=Array.from(e.querySelectorAll(".wd-pill")).map(i=>i.dataset.val).filter(Boolean);return}if(r==="timerlist"){this._pendingSettings[s]=Array.from(e.querySelectorAll(".wd-timer-row")).map(i=>({offset_minutes:parseFloat(i.querySelector('[data-field="offset_minutes"]').value)||0,message:(i.querySelector('[data-field="message"]').value||"").trim(),auto_pause:i.querySelector('[data-field="auto_pause"]').checked})).filter(i=>i.offset_minutes>0);return}if(r==="number"){const i=String(e.value).trim();if(i===""){a&&a.clearable?this._pendingSettings[s]=null:delete this._pendingSettings[s];return}const o=parseFloat(i);isNaN(o)||(this._pendingSettings[s]=o);return}if(r==="list"){this._pendingSettings[s]=String(e.value).split(",").map(i=>i.trim()).filter(Boolean);return}if(r==="intlist"){this._pendingSettings[s]=me(e.value);return}if(r==="json"){const i=String(e.value).trim();if(!i){this._pendingSettings[s]=[];return}try{this._pendingSettings[s]=JSON.parse(i)}catch{}return}if(r==="entity"||r==="device"){const i=String(e.value).trim();this._pendingSettings[s]=i||null;return}this._pendingSettings[s]=e.value})}_collectCheckboxlist(t,e){const s=Array.from(t.querySelectorAll("[data-choice]")).filter(r=>r.checked).map(r=>r.dataset.choice);if(s.length)return s;const a=wt[e];return a&&Array.isArray(a.def)&&a.def.length?a.def.slice():s}_conflictKeysForOpts(t,e){const s=Object.assign({},e||{},t),a=this._conflictCtx(),r=new Set;for(const i of ee)if(i.check(s,a))for(const o of Object.keys(i.fieldErrors(s,a)))r.add(o);return r}_conflictCtx(){const t=this._hass&&this._hass.states?this._hass.states:{};return{stateOf:e=>e&&t[e]||null}}_conflictCountForOpts(t,e){return this._conflictKeysForOpts(t,e).size}_conflictKeysFromOpts(){return this._conflictKeysForOpts(this._editedOpts(),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 a=s.dataset.opt;if(s.type==="checkbox"){e[a]=s.checked;return}if(s.dataset.ftype==="checkboxlist"){e[a]=this._collectCheckboxlist(s,a);return}if(s.dataset.ftype==="entity"||s.dataset.ftype==="device"){e[a]=String(s.value).trim()||null;return}const r=parseFloat(s.value);isNaN(r)?s.value!==""&&(e[a]=s.value):e[a]=r}),e}_liveValidateSettings(t){if(!t)return{};const e=this._readSettingsFormValues(t),s=t.getElementById("wd-settings-form");if(!s)return{};const a={};for(const n of this._suggestions||[])n.key!=null&&n.suggested!=null&&(a[n.key]=+n.suggested);const r={},i=this._conflictCtx();for(const n of ee){if(!n.check(e,i))continue;const l=n.fieldErrors(e,i);for(const[c,p]of Object.entries(l)){const _=a[c],f=_!=null&&!n.check({...e,[c]:_},i)?{...p,suggFix:_}:p;(r[c]=r[c]||[]).push(f)}}const o={};for(const n of Ae)if(n.check(e,i))for(const[l,c]of Object.entries(n.fieldNotes(e,i)))(o[l]=o[l]||[]).push(c);return s.querySelectorAll("[data-cnote]").forEach(n=>{const l=o[n.dataset.cnote];if(!l||!l.length){n.hidden=!0,n.innerHTML="";return}n.hidden=!1,n.innerHTML=l.map(c=>d(this._tText(c.msgKey,c.msgVars,c.msgFb))).join("<br>")}),s.querySelectorAll("[data-cerr]").forEach(n=>{const l=n.dataset.cerr,c=r[l],p=s.querySelector(`.wd-field[data-field="${l}"]`);!c||!c.length?(n.hidden=!0,n.innerHTML="",p&&p.classList.remove("wd-has-conflict")):(n.hidden=!1,p&&p.classList.add("wd-has-conflict"),n.innerHTML=c.map(_=>{const f=this._tText(_.msgKey,_.msgVars,_.msgFb);let u="";if(_.suggFix!=null){const b=+_.suggFix.toFixed(2);u=`<span class="wd-conflict-sug-note">${this._t("conflict.suggestion_resolves",{val:b},`Stage the pending suggestion (${b}) below to fix this`)}</span>`}else if(_.fixVal!=null&&!isNaN(+_.fixVal)){const b=Number.isInteger(_.fixVal)?_.fixVal:+_.fixVal.toFixed(2);u=`<button type="button" class="wd-conflict-fix" data-ckey="${l}" data-cval="${_.fixVal}">${this._t("conflict.use_fix",{val:b},`Use ${b}`)}</button>`}return`<div class="wd-conflict-row">\u26A0 ${d(f)}${u}</div>`}).join(""))}),r}_cascadeConflictFix(t,e,s){const a=new Set;for(let r=0;r<10;r++){const i=this._liveValidateSettings(t);let o=!1;for(const[n,l]of Object.entries(i)){if(n===s)continue;const c=l.find(_=>_.fixVal!=null&&!isNaN(+_.fixVal)&&+_.fixVal>0);if(!c)continue;const p=e.querySelector(`[data-opt="${n}"]`);p?(p.value=c.fixVal,this._dirtyOptKeys.add(n)):(this._preCascadeOpts==null&&(this._preCascadeOpts=JSON.parse(JSON.stringify(this._opts||{}))),this._opts={...this._opts,[n]:c.fixVal},(this._cascadePending??={})[n]=c.fixVal),a.add(n),o=!0;break}if(!o)break}if(this._liveValidateSettings(t),this._snapshotFormToPending(t),a.size>0){const r=a.size,i=r>1?"s":"";this._showToast(this._tText("conflict.cascade_toast",{n:r},`Other settings adjusted for consistency: ${r}`),"success")}}_changedOptions(t){const e=this._preCascadeOpts||this._opts||{},s={};for(const[a,r]of Object.entries(t))if(JSON.stringify(r)!==JSON.stringify(e[a])&&!(!(a in e)&&(r===""||r===null))){if(!(a in e)&&typeof r=="boolean"){const i=this._optDefaults&&a in this._optDefaults?this._optDefaults[a]:(wt[a]||{}).def;if(i!==void 0&&JSON.stringify(r)===JSON.stringify(i))continue}s[a]=r}return s}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(n=>{const l=n.dataset.opt,c=wt[l],p=c&&c.type||n.dataset.ftype||"text";if(n.type==="checkbox"){s[l]=n.checked;return}if(p==="checkboxlist"){s[l]=this._collectCheckboxlist(n,l);return}if(p==="entitylist"){s[l]=Array.from(n.querySelectorAll(".wd-pill")).map(f=>f.dataset.val).filter(Boolean);return}if(p==="timerlist"){s[l]=Array.from(n.querySelectorAll(".wd-timer-row")).map(f=>({offset_minutes:parseFloat(f.querySelector('[data-field="offset_minutes"]').value)||0,message:(f.querySelector('[data-field="message"]').value||"").trim(),auto_pause:f.querySelector('[data-field="auto_pause"]').checked})).filter(f=>f.offset_minutes>0);return}const _=n.value;if(p==="number"){const f=String(_).trim();if(f===""){c&&c.clearable?s[l]=null:delete s[l];return}const u=parseFloat(f);isNaN(u)||(s[l]=u);return}if(p==="list"){s[l]=String(_).split(",").map(f=>f.trim()).filter(Boolean);return}if(p==="intlist"){s[l]=me(_);return}if(p==="json"){const f=String(_).trim();if(!f){s[l]=[];return}try{s[l]=JSON.parse(f)}catch{this._invalidJson=l}return}if(p==="entity"||p==="device"){const f=String(_).trim();s[l]=f||null;return}s[l]=_});const a=this._optDefaults||{};for(const n of Object.keys(a))!(n in this._opts)&&n in s&&JSON.stringify(s[n])===JSON.stringify(a[n])&&delete s[n];if(this._invalidJson){this._showToast(this._tText("toast.invalid_json",{key:this._invalidJson},`"${this._invalidJson}" is not valid JSON - fix it or clear the field before saving.`),"error");return}const r=this._liveValidateSettings(t),i=new Set(Object.keys(r));if(i.size>0){const n={},l={};for(const[p,_]of Object.entries(s))i.has(p)?l[p]=_:n[p]=_;const c=this._changedOptions(n);this._pendingSettings={...this._pendingSettings,...l},Object.keys(c).length?await this._busyRun("save-settings",async()=>{try{await this._ws({type:`${w}/set_options`,entry_id:e.entry_id,options:c}),this._opts={...this._opts,...c},this._showToast(this._t("toast.saved_except_conflicts",{},"Saved. Fix the highlighted conflicts to save the rest."),"info")}catch(p){this._showToast(this._tText("msg.toast_save_failed",{error:p.message||p},"Save failed: "+(p.message||p)),"error")}}):this._showToast(this._t("toast.settings_conflicts",{},"Fix the highlighted setting conflicts before saving."),"error");return}const o=this._changedOptions(s);if(!Object.keys(o).length){this._pendingSettings={},this._dirtyOptKeys=new Set,this._showToast(this._t("toast.settings_no_changes",{},"No changes to save"),"info");return}await this._busyRun("save-settings",async()=>{try{const n=JSON.parse(JSON.stringify(this._preCascadeOpts||this._opts||{}));if(await this._ws({type:`${w}/set_options`,entry_id:e.entry_id,options:o}),this._opts={...this._opts,...o},this._prevOpts=n,this._cascadePending={},this._preCascadeOpts=null,this._pendingSettings={},this._dirtyOptKeys=new Set,this._stagedSuggestions){try{await this._ws({type:`${w}/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(n){this._showToast(this._tText("msg.toast_save_failed",{error:n.message||n},"Save failed: "+(n.message||n)),"error")}})}}customElements.get("ha-washdata-panel")||customElements.define("ha-washdata-panel",Ze);
|