Skip to content

Merge master into production (#minor) - #253

Merged
RobinBol merged 63 commits into
productionfrom
master
Oct 23, 2026
Merged

RobinBol merged 63 commits into
productionfrom
master

Conversation

@RobinBol

Copy link
Copy Markdown
Collaborator

No description provided.

nozols and others added 30 commits September 18, 2026 15:25
Each Cluster kept its own transaction sequence number counter starting
at 0, so every cluster on a node sent TSN 1, 2, ... and an app restart
reused the TSNs it had just sent. Some devices (Aqara Radiator
Thermostat E1) acknowledge a write whose TSN they recently saw on any
of their clusters but do not apply it.

Allocate TSNs from one counter per ZCLNode, shared by all endpoints and
clusters, starting at a random value. A standalone Endpoint whose owner
has no counter falls back to its own. Cluster.nextSeqNr() stays
overridable.

Refs #226
The stack can reject sendFrame (e.g. it gave up waiting for the APS ACK
and retried) after the device already received the frame and sent its
ZCL response. The command then failed even though the response proved
delivery. Ignore the send failure once the response has been handled.

Refs #226
Endpoint.handleFrame suppressed every reply to a frame with command id
0x0B without checking the frame type. Cluster-specific commands with id
0x0B (e.g. Door Lock setWeekDaySchedule) therefore got no cluster
response, no success Default Response and no error Default Response.

ZCL R8 2.5.12.2 only forbids answering another Default Response, which
is global command 0x0B (frame type 0b00). Cluster-specific command ids
are scoped per cluster (2.4.1.4), so 0x0B there is a different command.
handleFrame resolved a pending request with any incoming frame whose
TSN matched. Devices number their own unsolicited frames (attribute
reports, zone enroll requests, OTA queries) from their own counter, so
1 in 256 such frames was swallowed as the response: the request resolved
with wrong args and the frame's own handler never ran.

A TSN identifies a transaction (ZCL R8 2.4.1.3), and a response belongs
to the command it answers: the declared response command, or a Default
Response whose command identifier field names the sent command
(2.5.12.1.2). Only those frames now resolve the pending request; any
other frame is dispatched to its regular handler.
An unsupported command or a frame for a cluster the endpoint lacks was
answered with a Default Response status of FAILURE (0x01). ZCL R8
2.5.12.2 requires UNSUP_COMMAND (0x81, named UNSUP_CLUSTER_COMMAND in
the status enum) and UNSUPPORTED_CLUSTER (0xc3); FAILURE is only to be
tolerated from devices certified before ZCL revision 6. R8 Table 2-12
deprecates 0x82-0x84 in favor of UNSUP_COMMAND, so the manufacturer
specific and general cases use 0x81 too.

Throw ZCLError with these statuses instead of plain Errors, so the
existing ZCLError path in Endpoint.handleFrame encodes them. Handler
errors still map to FAILURE.
The attr.<name> event now passes a second argument with the report meta
data, including the ZCL transaction sequence number of the report frame.
This allows devices to detect repeated deliveries of the same report.
Makes devices that answer with a Default Response for the wrong command,
or an undeclared response command, visible in the debug log.
Endpoint now asks its owner for the next transaction sequence number
when the owner provides one, so the type and its comment no longer hold
that Endpoint only ever calls sendFrame.
The test is about whether a Default Response is sent, not which error
status it carries, so it keeps passing once unsupported commands get
UNSUP_COMMAND instead of FAILURE.
Endpoint.handleFrame only suppressed default responses for group-addressed
frames. A command received via network broadcast or sent to the broadcast
endpoint 0xFF still got a default response (success or error).

ZCL R8 2.5.12.2 generates a Default Response only for a unicast command,
and 2.3.2.1 says the broadcast endpoint follows that rule. Skip default
responses when meta.broadcast is set (Z-Stack wasBroadcast) or
meta.dstEndpoint is 0xFF. Cluster-specific responses are still sent.
ZCL R8 2.5.12.2 ties the Default Response to the frame being unicast,
not to its destination endpoint, so an APS unicast to endpoint 0xFF still
gets one. Only skip it when the stack flags the frame as a broadcast.
getAlarm had no response, so with response matching by command its Get
Alarm Response (0x01) no longer resolved the call. Before, it only
resolved by TSN, parsed as resetAllAlarms with no args. Status-dependent
payload per ZCL R8 3.11.2.5.2.
Apps define commands without a declared response and await a
cluster-specific reply with the request's TSN, e.g. Tuya datapoints sent
with disableDefaultResponse. Matching by command id would time those
out. For such commands any cluster-specific frame with the TSN still
counts; reports and other global frames never do.
_addPrototypeMethods defaults a response without a numeric id to the
command id. The trx handler recorded undefined instead, which switched
such commands to the any-cluster-specific-reply fallback and let an
unrelated frame with the same TSN resolve them.
Name the Default Response and frame type checks and split the declared
and undeclared response cases into an if/else. No behavior change.
Keep the match fields next to a handle function instead of attaching
them to the handler with Object.assign. No behavior change.
A cluster-specific frame carrying a global command's response id, and a
Default Response naming another command, must not resolve the pending
command.
fix: Share one random-start TSN counter per node
…-type

fix: Only skip responses for global Default Response frames
fix: Send UNSUP_COMMAND and UNSUPPORTED_CLUSTER in Default Response
…cast

fix: Skip default responses to broadcast frames
…-by-command

# Conflicts:
#	test/testClusterResponseTimeout.js
fix: Match pending responses by command, not only by TSN
Pass a whitelisted object instead of spreading the stack's frame meta,
so stack fields do not become part of the public event API, and use the
spec name transactionSequenceNumber. Document that the value wraps and
can be fixed per device, so it only marks duplicates within a short
window.
…umber

Emit the transaction sequence number with attribute reports
Run npm update across the dev dependency tree and bump jsdoc to 4,
jsdoc-ts-utils to 6 and mocha to 12. This takes npm audit from 49
findings to 19 and removes the only critical one.

The jsdoc major is not optional here. On jsdoc 3 the refreshed tree
pulls @types/linkify-it 5 next to @types/markdown-it 12, which makes
the tsc --noEmit index.d.ts check in CI fail. jsdoc 4 drops that
@types chain entirely.

Nothing here reaches consumers: the runtime dependencies and the
published lib/ and index.d.ts are untouched. npm writes the lockfile
as version 3, which is what the bundled npm on the Node version in
.nvmrc produces.

Still open and deliberately left out: eslint 6 to 10 needs the flat
config migration, and sinon 19 to 22 breaks the type check because
@types/sinon 17 no longer matches its fake-timers types.
build(deps): Update dev dependencies to clear audit findings
Both @athombv packages this repo depends on, data-types and
jsdoc-template, are published to npmjs.org. Most other @athombv packages
are published to GitHub Packages instead, so Athom machines commonly set
@athombv:registry to npm.pkg.github.com in their user config.

npm resolves a registry per scope, not per package, so that user config
sends these two to GitHub Packages as well, where jsdoc-template only
goes up to 1.6.3. npm install and npm update then fail with ETARGET on
jsdoc-template@^1.7.0. Only npm ci keeps working, because the lockfile
carries a full tarball URL per package.

Pinning the scope here overrides that for this repo. Both packages are
public on npmjs, so this needs no credentials and changes nothing for
CI, which already defaults to npmjs.
RobinBol and others added 28 commits October 23, 2026 09:30
// Mandatory, // Optional and // Conditional describe the attribute or
command as a whole. On the opening brace Prettier pulls them inside it,
where they read as if they belong to the id field. Above the key they
survive the formatter and say what they mean.

77 annotations across five cluster files, no other change.
Pure npm run format:write output, except for the prettier-ignore guard
on the enum8Status table in lib/zclTypes.js, which keeps its column
alignment.
The annotation moves above the key, which is both what it means and what
survives Prettier. Prettier also lowercases hex digits, so the id
examples follow.
The format:check script was added alongside Prettier but nothing ran it,
so the formatting was advisory: one commit formatted by hand and the
next npm run format:write is a hundred-file diff again, which undoes the
point of the reformat.
The three EventEmitter imports now read node:events. The prefix cannot
be shadowed by a local events.js and says outright that the module is
built in.

Only lib/ here; the test files are converted alongside the node:test
migration they belong to.
chore: Add Prettier and reformat
The generated docs are a static site that works straight from disk: no
fetch, no XHR, no module scripts, only relative classic script tags.
Checked over file:// in Chrome and Safari, with stylesheets, the sidebar
filter, dark mode, syntax highlighting and deep links all working, and
console output identical to serving it over http.

So serve, watch and concurrently go without a replacement, along with
build:clean and build:watch. Together they pulled in 148 transitive
dependencies and 5 audit findings for a preview that never runs in CI;
concurrently@5 is from 2020 and watch@1.0.2 from 2016.

npm run docs builds and opens build/index.html with macOS open. AGENTS.md
says how to view the docs elsewhere, and its lint line no longer refers
to the eslint-config-athom setup that #244 removed.
node:test covers describe/it/hooks and the mock API covers the four
sinon sandbox uses (spy, stub, callsFake, restore), so both packages
and @types/sinon can go.

Two things node:test does differently:
- A callback test must take the context first, function(t, done). With
  function(done) node:test treats the test as synchronous, passes it and
  never runs the assertions. All 22 callback tests are updated.
- There is no default test timeout, so a done that never fires hangs
  instead of failing. --test-timeout=5000 restores mocha's safety net.

The one test that waited on a real 150ms timer now uses mock.timers.
The test files already require `node:test` on the line above, so the
unprefixed `assert` next to it was the odd one out.
The repository supports Node 22 and up, but every workflow ran Node 24
through .nvmrc, so the supported floor was never the version anything
was actually checked against.

Verified on Node 22.23.4, the current 22.x, at the tip of the open
stack: npm ci, 114 tests pass, lint, format:check, the docs build,
generate-types with an unchanged index.d.ts, and tsc all succeed. That
includes the native type stripping #243 relies on, which needs Node
22.18 or newer; `22` in .nvmrc resolves to the latest 22.x.

engines.node stays ">=22": consumers are not restricted to 22, this only
fixes what the project itself develops and tests against.
Four unrelated bits of workflow upkeep.

Pushes to `testing` ran neither Lint nor Test, while deploy.yml
publishes a beta from that same branch. Both workflows now trigger on it.

setup-node gets `cache: 'npm'` in the four workflows that lacked it;
generate-types.yml already had it. They each re-resolved the whole tree
on every run.

actions/checkout and actions/setup-node go from v4 to v7, and
peaceiris/actions-gh-pages from v3.8.0 to v4.

The deploy.yml header still described a Node 12 environment; it has read
the version from .nvmrc for a long time.
Two problems the Node 22 pin exposed in deploy.yml.

Publishing uses OIDC trusted publishing with --provenance and carries no
NPM_AUTH_TOKEN, and that needs npm 11.5.1 or newer. Node 22 bundles npm
10.9.9, and setup-node installs whatever npm ships with the Node version
it selects, so both the production and the beta publish would have failed
authentication, after the version bump and tags had already been pushed.
npm is now upgraded explicitly. npm 11 needs Node >=22.9.0 and `22` in
.nvmrc resolves well past that.

Adding `testing` to the Lint and Test workflows made the checks run on
that branch but did not hold anything back: deploy.yml starts on the same
push and neither waits for them nor repeats them, so a `#patch` push
could still publish a beta before they finished. The deploy job now runs
lint, format:check and the tests itself, before the version bump, so a
failure stops the publish instead of racing it.
The docs build is the slowest step in the job and nothing about
publishing depends on it, so running lint, format:check and the tests
first means a failing check stops the job before that minute is spent.
Build and the publish steps that follow are unchanged.
chore: Drop the docs server in favor of opening the files
chore: Replace mocha and sinon with node:test
ci: Pin to Node 22 and tidy the workflows
AGENTS.md now lists format:check, format:write and generate-types next to
the other commands. Lint in CI runs format:check, so a change that skips
format:write fails there, and the file did not say the command exists.

docs.yml asked to add .github to .npmignore. There is no .npmignore and
none is needed: files in package.json is an allowlist of index.d.ts and
lib, so nothing else is packed.
docs: List the format and type commands, drop a stale npmignore note
Publishing put a version on npm and a tag in the repository, but no
GitHub release, so there was nowhere to read what changed.

The deploy job now creates one once npm has accepted the version, from
GitHub's generated notes. It starts from the previous release rather
than the previous tag, because a publish that fails after the version
bump leaves a tag behind without a release, and starting from that tag
would leave its changes out of the next notes.

.github/release.yml groups the notes into breaking changes, new
features, fixes and other changes, and leaves internal work out. The
groups key off labels, which label-pr.yml sets from the conventional
commit prefix of each pull request title: feat, fix and perf, a
breaking marker, and build, chore, ci, docs, refactor, style and test as
internal. Titles without a prefix land under other changes, so outside
contributions stay visible. The release pull request into production is
labeled too and excluded.

The notes keep each pull request title as is, so the prefix is stripped
from every line: the heading above it already says it.
The deploy workflow published betas from a testing branch, and Lint,
Test and Generate TypeScript Types listed it as a trigger. That branch
no longer exists and npm has no beta dist-tag, so the triggers, the job
condition and the beta publish step go. The deploy job now runs on
production only, which also makes the per-step production checks on
Publish and on the release step redundant.

Publish also stops writing package_version to the job environment;
nothing read it.
Without an explicit shell, Actions runs a step with bash -e but no
pipefail. When the generate-notes call failed, the Node filter after it
still exited cleanly on empty input, so the step published a release
with empty notes and reported success. That release then became the
starting point for the next release's notes, which lost these changes
too. shell: bash adds pipefail, so the step now stops instead.
The labeler only reacted to title edits, so retargeting a pull request
into or out of production kept its old label: a feature moved into
production was not marked as a release, and one moved out stayed
excluded from the notes. It now also reacts to base branch edits.

A title that lost its conventional prefix kept the label the prefix had
set, so a chore that became a user-facing change stayed hidden. That
label is now removed. Labels a maintainer set by hand on a pull request
without a prefix are still left alone.
Until the first GitHub release exists, the release step fell back to
the nearest earlier tag. A publish that fails after the version bump
leaves such a tag behind without an npm version, and this repository
has three of them. For v3.1.1 the fallback picked v3.1.0, an orphan, so
the notes would have left out everything between v3.0.2 and v3.1.0.

The fallback now asks npm for the highest version below the one being
released, which only counts versions that were really published.
The event payload holds the labels as they were when the event fired.
A pull request opened as chore: and retitled to feat: before the first
run finished produced two runs that both saw no labels: the first added
internal, the second added feature without removing internal, and since
internal is excluded from the notes, the feature disappeared from them.

Each run now reads the pull request as it is when the run starts, and
runs for the same pull request are serialized, so a later run sees the
labels an earlier one added.
The README's release section was a single line. It now covers the
master to production pull request with its version marker, why it has
to be merged with a merge commit or a squash merge, what the Deploy
workflow then does, and how the release notes are grouped by the
conventional commit prefix of each pull request title.

A Changelog section near the top points users to the GitHub releases.
The contributing notes mention Prettier and the pull request title
convention, and AGENTS.md states that convention and links to the
release section, since the title now decides where a change appears.
It named scripts/generate-types.js, which is generate-types.mts, and
said types regenerate on pushes to develop, a branch this repository
does not have. The workflow runs on any branch other than production,
for a wider set of paths than listed, and commits back to that branch.
The labeler kept a label set by hand on a pull request without a
conventional prefix, and to tell that apart from one it had set itself,
it worked out the label the previous title implied. When runs overtook
each other that went wrong: a pull request opened as chore: and retitled
twice within one run could keep internal, and with it drop out of the
release notes.

The five managed labels now follow the current title and base branch
and nothing else, so the last run always leaves them right, whichever
runs before it were skipped. A title without a prefix gets none of
them. To move a pull request to another section, retitle it, which is
what the README already said. Other labels are left alone.
ci: Create a GitHub release on publish and drop the testing branch
@RobinBol RobinBol added the release Release pull request into production, left out of the release notes label Oct 23, 2026
@RobinBol
RobinBol merged commit 4674ddc into production Oct 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release Release pull request into production, left out of the release notes

Development

Successfully merging this pull request may close these issues.

3 participants