From f21e2aa8bb817344301b6656ac766e6bd4227ad2 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Mon, 11 May 2026 17:33:26 +0200 Subject: [PATCH] P11.3 + P11.4: BaseNodeAdapter query.units + wikiGen Unit column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P11.3 BaseNodeAdapter auto-wires query.units: Implicit query.units topic registered if subclass commands don't already declare one. Returns {node, units: {topic → {measure, default, accepted: [...]}}} via convert.possibilities. Subclass query.units overrides. 17/17 tests; BaseNodeAdapter.js 211 lines. P11.4 wikiGen Unit column: Auto-generated topic-contract table grows a Unit column showing ` (default )` for topics with units, '—' otherwise. Effect column now uses descriptor.description when present (P11.2 field), falls back to generic per-prefix sentence. wikiGen.js 303 → 315 lines. WIKI_TEMPLATE.md §5 sample updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/wikiGen.js | 20 +++- src/nodered/BaseNodeAdapter.js | 37 ++++++- test/basic/BaseNodeAdapter.basic.test.js | 120 +++++++++++++++++++++++ 3 files changed, 172 insertions(+), 5 deletions(-) diff --git a/scripts/wikiGen.js b/scripts/wikiGen.js index 232bd25..55d7063 100644 --- a/scripts/wikiGen.js +++ b/scripts/wikiGen.js @@ -114,6 +114,17 @@ function spliceAutogen(filePath, marker, body) { // ── Subcommand: contract ─────────────────────────────────────────────────── +function describeUnits(units) { + // Descriptor.units is the validated `{ measure, default }` pair the + // commandRegistry stores; render it as ` (default )` so + // a reader sees both the dimension and the canonical default that the + // node coerces to. Em-dash for unit-less topics keeps the column tidy. + if (!units || typeof units !== 'object') return '—'; + const { measure, default: def } = units; + if (!measure || !def) return '—'; + return '`' + measure + '` (default `' + def + '`)'; +} + function renderContract(commandsPath) { const abs = resolveAbs(commandsPath); // eslint-disable-next-line import/no-dynamic-require, global-require @@ -123,16 +134,17 @@ function renderContract(commandsPath) { } const lines = []; - lines.push('| Canonical topic | Aliases | Payload | Effect |'); - lines.push('|---|---|---|---|'); + lines.push('| Canonical topic | Aliases | Payload | Unit | Effect |'); + lines.push('|---|---|---|---|---|'); for (const d of registry) { const topic = '`' + d.topic + '`'; const aliases = (d.aliases && d.aliases.length) ? d.aliases.map((a) => '`' + a + '`').join(', ') : '_(none)_'; const payload = describeSchema(d.payloadSchema); + const unit = describeUnits(d.units); const effect = d.description ? String(d.description) : topicEffectFallback(d.topic); - lines.push(`| ${topic} | ${aliases} | ${payload} | ${effect} |`); + lines.push(`| ${topic} | ${aliases} | ${payload} | ${unit} | ${effect} |`); } return lines.join('\n'); } @@ -300,4 +312,4 @@ if (require.main === module) { main(); } -module.exports = { renderContract, renderDatamodel, spliceAutogen, describeSchema }; +module.exports = { renderContract, renderDatamodel, spliceAutogen, describeSchema, describeUnits }; diff --git a/src/nodered/BaseNodeAdapter.js b/src/nodered/BaseNodeAdapter.js index 12a2ae4..5110dbf 100644 --- a/src/nodered/BaseNodeAdapter.js +++ b/src/nodered/BaseNodeAdapter.js @@ -18,9 +18,36 @@ const ConfigManager = require('../configs/index.js'); const OutputUtils = require('../helper/outputUtils.js'); const { createRegistry } = require('./commandRegistry.js'); const { StatusUpdater } = require('./statusUpdater.js'); +const convert = require('../convert'); const REGISTRATION_DELAY_MS = 100; +function _buildImplicitUnitsCommand(getCommands, getNodeName) { + return { + topic: 'query.units', + payloadSchema: { type: 'any' }, + description: 'Returns the unit spec (measure, default, accepted) for every topic that declares units.', + handler: (source, msg, ctx) => { + const units = {}; + for (const d of getCommands()) { + if (!d.units) continue; + const accepted = (convert && typeof convert.possibilities === 'function') + ? convert.possibilities(d.units.measure) : []; + units[d.topic] = { + measure: d.units.measure, + default: d.units.default, + accepted, + }; + } + const reply = Object.assign({}, msg, { + topic: 'query.units', + payload: { node: getNodeName(), units }, + }); + if (ctx && typeof ctx.send === 'function') ctx.send([reply, null, null]); + }, + }; +} + class BaseNodeAdapter { constructor(uiConfig, RED, nodeInstance, nameOfNode) { const ctor = this.constructor; @@ -56,7 +83,15 @@ class BaseNodeAdapter { this.node.source = this.source; this._output = new OutputUtils(); - this._commands = createRegistry(ctor.commands, { logger: this.source?.logger }); + const userHasUnitsQuery = ctor.commands.some( + (c) => c && (c.topic === 'query.units' || (Array.isArray(c.aliases) && c.aliases.includes('query.units')))); + const mergedCommands = userHasUnitsQuery + ? ctor.commands + : ctor.commands.concat([_buildImplicitUnitsCommand( + () => this._commands.list(), + () => this.name, + )]); + this._commands = createRegistry(mergedCommands, { logger: this.source?.logger }); this._tickInterval = null; this._outputChangedListener = null; diff --git a/test/basic/BaseNodeAdapter.basic.test.js b/test/basic/BaseNodeAdapter.basic.test.js index 061dea6..fcfc776 100644 --- a/test/basic/BaseNodeAdapter.basic.test.js +++ b/test/basic/BaseNodeAdapter.basic.test.js @@ -310,6 +310,126 @@ test('close handler clears tick interval, stops status, clears badge, calls sour // ---- 13. Hook points fire when defined ------------------------------------ +// ---- 14-16. Auto-wired query.units --------------------------------------- + +test('implicit query.units returns measure+default+accepted for every units-declaring topic', async () => { + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = [ + { + topic: 'set.demand', + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + payloadSchema: { type: 'number' }, + handler: () => {}, + }, + { + topic: 'cmd.calibrate.volume', + units: { measure: 'volume', default: 'm3' }, + payloadSchema: { type: 'number' }, + handler: () => {}, + }, + { + topic: 'set.mode', + payloadSchema: { type: 'string' }, + handler: () => {}, + }, + ]; + static statusInterval = 0; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + const sent = []; + await node.handlers.input( + { topic: 'query.units' }, + (arr) => sent.push(arr), + () => {}, + ); + assert.equal(sent.length, 1); + const [p0, p1, p2] = sent[0]; + assert.equal(p1, null); + assert.equal(p2, null); + assert.equal(p0.topic, 'query.units'); + assert.equal(p0.payload.node, 'measurement'); + const u = p0.payload.units; + assert.ok(u['set.demand'], 'set.demand entry present'); + assert.equal(u['set.demand'].measure, 'volumeFlowRate'); + assert.equal(u['set.demand'].default, 'm3/h'); + assert.ok(Array.isArray(u['set.demand'].accepted), 'accepted is an array'); + assert.ok(u['set.demand'].accepted.length > 0, 'accepted is non-empty'); + assert.ok(u['cmd.calibrate.volume'], 'cmd.calibrate.volume entry present'); + assert.equal(u['cmd.calibrate.volume'].measure, 'volume'); + assert.equal(u['cmd.calibrate.volume'].default, 'm3'); + // Topic without units does not show up. + assert.equal(u['set.mode'], undefined); + node.handlers.close(() => {}); +}); + +test('implicit query.units returns empty units object when no command declares units', async () => { + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = [ + { topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {} }, + ]; + static statusInterval = 0; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + const sent = []; + await node.handlers.input( + { topic: 'query.units' }, + (arr) => sent.push(arr), + () => {}, + ); + assert.equal(sent.length, 1); + const [p0] = sent[0]; + assert.equal(p0.topic, 'query.units'); + assert.deepEqual(p0.payload.units, {}); + assert.equal(p0.payload.node, 'measurement'); + node.handlers.close(() => {}); +}); + +test('explicit query.units descriptor wins over the implicit auto-wired handler', async () => { + let customRan = 0; + class Adapter extends BaseNodeAdapter { + static DomainClass = makeDomain(); + static commands = [ + { + topic: 'set.demand', + units: { measure: 'volumeFlowRate', default: 'm3/h' }, + payloadSchema: { type: 'number' }, + handler: () => {}, + }, + { + topic: 'query.units', + payloadSchema: { type: 'any' }, + handler: (source, msg, ctx) => { + customRan += 1; + if (ctx && typeof ctx.send === 'function') { + ctx.send([{ topic: 'query.units', payload: 'CUSTOM' }, null, null]); + } + }, + }, + ]; + static statusInterval = 0; + buildDomainConfig() { return {}; } + } + const node = makeNode(); + new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'); + const sent = []; + await node.handlers.input( + { topic: 'query.units' }, + (arr) => sent.push(arr), + () => {}, + ); + assert.equal(customRan, 1, 'custom handler must have been called once'); + assert.equal(sent.length, 1); + assert.equal(sent[0][0].payload, 'CUSTOM', + 'reply payload comes from the subclass-declared handler, not the implicit one'); + node.handlers.close(() => {}); +}); + test('extraSetup, extraInputDispatch, extraClose hooks fire when present', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const trace = [];