348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
@@ -129,10 +129,6 @@ const _SETTINGS_SECTIONS = [
doc: 'Expected time between sensor readings - used to size the smoothing window and start debounce correctly. Every sensor update is captured regardless of this value; it only calibrates the downstream calculations. The suggestion engine measures your sensor\'s actual cadence from past cycles and sets this automatically.' },
{ key: 'smoothing_window', label: 'Smoothing Window', type: 'number', min: 1, def: 2,
doc: 'How much the raw power signal is smoothed. Low (2) is responsive but noisy; high (5) smooths spikes but adds lag.' },
{ key: 'abrupt_drop_watts', label: 'Abrupt Drop', unit: 'W', type: 'number', min: 0, def: 500,
doc: 'A power drop larger than this flags the cycle as Interrupted (manual cancel) rather than a natural finish.' },
{ key: 'abrupt_drop_ratio', label: 'Abrupt Drop Ratio', type: 'number', step: 0.05, min: 0, max: 1, def: 0.6,
doc: 'A drop larger than this fraction of current power is also treated as abrupt (0.6 = a 60% drop). Complements the watts threshold across appliance sizes.' },
] },
] },
{ id: 'matching', label: 'Matching', intro: 'How finished cycles are matched to learned profiles and labelled.', notDeviceTypes: ['other'], groups: [
@@ -1375,7 +1371,7 @@ function _field(f, value, extra) {
// Chip/pill multi-picker: existing values as removable pills + a combobox
// add-input. Managed by DOM (no re-render) and collected on save.
const vals = Array.isArray(value) ? value : (value ? [value] : []);
const pills = vals.map(x => `<span class="wd-pill" data-val="${_esc(x)}">${_esc(x)}<button type="button" class="wd-pill-x" aria-label="Remove">×</button></span>`).join('');
const pills = vals.map(x => `<span class="wd-pill" data-val="${_esc(x)}">${_esc(x)}<button type="button" class="wd-pill-x" aria-label="${_esc(extra.t ? extra.t('btn.remove', {}, 'Remove') : 'Remove')}">×</button></span>`).join('');
input = `<div class="wd-pillbox" data-opt="${key}" data-ftype="entitylist">${pills}` +
`<div class="wd-combo wd-combo-pill">` +
`<input type="text" class="wd-pill-add" autocomplete="off" spellcheck="false" placeholder="${_esc(extra.t('placeholder.' + (f.domain || 'add'), {}, f.placeholder || 'add…'))}">` +
@@ -1500,7 +1496,6 @@ const _DIAGRAM_BY_KEY = {
min_power: 'min_power', off_delay: 'off_delay', smoothing_window: 'smoothing',
start_threshold_w: 'hysteresis', stop_threshold_w: 'hysteresis',
start_energy_threshold: 'start_energy', running_dead_zone: 'dead_zone',
abrupt_drop_watts: 'abrupt_drop', abrupt_drop_ratio: 'abrupt_drop',
profile_duration_tolerance: 'duration_tolerance',
profile_match_min_duration_ratio: 'match_ratios', profile_match_max_duration_ratio: 'match_ratios',
progress_reset_delay: 'progress_reset', completion_min_seconds: 'min_duration',
@@ -1578,11 +1573,6 @@ function _diagram(id) {
<line class="ax" x1="60" y1="47" x2="140" y2="47"/>
<line class="ln" x1="100" y1="30" x2="100" y2="64"/>
<text x="62" y="30">-tol</text><text x="120" y="30">+tol</text><text x="84" y="74">profile</text>`);
case 'abrupt_drop':
return wrap(`${base}
<polyline class="ln" points="8,72 30,34 110,34 111,74 190,74"/>
<line class="bad dash" x1="111" y1="34" x2="111" y2="74"/>
<text x="116" y="56">abrupt</text>`);
case 'match_ratios':
return wrap(`${base}
<line class="ax" x1="20" y1="47" x2="180" y2="47"/>
@@ -1701,6 +1691,8 @@ class HaWashdataPanel extends HTMLElement {
this._toastTimer = null;
this._hassUpdateThrottle = null;
this._evtUnsubs = [];
this._hoverRafId = null; // rAF handle for chart-hover coalescing
this._hoverPending = null; // last pending hover coords {px, py, id}
// Data
this._constants = { stateColors: {}, deviceTypes: [], mlLabEnabled: false, mlSuggestionsEnabled: false, mlTrainingAvailable: false, storeOnlineAvailable: false, storeOnlineEnabled: false, storeWebOrigin: '', storePrefs: {}, pgMatchDefaults: {} };
this._constantsLoaded = false;
@@ -1866,7 +1858,22 @@ class HaWashdataPanel extends HTMLElement {
set narrow(n) { this._narrow = n; }
connectedCallback() {
if (this._initialized) this._startPoll();
if (this._initialized) {
this._startPoll();
// Restore WS push subscriptions (cycle events + task registry) that
// disconnectedCallback tore down. Without this, navigate-away/back leaves
// the panel relying only on the 30s fallback poll — live cycle transitions
// and task progress no longer update, and modal Escape/Tab are dead.
this._setupSubscriptions();
// Immediately refresh state so a navigate-away/back shows current data
// instead of waiting up to 30s for the next poll tick.
this._fetchAll();
// Re-attach the modal keyboard handler (removed on disconnect).
if (this.shadowRoot && !this._kbdHandler) {
this._kbdHandler = (e) => this._onKeydown(e);
this.shadowRoot.addEventListener('keydown', this._kbdHandler);
}
}
this._onResize = () => this._resizeLogsPage();
window.addEventListener('resize', this._onResize);
}
@@ -1907,6 +1914,14 @@ class HaWashdataPanel extends HTMLElement {
this._fetchAll();
this._startPoll();
});
this._setupSubscriptions();
}
_setupSubscriptions() {
// Tear down any existing subscriptions first so reconnect is idempotent.
this._evtUnsubs.forEach(u => { try { u(); } catch (_) {} });
this._evtUnsubs = [];
this._tasksSubscribed = false;
// Subscribe to WashData cycle events for immediate push-refresh.
// These fire when a cycle starts/ends so the UI updates instantly
// instead of waiting for the 30s fallback poll.
@@ -2176,6 +2191,11 @@ class HaWashdataPanel extends HTMLElement {
// Header activity cluster: one pill per running task (device · action · % · ✕).
_htmlTaskPills() {
const running = Object.values(this._tasks || {}).filter(t => t.state === 'running');
// Prune stale cancelling-task IDs: if a task is no longer running (evicted from
// the registry, finished while our WS subscription was down, etc.), its
// "Cancelling…" pill would never clear on its own — clean it up here.
const runningIds = new Set(running.map(t => t.id));
this._cancellingTasks.forEach(id => { if (!runningIds.has(id)) this._cancellingTasks.delete(id); });
if (!running.length) return '';
return running.map(t => {
const cancelling = this._cancellingTasks.has(t.id);
@@ -2394,6 +2414,13 @@ class HaWashdataPanel extends HTMLElement {
await this._fetchCycles(dev.entry_id);
await this._fetchSuggestions(dev.entry_id);
await this._fetchProfiles(dev.entry_id);
// Prime the Setup Card on the very first paint so it appears immediately
// without requiring a tab click or device switch.
if (this._tab === 'status') {
try {
this._setupStatus = await this._ws({ type: `${_DOMAIN}/get_setup_status`, entry_id: dev.entry_id });
} catch (_) { this._setupStatus = null; }
}
// The Store tab's visibility depends on this._onlineEnabled(),
// which is normally loaded per-tab. Prime it at boot ONLY when the backend
// exposes online features, so the tab can appear without visiting Settings.
@@ -2813,6 +2840,7 @@ class HaWashdataPanel extends HTMLElement {
this._profiles = []; this._profileHealth = {}; this._profileTrends = {}; this._coverageGaps = {}; this._profileAdvisories = []; this._opts = {}; this._suggestions = [];
this._cycles = []; this._refCycles = []; this._recState = null; this._diag = null; this._maintenance = null; this._phases = [];
this._mlTrainingStatus = null; // per-device; re-fetched by _fetchTabData
this._setupStatus = null; // per-device; re-fetched by _fetchTabData
this._deviceAutomations = []; // per-device; re-fetched on the settings tab
this._selectMode = false; this._cycleSel = new Set();
this._cycleFilter = { text: '', status: '' };
@@ -3445,7 +3473,7 @@ class HaWashdataPanel extends HTMLElement {
<circle cx="12" cy="14" r="5"/>
<circle cx="12" cy="14" r="2"/>
</svg>`;
const burger = `<button class="wd-burger" id="wd-burger" aria-label="Toggle sidebar" title="${_esc(this._t('hdr.toggle_sidebar', {}, 'Toggle Home Assistant sidebar'))}">
const burger = `<button class="wd-burger" id="wd-burger" aria-label="${_esc(this._t('hdr.toggle_sidebar', {}, 'Toggle Home Assistant sidebar'))}" title="${_esc(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>`;
return `
@@ -5190,7 +5218,6 @@ class HaWashdataPanel extends HTMLElement {
['completion_min_seconds', 'Min Cycle Duration', 's', 'Shortest run that counts as a real cycle', 'timing'],
['start_duration_threshold','Start Duration', 's', 'Seconds above threshold to confirm start', 'timing'],
['end_repeat_count', 'End Repeat Count', '', 'Low readings in a row before ending', 'advanced'],
['abrupt_drop_watts', 'Abrupt Drop Threshold', 'W', 'Sudden drop treated as immediate end', 'advanced'],
['interrupted_min_seconds', 'Interrupted Min', 's', 'Short cycles flagged as interrupted', 'advanced'],
['profile_match_min_duration_ratio', 'Min Duration Ratio', '', 'Stage 1: shortest run (vs the profile) still allowed to match', 'matching'],
['profile_match_max_duration_ratio', 'Max Duration Ratio', '', 'Stage 1: longest run (vs the profile) still allowed to match', 'matching'],
@@ -7021,7 +7048,7 @@ class HaWashdataPanel extends HTMLElement {
_htmlLogDrawer() {
return `<div class="wd-log-drawer open" style="width:${this._logDrawerWidth}px">
<div class="wd-log-resize" title="Drag to resize"></div>
<div class="wd-log-resize" title="${_esc(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">
@@ -7048,7 +7075,10 @@ class HaWashdataPanel extends HTMLElement {
const dpr = window.devicePixelRatio || 1;
const cw = Math.max(1, Math.round(rect.width * dpr));
const ch = Math.max(1, Math.round((rect.height || 240) * dpr));
canvas.width = cw; canvas.height = ch;
// Only reallocate the canvas backing store when dimensions actually changed;
// reassigning canvas.width/height unconditionally clears the canvas AND triggers
// a GPU-side realloc on every hover redraw.
if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch; }
const ctx = canvas.getContext('2d');
const cs = getComputedStyle(this);
const primary = (cs.getPropertyValue('--primary-color') || '#03a9f4').trim() || '#03a9f4';
@@ -7255,12 +7285,25 @@ class HaWashdataPanel extends HTMLElement {
}
_onGraphHover(e, id) {
const canvas = this.shadowRoot.getElementById(id);
// rAF-coalesce: many pointermove events fire per frame; only do one redraw+overlay
// per animation frame. Capture coordinates immediately (they may be stale by rAF).
const px = e.clientX, py = e.clientY;
if (this._hoverRafId) { this._hoverPending = { px, py, id }; return; }
this._hoverPending = { px, py, id };
this._hoverRafId = requestAnimationFrame(() => {
this._hoverRafId = null;
const { px: cx, py: cy, id: cid } = this._hoverPending || {};
if (!cid) return;
this._onGraphHoverInner(cx, cy, cid);
});
}
_onGraphHoverInner(px, py, id) {
const canvas = this.shadowRoot && this.shadowRoot.getElementById(id);
const wd = canvas && canvas._wd;
if (!wd) return;
const rect = canvas.getBoundingClientRect();
const x = wd.cssToX(e.clientX - rect.left);
const cursorYdev = (e.clientY - rect.top) * wd.dpr;
const x = wd.cssToX(px - rect.left);
const cursorYdev = (py - rect.top) * wd.dpr;
this._redrawCanvas(id);
const ctx = canvas.getContext('2d');
const xp = wd.Xpx(x);
@@ -7304,7 +7347,7 @@ class HaWashdataPanel extends HTMLElement {
});
if (this._canvasZoom[id]) lines.push(`<span style="opacity:.45">${this._t('lbl.zoom_hint', {}, 'scroll to zoom · dblclick to reset')}</span>`);
ctx.restore();
this._showGraphTip(e.clientX, e.clientY, lines);
this._showGraphTip(px, py, lines);
this._syncSpagRowHighlight(this._hoverNearest ? this._hoverNearest.cid : null);
}
@@ -7321,7 +7364,7 @@ class HaWashdataPanel extends HTMLElement {
tip.style.top = Math.max(6, top) + 'px';
}
_hideGraphTip() { if (this._gtip) this._gtip.style.display = 'none'; this._syncSpagRowHighlight(null); }
_hideGraphTip() { if (this._hoverRafId) { cancelAnimationFrame(this._hoverRafId); this._hoverRafId = null; this._hoverPending = null; } if (this._gtip) this._gtip.style.display = 'none'; this._syncSpagRowHighlight(null); }
_syncSpagRowHighlight(cid) {
if (cid === this._spagHoverCid) return;
@@ -7399,8 +7442,8 @@ class HaWashdataPanel extends HTMLElement {
<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="${_esc(this._t('placeholder.profile_name', {}, 'e.g. Cotton 40°C'))}">
<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">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">Remove this many seconds from the end</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">${_esc(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">${_esc(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 (m.type === 'correct-feedback') {
@@ -7427,7 +7470,7 @@ class HaWashdataPanel extends HTMLElement {
body = `<h2>${this._t('modal.merge_cycles', {n: m.ids.length}, `Merge ${m.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__">+ Create new profile</option>${this._profileOptions()}</select></div>
<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')}</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="${_esc(this._t('placeholder.profile_name', {}, 'e.g. Cotton 40°C'))}"></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>`;
@@ -9048,7 +9091,9 @@ class HaWashdataPanel extends HTMLElement {
// Click the highlighted curve on the graph to toggle that cycle's selection.
const spag = sr.getElementById('wd-spag-canvas');
if (spag) spag.addEventListener('pointerdown', e => {
this._onGraphHover(e, 'wd-spag-canvas');
// Hit-test synchronously: _onGraphHover defers via rAF, so _hoverNearest
// would still be stale/null on this same tick and the click would no-op.
this._onGraphHoverInner(e.clientX, e.clientY, 'wd-spag-canvas');
const hn = this._hoverNearest;
if (hn && hn.cid) {
const sel = m.cleanup.selected;