Skip to content

Groups get a value of their own - #43

Merged
flic merged 13 commits into
mainfrom
group-values
Aug 1, 2026
Merged

Groups get a value of their own#43
flic merged 13 commits into
mainfrom
group-values

Conversation

@flic

@flic flic commented Aug 1, 2026

Copy link
Copy Markdown
Owner

A group has been a write-only concept: a command target with no state. That left
hal2Value, hal2Gate and hal2Bayes unable to use one at all, and hal2Event offering a Group
category that did not work.

Give a group a Value on the Event handler's Groups tab and it gains a state computed from its
members, shaped exactly like a Thing item's — so every reading node can consume it the same way it
consumes an item. "Alla lampor → any true" is one boolean for the whole house; "Temperatur inne →
average" is one number. A group without a Value stays exactly what it was.

The functions

latest, min, max, average, median, sum, range, any true, all true, any false,
all false, count true, count false, percent true.

They live in resources/group-aggregate.js, UMD like bayes-scale, so the list the editor offers
cannot drift from what the engine computes. The module is pure: the engine decides which members
count and hands over only those.

What counts is the load-bearing part

A member contributes only if its item carries state and its Thing is alive. A device that has
dropped off the network must not keep voting with the value it had before it went quiet — so
any true goes false when the last reachable lamp disappears, rather than reporting a light that
may well be dark.

The liveness rule is now lib/common.js:isThingAlive, shared with get_all_states so the group
engine and the MCP catalog cannot disagree about what offline means. The heartbeat item is not a
group member but does trigger a recompute — without that a group would keep counting a device for
as long as it stayed silent, which is the exact failure the rule exists to prevent.

Nothing eligible yields undefined rather than 0 or false, so a silent group can never read as
a real "off" in a Gate. The numeric functions skip non-numbers (a boolean member never counts as 1)
and the boolean functions are strict about true/false, matching lib/rules.js.

Behaving like an item

The group emits on every member update but moves laststate/last_change exactly as
hal2Thing.updateState does, so hal2Event's change filters behave identically whether they watch
a Thing or a group. msg.member says which member moved it. The emit goes straight onto the bus
rather than through publishUpdate: a derived value has no business in the history database, and
hal2Value can only read a group — use hal2Action to command one.

Bugs fixed along the way

  • hal2Event wiped its own configuration. Opening a group-configured node resolved the group id
    with RED.nodes.node(); groups are not nodes, so thing, item and typeSel were cleared every
    time. That was the whole of "groups don't work in Events".
  • hal2Gate discarded group rules for the same reason, one layer down: the rule loader dropped
    any row whose source did not resolve to a node, so the rule vanished on the next save.
  • hal2Gate duplicated dynamic rules, pre-existing: the "set by msg.thing.id" branch called
    addItem twice, once inside its guard and once after it.
  • hal2Bayes lost saved time-of-day steps'time' was missing from its source whitelist, so
    a time step silently reopened as a thing step.
  • hal2Value's dialog would not open when its source select had no matching option.
  • The Event handler field was optional even where it is required, so setting it to none on a group
    source passed validation and failed at runtime.

Testing

217 passing, up from 190. The new ones cover every function, the empty and all-offline cases that
must not read as 0/false, vacuous all true/all false, strict booleans, rounding, and the
laststate transition that decides when "on change" fires.

Verified against the running instance: the engine picks the aggregation up at startup, and median
over nine live members computed 25.68 — matching the members' actual readings — with last_change
correctly trailing last_update while the value held steady.

Not yet exercised in production: a member actually going offline, and Gate/Bayes reading a group at
runtime. Only median has run live; the other functions are unit-tested only.

flic and others added 13 commits July 31, 2026 20:09
A group has been a write-only concept: a command target with no state, which
left hal2Value, hal2Gate and hal2Bayes unable to use one at all and hal2Event
offering a Group category that did not work.

Give a group a Value on the Event handler's Groups tab and it gains a state
computed from its members — average, any true, count true, percent true and the
rest — shaped exactly like a thing item's, so every reading node can consume it
the same way it consumes an item. A group without one stays exactly what it was.

The aggregation lives in resources/group-aggregate.js, UMD like bayes-scale, so
the functions the editor offers cannot drift from what the engine computes. It
is pure: the engine decides which members count and hands over only those.

What counts is the load-bearing part. A member contributes only if its item
carries state and its thing is alive — a device that dropped off the network
must not keep voting with the value it had before it went quiet, so `any true`
goes false when the last reachable lamp disappears rather than reporting a light
that may well be dark. The liveness rule is now lib/common.js:isThingAlive,
shared with get_all_states so the group engine and the MCP catalog cannot
disagree about what offline means. The heartbeat item is not a group member but
does trigger a recompute, or a group would keep counting a device for as long as
it stayed silent.

Nothing eligible yields undefined rather than 0 or false, so a silent group can
never read as a real "off" in a Gate.

The group emits on every member update but only moves laststate/last_change when
the value actually changed, which is what hal2Thing.updateState does — so
hal2Event's change filters behave identically whether they watch a thing or a
group. It is emitted straight onto the bus rather than through publishUpdate: a
derived value has no business in the history database.

Also fixes two bugs found on the way. hal2Event wiped thing/item/typeSel every
time a group-configured node was opened, because it looked a group id up with
RED.nodes.node() and groups are not nodes — that was the whole of "groups don't
work in Events". And hal2Bayes omitted 'time' from its source whitelist, so a
saved time-of-day step silently reopened as a thing step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six editor problems, one of them the reason no group appeared anywhere.

halGetGroups returned an empty list whenever the Event handler id it was given
did not resolve, which is exactly the case on the Value and Gate nodes: their
Event handler field is new, so every existing instance carries an empty one. It
now falls back to every handler in the flow — via eachConfig, because
filterNodes only walks flow nodes and would never see a config node at all.
A group id is unique across handlers, so the fallback cannot pick the wrong one.

The Value node's dialog would not open. Its thing-change handler dereferenced
RED.nodes.node(...).thingType without checking, so a select with no matching
option — a new node, or a saved id absent from the current list — threw and
aborted oneditprepare. Guarded, and the select no longer ends up unselected.

The Value field on a group was disabled until the group had stateful members,
which made it unreachable at the one moment you want it: a group is created
before anything joins it. It is now always selectable and the tip reports the
member count instead of gating on it.

Also: an explicit "- no groups with a value -" placeholder instead of an empty
dropdown, "offline members are left out" instead of the clumsier phrasing, and
the tip wraps instead of running off the row, which forbids wrapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A group carried the last *different* value forward as laststate, so every
subsequent member update still looked like a transition: two consecutive
reports both arrived as 25.28 with laststate 25.25, and hal2Event's "on change"
filter passed both.

hal2Thing sets laststate to the previous value unconditionally and makes only
last_change conditional (core/thing.js:218-231). Groups now do the same, so the
second report reads 25.28 with laststate 25.28 and is correctly suppressed.

The transition moves into group-aggregate as nextRecord() to be testable —
covering the regression itself, the first computation, and a group going silent
(a real change to undefined) versus staying silent (none).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Setting it to "none" passed validation even on a group source, which then fails
at runtime with "no event handler configured". It is now required exactly when
it is used: on hal2Value when the Category is Group, on hal2Gate when any rule
reads a group. Elsewhere it stays optional, so existing nodes are not flagged.

The Value node only shows the field for a group, with a line saying why one is
needed at all: a Thing carries a reference to its own handler and the node
reaches the bus through it, but a group is not a node — its value lives in the
handler's group registry, so the owning handler has to be named.

The validator prefers the live Category while the dialog is open and the saved
one otherwise, keyed on an id unique to this template — hal2Event also has a
#node-input-typeSel, and reading that one would judge this node by another
node's open form.

Both tips are now form-tips blocks indented as a whole, so a wrapped second line
lines up under the first instead of running back out to the left margin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Event handler tip explained the whole ownership model where "pick the one
that owns the group" says everything the field needs, and the Value tip pointed
at the Action node — true, but not what you are there to find out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A form-tips block fills its parent, so margin-left indents the left edge without
shrinking the box — it ran past the right edge of the edit tray by exactly the
indent. Given the same width as the fields above it, calc(100% - 170px) after
the 105px label column, it now sits inside the dialog with its edges aligned to
the inputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It described the selected group but sat above the field that selects it. Moved
below, and given to hal2Event with the same markup and indent so a group reads
the same way in both nodes.

It hides rather than blanks when no group is selected — an empty tip box reads
as something failing to load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A rule whose source is a group was dropped while loading the editor: the loader
resolved rule.thing with RED.nodes.node(), a group id is not a node, so the row
was never added to the list and the next save wrote the rule away. Same shape as
the bug that wiped a group out of hal2Event. Group rules are now recognised
against the group registry.

While in there: the dynamic-thing branch called addItem twice, once inside the
guard and once after it, so every "set by msg.thing.id" rule was duplicated each
time the dialog opened.

Each rule that reads a group now shows the same Value line as hal2Event and
hal2Value, so what the rule is comparing against is visible where the rule is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Gate tip sat a notch right of the field above it: its labels are 80px with
box-sizing:border-box, so the padding is inside that width and the column is 80,
not 90. And form-tips caps itself at max-width:450px, which pulled every tip's
right edge in short of the field it belongs to — lifted wherever an explicit
width is set.

hal2Bayes now shows the same line, last in the step so it sits under the timing
row, indented to the 50px lead column the selects start in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same reading order as the other nodes: pick a group, and what it computes is
stated directly beneath.

Its width was auto with no box-sizing, so form-tips' 8px padding and 1px border
landed outside it — 18px of overflow past the right edge, unchanged at any
window width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It used fixed margins where the rows around it are flex, so it only lined up
while the remove-button column was visible. refreshRuleView hides that column
for a single-step rule, and the fields grow into the space — leaving the tip
stopping 34px short and resizing on a different curve.

It now has the same three-part shape as the lines above: the 50px lead the
selects start in, a flexible middle, and a bs-endpad the existing toggle picks
up by class along with the others.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It was created after the Rule row and so rendered last, while the other three
nodes carry it directly under the field it describes. Rows render in creation
order, so building it right after rowName is the whole change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flic
flic merged commit 99a8a57 into main Aug 1, 2026
3 checks passed
@flic
flic deleted the group-values branch August 1, 2026 06:28
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