A Thing type with a memory, an Event node that reports a level, and ranges (2.17.6) - #49
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 hadto 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 exactlyas 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
_metawants 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 insidethe 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/falseanswers the level directly:truewhen the rule startsholding,
falsewhen it stops, nothing in between. A threshold whose readingwanders without crossing it stays silent.
Rate limit is skipped and hidden for a level — it drops messages inside its
window, and a dropped
falseleaves the receiver believingtrueindefinitely.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.
alwaysis excluded from all of this: it cannot stop holding, so it keeps theordinary firing discipline and carries
true, producing exactly the stream theboolean 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()inlib/rules.jsis the one place that reads a pair, becauseNumber('')is0rather thanNaN: read naively, an empty upper bound turns"in range 20 to __" into the band 0–20 and matches things nobody asked for.
outrangeis written out rather than as!range, so a non-numeric reading isoutside nothing and a half-filled rule fails closed both ways.
Bugs found on the way
for a whilewas satisfied by the edge, not by the wait.driveFsmdispatches on pattern and
heldfell into the branch that completes a step ona 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.
matched === lastResult, so a rule that went true, started its delay and fellback looked like nothing had changed. It escaped the tests only because
lastResultstarts undefined.on change (ignore initial value)did half of what it says — it skippedthe initial value and then fired on every update, because the change filter
beside it applied only to
on change.oneditsavecannot set a property that has a DOM element. Node-RED runsthe edit panes'
apply()afterwards and re-reads every declared default fromits field.
compareSourceandcompareTypeonly appeared to work becauseneither had an element — the latter because an unterminated
styleattributehad swallowed it.
Tests
392 passing.
core/event.jshad no coverage at all;test/eventRuntime.test.jsfollows the stub-RED harness
test/thingRuntime.test.jsestablishes and drivesthe 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