Skip to content

A Thing type with a memory, an Event node that reports a level, and ranges (2.17.6) - #49

Merged
flic merged 15 commits into
mainfrom
store-and-metadata
Aug 9, 2026
Merged

A Thing type with a memory, an Event node that reports a level, and ranges (2.17.6)#49
flic merged 15 commits into
mainfrom
store-and-metadata

Conversation

@flic

@flic flic commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Five pieces of work that accumulated on one branch. Each is independent; the
common thread is that every one of them started as "this needs a second node to
express", and ends with the node saying it directly.

A Thing type's functions get a memory, and a way into metadata

Ingress, egress, filter and status functions are built with new Function(msg, attribute, item), so they see one message and nothing else. Anything that had
to be remembered between messages went to a function node and context.get/set
— which put the rule somewhere other than the type that owns it. They now take a
fourth argument, store, persisted through the type's own context store exactly
as state and metadata already are.

A type can also declare which topics carry device facts, for sources whose
topics are fixed by their firmware and cannot publish where _meta wants them.

hal2Bayes: a momentary rule restates itself instead of adding to itself

Reported as unintuitive, and it was: a rule worth 34 % that fired, decayed to
30 % and fired again contributed 64 % — a number the rule is not configured to
be able to produce.

Momentary rules pushed a new term on every firing and the terms summed. That is
the correct Bayesian reading of two independent observations, and the wrong
reading of one continuing fact reported repeatedly, which is what a motion
sensor does. A live node held twenty-one terms from seventeen firings in
twenty-three minutes and sat pinned at the clamp.

Firing now replaces the rule's own term and restarts its clock. Independence is
claimed per rule with repeat: 'stack', not assumed. The control sits inside
the fade span, which the editor already hides for continuous rules — so whether
the setting appears at all now tells you which kind of rule you are looking at,
which was the other half of the complaint.

hal2Event: a level output

The node only ever reported the rising edge. Anything that wanted to know
whether a condition currently holds needed a second node with the rule
inverted — a second place to keep in agreement with the first.

trigger true/false answers the level directly: true when the rule starts
holding, false when it stops, nothing in between. A threshold whose reading
wanders without crossing it stays silent.

Rate limit is skipped and hidden for a level — it drops messages inside its
window, and a dropped false leaves the receiver believing true indefinitely.
Delay applies per edge, with Delay on true and Delay on false, both on by
default so a ticked Delay event always does something. A queued edge is
dropped whenever the answer moves away from it again, so the node never
announces a state that has already stopped being true.

always is excluded from all of this: it cannot stop holding, so it keeps the
ordinary firing discipline and carries true, producing exactly the stream the
boolean output type produces — asserted by driving both configurations through
the test harness and comparing them.

in range / outside range

In hal2Event, hal2Gate and hal2Bayes. Inclusive at both ends, indifferent to
which bound was typed first — a range is a pair, not an ordered instruction.

rangeBounds() in lib/rules.js is the one place that reads a pair, because
Number('') is 0 rather than NaN: read naively, an empty upper bound turns
"in range 20 to __" into the band 0–20 and matches things nobody asked for.
outrange is written out rather than as !range, so a non-numeric reading is
outside nothing and a half-filled rule fails closed both ways.

Bugs found on the way

  • for a while was satisfied by the edge, not by the wait. driveFsm
    dispatches on pattern and held fell into the branch that completes a step on
    a rising edge. The duration was honoured only where the step was entered with
    the condition already true — not on the ordinary path. A blip fired the rule
    it exists to reject.
  • A queued level edge could go stale. The listener short-circuited on
    matched === lastResult, so a rule that went true, started its delay and fell
    back looked like nothing had changed. It escaped the tests only because
    lastResult starts undefined.
  • on change (ignore initial value) did half of what it says — it skipped
    the initial value and then fired on every update, because the change filter
    beside it applied only to on change.
  • oneditsave cannot set a property that has a DOM element. Node-RED runs
    the edit panes' apply() afterwards and re-reads every declared default from
    its field. compareSource and compareType only appeared to work because
    neither had an element — the latter because an unterminated style attribute
    had swallowed it.

Tests

392 passing. core/event.js had no coverage at all; test/eventRuntime.test.js
follows the stub-RED harness test/thingRuntime.test.js establishes and drives
the node's message stream directly — 24 cases. Every bug above has a test that
fails against the commit before its fix.

🤖 Generated with Claude Code

flic and others added 15 commits August 8, 2026 02:30
Reported as unintuitive, and it was: a rule worth 34% that fired, decayed to
30%, and fired again ended up contributing 64% — a number the rule is not
configured to be able to produce.

Momentary rules pushed a new term on every firing and the terms summed. That
is the correct Bayesian reading of two independent observations, and the wrong
reading of one continuing fact reported repeatedly, which is what a motion
sensor does. It is not a hypothetical: a live node held twenty-one terms from
seventeen firings in twenty-three minutes and sat pinned at the clamp.

Firing now replaces this rule's own term and restarts its clock. Independence
is claimed per rule with repeat:'stack', not assumed. Continuous rules were
never affected — they are recomputed from scratch every evaluation and hold one
weight by construction.

The control lives inside the fade span, which the editor already hides for
continuous rules. That the setting appears at all is now the clearest statement
of which kind of rule you are looking at, which was the other half of the
complaint.

Both new arithmetic tests fail without the one-line change in fire(); the
clock-restart one pins the value exactly, because a version that merely checked
'bigger than the decayed one' passed while stacking.
Two selects and a conditional number input trailing off the end of "makes it
true, moderate" wrapped wherever the dialog happened to be narrow. They are a
sentence about the push, not about the direction, so they read as one.

The row carries the br-fade-span class the span used to, so the existing toggle
hides the whole row for a continuous rule instead of leaving an empty one.
Both came out of standardising the ESPHome nodes, where the two gaps showed up
as the same complaint: logic that belongs to a Thing type had to live outside
it, in a function node, because the type could not hold state and could not
write a fact it had not been handed on the _meta topic.

The function store. Ingress, egress, filter and status functions are built with
new Function(msg, attribute, item), so they see one message and nothing else.
Anything that had to be remembered between messages went to a function node and
context.get/set, which put the rule somewhere other than the type that owns it.
They now take a fourth argument, `store`, with get/set/keys/clear, persisted
through the type's own context store exactly as state and metadata already are.
The room arbitration for a phone — one message per room, pick the strongest,
drop the ones that have gone quiet — fits in a filter function now, and the
result is read back by each item instead of being recomputed per item.

Metadata mappings. The _meta channel assumes a source that can publish where
hal2 wants it. A device whose topics are fixed by its firmware cannot, and the
alternative was to teach the firmware hal2's naming. A type can now declare
mappings — filters plus an ingress, like an item, but the return value lands in
the metadata bag under the mapping's key. Deliberately a list of its own rather
than an item type: items carry haType, history, groups, outputs and MCP
exposure, and none of that means anything for a fact. A message that only
matched a mapping now redraws the status too, since no item changed and nothing
else would have.

The function contract, written down. The help panel documented `attribute` and
nothing else, the default stubs said "msg & attribute objects are passed to
function" when there were three, and the ACE globals advertised context, flow,
RED and util — none of which exist in a new Function sandbox, which is the
mistake this invites. All three now say what is actually injected, and the
calling convention sits above each editor, where the help panel is not visible
while a function is open. The EventHandler's egress stub overwrote msg.payload
with the whole item snapshot; that is fixed too.

lib/store.js is pure and injected, so the delete-on-null rule and the swap that
clear() performs are unit-tested. thingRuntime.test.js is new: it drives
core/thing.js against a stub RED, which is the first test coverage that file
has had, and it pins the metadata flattening, the store's persistence across a
restart and the status redraw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The node only ever reported the rising edge. Anything that wanted to know
whether a condition currently holds needed a second node with the rule
inverted — the same shape as the flow variable that 'state' comparisons
removed, a second place to keep in agreement with the first.

The new 'trigger true/false' output answers the level directly: true when the
rule starts holding, false when it stops, nothing in between. A threshold whose
reading wanders without crossing it stays silent, which is the difference
between reporting a level and reporting an evaluation.

One predicate decides the mode, in the runtime and in the editor:

    levelMode = outputType === 'trigger' && operator !== 'always'

'always' is excluded because it cannot stop holding. Gating it on result change
would give one message in the node's lifetime, which is not what 'always' plus
'on change' implies. It keeps the ordinary firing discipline and carries true,
producing exactly the stream the boolean output type produces — asserted by
driving both configurations through the harness and comparing them.

Rate limit is skipped and hidden for a level: it drops messages inside its
window, and a dropped false leaves the receiver believing true indefinitely.
Delay stays as an on-delay, and a pending true is dropped whenever the rule
stops holding first, whatever delayReset says — announcing a condition that has
already stopped is not a late report but a false one.

Found on the way and fixed here: 'on change (ignore initial value)' skipped the
initial value and then fired on every update, because the change filter beside
it applied only to 'on change'. It is now on change, ignoring the initial value,
which is both its name and what the option next to it does. Invisible for the
level output, visible for every existing output type.

The rate-limit row is a flex line now. jQuery's .spinner() wraps the number
field and adds arrow buttons, so it is wider than the 53px the select's calc()
subtracted for, and the row wrapped. Nothing predicts the spinner's width any
more. Both details blocks also open now when a saved node that had them on is
reopened; they were only ever revealed by the click that turned them on.

core/event.js had no tests. test/eventRuntime.test.js follows the stub-RED
harness from test/thingRuntime.test.js — 14 cases over the message stream.
Testing against real cases showed one on-delay is not enough. A level often
wants the opposite shape — quick to react, slow to let go — so which edge waits
is now two checkboxes, shown only for the level output where the question
exists. Default is the old behaviour: true waits, false does not.

Building that surfaced a bug in 2.15.0. The listener short-circuited on
'matched === lastResult', so a rule that went true, started its delay and then
fell back looked like nothing had changed and returned early — leaving the
queued true to land as a claim about a condition that had already stopped
holding. It escaped the tests only because lastResult starts undefined, and the
one test that covered the shape happened to run from that state.

A pending edge now records which way it points, so it can be recognised as
stale rather than merely present, and the decision moved out of the event
filtering into settleLevel(). Four questions live there — is there anything to
say, is something already queued to say it, does that queued thing still hold,
and does this edge wait — and reading them among the change filters is what hid
the fault.

The new test fails against 2.15.0 with exactly that assertion.

Also: Output now sits above Rate limit and Delay. Reading the dialog top to
bottom, what the node sends belongs before the settings that qualify when it
sends it — and those settings now change shape depending on the output type,
which reads as a non sequitur when the cause sits below the effect.
A ticked "Delay event" with neither direction chosen did nothing, silently.
Defaulting both to on means the control always has an effect, and reaching the
inert configuration now takes two deliberate clicks rather than one oversight.

The runtime falls back the same way for a config saved before the fields
existed, so a node built yesterday with a level output and a delay now delays
its falling edge too. One rule everywhere beats a fallback that quietly differs
from what a new node would do.

Three tests in the delay block were leaning on the old asymmetric default
rather than stating the shape they meant to exercise. They now pass
delayOnFalse: false where an on-delay is the thing under test.
…redates them

Opening an existing Event node and saving it changed what the node did.

Node-RED does not backfill a declared default into a node whose saved config
has no such property — node.delayOnFalse is simply undefined. The checkbox
therefore rendered unchecked, and the next save wrote that back as a deliberate
false, overriding the runtime's 'absent means on'. The behaviour did not follow
from any decision the user made; it followed from opening a dialog.

oneditprepare now ticks both boxes when the property is absent, which is the
same question the runtime asks. Only these two defaults are affected — every
other default in this node is falsy, so the editor's undefined and the
runtime's fallback already agreed.
A band is the shape most thresholds actually want — a comfortable temperature,
a usable light level — and expressing one took two rules and an AND.

The comparator lives in lib/rules.js so both nodes evaluate it identically, but
deliberately not in halOperators(): hal2Bayes takes that list wholesale, and a
second bound has nowhere to go on a step row already carrying a source, an
operator, a value and a type picker. Event and Gate add it the way they already
add their own extras.

The pair arrives as the comparison value rather than as a third argument,
because the third is spoken for — hal2Event passes laststate there. Inclusive
at both ends, and indifferent to which bound was typed first: a range is a pair,
not an ordered instruction, and a rule that silently matched nothing because the
bounds were the wrong way round would be a poor way to learn that. A bound left
empty gives NaN, and every comparison against NaN is false, so a half-filled
rule stays quiet instead of reading as "everything above 20".

It is the one numeric operator that does not also offer the `state` type.
Comparing a reading against a band of two other live readings is a different
feature with its own questions.

Paired with the level output it is what the feature was for: true on the way
in, false on the way out, whichever side you leave by.
Three things, and a bug that only showed up because of them.

The bounds now sit together on a row of their own — "20 to 24" reads as one
value because it is one — and the comparison field above is hidden, having
nothing to hold. That is also why inside/outside became a second operator
rather than a switch: with the pair on its own line the operator line just says
what is being asked of it, and hal2Bayes gets the whole thing by reading the
operator list, with no second control to place on an already crowded step row.

outrange is written out rather than as !range. A reading that is not a number
is outside nothing, and a half-filled rule must not have one operator fail
closed while its opposite fails open.

Which is where the bug was. Number('') is 0, not NaN, so an empty upper bound
quietly became a real bound: "in range 20 to __" was the band 0–20, and matched
things nobody configured. 2.16.0's commit message claims otherwise and was
wrong. The range test passed only because 0 happened to fall on the far side of
the readings it used.

rangeBounds() in lib/rules.js is now the one place that reads a pair of bounds,
and it treats blank, whitespace, null and undefined as NaN — the only value
that makes both operators false. The Event test now uses a reading below the
low bound, which is the case that tells the two behaviours apart.

The snapshot label reads a range as the pair it is: "While Office Sensor ·
Temperature in 20–24", not "in 20".
oneditsave assigned this.compareValue from the range row, and Node-RED threw it
away again. The edit panes' apply() runs after oneditsave and re-reads
#node-input-<key> for every declared default:

    handleEditSave(editing_node, editState)                  // oneditsave
    activeEditPanes.forEach(pane => pane.apply(editState))   // re-reads the fields

compareValue is a declared default and has a field — the typedInput's own input
— so the assignment was overwritten by whatever that hidden widget held. The
upper bound was fine because compareHigh has its own visible field, and
compareSource has always worked because it has no field at all, which is why
this pattern looked safe.

The low bound is now written back through the typedInput before oneditsave
reads it, so the value the pane finds is the one the user typed. Working with
the machinery rather than around it.

hal2Gate and hal2Bayes harvest their rules in oneditsave too, but `rules` has
no matching element, so apply() skips it. Only this node was exposed.
They fit, and the select sizes itself: a <select> with no width is as wide as
its longest option, so "outside range" sets the control's width and no number
in the stylesheet has to predict it.

The row is flex now rather than a set of calc() budgets. Those budgets are what
made the rate-limit row wrap, and reproducing them here would have made the
answer to "does it fit" depend on how wide the dialog happened to be. With
flex-wrap the row uses the space when there is space and breaks between whole
fields when there is not.

Two things fell out of rewriting it. The value field's style attribute was
never terminated:

    <input type="text" id="node-input-compareValue" style="width:calc(100% - 219px)>
    <input type="hidden" id="node-input-compareType">

so the parser read the attribute up to the next quote — inside the hidden type
field — and #node-input-compareType almost certainly never existed. Which is
why compareType survived apply()'s re-read for the same reason compareSource
does: no element to read. It is a real element now, and the type still saves
because the typedInput writes into it.

And the operator handler no longer juggles widths: the wrapper is toggled
instead of the widget, so the hidden field's flex space collapses with it.
A range is one value and now reads as one. In hal2Gate and hal2Bayes the pair
replaces the value field on the rule and step rows, so neither row grew — the
field it stands in has nothing to hold for a range anyway.

Two sizing corrections in hal2Event. The bounds were 80px each with the word
"to" between them, wider than a temperature needs; they are 60px with an en
dash now. And leaving the operator <select> at its natural width meant it was
as wide as its longest option for every operator — "==" was rendered in a box
sized for "outside range". It is sized to what is selected instead, which the
node used to do before I replaced that arithmetic with flex and threw the
useful part out with it. The dropdown list is unaffected either way.

hal2Gate and hal2Bayes get the same treatment, so all three agree.
…ge pair

Replacing the calc() budgets with flex was the wrong lesson to draw from the
rate-limit row. Those budgets were doing something the flex version was not:
holding the controls out to the right margin whatever the operator. Without
them the comparison field sat at its content width and spilled past the margin
instead.

So the arithmetic is back, with a fourth branch for the range operators, and
every branch reaches the edge:

    short (==, !=, <)   select 50px   value calc(100% - 225px)
    long  (contains)    select 90px   value calc(100% - 265px)
    always/true/false   select calc(100% - 170px)   no value
    range/outrange      select 118px  bounds calc(50% - 166px) each

The select still gets an explicit width per branch rather than sizing itself,
which is what kept "==" out of a box built for "outside range".

The bounds carry the fa-arrows-h icon they had when they lived on their own
row — it reads as their label now that they sit inline — and stretch to fill,
so a range row is as wide as any other. hal2Gate and hal2Bayes match, the
latter with flex since its step rows were flex to begin with.
The bounds overflowed the right margin because each field carried its own
calc(), which meant the icon, the dash and the whitespace between inline
elements all had to be predicted. They were predicted wrong, and hal2Gate shows
by how much: its rule row's chrome is 110px, so with a 118px select the pair
had 100% − 228 to work with and I had given it 100% − 212.

Now one span is sized against the row the way every other control in these
dialogs is, and the two fields divide whatever it comes out to. Only one number
is sensitive, and getting it slightly wrong moves the pair's edge instead of
pushing a field off the dialog.

The span sets display explicitly rather than going through toggle(), which
would restore a <span> to inline and leave its width meaningless.

hal2Bayes had a different fault: narrowing the operator select to 118px for a
range shifted the bounds 12px left of where every other value on that line
starts. 130px already fits "outside range", so the select simply keeps it, and
the pair lines up with the item select above.
hal2Event gets it from the whitespace between its inline elements and hal2Bayes
from its flex margin. hal2Gate builds its rule row with .appendTo(), so there is
no text node to supply one and the icon sat flush against the operator select.

Stated explicitly there, with the width budget grown by the same 4px so the pair
still reaches the right margin.
@flic
flic merged commit 973a477 into main Aug 9, 2026
3 checks passed
@flic
flic deleted the store-and-metadata branch August 9, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant