diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a8e17d5..0a310ef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,15 @@ version: 2 updates: - - package-ecosystem: "pip" + # uv, not pip: pip's updater bumps pyproject.toml and leaves uv.lock behind, and + # the tests workflow syncs with --locked, so every such PR would fail. uv's + # updater moves both together. + - package-ecosystem: "uv" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 1 ignore: - # `ebus-panel-sim` 0.8.0 declares `ebus-sdk<0.23,>=0.20.1`, so anything from + # `ebus-panel-sim` 0.9.0 declares `ebus-sdk<0.23,>=0.20.1`, so anything from # 0.23 up makes `pip install .` unresolvable and breaks the add-on image # build — which is the only job that installs from `pyproject.toml`, so lint # passes and the failure lands on `main` after the merge. Lift this when the diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 0e52978..1325617 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -18,9 +18,11 @@ jobs: steps: - uses: actions/checkout@v7 + # requires-python is >=3.14. The hooks run in environments of their own, which + # is why 3.13 worked, but nothing should depend on that. - uses: actions/setup-python@v7 with: - python-version: "3.13" + python-version: "3.14" - name: Install pre-commit run: pip install pre-commit diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..f304b59 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,41 @@ +name: Tests + +# Nothing ran the test suite in CI before this: `lint.yaml` runs pre-commit, whose +# mypy hook covers `src/panelbench` and not the tests. A regression in code the +# suite pins, the broker link above all, would have shipped green. +# +# mosquitto comes from apt so the real-broker tests in tests/integration run here. +# They skip where mosquitto is missing, which is right on a laptop and wrong here: +# PANELBENCH_REQUIRE_MOSQUITTO turns that skip into a failure, so a runner image +# that loses the package cannot pass by testing less. +# +# --locked, not --frozen: --frozen installs whatever uv.lock says even when +# pyproject.toml has moved on, so a dependency bound changed without a re-lock +# would be tested against the old resolution and pass. --locked fails instead. + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + # The interpreter comes from .python-version, which matches requires-python. + - uses: astral-sh/setup-uv@v6 + + - name: Install mosquitto + run: sudo apt-get update && sudo apt-get install -y mosquitto + + - name: Install dependencies + run: uv sync --locked --all-groups + + - name: Run tests + env: + PANELBENCH_REQUIRE_MOSQUITTO: "1" + run: uv run --locked pytest -q -rs diff --git a/.gitignore b/.gitignore index c80428a..28470f6 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ configs/*_history.db configs/*.yaml !configs/default_*.yaml +# Source panels' passphrases and broker credentials (panel_secrets.py) +configs/.secrets/ + # Local-developer source overrides + env. See .env.example and uv.toml.example. .env uv.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 06a1efc..1b395f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,6 @@ +# Upstream's data, vendored byte-identical; test_vendored_example_matches_the_pinned_release +# compares the bytes with the pinned release, so no hook may rewrite them. +exclude: ^tests/fidelity/fixtures/upstream/.*\.(ya?ml|json)$ repos: - repo: local hooks: @@ -37,9 +40,10 @@ repos: # released versions from PyPI rather than a git ref: `ebus-panel-sim` used to # track the fork's moving `spanpanel/main` branch, so this hook could resolve a # different build on two consecutive runs with nothing in the repo changing. - - ebus-panel-sim==0.8.0 + - ebus-panel-sim==0.9.0 - ebus-sdk==0.22.0 - - aiomqtt>=2.0.0 + - aiomqtt>=2.5.1,<2.6 + - paho-mqtt>=2.1,<2.2 - aiohttp>=3.9.0 - aiohttp-jinja2>=1.6 - jinja2>=3.1.0 diff --git a/.prettierignore b/.prettierignore index 2cb3c32..8b871c6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,11 +8,11 @@ build/ .pytest_cache/ # Byte-vendored from the eBus emitter as the fidelity reference. PROVENANCE.md makes -# byte-identity with upstream's own files the property that gives them their value, and -# `test_vendored_reference_matches_upstream` asserts it, so a formatter run here breaks the -# comparison and turns every re-sync into a merge conflict. `pyproject.toml` already excludes -# the directory from ruff for the same reason; Prettier was the half nobody had covered, and -# `scripts/fix-markdown.sh` rewrote PROVENANCE.md on its next run. +# byte-identity with the pinned release's own files the property that gives them their value, +# and `test_vendored_example_matches_the_pinned_release` asserts it, so a formatter run here +# breaks the comparison and turns every re-sync into a merge conflict. `.pre-commit-config.yaml` +# excludes the vendored files from every hook for the same reason; Prettier is the half it does +# not cover, and `scripts/fix-markdown.sh` once rewrote PROVENANCE.md on its next run. tests/fidelity/fixtures/upstream/ # Match .gitignore: unpublished planning docs diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c38a5..c9cdb2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,106 @@ # Changelog +## 2.6.0 — SPAN firmware release 202639 + +**The shipped templates now publish SPAN firmware release 202639's conventions, and a config naming a 202633 release keeps that release's.** The add-on +refreshes its shipped templates on every start, so an upgraded install's templates publish release 202639's. Use this release with SPAN integration 2.1.2 or +later, since against release 202639 integration 2.1.1 shows the battery's Meter Power with its sign flipped. A config or clone whose `firmware_version` names +no SPAN release publishes release 202639's conventions too, so it changes on upgrade, and that includes a clone of the earlier MAIN 40 template, which named +`sim/v0.1.0`. To keep the earlier conventions, set its `firmware_version` to a 202633 release such as `spanos3/r202633/02`, or, for a clone of a panel, to the +source panel's firmware; a clone of one of these templates also needs `commissioned_system` removed from its solar template, which an earlier release refuses. + +### Added + +- **The dashboard can set the health of the panel's link to its battery**, which is what a SPAN panel requires before it honours Home Assistant's dominant power + source control. +- **Commissioned PV and battery system circuits**, locked as SPAN firmware release 202639 locks them, are modelled and kept by a clone, which recognises one by + its locks and the inverter or battery it feeds whatever it is named, and a config naming an earlier release is refused if it asks for one. +- **A clone of a panel keeps the panel's time zone and line voltages**, and its time zone wins over one derived from Home Assistant's home location. +- **The log warns when a clone gives the main breaker a default rating** because the panel publishes none. +- **The dashboard's Clone accepts broker credentials you already hold for a panel in place of its passphrase**, and then registers nothing with the panel. +- **The log warns about a solar circuit on a breaker whose template lacks `commissioned_system: pv` when the config's `firmware_version` names SPAN release + 202639 or later, or no SPAN release**, since that release locks such a circuit, though the config still loads. +- **The log warns about a solar circuit on a single tab**, since a grid-tied inverter is 240 V on a two-pole breaker with two tabs on opposite legs, though the + config still loads. +- **From SPAN firmware release 202639, a panel's status endpoint reports its hardware version**, as SPAN's does: `1.2` or `2.0` from the config's + `hardware_version`, and `UNKNOWN` for any other value. +- **The dashboard's Import accepts a panel definition file**, such as one `panel-sim-capture` wrote from a live panel, and turns it into a PanelBench config. +- **The dashboard can export a saved config as a panel definition file**, the makeup other eBus tools read, without PanelBench's behaviour settings. + +### Changed + +- **Home Assistant's dominant power source control is now ignored while the panel's link to its battery is healthy**, as a SPAN panel ignores it. +- **The battery's own power reading is now positive while discharging**, as SPAN firmware r202639 publishes it, unless the panel's firmware version names an + earlier release. +- **A SPAN Drive's user charge limit is no longer published until a user sets one**, as on SPAN firmware r202639, unless the panel's firmware version names an + earlier release. +- **The shipped templates now report firmware `spanos3/r202639/03` and publish SPAN release 202639's conventions**, as a SPAN panel after that upgrade + does, and a config naming a 202633 release, such as a clone of a template set to `spanos3/r202633/02`, keeps that release's conventions. +- **The shipped templates' solar circuit is now named "Commissioned PV System", as SPAN firmware r202639 names it**, and locked as that release locks it, with + its relay not switchable and its priority fixed at `NEVER`, where it was named "Solar Inverter". +- **A panel without a battery no longer publishes load-shed state**, as the eBus specification requires. +- **A config with two or more solar circuits now publishes each inverter as its own solar device, under new device ids**, unless its `firmware_version` names a + SPAN release before 202639, as SPAN firmware does from that release, and a clone keeps every inverter. +- **The top-level `pv` section now describes the inverter whose circuit `pv.feed` names by its `id`, or the only solar circuit's, where it followed whichever + solar circuit was listed first**; a `pv.feed` that names no solar circuit's `id` is now refused, as is a config with several solar circuits and no `pv.feed` + whose `firmware_version` names a release before 202639. +- **A solar rating set in the dashboard now replaces the stale copy a clone of the shipped MAIN 40 or MAIN 32 template kept**, at the template's top level or in + the solar circuit's `overrides`, and the log warns with both values and the file until the config is saved in the dashboard. +- **A `pv.nameplate_capacity_w`, or a circuit's `overrides` rating on a template other circuits share, that disagrees with the template's `energy_profile` + rating is now refused, naming both**, where the two could each win somewhere different; an equal or lone legacy rating moves into the template's + `energy_profile`. +- **Every solar device, battery, battery MID and set of lugs is now named after its own device id**, as SPAN firmware release 202639 names them, where they + were named "Solar", "Battery", "Microgrid Interconnect Device" and "Upstream lugs"; a circuit keeps its own name. +- **The shipped configs, and any config that sets no `hardware_version`, now report hardware version `1.2`**, as a SPAN panel publishes it, where they reported + `rev2`; a config copied from an earlier template keeps its `rev2`, which the status endpoint reports as `UNKNOWN` from release 202639. +- **A new clone of a panel whose battery publishes a serial number identifies that battery by its serial, as the source panel does**, so re-cloning such a panel + gives its battery, and the MID whose id follows it as `-mid`, a new device id, while existing clone configs are unchanged. + +### Fixed + +- **Commands from Home Assistant now take effect**, such as a circuit's relay or shed priority, where PanelBench subscribed to them and then never read them. +- **PanelBench reconnects after its MQTT broker restarts or stops answering, and publishes its panels again**, where it stayed disconnected, or on a broker + that had stopped answering, until it was restarted itself, and logged an error for every topic it could not publish. +- **A config naming the serial of a panel already running is refused, naming the config that runs it**, where both started, the second silently taking over + the first's broker connection and topics. +- **A panel that fails as it starts no longer leaves its broker connection open**, where it stayed open under the panel's identity until the panel started + again. +- **Cloning or syncing a panel connects to its broker at the address you gave**, where it dialled the panel's `.local` name, which does not resolve across + subnets, and reported a panel with no circuits. +- **A panel's solar output no longer changes when PanelBench restarts**, where each start gave the same panel different weather. +- **PanelBench registers with a panel once and reuses the credentials it is given for every later clone, sync and restore**, registering again only if the + panel's broker refuses them, where each one left another client on the panel for its owner to remove. +- **A panel's passphrase is no longer written into its clone's config, an export of it, or a text field**, but kept with the panel's broker credentials in a + file only PanelBench reads, and a clone that still carries one gives it up the next time PanelBench reads it. +- **A clone takes its size from the panel's model**, where a panel whose highest spaces were empty was cloned as a smaller one. +- **A clone's energy readings carry on from its panel's**, where they started at zero. +- **A clone keeps its panel's PCS priorities, vendor name, model, off-grid shed threshold and battery MID identity**, where it gave PanelBench's own. +- **A clone keeps its panel's network links and Wi-Fi network or the absence of one**, where a panel on Ethernet cloned as one on Wi-Fi with PanelBench's own + network. +- **A clone's battery charges and discharges at the battery's rated power**, or 5 kW per 13.5 kWh where the battery publishes none, where every battery was + held to 3.5 kW. +- **A clone records a breaker rating its panel does not publish as absent**, where it wrote 20 A into the config. +- **Restoring a circuit from a panel that cannot be reached says why**, where it left the circuit as it was without a word. +- **An upgraded add-on now refreshes its shipped `default_*` templates on every start**, where it kept the copies an earlier release laid down and published + whatever firmware they named. +- **The dashboard shows a panel as islandable when any of its solar inverters is hybrid**, where only the first solar circuit listed decided it. +- **A panel's HTTP status, Homie schema endpoint and mDNS advertisement now report the same firmware version it publishes over MQTT.** +- **A cloned panel keeps the source panel's firmware version**, so a clone of a panel on a SPAN release before 202639 keeps that release's battery power sign + and SPAN Drive charge limit. +- **A cloned panel keeps each solar inverter's vendor, model, serial number and firmware version.** +- **A cloned panel keeps its battery's model and each SPAN Drive's serial number and firmware version.** +- **A cloned panel keeps the source panel's hardware version.** +- **A cloned panel keeps the source panel's name** instead of the generic "Span Panel". +- **An imported config counts as unsaved until it is saved**, so switching panels asks before discarding it. +- **The dashboard now fetches a read-only template's weather**, where a script error on its missing location search stopped it. +- **The dashboard refuses an edit the panel would not load, says why and keeps the config as it was, and starts when the active config is one the panel + refuses**, where such an edit was saved and the panel then failed to restart, and an add-on restart with it active left no dashboard to fix it from. +- **The dashboard's Add Entity places a new circuit on the first free space**, where it made a circuit with no space, which the panel refused. +- **The dashboard's relay control shows a locked relay however its config spells it**, where a circuit written `non-controllable`, as the shipped configs write + it, showed `controllable` and saving its form unlocked it. +- **A solar inverter's nameplate edited in the dashboard now survives a restart and is the rating the panel both publishes and produces at**, including in a + clone of the shipped MAIN 40 or MAIN 32 template, where the template's stale copy of the rating replaced the edit when the panel started. + ## 2.5.3 — two simulated panels no longer publish over each other **A simulated panel already added to Home Assistant has to be removed and re-added**, because its circuits get new device ids and so its circuit entities get diff --git a/DEVELOPER.md b/DEVELOPER.md index f3e054f..f1659ed 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -230,6 +230,34 @@ unit, and the conformance checker rejects one that reaches the wire. The `conformance` package deliberately imports nothing from the rest of this simulator and nothing MQTT-related — a test enforces it. That keeps it a general eBus publisher checker rather than a SPAN-specific one, and it is why the rules describe _any_ publisher's output rather than just ours. +### Fidelity: the same panel through the reference emitter + +Conformance asks whether what PanelBench publishes is legal, and the specification permits omission, so it cannot ask whether PanelBench publishes what a panel +does. `tests/fidelity/` asks that. Each cell hands one panel to the pinned `ebus-panel-sim` release, the reference, and to PanelBench, and compares what the two +publish: + +| Cell | Reference (`ebus-panel-sim`) | PanelBench | +| ------------ | -------------------------------------------------------------- | --------------------------------------------- | +| `example` | the release's example definition and ticks | its import of that definition, run as a clone | +| `panelbench` | the release's capture reading PanelBench's published tree | `configs/default_MAIN_40.yaml` | +| `captured` | the release's reading of a real panel's capture on SPAN 202639 | its import of that definition, run as a clone | + +Every cell is at full structural parity and asserts it directly, and both producers must agree on what is settable. Identity values, device ids and the +properties both producers declare but neither values are held to baselines in `tests/fidelity/fixtures/`, so any movement fails. Devices are aligned by where +they hang rather than by name, because real firmware names a battery, MID or inverter after its own device id: a PV inverter, an in-panel battery or a SPAN +Drive by the circuit that feeds it, an upstream battery by the lugs it feeds, and a MID by its battery. The module docstring of +`tests/fidelity/test_upstream_parity.py` describes each cell and what it cannot see. + +The reference data is the release's own, vendored byte-identical under `tests/fidelity/fixtures/upstream/` (see its `PROVENANCE.md`). No upstream code is +copied: reading a definition or a capture is the pinned package's public API. `test_vendored_example_matches_the_pinned_release` compares the vendored bytes +with the release's tag in a local checkout of the upstream repository, and skips without one: + +```bash +EBUS_EMITTER_CHECKOUT=/path/to/distribution-enclosure-simulator uv run pytest tests/fidelity +``` + +When the pin moves, re-copy every vendored file from the new tag and re-review every fidelity baseline. + ## Running Locally There are three ways to run the simulator locally, depending on what you're testing: @@ -324,6 +352,13 @@ panel_config: longitude: float # Degrees east (default: -122.4) time_zone: str # IANA timezone (default: resolved from lat/lon) soc_shed_threshold: float # SOC % for SOC_THRESHOLD shedding (default: 20) + vendor_name: str # Published as info/vendor-name (default: Span); a clone keeps its panel's + model: str # Published as info/model (default: the model total_tabs names); a clone keeps its panel's + # The panel's network configuration, as a clone keeps it from its panel (default: links up). Its door + # and cloud state are live state, always published as door CLOSED and cloud CONNECTED. + wifi_link: bool # status/wifi + ethernet_link: bool # status/ethernet + wifi_ssid: str | null # status/wifi-ssid (default: sim-wifi while Wi-Fi is up); null publishes none circuit_templates: # Reusable template definitions template_name: @@ -333,10 +368,14 @@ circuit_templates: # Reusable template definitions typical_power: float # Base power in watts power_variation: float # Fraction (0.1 = +/-10%) efficiency: float # 0.0-1.0 (optional, PV/battery) - nameplate_capacity_w: float # PV nameplate rating in watts + nameplate_capacity_w: + float # The circuit's rating in watts, and its one source. A rating at the template's top level, in a + # circuit's overrides or in the pv section is moved here. Where this one is stated and another disagrees, + # a top-level or override copy is dropped with a warning (released templates left them stale beside a + # dashboard edit), and a pv section rating is refused. initial_consumed_energy_wh: float # Seed consumed energy (from clone) initial_produced_energy_wh: float # Seed produced energy (from clone) - relay_behavior: str # "controllable" | "non_controllable" + relay_behavior: str # "controllable" | "non-controllable" | "always-on" (underscore spellings are read the same) priority: str # "NEVER" | "SOC_THRESHOLD" | "OFF_GRID" never_backup: bool # Commissioning lock: no backup power, so permanently OFF_GRID @@ -344,8 +383,15 @@ circuit_templates: # Reusable template definitions # priority: OFF_GRID; the emitter rejects any other pairing. # Independent of relay_behavior, and NOT priority == "NEVER", # which is an ordinary settable value meaning "never shed". + commissioned_system: + str # "pv" | "backup": the circuit a SPAN panel adds for a commissioned + # system. Locked relay and priority permanently NEVER, neither settable, so it + # requires priority: NEVER and relay_behavior: non-controllable, and a + # firmware_version naming release 202639 or later, or none. A clone sets it. device_type: str # "circuit" | "evse" | "pv" (default: "circuit") - breaker_rating: int # Amps (derived from power_range if not set) + breaker_rating: + int | null # Amps (default: 20). null records a panel that publishes no rating, as a clone writes + # it. The pinned emitter still requires one and publishes 20 for it until it accepts the absence. # Optional behavioral modules cycling_pattern: @@ -395,25 +441,72 @@ circuits: template: str # References a circuit_templates key tabs: [int] # Tab positions ([1] = 120V, [1, 3] = 240V) breaker_rating: int # Per-circuit override (optional) - overrides: # Override any template field + pcs_priority: + int | null # Published as pcs/priority (default: the circuit's position). null records a panel that + # publishes none, as on a commissioned PV circuit; a clone keeps its panel's. + vendor: str # PV circuit: its inverter's vendor (the pv section's inverter: else pv.vendor) + model: str # PV circuit: its inverter's model (the pv section's inverter: else pv.product_name) + serial_number: str # PV circuit: its inverter's serial (the pv section's inverter: else pv.serial_number) + # EVSE circuit: its drive's serial (else evse.serial_number or the panel serial, by position) + firmware_version: str # PV circuit: its inverter's firmware (every inverter: else pv.firmware_version) + # EVSE circuit: its drive's firmware (every drive: else evse.firmware_version) + overrides: # Override any template field but its rating, which the loader moves into the template typical_power: 500.0 unmapped_tabs: [int] # Tab numbers with no circuit assigned +pv: # One inverter's identity, and every inverter's default firmware + enabled: bool # Publishes a PV device with no PV circuit, one upstream of the panel + instance_id: str # The single PV device's id when no serial_number is set + vendor: str # Default: Enphase + product_name: str # Published as info/model + serial_number: str + firmware_version: str # Every inverter's default, as a firmware version is not identity + inverter_type: str # "hybrid" | "ac-coupled"; else the PV circuit template's inverter_type + relative_position: str # "IN_PANEL" (default with a PV circuit) | "UPSTREAM" (default without) | "DOWNSTREAM" + nameplate_capacity_w: + float # The rating of an inverter with no PV circuit. With one, the rating belongs to that circuit's + # template, energy_profile.nameplate_capacity_w: the loader moves this value there, and refuses the + # config if the two disagree. + feed: + str # The id, under circuits, of the PV circuit feeding the inverter this section describes. + # Optional with one PV circuit; without it, several PV circuits each name their own inverter, + # and a firmware_version before release 202639 is refused. A value naming no PV circuit is refused. + simulation_params: update_interval: int # Seconds between snapshots (default: 5) time_acceleration: float # 1.0 = real-time, 2.0 = double speed noise_factor: float # Random noise fraction (0.02 = +/-2%) enable_realistic_behaviors: bool +firmware_version: + str # Reported over MQTT, HTTP and mDNS (default: sim/v). + # A SPAN string naming a release before 202639 keeps that release's BESS sign and EVSE limit. + # The shipped configs name spanos3/r202639/03; to emulate r202633, name a 202633 string + # and remove commissioned_system, which an earlier release refuses. + +hardware_version: + str # Published as info/hardware-version (default: 1.2). From release 202639 + # the status endpoint reports it as hardwareVersion if it is 1.2 or 2.0, else UNKNOWN. + # Clone provenance (written by the clone pipeline) panel_source: origin_serial: str # Real panel's serial (immutable) host: str # IP or hostname of source panel - passphrase: str | null # Proximity code (null for door-bypass) last_synced: str # ISO 8601 timestamp ``` +A panel with two or more PV circuits publishes one PV device per inverter, as SPAN firmware does from release 202639. Every PV device is named after its own +device id, as SPAN firmware names an inverter, so no name follows circuit order. A PV circuit's `vendor`, `model` and `serial_number` name its inverter. The +top-level `pv` section names one inverter where its circuit does not: the one whose circuit `pv.feed` names, else the only PV circuit's. It never follows list +order, because a real panel publishes the inverter its feeding circuit names, wherever that circuit sits; with several PV circuits and no `pv.feed` it names +none of them. A PV circuit's `firmware_version` is its inverter's firmware, and `pv.firmware_version` is every inverter's default, because a firmware version is +not identity. A panel whose `firmware_version` names an earlier release publishes a single PV device fed by the circuit `pv.feed` names, as that firmware did, +so such a config with several PV circuits and no `pv.feed` is refused. A panel with one PV circuit keeps the single PV device and id it has always published. + +An EVSE circuit's `serial_number` and `firmware_version` are its SPAN Drive's, in the same way: the serial wins over one derived by position from +`evse.serial_number` or the panel serial, and `evse.firmware_version` is every drive's default firmware. + ### Shed Priority Circuit shed priority controls backup behavior when the grid disconnects, matching the Homie v2 schema (`shed-priority` property): @@ -436,12 +529,12 @@ User relay overrides (from dashboard or MQTT) take precedence over shedding — These endpoints match the real SPAN panel's API exactly. -| Method | Path | Description | -| ------ | ------------------------ | -------------------------------------------------- | -| `GET` | `/api/v2/status` | Panel identity (`serialNumber`, `firmwareVersion`) | -| `POST` | `/api/v2/auth/register` | Returns MQTT credentials and broker details | -| `GET` | `/api/v2/certificate/ca` | Self-signed CA certificate (PEM) | -| `GET` | `/api/v2/homie/schema` | Homie v5 property schema | +| Method | Path | Description | +| ------ | ------------------------ | ----------------------------------------------------------------------------------------- | +| `GET` | `/api/v2/status` | Panel identity (`serialNumber`, `firmwareVersion`; `hardwareVersion` from release 202639) | +| `POST` | `/api/v2/auth/register` | Returns MQTT credentials and broker details | +| `GET` | `/api/v2/certificate/ca` | Self-signed CA certificate (PEM) | +| `GET` | `/api/v2/homie/schema` | Homie v5 property schema | Query `/api/v2/status?serial=XXX` to target a specific panel when multiple are loaded. @@ -512,14 +605,26 @@ with the target panel, scrapes its MQTT topics, translates the eBus description ### What gets cloned -- Panel identity (`sim-{serial}-clone`), main breaker rating, panel size -- All circuits: name, tab position, breaker rating, relay behavior, priority +- Panel identity (`sim-{serial}-clone`), vendor name, main breaker rating, and the panel size its model names +- The panel's time zone, line voltages, off-grid SOC shed threshold, model, and its network links and SSID (or its absence), + but not its door or cloud state, which are live state the clone simulates as it does power +- The panel's firmware version, so the clone publishes as that SPAN release does +- The panel's hardware version +- All circuits: name, tab position, breaker rating (or its absence), relay behavior, priority, PCS priority +- Commissioned PV and battery system circuits, recognised by both locks and the inverter or battery they feed, as `commissioned_system` - Energy profile mode inferred from device feeds (PV -> producer, BESS -> bidirectional, EVSE -> bidirectional) -- Energy accumulators seeded from the panel's imported/exported energy values -- Battery behavior with sensible schedule defaults -- PV nameplate capacity and production profile -- EVSE night-charging time-of-day profile -- Source panel credentials stored in `panel_source` for on-demand refresh +- Energy accumulators seeded from the panel's imported/exported energy values, which the clone's registers carry on from +- Battery behavior with sensible schedule defaults; the battery's vendor, model, part number, serial and firmware version; its charge and discharge limits from + its `info/nominal-power`, else 5 kW per 13.5 kWh; and its MID's own identity +- PV nameplate capacity and production profile, and each inverter's vendor, model, serial and firmware version on the circuit that feeds it +- EVSE night-charging time-of-day profile, and each SPAN Drive's serial and firmware version on the circuit that feeds it +- The source panel's host in `panel_source`, for on-demand refresh + +PanelBench registers with a source panel once, as `panelbench-clone-`, and reuses the broker credentials that returns for every later clone, sync and +restore, registering again only if the panel's broker refuses them. Broker credentials you already hold can be given in the clone form instead, and nothing is +registered. The passphrase and broker credentials are kept outside every config, keyed by the panel's serial, in `/.secrets/panel_sources.json` (or +`SECRETS_DIR`; the add-on uses `/data/secrets`), readable only by its owner. A config written before this that carries a `panel_source.passphrase` gives it up +to that store on the next scan of the config directory, and is never written with it again. ### Usage profile import diff --git a/README.md b/README.md index d91d36b..9e21428 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ Includes a web dashboard for real-time configuration, grid simulation, Home Assi | [`SpanPanel/simulator`](https://github.com/SpanPanel/simulator) | flat single-device | `r202603`–`r202627` | Both ship the same certificate authority, so stopping one and starting the other rehearses a firmware upgrade on a single panel instead of reading as a panel - substitution. Cloning follows the same schema line: PanelBench reads a panel running `r202633` or later and does not clone earlier firmware, because the two schemas are not convertible. @@ -29,8 +28,8 @@ schemas are not convertible. Click a simulator configuration to view it. Templates are read-only. A running simulator appears as a discovered panel in the SpanPanel integration (default configs excluded). -1. **Examine templates** — Load and run the included configs (`default_config.yaml`, `simple_test_config.yaml`, etc.) to see how circuits, PV, battery, and EVSE - are modeled. Pick one as a starting point for your own configuration. +1. **Examine templates** — Load and run the included configs (`default_MAIN_40.yaml`, `default_MAIN_32.yaml` and `default_MAIN_16.yaml`) to see how circuits, + PV, battery, and EVSE are modeled. Pick one as a starting point for your own configuration. 2. **Clone** — The **Clone** button creates an editable copy from a template, or from a panel running `r202633+` firmware; cloning a panel preserves recorder history per circuit. 3. **Model** — The **Model** button on a running panel opens the what-if view; add battery, PV, or circuits and compare before/after. Edits mark equipment as @@ -49,7 +48,7 @@ configs excluded). ## Home Assistant App PanelBench installs as a Home Assistant app from this repository. Images are published for `amd64` and `aarch64`, and the app is not distributed through HACS. -It requires the `span-panel` integration **v2.1.0 or later**. +It requires the `span-panel` integration **v2.1.2 or later**, which reads the SPAN firmware release 202639 the shipped templates publish. 1. Go to **Settings > Apps** > **App Store** > three-dot menu > **Repositories** 2. Add `https://github.com/SpanPanel/panelbench` @@ -106,6 +105,73 @@ using the port shown in the dashboard panel list: Each panel has a unique serial number, so there is no conflict between the auto-discovered panel and manually added ones. +## Rehearsing a SPAN firmware upgrade + +A SPAN panel reads its firmware once, when it starts, and an over-the-air upgrade takes it offline and brings it back reporting the new release. PanelBench +rehearses an upgrade the same way: two configs for one panel, run one after the other. The upgrade rehearsed here is to SPAN release 202639. The shipped +templates name `spanos3/r202639/03` (see [Firmware Version](#firmware-version)) and so are panels after it, which is why the first config names an earlier +release. + +A panel with a commissioned PV system has a circuit named "Commissioned PV System" before the upgrade. On a release before 202639 it is unlocked: its relay is +switchable and not always-on, and its priority is `OFF_GRID` and can be changed, so it sheds like any other circuit. The upgrade to release 202639 locks it. + +1. **Before.** Clone your panel from the dashboard while it still runs a release before 202639, or clone one of the shipped templates. A clone keeps its + source's `firmware_version`, so it publishes what that release publishes, and a panel clone's **Update eBus Energy** button, which refreshes its energy + readings from the panel, never changes it. A clone of your panel already holds the "Commissioned PV System" circuit as the panel publishes it. A clone of a + template holds it locked, as release 202639 publishes it, so take the clone back to the earlier release: set its `firmware_version` to a 202633 release, for + example `spanos3/r202633/02`, and in the circuit's template remove `commissioned_system: pv` and set `relay_behavior: controllable` and `priority: OFF_GRID`. +2. **After.** Copy the first config's YAML to a second file in the same config directory, under a name that does not start with `default_` (that prefix marks a + read-only template), and change two things in the copy: + - Set `firmware_version` to a 202639 release, for example `spanos3/r202639/03`. + - Lock the "Commissioned PV System" circuit as release 202639 does: give its template `commissioned_system: pv`, `priority: NEVER` and + `relay_behavior: non-controllable`. A circuit named "Commissioned Backup System" is locked the same way, with `commissioned_system: backup`. PanelBench + refuses `commissioned_system` on a config naming an earlier release, so these keys belong only in this file. + + That is all for a panel with one inverter; [A second inverter](#a-second-inverter) adds to it. The serial number, circuits and tabs are what make the two + configs one panel. + +3. Start the first instance with `CONFIG_NAME=.yaml ./scripts/run-local.sh` and add the panel to Home Assistant. +4. Stop the first instance and start the second: `./scripts/run-local.sh --stop` from another terminal (or Ctrl+C in the first), then + `CONFIG_NAME=.yaml ./scripts/run-local.sh`. `CONFIG_NAME=.yaml ./scripts/run-local.sh --restart` does both at once. Home Assistant sees the + same panel come back on the new release. + +Run both from the same checkout with the same `HTTP_PORT`, `DASHBOARD_PORT` and `BROKER_PORT`; the defaults are fine. The second instance must answer where the +first did. The two must never run at once: `run-local.sh` keeps one simulator PID file per checkout, and both would advertise the same serial number. + +From release 202639 the second instance does what SPAN's public [CHANGELOG](https://github.com/spanio/SPAN-API-Client-Docs) lists for that release. It publishes +the battery's own power reading as positive while discharging, and leaves a SPAN Drive's user charge limit unpublished until one is set. It publishes the +"Commissioned PV System" circuit locked, with no relay command and a priority fixed at `NEVER`. `GET /api/v2/status` reports `hardwareVersion`: `1.2` or `2.0` +from the config's `hardware_version`, `1.2` when it sets none, and `UNKNOWN` for any other value. It publishes one solar device per inverter when the panel has +more than one, and a panel with one keeps its device id. + +### A second inverter + +Before release 202639 a panel publishes one solar device, so a clone holds one PV circuit and models any other inverter's circuit as a load. To rehearse the +upgrade of a panel with a second inverter, also make that circuit a commissioned inverter's, locked, in the second config. + +The second inverter's circuit is a 240 V two-pole circuit, two tabs on opposite legs, as the "Commissioned PV System" circuit is: a grid-tied inverter in a US +panel, whether a string inverter or a set of microinverters, sits on a two-pole breaker. A clone of your panel already holds it, as a two-pole circuit modelled +as a load, so pick that circuit. A clone of a template has no second inverter, so add its circuit to both configs on two free spaces on opposite legs, such as +`24` and `26`, with the same `id` and `tabs` in both, a two-pole `breaker_rating` such as `20`, and a load's template in the first config; take the two spaces +out of `unmapped_tabs`. PanelBench warns when a solar circuit sits on one tab. Then, in the second config: + +- Name the circuit as its inverter's, for example `Solar Inverter 2`, rather than leaving a load's name. +- Give it a commissioned PV template: copy the "Commissioned PV System" circuit's template as this config has it, locked, under a new name. Release 202639 + publishes each commissioned inverter fed by its own circuit and locks that circuit as it locks the "Commissioned PV System" one, so the copy keeps + `commissioned_system: pv`, `priority: NEVER` and `relay_behavior: non-controllable`; the template, not the name, is what locks it. PanelBench warns when a + solar circuit on a config naming release 202639 or later has no `commissioned_system: pv`. Set the copy's `energy_profile.nameplate_capacity_w` to the + inverter's rating, its producer `power_range` to match, such as `[-7600.0, 0.0]`, and its `typical_power` to about 60% of the rating, negative, such as + `-4560.0`, and point the circuit's `template` at the copy. Production follows the rating and the time of day, but `typical_power` seeds the energy total the + inverter's circuit starts from, so a copy that keeps the original's would start with a total sized for the original inverter. The dashboard's nameplate field + sets all three for you. +- Remove the circuit's own `overrides`. They were the load's: a `power_range` there would cap the inverter's production, and a `typical_power` would replace the + template's. +- Give the circuit, under `circuits`, its own inverter's `vendor`, `model` and `serial_number`. It takes `pv.firmware_version` unless it sets its own + `firmware_version`, which a config with no `pv` section needs. +- Where the config has a top-level `pv` section, as a clone of a template does, set `pv.feed` to the `id` of the original inverter's circuit. The section + describes one inverter, the one whose circuit `pv.feed` names; with two PV circuits and no `pv.feed` it describes neither, and the original inverter would + lose the identity the first config gave it. A clone of your panel names each inverter on its own circuit instead, and needs no `pv.feed`. + ## Running with Docker (Linux only) ```bash @@ -123,18 +189,24 @@ The dashboard runs on port 18080 and provides full control over the simulated pa - **Multi-panel** — load multiple YAML configs; click a row to select, start/stop/restart individual panels. Running panels appear as discovered devices in the SpanPanel integration (default configs excluded). -- **Clone** — create an editable copy from a template, or from a panel running `r202633+` firmware (IP + passphrase). +- **Clone** — create an editable copy from a template, or from a panel running `r202633+` firmware (IP and passphrase, or broker credentials you already hold). + PanelBench registers with a panel once, keeps the passphrase and credentials outside the config, and reuses them for every sync. - **Model** — open the energy what-if view for a running panel. - **Purge** — remove recorder history written by the simulated panel's sensors when the simulated panel was added to HA's integration. - **File operations** — import/export YAML, save & reload - **Config persistence** — the simulator remembers the last running config across restarts +Import also accepts a panel definition file (`panel-sim-definition/1`, as `panel-sim-capture` writes one). PanelBench builds a config from the panel's makeup +and gives every circuit a default behaviour, which you then tune as for a clone. **Export definition** writes a saved config the other way, as a panel +definition file holding the panel's makeup without PanelBench's behaviour settings. + ### Simulation Controls - **Time-of-day slider** — scrub through the day to see solar curves, time-of-day profiles, and battery schedules respond - **Speed acceleration** — 1x to 360x time acceleration - **Grid online/offline** — toggle to test backup behavior and load shedding - **Islandable toggle** — controls whether PV operates during grid outage +- **Battery link** — sets the health of the panel's link to its battery; Home Assistant's dominant power source control is honoured only while it is not OK - **Live power chart** — real-time grid, solar, and battery power flows ### Recorder Replay @@ -184,8 +256,9 @@ Add, edit, and delete circuits with specialized editors per type: - **EVSE** — charging schedule with presets (Peak Solar, Evening, Night) or custom start/duration, 24-hour visual timeline - **Circuits** — typical power, 24-hour usage profile with presets, HVAC type selector with seasonal power modulation -PV and Battery are singleton types — only one of each can exist per panel. Recorder-sourced entities preserve their original panel settings (priority, relay -behavior) as read-only. +The dashboard adds one PV circuit per panel, and a panel has one battery. A panel with more than one inverter gives each inverter its own PV circuit in the +config's YAML, as [Rehearsing a SPAN firmware upgrade](#rehearsing-a-span-firmware-upgrade) shows. Recorder-sourced entities preserve their original panel +settings (priority, relay behavior) as read-only. ### Relay Control and Load Shedding @@ -253,38 +326,57 @@ simulation_params: ### Config Selection -By default, the simulator loads `default_config.yaml`. To use a different config: +To start a specific config: ```bash -CONFIG_NAME=simple_test_config.yaml ./scripts/run-local.sh +CONFIG_NAME=default_MAIN_16.yaml ./scripts/run-local.sh ``` -The simulator remembers the last running config and resumes it on restart. When no config is specified and no default exists, all YAML files in the config -directory are loaded. +Without `CONFIG_NAME`, the simulator resumes the config it last ran, or starts with no panel running until you pick one in the dashboard. ### Included Configs | File | Tabs | Description | | ---------------------- | ---- | -------------------------------------------- | | `default_MAIN_40.yaml` | 40 | Full residential with solar, battery, 2 EVSE | -| `default_main_32.yaml` | 32 | Full residential with solar, battery, 1 EVSE | +| `default_MAIN_32.yaml` | 32 | Full residential with solar, battery, 1 EVSE | | `default_MAIN_16.yaml` | 16 | Minimal test: lights, outlets, HVAC, solar | +### Firmware Version + +`firmware_version` is the firmware string a panel reports over MQTT, its HTTP status endpoint and mDNS. It also decides which side of SPAN release 202639 the +panel emulates: the battery's power sign, whether a SPAN Drive's user charge limit is published before a user sets one, one solar device per inverter, the +status endpoint's hardware version, and commissioned-system circuits. + +The included configs name `spanos3/r202639/03`, as a SPAN panel on that release publishes it, and so are panels after the upgrade to release 202639: their solar +circuit is the locked "Commissioned PV System" circuit, and they report hardware version `1.2`. Read them with the `span-panel` integration v2.1.2 or later; +v2.1.1 shows the battery's Meter Power with its sign flipped. + +To emulate the firmware before that upgrade, clone a template, since templates are read-only, and in the clone's YAML under `configs/` name a 202633 release and +remove `commissioned_system` from its solar template, which PanelBench refuses on an earlier release: + +```yaml +firmware_version: spanos3/r202633/02 +``` + +A config naming no firmware reports `sim/v`, which names no SPAN release, and publishes r202639's conventions. A clone keeps its source panel's +firmware. + ## Environment Variables All variables can also be passed as CLI arguments (`--help` for full list). -| Variable | Default | Description | -| ------------------- | --------------------- | ----------------------------------------- | -| `CONFIG_DIR` | `./configs` | Directory containing panel YAML configs | -| `CONFIG_NAME` | `default_config.yaml` | Specific config file to load | -| `TICK_INTERVAL` | `1.0` | Seconds between simulation ticks | -| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | -| `HTTP_PORT` | `8081` | Bootstrap HTTP server port (TLS on +1000) | -| `DASHBOARD_PORT` | `18080` | Dashboard web UI port | -| `BROKER_HOST` | `localhost` | MQTT broker hostname | -| `BROKER_PORT` | `18883` | MQTTS broker port | -| `ADVERTISE_ADDRESS` | auto-detected | IP to advertise via mDNS | +| Variable | Default | Description | +| ------------------- | --------------- | ----------------------------------------- | +| `CONFIG_DIR` | `./configs` | Directory containing panel YAML configs | +| `CONFIG_NAME` | last config run | Specific config file to load | +| `TICK_INTERVAL` | `1.0` | Seconds between simulation ticks | +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | +| `HTTP_PORT` | `8081` | Bootstrap HTTP server port (TLS on +1000) | +| `DASHBOARD_PORT` | `18080` | Dashboard web UI port | +| `BROKER_HOST` | `localhost` | MQTT broker hostname | +| `BROKER_PORT` | `18883` | MQTTS broker port | +| `ADVERTISE_ADDRESS` | auto-detected | IP to advertise via mDNS | ## Development diff --git a/configs/default_MAIN_16.yaml b/configs/default_MAIN_16.yaml index 6e5ec95..767fd68 100644 --- a/configs/default_MAIN_16.yaml +++ b/configs/default_MAIN_16.yaml @@ -12,6 +12,11 @@ panel_config: wifi_ssid: sim-wifi latitude: 37.7 longitude: -122.4 +# SPAN release 202639, in the form a panel on it publishes, so this template is a panel +# after the upgrade. To emulate the earlier firmware, clone it, set a 202633 string such as +# `spanos3/r202633/02`, and remove `commissioned_system` from its solar template. +firmware_version: spanos3/r202639/03 +hardware_version: "1.2" pv: # Identity only. There is no `enabled` key: the PV device is instantiated from the @@ -105,6 +110,8 @@ circuit_templates: nameplate_capacity_w: 5000.0 relay_behavior: "non-controllable" priority: "NEVER" + # Release 202639 locks the circuit feeding a commissioned inverter. + commissioned_system: "pv" device_type: "pv" breaker_rating: 30 @@ -135,7 +142,8 @@ circuits: tabs: [5, 7] - id: "solar_inverter" - name: "Solar Inverter" + # The name a SPAN panel gives the circuit it adds for a commissioned PV system. + name: "Commissioned PV System" template: "solar" tabs: [6, 8] diff --git a/configs/default_MAIN_32.yaml b/configs/default_MAIN_32.yaml index e3d7d25..f87b20c 100644 --- a/configs/default_MAIN_32.yaml +++ b/configs/default_MAIN_32.yaml @@ -12,6 +12,11 @@ panel_config: wifi_ssid: sim-wifi latitude: 37.7 longitude: -122.4 +# SPAN release 202639, in the form a panel on it publishes, so this template is a panel +# after the upgrade. To emulate the earlier firmware, clone it, set a 202633 string such as +# `spanos3/r202633/02`, and remove `commissioned_system` from its solar template. +firmware_version: spanos3/r202639/03 +hardware_version: "1.2" pv: # Identity only. There is no `enabled` key: the PV device is instantiated from the @@ -480,6 +485,8 @@ circuit_templates: nameplate_capacity_w: 8000.0 relay_behavior: "non-controllable" priority: "NEVER" + # Release 202639 locks the circuit feeding a commissioned inverter. + commissioned_system: "pv" device_type: "pv" breaker_rating: 30 @@ -692,12 +699,11 @@ circuits: # Solar (tabs 28-30) - id: "solar_inverter" - name: "Solar Inverter" + # The name a SPAN panel gives the circuit it adds for a commissioned PV system. + name: "Commissioned PV System" template: "solar_production" tabs: [28, 30] device_type: "pv" - overrides: - nameplate_capacity_w: 8000.0 unmapped_tabs: [31, 32] diff --git a/configs/default_MAIN_40.yaml b/configs/default_MAIN_40.yaml index 0894147..5528279 100644 --- a/configs/default_MAIN_40.yaml +++ b/configs/default_MAIN_40.yaml @@ -26,8 +26,11 @@ panel_config: # are. Synthetic, so it attests the mapping rather than what a real network is # called. wifi_ssid: sim-wifi -firmware_version: sim/v0.1.0 -hardware_version: rev2 +# SPAN release 202639, in the form a panel on it publishes, so this template is a panel +# after the upgrade. To emulate the earlier firmware, clone it, set a 202633 string such as +# `spanos3/r202633/02`, and remove `commissioned_system` from its solar template. +firmware_version: spanos3/r202639/03 +hardware_version: '1.2' pv: enabled: true vendor: Enphase @@ -581,6 +584,8 @@ circuit_templates: nameplate_capacity_w: 10000.0 relay_behavior: non-controllable priority: NEVER + # Release 202639 locks the circuit feeding a commissioned inverter. + commissioned_system: pv # A grid-forming inverter. This is what makes the site islandable, which is what # gives the BESS a MID -- the device carrying islanding-state and # grid-forming-entity in v1.0. Shipped hybrid so a fresh clone exercises the MID @@ -592,7 +597,6 @@ circuit_templates: inverter_type: hybrid device_type: pv breaker_rating: 30 - nameplate_capacity_w: 10000.0 pool: energy_profile: mode: consumer @@ -839,7 +843,8 @@ circuits: - 35 - 37 - id: solar_inverter - name: Solar Inverter + # The name a SPAN panel gives the circuit it adds for a commissioned PV system. + name: Commissioned PV System template: solar tabs: - 36 @@ -860,43 +865,3 @@ simulation_params: time_acceleration: 1.0 noise_factor: 0.01 enable_realistic_behaviors: true -ticks: -- name: loads and both EVSEs with some PV - current_time: 0.0 - grid_online: true - circuits: - master_bedroom_lights: 40.0 - living_room_lights: 50.0 - kitchen_outlets_1: 300.0 - refrigerator: 120.0 - main_hvac: 1400.0 - water_heater: 4500.0 - span_drive_garage: 2410.0 - span_drive_driveway: 2420.0 - solar_inverter: -2430.0 -- name: PV surplus charges BESS and exports - current_time: 60.0 - grid_online: true - circuits: - master_bedroom_lights: 40.0 - living_room_lights: 50.0 - kitchen_outlets_1: 300.0 - refrigerator: 120.0 - main_hvac: 0.0 - water_heater: 0.0 - span_drive_garage: 2410.0 - span_drive_driveway: 0.0 - solar_inverter: -8500.0 -- name: grid outage, the BESS forms the island - current_time: 120.0 - grid_online: false - circuits: - master_bedroom_lights: 40.0 - living_room_lights: 50.0 - kitchen_outlets_1: 300.0 - refrigerator: 120.0 - main_hvac: 0.0 - water_heater: 0.0 - span_drive_garage: 0.0 - span_drive_driveway: 0.0 - solar_inverter: -1200.0 diff --git a/panelbench/CHANGELOG.md b/panelbench/CHANGELOG.md index 710aa89..b8616f3 100644 --- a/panelbench/CHANGELOG.md +++ b/panelbench/CHANGELOG.md @@ -1,5 +1,139 @@ # Changelog +## 2.6.0 — 2026-10-08 + +The shipped templates now publish SPAN firmware release 202639's conventions, and a config naming a 202633 release keeps that release's. The add-on +refreshes its shipped templates on every start, so an upgraded install's templates publish release 202639's. Use it with SPAN integration 2.1.2 or later, +since against release 202639 integration 2.1.1 shows the battery's Meter Power with its sign flipped. A config or clone that names no SPAN release, including +a clone of the earlier MAIN 40 template, publishes release 202639's too; to keep the earlier conventions, set its `firmware_version` to a 202633 release such +as `spanos3/r202633/02`, or, for a clone of a panel, to the source panel's firmware, and remove `commissioned_system` from a template clone's solar template. + +### Added + +- **The dashboard can set the health of the panel's link to its battery**, which a SPAN panel requires before it honours Home Assistant's dominant power source + control. +- **Commissioned PV and battery system circuits are modelled and kept by a clone**, locked as release 202639 locks them and recognised by what they feed + whatever they are named, and a config naming an earlier release is refused if it asks for one. +- **A clone of a panel keeps the panel's time zone and line voltages.** +- **The log warns when a clone gives the main breaker a default rating** because the panel publishes none. +- **Clone accepts broker credentials you already hold for a panel in place of its passphrase**, and then registers nothing with the panel. +- **From release 202639, the log warns about a solar circuit on a breaker whose template lacks `commissioned_system: pv`**, since that release locks it. +- **The log warns about a solar circuit on a single tab**, since a grid-tied inverter is 240 V on a two-pole breaker. +- **From release 202639, a panel's status endpoint reports its hardware version**: `1.2` or `2.0` from the config's `hardware_version`, and `UNKNOWN` otherwise. +- **The dashboard's Import accepts a panel definition file**, such as one `panel-sim-capture` wrote from a live panel, and turns it into a PanelBench config. +- **The dashboard can export a saved config as a panel definition file**, the makeup other eBus tools read. + +### Changed + +- **Home Assistant's dominant power source control is ignored while the panel's link to its battery is healthy**, as a SPAN panel ignores it. +- **From release 202639, the battery's own power reading is positive while discharging**, as SPAN firmware publishes it. +- **From release 202639, a SPAN Drive's user charge limit is not published until a user sets one**, as on SPAN firmware. +- **The shipped templates report firmware `spanos3/r202639/03` and publish release 202639's conventions**, and a config naming a 202633 release keeps that + release's. +- **The shipped templates' solar circuit is now named "Commissioned PV System", as SPAN firmware r202639 names it**, and locked as that release locks it, + where it was named "Solar Inverter". +- **The shipped configs, and any config that sets no `hardware_version`, now report hardware version `1.2`**, as a SPAN panel publishes it, where they reported + `rev2`; a config copied from an earlier template keeps its `rev2`, which the status endpoint reports as `UNKNOWN` from release 202639. +- **A panel without a battery no longer publishes load-shed state**, as the eBus specification requires. +- **From release 202639, a config with two or more solar circuits publishes each inverter as its own solar device**, under new device ids, and a clone keeps + every inverter. +- **Every solar device, battery, battery MID and set of lugs is named after its own device id**, as SPAN firmware release 202639 names them, where they were + named "Solar", "Battery" and so on. +- **The `pv` section describes the inverter whose circuit `pv.feed` names by its `id`, or the only solar circuit's**, where it followed whichever was listed + first, and a config whose `pv.feed` names no solar circuit, or with several solar circuits and no `pv.feed` before release 202639, is refused. +- **A solar rating set in the dashboard replaces the stale copy a clone of the shipped MAIN 40 or MAIN 32 template kept**, and the log warns about it until the + config is saved in the dashboard. +- **A `pv.nameplate_capacity_w`, or a circuit override on a shared template, that disagrees with the template's rating is refused**, naming both values. +- **A new clone of a panel whose battery publishes a serial number identifies that battery by its serial**, which gives the battery and its MID new device ids + when such a panel is cloned again. + +### Fixed + +- **Commands from Home Assistant take effect**, such as a circuit's relay or shed priority, where PanelBench never read them. +- **PanelBench reconnects after its MQTT broker restarts or stops answering, and publishes its panels again**, where it stayed disconnected until the add-on + was restarted. +- **A config naming the serial of a panel already running is refused**, where both started, the second silently taking over the first's broker connection. +- **A panel that fails as it starts no longer leaves its broker connection open**, where it stayed open until the panel started again. +- **Cloning or syncing a panel connects to its broker at the address you gave**, where it dialled the panel's `.local` name, which does not resolve across + subnets. +- **A panel's solar output no longer changes when the add-on restarts**, where each start gave the same panel different weather. +- **The add-on registers with a panel once and reuses its credentials for every clone, sync and restore**, registering again only if the panel's broker + refuses them, where each one left another client on the panel. +- **A panel's passphrase is no longer written into its clone's config or an export of it**, but kept in the add-on's private storage, where an existing clone's + passphrase moves the next time the add-on reads it. +- **A clone takes its size from the panel's model**, where a panel whose highest spaces were empty was cloned as a smaller one. +- **A clone's energy readings carry on from its panel's**, where they started at zero. +- **A clone keeps its panel's PCS priorities, vendor name, model, off-grid shed threshold and battery MID identity.** +- **A clone keeps its panel's network links and Wi-Fi network or its absence**, where a panel on Ethernet cloned as one on Wi-Fi. +- **A clone's battery charges and discharges at the battery's rated power**, where every battery was held to 3.5 kW. +- **A clone records a breaker rating its panel does not publish as absent**, where it wrote 20 A into the config. +- **Restoring a circuit from a panel that cannot be reached says why**, where it left the circuit unchanged without a word. +- **An upgraded add-on refreshes its shipped `default_*` templates on every start**, where it kept the copies an earlier release laid down. +- **The panel's HTTP status, Homie schema endpoint and mDNS advertisement report the same firmware version it publishes over MQTT.** +- **A cloned panel keeps the source panel's name, firmware version and hardware version.** +- **A cloned panel keeps each solar inverter's identity, its battery's model, and each SPAN Drive's serial number and firmware version.** +- **The dashboard refuses an edit the panel would not load, says why and keeps the config as it was**, where such an edit was saved and the panel then failed to + restart. +- **The dashboard starts when the active config is one the panel refuses**, showing why, where the add-on had no dashboard to fix it from. +- **A solar nameplate edited in the dashboard survives a restart and is the rating the panel both publishes and produces at.** +- **The dashboard's relay control shows a locked relay however its config spells it**, where saving the form could unlock it. +- **The dashboard's Add Entity places a new circuit on the first free space**, where it made a circuit with no space, which the panel refused. +- **The dashboard shows a panel as islandable when any of its solar inverters is hybrid**, where only the first solar circuit listed decided it. +- **An imported config counts as unsaved until it is saved**, so switching panels asks before discarding it. +- **The dashboard fetches a read-only template's weather**, where a script error stopped it. + +## 2.5.3 — 2026-08-28 + +**A simulated panel already added to Home Assistant has to be removed and re-added**, because its circuits get new device ids. + +### Fixed + +- **Two simulated panels running in one add-on no longer overwrite each other's circuit readings.** + +## 2.5.2 — 2026-08-28 + +### Fixed + +- **Discovery gives Home Assistant the panel's address rather than an internal add-on hostname**, so a panel added by IP keeps verifying its certificate. + +## 2.5.1 — 2026-08-28 + +### Fixed + +- **The certificate and the mDNS advertisement carry this host's own address**, so Home Assistant can reach the panel and verify it, and an existing install + corrects itself on the next start. +- **Discovery entries are removed when the add-on stops**, where stale ones accumulated across restarts. +- **The add-on image builds again.** + +## 2.5.0 — 2026-08-28 + +**The certificate authority now ships with the app and matches the SPAN Panel Simulator's**, so swapping the simulator for PanelBench on one panel rehearses a +firmware upgrade rather than a panel replacement; upgrade the simulator to 1.1.0 first, and the swap raises no certificate repair in Home Assistant. + +### Fixed + +- **A changed address or hostname re-signs only the server certificate**, where it rotated the certificate authority too. +- **An expired, corrupt or superseded server certificate is replaced at startup**, where it failed handshakes or put the add-on into a restart loop. +- **Certificate files are written atomically, and the authority's private key is no longer written to the certificate directory.** + +## 2.4.0 — 2026-08-26 + +### Changed + +- **Locked circuits no longer offer a relay command**, as a real panel publishes them, and the dashboard refuses a relay toggle on one. +- **The dashboard refuses a shed-priority change on a never-backup circuit**, which would have saved a config the panel could not start from. + +### Added + +- **Cloning a panel reproduces its never-backup circuits.** + +## 2.3.0 — 2026-08-26 + +### Added + +- **Every panel serves its API over TLS as well as plaintext**, on a port 1000 above its bootstrap port, which Home Assistant's setup needs. +- **Discovery publishes both ports, and the dashboard shows the pair beside each running panel** for adding one to Home Assistant by hand. + ## 2.2.0 — 2026-08-23 ### Fixed diff --git a/panelbench/DOCS.md b/panelbench/DOCS.md index 57630bd..42c3fc7 100644 --- a/panelbench/DOCS.md +++ b/panelbench/DOCS.md @@ -30,7 +30,9 @@ real SPAN panel's configuration directly from the dashboard. individual panels - **File operations** — import/export YAML, clone configs, save & reload - **Panel cloning** — clone a real SPAN panel's configuration from - the dashboard (enter the panel IP and passphrase) + the dashboard (enter the panel IP and passphrase, or broker + credentials you already hold); the passphrase and credentials are + kept in the add-on's private storage, never in the config - **Config persistence** — the simulator remembers the last running config across restarts @@ -134,6 +136,24 @@ System, light, or dark theme via the header selector. | `dashboard_enabled` | `true` | Enable the web dashboard | | `base_http_port` | `8081` | Base port for per-panel HTTP servers (first panel uses this port, second uses port+1, etc.). Each panel also serves the same API over TLS 1000 ports above its HTTP one | +## Firmware version + +The included configs are SPAN panels on firmware release 202639 and +report `spanos3/r202639/03`: the battery's power reading is positive +while discharging, the solar circuit is the locked "Commissioned PV +System" circuit, and the status endpoint reports hardware version +`1.2`. Read them with the SpanPanel/span integration **v2.1.2 or +later**. + +To emulate a panel before the upgrade to release 202639, clone a +config from the dashboard, then in the clone's YAML name a 202633 +release and remove `commissioned_system: pv` from its solar template, +which PanelBench refuses on an earlier release: + +```yaml +firmware_version: spanos3/r202633/02 +``` + ## Custom configs Place simulation config YAML files in `/config/panelbench/` and diff --git a/panelbench/Dockerfile b/panelbench/Dockerfile index 2184e47..664382b 100644 --- a/panelbench/Dockerfile +++ b/panelbench/Dockerfile @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080 LABEL io.hass.name="SPAN PanelBench" \ io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \ io.hass.type="addon" \ - io.hass.version="2.5.3" \ + io.hass.version="2.6.0" \ io.hass.arch="aarch64|amd64" CMD ["/run.sh"] diff --git a/panelbench/README.md b/panelbench/README.md index 94e2d12..1149f2a 100644 --- a/panelbench/README.md +++ b/panelbench/README.md @@ -16,7 +16,8 @@ distributed through HACS. This App emulates SPAN firmware `r202633+`, the first to publish the parent/child device tree (`data-model-version` `1.x`). -Requires the SpanPanel/span integration **v2.1.0 or later**. +Requires the SpanPanel/span integration **v2.1.2 or later**, which +reads the SPAN firmware release 202639 the included configs publish. Visit [SPAN PanelBench](https://github.com/SpanPanel/panelbench) page for more details. diff --git a/panelbench/config.yaml b/panelbench/config.yaml index df5848e..2f44abd 100644 --- a/panelbench/config.yaml +++ b/panelbench/config.yaml @@ -1,6 +1,6 @@ name: "SPAN PanelBench" description: "Simulates a SPAN electrical panel for testing and upgrade modeling" -version: "2.5.3" +version: "2.6.0" slug: "panelbench" url: "https://github.com/SpanPanel/panelbench" image: "ghcr.io/spanpanel/panelbench/{arch}" diff --git a/panelbench/run.sh b/panelbench/run.sh index d4ba6ff..a1d5403 100755 --- a/panelbench/run.sh +++ b/panelbench/run.sh @@ -54,6 +54,9 @@ detect_advertise_address() { ADVERTISE_ADDRESS="${ADVERTISE_ADDRESS:-$(detect_advertise_address)}" export ADVERTISE_ADDRESS export CERT_DIR="/data/certs" +# Source panels' passphrases and broker credentials. In /data, which only the add-on +# reads, rather than beside the configs in the folder Home Assistant shares. +export SECRETS_DIR="/data/secrets" export BROKER_USERNAME="span" export BROKER_PASSWORD="sim-password" @@ -69,17 +72,34 @@ LEGACY_CONFIG_DIR="/config/span_simulator" CONFIG_DIR="/config/panelbench" mkdir -p "${CONFIG_DIR}" -# Seed the shipped defaults, still only where a file is missing, so an edited -# config is never overwritten. The difference is that this directory starts empty -# on an upgrade, so the shipped defaults actually land. -for src in /app/configs/*.yaml /app/configs/*.yml; do - [ -f "${src}" ] || continue - dest="${CONFIG_DIR}/$(basename "${src}")" - if [ ! -f "${dest}" ]; then - cp "${src}" "${dest}" - echo "Seeded config: $(basename "${src}")" - fi -done +# Lay the shipped configs into the config directory. +# +# The `default_*` templates belong to PanelBench: the dashboard refuses to save, +# delete or rename them, so a copy here can only ever be one a release laid +# down. They are refreshed on every start. Seeding them only where they were +# missing left an upgraded install on the previous release's copy, and a template +# that names no SPAN firmware release publishes the newest release's conventions, +# so the old copy silently changed what the panel published. +# +# Every other file here is the user's -- clones live in this directory -- so it is +# never touched. Any other shipped file is seeded once, where it is missing. +seed_configs() { + local shipped_dir="$1" config_dir="$2" src name dest + for src in "${shipped_dir}"/*.yaml "${shipped_dir}"/*.yml; do + [ -f "${src}" ] || continue + name="$(basename "${src}")" + dest="${config_dir}/${name}" + if [ ! -f "${dest}" ]; then + cp "${src}" "${dest}" + echo "Seeded config: ${name}" + elif [[ "${name}" == default_* ]] && ! cmp -s "${src}" "${dest}"; then + cp "${src}" "${dest}" + echo "Refreshed shipped template: ${name}" + fi + done +} + +seed_configs /app/configs "${CONFIG_DIR}" # Nothing is migrated from the old directory. A file there may have been written # by either add-on and nothing distinguishes them, so copying would reintroduce diff --git a/pyproject.toml b/pyproject.toml index b3029bd..7c53c58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,15 @@ build-backend = "hatchling.build" [project] name = "panelbench" -version = "2.5.3" +version = "2.6.0" description = "Standalone eBus simulator for SPAN panels" requires-python = ">=3.14" dependencies = [ - "aiomqtt>=2.0.0", + # Bounded to the range CI proves: the broker link reaches into aiomqtt's private + # `_client` and paho's private `_sock_close`, and the add-on installs from this + # list, not the lock. tests/emitter_adapter/test_aiomqtt_contract.py fails on a + # bump that moves either, so raise a bound only with that test green. + "aiomqtt>=2.5.1,<2.6", "aiohttp>=3.9.0", "aiohttp-jinja2>=1.6", "jinja2>=3.1.0", @@ -46,7 +50,7 @@ dependencies = [ # `pformat` are both still absent from `homie.py` and the logger is still NOTSET at # import, so the filter remains a guard rather than a live need. # - # 0.22.0, which is the ceiling `ebus-panel-sim` 0.8.0 allows: it declares + # 0.22.0, which is the ceiling `ebus-panel-sim` 0.9.0 allows: it declares # `ebus-sdk<0.23,>=0.20.1`, so 0.23.x is out of range until the emitter widens it. That # costs nothing here. Everything 0.21.0 through 0.23.1 adds is the producer-side # declarative tree API — `DeviceSpec`, `DeviceTreeBuilder`, `build_from_declarations` and @@ -63,8 +67,8 @@ dependencies = [ # `RecordingTransport` (last-wins per topic, like a broker's retained store) and # therefore to the fidelity captures. "ebus-sdk==0.22.0", - "ebus-panel-sim==0.8.0", - "paho-mqtt>=2.0.0", + "ebus-panel-sim==0.9.0", + "paho-mqtt>=2.1,<2.2", ] [dependency-groups] @@ -104,11 +108,6 @@ packages = ["src/panelbench"] # Bump back to py314 once the upstream bug is fixed. target-version = "py313" line-length = 99 -# Byte-vendored from the eBus emitter as the fidelity reference (see -# tests/fidelity/fixtures/upstream/PROVENANCE.md). Reformatting it to our rules -# would destroy the property that makes it useful -- that it is upstream's file, -# unmodified -- and would make every re-sync a merge conflict. -extend-exclude = ["tests/fidelity/fixtures/upstream"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "SIM", "TCH", "RUF"] @@ -130,10 +129,6 @@ testpaths = ["tests"] [tool.mypy] python_version = "3.14" strict = true -# Same reason as ruff's exclusion: the vendored reference is upstream's code held -# byte-identical, not ours to annotate. It is exercised by the parity test, which -# is a stronger check than type-checking it would be. -exclude = ["tests/fidelity/fixtures/upstream/"] [[tool.mypy.overrides]] module = "timezonefinder" diff --git a/scripts/run-local.sh b/scripts/run-local.sh index 89407c6..8687f8b 100755 --- a/scripts/run-local.sh +++ b/scripts/run-local.sh @@ -28,7 +28,7 @@ Options: Environment variables: ADVERTISE_ADDRESS Override mDNS advertised IP (auto-detected from en0/en1) CONFIG_DIR Config directory (default: ./configs) - CONFIG_NAME Specific config file to load (e.g., default_config.yaml) + CONFIG_NAME Specific config file to load (e.g., default_MAIN_16.yaml) TICK_INTERVAL Simulation tick interval in seconds (default: 1.0) LOG_LEVEL Logging level (default: INFO) BROKER_USERNAME MQTT broker username (default: span) diff --git a/src/panelbench/__init__.py b/src/panelbench/__init__.py index c6d6778..4023294 100644 --- a/src/panelbench/__init__.py +++ b/src/panelbench/__init__.py @@ -1,3 +1,3 @@ """Standalone eBus simulator for SPAN panels.""" -__version__ = "2.5.3" +__version__ = "2.6.0" diff --git a/src/panelbench/__main__.py b/src/panelbench/__main__.py index 9d408f9..8520c5b 100644 --- a/src/panelbench/__main__.py +++ b/src/panelbench/__main__.py @@ -123,8 +123,8 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument( "--config", default=os.environ.get("CONFIG_NAME"), - help="Name of a specific config file to load (e.g., default_config.yaml). " - "When omitted, loads default_config.yaml if it exists, otherwise all configs.", + help="Name of a specific config file to load (e.g., default_MAIN_16.yaml). " + "When omitted, resumes the last config run, or starts with no panel running.", ) parser.add_argument( "--tick-interval", @@ -170,6 +170,13 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: default=Path(os.environ.get("CERT_DIR", "/tmp/span-sim-certs")), help="Directory for generated TLS certificates", ) + parser.add_argument( + "--secrets-dir", + type=Path, + default=Path(secrets_dir) if (secrets_dir := os.environ.get("SECRETS_DIR")) else None, + help="Directory for source panels' passphrases and broker credentials " + "(default: .secrets in the config directory)", + ) parser.add_argument( "--dashboard-port", type=int, @@ -272,6 +279,7 @@ def main(argv: list[str] | None = None) -> None: dashboard_port=args.dashboard_port, advertise_address=args.advertise_address, ha_config=ha_config, + secrets_dir=args.secrets_dir, ) try: diff --git a/src/panelbench/app.py b/src/panelbench/app.py index d5d8c99..46761f7 100644 --- a/src/panelbench/app.py +++ b/src/panelbench/app.py @@ -27,7 +27,6 @@ DEFAULT_BASE_HTTP_PORT, DEFAULT_BROKER_PASSWORD, DEFAULT_BROKER_USERNAME, - DEFAULT_FIRMWARE_VERSION, DEFAULT_TICK_INTERVAL_S, MQTTS_PORT, https_port_for, @@ -37,10 +36,13 @@ from panelbench.emitter_adapter.runtime import BrokerConnection from panelbench.panel import PanelInstance from panelbench.panel_models import PANEL_SIZE_TO_MODEL +from panelbench.panel_secrets import SECRETS_FILENAME, PanelSecretsStore from panelbench.recorder import RecorderDataSource from panelbench.schema import HomieSchemaRegistry, load_schema, render_for_panel if TYPE_CHECKING: + from ebus_panel_sim import BESSCommunication + from panelbench.certs import CertificateBundle from panelbench.config_types import BESSConfigYAML from panelbench.engine import DynamicSimulationEngine @@ -117,11 +119,16 @@ def __init__( dashboard_port: int = DASHBOARD_PORT, advertise_address: str | None = None, ha_config: HAConnectionConfig | None = None, + secrets_dir: Path | None = None, ) -> None: self._config_dir = config_dir + self._panel_secrets = ( + PanelSecretsStore(secrets_dir / SECRETS_FILENAME) + if secrets_dir is not None + else PanelSecretsStore.in_config_dir(config_dir) + ) self._config_filter = config_filter self._tick_interval = tick_interval - self._firmware = DEFAULT_FIRMWARE_VERSION self._broker_username = broker_username self._broker_password = broker_password self._broker_host = broker_host @@ -249,6 +256,11 @@ def _set_grid_islandable(self, islandable: bool) -> None: if engine is not None: engine.set_grid_islandable(islandable) + def _set_bess_link(self, link: BESSCommunication) -> None: + engine = self._get_first_engine() + if engine is not None: + engine.set_bess_link(link) + def _set_circuit_priority(self, circuit_id: str, priority: str) -> None: engine = self._get_first_engine() if engine is not None: @@ -306,7 +318,9 @@ async def _start_panel(self, config_path: Path) -> PanelInstance: recorder=recorder, broker=broker, ) - serial = await panel.start() + serial = await panel.start( + running={s: p.config_path for s, p in self._serial_to_panel.items()} + ) # Validate panel size and render schema before registering. If the # panel's total_tabs is not a SPAN model size, stop the started panel @@ -314,33 +328,17 @@ async def _start_panel(self, config_path: Path) -> PanelInstance: # without a bootstrap HTTP server to match. try: total_tabs = panel.total_tabs - panel_model = PANEL_SIZE_TO_MODEL[total_tabs] - panel_schema = render_for_panel(self._schema, total_tabs) + if total_tabs not in PANEL_SIZE_TO_MODEL: + raise KeyError(f"total_tabs {total_tabs} is not a SPAN panel size") + # The model MQTT publishes, so mDNS cannot advertise another. + panel_model = panel.model + firmware = panel.firmware_version + status_hardware = panel.status_hardware_version + panel_schema = render_for_panel(self._schema, total_tabs, firmware=firmware) except Exception: await panel.stop() raise - # Ensure unique serial — cloned configs may share the same serial. - # Append a suffix to avoid MQTT topic and mDNS name collisions. - # Moved below validation — no point renaming a panel we are about to discard. - if serial in self._serial_to_panel: - base_serial = serial - suffix = 2 - while f"{base_serial}-{suffix}" in self._serial_to_panel: - suffix += 1 - serial = f"{base_serial}-{suffix}" - # Serial-override post-cutover requires emitter restart to propagate the - # new serial through the manifest + lifecycle. For v0.1.0 of the emitter - # integration this is a degraded path: log the duplicate and skip the - # rename. Follow-up: implement runtime serial override by tearing down - # and restarting the panel's CloneRuntime with the new serial. - _LOGGER.warning( - "Duplicate serial %s detected — renamed to %s " - "(emitter restart required to take effect)", - base_serial, - serial, - ) - self._panels[config_path] = panel self._serial_to_panel[serial] = panel @@ -352,7 +350,7 @@ async def _start_panel(self, config_path: Path) -> PanelInstance: def _build(http_port: int) -> BootstrapHttpServer: return BootstrapHttpServer( serial, - self._firmware, + firmware, certs, panel_schema, broker_username=self._broker_username, @@ -360,6 +358,7 @@ def _build(http_port: int) -> BootstrapHttpServer: broker_host=self._broker_host, port=http_port, https_port=https_port_for(http_port), + hardware_version=status_hardware, ) port = self._allocate_port() @@ -391,7 +390,7 @@ def _build(http_port: int) -> BootstrapHttpServer: if self._advertiser is not None: await self._advertiser.register_panel( serial, - self._firmware, + firmware, model=panel_model, port=port, https_port=https_port_for(port), @@ -578,6 +577,10 @@ async def reload(self) -> dict[str, list[str]]: stopped, or reloaded. Errors for affected filenames are stored on ``self._panel_start_errors`` so the dashboard can surface them. """ + # A clone written before the secrets store existed carries its source panel's + # passphrase. It moves out on the first scan that finds it, every file in the + # directory alike, and before anything hashes or reads them. + self._panel_secrets.migrate_config_files(self._config_dir) current = _discover_configs(self._config_dir, self._config_filter) # Exclude configs the user explicitly stopped via the dashboard @@ -834,6 +837,7 @@ async def run(self) -> None: set_time_acceleration=self._set_time_acceleration, set_grid_online=self._set_grid_online, set_grid_islandable=self._set_grid_islandable, + set_bess_link=self._set_bess_link, set_circuit_priority=self._set_circuit_priority, set_circuit_relay=self._set_circuit_relay, apply_bess_config_live=self.apply_bess_config_live, @@ -841,6 +845,7 @@ async def run(self) -> None: ha_client=ha_client, history_provider=ha_client, panel_browser=browser, + panel_secrets=self._panel_secrets, ) dashboard_app = create_dashboard_app(dashboard_ctx) self._dashboard_runner = web.AppRunner(dashboard_app) diff --git a/src/panelbench/bess_link.py b/src/panelbench/bess_link.py new file mode 100644 index 0000000..b79b9ec --- /dev/null +++ b/src/panelbench/bess_link.py @@ -0,0 +1,20 @@ +"""Health of the panel's link to its battery, as a producer reports it. + +The values are the emitter's ``BESSCommunication`` — the ``status`` catalog's +``communication-state`` enum. Kept in one place so the engine, which stores the +requested health, and the dashboard, which validates what a user sends, agree +on what is legal without either re-deriving it. +""" + +from __future__ import annotations + +from typing import Final, TypeIs, get_args + +from ebus_panel_sim import BESSCommunication + +BESS_LINKS: Final[frozenset[str]] = frozenset(get_args(BESSCommunication)) + + +def is_bess_link(value: object) -> TypeIs[BESSCommunication]: + """Whether *value* is a link health the emitter accepts.""" + return value in BESS_LINKS diff --git a/src/panelbench/bootstrap.py b/src/panelbench/bootstrap.py index 89feec2..dc031cf 100644 --- a/src/panelbench/bootstrap.py +++ b/src/panelbench/bootstrap.py @@ -11,10 +11,11 @@ registration passphrase over the TLS port under that anchor. Endpoints: - GET /api/v2/status -> panel identity (serialNumber, firmwareVersion) + GET /api/v2/status -> panel identity (serialNumber, firmwareVersion, + and hardwareVersion from r202639) POST /api/v2/auth/register -> JWT + MQTT credentials (camelCase fields) GET /api/v2/certificate/ca -> self-signed CA PEM - GET /api/v2/homie/schema -> Homie property schema JSON + GET /api/v2/homie/schema -> Homie property schema JSON (the panel's firmwareVersion) """ from __future__ import annotations @@ -64,6 +65,7 @@ def __init__( host: str = "0.0.0.0", port: int = 443, https_port: int = DEFAULT_HTTPS_PORT, + hardware_version: str | None = None, ) -> None: self._serial = serial self._firmware = firmware @@ -74,6 +76,7 @@ def __init__( self._host = host self._port = port self._https_port = https_port + self._hardware_version = hardware_version self._homie_schema = schema.raw_json self._app = web.Application() @@ -95,15 +98,19 @@ async def _handle_status(self, _request: web.Request) -> web.Response: The ``?serial=`` query parameter is accepted but ignored — each server only knows about one panel. - Response matches real panel: ``{"serialNumber": "...", "firmwareVersion": "..."}`` + Matches the panel's ``StatusV2Out``: ``serialNumber``, ``firmwareVersion`` + and ``proximityProven``, plus ``hardwareVersion``, which SPAN release + 202639 made required and earlier releases do not send. The caller passes + ``None`` for a panel on an earlier release. """ - return web.json_response( - { - "serialNumber": self._serial, - "firmwareVersion": self._firmware, - "proximityProven": True, - } - ) + payload: dict[str, object] = { + "serialNumber": self._serial, + "firmwareVersion": self._firmware, + "proximityProven": True, + } + if self._hardware_version is not None: + payload["hardwareVersion"] = self._hardware_version + return web.json_response(payload) async def _handle_register(self, request: web.Request) -> web.Response: """POST /api/v2/auth/register — return MQTT credentials. @@ -112,6 +119,12 @@ async def _handle_register(self, request: web.Request) -> web.Response: by the simulator — any passphrase is accepted). Response matches real panel's camelCase field names exactly. + + SPAN release 202639 made ``hopPassphrase`` and ``ebusBrokerPassword`` + nullable and added a 422 (``Dashboard password is not available``) and a + 503 (``Serial number is not available yet``) for a panel whose passphrase + or serial is unavailable. A simulated panel always has both, so it never + takes those paths, on any firmware. """ body: dict[str, str] = {} with contextlib.suppress(Exception): diff --git a/src/panelbench/circuit.py b/src/panelbench/circuit.py index 950aae0..0777a9e 100644 --- a/src/panelbench/circuit.py +++ b/src/panelbench/circuit.py @@ -47,7 +47,6 @@ def __init__( "typical_power", "power_variation", "efficiency", - "nameplate_capacity_w", } for key, value in circuit_def["overrides"].items(): if key in _ENERGY_PROFILE_KEYS: diff --git a/src/panelbench/clone.py b/src/panelbench/clone.py index ff0a394..f82eaa4 100644 --- a/src/panelbench/clone.py +++ b/src/panelbench/clone.py @@ -1,8 +1,8 @@ """eBus-to-YAML translation — converts a discovered panel into a simulator config. -Pure data transformation: takes a ``ScrapedPanel`` (a tree of discovered Homie -devices) and produces a complete YAML config dict matching the ``SimulationConfig`` -TypedDict shape. +Pure data transformation: takes a panel's tree of discovered Homie devices — scraped +from a live panel, or read back from a published capture — and produces a complete +YAML config dict matching the ``SimulationConfig`` TypedDict shape. Design principles: - Each circuit gets its own template (``clone_{first position}``) for per-circuit @@ -15,10 +15,14 @@ from __future__ import annotations +import json import logging +import math +import re from collections.abc import Mapping from datetime import UTC, datetime -from typing import TYPE_CHECKING +from types import MappingProxyType +from typing import TYPE_CHECKING, Final import yaml from ebus_sdk import DiscoveredDevice @@ -33,6 +37,36 @@ TYPE_BESS = "energy.ebus.device.bess" TYPE_PV = "energy.ebus.device.pv" TYPE_EVSE = "energy.ebus.device.evse" +TYPE_MID = "energy.ebus.device.mid" + +COMMISSIONED_SYSTEM_FEEDS: Final[Mapping[str, str]] = MappingProxyType( + {TYPE_PV: "pv", TYPE_BESS: "backup"} +) +"""The system a commissioned circuit belongs to, by the type of device it feeds.""" + +COMMISSIONED_SYSTEM_NAMES: Final[Mapping[str, str]] = MappingProxyType( + {"Commissioned PV System": "pv", "Commissioned Backup System": "backup"} +) +"""The names a SPAN panel gives the circuits it adds for a commissioned system. + +Read only when a circuit publishes no ``connection`` to say what it feeds: the name +is the user's to change, and a renamed circuit is commissioned all the same. +""" + +# A main breaker rating for a panel that publishes none, reported when used: the +# config has no way yet to say a panel has none. +_DEFAULT_MAIN_BREAKER_A: Final = 200 +# What sizes the simulated power range of a circuit that publishes no rating. Only +# that: the clone records the rating as absent, and publishes none of its own. +_MODELLING_BREAKER_A: Final = 20 + +# A battery that publishes no `info/nominal-power` is given 5 kW per 13.5 kWh, a +# Powerwall 2's continuous rating: the most common battery behind these panels, and +# one that scales exactly for a stack of them (six, 81 kWh, are 30 kW). +_BESS_W_PER_KWH_UNPUBLISHED: Final = 5000.0 / 13.5 + +# What a panel publishing no size is rounded up to from its highest occupied space. +_STANDARD_PANEL_SIZES: Final = (16, 24, 32, 40, 48) if TYPE_CHECKING: from pathlib import Path @@ -41,6 +75,37 @@ _LOGGER = logging.getLogger(__name__) +# The `info` properties a clone carries for each kind of DER, and the config key each +# is written to. Each set is exactly what `spec_generator` publishes from that key, so +# a clone republishes what it read: a PV circuit's and an EVSE circuit's keys, and the +# top-level `bess` section's. +_PV_IDENTITY: Final = ( + ("serial-number", "serial_number"), + ("model", "model"), + ("vendor-name", "vendor"), + ("firmware-version", "firmware_version"), +) +_EVSE_IDENTITY: Final = ( + ("serial-number", "serial_number"), + ("firmware-version", "firmware_version"), +) +# A battery's MID publishes its own identity, which the clone keeps on the battery's +# section under the keys `spec_generator` builds the MID from. +_MID_IDENTITY: Final = ( + ("vendor-name", "mid_vendor"), + ("serial-number", "mid_serial_number"), + ("model", "mid_product_name"), + ("firmware-version", "mid_firmware_version"), + ("hardware-version", "mid_hardware_version"), +) +_BESS_IDENTITY: Final = ( + ("vendor-name", "vendor"), + ("model", "model"), + ("part-number", "part_number"), + ("serial-number", "serial_number"), + ("firmware-version", "firmware_version"), +) + def make_clone_serial(original_serial: str) -> str: """Derive the clone serial from an original panel serial. @@ -90,34 +155,59 @@ def translate_scraped_panel( scraped: ScrapedPanel, *, host: str | None = None, - passphrase: str | None = None, ) -> dict[str, object]: """Translate a scraped panel into a simulator config dict. Args: scraped: The scraped panel data. host: Source panel IP/hostname (stored in panel_source for refresh). - passphrase: Source panel passphrase (stored in panel_source for refresh). Returns a dict matching the ``SimulationConfig`` TypedDict shape, ready for YAML serialisation and ``validate_yaml_config()``. """ - root = scraped.serial_number - circuit_nodes = _devices_of_type(scraped.devices, root, TYPE_CIRCUIT) - bess_nodes = _devices_of_type(scraped.devices, root, TYPE_BESS) - pv_nodes = _devices_of_type(scraped.devices, root, TYPE_PV) - evse_nodes = _devices_of_type(scraped.devices, root, TYPE_EVSE) + return translate_panel_tree(scraped.serial_number, scraped.devices, host=host) + + +def translate_panel_tree( + panel_device_id: str, + devices: Mapping[str, DiscoveredDevice], + *, + host: str | None = None, +) -> dict[str, object]: + """Translate a panel's device tree into a simulator config dict. + + Args: + panel_device_id: The panel's Homie device id, which roots the tree. On a + live SPAN panel it is the serial number. + devices: Every discovered device; only *panel_device_id*'s tree is read. + host: Source panel IP/hostname (stored in panel_source for refresh). What it + takes to reach the panel there is kept in the ``PanelSecretsStore``, + never in the config. + + Returns a dict matching the ``SimulationConfig`` TypedDict shape, + ready for YAML serialisation and ``validate_yaml_config()``. + """ + circuit_nodes = _devices_of_type(devices, panel_device_id, TYPE_CIRCUIT) + bess_nodes = _devices_of_type(devices, panel_device_id, TYPE_BESS) + pv_nodes = _devices_of_type(devices, panel_device_id, TYPE_PV) + evse_nodes = _devices_of_type(devices, panel_device_id, TYPE_EVSE) # Build feed cross-reference: circuit_uuid → device_type - feed_map = _build_feed_map(scraped.devices, circuit_nodes) + feed_map = _build_feed_map(devices, circuit_nodes) # Extract panel-level values - main_breaker = _int_prop(scraped.devices, scraped.serial_number, "breaker", "rating") or 200 + main_breaker = _int_prop(devices, panel_device_id, "breaker", "rating") + if not main_breaker: + _LOGGER.warning( + "Panel %s publishes no breaker/rating; cloning it with a %d A main breaker", + panel_device_id, + _DEFAULT_MAIN_BREAKER_A, + ) + main_breaker = _DEFAULT_MAIN_BREAKER_A - # Derive panel size from maximum space value across all circuits - total_tabs = _derive_total_tabs(scraped.devices, circuit_nodes) + total_tabs = _derive_total_tabs(devices, panel_device_id, circuit_nodes) - clone_serial = make_clone_serial(scraped.serial_number) + clone_serial = make_clone_serial(panel_device_id) panel_config: dict[str, object] = { "serial_number": clone_serial, @@ -126,6 +216,21 @@ def translate_scraped_panel( "latitude": 37.7, "longitude": -122.4, } + panel_device = devices.get(panel_device_id) + panel_description = panel_device.description if panel_device is not None else None + if isinstance(panel_description, dict) and isinstance(panel_description.get("name"), str): + panel_config["display_name"] = panel_description["name"] + vendor = _get_prop(devices, panel_device_id, "info", "vendor-name") + if vendor: + panel_config["vendor_name"] = vendor + model = _get_prop(devices, panel_device_id, "info", "model") + if model: + panel_config["model"] = model + _copy_envelope(devices, panel_device_id, panel_config) + _copy_site_values(devices, panel_device_id, panel_config) + shed_threshold = _soc_shed_threshold(devices, panel_device_id) + if shed_threshold is not None: + panel_config["soc_shed_threshold"] = shed_threshold # Build per-circuit templates and definitions templates: dict[str, dict[str, object]] = {} @@ -134,7 +239,7 @@ def translate_scraped_panel( for node_uuid in sorted(circuit_nodes): result = _translate_circuit( - scraped.devices, + devices, node_uuid, feed_map, ) @@ -148,11 +253,11 @@ def translate_scraped_panel( # Enrich PV circuit template for pv_id in pv_nodes: - _enrich_pv_template(scraped.devices, pv_id, feed_map, templates) + _enrich_pv_template(devices, circuit_nodes, pv_id, feed_map, templates, circuits) # Enrich EVSE circuit templates for evse_id in evse_nodes: - _enrich_evse_template(scraped.devices, evse_id, feed_map, templates) + _enrich_evse_template(devices, circuit_nodes, evse_id, feed_map, templates, circuits) # Unmapped tabs all_tabs = set(range(1, total_tabs + 1)) @@ -171,17 +276,30 @@ def translate_scraped_panel( }, } + # The panel's firmware decides which side of SPAN release 202639 the clone + # publishes (the BESS meter's sign, the EVSE limit), so a clone of a panel on an + # earlier release must keep it rather than inherit the simulator's own string. + firmware = _get_prop(devices, panel_device_id, "info", "firmware-version") + if firmware: + config["firmware_version"] = firmware + + # Published as info/hardware-version and, from SPAN release 202639, reported by + # the status endpoint, so a clone reports the source panel's value on both. + hardware = _get_prop(devices, panel_device_id, "info", "hardware-version") + if hardware: + config["hardware_version"] = hardware + # Build top-level BESS config (only when a battery is actually connected) + mid_nodes = _devices_of_type(devices, panel_device_id, TYPE_MID) for bess_id in bess_nodes: - bess_cfg = _build_bess_config(scraped.devices, bess_id) + bess_cfg = _build_bess_config(devices, bess_id, mid_nodes) if bess_cfg is not None: config["bess"] = bess_cfg if host is not None: panel_source: dict[str, object] = { - "origin_serial": scraped.serial_number, + "origin_serial": panel_device_id, "host": host, - "passphrase": passphrase, "last_synced": datetime.now(UTC).isoformat(), } # Snapshot the original BESS config so the modeling Before pass @@ -403,9 +521,11 @@ def _float_prop( if raw is None: return None try: - return float(raw) + value = float(raw) except ValueError: return None + # `inf` and `nan` parse, and mean nothing a config can carry: read them as unpublished. + return value if math.isfinite(value) else None def _int_prop( @@ -415,13 +535,8 @@ def _int_prop( prop: str, ) -> int | None: """An integer property, tolerating a float-formatted payload.""" - raw = _get_prop(devices, device_id, capability, prop) - if raw is None: - return None - try: - return int(float(raw)) - except ValueError: - return None + value = _float_prop(devices, device_id, capability, prop) + return None if value is None else int(value) def _bool_prop( @@ -513,16 +628,19 @@ def _devices_of_type( def _circuit_feeding( devices: Mapping[str, DiscoveredDevice], + circuit_nodes: list[str], target_device_id: str, ) -> str | None: - """The circuit that feeds ``target_device_id``, or None. + """The circuit of this panel that feeds ``target_device_id``, or None. The reverse of the flat schema's lookup. A DER used to name its circuit through ``feed``; now the circuit names the DER through ``connection/feeds-device-id``, so finding a DER's circuit means searching the circuits rather than reading one - property off the DER. + property off the DER. Only *circuit_nodes*, this panel's circuits: a broker + serving two panels hands back both trees, and another device publishing + ``connection`` is not a circuit of this one. """ - for device_id in devices: + for device_id in circuit_nodes: if _get_prop(devices, device_id, "connection", "feeds-device-id") == target_device_id: return device_id return None @@ -560,29 +678,132 @@ def _build_feed_map( def _derive_total_tabs( devices: Mapping[str, DiscoveredDevice], + panel_device_id: str, circuit_nodes: list[str], ) -> int: - """Derive panel size from the highest breaker position any circuit occupies. + """The panel's size: what it publishes, else its highest occupied space. + + An ``info/panel-size``, where one is published, then the numeric suffix of + ``info/model`` (``MAIN_32``), as upstream's capture reads it. Only without either + does the highest breaker position decide, rounded up to a standard size, and + that undercounts any panel whose top spaces are empty. - v1.0 publishes the positions directly in ``info/spaces``, so a 240 V circuit - already reports both. The flat schema published one position plus a ``dipole`` - flag and left the consumer to infer the companion as position + 2 — that - inference is gone, along with the class of bug where a panel wired against the - convention was silently mis-sized. + A published size the circuits do not fit in is reported and set aside, since the + clone would place a circuit on a space the panel does not have. """ max_space = 0 for node_id in circuit_nodes: for space in _spaces_prop(devices, node_id): max_space = max(max_space, space) - # Round up to standard panel sizes - for standard_size in (16, 24, 32, 40, 48): + published = _published_panel_size(devices, panel_device_id) + if published is not None: + if published >= max_space: + return published + _LOGGER.warning( + "Panel %s publishes a size of %d (model %s) but has a circuit on space %d; " + "sizing the clone from its circuits", + panel_device_id, + published, + _get_prop(devices, panel_device_id, "info", "model"), + max_space, + ) + + for standard_size in _STANDARD_PANEL_SIZES: if max_space <= standard_size: return standard_size return max_space +def _published_panel_size( + devices: Mapping[str, DiscoveredDevice], + panel_device_id: str, +) -> int | None: + """The size the panel's ``info/model`` names by its numeric suffix (``MAIN_32``). + + A panel publishes no size of its own: upstream's capture derives one the same way. + """ + model = _get_prop(devices, panel_device_id, "info", "model") or "" + suffix = re.search(r"(\d+)$", model) + return int(suffix.group(1)) if suffix else None + + +def _soc_shed_threshold( + devices: Mapping[str, DiscoveredDevice], + panel_device_id: str, +) -> float | None: + """The off-grid SOC shed threshold from the panel's published ``shed/policy``.""" + raw = _get_prop(devices, panel_device_id, "shed", "policy") + if raw is None: + return None + try: + threshold = json.loads(raw)["parameters"]["soc-threshold-shed"] + return float(threshold) + except (ValueError, KeyError, TypeError): + _LOGGER.warning( + "Panel %s publishes a shed/policy with no soc-threshold-shed", panel_device_id + ) + return None + + +# The panel envelope's network configuration: the links the panel says it has, and +# the panel_config key each is kept under for the emitter's envelope to publish. Not +# its door or cloud state: those are live state, which the emulator simulates, like +# power, and a copy would freeze them at the moment of the clone. +_ENVELOPE_FLAGS: Final = (("wifi", "wifi_link"), ("ethernet", "ethernet_link")) + + +def _copy_envelope( + devices: Mapping[str, DiscoveredDevice], + panel_device_id: str, + panel_config: dict[str, object], +) -> None: + """The panel's network configuration, absences included. + + A panel on Ethernet publishes Wi-Fi down and no SSID; its clone records both, so + it publishes neither a Wi-Fi link nor PanelBench's default network. The SSID is + written as null only for a panel that states its links and names no network. + """ + for prop, key in _ENVELOPE_FLAGS: + flag = _bool_prop(devices, panel_device_id, "status", prop) + if flag is not None: + panel_config[key] = flag + ssid = _get_prop(devices, panel_device_id, "status", "wifi-ssid") + if ssid: + panel_config["wifi_ssid"] = ssid + elif "wifi_link" in panel_config: + panel_config["wifi_ssid"] = None + + +def _copy_site_values( + devices: Mapping[str, DiscoveredDevice], + panel_device_id: str, + panel_config: dict[str, object], +) -> None: + """The panel's time zone and line voltages, where it publishes them. + + A simulated panel publishes one per-leg voltage on both legs, so the clone takes + the mean of the legs the source publishes, and their sum (or twice the one) as + the service voltage. A value the panel does not publish is left unwritten, so + the config's default applies. The postal code is deliberately not copied. + """ + time_zone = _get_prop(devices, panel_device_id, "status", "time-zone") + if time_zone: + panel_config["time_zone"] = time_zone + + legs = [ + voltage + for leg in ("voltage-a", "voltage-b") + if (voltage := _float_prop(devices, panel_device_id, "meter", leg)) is not None + and voltage > 0 + ] + if legs: + line = sum(legs) / len(legs) + panel_config["line_voltage_v"] = round(line, 2) + panel_config["service_voltage_v"] = round(2 * line, 2) + + def _translate_circuit( devices: Mapping[str, DiscoveredDevice], node_uuid: str, @@ -600,7 +821,11 @@ def _translate_circuit( space = tabs[0] name = _get_prop(devices, node_uuid, "info", "name") or f"Circuit {space}" - breaker_rating = _int_prop(devices, node_uuid, "breaker", "rating") or 20 + # Faithful to what the panel publishes: a rating it does not publish stays absent + # in the clone. The simulated power range still needs a size, so a modelling one + # stands in for that alone. + breaker_rating = _int_prop(devices, node_uuid, "breaker", "rating") or None + modelling_rating = breaker_rating or _MODELLING_BREAKER_A active_power = _float_prop(devices, node_uuid, "meter", "active-power") priority = _get_prop(devices, node_uuid, "load-shed", "priority") or "NEVER" # v1.0 publishes controllability directly as `switch/relay-controllable`, where @@ -622,7 +847,15 @@ def _translate_circuit( # Deliberately not derived from `priority == "NEVER"`. `NEVER` is an ordinary settable # value meaning "never shed"; a production capture publishes two `NEVER` circuits with # `$settable = true`, which no value-derived flag can produce. - never_backup = not _is_settable(devices, node_uuid, "load-shed", "priority") + priority_locked = not _is_settable(devices, node_uuid, "load-shed", "priority") + # A circuit SPAN adds for a commissioned PV or battery system is the third lock: + # priority locked at NEVER on a locked relay. + commissioned_system = ( + _commissioned_system(devices, node_uuid, name) + if priority_locked and not relay_controllable and priority == "NEVER" + else None + ) + never_backup = priority_locked and commissioned_system is None if never_backup and priority != "OFF_GRID": # The panel contradicted itself: a circuit commissioned never-backup *is* # permanently OFF_GRID, and the emitter rejects the pair at construction rather @@ -645,10 +878,10 @@ def _translate_circuit( mode = _device_role_to_mode(device_role) # Relay behavior - relay_behavior = "controllable" if relay_controllable else "non_controllable" + relay_behavior = "controllable" if relay_controllable else "non-controllable" # Power range and typical power - max_power = breaker_rating * voltage + max_power = modelling_rating * voltage typical = abs(active_power) if active_power is not None else max_power * 0.3 # Clamp typical to max typical = min(typical, max_power) @@ -701,6 +934,12 @@ def _translate_circuit( if never_backup: template["never_backup"] = True + # A commissioned-system circuit, as capture's rule recognises it. Written with + # the locks it implies already present above (relay non-controllable, priority + # NEVER), so the cloned template validates. + if commissioned_system is not None: + template["commissioned_system"] = commissioned_system + if device_role == "evse": template["device_type"] = "evse" elif device_role == "pv": @@ -715,11 +954,32 @@ def _translate_circuit( "name": name, "template": template_name, "tabs": tabs, + # The panel's own value, or none where it publishes none, as a commissioned + # PV circuit does: never the position-derived one PanelBench's configs get. + "pcs_priority": _int_prop(devices, node_uuid, "pcs", "priority"), } return template_name, template, circuit_def, tabs +def _commissioned_system( + devices: Mapping[str, DiscoveredDevice], + node_uuid: str, + name: str, +) -> str | None: + """The commissioned system a doubly locked circuit belongs to, if any. + + What the circuit feeds decides it: an inverter makes it the PV system, a battery + the backup system, anything else neither. Only a circuit that publishes no + ``connection`` falls back to the name SPAN gives such a circuit, which the user + may since have changed. + """ + feeds = _get_prop(devices, node_uuid, "connection", "feeds-device-type") + if feeds: + return COMMISSIONED_SYSTEM_FEEDS.get(feeds) + return COMMISSIONED_SYSTEM_NAMES.get(name) + + def _device_role_to_mode(device_role: str | None) -> str: """Map a device role from the feed map to an energy profile mode.""" if device_role == "pv": @@ -732,6 +992,7 @@ def _device_role_to_mode(device_role: str | None) -> str: def _build_bess_config( devices: Mapping[str, DiscoveredDevice], bess_node_id: str, + mid_nodes: list[str], ) -> dict[str, object] | None: """Build top-level bess config from scraped BESS node properties. @@ -741,29 +1002,53 @@ def _build_bess_config( nameplate = _float_prop(devices, bess_node_id, "info", "nameplate-capacity") if not nameplate: return None - - return { + # `info/nominal-power` is the battery's "nameplate maximum rated power output" + # (eBus devices/bess.md, `info`); a battery that publishes none is scaled from its + # capacity by `_BESS_W_PER_KWH_UNPUBLISHED`. Either way, charge as discharge. + rated_power = _float_prop(devices, bess_node_id, "info", "nominal-power") + if rated_power is None or rated_power <= 0: + rated_power = nameplate * _BESS_W_PER_KWH_UNPUBLISHED + + bess: dict[str, object] = { "enabled": True, "charge_mode": "custom", "nameplate_capacity_kwh": nameplate, "backup_reserve_pct": 20.0, "charge_efficiency": 0.95, "discharge_efficiency": 0.95, - "max_charge_w": 3500.0, - "max_discharge_w": 3500.0, + "max_charge_w": rated_power, + "max_discharge_w": rated_power, "charge_hours": [0, 1, 2, 3, 4, 5], "discharge_hours": [16, 17, 18, 19, 20, 21], } + # The battery's identity, so a clone republishes the battery it read. Its serial + # also names its device id, `-`, under the clone's own panel id. + _copy_info(devices, bess_node_id, bess, _BESS_IDENTITY) + mid = next( + ( + mid_id + for mid_id in mid_nodes + if (description := devices[mid_id].description) is not None + and description.get("parent") == bess_node_id + ), + None, + ) + if mid is not None: + _copy_info(devices, mid, bess, _MID_IDENTITY) + return bess def _enrich_pv_template( devices: Mapping[str, DiscoveredDevice], + circuit_nodes: list[str], pv_node_id: str, feed_map: dict[str, str], templates: dict[str, dict[str, object]], + circuits: list[dict[str, object]], ) -> None: - """Enrich the PV circuit template with nameplate capacity and solar profile.""" - circuit_uuid = _circuit_feeding(devices, pv_node_id) + """Enrich the PV circuit template with nameplate capacity and solar profile, and + write the inverter's identity onto the circuit that feeds it.""" + circuit_uuid = _circuit_feeding(devices, circuit_nodes, pv_node_id) template = _find_template_for_feed(circuit_uuid, feed_map, templates, devices) if template is None: return @@ -777,15 +1062,24 @@ def _enrich_pv_template( ep["power_range"] = [-nameplate, 0.0] ep["typical_power"] = -nameplate * 0.6 + # The inverter's identity and firmware belong to the circuit that feeds it, so a + # clone of a panel with several inverters republishes each as it was. + circuit = _circuit_using(template, templates, circuits) + if circuit is not None: + _copy_info(devices, pv_node_id, circuit, _PV_IDENTITY) + def _enrich_evse_template( devices: Mapping[str, DiscoveredDevice], + circuit_nodes: list[str], evse_node_id: str, feed_map: dict[str, str], templates: dict[str, dict[str, object]], + circuits: list[dict[str, object]], ) -> None: - """Enrich the EVSE circuit template with time-of-day charging profile.""" - circuit_uuid = _circuit_feeding(devices, evse_node_id) + """Enrich the EVSE circuit template with time-of-day charging profile, and write + the drive's serial and firmware onto the circuit that feeds it.""" + circuit_uuid = _circuit_feeding(devices, circuit_nodes, evse_node_id) template = _find_template_for_feed(circuit_uuid, feed_map, templates, devices) if template is None: return @@ -795,6 +1089,42 @@ def _enrich_evse_template( "hour_factors": dict(_NIGHT_CHARGING_HOURS), } + # On the circuit, as for an inverter: the clone orders circuits by source device + # id, so a serial derived from position could move a drive's serial onto another + # drive. Verbatim, because the drive's device id is already scoped by the clone's + # own panel id and cannot collide with the source's on one broker. + circuit = _circuit_using(template, templates, circuits) + if circuit is not None: + _copy_info(devices, evse_node_id, circuit, _EVSE_IDENTITY) + + +def _circuit_using( + template: dict[str, object], + templates: dict[str, dict[str, object]], + circuits: list[dict[str, object]], +) -> dict[str, object] | None: + """The circuit definition built on *template*, which a clone gives one circuit.""" + template_name = next((name for name, value in templates.items() if value is template), None) + return next((c for c in circuits if c.get("template") == template_name), None) + + +def _copy_info( + devices: Mapping[str, DiscoveredDevice], + device_id: str, + target: dict[str, object], + keys: tuple[tuple[str, str], ...], +) -> None: + """Write each of *device_id*'s published ``info`` values onto *target*. + + *keys* pairs an ``info`` property with the config key it is written to. A + property the device does not value is left unwritten, so the config's default + applies to it. + """ + for prop, key in keys: + value = _get_prop(devices, device_id, "info", prop) + if value: + target[key] = value + def _find_template_for_feed( circuit_uuid: str | None, diff --git a/src/panelbench/config_defaults.py b/src/panelbench/config_defaults.py index 7cb9d29..3dfd3bb 100644 --- a/src/panelbench/config_defaults.py +++ b/src/panelbench/config_defaults.py @@ -6,7 +6,7 @@ presentation one: config loading needs them, and core depending on the UI layer to learn them would be backwards. -``normalize_circuit_templates`` applies them at load. A config that omits +``normalize_config`` applies them at load. A config that omits ``energy_profile`` is structurally complete and behaviourally inert rather than invalid: the wire surface a template produces is fixed by ``device_type``, ``relay_behavior``, ``priority`` and ``breaker_rating``, while the energy profile @@ -18,7 +18,12 @@ from __future__ import annotations from copy import deepcopy -from typing import Any +from typing import TYPE_CHECKING, Any + +from panelbench.pv_rating import fold_legacy_ratings + +if TYPE_CHECKING: + from collections.abc import Mapping ENTITY_TYPE_DEFAULTS: dict[str, dict[str, Any]] = { "circuit": { @@ -47,7 +52,7 @@ "efficiency": 0.85, "nameplate_capacity_w": 5000.0, }, - "relay_behavior": "non_controllable", + "relay_behavior": "non-controllable", "priority": "NEVER", "device_type": "pv", "breaker_rating": 30, @@ -109,12 +114,6 @@ } -# Template top-level keys that belong inside ``energy_profile``. The eBus -# emitter's example configs put ``nameplate_capacity_w`` at template top level -# (its ``_pv_instance`` reads it there); this package reads it from the nested -# profile. Promoting rather than duplicating lets one config drive both. -_PROMOTABLE_TO_PROFILE = ("nameplate_capacity_w",) - _DEFAULT_DEVICE_TYPE = "circuit" @@ -125,39 +124,33 @@ def default_energy_profile(device_type: str) -> dict[str, Any]: return deepcopy(profile) -def normalize_circuit_templates(config_data: Any) -> None: - """Fill in omitted ``energy_profile`` blocks, in place, before validation. +def normalize_config(config: Mapping[str, object], *, source: str) -> None: + """Make *config* say what its templates mean, in place, before validation. - Typed ``Any`` for the same reason ``validate_yaml_config`` next door is: - callers hold either a raw ``dict`` from ``yaml.safe_load`` or a - ``SimulationConfig`` TypedDict, and a TypedDict is not assignable to - ``dict[str, Any]`` — mypy rejects it, since mutating one through a plain-dict - alias could violate its declared keys. Narrowing the parameter would push a - cast onto every caller and buy nothing real. - - - Two steps, both keyed off what the template already declares: + The one normaliser every loader runs -- the engine, the dashboard on load and on + save, and the history generator -- so each reads the same config. Takes the + config as YAML hands it over, before validation has typed it, and changes only + what it finds well formed; validation names the rest. 1. A template with no ``energy_profile`` gets the default for its ``device_type`` -- producer shape for ``pv``, high-power consumer for ``evse``, ordinary consumer otherwise. - 2. Energy-profile keys written at template top level are moved into the - profile. An explicit value always wins over the default, whichever level - it was written at, so this cannot silently override what a config says. + 2. Every legacy rating folds into its template's + ``energy_profile.nameplate_capacity_w``, the one source of a circuit's + rating (``pv_rating``). A default from step 1 is not a rating the config + stated, so a legacy one replaces it. A stale copy dropped beside a stated + profile is logged against *source*, the file or config being read. """ - templates = config_data.get("circuit_templates") + templates = config.get("circuit_templates") if not isinstance(templates, dict): return - for template in templates.values(): + defaulted: set[str] = set() + for name, template in templates.items(): if not isinstance(template, dict): continue - device_type = template.get("device_type", _DEFAULT_DEVICE_TYPE) if "energy_profile" not in template: + device_type = str(template.get("device_type", _DEFAULT_DEVICE_TYPE)) template["energy_profile"] = default_energy_profile(device_type) - profile = template["energy_profile"] - if not isinstance(profile, dict): - continue - for key in _PROMOTABLE_TO_PROFILE: - if key in template: - profile[key] = template[key] + defaulted.add(str(name)) + fold_legacy_ratings(config, unstated=frozenset(defaulted), source=source) diff --git a/src/panelbench/config_types.py b/src/panelbench/config_types.py index eb38cea..9a24fa5 100644 --- a/src/panelbench/config_types.py +++ b/src/panelbench/config_types.py @@ -11,11 +11,14 @@ class PanelSource(TypedDict, total=False): - """Source panel connection details for clone configs.""" + """Source panel provenance for clone configs. + + What it takes to reach the panel, its passphrase and broker credentials, is kept + in the ``PanelSecretsStore`` under ``origin_serial``, never here. + """ origin_serial: str # real panel's serial (immutable provenance) host: str # IP or hostname of the source panel - passphrase: str | None # proximity code (null for door-bypass) last_synced: str # ISO 8601 timestamp of last sync @@ -35,7 +38,14 @@ class PanelConfig(TypedDict): service_voltage_v: NotRequired[float] # service voltage (default 240.0) line_voltage_v: NotRequired[float] # per-leg voltage (default 120.0) islandable: NotRequired[bool] # Explicit override for whether the panel can island. - wifi_ssid: NotRequired[str] # SSID published on status/wifi-ssid (default DEFAULT_WIFI_SSID) + vendor_name: NotRequired[str] # info/vendor-name (default "Span"); a clone keeps its panel's + model: NotRequired[str] # info/model (default: from total_tabs); a clone keeps its panel's + # The panel's network configuration, as a clone reads it from its panel. Defaults: + # both links up, and DEFAULT_WIFI_SSID while Wi-Fi is up. A null wifi_ssid is a + # panel that publishes none, as one on Ethernet does. + wifi_link: NotRequired[bool] + ethernet_link: NotRequired[bool] + wifi_ssid: NotRequired[str | None] class CyclingPattern(TypedDict, total=False): @@ -99,7 +109,7 @@ class CircuitTemplate(TypedDict): """Circuit template configuration.""" energy_profile: EnergyProfileExtended - relay_behavior: str # "controllable", "non_controllable" + relay_behavior: str # "controllable", "non-controllable", "always-on" priority: str # "MUST_HAVE", "NON_ESSENTIAL" @@ -122,6 +132,8 @@ class BESSConfigYAML(TypedDict, total=False): mid_product_name: str mid_firmware_version: str mid_hardware_version: str + mid_serial_number: str # the MID's own serial, as a clone reads it; else `-mid` + mid_vendor: str # the MID's own vendor, as a clone reads it; else the battery's serial_number: str firmware_version: str relative_position: Literal["UPSTREAM", "DOWNSTREAM", "IN_PANEL"] @@ -157,6 +169,8 @@ class PVConfigYAML(TypedDict, total=False): inverter_type: Literal["hybrid", "ac_coupled", "ac-coupled"] firmware_version: str relative_position: Literal["UPSTREAM", "DOWNSTREAM", "IN_PANEL"] + # The `id` of the PV circuit feeding the inverter this section describes: see + # `spec_generator.pv_section_circuit`. feed: str @@ -201,7 +215,9 @@ class CircuitTemplateExtended(CircuitTemplate, total=False): inverter_type: str hvac_type: str # "central_ac", "heat_pump", "heat_pump_aux" monthly_factors: dict[int, float] # month (1-12) -> multiplier (1.0 = peak month) - breaker_rating: int # Breaker rating in Amps (derived from power_range if not set) + # Breaker rating in Amps. None records a panel that publishes none, as a clone + # writes it; absent, 20. + breaker_rating: int | None breaker_rating_a: float # Legacy alias for breaker_rating, used by older clones. recorder_entity: str # HA entity ID for recorder replay (e.g. "sensor.span_panel_..._power") user_modified: bool # True when user has edited profile → use synthetic instead of replay @@ -211,6 +227,11 @@ class CircuitTemplateExtended(CircuitTemplate, total=False): # ordinary settable value meaning "never shed", not this. The emitter rejects the # pair when `priority` is anything but `OFF_GRID`. never_backup: bool + # The circuit a SPAN panel adds for a commissioned PV or battery system: + # its relay is locked and `load-shed/priority` is permanently NEVER, neither + # settable. A different lock from `never_backup`, which is permanently OFF_GRID on + # an otherwise controllable relay; validation refuses a template claiming both. + commissioned_system: Literal["pv", "backup"] class CircuitDefinition(TypedDict): @@ -228,6 +249,19 @@ class CircuitDefinitionExtended(CircuitDefinition, total=False): overrides: dict[str, Any] breaker_rating: int # Per-circuit breaker rating in Amps (overrides template) recorder_entity: str # HA entity for recorder replay (also merged onto resolved template) + # The identity of the device this circuit feeds: a DER's identity belongs to the + # circuit, not to its position in a list (see `instance_ids.evse_circuit_serial`). + # Read for a PV circuit's inverter, where the top-level `pv` section is the + # default; `serial_number` is also read for an EVSE circuit's drive. + serial_number: str + model: str + vendor: str + # Not identity, so the `pv` or `evse` section's is every inverter's or drive's + # default, not only the first's. + firmware_version: str + # pcs/priority. Absent, the circuit's position; None, a panel that publishes none + # for it, as a clone writes it. + pcs_priority: int | None class TabSynchronization(TypedDict): diff --git a/src/panelbench/const.py b/src/panelbench/const.py index 122acfb..292b061 100644 --- a/src/panelbench/const.py +++ b/src/panelbench/const.py @@ -42,8 +42,9 @@ def https_port_for(http_port: int) -> int: PATH_HOMIE_SCHEMA = "/api/v2/homie/schema" -# Simulated firmware version — derived from the package version so that -# HTTP bootstrap, MQTT snapshots, and mDNS all report the same value. +# Default firmware version for a config that names no ``firmware_version``, +# derived from the package version. ``panelbench.firmware`` decides each panel's +# string, which its MQTT tree, HTTP status and mDNS advertisement all report. def _firmware_version() -> str: from panelbench import __version__ diff --git a/src/panelbench/dashboard/__init__.py b/src/panelbench/dashboard/__init__.py index 22b8350..1ab1b42 100644 --- a/src/panelbench/dashboard/__init__.py +++ b/src/panelbench/dashboard/__init__.py @@ -11,43 +11,63 @@ import aiohttp_jinja2 import jinja2 +import yaml from aiohttp import web from panelbench.dashboard.config_store import ConfigStore from panelbench.dashboard.context import DashboardContext from panelbench.dashboard.keys import ( APP_KEY_DASHBOARD_CONTEXT, + APP_KEY_PANEL_SECRETS, APP_KEY_PENDING_CLONES, APP_KEY_PRESET_REGISTRY, APP_KEY_RATE_CACHE, APP_KEY_STORE, ) from panelbench.dashboard.presets import init_presets -from panelbench.dashboard.routes import setup_routes +from panelbench.dashboard.routes import refuse_unloadable_edits, setup_routes +from panelbench.panel_secrets import PanelSecretsStore from panelbench.rates.cache import RateCache __all__ = ["DashboardContext", "create_dashboard_app"] +def _view_first_default(context: DashboardContext, store: ConfigStore) -> None: + """Show the first shipped template, read-only, or nothing when there is none.""" + defaults = sorted(context.config_dir.glob("default_*.yaml")) + if defaults: + store.load_from_file(defaults[0]) + context.edit(defaults[0].name) + else: + context.edit(None) + + def create_dashboard_app(context: DashboardContext) -> web.Application: """Create and return the dashboard aiohttp application.""" - app = web.Application() + app = web.Application(middlewares=[refuse_unloadable_edits]) - store = ConfigStore() + secrets = context.panel_secrets or PanelSecretsStore.in_config_dir(context.config_dir) + store = ConfigStore(secrets=secrets) - # Load the active config into the editor/viewer. + # Load the active config into the editor/viewer. One the panel would refuse is + # not opened, so nothing can be saved over it: the dashboard still starts on the + # first default template, read-only, says why, and lets the user pick or fix a + # config. if context.config_filter: config_path = context.config_dir / context.config_filter if config_path.exists(): - store.load_from_file(config_path) + try: + store.load_from_file(config_path) + except (ValueError, TypeError, yaml.YAMLError) as exc: + load_error = f"{context.config_filter} was not opened: {exc}" + _view_first_default(context, store) + context.load_error = load_error else: # No active config — show first default template (read-only). - defaults = sorted(context.config_dir.glob("default_*.yaml")) - if defaults: - context.config_filter = defaults[0].name - store.load_from_file(defaults[0]) + _view_first_default(context, store) app[APP_KEY_STORE] = store + app[APP_KEY_PANEL_SECRETS] = secrets app[APP_KEY_DASHBOARD_CONTEXT] = context app[APP_KEY_PRESET_REGISTRY] = init_presets(context.config_dir) app[APP_KEY_PENDING_CLONES] = {} diff --git a/src/panelbench/dashboard/config_store.py b/src/panelbench/dashboard/config_store.py index 274c258..b11ba6b 100644 --- a/src/panelbench/dashboard/config_store.py +++ b/src/panelbench/dashboard/config_store.py @@ -2,21 +2,35 @@ Holds the full simulator config as a mutable dict tree (matching the YAML schema). Changes only persist when the user explicitly saves. + +Every change goes through ``ConfigStore.edit``: it is made on a copy, normalised and +validated as the panel will validate it, and kept only if the panel would load it. +So the store never holds a config the panel would refuse, and a save cannot be +refused for an edit. """ from __future__ import annotations +import functools +import logging +from contextlib import contextmanager from copy import deepcopy from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast import yaml from panelbench.inverter import AC_COUPLED, normalise_inverter_type, template_inverter_type if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping from pathlib import Path + from panelbench.panel_secrets import PanelSecretsStore + from panelbench.scraper import ScrapedPanel + +from panelbench.clone import update_config_from_scrape +from panelbench.config_defaults import normalize_config from panelbench.config_types import BESSConfigYAML from panelbench.dashboard.defaults import make_defaults from panelbench.dashboard.presets import ( @@ -26,11 +40,13 @@ get_evse_tuples, get_preset, ) -from panelbench.emitter_adapter.spec_generator import relay_locked +from panelbench.emitter_adapter.spec_generator import normalise_relay_behavior, relay_locked from panelbench.solar import compute_solar_curve from panelbench.validation import validate_yaml_config from panelbench.weather import get_cached_weather +_LOGGER = logging.getLogger(__name__) + @dataclass class EntityView: @@ -55,6 +71,17 @@ class EntityView: user_modified: bool = False never_backup: bool = False + commissioned_system: str | None = None + + @property + def relay_behavior_option(self) -> str: + """``relay_behavior`` as the edit form spells it, whichever spelling the config uses. + + The form selects the option equal to this, so a circuit written + ``non_controllable`` or ``always_on`` shows its own lock, not ``controllable``, + and saving the form leaves the relay as it was. + """ + return normalise_relay_behavior(self.relay_behavior) @property def relay_locked(self) -> bool: @@ -62,9 +89,16 @@ def relay_locked(self) -> bool: Derived rather than stored, from the one predicate the manifest is built with, so the dot the dashboard offers and the `$settable` the panel - publishes cannot disagree. + publishes cannot disagree. A commissioned-system circuit is locked whatever + its `relay_behavior` says, as upstream's `manifest_physics.relay_locked` + decides it from the `commissioned-system` key alone. """ - return relay_locked(self.relay_behavior) + return relay_locked(self.relay_behavior) or self.commissioned_system is not None + + @property + def priority_locked(self) -> bool: + """Whether this circuit's priority is a commissioning lock no consumer may change.""" + return self.never_backup or self.commissioned_system is not None def _detect_entity_type(template: dict[str, Any]) -> str: @@ -77,10 +111,37 @@ def _detect_entity_type(template: dict[str, Any]) -> str: return "circuit" +_IMPORTED = "the imported config" +_EDITED = "the dashboard's edit" + + +class EditRefused(ValueError): + """An edit the panel would not load. The store is as it was before it.""" + + +def _one_edit[**P, R]( + method: Callable[Concatenate[ConfigStore, P], R], +) -> Callable[Concatenate[ConfigStore, P], R]: + """Make *method* one ``ConfigStore.edit``: all of its change or none of it.""" + + @functools.wraps(method) + def edit(store: ConfigStore, /, *args: P.args, **kwargs: P.kwargs) -> R: + with store.edit(): + return method(store, *args, **kwargs) + + return edit + + class ConfigStore: - """In-memory config state: load, mutate, validate, export.""" + """In-memory config state: load, mutate, validate, export. + + A source panel's passphrase never enters the state: a config loaded with one, + written before the secrets store existed, gives it to *secrets*, so nothing + exported or saved from here can carry it. + """ - def __init__(self) -> None: + def __init__(self, *, secrets: PanelSecretsStore | None = None) -> None: + self._secrets = secrets self._dirty: bool = False self._state: dict[str, Any] = { "panel_config": { @@ -99,24 +160,56 @@ def __init__(self) -> None: "enable_realistic_behaviors": True, }, } + self._editing = False @property def dirty(self) -> bool: """Whether in-memory state has unsaved changes.""" return self._dirty - def load_from_yaml(self, content: str) -> None: - """Parse, validate, and replace state from YAML string.""" + def load_from_yaml(self, content: str, *, saved: bool = True, source: str = _IMPORTED) -> None: + """Parse, validate, and replace state from YAML string. + + *saved* says whether *content* is what the loaded file holds; an import + is not, so it loads unsaved and leaving it prompts like any other edit. + *source* names it in what the loader logs: the file, or an import. + """ data = yaml.safe_load(content) if not isinstance(data, dict): raise ValueError("YAML content must be a mapping") - validate_yaml_config(data) - self._state = data - self._dirty = False + self.load_from_mapping(data, saved=saved, source=source) + + def load_from_mapping( + self, data: Mapping[str, object], *, saved: bool = True, source: str = _IMPORTED + ) -> None: + """Normalise, validate and replace state from an already-parsed config; *saved* + and *source* as for ``load_from_yaml``. + + Normalised as the engine normalises, so the editor holds a circuit's rating in + the one place its form reads and writes, and saving the form cannot re-rate it + from a key the form never showed. + """ + state = deepcopy(dict(data)) + self._take_secrets(state, source=source) + normalize_config(state, source=source) + validate_yaml_config(state) + self._state = state + self._dirty = not saved + + def _take_secrets(self, state: dict[str, Any], *, source: str) -> None: + """Move a passphrase out of *state*, into the secrets store where there is one.""" + if self._secrets is not None: + self._secrets.take_from_config(state) + return + panel_source = state.get("panel_source") + if isinstance(panel_source, dict) and panel_source.pop("passphrase", None) is not None: + _LOGGER.warning( + "Dropped the source panel's passphrase from %s: no store to keep it in", source + ) def load_from_file(self, path: Path) -> None: """Read a file and load its content.""" - self.load_from_yaml(path.read_text(encoding="utf-8")) + self.load_from_yaml(path.read_text(encoding="utf-8"), source=str(path)) def export_yaml(self) -> str: """Serialize current state to YAML.""" @@ -128,15 +221,60 @@ def export_yaml(self) -> str: ) def save_to_file(self, path: Path) -> None: - """Serialize current state to YAML and write to disk.""" + """Serialize current state to YAML and write to disk, normalised as on load. + + Validated first, as the panel will validate it. Every edit already was, so + this only guards a store changed some other way: nothing is written then. + + Raises: + ValueError: the panel would refuse the config; nothing is written. + """ + normalize_config(self._state, source=str(path)) + validate_yaml_config(self._state) path.write_text(self.export_yaml(), encoding="utf-8") self._dirty = False + @contextmanager + def edit(self) -> Iterator[None]: + """Make the changes inside one edit the panel would load, or none of them. + + The changes are made on a copy of the state, which is normalised and + validated as the panel will validate it and kept only if the panel would load + it. Every public mutator is one edit; a handler that makes several changes as + one wraps them in another, and an edit inside an edit is part of it. + + Raises: + EditRefused: the panel would not load the result, or the change itself + refused its input (an odd tab count, a field that is not a number); + the state and the unsaved-changes flag are as they were. + """ + if self._editing: + yield + return + original, was_dirty = self._state, self._dirty + self._state = deepcopy(original) + self._editing = True + try: + try: + yield + normalize_config(self._state, source=_EDITED) + validate_yaml_config(self._state) + except EditRefused: + raise + except ValueError as exc: + raise EditRefused(str(exc)) from exc + except BaseException: + self._state, self._dirty = original, was_dirty + raise + finally: + self._editing = False + # -- Panel config -- def get_panel_config(self) -> dict[str, Any]: return dict(self._state.get("panel_config", {})) + @_one_edit def update_panel_config(self, data: dict[str, Any]) -> None: cfg = self._state.setdefault("panel_config", {}) for key in ("serial_number", "total_tabs", "main_size"): @@ -182,6 +320,7 @@ def has_bess(self) -> bool: bess = self._state.get("bess") return isinstance(bess, dict) and bool(bess.get("enabled")) + @_one_edit def update_bess_config(self, data: dict[str, Any]) -> None: """Update top-level BESS settings from form data. @@ -201,6 +340,7 @@ def update_bess_config(self, data: dict[str, Any]) -> None: bess[yaml_key] = float(data[form_key]) self._dirty = True + @_one_edit def add_bess(self) -> None: """Add a default BESS configuration.""" self._state["bess"] = { @@ -217,6 +357,7 @@ def add_bess(self) -> None: } self._dirty = True + @_one_edit def remove_bess(self) -> None: """Remove the BESS configuration.""" self._state.pop("bess", None) @@ -227,6 +368,7 @@ def remove_bess(self) -> None: def get_simulation_params(self) -> dict[str, Any]: return dict(self._state.get("simulation_params", {})) + @_one_edit def update_simulation_params(self, data: dict[str, Any]) -> None: params = self._state.setdefault("simulation_params", {}) for key in ("update_interval", "time_acceleration", "noise_factor"): @@ -268,6 +410,31 @@ def get_recorder_map(self) -> dict[str, str]: return dict(rm) return {} + @_one_edit + def record_recorder_snapshots( + self, recorder_map: dict[str, str], snapshots: dict[str, object] + ) -> None: + """Keep a re-scrape's recorder mapping and template snapshots in ``panel_source``, + without touching the current templates.""" + ps = self._state.setdefault("panel_source", {}) + if isinstance(ps, dict): + if recorder_map: + ps["recorder_map"] = recorder_map + ps["recorder_snapshots"] = snapshots + self._dirty = True + + @_one_edit + def update_from_scrape(self, scraped: ScrapedPanel) -> bool: + """Refresh the energy seeds from a scrape of the source panel. + + Returns whether anything changed; a change is unsaved, as any edit is. + """ + changed = update_config_from_scrape(self._state, scraped) + if changed: + self._dirty = True + return changed + + @_one_edit def restore_recorder(self, entity_id: str) -> bool: """Restore a template to its original recorder state. @@ -309,6 +476,7 @@ def restore_recorder(self, entity_id: str) -> bool: self._dirty = True return True + @_one_edit def toggle_user_modified(self, entity_id: str) -> bool: """Toggle the user_modified flag on a template. Returns the new value. @@ -362,6 +530,7 @@ def _merge_entity(self, circuit: dict[str, Any]) -> EntityView: recorder_entity=template.get("recorder_entity"), user_modified=bool(template.get("user_modified")), never_backup=bool(template.get("never_backup")), + commissioned_system=template.get("commissioned_system"), ) def list_entities(self) -> list[EntityView]: @@ -378,6 +547,7 @@ def get_entity(self, entity_id: str) -> EntityView: raise KeyError(f"Entity not found: {entity_id}") return self._merge_entity(circuit) + @_one_edit def update_entity(self, entity_id: str, data: dict[str, Any]) -> None: """Update circuit and template fields from form data. @@ -425,6 +595,9 @@ def update_entity(self, entity_id: str, data: dict[str, Any]) -> None: ep["nameplate_capacity_w"] = nameplate ep["power_range"] = [-nameplate, 0.0] ep["typical_power"] = -nameplate * 0.6 + # The template's profile is the nameplate's one source: a circuit override + # of any of these would replace the edit in the engine's circuit. + overrides.pop("nameplate_capacity_w", None) overrides.pop("typical_power", None) overrides.pop("power_range", None) # Nameplate always implies leaving HA replay — same as other edits @@ -468,9 +641,22 @@ def update_entity(self, entity_id: str, data: dict[str, Any]) -> None: self._mark_user_modified(template_name) self._dirty = True + @_one_edit def add_entity(self, entity_type: str) -> EntityView: - """Create a new entity with type-appropriate defaults.""" + """Create a new entity with type-appropriate defaults. + + A circuit is placed on the first unmapped space, since the panel refuses a + circuit with none. + + Raises: + EditRefused: a circuit was asked for and no space is free. + """ entity_id, template_name, template_dict, circuit_dict = make_defaults(entity_type) + if entity_type == "circuit" and not circuit_dict.get("tabs"): + free = self.get_unmapped_tabs() + if not free: + raise EditRefused("No unmapped space is left for a circuit") + circuit_dict["tabs"] = [free[0]] existing_ids = {c["id"] for c in self._circuits()} base_id = entity_id @@ -493,6 +679,7 @@ def get_unmapped_tabs(self) -> list[int]: used.update(circ.get("tabs", [])) return sorted(t for t in range(1, total_tabs + 1) if t not in used) + @_one_edit def add_entity_from_tabs(self, tabs: list[int]) -> EntityView: """Create a new circuit entity assigned to the given tabs. @@ -534,17 +721,28 @@ def add_entity_from_tabs(self, tabs: list[int]) -> EntityView: self._dirty = True return self._merge_entity(circuit_dict) + @_one_edit def delete_entity(self, entity_id: str) -> None: - """Remove an entity and its template if no other circuit uses it.""" + """Remove an entity and its template if no other circuit uses it. + + A ``pv.feed`` naming the circuit goes with it, and then the ``pv`` section + binds by the sole-circuit rule as usual (``pv_section.bound_pv_circuit_id``). + Where the panel would refuse what is left -- before SPAN release 202639, + several PV circuits and no ``pv.feed``, or no circuit at all -- the edit is + refused and nothing changes. + + Raises: + KeyError: no circuit has *entity_id*. + EditRefused: the panel would not load the config left behind. + """ circuits = self._circuits() - circuit = None - for i, c in enumerate(circuits): - if c.get("id") == entity_id: - circuit = circuits.pop(i) - break - if circuit is None: + index = next((i for i, c in enumerate(circuits) if c.get("id") == entity_id), None) + if index is None: raise KeyError(f"Entity not found: {entity_id}") - + circuit = circuits.pop(index) + pv = self._state.get("pv") + if isinstance(pv, dict) and str(pv.get("feed")) == str(entity_id): + pv.pop("feed") template_name = circuit["template"] still_used = any(c.get("template") == template_name for c in circuits) if not still_used: @@ -563,6 +761,7 @@ def get_active_days(self, entity_id: str) -> list[int]: days: list[int] = tod.get("active_days", []) return [d for d in days if isinstance(d, int) and 0 <= d <= 6] + @_one_edit def update_active_days(self, entity_id: str, days: list[int]) -> None: """Write active weekdays into the entity's template.""" circuit = self._find_circuit(entity_id) @@ -590,6 +789,7 @@ def get_bess_active_days(self) -> list[int]: days: list[int] = bess.get("active_days", []) return [d for d in days if isinstance(d, int) and 0 <= d <= 6] + @_one_edit def update_bess_active_days(self, days: list[int]) -> None: """Write active weekdays into BESS config.""" bess = self._state.setdefault("bess", {"enabled": True}) @@ -641,6 +841,7 @@ def get_entity_profile(self, entity_id: str) -> dict[int, float]: return multipliers + @_one_edit def update_entity_profile(self, entity_id: str, multipliers: dict[int, float]) -> None: """Write 24-hour multipliers into the entity's template.""" circuit = self._find_circuit(entity_id) @@ -664,6 +865,7 @@ def update_entity_profile(self, entity_id: str, multipliers: dict[int, float]) - self._mark_user_modified(template_name) self._dirty = True + @_one_edit def apply_preset( self, entity_id: str, @@ -713,6 +915,7 @@ def get_battery_charge_mode(self) -> str: return raw return "self-consumption" + @_one_edit def update_battery_charge_mode( self, mode: str, @@ -756,6 +959,7 @@ def get_battery_profile(self) -> dict[int, str]: profile[h] = "idle" return profile + @_one_edit def update_battery_profile(self, hour_modes: dict[int, str]) -> None: """Write per-hour charge/discharge/idle schedule into BESS config.""" bess = self._state.setdefault("bess", {"enabled": True}) @@ -763,6 +967,7 @@ def update_battery_profile(self, hour_modes: dict[int, str]) -> None: bess["discharge_hours"] = sorted(h for h, m in hour_modes.items() if m == "discharge") self._dirty = True + @_one_edit def apply_battery_preset(self, preset_name: str) -> dict[int, str]: """Apply a named battery preset and return the schedule.""" hour_modes = get_battery_preset(preset_name) @@ -825,6 +1030,7 @@ def get_evse_schedule(self, entity_id: str) -> dict[str, Any]: "profile": profile, } + @_one_edit def update_evse_schedule(self, entity_id: str, start_hour: int, duration_hours: int) -> None: """Update EVSE charging schedule from start hour and duration.""" circuit = self._find_circuit(entity_id) @@ -843,6 +1049,7 @@ def update_evse_schedule(self, entity_id: str, start_hour: int, duration_hours: self._mark_user_modified(template_name) self._dirty = True + @_one_edit def apply_evse_preset(self, entity_id: str, preset_name: str) -> dict[int, float]: """Apply an EVSE charging preset and return the schedule factors.""" factors = get_evse_preset(preset_name) diff --git a/src/panelbench/dashboard/context.py b/src/panelbench/dashboard/context.py index 4947d6a..6a327ed 100644 --- a/src/panelbench/dashboard/context.py +++ b/src/panelbench/dashboard/context.py @@ -9,8 +9,11 @@ from collections.abc import Awaitable, Callable from pathlib import Path + from ebus_panel_sim import BESSCommunication + from panelbench.config_types import BESSConfigYAML from panelbench.history import HistoryProvider + from panelbench.panel_secrets import PanelSecretsStore async def _noop_modeling_data( @@ -38,6 +41,7 @@ class DashboardContext: set_time_acceleration: Callable[[float], None] = lambda _: None set_grid_online: Callable[[bool], None] = lambda _: None set_grid_islandable: Callable[[bool], None] = lambda _: None + set_bess_link: Callable[[BESSCommunication], None] = lambda _: None set_circuit_priority: Callable[[str, str], None] = lambda _id, _pri: None set_circuit_relay: Callable[[str, str], None] = lambda _id, _state: None apply_bess_config_live: Callable[[str, BESSConfigYAML], bool] = lambda _f, _c: False @@ -47,3 +51,25 @@ class DashboardContext: ha_client: Any = None # HAClient | None — optional, set when HA API is available history_provider: HistoryProvider | None = None panel_browser: Any = None # PanelBrowser | None — mDNS discovery for standalone mode + # Source panels' passphrases and broker credentials; beside config_dir when None. + panel_secrets: PanelSecretsStore | None = None + # Why the active config could not be opened when the dashboard started, shown + # until the editor moves to another config. + load_error: str | None = None + + # Why the last edit was refused, shown once on the page that reloads after it. + notice: str | None = None + + def take_notice(self) -> str | None: + """The notice to show, once.""" + notice, self.notice = self.notice, None + return notice + + def edit(self, filename: str | None) -> None: + """Move the editor to *filename*, or to none. + + The one way the editor changes file, so a load error, which is always about + the file the editor last failed to open, goes when it does. + """ + self.config_filter = filename + self.load_error = None diff --git a/src/panelbench/dashboard/keys.py b/src/panelbench/dashboard/keys.py index 67348ef..fb96519 100644 --- a/src/panelbench/dashboard/keys.py +++ b/src/panelbench/dashboard/keys.py @@ -11,6 +11,7 @@ from panelbench.dashboard.config_store import ConfigStore from panelbench.dashboard.context import DashboardContext from panelbench.dashboard.presets import PresetRegistry +from panelbench.panel_secrets import PanelSecretsStore from panelbench.rates.cache import RateCache APP_KEY_STORE = web.AppKey("store", ConfigStore) @@ -20,3 +21,4 @@ "pending_clones", dict ) APP_KEY_RATE_CACHE = web.AppKey("rate_cache", RateCache) +APP_KEY_PANEL_SECRETS = web.AppKey("panel_secrets", PanelSecretsStore) diff --git a/src/panelbench/dashboard/routes.py b/src/panelbench/dashboard/routes.py index e1bc0fa..d59c818 100644 --- a/src/panelbench/dashboard/routes.py +++ b/src/panelbench/dashboard/routes.py @@ -13,20 +13,27 @@ import aiohttp_jinja2 import yaml from aiohttp import web +from ebus_panel_sim import EmitterError if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Mapping from pathlib import Path import multidict from panelbench.dashboard.context import DashboardContext + from panelbench.panel_secrets import PanelSecretsStore + from panelbench.scraper import SuppliedBroker from datetime import UTC, datetime from zoneinfo import ZoneInfo +from panelbench.bess_link import BESS_LINKS, is_bess_link from panelbench.const import https_port_for +from panelbench.dashboard.config_store import ConfigStore, EditRefused from panelbench.dashboard.keys import ( APP_KEY_DASHBOARD_CONTEXT, + APP_KEY_PANEL_SECRETS, APP_KEY_PENDING_CLONES, APP_KEY_PRESET_REGISTRY, APP_KEY_RATE_CACHE, @@ -38,6 +45,9 @@ is_random_days_preset, match_battery_preset, ) +from panelbench.definition_export import definition_for_config_file, definition_text +from panelbench.definition_import import config_from_definition_text, is_definition_text +from panelbench.emitter_adapter.spec_generator import normalise_relay_behavior from panelbench.ha_api.opower import ( async_discover_opower, async_get_opower_cost, @@ -53,7 +63,7 @@ from panelbench.weather import fetch_historical_weather, get_cached_weather if TYPE_CHECKING: - from panelbench.dashboard.config_store import ConfigStore + from panelbench.dashboard.config_store import EntityView from panelbench.rates.cache import RateCache _LOGGER = logging.getLogger(__name__) @@ -75,7 +85,7 @@ class RecorderPurgeResult: "SOC_THRESHOLD", "OFF_GRID", ] -RELAY_BEHAVIORS = ["controllable", "non_controllable"] +RELAY_BEHAVIORS = ["controllable", "non-controllable"] ENTITY_TYPES = ["circuit", "pv", "evse"] # Infrastructure types that should only appear once in a panel config. _SINGLETON_TYPES = {"pv"} @@ -94,6 +104,10 @@ def _store(request: web.Request) -> ConfigStore: return request.app[APP_KEY_STORE] +def _panel_secrets(request: web.Request) -> PanelSecretsStore: + return request.app[APP_KEY_PANEL_SECRETS] + + def _rate_cache(request: web.Request) -> RateCache: return request.app[APP_KEY_RATE_CACHE] @@ -106,6 +120,20 @@ def _presets(request: web.Request) -> PresetRegistry: return request.app[APP_KEY_PRESET_REGISTRY] +async def _json_object(request: web.Request) -> dict[str, object]: + """The request body as a JSON object, or a 400 when it is not one. + + A malformed body is the caller's error, so it must not surface as a 500. + """ + try: + data = await request.json() + except ValueError as exc: + raise web.HTTPBadRequest(text="Request body must be JSON") from exc + if not isinstance(data, dict): + raise web.HTTPBadRequest(text="Request body must be a JSON object") + return data + + def _render(template: str, request: web.Request, context: dict[str, Any]) -> web.Response: """Render a Jinja2 template and return an HTML response.""" body = aiohttp_jinja2.render_string(template, request, context) @@ -121,6 +149,48 @@ def _available_configs(request: web.Request) -> list[str]: return sorted(set(files)) +@web.middleware +async def refuse_unloadable_edits( + request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]] +) -> web.StreamResponse: + """Answer an edit the panel would not load, from any handler, in one way. + + The store has already refused it and is as it was, so the answer is 422 with the + reason. An htmx request also reloads the page, which then shows what the store + holds and the reason, rather than an edit the store does not. A script's request + gets the reason in the response, for it to show where it asked. + """ + try: + return await handler(request) + except EditRefused as exc: + reason = f"Not changed: {exc}" + if request.headers.get("HX-Request") != "true": + return web.Response(status=422, text=reason) + _ctx(request).notice = reason + return web.Response(status=422, text=reason, headers={"HX-Refresh": "true"}) + + +def _open_in_editor(request: web.Request, filename: str) -> None: + """Load *filename* into the editor and make it the file being edited. + + Answers 400 with the panel's reason when it would refuse the file, and the editor + keeps what it had. + """ + reason = _try_open_in_editor(request, filename) + if reason is not None: + raise web.HTTPBadRequest(text=f"{filename} was not opened: {reason}") + + +def _try_open_in_editor(request: web.Request, filename: str) -> str | None: + """``_open_in_editor``, answering why the panel would refuse the file instead.""" + try: + _store(request).load_from_file(_ctx(request).config_dir / filename) + except (ValueError, TypeError, yaml.YAMLError) as exc: + return str(exc) + _ctx(request).edit(filename) + return None + + def _first_default_config(config_dir: Path) -> str | None: """Return the filename of the first default_* template, or None.""" defaults = sorted(p.name for p in config_dir.glob("default_*.yaml")) @@ -193,6 +263,8 @@ def _dashboard_context(request: web.Request) -> dict[str, Any]: "panels": _all_panels(request), "readonly": _is_readonly(ctx), "bess_config": store.get_bess_config(), + "load_error": ctx.load_error, + "notice": ctx.take_notice(), } @@ -201,11 +273,17 @@ def _presets_for_type(request: web.Request, entity_type: str) -> dict[str, str]: return _presets(request).presets_for_type(entity_type) -def _entity_list_context(request: web.Request, editing_id: str | None = None) -> dict[str, Any]: +def _entity_list_context( + request: web.Request, + editing_id: str | None = None, + *, + restore_error: str | None = None, +) -> dict[str, Any]: """Build the entity-list template context. When *editing_id* is set, the template renders that entity in edit - mode and all others as collapsed rows. + mode and all others as collapsed rows. *restore_error* says why a restore + from the source panel failed. """ store = _store(request) dash_ctx = _ctx(request) @@ -217,6 +295,7 @@ def _entity_list_context(request: web.Request, editing_id: str | None = None) -> "unmapped_tabs": store.get_unmapped_tabs(), "readonly": _is_readonly(dash_ctx), "restorable_templates": set(recorder_map.keys()), + "restore_error": restore_error, } if editing_id is not None: entity = store.get_entity(editing_id) @@ -487,12 +566,14 @@ async def handle_put_current_rate(request: web.Request) -> web.Response: label = body.get("label", "").strip() if not label: return web.json_response({"error": "label is required"}, status=400) - _rate_cache(request).set_current_rate_label(label) + # The battery's edit first: if the panel would refuse it, the current rate stays + # as it was too. store = _store(request) if store.get_battery_charge_mode() == "custom": store.update_battery_charge_mode("custom", rate_label=label) _persist_config(request) + _rate_cache(request).set_current_rate_label(label) return web.json_response({"ok": True}) @@ -627,6 +708,7 @@ def setup_routes(app: web.Application) -> None: app.router.add_post("/set-acceleration", handle_set_acceleration) app.router.add_post("/set-grid-state", handle_set_grid_state) app.router.add_post("/set-grid-islandable", handle_set_grid_islandable) + app.router.add_post("/set-bess-link", handle_set_bess_link) app.router.add_post("/entities/{id}/relay", handle_set_relay) app.router.add_post("/entities/{id}/toggle-replay", handle_toggle_replay) app.router.add_post("/entities/{id}/restore-recorder", handle_restore_recorder) @@ -639,6 +721,7 @@ def setup_routes(app: web.Application) -> None: # File operations app.router.add_get("/export", handle_export) + app.router.add_get("/export-definition", handle_export_definition) app.router.add_post("/import", handle_import) app.router.add_post("/load-config", handle_load_config) app.router.add_post("/clone", handle_clone) @@ -787,11 +870,23 @@ def _persist_config(request: web.Request) -> None: if not filename or filename.startswith("default_"): return output_path = ctx.config_dir / filename - _store(request).save_to_file(output_path) + _save_or_refuse(_store(request), output_path) _LOGGER.info("Config saved to %s", output_path) ctx.start_panel(filename) +def _save_or_refuse(store: ConfigStore, output_path: Path) -> None: + """Save *store* to *output_path*, or answer 422 with why the panel would refuse it. + + Every edit is validated as it is made, so this answers only for a store changed + some other way; nothing is written then. + """ + try: + store.save_to_file(output_path) + except ValueError as exc: + raise web.HTTPUnprocessableEntity(text=f"Not saved: {exc}") from exc + + def _persist_config_live_bess(request: web.Request) -> None: """BESS-only persistence path — saves YAML and applies the new BESS config to the running emitter without restarting the panel. SOC/SOE persists @@ -803,7 +898,7 @@ def _persist_config_live_bess(request: web.Request) -> None: return output_path = ctx.config_dir / filename store = _store(request) - store.save_to_file(output_path) + _save_or_refuse(store, output_path) _LOGGER.info("Config saved to %s (live BESS apply)", output_path) bess_yaml = store.get_bess_config() if not ctx.apply_bess_config_live(filename, bess_yaml): @@ -812,33 +907,71 @@ def _persist_config_live_bess(request: web.Request) -> None: ctx.start_panel(filename) +def _refuse_locked_priority_change(entity: EntityView, priority: str) -> None: + """Raise 409 when *priority* would change a priority-locked circuit's priority.""" + if not entity.priority_locked or priority == entity.priority: + return + if entity.commissioned_system is not None: + reason = ( + f"{entity.name} is the commissioned {entity.commissioned_system} system's " + f"circuit, so it is permanently {entity.priority} and publishes " + "load-shed/priority without $settable." + ) + else: + reason = ( + f"{entity.name} is commissioned never-backup, so it is permanently " + f"{entity.priority} and publishes load-shed/priority without " + "$settable. Clear never_backup to re-prioritise it." + ) + raise web.HTTPConflict(text=reason) + + +def _commissioned_relay_reason(entity: EntityView, system: str) -> str: + """Why a commissioned *system*'s circuit refuses any change to its relay.""" + return ( + f"{entity.name} is the commissioned {system} system's circuit, so its relay is " + "locked and publishes switch/relay without $settable and relay-controllable=false." + ) + + +def _refuse_commissioned_relay_change(entity: EntityView, relay_behavior: str) -> None: + """Raise 409 when *relay_behavior* would change a commissioned-system circuit's.""" + if entity.commissioned_system is None or ( + normalise_relay_behavior(relay_behavior) == entity.relay_behavior_option + ): + return + raise web.HTTPConflict(text=_commissioned_relay_reason(entity, entity.commissioned_system)) + + async def handle_put_entity(request: web.Request) -> web.Response: """Save a circuit's edited fields. - A priority change is refused on a never-backup circuit, for the same reason - `handle_set_relay` refuses a locked relay: this panel publishes - `load-shed/priority` without `$settable` there, so no consumer is offered the - write and the dashboard must not offer it either. The commissioning lock also - *is* permanently `OFF_GRID` — the emitter rejects a manifest that says - otherwise at construction — so accepting the edit would produce a config that - cannot start the panel. + A priority change is refused on a priority-locked circuit (never-backup, or a + commissioned PV or battery system's), for the same reason `handle_set_relay` + refuses a locked relay: this panel publishes `load-shed/priority` without + `$settable` there, so no consumer is offered the write and the dashboard must + not offer it either. Each lock also fixes the priority — permanently `OFF_GRID` + for never-backup, `NEVER` for a commissioned system — and the emitter rejects a + manifest that says otherwise at construction, so accepting the edit would + produce a config that cannot start the panel. + + A `relay_behavior` change is refused on a commissioned-system circuit for the + second half of that reason: its relay is locked too, and validation refuses a + commissioned template whose relay is controllable, so the saved config would + not start the panel either. """ entity_id = request.match_info["id"] data = await request.post() store = _store(request) - if "priority" in data: + if "priority" in data or "relay_behavior" in data: try: entity = store.get_entity(entity_id) except KeyError: raise web.HTTPNotFound(text=f"Entity not found: {entity_id}") from None - if entity.never_backup and str(data["priority"]) != entity.priority: - raise web.HTTPConflict( - text=( - f"{entity.name} is commissioned never-backup, so it is permanently " - f"{entity.priority} and publishes load-shed/priority without " - "$settable. Clear never_backup to re-prioritise it." - ) - ) + if "priority" in data: + _refuse_locked_priority_change(entity, str(data["priority"])) + if "relay_behavior" in data: + _refuse_commissioned_relay_change(entity, str(data["relay_behavior"])) store.update_entity(entity_id, dict(data)) # Push priority change to the running engine immediately if "priority" in data: @@ -896,10 +1029,11 @@ async def handle_put_profile(request: web.Request) -> web.Response: if key in data: multipliers[h] = float(str(data[key])) store = _store(request) - store.update_entity_profile(entity_id, multipliers) - active = _parse_active_days(data) - if active is not None: - store.update_active_days(entity_id, active) + with store.edit(): + store.update_entity_profile(entity_id, multipliers) + active = _parse_active_days(data) + if active is not None: + store.update_active_days(entity_id, active) return _render("partials/profile_editor.html", request, _profile_context(request, entity_id)) @@ -972,10 +1106,11 @@ async def handle_put_bess_schedule(request: web.Request) -> web.Response: mode = str(data.get(key, "idle")) hour_modes[h] = mode if mode in ("charge", "discharge", "idle") else "idle" store = _store(request) - store.update_battery_profile(hour_modes) - active = _parse_active_days(data) - if active is not None: - store.update_bess_active_days(active) + with store.edit(): + store.update_battery_profile(hour_modes) + active = _parse_active_days(data) + if active is not None: + store.update_bess_active_days(active) _persist_config(request) return _render("partials/bess_card.html", request, _bess_card_context(request, editing=True)) @@ -1054,10 +1189,11 @@ async def handle_put_evse_schedule(request: web.Request) -> web.Response: start = int(str(data.get("charge_start", "0"))) duration = int(str(data.get("charge_duration", "6"))) store = _store(request) - store.update_evse_schedule(entity_id, start, duration) - active = _parse_active_days(data) - if active is not None: - store.update_active_days(entity_id, active) + with store.edit(): + store.update_evse_schedule(entity_id, start, duration) + active = _parse_active_days(data) + if active is not None: + store.update_active_days(entity_id, active) return _render( "partials/evse_schedule.html", request, @@ -1279,6 +1415,16 @@ async def handle_set_grid_islandable(request: web.Request) -> web.Response: return web.json_response({"ok": True, "islandable": islandable}) +async def handle_set_bess_link(request: web.Request) -> web.Response: + """Set the health of the panel's link to its battery.""" + data = await _json_object(request) + link = str(data.get("link", "OK")).upper() + if not is_bess_link(link): + raise web.HTTPBadRequest(text=f"link must be one of {sorted(BESS_LINKS)}, got {link!r}") + _ctx(request).set_bess_link(link) + return web.json_response({"ok": True, "link": link}) + + async def handle_set_relay(request: web.Request) -> web.Response: """Toggle a circuit relay (OPEN/CLOSED). @@ -1309,11 +1455,13 @@ async def handle_set_relay(request: web.Request) -> web.Response: entity = _store(request).get_entity(entity_id) except KeyError: raise web.HTTPNotFound(text=f"Entity not found: {entity_id}") from None + if entity.commissioned_system is not None: + raise web.HTTPConflict(text=_commissioned_relay_reason(entity, entity.commissioned_system)) if entity.relay_locked: raise web.HTTPConflict( text=( f"{entity.name} has a locked relay (relay_behavior=" - f"{entity.relay_behavior!r}), so it publishes switch/relay without " + f"{entity.relay_behavior_option!r}), so it publishes switch/relay without " "$settable and relay-controllable=false. Set relay_behavior to " "'controllable' to command it." ) @@ -1338,9 +1486,13 @@ async def handle_toggle_replay(request: web.Request) -> web.Response: if entity.user_modified: # SYN → REC: full restore - if not store.restore_recorder(entity_id): - await _rescrape_snapshots(request) - store.restore_recorder(entity_id) + failure = await _restore_recorder(request, entity_id) + if failure is not None: + return _render( + "partials/entity_list.html", + request, + _entity_list_context(request, restore_error=failure), + ) _persist_config(request) else: # REC → SYN: flip the flag and persist so the engine matches the UI @@ -1357,40 +1509,59 @@ async def handle_restore_recorder(request: web.Request) -> web.Response: needed — no other templates are touched. """ entity_id = request.match_info["id"] - store = _store(request) - if not store.restore_recorder(entity_id): - # No snapshot — try a targeted re-scrape - await _rescrape_snapshots(request) - store.restore_recorder(entity_id) + failure = await _restore_recorder(request, entity_id) + if failure is not None: + return _render( + "partials/entity_list.html", + request, + _entity_list_context(request, restore_error=failure), + ) _persist_config(request) return _render("partials/entity_list.html", request, _entity_list_context(request)) +async def _restore_recorder(request: web.Request, entity_id: str) -> str | None: + """Restore *entity_id* from its snapshot, re-scraping the source panel for one. + + Answers why the restore failed, or None. A re-scrape that fails is reported, as + a sync's is, rather than leaving the circuit as it was without a word. + """ + from panelbench.scraper import ScrapeError + + store = _store(request) + if store.restore_recorder(entity_id): + return None + try: + await _rescrape_snapshots(request) + except ScrapeError as exc: + return f"Restore failed: [{exc.phase}] {exc}" + store.restore_recorder(entity_id) + return None + + async def _rescrape_snapshots(request: web.Request) -> None: - """Re-scrape source panel and store snapshots without modifying templates.""" + """Re-scrape source panel and store snapshots without modifying templates. + + Raises: + ScrapeError: The config has no source panel, or it could not be scraped. + """ import copy from panelbench.clone import translate_scraped_panel - from panelbench.scraper import register_with_panel, scrape_ebus + from panelbench.scraper import ScrapeError, scrape_panel store = _store(request) panel_source = store.get_panel_source() if not panel_source: - return + raise ScrapeError("source", "This config was not cloned from a panel") host = panel_source.get("host", "") - passphrase = panel_source.get("passphrase") - - try: - creds, ca_pem = await register_with_panel(host, passphrase) - scraped = await scrape_ebus(creds, ca_pem) - except Exception: - return + scraped = await scrape_panel(host, _panel_secrets(request)) # Build a fresh config to get original template values - fresh = translate_scraped_panel(scraped, host=host, passphrase=passphrase) + fresh = translate_scraped_panel(scraped, host=host) fresh_templates = fresh.get("circuit_templates") if not isinstance(fresh_templates, dict): return @@ -1421,12 +1592,7 @@ async def _rescrape_snapshots(request: web.Request) -> None: snapshots[tpl_name] = copy.deepcopy(tpl) # Persist map and snapshots without touching current templates - ps = store._state.setdefault("panel_source", {}) - if isinstance(ps, dict): - if recorder_map: - ps["recorder_map"] = recorder_map - ps["recorder_snapshots"] = snapshots - store._dirty = True + store.record_recorder_snapshots(recorder_map, snapshots) # -- Energy projection -- @@ -1576,6 +1742,27 @@ async def handle_export(request: web.Request) -> web.Response: ) +async def handle_export_definition(request: web.Request) -> web.Response: + """The loaded config's makeup as a panel definition, without its behaviour.""" + ctx = _ctx(request) + if not ctx.config_filter: + raise web.HTTPBadRequest(text="No config file is loaded") + if _store(request).dirty: + raise web.HTTPConflict(text="Save the config before exporting its definition") + path = ctx.config_dir / ctx.config_filter + if path.resolve().parent != ctx.config_dir.resolve() or not path.is_file(): + raise web.HTTPBadRequest(text=f"Config file not found: {ctx.config_filter}") + try: + definition = await definition_for_config_file(path) + except (ValueError, TypeError, yaml.YAMLError, EmitterError) as exc: + raise web.HTTPBadRequest(text=str(exc)) from exc + return web.Response( + text=definition_text(definition), + content_type="application/x-yaml", + headers={"Content-Disposition": f'attachment; filename="{path.stem}.definition.yaml"'}, + ) + + async def handle_import(request: web.Request) -> web.Response: data = await request.post() upload = data.get("file") @@ -1584,8 +1771,11 @@ async def handle_import(request: web.Request) -> web.Response: raw = upload.file.read() try: text = raw.decode("utf-8") if isinstance(raw, bytes) else raw - _store(request).load_from_yaml(text) - except (ValueError, TypeError) as exc: + if is_definition_text(text): + _store(request).load_from_mapping(config_from_definition_text(text), saved=False) + else: + _store(request).load_from_yaml(text, saved=False) + except (ValueError, TypeError, yaml.YAMLError, EmitterError) as exc: raise web.HTTPBadRequest(text=str(exc)) from exc return web.Response(status=200, headers={"HX-Redirect": "./"}) @@ -1603,14 +1793,9 @@ async def handle_load_config(request: web.Request) -> web.Response: # Prevent path traversal if config_path.resolve().parent != ctx.config_dir.resolve(): raise web.HTTPBadRequest(text="Invalid config file path") - try: - _store(request).load_from_file(config_path) - except (ValueError, TypeError) as exc: - raise web.HTTPBadRequest(text=str(exc)) from exc - # Track which file the editor is working on. Does NOT affect # running engines — use Start/Stop/Restart in the panels list. - ctx.config_filter = filename + _open_in_editor(request, filename) # Full page redirect so HTMX replaces the entire document return web.Response(status=200, headers={"HX-Redirect": "./"}) @@ -1678,6 +1863,18 @@ async def handle_clone(request: web.Request) -> web.Response: else: yaml_content = _store(request).export_yaml() + # Refused before anything is written: a copy of a config the panel would not + # load is not a clone of anything. A clone is a write the user asked for, so it + # is written as the loader reads it, a stale rating copy already gone, rather + # than warning every reader after; content the loader changes nothing in keeps + # its text, comments and all. + clone = ConfigStore(secrets=_panel_secrets(request)) + try: + clone.load_from_yaml(yaml_content, source=str(output_path)) + except (ValueError, TypeError, yaml.YAMLError) as exc: + raise web.HTTPBadRequest(text=f"Not cloned: {exc}") from exc + if yaml.safe_load(clone.export_yaml()) != yaml.safe_load(yaml_content): + yaml_content = clone.export_yaml() output_path.write_text(yaml_content, encoding="utf-8") _LOGGER.info("Config cloned to %s", output_path) @@ -1697,8 +1894,7 @@ async def handle_clone(request: web.Request) -> web.Response: # Auto-switch the editor to the newly cloned config so the entity # list, runtime controls, and modeling view all reflect the clone. - _store(request).load_from_file(output_path) - ctx.config_filter = filename + _open_in_editor(request, filename) return web.Response(status=200, headers={"HX-Redirect": "./"}) @@ -1745,8 +1941,7 @@ async def handle_get_panel_source(request: web.Request) -> web.Response: async def handle_sync_panel_source(request: web.Request) -> web.Response: """Re-scrape the source panel and overwrite typical_power + energy seeds.""" - from panelbench.clone import update_config_from_scrape - from panelbench.scraper import ScrapeError, register_with_panel, scrape_ebus + from panelbench.scraper import ScrapeError, scrape_panel store = _store(request) panel_source = store.get_panel_source() @@ -1757,21 +1952,23 @@ async def handle_sync_panel_source(request: web.Request) -> web.Response: ) host = panel_source.get("host", "") - passphrase = panel_source.get("passphrase") try: - creds, ca_pem = await register_with_panel(host, passphrase) - scraped = await scrape_ebus(creds, ca_pem) + scraped = await scrape_panel(host, _panel_secrets(request)) except ScrapeError as exc: return web.Response( text=f'
Sync failed: [{exc.phase}] {exc}
', content_type="text/html", ) - update_config_from_scrape(store._state, scraped) + changed = store.update_from_scrape(scraped) ctx = _panel_source_context(request) - ctx["sync_message"] = "Updated energy seeds from source panel." + ctx["sync_message"] = ( + "Updated energy seeds from source panel. Save to keep it." + if changed + else "The energy seeds already match the source panel." + ) return _render("partials/panel_source.html", request, ctx) @@ -1830,10 +2027,10 @@ async def handle_start_panel(request: web.Request) -> web.Response: if err is not None: return err ctx = _ctx(request) + # Opened first: the engine refuses what the editor refuses, and a requested start + # also becomes the config the next boot tries. + _open_in_editor(request, filename) ctx.start_panel(filename) - # Auto-switch the editor to this panel so entity list stays in sync. - _store(request).load_from_file(ctx.config_dir / filename) - ctx.config_filter = filename return web.Response(status=200, headers={"HX-Redirect": "./"}) @@ -1843,9 +2040,12 @@ async def handle_stop_panel(request: web.Request) -> web.Response: if err is not None: return err ctx = _ctx(request) + # Stopping does not need the file, so a panel whose file has gone bad still stops, + # and the page says the editor did not open it. ctx.stop_panel(filename) - _store(request).load_from_file(ctx.config_dir / filename) - ctx.config_filter = filename + reason = _try_open_in_editor(request, filename) + if reason is not None: + ctx.load_error = f"Stopped {filename}; it was not opened in the editor: {reason}" return web.Response(status=200, headers={"HX-Redirect": "./"}) @@ -1855,9 +2055,9 @@ async def handle_restart_panel(request: web.Request) -> web.Response: if err is not None: return err ctx = _ctx(request) + # Opened first: a running panel is not taken down for a start the engine refuses. + _open_in_editor(request, filename) ctx.restart_panel(filename) - _store(request).load_from_file(ctx.config_dir / filename) - ctx.config_filter = filename return web.Response(status=200, headers={"HX-Redirect": "./"}) @@ -2000,9 +2200,10 @@ async def handle_delete_config(request: web.Request) -> web.Response: # the first default template (read-only). if filename == ctx.config_filter: first_default = _first_default_config(ctx.config_dir) - ctx.config_filter = first_default if first_default: - _store(request).load_from_file(ctx.config_dir / first_default) + _open_in_editor(request, first_default) + else: + ctx.edit(None) return web.Response( status=200, headers={"HX-Redirect": "./"}, @@ -2015,6 +2216,47 @@ async def handle_delete_config(request: web.Request) -> web.Response: ) +_SPAN_MQTTS_PORT = 8883 + + +def _supplied_broker(form: Mapping[str, object]) -> SuppliedBroker | None: + """Broker credentials the clone form was given in place of registering, if any. + + All of serial, username and password, or none of them; the port defaults to + the one SPAN's broker listens on, and a blank CA is fetched from the panel. + + Raises: + ValueError: Some of the three were given, but not all. + """ + from panelbench.scraper import SuppliedBroker + + fields = { + key: str(form.get(key, "")).strip() + for key in ("broker_serial", "broker_username", "broker_password") + } + if not any(fields.values()): + return None + missing = [key.removeprefix("broker_") for key, value in fields.items() if not value] + if missing: + raise ValueError( + "Existing broker credentials need the panel's serial, a username and a " + f"password; missing: {', '.join(missing)}." + ) + port_text = str(form.get("broker_port", "")).strip() + try: + port = int(port_text) if port_text else _SPAN_MQTTS_PORT + except ValueError: + raise ValueError(f"The broker port must be a number, not {port_text!r}.") from None + ca_pem = str(form.get("broker_ca", "")).strip() + return SuppliedBroker( + serial=fields["broker_serial"], + username=fields["broker_username"], + password=fields["broker_password"], + port=port, + ca_pem=f"{ca_pem}\n" if ca_pem else None, + ) + + def _clone_panel_context(request: web.Request, **extra: object) -> dict[str, Any]: """Build the clone panel section template context.""" ctx = _ctx(request) @@ -2111,9 +2353,7 @@ async def _finalize_clone( clone_path = write_clone_config(config, ctx.config_dir, origin_serial, filename=filename) # Load the clone config into the dashboard editor - store = _store(request) - store.load_from_file(clone_path) - ctx.config_filter = clone_path.name + _open_in_editor(request, clone_path.name) _LOGGER.info("Panel cloned from %s -> %s", host, clone_path.name) @@ -2172,7 +2412,7 @@ async def _finalize_clone( if profiles_imported: # Re-read config after profile application - store.load_from_file(clone_path) + _open_in_editor(request, clone_path.name) # Redirect to refresh the full dashboard with the new config return web.Response(status=200, headers={"HX-Redirect": "./"}) @@ -2222,7 +2462,7 @@ async def handle_clone_from_panel(request: web.Request) -> web.Response: clone_config_path, translate_scraped_panel, ) - from panelbench.scraper import ScrapeError, register_with_panel, scrape_ebus + from panelbench.scraper import ScrapeError, scrape_panel data = await request.post() host = str(data.get("host", "")).strip() @@ -2235,20 +2475,19 @@ async def handle_clone_from_panel(request: web.Request) -> web.Response: _clone_panel_context(request, clone_error="Panel IP or hostname is required."), ) - if not passphrase: + try: + supplied = _supplied_broker(data) + except ValueError as exc: return _render( "partials/clone_panel.html", request, - _clone_panel_context( - request, - clone_error="Passphrase is required.", - clone_host=host, - ), + _clone_panel_context(request, clone_error=str(exc), clone_host=host), ) try: - creds, ca_pem = await register_with_panel(host, passphrase) - scraped = await scrape_ebus(creds, ca_pem) + scraped = await scrape_panel( + host, _panel_secrets(request), passphrase=passphrase, supplied=supplied + ) except ScrapeError as exc: return _render( "partials/clone_panel.html", @@ -2260,7 +2499,7 @@ async def handle_clone_from_panel(request: web.Request) -> web.Response: ), ) - config = translate_scraped_panel(scraped, host=host, passphrase=passphrase) + config = translate_scraped_panel(scraped, host=host) ctx = _ctx(request) @@ -2276,13 +2515,20 @@ async def handle_clone_from_panel(request: web.Request) -> web.Response: panel_cfg["longitude"] = lon # Derive IANA timezone from coordinates so profile - # builder can bucket hour_factors in local time. - from timezonefinder import TimezoneFinder - - tz_result = TimezoneFinder().timezone_at(lat=lat, lng=lon) - tz_name = str(tz_result) if tz_result else "America/Los_Angeles" - panel_cfg["time_zone"] = tz_name - _LOGGER.info("Applied HA home location: %.4f, %.4f → %s", lat, lon, tz_name) + # builder can bucket hour_factors in local time, unless the + # panel published its own, which the clone already holds. + if "time_zone" not in panel_cfg: + from timezonefinder import TimezoneFinder + + tz_result = TimezoneFinder().timezone_at(lat=lat, lng=lon) + tz_name = str(tz_result) if tz_result else "America/Los_Angeles" + panel_cfg["time_zone"] = tz_name + _LOGGER.info( + "Applied HA home location: %.4f, %.4f → %s", + lat, + lon, + panel_cfg["time_zone"], + ) except Exception: _LOGGER.debug("Could not fetch HA location", exc_info=True) diff --git a/src/panelbench/dashboard/static/dashboard.css b/src/panelbench/dashboard/static/dashboard.css index 2afd1fe..10e3a62 100644 --- a/src/panelbench/dashboard/static/dashboard.css +++ b/src/panelbench/dashboard/static/dashboard.css @@ -1084,6 +1084,13 @@ legend { font-weight: 600; } +.bess-link-control { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; +} + /* EVSE charging schedule */ .evse-schedule { margin-top: 0.75rem; diff --git a/src/panelbench/dashboard/templates/base.html b/src/panelbench/dashboard/templates/base.html index 8d335cc..3d8de44 100644 --- a/src/panelbench/dashboard/templates/base.html +++ b/src/panelbench/dashboard/templates/base.html @@ -51,6 +51,17 @@

SPAN PanelBench Dashboard

} }); + // A refused request (a 4xx carrying the reason, such as a save the panel would + // not load) says why in the flash area, which htmx alone would leave blank. + document.body.addEventListener('htmx:responseError', function(evt) { + var area = document.getElementById('flash-area'); + if (!area) return; + var flash = document.createElement('div'); + flash.className = 'flash error'; + flash.textContent = evt.detail.xhr.responseText || ('Request failed (' + evt.detail.xhr.status + ')'); + area.replaceChildren(flash); + }); + // Toggle random preset parameters visibility function toggleRandomParams(sel, entityId) { var el = document.getElementById('random-params-' + entityId); diff --git a/src/panelbench/dashboard/templates/dashboard.html b/src/panelbench/dashboard/templates/dashboard.html index 20acf9c..7305106 100644 --- a/src/panelbench/dashboard/templates/dashboard.html +++ b/src/panelbench/dashboard/templates/dashboard.html @@ -9,7 +9,10 @@

Getting started

  • Purge removes recorder history written by the simulated panel's sensors if you added the simulated panel to Home Assistant's integration.
  • -
    +
    + {%- if load_error %}
    {{ load_error }}
    {% endif -%} + {%- if notice %}
    {{ notice }}
    {% endif -%} +
    {% include "partials/running_panels.html" %} {% include "partials/clone_panel.html" %} diff --git a/src/panelbench/dashboard/templates/partials/clone_panel.html b/src/panelbench/dashboard/templates/partials/clone_panel.html index f6bda1d..2434f1b 100644 --- a/src/panelbench/dashboard/templates/partials/clone_panel.html +++ b/src/panelbench/dashboard/templates/partials/clone_panel.html @@ -36,12 +36,36 @@

    Clone from Panel

    {% endif %} ⏳
    +
    + Use existing broker credentials instead of registering +

    + PanelBench registers with a panel once and keeps what it is given. Credentials + you already hold for its broker can be used instead, and nothing is registered. +

    +
    + + + + +
    + +