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
56 changes: 56 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: pages

# The catalog page is built from the commit it is published from, never from a
# checked-in copy. There is no way for the page to fall behind main, and no
# generated HTML in the repository to review.

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

# One deployment at a time, and never cancel one half-done: an interrupted
# deploy would leave the published catalog in an unknown state.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
# configure-pages turns Pages on for this repository the first time it
# runs, so publishing needs no manual repository-settings change.
pages: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
python-version: "3.12"
- run: uv sync --frozen --extra package --extra dev
- name: Check the page against the manifests before publishing it
run: uv run --no-sync pytest -q tests/test_site.py
- run: uv run --no-sync python tools/generate_site.py --output site
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
with:
enablement: true
- uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with:
path: site

deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/.artifacts/
/.venv/
/site/
/lua55
/luac55
/.pytest_cache/
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html

## [Unreleased]

### Added
- **A published driver catalog at [srcfl.github.io/device-drivers](https://srcfl.github.io/device-drivers/)** — all 80 drivers, searchable by manufacturer or model number and filterable by device type, protocol, tier and control. `tools/generate_site.py` builds it from the manifests and the Lua sources on every push to `main`, so no copy of this data is checked in and the page cannot fall behind what a host installs. It reports hardware evidence the way the repository does rather than the way a catalog usually does: 5 drivers state they have been confirmed against physical hardware and 28 state that they have not, and `tests/test_site.py` fails the build if a driver's `verification_status` is ever rendered as a stronger claim than the driver makes

### Added
- **The flap sungrow 1.5.7 fixed is measured across the catalog, and it is not one driver — it is eleven.** `drivers/tests/refused_write_probe.lua` refuses every write a device is asked to take, then calls `driver_default_mode` eight times and counts what the driver does about it. That path runs on a timer — lease expiry, the telemetry watchdog, the stale-meter standdown, shutdown — and nothing reaching it can say no, so a driver that writes whatever the device answers keeps writing for the life of the session. **atmoce, deye, ferroamp_modbus, huawei, pixii, sigenergy, solaredge, solaredge_legacy, solaredge_pv, solinteg and solis** all reissue the same refused write on every call and never stop. sungrow is the only one that settles, and only because a customer's SG12RT forced it
- `drivers/tests/test_refused_write_settles.py` ratchets against `refused-write-baseline.json`, the way #36 did for reads: a count that rises fails, a driver not listed must be clean, and a count that falls fails until the file is updated, so the debt cannot be quietly re-borrowed. `make refused-write-report ID=<id>` prints it for one driver
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ 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
host-api site

# 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.
site:
uv run --frozen --extra package --extra dev python tools/generate_site.py --output site

# Does any driver call a host function no host provides?
host-api:
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ Device Support may later consume an exact public commit to build packages for
other products or a higher support level. It does not own a second editable
copy of the source and is not FTW's normal driver source.

## Browse the catalog

**[Every driver, and what stands behind it →](https://srcfl.github.io/device-drivers/)**

The catalog page is generated from this repository and republished on every push
to `main`, so it cannot fall behind the source. The manifests supply versions,
tiers, protocols and tested models; each Lua source supplies its own description
and verification record. Search by manufacturer or model number, filter by device
type or protocol, or take the whole catalog as
[drivers.json](https://srcfl.github.io/device-drivers/drivers.json).

Being listed is not an install claim. The page states the same evidence the
repository does, including how few drivers have been confirmed against physical
hardware.

## Contribute a driver

Start from **[`blueprint/BLUEPRINT.lua`](blueprint/BLUEPRINT.lua)**. It is a
Expand Down
127 changes: 127 additions & 0 deletions tests/test_site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""The catalog page must not be able to say something the repository doesn't.

The page is the first thing most people see about these drivers, and it is the
one artefact where a stale number would be invisible: nobody diffs a rendered
page against 80 manifests. So the generator is held to the same rule as the
other generated files here — it may only restate what the repository already
says, and it must restate all of it.

The claim that matters most is hardware coverage. Five drivers say they have
run on real hardware and 28 say they have not; a page that blurred that
distinction would be worse than no page.
"""

from __future__ import annotations

import json
import re
import sys
from pathlib import Path

import pytest

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

import generate_site # noqa: E402
from manifest_parser import parse_yaml_simple # noqa: E402


@pytest.fixture(scope="module")
def catalog() -> dict:
return generate_site.build_catalog()


@pytest.fixture(scope="module")
def rendered(tmp_path_factory) -> Path:
out = tmp_path_factory.mktemp("site")
generate_site.write_site(out)
return out


def manifest_ids() -> set[str]:
return {path.stem for path in (ROOT / "manifests").glob("*.yaml")}


def test_every_manifest_reaches_the_page(catalog: dict) -> None:
"""A driver that exists but is missing from the catalog is invisible."""
listed = {driver["id"] for driver in catalog["drivers"]}
assert listed == manifest_ids(), (
"the page and manifests/ disagree about which drivers exist: "
f"{listed ^ manifest_ids()}")


def test_versions_match_the_manifests(catalog: dict) -> None:
"""The page states an installable version; it has to be the real one."""
for driver in catalog["drivers"]:
manifest = parse_yaml_simple(
(ROOT / "manifests" / f"{driver['id']}.yaml").read_text(encoding="utf-8"))
assert driver["version"] == manifest.get("version"), driver["id"]
assert driver["tier"] == manifest.get("tier", "community"), driver["id"]
assert driver["control"] == bool(manifest.get("control", False)), driver["id"]


def test_hardware_claims_come_only_from_the_driver_source(catalog: dict) -> None:
"""`hardware_verified` may only be true for a status the driver itself sets.

Every other status — experimental, beta, alpha — describes a driver that has
not been confirmed against the device, and must not be shown as if it had.
"""
for driver in catalog["drivers"]:
verification = driver["verification"]
if driver["hardware_verified"]:
assert verification is not None, driver["id"]
assert verification["status"] in generate_site.HARDWARE_VERIFIED, driver["id"]
assert verification["level"] == "confirmed", driver["id"]
elif verification:
assert verification["level"] != "confirmed", (
f"{driver['id']} is shown as confirmed but its source says "
f"{verification['status']}")


def test_every_verification_status_has_wording(catalog: dict) -> None:
"""An unmapped status would reach the page as a bare enum value."""
for driver in catalog["drivers"]:
verification = driver["verification"]
if verification:
assert verification["status"] in generate_site.VERIFICATION, (
f"{driver['id']}: verification_status "
f"{verification['status']!r} has no wording in VERIFICATION")


def test_tested_devices_survive_intact(catalog: dict) -> None:
"""The model list is the reason to visit the page; it must be complete."""
total = sum(len(d["tested_devices"]) for d in catalog["drivers"])
assert total >= 170, f"only {total} tested-device entries reached the page"
for driver in catalog["drivers"]:
for device in driver["tested_devices"]:
assert device["manufacturer"], driver["id"]
assert device["model_family"], driver["id"]


def test_every_driver_says_something(catalog: dict) -> None:
"""A row with no prose tells a reader nothing the id didn't already."""
silent = [d["id"] for d in catalog["drivers"]
if not d["description"] and not d["source_note"]]
assert not silent, f"no description and no source note: {silent}"


def test_page_is_written_and_self_consistent(rendered: Path) -> None:
"""The embedded payload has to survive being inlined into a script tag."""
assert (rendered / ".nojekyll").exists()
sidecar = json.loads((rendered / "drivers.json").read_text(encoding="utf-8"))

html = (rendered / "index.html").read_text(encoding="utf-8")
match = re.search(r"window\.__CATALOG__ = (\{.*?\});</script>", html, re.DOTALL)
assert match, "the page no longer embeds a catalog the browser can read"
assert "</script>" not in match.group(1), "payload can close its own script tag"
embedded = json.loads(match.group(1))
assert embedded == sidecar, "the page and drivers.json disagree"


def test_page_lists_every_driver_without_javascript(rendered: Path) -> None:
"""Search engines and reader modes never run the filter."""
html = (rendered / "index.html").read_text(encoding="utf-8")
noscript = html[html.index("<noscript>"):html.index("</noscript>")]
for driver_id in sorted(manifest_ids()):
assert driver_id in noscript, f"{driver_id} is missing from the noscript list"
Loading