-
Notifications
You must be signed in to change notification settings - Fork 8
feat(web): set a heat pump's curve offset from the Heating view #749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 520-driver-control-path
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import test from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { readFileSync } from 'node:fs'; | ||
|
|
||
| const source = readFileSync(new URL('./heating.js', import.meta.url), 'utf8'); | ||
|
|
||
| // 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</); | ||
| }); | ||
|
|
||
| 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', () => { | ||
| // refresh() drops overlapping calls, which is right for the 30 s timer and | ||
| // wrong immediately after a button press. | ||
| assert.match(source, /function refreshAfterControl\(\)/); | ||
| assert.match(source, /if \(!refreshInFlight\) \{ refresh\(\); return; \}/); | ||
| }); | ||
|
|
||
| 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[^"]*"[^>]*<input/); | ||
| assert.match(source, /data-hpc-value="/); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -125,6 +129,23 @@ | |
| '.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-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 +384,7 @@ | |
| '<div class="ftw-hp-head"><span class="ftw-hp-name">' + escapeHtml(name) + '</span>' + | ||
| '<span class="ftw-hp-more">All signals →</span></div>' + | ||
| asof + | ||
| controlBlock(name, detail) + | ||
| groups + | ||
| energyPeriodsBlock(energy) + | ||
| tempChartBlock(temps) + | ||
|
|
@@ -376,6 +398,142 @@ | |
| }); | ||
| } | ||
|
|
||
| // ---- 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 : ''); | ||
| } | ||
|
|
||
| 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 value = held ? hold.value : 0; | ||
|
|
||
| // Without a hold the driver is running its own default. Saying "Auto" is | ||
| // the honest answer: nothing here knows what offset the pump has settled | ||
| // on internally, and printing 0 would claim knowledge we do not have. | ||
| var state = held | ||
| ? '<span class="ftw-hpc-value">' + escapeHtml(fmtOffsetValue(hold.value, input.unit)) + '</span>' + | ||
| '<span class="ftw-hpc-until">until ' + escapeHtml(fmtHoldUntil(hold.expires_at_ms)) + '</span>' | ||
| : '<span class="ftw-hpc-auto">Auto</span>'; | ||
|
|
||
| var atMin = held && typeof input.min === 'number' && value <= input.min; | ||
| var atMax = held && typeof input.max === 'number' && value >= input.max; | ||
| var btn = function (delta, label, disabled) { | ||
| return '<button type="button" class="ftw-hpc-btn" data-hpc-driver="' + escapeHtml(name) + '"' + | ||
| ' data-hpc-control="' + escapeHtml(control.id) + '"' + | ||
| ' data-hpc-value="' + escapeHtml(String(clampControl(value + delta, input))) + '"' + | ||
| (disabled ? ' disabled' : '') + | ||
| ' aria-label="' + escapeHtml(label + ' ' + (control.label || control.id)) + '">' + | ||
| escapeHtml(label === 'Lower' ? '−' : '+') + '</button>'; | ||
| }; | ||
|
|
||
| return '<div class="ftw-hpc">' + | ||
| '<div class="ftw-hpc-row">' + | ||
| '<span class="ftw-hpc-label">' + escapeHtml(control.label || control.id) + '</span>' + | ||
| '<span class="ftw-hpc-state">' + state + '</span>' + | ||
| btn(-step, 'Lower', atMin) + | ||
| btn(step, 'Raise', atMax) + | ||
| (held | ||
| ? '<button type="button" class="ftw-hpc-release" data-hpc-release="' + escapeHtml(name) + '">Release</button>' | ||
| : '') + | ||
| '</div>' + | ||
| (control.evidence === 'readback' | ||
| ? '' | ||
| : '<div class="ftw-hpc-note">The pump does not confirm this setting — FTW cannot tell whether it took.</div>') + | ||
| '<div class="ftw-hpc-err" hidden></div>' + | ||
| '</div>'; | ||
| } | ||
|
|
||
| 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) { | ||
| el.disabled = true; | ||
| apiFetch('/api/drivers/' + encodeURIComponent(name) + '/control', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ control: control, value: value }), | ||
| }).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 () { | ||
| refreshAfterControl(); | ||
| }).catch(function (err) { | ||
| el.disabled = false; | ||
| controlError(el, String(err.message || err)); | ||
| }); | ||
| } | ||
|
|
||
| function releaseControl(el, name) { | ||
| el.disabled = true; | ||
| apiFetch('/api/drivers/' + encodeURIComponent(name) + '/control', { method: 'DELETE' }) | ||
| .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 () { refreshAfterControl(); }) | ||
| .catch(function (err) { | ||
| el.disabled = false; | ||
| controlError(el, String(err.message || err)); | ||
| }); | ||
| } | ||
|
|
||
| // refresh() drops the call if one is already running, which for the 30 s | ||
| // timer is right and here is not: the operator just pressed a button and | ||
| // has to see the result. Retry once shortly after, which is long enough for | ||
| // an in-flight cycle to finish. | ||
| function refreshAfterControl() { | ||
| if (!refreshInFlight) { refresh(); return; } | ||
| setTimeout(refresh, 400); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a control response arrives while a refresh remains active for more than 400 ms, the scheduled call reaches Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| // 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. | ||
|
|
@@ -619,6 +777,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); | ||
| 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); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the pump's autonomous/default offset is nonzero, this initializes the stepper at zero even though the following comment acknowledges that the current value is unknown. For example, if
driver_default_modehas settled at +2 °C, pressing “Raise” sends +1 °C and actually makes the target cooler for the next four hours. Read the current offset from telemetry before offering relative controls, or require an explicit absolute selection when leaving Auto.Useful? React with 👍 / 👎.