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, /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[^"]*"[^>]*' + escapeHtml(name) + '' + 'All signals →' + asof + + controlBlock(name, detail) + groups + energyPeriodsBlock(energy) + tempChartBlock(temps) + @@ -376,6 +402,218 @@ }); } + // ---- Control: one declared command per pump ---- + // + // Rendered from what the driver declares (/api/drivers/{name} → controls), + // so a pump that declares nothing shows nothing and this file never learns + // a driver's name. Stepper buttons rather than a slider or a number field: + // the whole card is re-rendered every 30 s, and a control holding input + // state would lose a half-typed value on every refresh. + // + // Only number controls render today, which is what a heat curve offset is. + // A boolean would want a different shape and no driver declares one yet. + function firstNumberControl(detail) { + var controls = (detail && detail.controls) || []; + for (var i = 0; i < controls.length; i++) { + if (controls[i] && controls[i].input && controls[i].input.type === 'number') return controls[i]; + } + return null; + } + + function fmtHoldUntil(ms) { + if (!ms) return ''; + return new Date(ms).toLocaleTimeString('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false, + }); + } + + // Signed, because an offset's sign is its whole meaning: +1 is warmer, + // -1 is cooler, and "1" would be ambiguous. + function fmtOffsetValue(v, unit) { + var sign = v > 0 ? '+' : ''; + return sign + String(v) + (unit ? ' ' + unit : ''); + } + + // A control value is an absolute setting. When no hold is active, use the + // driver's latest offset telemetry as the starting point; zero is not a + // safe default because the driver may have a non-zero autonomous offset. + function observedControlValue(detail, control) { + if (!detail || !control || control.id !== 'set_heat_curve_offset') return null; + var metrics = detail.metrics || []; + var preferred = ['hp_z1_heat_offset', 'hp_heating_offset_climate_system_1']; + for (var i = 0; i < preferred.length; i++) { + for (var j = 0; j < metrics.length; j++) { + if (metrics[j] && metrics[j].name === preferred[i] && Number.isFinite(metrics[j].value)) { + return metrics[j].value; + } + } + } + return null; + } + + function controlKey(name, control) { + return String(name) + '\u0000' + String(control); + } + + function syncControlElements(name, control) { + var grid = document.getElementById('heating-grid'); + if (!grid || !grid.querySelectorAll) return; + var nodes = grid.querySelectorAll('.ftw-hpc-btn, .ftw-hpc-release'); + var key = controlKey(name, control); + var inFlight = !!controlInFlight[key]; + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + var nodeName = node.dataset && (node.dataset.hpcDriver || node.dataset.hpcRelease); + if (nodeName !== name || !node.dataset || node.dataset.hpcControl !== control) continue; + node.disabled = inFlight || node.dataset.hpcEnabled !== 'true'; + } + } + + function beginControl(name, control) { + var key = controlKey(name, control); + if (controlInFlight[key]) return false; + controlInFlight[key] = true; + syncControlElements(name, control); + return true; + } + + function endControl(name, control) { + delete controlInFlight[controlKey(name, control)]; + syncControlElements(name, control); + } + + function controlBlock(name, detail) { + var control = firstNumberControl(detail); + if (!control) return ''; + var input = control.input || {}; + var hold = (detail && detail.hold && detail.hold.control === control.id) ? detail.hold : null; + var held = hold && typeof hold.value === 'number'; + var step = typeof input.step === 'number' && input.step > 0 ? input.step : 1; + var observed = observedControlValue(detail, control); + var value = observed; + var canAdjust = typeof observed === 'number' && Number.isFinite(observed); + var inFlight = !!controlInFlight[controlKey(name, control.id)]; + + // Without a hold the driver is running its own default. Saying "Auto" is + // still the state label, but the current telemetry value anchors the next + // absolute command. Without it, the buttons stay disabled rather than + // guessing a starting point. + var state = held + ? '' + escapeHtml(fmtOffsetValue(hold.value, input.unit)) + '' + + 'until ' + escapeHtml(fmtHoldUntil(hold.expires_at_ms)) + '' + : 'Auto' + + (canAdjust ? 'current ' + escapeHtml(fmtOffsetValue(value, input.unit)) + '' : ''); + + var atMin = canAdjust && typeof input.min === 'number' && value <= input.min; + var atMax = canAdjust && typeof input.max === 'number' && value >= input.max; + var btn = function (delta, label, disabled) { + var target = canAdjust ? clampControl(value + delta, input) : null; + var enabled = canAdjust && !disabled; + return ''; + }; + + var note = !canAdjust + ? '
Current offset unavailable — controls wait for telemetry instead of assuming 0.
' + : ''; + note += control.evidence === 'readback' + ? '' + : '
The pump does not confirm this setting — FTW cannot tell whether it took.
'; + + return '
' + + '
' + + '' + escapeHtml(control.label || control.id) + '' + + '' + state + '' + + btn(-step, 'Lower', atMin) + + btn(step, 'Raise', atMax) + + (held + ? '' + : '') + + '
' + + note + + '' + + '
'; + } + + function clampControl(v, input) { + if (typeof input.min === 'number' && v < input.min) v = input.min; + if (typeof input.max === 'number' && v > input.max) v = input.max; + // Steps are commonly fractional (0.5 °C); rounding here keeps 0.30000000000000004 + // out of both the button label and the request body. + return Math.round(v * 1000) / 1000; + } + + function controlError(el, message) { + var box = el.closest('.ftw-hpc') && el.closest('.ftw-hpc').querySelector('.ftw-hpc-err'); + if (!box) return; + box.textContent = message; + box.hidden = false; + } + + function sendControl(el, name, control, value) { + if (!beginControl(name, control)) return; + var request; + try { + request = apiFetch('/api/drivers/' + encodeURIComponent(name) + '/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ control: control, value: value }), + }); + } catch (err) { + try { controlError(el, String(err.message || err)); } finally { endControl(name, control); } + return; + } + Promise.resolve(request).then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error(body && body.error ? body.error : 'request failed'); + return body; + }); + }).then(function () { + return refreshAfterControl(); + }).then(function () { + endControl(name, control); + }).catch(function (err) { + try { controlError(el, String(err.message || err)); } finally { endControl(name, control); } + }); + } + + function releaseControl(el, name, control) { + if (!beginControl(name, control)) return; + var request; + try { + request = apiFetch('/api/drivers/' + encodeURIComponent(name) + '/control', { method: 'DELETE' }); + } catch (err) { + try { controlError(el, String(err.message || err)); } finally { endControl(name, control); } + return; + } + Promise.resolve(request) + .then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error(body && body.error ? body.error : 'request failed'); + return body; + }); + }) + .then(function () { return refreshAfterControl(); }) + .then(function () { endControl(name, control); }) + .catch(function (err) { + try { controlError(el, String(err.message || err)); } finally { endControl(name, control); } + }); + } + + // refresh() queues one follow-up when a cycle is already running. This is + // needed after a control response: a fixed-delay retry can also land inside + // a long history cycle and get dropped again. + function refreshAfterControl() { + return refresh(); + } + // The live values are cheap and refresh every 30 s. Month/year history is // comparatively expensive SQLite/Parquet range scans, so cache those series // for five minutes per heat pump. @@ -411,8 +649,11 @@ function refresh() { var section = document.getElementById('heating-section'); var grid = document.getElementById('heating-grid'); - if (!section || !grid) return; - if (refreshInFlight) return; + if (!section || !grid) return Promise.resolve(); + if (refreshInFlight) { + refreshQueued = true; + return new Promise(function (resolve) { refreshWaiters.push(resolve); }); + } refreshInFlight = true; // Re-run discovery on first call, then periodically — so a heat-pump @@ -427,36 +668,51 @@ ? discover().then(function (names) { heatPumpDrivers = names; lastDiscoverMs = nowMs; return names; }) : Promise.resolve(heatPumpDrivers); - ready.then(function (names) { - if (!names || names.length === 0) { - section.hidden = true; - return; - } - // Refresh live detail every cycle; reuse bounded-age history. - return Promise.all(names.map(function (n) { - return Promise.all([ - fetchJSON('/api/drivers/' + encodeURIComponent(n)), - fetchPumpHistory(n), - ]).then(function (parts) { - var h = parts[1] || {}; - return { name: n, detail: parts[0], temps: h.temps, energy: h.energy, power: h.power }; + return new Promise(function (resolve) { + ready.then(function (names) { + if (!names || names.length === 0) { + section.hidden = true; + return; + } + // Refresh live detail every cycle; reuse bounded-age history. + return Promise.all(names.map(function (n) { + return Promise.all([ + fetchJSON('/api/drivers/' + encodeURIComponent(n)), + fetchPumpHistory(n), + ]).then(function (parts) { + var h = parts[1] || {}; + return { name: n, detail: parts[0], temps: h.temps, energy: h.energy, power: h.power }; + }); + })).then(function (pumps) { + var live = pumps.filter(function (p) { return p.detail && isHeatPump(p.detail); }); + if (live.length === 0) { section.hidden = true; return; } + injectStyles(); + section.hidden = false; + grid.innerHTML = live.map(function (p) { + return renderPump(p.name, p.detail, p.temps, p.energy, p.power); + }).join(''); }); - })).then(function (pumps) { - var live = pumps.filter(function (p) { return p.detail && isHeatPump(p.detail); }); - if (live.length === 0) { section.hidden = true; return; } - injectStyles(); - section.hidden = false; - grid.innerHTML = live.map(function (p) { - return renderPump(p.name, p.detail, p.temps, p.energy, p.power); - }).join(''); + }).then(function () { + finishRefresh(resolve); + }, function () { + finishRefresh(resolve); }); - }).then(function () { - refreshInFlight = false; - }, function () { - refreshInFlight = false; }); } + function finishRefresh(resolve) { + refreshInFlight = false; + resolve(); + if (refreshQueued) { + refreshQueued = false; + var waiters = refreshWaiters; + refreshWaiters = []; + refresh().then(function () { + waiters.forEach(function (waiter) { waiter(); }); + }); + } + } + // ---- Detail drill-in: all points grouped by unit ---- // Ordered unit groups. First matching predicate wins; anything unmatched @@ -619,6 +875,23 @@ // The ? help icons explain a metric in place (native tooltip) — a click on // one must NOT navigate into the all-signals detail. if (e.target.closest && e.target.closest('.ftw-hp-i')) return; + // Commanding the pump is not navigating to its signals. The card is the + // button, so every control click has to stop here or the detail view + // opens over the thing the operator just pressed. + var step = e.target.closest && e.target.closest('.ftw-hpc-btn'); + if (step) { + e.stopPropagation(); + sendControl(step, step.dataset.hpcDriver, step.dataset.hpcControl, + parseFloat(step.dataset.hpcValue)); + return; + } + var release = e.target.closest && e.target.closest('.ftw-hpc-release'); + if (release) { + e.stopPropagation(); + releaseControl(release, release.dataset.hpcRelease, release.dataset.hpcControl); + return; + } + if (e.target.closest && e.target.closest('.ftw-hpc')) return; var card = e.target.closest && e.target.closest('.ftw-hp-clickable'); if (card && card.dataset.hpDriver) openDetail(card.dataset.hpDriver); }