Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/watch-upstream-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: watch-upstream-docs

# Weekly, fetch every manifest `upstream_docs` URL, diff it against the committed
# baseline (upstream-docs-state.json), and open a tracking issue when a watched
# document changes or goes missing. See tools/check_upstream_docs.py.

on:
schedule:
- cron: "17 6 * * 1" # Mondays 06:17 UTC
workflow_dispatch:

permissions:
contents: write # commit the updated baseline
issues: write # open tracking issues

concurrency:
group: watch-upstream-docs
cancel-in-progress: false

jobs:
watch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
python-version: "3.12"
- run: uv sync --frozen

- name: Fetch and diff watched documents
run: |
uv run --no-sync python tools/check_upstream_docs.py \
--changes-out "$RUNNER_TEMP/upstream-doc-changes.json"

- name: Open a tracking issue for each change
env:
GH_TOKEN: ${{ github.token }}
CHANGES: ${{ runner.temp }}/upstream-doc-changes.json
run: |
set -euo pipefail
count="$(jq 'length' "$CHANGES")"
echo "alerts: $count"
if [ "$count" -eq 0 ]; then exit 0; fi

# Ensure the labels exist (no-op if they already do).
gh label create upstream-doc-changed --color d93f0b \
--description "A watched upstream document changed" 2>/dev/null || true
gh label create upstream-doc-unreachable --color b60205 \
--description "A watched upstream document could not be fetched" 2>/dev/null || true

for i in $(seq 0 $((count - 1))); do
title="$(jq -r ".[$i].issue_title" "$CHANGES")"
label="$(jq -r ".[$i].label" "$CHANGES")"
jq -r ".[$i].issue_body" "$CHANGES" > "$RUNNER_TEMP/body.md"

# Notify once: if an open issue with this exact title already
# exists, this change (or this outage) has already been reported.
existing="$(gh issue list --state open --search "$title in:title" \
--json title | jq --arg t "$title" '[.[] | select(.title == $t)] | length')"
if [ "$existing" -gt 0 ]; then
echo "already open, skipping: $title"
continue
fi

echo "opening: $title"
gh issue create --title "$title" --body-file "$RUNNER_TEMP/body.md" --label "$label"
done

- name: Commit the updated baseline
run: |
set -euo pipefail
if git diff --quiet -- upstream-docs-state.json; then
echo "baseline unchanged"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add upstream-docs-state.json
git commit -m "chore(upstream-docs): update watched-document baseline [skip ci]"
git push
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ rule may be right and the driver wrong, or the check itself may be wrong.
- PV generation, meter export and battery/vehicle discharge are negative.
- Report stable hardware identity early.
- Do not emit stale cached telemetry as fresh.
- Record the vendor documents a driver was decoded from — register map,
parameter changelog, API reference — in the manifest's `upstream_docs`, at
the most durable URL available. A weekly watcher opens a tracking issue when
one changes or disappears, so a driver that has fallen behind its source is
caught there rather than by a wrong value on a customer's site. A document
behind a login cannot be watched; reference it in a driver comment instead.
See `docs/WRITING-A-DRIVER.md`.
- Keep Lua compatible with every runtime declared in the package recipe.
- Package id, version, read-only state and target metadata must match the Lua
`DRIVER` block.
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
## [Unreleased]

### Added
- **`upstream_docs`** — an optional manifest field (`url`, `title`, `kind`, `url_stability`) naming the vendor documents a driver was built against — a register map, a parameter changelog — at a semi-persistent URL. A driver decodes a device by following that document; when it moves upstream, the driver can fall behind and nobody notices for months. Recording the URL lets a watcher poll it and flag the driver for review. `url_stability` (`committed`/`stable`/`volatile`/`unknown`) records whether the manufacturer keeps the URL put, so a broken `stable` link reads as a real signal and a broken `volatile` one as routine. `nibe_local` and `myuplink` are the first to declare it, both pointing at NIBE's myUplink register changelog. `tools/validate_manifest.py` holds each entry to an `http(s)` URL and known `kind`/`url_stability` values; the field is descriptive metadata only and never reaches `index.yaml` or how a driver installs. Specified in `spec/manifest-v2.md` (V2.3), and `docs/WRITING-A-DRIVER.md` and `AGENTS.md` now tell a driver author to record it beside the older instruction to write down what they learned about the registers — where the knowledge came from is the half that was never asked for, and a field nobody is told to populate stays at two manifests out of 80. Both say what it cannot do: a document behind a login is not fetchable and belongs in a driver comment instead. Tested in `tests/test_upstream_docs.py`
- **`watch-upstream-docs`** — the automation half of `upstream_docs`. `tools/check_upstream_docs.py` fetches every watched URL, hashes it, and diffs it against a committed baseline (`upstream-docs-state.json`); the weekly `.github/workflows/watch-upstream-docs.yml` opens a tracking issue when a document changes (registers to review) or goes missing (link rotted), weighted by `url_stability`. It notifies **once** per event — a change updates the baseline so it can't re-fire, an outage alerts only on crossing a three-run failure threshold, and the workflow skips a title that already has an open issue. Detection is a raw-byte hash, so a flagged change is a prompt to look, not proof the registers moved — the issue says so. Tested in `tests/test_upstream_docs_watch.py`
- **The release's version rule now runs on every pull request, where it can still be acted on.** The signed channel refuses to republish changed artifact bytes under a version it has already published — the rule that makes a version name a set of bytes an operator can pin. Asking it needed the published channel, so it only ever ran from the release job on `main`, *after* the merge: #53 merged with five green checks and took the release down, and the repair (#55) had to be a second pull request against a `main` that was already broken. Nothing about the question needs the signing key, though — the published manifest is a release asset and its signature verifies with the public key — so `ftw_repository.py check-versions` builds the artifacts in memory, signs nothing, and answers exactly what the release would. The new `signed channel accepts this tree` job runs it on every pull request, including from forks, which need no secret to read a public asset. It reports **every** colliding driver rather than the first, each with the `make bump-driver` command that fixes it. `_version_collisions` is the single statement of the rule that both the release and this check read, and `test_check_versions_answers_what_the_release_would_answer` holds them to the same verdict on the same tree — a rule stated twice is a rule that drifts, and a check that drifts from the release is worse than no check. `test_check_versions_catches_a_manifest_only_edit` is #53 in the shape it arrived in: a manifest field corrected, no Lua touched, and the bytes moved anyway

### Fixed
Expand Down Expand Up @@ -110,6 +112,7 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html

### Changed
- The 19 drivers whose implementation was replaced by FTW's move to a **major version**. The signed channel refuses to publish changed bytes under a version it already published — that immutability is what makes rollback mean anything — so the release failed until they moved. A major bump is also the honest signal: these are different implementations, not patches, and an operator pinning a version needs to know that
- **nibe_local** manifest `author` is now `HuggeK with the help of Claude Code`, matching the driver's own `DRIVER.authors` and its real provenance rather than the `Sourceful Labs AB` default the V1→V2 migration stamped on every core manifest

### Fixed
- **sungrow** 1.4.0 — **The SG12RT outage.** Sungrow ships two families behind one driver: SH hybrids answer the 13xxx block, SG string inverters have no battery and answer none of it. The driver read that block regardless, and because the host fails a whole poll when any single read fails, a string inverter did not report less telemetry — it reported none. A customer's SG12RT lost everything, 12 of 19 reads failing on every poll. It now asks the device which family it is, using the classification `driver_fingerprint` already had, and reads the hybrid block only when there is reason to. Measured: **zero failed reads from the first poll**, where it was eight
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ LEVEL ?= patch
.PHONY: bootstrap new-driver test-driver package-driver check boundary \
refused-write-report absent-register-report \
sync-manifests bump-driver history ftw-baseline ftw-baseline-report \
host-api site
host-api site watch-upstream-docs

# Build the public driver catalog page into site/, exactly as GitHub Pages
# publishes it. Open site/index.html to review a change before it ships.
Expand Down Expand Up @@ -67,6 +67,12 @@ boundary:
sync-manifests:
uv run --frozen --extra package --extra dev python tools/sync_manifests.py

# Fetch every manifest upstream_docs URL and report changes against the
# baseline WITHOUT rewriting it. The scheduled watch-upstream-docs workflow
# runs the same tool for real and opens a tracking issue on a change.
watch-upstream-docs:
uv run --frozen --extra package --extra dev python tools/check_upstream_docs.py --dry-run

# Raise a driver version in the manifest and the DRIVER table together.
# Example: make bump-driver ID=sungrow LEVEL=patch
bump-driver:
Expand Down
45 changes: 45 additions & 0 deletions docs/WRITING-A-DRIVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ what, what scale it uses, which firmware lies, what the vendor documented
wrongly. **Write that down in comments as you find it.** Nobody can re-derive
it from the numbers later, and it is the reason this repository exists.

**Write down where you got it, too.** The register map, the parameter
changelog, the API reference you decoded the device from — record those in the
manifest so the driver can be checked against them again later. See
[Record what you decoded it from](#record-what-you-decoded-it-from).

## The five entry points

| Function | When |
Expand Down Expand Up @@ -223,6 +228,46 @@ Then write `manifests/mydevice.yaml`. The manifest and the driver's own
`DRIVER` table must agree on the version; `make bump-driver ID=mydevice` moves
both.

### Record what you decoded it from

A driver is only ever as current as the vendor document it was written from,
and that document does not hold still. A register renumbered, a parameter
added, a scale corrected in a new revision — none of it announces itself. The
driver goes on decoding the old map and reporting a plausible wrong number,
and it is a human noticing on a real site months later that ends it.

So record the documents you worked from, at the most durable URL you can find:

```yaml
upstream_docs:
- url: "https://www.nibe.eu/webdav/files/myuplink_changelog/nibe-n.pdf"
title: "NIBE S-series myUplink register/parameter changelog"
kind: changelog
url_stability: stable
```

Only `url` is required. `kind` is one of `changelog`, `register_map`,
`manual`, `api_docs`, `firmware_notes`, `other`. `url_stability` says how much
a broken link means — `committed` (the vendor promises the URL is permanent),
`stable` (stable in practice), `volatile` (dated or versioned links that
rotate), `unknown`. `spec/manifest-v2.md` has the full field reference.

`.github/workflows/watch-upstream-docs.yml` fetches each URL weekly and opens
a tracking issue when a document changes or disappears, so the driver gets
re-read against the new material rather than quietly falling behind. It is
descriptive metadata only: it never reaches `index.yaml` and changes nothing
about how the driver installs or runs. `make watch-upstream-docs` runs the
check locally without touching the baseline.

Two things it cannot do. A document behind a login or a vendor portal is not
fetchable — put that reference in a comment in the driver instead, where it
still tells the next reader where to look. And detection is a hash of the
bytes, so a rebuilt PDF can flag a change that isn't one; a flagged document
is a prompt to look, not proof the registers moved.

Only drivers that declare the field are watched. A driver with no entry is one
nobody will be warned about.

```bash
make test-driver ID=mydevice
make check
Expand Down
5 changes: 5 additions & 0 deletions manifests/myuplink.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ tested_devices:
firmware_versions: ""
notes: "Read-only heat-pump telemetry via MyUplink Cloud REST API v2: compressor power + hot-water/indoor/outdoor temperatures. Observe-only — no control. OAuth: authorization-code + refresh-token (connect in Settings → Devices)."
min_driver_version: "1.0.0"
upstream_docs:
- url: "https://www.nibe.eu/webdav/files/myuplink_changelog/nibe-n.pdf"
title: "NIBE myUplink register/parameter changelog (nibe-n.pdf)"
kind: changelog
url_stability: stable
min_host_version: "2.0.0"
size_bytes: 15278
dkb_id: "myuplink"
Expand Down
7 changes: 6 additions & 1 deletion manifests/nibe_local.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: "nibe_local"
version: "1.1.1"
tier: core
author: "Sourceful Labs AB"
author: "HuggeK with the help of Claude Code"
protocol: http
ders: [heatpump]
control: true
Expand All @@ -13,6 +13,11 @@ tested_devices:
firmware_versions: ""
notes: "Read-only NIBE S-series heat-pump telemetry over the on-prem Local REST API (HTTPS + Basic auth, self-signed cert pinned via tls_pin_sha256). Emits compressor/used power, lifetime energy meters, and the full ~980-point register map. Observe-only — no control."
min_driver_version: "1.0.0"
upstream_docs:
- url: "https://www.nibe.eu/webdav/files/myuplink_changelog/nibe-n.pdf"
title: "NIBE S-series myUplink register/parameter changelog (nibe-n.pdf)"
kind: changelog
url_stability: stable
min_host_version: "2.0.0"
size_bytes: 18227
dkb_id: "nibe_local"
Expand Down
46 changes: 46 additions & 0 deletions spec/manifest-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Each driver has a YAML manifest in `manifests/` that describes its metadata, cap
| `ders` | list | Yes | DER types: `pv`, `battery`, `meter`, `v2x_charger` |
| `control` | bool | Yes | Whether the driver supports EMS control commands |
| `tested_devices` | list | No | Devices tested against (see below) |
| `upstream_docs` | list | No | Vendor reference documents to watch for register/protocol changes (see below) |
| `min_host_version` | string | No | Minimum gateway firmware version required |
| `size_bytes` | int | Yes | Size of the `.lua` file in bytes |
| `dkb_id` | string | No | Corresponding Hugin DKB device profile ID |
Expand All @@ -40,6 +41,48 @@ The `tested_devices` list describes the device models a driver has been verified

A driver may list multiple `tested_devices` entries if it supports devices from different manufacturers or distinct model families.

## Upstream Docs

A driver decodes a device by following the vendor's own reference material: a
register map, a Modbus map, a parameter/changelog PDF, an API reference. When
that material changes upstream — a register renumbered, a parameter added — the
driver may silently fall out of date. `upstream_docs` records those documents at
a **semi-persistent URL** so a watcher can poll them and flag the driver for
review when the source moves, instead of a human noticing months later.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | Yes | Semi-persistent `http(s)` URL of the document to watch |
| `title` | string | No | Human-readable label (e.g., `"NIBE S-series register changelog"`) |
| `kind` | string | No | One of: `changelog`, `register_map`, `manual`, `api_docs`, `firmware_notes`, `other` |
| `url_stability` | string | No | How durable the URL is: `committed`, `stable`, `volatile`, `unknown` (default) |

`url_stability` records whether the URL can be trusted to stay put:

- `committed` — the manufacturer documents or promises the URL is permanent;
- `stable` — stable in practice, with no explicit promise;
- `volatile` — known to rotate (dated or versioned links);
- `unknown` — not assessed (also the default when the field is absent).

The watcher uses it to weigh a broken link: a `committed` or `stable` URL going
missing is a real signal that the document moved and the driver may be following
a source that no longer exists, whereas a `volatile` one breaking can be routine.

A driver may list multiple `upstream_docs` entries — for example a register map
and a separate changelog. The field is descriptive metadata only: it is not
copied into `index.yaml` and never affects how a driver is installed or run.
It is consumed by `tools/check_upstream_docs.py` and the `watch-upstream-docs`
workflow, which fetch each URL, diff it against `upstream-docs-state.json`, and
open a tracking issue when a watched document changes or disappears.

```yaml
upstream_docs:
- url: "https://www.nibe.eu/webdav/files/myuplink_changelog/nibe-n.pdf"
title: "NIBE S-series myUplink register/parameter changelog (nibe-n.pdf)"
kind: changelog
url_stability: stable
```

## Example

```yaml
Expand Down Expand Up @@ -88,6 +131,7 @@ Version changes must be accompanied by a `CHANGELOG.md` entry under `[Unreleased
7. `core` tier drivers must have an `author`
8. Every manifest must have a corresponding `.lua` file in `drivers/`
9. `tested_devices` entries must have `manufacturer` and `model_family`
10. `upstream_docs` entries must have an `http(s)` `url`; `kind` and `url_stability`, when present, must be known values

## Migration from V1

Expand All @@ -99,4 +143,6 @@ V2.1 extends `tested_devices` with: `model_family` (replaces `model`), `variants

V2.2 adds bytecode fields: `bytecode_sha256`, `bytecode_signature`, `bytecode_size` for Lua 5.5.0 compiled bytecode.

V2.3 adds `upstream_docs`: an optional list of vendor reference documents (`url`, `title`, `kind`, `url_stability`) to watch for register/protocol changes.

Use `tools/migrate_manifests.py` to convert V1 JSON → V2 YAML.
Loading