diff --git a/.changeset/heating-control-ui.md b/.changeset/heating-control-ui.md new file mode 100644 index 00000000..adf53f56 --- /dev/null +++ b/.changeset/heating-control-ui.md @@ -0,0 +1,28 @@ +--- +"ftw": minor +--- + +The Heating view can now set a heat pump's curve offset. A pump whose driver +declares a control gets a row on its card: the value in force, when the hold +ends, and buttons to move it or release it. A pump that declares nothing looks +exactly as it did. + +It lives on the Heating card rather than in Settings → Devices because that is +where the pump's own state already is — the offset sits next to the +temperatures it moves. Settings is for connecting a device, not for running it. + +Rendered entirely from the declaration: label, bounds, step and unit come from +the driver, and nothing in the view knows a driver by name. Stepper buttons +rather than a slider or a number field, because the card is re-rendered every +30 s and a control holding input state would lose a half-typed value on each +refresh. + +With no hold the row reads "Auto" rather than a number. Nothing in the browser +knows what offset the pump has settled on internally, and printing 0 would +claim knowledge we do not have. Held state is carried by the text and the +weight of the value, never by colour: the theme's green/red pair is not +separable under deuteranopia. + +A driver that declares `evidence: "write_ack"` instead of `"readback"` says so +in the row — "the pump does not confirm this setting". The weaker guarantee +belongs where the operator is standing. diff --git a/.gitignore b/.gitignore index 127e881d..21eeba7f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,12 +51,15 @@ dev-data/ # State DB *.db +*.db.clean +*.db.snapshot *.db-journal *.db-wal *.db-shm bin/ artifacts/ .cache/ +/go/driver-repository/cache/ # Changesets / Node — devtool surface only. package.json + the # committed package-lock.json are tracked (reproducible installs in diff --git a/web/heating-control.test.mjs b/web/heating-control.test.mjs new file mode 100644 index 00000000..ce9cac47 --- /dev/null +++ b/web/heating-control.test.mjs @@ -0,0 +1,258 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('./heating.js', import.meta.url), 'utf8'); + +function loadHeatingHarness(overrides = {}) { + const section = { hidden: false }; + const grid = overrides.grid || { innerHTML: '' }; + const context = { + document: { + readyState: 'loading', + addEventListener() {}, + head: { appendChild() {} }, + createElement() { return {}; }, + getElementById(id) { + if (overrides.noRefreshDom && id === 'heating-section') return null; + if (id === 'heating-section') return section; + if (id === 'heating-grid') return grid; + return null; + }, + }, + fetch: overrides.fetch || (() => Promise.resolve({ json: () => Promise.resolve({}) })), + console, + }; + const instrumented = source.replace( + ' if (document.readyState === \'loading\') {', + ' globalThis.__ftwHeatingTest = { controlBlock, refresh, onGridClick };\n\n if (document.readyState === \'loading\') {' + ); + assert.notEqual(instrumented, source, 'heating test hook anchor moved'); + vm.runInNewContext(instrumented, context); + return { api: context.__ftwHeatingTest, section, grid }; +} + +// These guard the invariants that break silently. The behaviour itself was +// verified against a running FTW and a probe driver: pressing + drove the +// driver's own hp_z1_heat_offset metric to 1, raise disabled at the declared +// +3, Release returned the metric to 0 via driver_default_mode, and a click +// on the control did not open the all-signals detail while a click on the +// card still did. + +test('the control is rendered from the driver declaration, never from a driver name', () => { + assert.match(source, /function controlBlock\(name, detail\)/); + assert.match(source, /firstNumberControl\(detail\)/); + // No control branch may key on a driver id — that is the mistake Settings + // made, and the reason a declared control exists at all. Scoped to the + // control code: the file's telemetry half names drivers freely and should. + const start = source.indexOf('// ---- Control: one declared command per pump ----'); + const end = source.indexOf('// ---- Detail drill-in'); + assert.ok(start > 0 && end > start, 'control section markers moved'); + assert.doesNotMatch(source.slice(start, end), /heishamon|myuplink|nibe_local/i); +}); + +test('a pump that declares nothing renders nothing', () => { + assert.match(source, /if \(!control\) return '';/); +}); + +test('bounds and step come from the declaration, not from constants here', () => { + assert.match(source, /input\.step === 'number' && input\.step > 0 \? input\.step : 1/); + assert.match(source, /typeof input\.min === 'number' && value <= input\.min/); + assert.match(source, /typeof input\.max === 'number' && value >= input\.max/); +}); + +test('commanding the pump does not navigate into its signals', () => { + // The whole card is a button; without stopPropagation the detail view opens + // over the control the operator just pressed. + assert.match(source, /closest\('\.ftw-hpc-btn'\)[\s\S]{0,120}e\.stopPropagation\(\)/); + assert.match(source, /closest\('\.ftw-hpc-release'\)[\s\S]{0,120}e\.stopPropagation\(\)/); +}); + +test('no hold reads as Auto rather than a number we do not know', () => { + assert.match(source, /ftw-hpc-auto">Auto); + assert.match(source, /observedControlValue\(detail, control\)/); + assert.match(source, /var value = observed;/); + assert.match(source, /controls wait for telemetry instead of assuming 0/); +}); + +test('an absolute step starts from reported offset telemetry', () => { + assert.match(source, /hp_z1_heat_offset/); + assert.match(source, /hp_heating_offset_climate_system_1/); + assert.match(source, /clampControl\(value \+ delta, input\)/); + assert.match(source, /!enabled \|\| inFlight/); +}); + +test('the rendered absolute step uses the live offset and disables without it', () => { + const { api } = loadHeatingHarness(); + const detail = { + controls: [{ id: 'set_heat_curve_offset', label: 'Curve offset', evidence: 'readback', input: { type: 'number', min: -3, max: 3, step: 1, unit: '°C' } }], + metrics: [{ name: 'hp_z1_heat_offset', value: 2 }], + }; + const anchored = api.controlBlock('heat', detail); + assert.match(anchored, /current \+2 °C/); + assert.match(anchored, /data-hpc-value="3"/); + + const unknown = api.controlBlock('heat', { ...detail, metrics: [] }); + assert.match(unknown, /Current offset unavailable/); + assert.match(unknown, /class="ftw-hpc-btn"[^>]*disabled/); + assert.doesNotMatch(unknown, /data-hpc-value=/); +}); + +test('a hold never enables an absolute stepper without reported offset telemetry', () => { + const { api } = loadHeatingHarness(); + const detail = { + controls: [{ id: 'set_heat_curve_offset', label: 'Curve offset', evidence: 'readback', input: { type: 'number', min: -3, max: 3, step: 1, unit: '°C' } }], + hold: { control: 'set_heat_curve_offset', value: 2, expires_at_ms: Date.now() + 60000 }, + metrics: [], + }; + const held = api.controlBlock('heat', detail); + assert.match(held, /\+2 °C/); + assert.match(held, /Current offset unavailable/); + assert.equal((held.match(/class="ftw-hpc-btn"[^>]* disabled/g) || []).length, 2); + assert.doesNotMatch(held, /data-hpc-value=/); +}); + +function actionButton(kind, value) { + const error = { hidden: true, textContent: '' }; + const row = { + querySelector(selector) { return selector === '.ftw-hpc-err' ? error : null; }, + }; + return { + disabled: false, + dataset: { hpcDriver: 'heat', hpcControl: 'set_heat_curve_offset', hpcValue: String(value), hpcEnabled: 'true' }, + error, + closest(selector) { + if (selector === '.ftw-hpc-btn') return kind === 'step' ? this : null; + if (selector === '.ftw-hpc') return row; + return null; + }, + }; +} + +function click(api, button) { + let stopped = 0; + api.onGridClick({ target: button, stopPropagation() { stopped += 1; } }); + assert.equal(stopped, 1); +} + +function response(ok = true) { + return { ok, json: () => Promise.resolve(ok ? {} : { error: 'rejected' }) }; +} + +test('one in-flight gate blocks double-clicks, both directions, and rerendered buttons', async () => { + let resolvePost; + let postCalls = 0; + const pending = new Promise((resolve) => { resolvePost = resolve; }); + const plus = actionButton('step', 3); + const minus = actionButton('step', 1); + const grid = { innerHTML: '', querySelectorAll() { return [plus, minus]; } }; + const { api } = loadHeatingHarness({ + noRefreshDom: true, + grid, + fetch(path) { + if (path.endsWith('/control')) { postCalls += 1; return pending; } + throw new Error('unexpected request ' + path); + }, + }); + click(api, plus); + click(api, plus); + click(api, minus); + assert.equal(postCalls, 1, 'double-click and opposite direction must share one gate'); + assert.equal(plus.disabled, true, 'the clicked button closes'); + assert.equal(minus.disabled, true, 'the opposite button closes too'); + + const detail = { + controls: [{ id: 'set_heat_curve_offset', label: 'Curve offset', evidence: 'readback', input: { type: 'number', min: -3, max: 3, step: 1, unit: '°C' } }], + metrics: [{ name: 'hp_z1_heat_offset', value: 2 }], + }; + const during = api.controlBlock('heat', detail); + assert.equal((during.match(/class="ftw-hpc-btn"[^>]* disabled/g) || []).length, 2, 'a rerender must keep both buttons closed'); + + resolvePost(response()); + await new Promise((resolve) => setTimeout(resolve, 0)); + const after = api.controlBlock('heat', detail); + assert.equal((after.match(/class="ftw-hpc-btn"[^>]* disabled/g) || []).length, 0, 'gate reopens only after refresh settles'); + assert.equal(plus.disabled, false, 'the clicked button reopens after refresh'); + assert.equal(minus.disabled, false, 'the opposite button reopens after refresh'); +}); + +test('a failed command closes the gate only through error handling', async () => { + let rejectPost; + let postCalls = 0; + const pending = new Promise((resolve, reject) => { rejectPost = reject; }); + const { api } = loadHeatingHarness({ + noRefreshDom: true, + fetch(path) { + if (path.endsWith('/control')) { postCalls += 1; return pending; } + throw new Error('unexpected request ' + path); + }, + }); + const button = actionButton('step', 3); + click(api, button); + const detail = { + controls: [{ id: 'set_heat_curve_offset', label: 'Curve offset', evidence: 'readback', input: { type: 'number', min: -3, max: 3, step: 1, unit: '°C' } }], + metrics: [{ name: 'hp_z1_heat_offset', value: 2 }], + }; + assert.equal((api.controlBlock('heat', detail).match(/class="ftw-hpc-btn"[^>]* disabled/g) || []).length, 2); + rejectPost(new Error('network down')); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(button.error.textContent, 'network down'); + click(api, actionButton('step', 3)); + assert.equal(postCalls, 2, 'a later command may retry after the failure is handled'); +}); + +test('a refresh requested during an active cycle runs after it settles', async () => { + let releaseFirst; + let calls = 0; + const detailCalls = []; + const first = new Promise((resolve) => { releaseFirst = resolve; }); + const { api } = loadHeatingHarness({ + fetch(path) { + calls += 1; + if (calls === 1) return first; + if (path === '/api/drivers/heat') { + detailCalls.push(path); + return Promise.resolve({ json: () => Promise.resolve({ metrics: [{ name: 'hp_power_w', value: 1 }] }) }); + } + return Promise.resolve({ json: () => Promise.resolve({ points: [] }) }); + }, + }); + + api.refresh(); + api.refresh(); + assert.equal(calls, 1, 'second request should queue while the first is active'); + releaseFirst({ json: () => Promise.resolve({ heat: {} }) }); + for (let i = 0; i < 20 && detailCalls.length < 2; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + // The first cycle fetches the detail once for discovery and once for the + // render; the queued cycle adds the third detail fetch. + assert.equal(detailCalls.length, 3, 'queued request should run after the first cycle'); +}); + +test('held state is carried by text and weight, not by colour alone', () => { + // The theme's green/red pair is not separable under deuteranopia, so the + // control must not encode its state in colour. + const styles = source.slice(source.indexOf('.ftw-hpc{'), source.indexOf('.ftw-hpc-err{')); + assert.doesNotMatch(styles, /var\(--green\)|var\(--red\)|var\(--accent\)/); + assert.match(source, /\.ftw-hpc-value\{[^}]*font-weight:600/); +}); + +test('a driver that cannot confirm its writes says so in the UI', () => { + assert.match(source, /control\.evidence === 'readback'/); + assert.match(source, /does not confirm this setting/); +}); + +test('the operator sees the result of a press even mid-refresh', () => { + // A refresh requested during a long cycle must run after that cycle settles. + assert.match(source, /function refreshAfterControl\(\)/); + assert.match(source, /if \(refreshInFlight\) \{[\s\S]{0,120}refreshQueued = true;[\s\S]{0,120}refreshWaiters\.push/); + assert.match(source, /if \(refreshQueued\) \{[\s\S]{0,200}refreshQueued = false;[\s\S]{0,200}refresh\(\)/); +}); + +test('stepper buttons rather than an input that a re-render would clear', () => { + // The card is re-rendered wholesale every 30 s. + assert.doesNotMatch(source, /class="ftw-hpc[^"]*"[^>]* assert.match(source, /metrics\.filter\(function \(m\) \{ return !!infoForKey\(m\.name\); \}\)\.forEach/); }); -test('overlapping dashboard refreshes are suppressed', () => { - assert.match(source, /if \(refreshInFlight\) return;/); +test('overlapping dashboard refreshes queue one follow-up cycle', () => { + assert.match(source, /if \(refreshInFlight\) \{[\s\S]{0,120}refreshQueued = true;[\s\S]{0,120}refreshWaiters\.push/); + assert.match(source, /var refreshQueued = false;/); + assert.match(source, /function finishRefresh\(resolve\)/); assert.match(source, /refreshInFlight = false;/); }); diff --git a/web/heating.js b/web/heating.js index 5d604be6..56b73664 100644 --- a/web/heating.js +++ b/web/heating.js @@ -7,7 +7,11 @@ // fetch per driver); steady-state polling then only touches the heat-pump // drivers, avoiding unnecessary work every 30 seconds. // -// See docs/myuplink-oauth.md. No control here — telemetry only. +// See docs/myuplink-oauth.md. +// +// Control is rendered only for a driver that declares one (/api/drivers/{name} +// → controls). Nothing here knows a driver by name: a pump that declares +// nothing shows telemetry and no buttons, exactly as before. (function () { 'use strict'; @@ -19,7 +23,10 @@ var DISCOVER_EVERY_MS = 300000; // re-scan for newly-added heat pumps (5 min) var HISTORY_REFRESH_MS = 300000; // long TS queries refresh at most every 5 min var historyCache = Object.create(null); + var controlInFlight = Object.create(null); var refreshInFlight = false; + var refreshQueued = false; + var refreshWaiters = []; function apiFetch(path, opts) { return fetch(path, opts); @@ -125,6 +132,24 @@ '.ftw-hp-legend{display:flex;gap:14px;flex-wrap:wrap}', '.ftw-hp-chartsub{font-family:var(--sans);font-size:0.72rem;color:var(--fg-muted);margin:-2px 0 6px}', '.ftw-hp-asof{font-family:var(--mono);font-size:0.62rem;letter-spacing:0.04em;color:var(--fg-muted);margin:-4px 0 10px}', + // Control row. Held vs Auto is carried by the text and the weight of the + // value, never by colour alone — the palette's green/red pair is not + // separable under deuteranopia. + '.ftw-hpc{border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin:0 0 12px}', + '.ftw-hpc-row{display:flex;align-items:center;gap:8px}', + '.ftw-hpc-label{font-family:var(--mono);font-size:0.66rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--fg-muted);flex:0 0 auto}', + '.ftw-hpc-state{flex:1 1 auto;display:flex;align-items:baseline;gap:6px;min-width:0}', + '.ftw-hpc-value{font-family:var(--mono);font-size:0.95rem;font-weight:600;color:var(--fg)}', + '.ftw-hpc-until{font-family:var(--mono);font-size:0.62rem;color:var(--fg-muted);white-space:nowrap}', + '.ftw-hpc-auto{font-family:var(--mono);font-size:0.8rem;color:var(--fg-muted)}', + '.ftw-hpc-observed{font-family:var(--mono);font-size:0.72rem;color:var(--fg-muted);white-space:nowrap}', + '.ftw-hpc-btn{width:30px;height:30px;flex:0 0 auto;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg);font-size:1rem;line-height:1;cursor:pointer;font-family:var(--mono)}', + '.ftw-hpc-btn:hover:not(:disabled){border-color:var(--fg-muted)}', + '.ftw-hpc-btn:disabled{opacity:0.35;cursor:default}', + '.ftw-hpc-release{flex:0 0 auto;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--fg-muted);font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;padding:6px 8px;cursor:pointer}', + '.ftw-hpc-release:hover:not(:disabled){color:var(--fg);border-color:var(--fg-muted)}', + '.ftw-hpc-note{font-family:var(--sans);font-size:0.68rem;color:var(--fg-muted);margin-top:6px}', + '.ftw-hpc-err{font-family:var(--sans);font-size:0.68rem;color:var(--fg);margin-top:6px}', '.ftw-hp-leg{font-family:var(--mono);font-size:0.62rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--fg-muted);display:inline-flex;align-items:center;gap:4px}', '.ftw-hp-leg-dot{width:8px;height:8px;border-radius:2px;display:inline-block}', '.ftw-hp-erow{display:grid;grid-template-columns:1fr auto auto;gap:7px 18px;align-items:baseline}', @@ -363,6 +388,7 @@ '