224 files

This commit is contained in:
Home Assistant Version Control
2026-07-27 13:30:34 +00:00
parent b5723aa856
commit e36b8a1a22
224 changed files with 9967 additions and 2663 deletions
@@ -109,8 +109,6 @@ const _SETTINGS_SECTIONS = [
doc: 'Energy (power x time) the appliance must consume before RUNNING. A brief high-power spike has very low energy and is ignored, preventing false starts.' },
{ key: 'completion_min_seconds', label: 'Min Cycle Duration', unit: 's', type: 'number', min: 0, def: 600, basic: true,
doc: 'Cycles shorter than this are discarded as ghost cycles (test runs, opening the door to add a sock).' },
{ key: 'running_dead_zone', label: 'Running Dead Zone', unit: 's', type: 'number', min: 0, def: 3,
doc: 'After a cycle starts, power dips within this window are ignored. Washing machines fill with cold water (dropping near 0 W before heating) - without this protection that fill phase looks like a cycle end. This does NOT skip data: the full power trace is recorded from T=0. The suggestion engine measures your machine\'s actual startup pattern and sizes this automatically.' },
] },
{ sub: 'Cycle End', fields: [
{ key: 'end_energy_threshold', label: 'End Energy', unit: 'Wh', type: 'number', step: 0.001, min: 0, def: 0.05,
@@ -190,6 +188,8 @@ const _SETTINGS_SECTIONS = [
doc: 'Pulses longer than this are treated as a real cycle rather than an anti-wrinkle tumble.' },
{ key: 'anti_wrinkle_exit_power', label: 'Exit Power Threshold', unit: 'W', type: 'number', step: 0.1, min: 0, def: 0.8,
doc: 'Power must fall below this between pulses for anti-wrinkle mode to stay active.' },
{ key: 'anti_wrinkle_idle_timeout', label: 'Max Pulse Gap', unit: 's', type: 'number', step: 30, min: 0, def: 120,
doc: 'How long the machine may stay quiet between two tumble pulses before anti-wrinkle mode ends. Set it above the longest gap your dryer leaves between pulses, otherwise every later pulse is read as a false start.' },
] },
{ id: 'delay', label: 'Delay Start', intro: 'Delayed-start detection identifies when an appliance is powered but has not yet begun its cycle.', fields: [
{ key: 'delay_start_detect_enabled', label: 'Enable Delay-Start Detection', type: 'checkbox',
@@ -1509,6 +1509,7 @@ const _DIAGRAM_BY_KEY = {
no_update_active_timeout: 'watchdog_timeout',
anti_wrinkle_enabled: 'anti_wrinkle', anti_wrinkle_max_power: 'anti_wrinkle',
anti_wrinkle_max_duration: 'anti_wrinkle', anti_wrinkle_exit_power: 'anti_wrinkle',
anti_wrinkle_idle_timeout: 'anti_wrinkle',
sampling_interval: 'sampling',
};
@@ -1694,7 +1695,7 @@ class HaWashdataPanel extends HTMLElement {
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._constants = { stateColors: {}, deviceTypes: [], mlLabEnabled: false, mlSuggestionsEnabled: false, mlTrainingAvailable: false, storeOnlineAvailable: false, storeOnlineEnabled: false, storeWebOrigin: '', storePrefs: {}, pgMatchDefaults: {}, version: '', iconUrl: '' };
this._constantsLoaded = false;
this._devices = [];
this._cycles = [];
@@ -1810,6 +1811,8 @@ class HaWashdataPanel extends HTMLElement {
this._pgView = null; // {min,max} time-axis zoom window (seconds); null = full
this._pgHoverT = null; // hovered time (seconds) for cursor readout; null = none
this._pgMap = null; // current time<->x mapping, set by _pgDrawCanvas
this._pgStressTail = false; // idle termination test toggle
this._pgStressIdleW = null; // null = auto-derive; number = manual override (W)
this._pgPanStart = null; // pan drag anchor {clientX, vMin, vMax, totalDur}
this._pgHoverEvent = null; // {t,type} of the event pin under the cursor (tooltip)
this._pgBatchProgress = null; // {done,total} for history/sweep chunked runs (determinate bar)
@@ -2354,7 +2357,7 @@ class HaWashdataPanel extends HTMLElement {
if (!this._constantsLoaded) {
try {
const c = await this._ws({ type: `${_DOMAIN}/get_constants` });
this._constants = { stateColors: c.state_colors || {}, deviceTypes: c.device_types || [], mlLabEnabled: !!(c.ml_lab_enabled), mlSuggestionsEnabled: !!(c.ml_suggestions_enabled), mlTrainingAvailable: !!(c.ml_training_available), storeOnlineAvailable: !!(c.store_online_available), storeOnlineEnabled: !!(c.store_online_enabled), storeWebOrigin: c.store_web_origin || '', storePrefs: c.store_prefs || {}, pgMatchDefaults: c.pg_match_defaults || {}, PROFILE_MIN_WARMUP_CYCLES: c.PROFILE_MIN_WARMUP_CYCLES };
this._constants = { stateColors: c.state_colors || {}, deviceTypes: c.device_types || [], mlLabEnabled: !!(c.ml_lab_enabled), mlSuggestionsEnabled: !!(c.ml_suggestions_enabled), mlTrainingAvailable: !!(c.ml_training_available), storeOnlineAvailable: !!(c.store_online_available), storeOnlineEnabled: !!(c.store_online_enabled), storeWebOrigin: c.store_web_origin || '', storePrefs: c.store_prefs || {}, pgMatchDefaults: c.pg_match_defaults || {}, PROFILE_MIN_WARMUP_CYCLES: c.PROFILE_MIN_WARMUP_CYCLES, version: c.version || '', iconUrl: c.icon_url || '' };
} catch (_) { /* fall back to humanized labels */ }
try {
this._panelCfg = await this._ws({ type: `${_DOMAIN}/get_panel_config` });
@@ -2858,6 +2861,7 @@ class HaWashdataPanel extends HTMLElement {
this._busy.delete('pg-history'); this._busy.delete('pg-sweep'); this._pgBatchProgress = null;
// Cancel any pending detail re-run so it can't repopulate the outgoing device.
if (this._pgDetailDebounceTimer) { clearTimeout(this._pgDetailDebounceTimer); this._pgDetailDebounceTimer = null; }
this._pgStressTail = false; this._pgStressIdleW = null;
// Reset the Community Store browse on device change (status re-fetched per-tab).
this._storeView = 'brands'; this._storeDevice = null; this._storeProfile = null; this._storeQuery = '';
this._storeDevices = []; this._storeProfiles = []; this._storeCycles = [];
@@ -3467,12 +3471,16 @@ class HaWashdataPanel extends HTMLElement {
const working = nonTaskBusy
? `<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…')}</span>`
: '';
const logo = `<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">
const iconUrl = this._constants.iconUrl;
const logo = iconUrl
? `<img class="wd-logo" src="${_esc(iconUrl)}" 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>`;
const ver = this._constants.version;
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>`;
@@ -3480,7 +3488,7 @@ class HaWashdataPanel extends HTMLElement {
<div class="wd-header">
${burger}
${logo}
<div><h1>WashData</h1><div class="wd-sub">${this._t('msg.appliance_monitor', {}, 'Appliance monitor')}</div></div>
<div><h1>WashData</h1><div class="wd-sub">${ver ? `v${_esc(ver)} &middot; ` : ''}${this._t('msg.appliance_monitor', {}, 'Appliance monitor')}</div></div>
${working}
<span class="wd-task-pills" id="wd-task-pills">${this._htmlTaskPills()}</span>
<span style="flex:1"></span>
@@ -5219,6 +5227,11 @@ class HaWashdataPanel extends HTMLElement {
['start_duration_threshold','Start Duration', 's', 'Seconds above threshold to confirm start', 'timing'],
['end_repeat_count', 'End Repeat Count', '', 'Low readings in a row before ending', 'advanced'],
['interrupted_min_seconds', 'Interrupted Min', 's', 'Short cycles flagged as interrupted', 'advanced'],
['anti_wrinkle_enabled', 'Enable Anti-Wrinkle Detection', '', 'Absorb the tumble pulses after the main phase instead of reading them as new cycles', 'advanced', 'bool'],
['anti_wrinkle_max_power', 'Max Anti-Wrinkle Power','W', 'A pulse above this ends anti-wrinkle and opens a new cycle', 'advanced'],
['anti_wrinkle_max_duration','Max Anti-Wrinkle Duration','s', 'A pulse longer than this ends anti-wrinkle and opens a new cycle', 'advanced'],
['anti_wrinkle_exit_power', 'Anti-Wrinkle Exit Power','W', 'Power must fall below this between pulses for anti-wrinkle to stay active', 'advanced'],
['anti_wrinkle_idle_timeout','Max Pulse Gap', 's', 'Quiet time allowed between two tumble pulses before anti-wrinkle ends', 'advanced'],
['profile_match_min_duration_ratio', 'Min Duration Ratio', '', 'Stage 1: shortest run (vs the profile) still allowed to match', 'matching'],
['profile_match_max_duration_ratio', 'Max Duration Ratio', '', 'Stage 1: longest run (vs the profile) still allowed to match', 'matching'],
['corr_weight', 'Correlation Weight', '', 'Stage 2: balance between curve shape (correlation) and power level (MAE); default 0.45', 'matching'],
@@ -5358,13 +5371,14 @@ class HaWashdataPanel extends HTMLElement {
matching: this._t('lbl.pg_group_matching', {}, 'Program matching'),
};
let lastGroup = '';
const paramRows = fields.map(([key, fb, unit, desc, group]) => {
const paramRows = fields.map(([key, fb, unit, desc, group, type]) => {
const lbl = this._t('setting.' + key + '.label', {}, fb);
const liveVal = this._pgFieldVal(key, {});
let curVal;
if (key === 'start_threshold_w') curVal = this._pgThreshStart ?? liveVal;
else if (key === 'stop_threshold_w') curVal = this._pgThreshStop ?? liveVal;
else curVal = this._pgParamOverrides[key] ?? liveVal;
const isBool = type === 'bool';
const isDrag = threshFields.has(key);
const unitTxt = unit ? unit : '';
const gc = groupColors[group] || '#2a78d6';
@@ -5383,7 +5397,9 @@ class HaWashdataPanel extends HTMLElement {
${desc ? `<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${_esc(this._t('pg_desc.' + key, {}, desc))}</div>` : ''}
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<input class="wd-pg-param-inp" type="number" data-pgkey="${_esc(key)}" value="${curVal !== '' ? _esc(String(curVal)) : ''}" placeholder="${liveVal !== '' ? _esc(String(liveVal)) : ''}" style="width:72px">
${isBool
? `<input class="wd-pg-param-chk" type="checkbox" data-pgkey="${_esc(key)}" data-pgtype="bool" aria-label="${_esc(lbl)}" ${curVal ? 'checked' : ''} style="width:18px;height:18px;margin:1px 27px 0 27px">`
: `<input class="wd-pg-param-inp" type="text" inputmode="decimal" data-pgkey="${_esc(key)}" value="${curVal !== '' ? _esc(String(curVal)) : ''}" placeholder="${liveVal !== '' ? _esc(String(liveVal)) : ''}" aria-label="${_esc(lbl)}" style="width:72px">`}
${unitTxt ? `<span style="font-size:.75em;color:var(--secondary-text-color);min-width:14px">${_esc(unitTxt)}</span>` : ''}
</div>
</div>`;
@@ -5393,9 +5409,34 @@ class HaWashdataPanel extends HTMLElement {
const applyBtn = (this._canEdit() && hasOverrides)
? `<button class="wd-btn wd-btn-sm wd-btn-primary" data-action="pg-apply-settings" title="${_esc(this._t('btn.pg_apply_to_settings_tip', {}, 'Copy the values you edited here into this device\'s live settings'))}">${this._t('btn.pg_apply_to_settings', {}, 'Save to settings')}</button>`
: '';
const stressGc = '#c0392b';
const stressHeader = `<div style="display:flex;align-items:center;gap:8px;margin:12px 0 5px">
<div style="width:3px;height:14px;border-radius:2px;background:${stressGc};flex-shrink:0"></div>
<span style="font-size:.7em;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--secondary-text-color)">${_esc(this._t('lbl.pg_stress_group', {}, 'Idle termination test'))}</span>
</div>`;
const stressToggle = `<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">${_esc(this._t('lbl.pg_stress_toggle', {}, 'Test idle termination'))}</div>
<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${_esc(this._t('lbl.pg_stress_toggle_desc', {}, 'Simulates the appliance staying at its standby draw after recording ends — shows if and when WashData stops the cycle.'))}</div>
</div>
<label style="display:flex;align-items:center;gap:4px;flex-shrink:0;cursor:pointer">
<input type="checkbox" id="wd-pg-stress-toggle" ${this._pgStressTail ? 'checked' : ''} aria-label="${_esc(this._t('lbl.pg_stress_toggle', {}, 'Test idle termination'))}">
</label>
</div>`;
const stressIdleField = this._pgStressTail ? `<div style="display:flex;align-items:flex-start;gap:6px;margin:0 0 6px 11px">
<div style="flex:1;min-width:0">
<div style="font-size:.82em;font-weight:600;margin-bottom:1px">${_esc(this._t('lbl.pg_stress_idle_w', {}, 'Idle level (W)'))}</div>
<div style="font-size:.72em;color:var(--secondary-text-color);line-height:1.3">${_esc(this._t('lbl.pg_stress_idle_w_desc', {}, 'Override the auto-detected standby floor (leave blank for auto).'))}</div>
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<input class="wd-pg-param-inp" type="text" inputmode="decimal" id="wd-pg-stress-idle-w" value="${this._pgStressIdleW != null ? _esc(String(this._pgStressIdleW)) : ''}" placeholder="${_esc(this._t('lbl.pg_stress_idle_auto', {}, 'Auto'))}" aria-label="${_esc(this._t('lbl.pg_stress_idle_w', {}, 'Idle level (W)'))}" style="width:72px">
<span style="font-size:.75em;color:var(--secondary-text-color);min-width:14px">W</span>
</div>
</div>` : '';
const stressGroup = `${stressHeader}${stressToggle}${stressIdleField}`;
return `<div class="wd-pg-params">
<div class="wd-subhead" style="margin:0 0 6px">${this._t('hdr.pg_detection_params', {}, 'Detection settings')}</div>
${paramRows}
${paramRows}${stressGroup}
<div style="display:flex;gap:6px;margin:8px 0 4px;align-items:center;flex-wrap:wrap">
${applyBtn}
<button class="wd-btn wd-btn-sm" data-action="pg-reset-params">${this._t('btn.reset', {}, 'Reset')}</button>
@@ -5417,7 +5458,7 @@ class HaWashdataPanel extends HTMLElement {
const alertRows = alerts.length
? alerts.map(a => `<div class="wd-pg-alert" style="border-left-color:${sevColor[a.severity] || sevColor.info}">
<span style="font-weight:600">${_esc(this._pgAlertLabel(a.code))}</span>
<div style="font-size:.78em;color:var(--secondary-text-color)">${_esc(a.detail || '')}</div>
<div style="font-size:.78em;color:var(--secondary-text-color)">${_esc(a.detail_key ? this._t(a.detail_key, a.detail_params || {}, a.detail || '') : (a.detail || ''))}</div>
</div>`).join('')
: `<div style="font-size:.82em;color:var(--success-color,#4caf50)">✓ ${this._t('msg.pg_no_alerts', {}, 'No issues detected in this run.')}</div>`;
const term = o.termination_reason ? String(o.termination_reason) : '—';
@@ -5448,6 +5489,9 @@ class HaWashdataPanel extends HTMLElement {
energy_anomaly: this._t('lbl.pg_alert_energy', {}, 'Energy anomaly'),
timeout_end: this._t('lbl.pg_alert_timeout_end', {}, 'Ended by timeout, not prediction'),
would_run_indefinitely: this._t('lbl.pg_alert_indefinite', {}, 'Would run indefinitely'),
stress_terminated: this._t('lbl.pg_alert_stress_ok', {}, 'Idle termination: cycle stopped'),
stress_above_threshold: this._t('lbl.pg_alert_stress_warn', {}, 'Idle draw above stop threshold'),
stress_hit_cap: this._t('lbl.pg_alert_stress_cap', {}, 'Hit safety cap'),
};
return map[code] || code;
}
@@ -5587,7 +5631,8 @@ class HaWashdataPanel extends HTMLElement {
_htmlPgSweepMode() {
const busy = this._busy.has('pg-sweep');
const fields = this._pgOverrideFields();
// Sweeping walks a numeric range, so on/off fields are not offered here.
const fields = this._pgOverrideFields().filter(([, , , , , type]) => type !== 'bool');
const paramOpts = fields.map(([k, fb]) => `<option value="${k}" ${this._pgSweepParam === k ? 'selected' : ''}>${_esc(this._t('setting.' + k + '.label', {}, fb))}</option>`).join('');
const objOpts = this._pgSweepObjectives().map(([k, lbl]) => `<option value="${k}" ${this._pgSweepObjective === k ? 'selected' : ''}>${_esc(lbl)}</option>`).join('');
const intro = `<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>`;
@@ -5689,7 +5734,6 @@ class HaWashdataPanel extends HTMLElement {
}
});
this._render();
this._pgRerunDetail();
}
async _pgApplySweepValue(val) {
@@ -5709,7 +5753,6 @@ class HaWashdataPanel extends HTMLElement {
else this._pgParamOverrides[paramKey] = +val;
this._showToast(this._t('toast.settings_saved', {}, 'Settings saved; integration reloading'));
this._render();
this._pgRerunDetail();
} catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); }
});
}
@@ -5919,7 +5962,7 @@ class HaWashdataPanel extends HTMLElement {
// completion here (the caller drives the busy spinner + redraw), but the
// backend yields the event loop between chunks. A header pill shows progress.
try {
const r = await this._ws({ type: `${_DOMAIN}/start_playground_cycle_detail`, entry_id: entryId, cycle_id: cid, settings_override: override });
const r = await this._ws({ type: `${_DOMAIN}/start_playground_cycle_detail`, entry_id: entryId, cycle_id: cid, settings_override: override, stress_tail: this._pgStressTail, stress_idle_w: this._pgStressIdleW != null ? parseFloat(this._pgStressIdleW) : null });
const tid = r && r.task_id;
if (!tid) throw new Error('no task id');
this._addProvisionalTask(tid, 'pg_detail', entryId, 0);
@@ -5967,6 +6010,7 @@ class HaWashdataPanel extends HTMLElement {
if (st === 'running' || st === 'paused') return 'running';
if (st === 'ending') return 'ending';
if (st === 'starting') return 'detecting';
if (st === 'anti_wrinkle') return 'anti_wrinkle';
return 'idle';
}
@@ -6032,8 +6076,19 @@ class HaWashdataPanel extends HTMLElement {
return;
}
const totalDur = (this._cycles || []).find(c => c.id === this._pgCycleId)?._pg_duration || pts[pts.length-1].t || 1;
const maxW = Math.max(...pts.map(p => p.w), 1);
// Stress tail — extract outcome early so totalDur and maxW can include it.
const stressOut = this._pgDetail && this._pgDetail.outcome && this._pgDetail.outcome.stress;
const stressFrom = stressOut && stressOut.enabled ? stressOut.synthetic_from_s : null;
const stressSeries = (stressFrom != null && this._pgDetail && this._pgDetail.series)
? this._pgDetail.series.filter(pt => pt.t >= stressFrom)
: [];
let totalDur = (this._cycles || []).find(c => c.id === this._pgCycleId)?._pg_duration || pts[pts.length-1].t || 1;
if (stressOut && stressOut.terminated && stressOut.terminated_after_s != null && stressFrom != null) {
totalDur = Math.max(totalDur, stressFrom + stressOut.terminated_after_s);
} else if (stressSeries.length) {
totalDur = Math.max(totalDur, stressSeries[stressSeries.length - 1].t);
}
const maxW = Math.max(...pts.map(p => p.w), ...stressSeries.map(p => p.power || 0), 1);
const threshStart = this._pgThreshStart ?? this._pgFieldVal('start_threshold_w', {}) ?? 50;
const threshStop = this._pgThreshStop ?? this._pgFieldVal('stop_threshold_w', {}) ?? 5;
@@ -6139,6 +6194,37 @@ class HaWashdataPanel extends HTMLElement {
pts.forEach((p, i) => i ? ctx.lineTo(toX(p.t), toY(p.w)) : ctx.moveTo(toX(p.t), toY(p.w)));
ctx.stroke();
// Stress-tail synthetic power trace — dashed continuation of the real curve.
if (stressSeries.length) {
const lastReal = pts[pts.length - 1];
ctx.beginPath();
ctx.moveTo(toX(lastReal.t), toY(lastReal.w));
stressSeries.forEach(p => ctx.lineTo(toX(p.t), toY(p.power)));
ctx.lineTo(toX(stressSeries[stressSeries.length - 1].t), toY(0));
ctx.lineTo(toX(lastReal.t), toY(0));
ctx.closePath();
ctx.fillStyle = _withAlpha(primary, 0.06); ctx.fill();
ctx.beginPath();
ctx.strokeStyle = primary; ctx.lineWidth = 1.5 * dpr;
ctx.setLineDash([4 * dpr, 4 * dpr]);
ctx.moveTo(toX(lastReal.t), toY(lastReal.w));
stressSeries.forEach(p => ctx.lineTo(toX(p.t), toY(p.power)));
ctx.stroke(); ctx.setLineDash([]);
}
// Stress-tail synthetic region: tinted overlay from synthetic_from_s onward
if (stressFrom != null && stressFrom < vMax) {
const xFrom = toX(stressFrom);
ctx.fillStyle = 'rgba(192,57,43,0.07)';
ctx.fillRect(xFrom, padT, Math.max(0, padL + plotW - xFrom), powerH);
ctx.save();
ctx.strokeStyle = 'rgba(192,57,43,0.5)';
ctx.lineWidth = dpr;
ctx.setLineDash([4*dpr, 4*dpr]);
ctx.beginPath(); ctx.moveTo(xFrom, padT); ctx.lineTo(xFrom, padT + powerH); ctx.stroke();
ctx.restore();
}
ctx.restore(); // end plot clip
// Threshold lines
@@ -6158,8 +6244,8 @@ class HaWashdataPanel extends HTMLElement {
drawThrLine(+threshStop, '#e34948', this._t('btn.stop', {}, 'Stop'));
// State band
const stateColors = { idle: bgCol, detecting: '#42a5f566', running: '#66bb6a66', ending: '#ef535066' };
const stateLabels = { 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') };
const stateColors = { idle: bgCol, detecting: '#42a5f566', running: '#66bb6a66', ending: '#ef535066', anti_wrinkle: '#ab47bc66' };
const stateLabels = { 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') };
const stateY = ch - stateBandH - phaseBandH;
ctx.fillStyle = bgCol; ctx.fillRect(padL, stateY, cw - padL - padR, stateBandH);
// Real detector state band from the backend simulation (no client-side copy).
@@ -6377,7 +6463,9 @@ class HaWashdataPanel extends HTMLElement {
_pgUpdateParamInput(key, val) {
const sr = this.shadowRoot;
const inp = sr && sr.querySelector(`[data-pgkey="${key}"]`);
if (inp) inp.value = typeof val === 'number' ? Math.round(val) : val;
if (!inp) return;
if (inp.dataset.pgtype === 'bool') inp.checked = !!val;
else inp.value = typeof val === 'number' ? Math.round(val) : val;
}
// Update the readout strip for a hovered time (seconds), or the final state
@@ -6399,6 +6487,7 @@ class HaWashdataPanel extends HTMLElement {
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'],
};
const [stateText, stateColor] = stripStateMap[stateKey] || stripStateMap.idle;
const pct = sp && sp.progress != null ? Math.round(sp.progress) : null;
@@ -6530,10 +6619,12 @@ class HaWashdataPanel extends HTMLElement {
</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', {}, 'Export all profiles and cycles to JSON, or restore from a previous export.')}</p>
<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-secondary" data-action="export-config">${this._t('btn.export_json', {}, 'Export to JSON')}</button>
<button class="wd-btn wd-btn-secondary" data-action="import-config-open">${this._t('btn.import_json', {}, 'Import from JSON')}</button>
<button class="wd-btn wd-btn-primary" data-action="export-select-open">${this._t('btn.export_selected', {}, 'Export (choose data)')}</button>
<button class="wd-btn wd-btn-primary" data-action="import-config-open">${this._t('btn.import_json', {}, 'Import from JSON')}</button>
<button class="wd-btn wd-btn-secondary" data-action="export-config">${this._t('btn.export_all', {}, 'Quick export everything')}</button>
<button class="wd-btn wd-btn-secondary" data-action="import-config-raw">${this._t('btn.import_raw', {}, 'Advanced: replace all from JSON')}</button>
</div>
</div>` : `<div class="wd-card"><p class="wd-info">${this._t('msg.maintenance_requires_access', {}, 'Maintenance and export/import require full access.')}</p></div>`}`;
}
@@ -7401,6 +7492,8 @@ class HaWashdataPanel extends HTMLElement {
if (m.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(m)}</div></div>`;
if (m.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(m)}</div></div>`;
if (m.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(m)}</div></div>`;
if (m.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(m)}</div></div>`;
if (m.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(m)}</div></div>`;
let body = '';
if (m.type === 'confirm') {
@@ -7611,6 +7704,248 @@ class HaWashdataPanel extends HTMLElement {
</div>`;
}
// ── Selective export/import wizard ──────────────────────────────────────────
// Fixed display order for the category tree (mirrors _EXPORT_CATEGORIES on the
// backend). Enumerable categories (profiles / cycles) come first.
_wizCatOrder() {
return ['profiles', 'real_cycles', 'reference_cycles', 'custom_phases',
'profile_groups', 'settings', 'matching_config', 'ml_models', 'feedback',
'suggestions', 'maintenance_log', 'history_logs', 'lifetime_stats'];
}
_wizCatLabel(catId) {
const map = {
profiles: this._t('lbl.cat_profiles', {}, 'Profiles (programs)'),
real_cycles: this._t('lbl.cat_real_cycles', {}, 'Cycles (run history)'),
reference_cycles: this._t('lbl.cat_reference_cycles', {}, 'Reference cycles (imported)'),
custom_phases: this._t('lbl.cat_custom_phases', {}, 'Custom phases'),
profile_groups: this._t('lbl.cat_profile_groups', {}, 'Profile groups'),
settings: this._t('lbl.cat_settings', {}, 'Detection & matching settings'),
matching_config: this._t('lbl.cat_matching_config', {}, 'Matcher tuning'),
ml_models: this._t('lbl.cat_ml_models', {}, 'ML models'),
feedback: this._t('lbl.cat_feedback', {}, 'Feedback & review labels'),
suggestions: this._t('lbl.cat_suggestions', {}, 'Suggestions'),
maintenance_log: this._t('lbl.cat_maintenance_log', {}, 'Maintenance log'),
history_logs: this._t('lbl.cat_history_logs', {}, 'History & change logs'),
lifetime_stats: this._t('lbl.cat_lifetime_stats', {}, 'Lifetime totals'),
};
return map[catId] || catId;
}
// Build a fresh selection model from a manifest. When importableOnly is set,
// categories/items the backend marked not-importable are left unselected.
_wizInitSel(manifest, importableOnly) {
const cats = new Set(), profiles = new Set(), realIds = new Set(), refIds = new Set();
const c = (manifest && manifest.categories) || {};
const ok = (info) => !!(info && info.present && (!importableOnly || info.importable !== false));
if (ok(c.profiles)) (c.profiles.items || []).forEach(i => profiles.add(i.name));
if (ok(c.real_cycles)) (c.real_cycles.groups || []).forEach(g => g.cycles.forEach(cy => { if (cy.id != null) realIds.add(String(cy.id)); }));
if (ok(c.reference_cycles)) (c.reference_cycles.groups || []).forEach(g => g.cycles.forEach(cy => { if (cy.id != null) refIds.add(String(cy.id)); }));
Object.keys(c).forEach(cid => {
if (['profiles', 'real_cycles', 'reference_cycles'].includes(cid)) return;
if (ok(c[cid])) cats.add(cid);
});
return { cats, profiles, realIds, refIds };
}
// The selection payload sent to the backend (categories + item subsets).
_wizSelectionPayload(m) {
const s = m.sel;
const categories = [];
if (s.profiles.size) categories.push('profiles');
if (s.realIds.size) categories.push('real_cycles');
if (s.refIds.size) categories.push('reference_cycles');
s.cats.forEach(cid => categories.push(cid));
const out = { categories };
if (s.profiles.size) out.profiles = Array.from(s.profiles);
if (s.realIds.size) out.real_cycle_ids = Array.from(s.realIds);
if (s.refIds.size) out.reference_cycle_ids = Array.from(s.refIds);
return out;
}
// Ids of the cycles in one manifest group (real_cycles / reference_cycles).
_wizGroupIds(manifest, catId, prof) {
const cat = (manifest.categories || {})[catId] || {};
const g = (cat.groups || []).find(gr => gr.profile === prof);
return g ? g.cycles.map(cy => String(cy.id)).filter(id => id !== 'null') : [];
}
// tri-state {sel, total, state} for a category, for the checkbox rendering.
_wizCatState(m, catId, manifest) {
const s = m.sel;
const cat = (manifest.categories || {})[catId] || {};
if (catId === 'profiles') {
const items = cat.items || [];
const total = items.length;
const sel = items.filter(i => s.profiles.has(i.name)).length;
return { sel, total, state: sel === 0 ? 'none' : (sel === total ? 'all' : 'some') };
}
if (catId === 'real_cycles' || catId === 'reference_cycles') {
const set = catId === 'real_cycles' ? s.realIds : s.refIds;
const total = cat.count || 0;
let sel = 0;
(cat.groups || []).forEach(g => g.cycles.forEach(cy => { if (set.has(String(cy.id))) sel++; }));
return { sel, total, state: sel === 0 ? 'none' : (sel === total ? 'all' : 'some') };
}
const on = s.cats.has(catId);
return { sel: on ? 1 : 0, total: 1, state: on ? 'all' : 'none' };
}
// Render the shared tri-state category tree from a manifest/inventory.
// opts = { importableOnly, conflicts } — conflicts marks profiles that clash.
_htmlSelectionTree(m, manifest, opts = {}) {
const cats = (manifest && manifest.categories) || {};
const conflictNames = new Set();
if (opts.conflicts) (cats.profiles && cats.profiles.items || []).forEach(i => { if (i.conflict) conflictNames.add(i.name); });
const order = this._wizCatOrder().filter(cid => cats[cid] && cats[cid].present);
// Master select-all state across every currently-selectable category.
let anySel = false, allSel = true;
order.forEach(cid => {
if (opts.importableOnly && cats[cid].importable === false) return;
const st = this._wizCatState(m, cid, manifest);
if (st.state !== 'none') anySel = true;
if (st.state !== 'all') allSel = false;
});
const master = `<label class="wd-sd-prof" style="border-bottom:1px solid var(--divider-color, #444)">
<input type="checkbox" data-maction="wiz-toggle-all" ${allSel ? 'checked' : ''} ${!allSel && anySel ? 'data-indeterminate="1"' : ''}>
<span class="wd-sd-prof-name"><strong>${this._t('btn.select_all', {}, 'Select all')}</strong></span>
</label>`;
const rows = order.map(cid => {
const info = cats[cid];
const blocked = opts.importableOnly && info.importable === false;
const st = this._wizCatState(m, cid, manifest);
const enumerable = ['profiles', 'real_cycles', 'reference_cycles'].includes(cid);
const countLbl = enumerable ? `${st.sel}/${info.count}` : '';
const header = `<label class="wd-sd-prof">
<input type="checkbox" data-maction="wiz-toggle-cat" data-cat="${cid}" ${st.state === 'all' ? 'checked' : ''} ${st.state === 'some' ? 'data-indeterminate="1"' : ''} ${blocked ? 'disabled' : ''}>
<span class="wd-sd-prof-name">${_esc(this._wizCatLabel(cid))}${blocked ? ` <span class="wd-tag">${this._t('badge.not_importable', {}, 'n/a here')}</span>` : ''}</span>
<span class="wd-sd-count">${countLbl}</span>
</label>`;
let items = '';
if (!blocked && cid === 'profiles') {
items = `<div class="wd-sd-cycles">${(info.items || []).map(i => `<label class="wd-sd-cyc">
<input type="checkbox" data-maction="wiz-toggle-profile" data-name="${_esc(i.name)}" ${m.sel.profiles.has(i.name) ? 'checked' : ''}>
<span class="wd-sd-cyc-meta">${_esc(i.name)} · ${(i.real_cycles || 0) + (i.reference_cycles || 0)} ${this._t('lbl.cycles_short', {}, 'cycles')}${conflictNames.has(i.name) ? ` <span class="wd-tag" style="color:var(--warning-color,#e6a700)">${this._t('badge.exists', {}, 'exists')}</span>` : ''}</span>
</label>`).join('')}</div>`;
} else if (!blocked && (cid === 'real_cycles' || cid === 'reference_cycles')) {
const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds;
items = `<div class="wd-sd-cycles">${(info.groups || []).map(g => {
const ids = g.cycles.map(cy => String(cy.id));
const gsel = ids.filter(id => set.has(id)).length;
const gall = gsel === ids.length && ids.length > 0;
const gsome = gsel > 0 && !gall;
const key = `${cid}:${g.profile}`;
const expanded = m.expanded && m.expanded.has(key);
const rows2 = expanded ? g.cycles.map(cy => `<label class="wd-sd-cyc" style="margin-left:18px">
<input type="checkbox" data-maction="wiz-toggle-cyc" data-cat="${cid}" data-cid="${_esc(String(cy.id))}" ${set.has(String(cy.id)) ? 'checked' : ''}>
<span class="wd-sd-cyc-meta">${_esc(cy.date ? _fmtDate(cy.date) : String(cy.id))}${cy.duration != null ? ' · ' + _fmtDuration(cy.duration) : ''}</span>
</label>`).join('') : '';
return `<div>
<label class="wd-sd-cyc">
<input type="checkbox" data-maction="wiz-toggle-cycgroup" data-cat="${cid}" data-prof="${_esc(g.profile)}" ${gall ? 'checked' : ''} ${gsome ? 'data-indeterminate="1"' : ''}>
<span class="wd-sd-cyc-meta"><strong>${_esc(g.profile || this._t('lbl.unlabelled', {}, 'Unlabelled'))}</strong> (${gsel}/${g.count})</span>
<button type="button" class="wd-linkbtn" data-maction="wiz-expand" data-key="${_esc(key)}" style="margin-left:auto;background:none;border:none;color:var(--primary-color);cursor:pointer">${expanded ? '▾' : '▸'}</button>
</label>
${rows2}
</div>`;
}).join('')}</div>`;
}
return `<div class="wd-sd-group">${header}${items}</div>`;
}).join('');
return `<div class="wd-sd-tree">${master}${rows}</div>`;
}
_htmlExportSelectModal(m) {
const busy = this._busy.has('export-select');
let body;
if (m.loading || !m.inventory) {
body = `<div class="wd-empty" style="padding:24px"><div class="wd-icon">⏳</div>${this._t('msg.loading', {}, 'Loading…')}</div>`;
} else {
body = this._htmlSelectionTree(m, { categories: m.inventory }, { importableOnly: false });
}
const anything = m.sel && (m.sel.profiles.size || m.sel.realIds.size || m.sel.refIds.size || m.sel.cats.size);
return `<h2 id="wd-modal-title">${this._t('modal.export_select', {}, 'Export - choose data')}</h2>
<p class="wd-info" style="margin-bottom:12px">${this._t('msg.export_select_intro', {}, 'Tick exactly what to include. Selecting profiles without their cycles still exports a matchable program (its learned shape travels along).')}</p>
${body}
<div class="wd-modal-actions">
<button class="wd-btn wd-btn-secondary" data-maction="cancel" ${busy ? 'disabled' : ''}>${this._t('btn.cancel', {}, 'Cancel')}</button>
<button class="wd-btn wd-btn-primary" data-maction="export-generate" ${busy || !anything ? 'disabled' : ''}>${busy ? '<span class="wd-spin"></span> ' : ''}${this._t('btn.download_export', {}, 'Download export')}</button>
</div>`;
}
_htmlImportWizardModal(m) {
const busy = this._busy.has('import-wizard');
const title = `<h2 id="wd-modal-title">${this._t('modal.import_wizard', {}, 'Import - choose data')}</h2>`;
if (m.step === 'input' || m.step === 'analyze') {
const analyzing = m.step === 'analyze';
return `${title}
<p class="wd-info" style="margin-bottom:12px">${this._t('msg.import_analyze_hint', {}, 'Load an exported file (or paste its JSON). WashData analyzes it and shows exactly what can be imported before anything changes.')}</p>
<div class="wd-field"><label>${this._t('lbl.load_from_file', {}, 'Load from file')}</label><input type="file" id="wd-import-file" accept=".json,application/json" ${analyzing ? '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" ${analyzing ? 'disabled' : ''}>${_esc(m.jsonText || '')}</textarea></div>
${m.error ? `<p class="wd-info" style="color:var(--error-color)">${_esc(m.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" ${analyzing ? 'disabled' : ''}>${analyzing ? '<span class="wd-spin"></span> ' + this._t('msg.analyzing', {}, 'Analyzing…') : this._t('btn.analyze_import', {}, 'Analyze file')}</button>
</div>`;
}
// step === 'select'
const man = m.manifest || {};
const mismatch = man.device_type_match === false;
// Escape: source_device_type comes verbatim from the imported/shared file's
// device_fingerprint and is unvalidated; _t() does raw {var} substitution, so an
// un-escaped value would inject markup into innerHTML (XSS via a crafted import).
const srcDt = _esc(man.source_device_type || '?');
const localDt = _esc(man.local_device_type || '?');
const warnBanner = mismatch ? `<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: srcDt, local: localDt }, 'This export is from a different appliance type (' + srcDt + ' vs ' + localDt + '). Programs and cycles can still be imported as reference data, but device-specific settings and real-history import are disabled.')}
</div>` : '';
const tree = this._htmlSelectionTree(m, man, { importableOnly: true, conflicts: true });
// Merge / replace mode toggle.
const modeBar = `<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 ${m.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 ${m.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">${m.mode === 'replace' ? this._t('msg.replace_warn', {}, 'Each ticked category is wiped and replaced from the file. Unticked categories are left untouched.') : this._t('msg.merge_hint', {}, 'Imported items are added; nothing local is lost. Name clashes are resolved below.')}</div>
</div>`;
// Cycle destination toggle (only relevant when cycles are being imported).
const realAllowed = man.real_history_allowed !== false;
const destBar = `<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 ${m.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 ${m.cycleDest === 'real_history' ? 'wd-btn-primary' : 'wd-btn-secondary'}" data-maction="imp-dest-real" ${realAllowed ? '' : 'disabled'}>${this._t('lbl.dest_real_history', {}, 'Real history (counts in stats)')}</button>
</div>
<div class="wd-field-hint">${m.cycleDest === 'real_history' ? this._t('msg.dest_real_history_hint', {}, 'Imported cycles count as this device\'s own history and feed energy/usage stats. Use for moving one appliance to a new install.') : this._t('msg.dest_reference_hint', {}, 'Imported cycles only improve program matching and never affect usage/energy statistics.')}</div>
</div>`;
// Per-conflict resolution (merge mode, selected conflicting profiles).
let conflictBar = '';
const profItems = (man.categories && man.categories.profiles && man.categories.profiles.items) || [];
const selConflicts = profItems.filter(i => i.conflict && m.sel.profiles.has(i.name));
if (m.mode === 'merge' && selConflicts.length) {
conflictBar = `<div class="wd-field"><label>${this._t('lbl.conflict_resolution', {}, 'Name clashes')}</label>
${selConflicts.map(i => `<div style="display:flex;align-items:center;gap:8px;margin:4px 0">
<span style="flex:1">${_esc(i.name)}</span>
<select data-maction="imp-conflict" data-prof="${_esc(i.name)}">
<option value="import_as_copy" ${(m.conflicts[i.name] || 'import_as_copy') === 'import_as_copy' ? 'selected' : ''}>${this._t('lbl.conflict_import_copy', {}, 'Import as copy')}</option>
<option value="keep_mine" ${m.conflicts[i.name] === 'keep_mine' ? 'selected' : ''}>${this._t('lbl.conflict_keep_mine', {}, 'Keep mine')}</option>
<option value="overwrite" ${m.conflicts[i.name] === 'overwrite' ? 'selected' : ''}>${this._t('lbl.conflict_overwrite', {}, 'Overwrite')}</option>
</select>
</div>`).join('')}
</div>`;
}
const anything = m.sel && (m.sel.profiles.size || m.sel.realIds.size || m.sel.refIds.size || m.sel.cats.size);
return `${title}
${warnBanner}
${tree}
${modeBar}
${destBar}
${conflictBar}
<div class="wd-modal-actions">
<button class="wd-btn wd-btn-secondary" data-maction="import-back" ${busy ? 'disabled' : ''}>${this._t('btn.back', {}, 'Back')}</button>
<button class="wd-btn wd-btn-primary" data-maction="import-apply-ok" ${busy || !anything ? 'disabled' : ''}>${busy ? '<span class="wd-spin"></span> ' : ''}${this._t('btn.import_selected', {}, 'Import selected')}</button>
</div>`;
}
// Interactive cycle inspector: view / trim / split.
_htmlCycleModal(m) {
if (!m.loaded) {
@@ -7659,6 +7994,23 @@ class HaWashdataPanel extends HTMLElement {
<button class="wd-btn wd-btn-sm ${m.mode === 'review' ? 'wd-btn-primary' : 'wd-btn-secondary'}" data-maction="cyc-review" title="${needsReview ? 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')}${reviewDot}</button>
</div>` : (isRef ? `<div class="wd-info" style="margin:0 0 8px"><span style="color:var(--info-color,#2196f3)">📥</span> ${this._t('msg.imported_readonly', {}, 'Imported from the community store. Shown for reference and matching. It is not counted in your stats and cannot be edited.')}</div>` : '');
// Pending-detection-feedback banner (Confirm / Correct… / Ignore). Built once
// and shown in BOTH Inspect and Review modes, so a cycle in the "needs review"
// queue exposes the resolve controls without the user having to discover Review
// mode (#331). Editors only; imported reference cycles never carry feedback.
const pendingFb = this._canEdit() ? (this._feedbacks || []).find(f => f.cycle_id === m.cycleId) : null;
const fbProf = pendingFb ? (pendingFb.detected_profile || pendingFb.profile_name || this._t('lbl.unknown', {}, 'Unknown')) : '';
const fbBanner = pendingFb ? `
<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"> ${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>${_esc(fbProf)}</strong>${pendingFb.confidence != null ? ` (${this._t('lbl.confidence', {}, 'confidence').toLowerCase()} ${(pendingFb.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="${_esc(m.cycleId)}">${this._t('btn.confirm', {}, 'Confirm')}</button>
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-correct" data-cid="${_esc(m.cycleId)}" data-prof="${_esc(fbProf)}">${this._t('btn.correct', {}, 'Correct…')}</button>
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-ignore" data-cid="${_esc(m.cycleId)}">${this._t('btn.ignore', {}, 'Ignore')}</button>
</div>
</div>` : '';
let controls = '';
if (m.mode === 'view') {
// Share to community store: only for recorded/golden reference cycles, and
@@ -7674,7 +8026,7 @@ class HaWashdataPanel extends HTMLElement {
: isRef ? `<button class="wd-btn wd-btn-danger" data-maction="cyc-delete">${this._t('btn.delete', {}, 'Delete')}</button>`
: `<button class="wd-btn wd-btn-danger" data-maction="cyc-delete">${this._t('btn.delete', {}, 'Delete')}</button>
<button class="wd-btn wd-btn-primary" data-maction="cyc-label">${this._t('btn.label', {}, 'Label')}</button>`;
controls = `<div class="wd-modal-actions">
controls = `${fbBanner}<div class="wd-modal-actions">
<button class="wd-btn wd-btn-secondary" data-maction="cancel">${this._t('btn.close', {}, 'Close')}</button>
${shareBtn}
${editBtns}</div>`;
@@ -7737,21 +8089,8 @@ class HaWashdataPanel extends HTMLElement {
];
const tagChecks = TAGS.map(([v, l]) => `<label class="wd-rev-tag"><input type="checkbox" class="wd-cyc-rev-tag" value="${v}" ${(rv.tags || []).includes(v) ? 'checked' : ''}> ${l}</label>`).join('');
const reviewedBadge = rv.reviewed_at ? `<span style="font-size:.75em;color:var(--secondary-text-color)">${this._t('lbl.reviewed_on', {date: new Date(rv.reviewed_at).toLocaleDateString()}, `reviewed ${new Date(rv.reviewed_at).toLocaleDateString()}`)}</span>` : '';
// If this cycle has a pending detection feedback (the learning loop is
// unsure of the program it matched), surface Confirm/Correct/Ignore right
// here. This folds the old Feedbacks subtab into the unified review flow.
const pendingFb = (this._feedbacks || []).find(f => f.cycle_id === m.cycleId);
const fbProf = pendingFb ? (pendingFb.detected_profile || pendingFb.profile_name || this._t('lbl.unknown', {}, 'Unknown')) : '';
const fbBanner = pendingFb ? `
<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"> ${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>${_esc(fbProf)}</strong>${pendingFb.confidence != null ? ` (${this._t('lbl.confidence', {}, 'confidence').toLowerCase()} ${(pendingFb.confidence * 100).toFixed(0)}%)` : ''}. ${this._t('msg.feedback_prompt', {}, 'Confirm it was right, correct the program, or ignore.')}</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="${_esc(m.cycleId)}">${this._t('btn.confirm', {}, 'Confirm')}</button>
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-correct" data-cid="${_esc(m.cycleId)}" data-prof="${_esc(fbProf)}">${this._t('btn.correct', {}, 'Correct…')}</button>
<button class="wd-btn wd-btn-secondary wd-btn-sm" data-action="fb-ignore" data-cid="${_esc(m.cycleId)}">${this._t('btn.ignore', {}, 'Ignore')}</button>
</div>
</div>` : '';
// Pending-feedback banner (Confirm/Correct/Ignore) is built in the shared
// scope above and rendered here as well as in Inspect mode (#331).
const tProfile = _tip(this._t('msg.review_profile_tip', {}, 'The program this cycle is labelled as. If the auto-detected program was wrong, correct it here - labelling teaches matching for future cycles.'));
const tQuality = _tip(this._t('msg.review_quality_tip', {}, 'How clean this cycle is. Good = a textbook example of this program; Bad = detected but noisy or atypical; Unusable = mis-detected (merged, truncated or spurious). Drives the health score and which cycles are allowed to train the model.'));
const tRecorded = _tip(this._t('msg.review_recorded_tip', {}, 'Mark this as a hand-picked reference cycle for its program - the same role as a manually recorded cycle. Reference cycles are always kept, seed the matching template, and are never dropped by cleanup. (This is the "golden"/recorded flag; both are the same thing.)'));
@@ -8338,7 +8677,7 @@ class HaWashdataPanel extends HTMLElement {
const wasThr = this._pgDragging === 'start_thr' || this._pgDragging === 'stop_thr';
this._pgDragging = null; this._pgPanStart = null;
pgCanvas.classList.remove('wd-pg-panning');
if (wasThr) this._pgRerunDetail(); // re-run the sim under the new threshold
if (wasThr) this._pgDrawCanvas();
});
pgCanvas.addEventListener('pointerleave', () => {
if (this._pgDragging) return;
@@ -8363,18 +8702,65 @@ class HaWashdataPanel extends HTMLElement {
}
// F3: Param input fields → sync to threshold state + redraw
sr.querySelectorAll('[data-pgkey]').forEach(inp => inp.addEventListener('input', () => {
const key = inp.dataset.pgkey;
const val = parseFloat(inp.value);
if (isNaN(val)) return;
if (key === 'start_threshold_w') this._pgThreshStart = val;
else if (key === 'stop_threshold_w') this._pgThreshStop = val;
else this._pgParamOverrides[key] = val;
this._pgDrawCanvas();
// Re-run the faithful sim under the new setting so the state band, model
// estimates, events and alerts reflect it (debounced).
this._pgRerunDetail();
}));
sr.querySelectorAll('[data-pgkey]').forEach(inp => {
inp.addEventListener('input', () => {
const key = inp.dataset.pgkey;
if (inp.dataset.pgtype === 'bool') {
const val = inp.checked;
if (key === 'start_threshold_w') this._pgThreshStart = val;
else if (key === 'stop_threshold_w') this._pgThreshStop = val;
else this._pgParamOverrides[key] = val;
this._render();
const again = sr.querySelector(`[data-pgkey="${key}"]`);
if (again) again.focus();
requestAnimationFrame(() => this._pgDrawCanvas());
return;
}
const raw = inp.value.trim();
if (!raw) {
if (key === 'start_threshold_w') this._pgThreshStart = null;
else if (key === 'stop_threshold_w') this._pgThreshStop = null;
else delete this._pgParamOverrides[key];
} else {
const val = parseFloat(raw);
if (!isNaN(val)) {
if (key === 'start_threshold_w') this._pgThreshStart = val;
else if (key === 'stop_threshold_w') this._pgThreshStop = val;
else this._pgParamOverrides[key] = val;
}
// Partial value ("1.", ".", "-") — leave state unchanged until next keystroke.
}
// Save the raw text and caret before _render() destroys this input. With
// type="text" (not number), selectionStart is a real index and setSelectionRange
// works, so cursor restoration is reliable unlike the old type=number approach.
const caret = inp.selectionStart;
const rawVal = inp.value;
this._render();
const again = sr.querySelector(`[data-pgkey="${key}"]`);
if (again) {
again.value = rawVal; // restore raw text incl. trailing "." the browser strips
again.focus();
try { again.setSelectionRange(caret, caret); } catch (_) {}
}
requestAnimationFrame(() => this._pgDrawCanvas());
});
});
// F3: Idle termination test toggle
const pgStressToggle = sr.getElementById('wd-pg-stress-toggle');
if (pgStressToggle) pgStressToggle.addEventListener('change', () => {
this._pgStressTail = pgStressToggle.checked;
this._pgStressIdleW = null;
this._render();
});
// F3: Idle level override field (only present when toggle is on)
const pgStressIdleW = sr.getElementById('wd-pg-stress-idle-w');
if (pgStressIdleW) pgStressIdleW.addEventListener('input', () => {
const v = parseFloat(pgStressIdleW.value);
// Number.isFinite rejects Infinity (parseFloat("1e999")) which isNaN misses.
this._pgStressIdleW = (Number.isFinite(v) && v >= 0) ? v : null;
});
// F3: Cycle selector
const pgCycSel = sr.getElementById('wd-pg-cyc-sel');
@@ -8840,6 +9226,10 @@ class HaWashdataPanel extends HTMLElement {
sr.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', e => this._onAction(e.currentTarget)));
sr.querySelectorAll('[data-maction]').forEach(btn => btn.addEventListener('click', e => this._onModalAction(e.currentTarget.dataset.maction, e.currentTarget)));
// A <select data-maction> commits via `change` (not `click`, esp. on keyboard
// selection), so bind change too — otherwise e.g. the import conflict-resolution
// dropdown never records the user's pick and silently falls back to the default.
sr.querySelectorAll('select[data-maction]').forEach(sel => sel.addEventListener('change', e => this._onModalAction(e.currentTarget.dataset.maction, e.currentTarget)));
// indeterminate is a JS property (no HTML attribute); apply it after render for
// the share-device tree's partially-selected profile checkboxes.
sr.querySelectorAll('input[data-indeterminate]').forEach(cb => { cb.indeterminate = true; });
@@ -9679,6 +10069,21 @@ class HaWashdataPanel extends HTMLElement {
document.body.appendChild(a2); a2.click(); document.body.removeChild(a2); URL.revokeObjectURL(url);
this._showToast(this._t('toast.export_downloaded', {}, 'Export downloaded'));
}).catch(e => this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'));
} else if (a === 'export-select-open') {
// Open the export wizard: fetch this device's inventory, default everything on.
this._modal = { type: 'export-select', inventory: null, loading: true, sel: { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() }, expanded: new Set() };
this._render();
(async () => {
let inv = null;
try { const r = await this._ws({ type: `${_DOMAIN}/get_export_inventory`, entry_id: eid }); inv = (r && r.manifest) || null; }
catch (e) { this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'); }
if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'export-select') return;
if (!inv) { this._modal = null; this._render(); return; }
this._modal.inventory = inv;
this._modal.sel = this._wizInitSel({ categories: inv }, false);
this._modal.loading = false;
this._render();
})();
} else if (a === 'cyc-select-toggle') {
this._selectMode = !this._selectMode;
if (!this._selectMode) this._cycleSel.clear();
@@ -9752,6 +10157,7 @@ class HaWashdataPanel extends HTMLElement {
this._pgCancelRun();
} else if (a === 'pg-reset-params') {
this._pgThreshStart = null; this._pgThreshStop = null; this._pgParamOverrides = {};
this._pgStressTail = false; this._pgStressIdleW = null;
this._render(); requestAnimationFrame(() => this._pgDrawCanvas());
} else if (a === 'pg-apply-settings') {
this._pgApplyToSettings();
@@ -9802,7 +10208,7 @@ class HaWashdataPanel extends HTMLElement {
const val = JSON.parse(btn.dataset.val);
if (!key) return;
const eid = dev.entry_id;
this._ws({ type: `${_DOMAIN}/ws_set_options`, entry_id: eid, options: { [key]: val } })
this._ws({ type: `${_DOMAIN}/set_options`, entry_id: eid, options: { [key]: val } })
.then(() => this._ws({ type: `${_DOMAIN}/get_options`, entry_id: eid }))
.then(r => { this._opts = r.options || {}; return this._fetchSettingsChangelog(eid); })
.then(() => {
@@ -9845,6 +10251,13 @@ class HaWashdataPanel extends HTMLElement {
this._showToast(this._t('toast.logs_exported', {}, 'Logs exported'));
}).catch(e => this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'));
} else if (a === 'import-config-open') {
// Open the import wizard at the paste/upload step.
this._modal = { type: 'import-wizard', step: 'input', jsonText: '', manifest: null, error: null,
sel: { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() },
expanded: new Set(), mode: 'merge', cycleDest: 'reference', conflicts: {} };
this._render();
} else if (a === 'import-config-raw') {
// Advanced fallback: the legacy raw-JSON whole-store replace.
this._modal = { type: 'import-config' }; this._render();
} else if (a === 'save-prefs') {
@@ -10105,6 +10518,146 @@ class HaWashdataPanel extends HTMLElement {
}
}
// ---- Selective export / import wizard ----
if (m && (m.type === 'export-select' || m.type === 'import-wizard')) {
// The manifest that backs the tree: inventory for export, analyze result for import.
const man = m.type === 'export-select' ? { categories: m.inventory || {} } : (m.manifest || { categories: {} });
if (action === 'wiz-toggle-all') {
const cats = man.categories || {};
const importableOnly = m.type === 'import-wizard';
// If everything is already selected, clear; otherwise select all selectable.
let allSel = true;
this._wizCatOrder().filter(cid => cats[cid] && cats[cid].present).forEach(cid => {
if (importableOnly && cats[cid].importable === false) return;
if (this._wizCatState(m, cid, man).state !== 'all') allSel = false;
});
m.sel = allSel
? { cats: new Set(), profiles: new Set(), realIds: new Set(), refIds: new Set() }
: this._wizInitSel(man, importableOnly);
this._render();
return;
}
if (action === 'wiz-toggle-cat') {
const cid = btn.dataset.cat;
const st = this._wizCatState(m, cid, man);
const turnOn = st.state !== 'all';
if (cid === 'profiles') {
const items = (man.categories.profiles && man.categories.profiles.items) || [];
m.sel.profiles = new Set(turnOn ? items.map(i => i.name) : []);
} else if (cid === 'real_cycles' || cid === 'reference_cycles') {
const set = new Set();
if (turnOn) ((man.categories[cid] && man.categories[cid].groups) || []).forEach(g => g.cycles.forEach(cy => { if (cy.id != null) set.add(String(cy.id)); }));
if (cid === 'real_cycles') m.sel.realIds = set; else m.sel.refIds = set;
} else if (turnOn) { m.sel.cats.add(cid); } else { m.sel.cats.delete(cid); }
this._render();
return;
}
if (action === 'wiz-toggle-profile') {
const name = btn.dataset.name;
if (m.sel.profiles.has(name)) m.sel.profiles.delete(name); else m.sel.profiles.add(name);
this._render();
return;
}
if (action === 'wiz-toggle-cycgroup') {
const cid = btn.dataset.cat; const prof = btn.dataset.prof;
const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds;
const ids = this._wizGroupIds(man, cid, prof);
const all = ids.length > 0 && ids.every(id => set.has(id));
ids.forEach(id => { if (all) set.delete(id); else set.add(id); });
this._render();
return;
}
if (action === 'wiz-toggle-cyc') {
const cid = btn.dataset.cat; const id = btn.dataset.cid;
const set = cid === 'real_cycles' ? m.sel.realIds : m.sel.refIds;
if (set.has(id)) set.delete(id); else set.add(id);
this._render();
return;
}
if (action === 'wiz-expand') {
const key = btn.dataset.key;
if (!m.expanded) m.expanded = new Set();
if (m.expanded.has(key)) m.expanded.delete(key); else m.expanded.add(key);
this._render();
return;
}
}
// Export wizard: generate + download the filtered JSON.
if (m && m.type === 'export-select' && action === 'export-generate' && eid) {
const selection = this._wizSelectionPayload(m);
await this._busyRun('export-select', async () => {
try {
const r = await this._ws({ type: `${_DOMAIN}/export_config_selective`, entry_id: eid, selection });
const blob = new Blob([r.json_data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a2 = document.createElement('a');
a2.href = url; a2.download = `washdata_export_${eid.slice(0, 8)}.json`;
document.body.appendChild(a2); a2.click(); document.body.removeChild(a2); URL.revokeObjectURL(url);
this._modal = null;
this._showToast(this._t('toast.export_selective_done', {}, 'Export downloaded'));
} catch (e) { this._showToast(this._t('toast.export_failed', {error: e.message || e}, 'Export failed: ' + (e.message || e)), 'error'); }
});
return;
}
// Import wizard: step machine + toggles + apply.
if (m && m.type === 'import-wizard') {
if (action === 'import-back') {
m.step = 'input'; m.error = null; this._render();
return;
}
if (action === 'imp-mode-merge') { m.mode = 'merge'; this._render(); return; }
if (action === 'imp-mode-replace') { m.mode = 'replace'; this._render(); return; }
if (action === 'imp-dest-reference') { m.cycleDest = 'reference'; this._render(); return; }
if (action === 'imp-dest-real') { if ((m.manifest || {}).real_history_allowed !== false) { m.cycleDest = 'real_history'; this._render(); } return; }
if (action === 'imp-conflict') { m.conflicts[btn.dataset.prof] = btn.value; return; }
if (action === 'import-analyze' && eid) {
const ta = sr.getElementById('wd-import-json');
const jsonText = ta ? ta.value : (m.jsonText || '');
m.jsonText = jsonText;
if (!jsonText.trim()) { this._showToast(this._t('toast.json_required', {}, 'JSON data is required'), 'error'); return; }
m.step = 'analyze'; m.error = null; this._render();
try {
const r = await this._ws({ type: `${_DOMAIN}/analyze_import`, entry_id: eid, json_data: jsonText });
if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'import-wizard') return;
const manifest = (r && r.manifest) || {};
if (manifest.error) { m.step = 'input'; m.error = manifest.error; this._render(); return; }
m.manifest = manifest;
m.sel = this._wizInitSel(manifest, true);
// Default every conflicting profile to the safest resolution.
m.conflicts = {};
((manifest.categories && manifest.categories.profiles && manifest.categories.profiles.items) || [])
.forEach(i => { if (i.conflict) m.conflicts[i.name] = 'import_as_copy'; });
m.step = 'select';
this._render();
} catch (e) {
if (!this._isActiveEntry(eid) || !this._modal || this._modal.type !== 'import-wizard') return;
m.step = 'input'; m.error = (e && e.message) || String(e); this._render();
}
return;
}
if (action === 'import-apply-ok' && eid) {
const selection = this._wizSelectionPayload(m);
await this._busyRun('import-wizard', async () => {
try {
const r = await this._ws({ type: `${_DOMAIN}/import_config_selective`, entry_id: eid,
json_data: m.jsonText, selection, mode: m.mode,
conflict_resolutions: m.conflicts, cycle_destination: m.cycleDest, apply_settings: true });
const s = (r && r.summary) || {};
this._modal = null;
this._showToast(this._t('toast.import_selective_done', {
profiles: s.profiles_imported || 0,
cycles: (s.real_cycles_imported || 0) + (s.reference_cycles_imported || 0),
}, `Imported ${s.profiles_imported || 0} profile(s) and ${(s.real_cycles_imported || 0) + (s.reference_cycles_imported || 0)} cycle(s)`));
await this._fetchCycles(eid);
await this._fetchProfiles(eid);
} catch (e) { this._showToast(this._t('toast.import_failed', {error: e.message || e}, 'Import failed: ' + (e.message || e)), 'error'); }
});
return;
}
}
// ---- Cycle inspector ----
if (m && m.type === 'cycle-detail') {
@@ -10128,6 +10681,9 @@ class HaWashdataPanel extends HTMLElement {
}
this._showToast(this._t('toast.review_saved', {}, 'Review saved'));
await this._fetchCycles(eid);
// A label change in review now resolves the pending feedback backend-side
// (#331), so refresh the queue rather than leaving a stale entry.
if (newLabel !== curLabel) await this._fetchFeedbacks(eid);
await this._loadMlIndex(eid);
if (this._modal && this._modal.cycleId === cid) this._modal.ml = (this._mlById || {})[cid] || this._modal.ml;
} catch (e) { this._showToast(this._t('msg.toast_save_failed', {error: e.message || e}, 'Save failed: ' + (e.message || e)), 'error'); }
@@ -10274,7 +10830,9 @@ class HaWashdataPanel extends HTMLElement {
? (sr.getElementById('wd-new-profile-name')?.value?.trim() || null)
: null;
this._modal = null;
try { await this._ws({ type: `${_DOMAIN}/label_cycle`, entry_id: eid, cycle_id: m.cycleId, profile_name: profileName || null, new_profile_name: newName }); this._showToast(this._t('toast.cycle_labelled', {}, 'Cycle labelled')); await this._fetchCycles(eid); await this._fetchProfiles(eid); }
// Re-fetch feedbacks too: labelling a review cycle now resolves its pending
// feedback backend-side (#331), so the "needs review" queue must refresh.
try { await this._ws({ type: `${_DOMAIN}/label_cycle`, entry_id: eid, cycle_id: m.cycleId, profile_name: profileName || null, new_profile_name: newName }); this._showToast(this._t('toast.cycle_labelled', {}, 'Cycle labelled')); await this._fetchCycles(eid); await this._fetchProfiles(eid); await this._fetchFeedbacks(eid); }
catch (e) { this._showToast(this._t('toast.label_failed', {error: e.message || e}, 'Label failed: ' + (e.message || e)), 'error'); }
this._render();
} else if (action === 'create-profile-ok' && eid) {
@@ -10364,7 +10922,8 @@ class HaWashdataPanel extends HTMLElement {
for (const cid of ids) await this._ws({ type: `${_DOMAIN}/label_cycle`, entry_id: eid, cycle_id: cid, profile_name: profileName || null });
this._showToast(this._t('toast.relabel_done', { count: ids.length }, `Relabelled ${ids.length} cycle(s)`));
this._cycleSel.clear(); this._selectMode = false;
await this._fetchCycles(eid); await this._fetchProfiles(eid);
// Bulk relabel resolves any pending feedback on those cycles (#331).
await this._fetchCycles(eid); await this._fetchProfiles(eid); await this._fetchFeedbacks(eid);
} catch (e) { this._showToast(this._t('toast.relabel_failed', { error: e.message || e }, 'Relabel failed: ' + (e.message || e)), 'error'); }
});
}
+47
View File
@@ -12,6 +12,10 @@ export interface AddMaintenanceEventResponse {
event: Record<string, unknown>;
}
export interface AnalyzeImportResponse {
manifest: Record<string, unknown>;
}
export interface AnalyzeSplitResponse {
segments: number[][];
split_offsets: number[];
@@ -142,6 +146,10 @@ export interface GetDtwDebugResponse {
warp_path: number[][];
}
export interface GetExportInventoryResponse {
manifest: Record<string, unknown>;
}
export interface GetFeedbacksResponse {
feedbacks: Record<string, unknown>[];
}
@@ -275,6 +283,11 @@ export interface GetSuggestionsResponse {
suggestions: Record<string, unknown>[];
}
export interface ImportConfigSelectiveResponse {
success: boolean;
summary: Record<string, unknown>;
}
export interface ListTasksResponse {
tasks: TaskSnapshot[];
}
@@ -671,6 +684,30 @@ export interface ImportConfigRequest {
json_data: string;
}
export interface GetExportInventoryRequest {
entry_id: string;
}
export interface AnalyzeImportRequest {
entry_id: string;
json_data: string;
}
export interface ExportConfigSelectiveRequest {
entry_id: string;
selection: Record<string, unknown>;
}
export interface ImportConfigSelectiveRequest {
entry_id: string;
json_data: string;
selection: Record<string, unknown>;
mode?: "merge" | "replace";
conflict_resolutions?: Record<string, unknown>;
cycle_destination?: "reference" | "real_history";
apply_settings?: boolean;
}
export interface GetConstantsRequest {
}
@@ -871,6 +908,8 @@ export interface StartPlaygroundCycleDetailRequest {
entry_id: string;
cycle_id: string;
settings_override?: Record<string, unknown>;
stress_tail?: boolean;
stress_idle_w?: number | null;
}
export interface StoreStatusRequest {
@@ -1020,6 +1059,10 @@ export interface WashDataWsRequests {
"ha_washdata/wipe_history": WipeHistoryRequest;
"ha_washdata/export_config": ExportConfigRequest;
"ha_washdata/import_config": ImportConfigRequest;
"ha_washdata/get_export_inventory": GetExportInventoryRequest;
"ha_washdata/analyze_import": AnalyzeImportRequest;
"ha_washdata/export_config_selective": ExportConfigSelectiveRequest;
"ha_washdata/import_config_selective": ImportConfigSelectiveRequest;
"ha_washdata/get_constants": GetConstantsRequest;
"ha_washdata/get_suggestions": GetSuggestionsRequest;
"ha_washdata/apply_suggestions": ApplySuggestionsRequest;
@@ -1121,6 +1164,10 @@ export interface WashDataWsResponses {
"ha_washdata/wipe_history": SuccessResponse;
"ha_washdata/export_config": ExportConfigResponse;
"ha_washdata/import_config": SuccessResponse;
"ha_washdata/get_export_inventory": GetExportInventoryResponse;
"ha_washdata/analyze_import": AnalyzeImportResponse;
"ha_washdata/export_config_selective": ExportConfigResponse;
"ha_washdata/import_config_selective": ImportConfigSelectiveResponse;
"ha_washdata/get_constants": GetConstantsResponse;
"ha_washdata/get_suggestions": GetSuggestionsResponse;
"ha_washdata/apply_suggestions": ApplySuggestionsResponse;