Skip to content

fix(nibe_local): file the heat pump as a heat pump, not a meter - #53

Merged
HuggeK merged 1 commit into
srcfl:mainfrom
HuggeK:nibe-der-heatpump
Jul 31, 2026
Merged

fix(nibe_local): file the heat pump as a heat pump, not a meter#53
HuggeK merged 1 commit into
srcfl:mainfrom
HuggeK:nibe-der-heatpump

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

nibe_local was filed as a meter. It reads a NIBE S-series heat pump.

The driver emits nothing but metrics — hp_power_w, hp_used_power_w, the temperatures and the lifetime energy counters, all through host.emit_metric and never through host.emit("meter", …). Its header says that is deliberate: a heat pump is a load, and emitting it as a meter would double-count the pump's draw against the site's real grid meter. So ders: [meter] advertised a DER the driver does not provide.

heishamon — the other metric-only heat pump in the catalog — has been ders: [heatpump] since it landed. This makes nibe_local match it.

Why it is worth a PR of its own

The catalog page published by #51 badges and filters drivers by this field, so today the pump appears under Meter and is missing from Heat pump — where an owner looking for NIBE support would start. Rebuilt the page locally to confirm the fix lands:

$ python tools/generate_site.py --output site
Generated site/index.html: 80 drivers, 69 manufacturers, 171 model families.
nibe_local ders = ['heatpump']
Scope, and what deliberately did not change

Manifest metadata only. The Lua source, its version (1.1.0) and its sha256 are untouched, so no host sees a new driver build and the FTW baseline byte-identity exemption is unaffected. index.yaml and devices.yaml are the regenerated outputs.

myuplink — the cloud NIBE/Bosch/Daikin heat-pump driver — declares ders: [], and tools/ftw_repository.py defends that in a comment: a driver that publishes only metrics should not have to claim telemetry it never emits. That reasoning sits awkwardly beside heishamon, which is also metric-only and is filed [heatpump]; the distinction seems to be that heishamon has a control path. Left alone here rather than folded into a one-line fix — happy to follow up either way, but the catalog should probably pick one rule.

control: true on this manifest predates the write path and is likewise untouched.

Ordering

Two open branches also touch manifests/nibe_local.yaml#46 (Solar PV write path) and #48 (upstream_docs). Both are mine and both take a trivial rebase after this lands; this one is one line and independent, so it should go first.

🤖 Generated with Claude Code

nibe_local's manifest declared ders: [meter]. It reads a NIBE S-series
heat pump and emits nothing but metrics -- hp_power_w, hp_used_power_w,
the temperatures and the lifetime energy counters, all via
host.emit_metric and never via host.emit("meter", ...), which the
driver header states is deliberate so a pump's draw cannot double-count
against the site's real grid meter. So the catalog advertised a DER the
driver does not provide.

The published catalog page badges and filters on this field, so the pump
sat under Meter and was missing from Heat pump. heishamon -- the other
metric-only heat pump -- has been ders: [heatpump] since it landed; this
matches it.

Manifest metadata only. The Lua source, its version and its sha256 are
untouched, so no host sees a new driver build. index.yaml and
devices.yaml are regenerated.

Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@HuggeK
HuggeK marked this pull request as ready for review July 31, 2026 08:44
@HuggeK
HuggeK merged commit a9057f4 into srcfl:main Jul 31, 2026
5 checks passed
HuggeK added a commit that referenced this pull request Jul 31, 2026
…h it

The driver release failed on main with

    error: nibe_local: changed artifact needs a higher version than 1.1.0

#53 corrected the manifest from ders: [meter] to ders: [heatpump] and read
that as catalog-only metadata, so it left the version at 1.1.0. It is not
catalog-only. _load_channel copies ders into the artifact's capabilities and
the channel signs source and metadata as one file, so the correction moved
the published bytes under a version that has already shipped. The channel
refused, and it was right to: that immutability is what lets an operator name
the bytes a site is running.

So nibe_local takes a patch. Patch rather than minor because no register,
field or emitted metric changed -- only the DER the catalog states. The Lua
source is untouched and its sha256 with it, and bump_driver leaves the DRIVER
table alone because the file is byte-identical to its FTW baseline, so the
provenance guarantee #29 established is spent on nothing.

Verified against the currently published beta manifest: of 80 drivers exactly
one differs from what is published, and after the bump nothing is blocked.

CONTRIBUTING said to raise the version whenever the Lua source changes, which
is what #53 followed. It now also names the manifest fields that reach the
signed artifact -- ders, protocol, and a tested_devices manufacturer and
model_family -- and warns that this check runs on main after the merge rather
than in the pull request, because only the release can read the channel.

Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
HuggeK added a commit that referenced this pull request Jul 31, 2026
The signed channel refuses to republish changed artifact bytes under a version
it has already published. That is the rule that makes a version name a set of
bytes an operator can pin, and it was the one release check no pull request
could run: asking it needs the published channel, so it only ever ran from the
release job on main. #53 merged with five green checks and took the release
down; the repair had to be a second pull request against a main that was
already broken.

Nothing about the question needs the signing key. The published manifest is a
public release asset, its signature verifies with the public key already in
the workflow, and the bytes a driver would publish as come from the tree. So
check-versions builds the artifacts in memory, signs nothing, and answers what
the release would answer -- on every pull request, forks included, since a
fork needs no secret to read a public asset.

_version_collisions is now the single statement of the rule. Both the release
and the check read it, because a rule stated twice drifts, and a check that
drifts from the release is worse than no check at all:
test_check_versions_answers_what_the_release_would_answer holds them to the
same verdict on the same tree.

The check reports every colliding driver rather than the first, each with the
bump_driver command that fixes it. The release still stops at one; it has
nothing else to do, and someone fixing a branch wants the list.

test_check_versions_catches_a_manifest_only_edit is #53 in the shape it
arrived in -- a manifest field corrected, no Lua touched, the published bytes
moved anyway. Verified against the live channel as well: the current main
passes, and replaying that edit is refused before a merge.

CONTRIBUTING no longer says this fails only after the merge, and carries the
command to ask it locally. It also states the one sharp edge: the comparison
is against the published channel, not against main, so a main that is itself
unpublishable fails this on unrelated pull requests until it is fixed.

Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
HuggeK added a commit that referenced this pull request Jul 31, 2026
myuplink declared ders: [], so the one MyUplink cloud heat-pump driver
was absent from the Heat pump filter and from every other device type on
the catalog page -- unreachable by the thing an owner actually searches
for. It is now ders: [heatpump].

This revisits a deliberate choice. #27 widened the DER vocabulary and set
myuplink to none, reading ders as the emit stream a driver produces
rather than the device it covers. #53 settled it the other way for
nibe_local, citing heishamon. All three report a pump through
host.emit_metric and never call host.emit; myuplink and heishamon emit
the same hp_* metric names. Two of the three already declared heatpump,
so myuplink was the one left behind rather than the one holding a line.

1.1.0 -> 1.1.1 because ders is copied into the signed artifact's
capabilities, so this changes the published bytes with no Lua moved --
the same reason nibe_local took 1.1.1. Unlike #53 the channel never got
the chance to refuse it: the preflight check answers on the pull request,
and putting the version back to 1.1.0 there reproduces the refusal.

spec/manifest-v2.md documented four DER types where validate_manifest.py
accepts seven. ev, heatpump and vehicle have been valid since #27 and are
used by seven manifests, but the field table a driver author reads never
listed them -- and the missing vocabulary is exactly what myuplink
needed. The table is now complete and says what the field means for a
metrics-only driver, which is the ambiguity that produced three different
answers for three drivers doing the same thing.

Signed-off-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
HuggeK added a commit that referenced this pull request Jul 31, 2026
…o unlock it

protocol names a transport and answers neither question an owner asks first.
nibe_local and myuplink are both protocol: http and both read a NIBE heat
pump, and they are opposites: one reads the pump over the LAN, the other
reads NIBE's cloud. The catalog could not tell them apart.

Two fields, orthogonal to protocol rather than folded into it. Splitting
protocol into http_local/http_online was the obvious move and the wrong one:
protocol feeds PROTOCOL_PERMISSIONS, so new values change what a package
build grants, and it does not generalise -- MQTT can be a cloud broker too.

  connectivity: local | cloud    where the driver talks while running
  setup: [...]                   what a human must obtain first, and from whom

Backfilled across all 80 drivers from what the sources do. 77 local, 3 cloud:
myuplink, easee_cloud and tibber are the only drivers that hardcode a vendor
endpoint. Every other HTTP driver builds its base URL from config, and the
Modbus, MQTT and serial drivers name a vendor only as homepage.

setup is the second axis, and a driver can be local and still locked until
somebody unlocks the interface: nibe_local is enabled in the pump's own
installer menu -- no app, no account -- while sonnen and CTEK are enabled in
the device's web UI, sma_pv needs an installer, and eight drivers need a
bridge in between. 17 have a recorded gate; 63 do not, and absent means
unrecorded. The page renders that as "Not recorded", never "Nothing to set
up", and only setup: [none] claims a device needs nothing but an address.

tests/test_connectivity.py stops connectivity being decorative. A cloud
endpoint must be hardcoded -- there is nowhere else for it to come from --
while a LAN address comes from config, so the test reads that asymmetry in
both directions and separates exactly the three from the other 77.

Neither field reaches the signed artifact: _load_channel copies an explicit
allowlist and these are not in it. So no driver version moves, unlike the
ders correction in #53 that #55 had to version.

Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
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.

2 participants