Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/heating-control-ui.md
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.
1 change: 1 addition & 0 deletions go/driver-repository/cache/ftw-official-beta.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions go/driver-repository/cache/ftw-official.json

Large diffs are not rendered by default.

Empty file added go/state.db.clean
Empty file.
Binary file added go/state.db.snapshot
Binary file not shown.
71 changes: 71 additions & 0 deletions web/heating-control.test.mjs
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="/);
});
177 changes: 176 additions & 1 deletion web/heating.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}',
Expand Down Expand Up @@ -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) +
Expand All @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Base the first adjustment on the actual offset

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_mode has 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 👍 / 👎.


// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry refresh after the in-flight cycle finishes

When a control response arrives while a refresh remains active for more than 400 ms, the scheduled call reaches refresh() while refreshInFlight is still true and is discarded. This is plausible during the five-minute history-cache refresh, which issues nine range queries per pump; if the detail response was captured before the command, the card remains stale and the control can appear unchanged until the next 30-second interval. Queue a refresh that runs when the current cycle settles rather than retrying only once after a fixed delay.

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.
Expand Down Expand Up @@ -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);
}
Expand Down