Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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 All @@ -25,6 +26,8 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
### Added
- **`blueprint/BLUEPRINT.lua`** — a complete, working driver for an imaginary inverter, written for people and agents alike. Every rule this repository enforces appears beside the code that follows it: bounded probing, never fabricating a zero, decoding on 16-bit halves, negating at the sign boundary. `tests/test_blueprint.py` holds it to every rule a shipped driver must meet and runs it against the harness, including the test that matters most — that failed reads per poll settle to zero, because a driver that never gives up takes the site offline
- **`docs/WRITING-A-DRIVER.md`** — the reasoning behind each rule, with the blueprint as the specification
- **`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. Documented in `spec/manifest-v2.md` (V2.3); 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`

### Removed
- `tools/canonical_debt.py` measured distance from an ideal that never existed. In its place `tools/host_api_check.py` asks the only question that predicts a crash: does a driver call a function no host provides? That is what 35 drivers were doing while passing every test here
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ LEVEL ?= patch

.PHONY: bootstrap new-driver test-driver package-driver check boundary \
sync-manifests bump-driver history ftw-baseline ftw-baseline-report \
host-api
host-api watch-upstream-docs

# Does any driver call a host function no host provides?
host-api:
Expand Down Expand Up @@ -48,6 +48,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
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.0"
tier: core
author: "Sourceful Labs AB"
author: "HuggeK with the help of Claude Code"
protocol: http
ders: [meter]
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.
162 changes: 162 additions & 0 deletions tests/test_upstream_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""upstream_docs: the vendor documents a driver is watched against.

A driver decodes a device by following the vendor's own register map or
changelog. When that document moves upstream, the driver can fall behind
without anything noticing. `upstream_docs` records those documents at a
semi-persistent URL so a watcher can poll them and flag the driver for review.

These tests hold the field to its contract and check that the two NIBE drivers
actually declare the changelog they were built against.
"""

from __future__ import annotations

import sys
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))

from manifest_parser import parse_tested_devices, parse_upstream_docs # noqa: E402
from validate_manifest import validate_manifest # noqa: E402

MANIFEST_DIR = ROOT / "manifests"
NIBE_CHANGELOG = "https://www.nibe.eu/webdav/files/myuplink_changelog/nibe-n.pdf"
NIBE_IDS = ["nibe_local", "myuplink"]


# ---- Parser -------------------------------------------------------------


def test_absent_block_parses_as_empty():
assert parse_upstream_docs("name: x\nversion: 1.0.0\n") == []


def test_empty_inline_list_parses_as_empty():
assert parse_upstream_docs("upstream_docs: []\nmin_host_version: 2.0.0\n") == []


def test_block_parses_url_title_kind():
text = (
"tested_devices:\n"
' - manufacturer: "NIBE"\n'
' model_family: "S"\n'
"upstream_docs:\n"
' - url: "https://example.com/a.pdf"\n'
' title: "A"\n'
" kind: changelog\n"
' - url: "https://example.com/b.pdf"\n'
" kind: register_map\n"
'min_host_version: "2.0.0"\n'
)
assert parse_upstream_docs(text) == [
{"url": "https://example.com/a.pdf", "title": "A", "kind": "changelog"},
{"url": "https://example.com/b.pdf", "kind": "register_map"},
]


def test_block_does_not_bleed_into_tested_devices():
"""The two adjacent blocks must not consume each other's lines."""
text = (
"tested_devices:\n"
' - manufacturer: "NIBE"\n'
' model_family: "S"\n'
"upstream_docs:\n"
' - url: "https://example.com/a.pdf"\n'
' title: "A"\n'
'min_host_version: "2.0.0"\n'
)
assert parse_tested_devices(text) == [{"manufacturer": "NIBE", "model_family": "S"}]
assert parse_upstream_docs(text) == [
{"url": "https://example.com/a.pdf", "title": "A"}
]


# ---- Validation ---------------------------------------------------------


def _errors_for(tmp_path: Path, upstream_block: str) -> list[str]:
body = (
'name: "nibe_local"\n'
'version: "1.0.0"\n'
"tier: community\n"
"protocol: http\n"
"ders: [meter]\n"
"size_bytes: 1\n"
f"{upstream_block}"
)
path = tmp_path / "nibe_local.yaml"
path.write_text(body, encoding="utf-8")
return validate_manifest(path, tmp_path / "nonexistent")


def test_url_is_required(tmp_path):
errors = _errors_for(tmp_path, 'upstream_docs:\n - title: "no url"\n')
assert any("upstream_docs[0]: missing required field 'url'" in e for e in errors)


def test_non_http_url_is_rejected(tmp_path):
errors = _errors_for(tmp_path, 'upstream_docs:\n - url: "ftp://example.com/a.pdf"\n')
assert any("upstream_docs[0]: url must be an http(s) URL" in e for e in errors)


def test_unknown_kind_is_rejected(tmp_path):
errors = _errors_for(
tmp_path, 'upstream_docs:\n - url: "https://x/a.pdf"\n kind: bogus\n'
)
assert any("upstream_docs[0]: kind 'bogus' is not valid" in e for e in errors)


def test_unknown_field_is_rejected(tmp_path):
errors = _errors_for(
tmp_path, 'upstream_docs:\n - url: "https://x/a.pdf"\n foo: bar\n'
)
assert any("upstream_docs[0]: unknown field 'foo'" in e for e in errors)


def test_unknown_url_stability_is_rejected(tmp_path):
errors = _errors_for(
tmp_path,
'upstream_docs:\n - url: "https://x/a.pdf"\n url_stability: rock_solid\n',
)
assert any("upstream_docs[0]: url_stability 'rock_solid' is not valid" in e
for e in errors)


def test_known_url_stability_is_accepted(tmp_path):
errors = _errors_for(
tmp_path,
'upstream_docs:\n - url: "https://x/a.pdf"\n url_stability: committed\n',
)
assert not [e for e in errors if e.startswith("upstream_docs")]


def test_wellformed_entry_raises_no_upstream_error(tmp_path):
errors = _errors_for(
tmp_path,
'upstream_docs:\n - url: "https://x/a.pdf"\n title: "A"\n kind: changelog\n',
)
assert not [e for e in errors if e.startswith("upstream_docs")]


# ---- The shipped manifests ---------------------------------------------


@pytest.mark.parametrize("driver_id", NIBE_IDS)
def test_nibe_drivers_watch_the_changelog(driver_id):
text = (MANIFEST_DIR / f"{driver_id}.yaml").read_text(encoding="utf-8")
docs = parse_upstream_docs(text)
assert docs, f"{driver_id} declares no upstream_docs"
assert NIBE_CHANGELOG in [d.get("url") for d in docs]
assert all(d.get("kind") == "changelog" for d in docs)


def test_every_declared_upstream_doc_is_wellformed():
for path in sorted(MANIFEST_DIR.glob("*.yaml")):
for i, doc in enumerate(parse_upstream_docs(path.read_text(encoding="utf-8"))):
url = doc.get("url", "")
assert url.startswith(("http://", "https://")), f"{path.name}[{i}]: bad url"
allowed = {"url", "title", "kind", "url_stability"}
assert set(doc) <= allowed, f"{path.name}[{i}]: unknown field"
Loading