Compare commits
1 Commits
5ea968eabc
...
f21e2aa8bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f21e2aa8bb |
@@ -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 `<measure> (default <unit>)` 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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user