span-panel-api 2.6.2__tar.gz → 2.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.gitignore +3 -0
  2. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/CHANGELOG.md +12 -0
  3. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/PKG-INFO +1 -1
  4. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/pyproject.toml +1 -1
  5. span_panel_api-2.6.3/scripts/validate_lug_derivation/README.md +71 -0
  6. span_panel_api-2.6.3/scripts/validate_lug_derivation/compare.py +311 -0
  7. span_panel_api-2.6.3/scripts/validate_lug_derivation/run_local.sh.example +22 -0
  8. span_panel_api-2.6.3/scripts/validate_lug_derivation/v1_reader.py +86 -0
  9. span_panel_api-2.6.3/scripts/validate_lug_derivation/v2_reader.py +168 -0
  10. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/field_metadata.py +13 -2
  11. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/homie.py +71 -9
  12. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_homie.py +92 -15
  13. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/uv.lock +1 -1
  14. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.codefactor +0 -0
  15. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.codefactor.yml +0 -0
  16. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.deps-installed +0 -0
  17. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/dependabot.yml +0 -0
  20. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/workflows/ci.yml +0 -0
  21. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/workflows/dependabot-auto-approve.yml +0 -0
  22. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/workflows/dependabot-auto-merge.yml +0 -0
  23. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/workflows/release.yml +0 -0
  24. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.markdownlint-cli2.jsonc +0 -0
  25. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.markdownlint.json +0 -0
  26. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.pre-commit-config.yaml +0 -0
  27. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.prettierrc.json +0 -0
  28. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.vscode/extensions.json +0 -0
  29. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.vscode/tasks.json +0 -0
  30. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/DEVELOPMENT.md +0 -0
  31. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/LICENSE +0 -0
  32. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/README.md +0 -0
  33. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/SECURITY.md +0 -0
  34. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/conftest.py +0 -0
  35. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/developer_attribute_readme.md +0 -0
  36. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/openapi.json +0 -0
  37. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/pytest.ini +0 -0
  38. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/pytest_output.log +0 -0
  39. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/__init__.py +0 -0
  40. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/coverage.py +0 -0
  41. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/format.sh +0 -0
  42. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/format_markdown.py +0 -0
  43. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/test_live_auth.py +0 -0
  44. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/setup-hooks.sh +0 -0
  45. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/__init__.py +0 -0
  46. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/_http.py +0 -0
  47. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/auth.py +0 -0
  48. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/const.py +0 -0
  49. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/detection.py +0 -0
  50. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/exceptions.py +0 -0
  51. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/factory.py +0 -0
  52. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/models.py +0 -0
  53. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/__init__.py +0 -0
  54. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/accumulator.py +0 -0
  55. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/async_client.py +0 -0
  56. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/client.py +0 -0
  57. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/connection.py +0 -0
  58. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/const.py +0 -0
  59. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/models.py +0 -0
  60. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/phase_validation.py +0 -0
  61. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/protocol.py +0 -0
  62. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/py.typed +0 -0
  63. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/conftest.py +0 -0
  64. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  65. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  66. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  67. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/v2/README.md +0 -0
  68. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/v2/homie_schema.json +0 -0
  69. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/v2/status.json +0 -0
  70. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/simulation_fixtures/circuits.response.txt +0 -0
  71. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/simulation_fixtures/panel.response.txt +0 -0
  72. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/simulation_fixtures/soe.response.txt +0 -0
  73. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/simulation_fixtures/status.response.txt +0 -0
  74. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_accumulator.py +0 -0
  75. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_async_mqtt_client.py +0 -0
  76. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_auth_and_homie_helpers.py +0 -0
  77. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_detection_auth.py +0 -0
  78. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_exceptions.py +0 -0
  79. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_field_metadata.py +0 -0
  80. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_bridge.py +0 -0
  81. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_client_connection.py +0 -0
  82. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_connect_flow.py +0 -0
  83. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_debounce.py +0 -0
  84. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_phase_validation_configs.py +0 -0
  85. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_phase_validation_errors.py +0 -0
  86. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_protocol_conformance.py +0 -0
  87. {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_protocol_models.py +0 -0
@@ -34,3 +34,6 @@ dmypy.json
34
34
  coverage_output.log
35
35
  **/.DS_Store
36
36
  .local_coverage_data
37
+
38
+ # Local credentialed runner for validate_lug_derivation
39
+ scripts/validate_lug_derivation/run_local.sh
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.6.3] - 04/2026
8
+
9
+ ### Fixed
10
+
11
+ - **Feedthrough values now derived via Kirchhoff instead of read from `downstream-lugs`** — `SpanPanelSnapshot.feedthrough_power_w`, `feedthrough_energy_consumed_wh`, and `feedthrough_energy_produced_wh` are computed as `main − Σ(branches)` inside
12
+ `HomieDeviceConsumer._build_snapshot` (and mirrored in the dirty-circuit rebuild path) rather than sourced from the native `energy.ebus.device.lugs.downstream` `active-power` / `imported-energy` / `exported-energy` properties. Kirchhoff at the main bus
13
+ produces a physically-consistent result: `P_main = P_feedthrough + Σ(branches, load-perspective)` for instantaneous power, and the same identity applied to _net_ energy (`main.consumed − main.produced − Σ(branch.net)`) split into non-negative consumed /
14
+ produced counters. Net-based energy handling is required because a per-direction subtraction would emit negative cumulative counters whenever circuits flow bidirectionally (the classic case is PV self-consumption: `Σ(branch.consumed)` can exceed
15
+ `main.consumed` even when the net balance is valid). The synthesized PV virtual circuit participates with the correct load-perspective sign, and unmapped tab entries are zero-power, so both contribute safely to the sum. No public interface change — field
16
+ names and types are unchanged; only the source of the values shifts. `downstream_l1_current_a` / `downstream_l2_current_a` continue to be read directly from the downstream-lugs node — those per-phase readings are orthogonal to the defect. The underlying
17
+ firmware defect is tracked upstream at [spanio/SPAN-API-Client-Docs#13](https://github.com/spanio/SPAN-API-Client-Docs/issues/13).
18
+
7
19
  ## [2.6.2] - 04/2026
8
20
 
9
21
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: span-panel-api
3
- Version: 2.6.2
3
+ Version: 2.6.3
4
4
  Summary: A client library for SPAN Panel API
5
5
  Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
6
  Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "2.6.2"
3
+ version = "2.6.3"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -0,0 +1,71 @@
1
+ # validate_lug_derivation
2
+
3
+ Diagnostic harness that compares **v1 REST**, **v2 MQTT (via the current library)**, and **Kirchhoff-derived** values for the SPAN panel's downstream-lugs (feedthrough) power and energy. Used to:
4
+
5
+ 1. Validate that the library's Kirchhoff derivation (introduced in `span-panel-api` 2.6.3) stays consistent with v1 REST as an independent ground truth.
6
+ 2. **Track when the upstream firmware defect on `downstream-lugs` is fixed.** The SPAN API is in beta and carries no version signal, so we detect the fix empirically by watching the raw MQTT properties converge on the Kirchhoff-derived values. Upstream
7
+ issue: [spanio/SPAN-API-Client-Docs#13](https://github.com/spanio/SPAN-API-Client-Docs/issues/13).
8
+
9
+ ## Files
10
+
11
+ - **`v1_reader.py`** — Captures N v1-REST snapshots using an isolated `span-panel-api==1.1.15` env (the last version that still shipped the v1 client). Output: JSON on stdout.
12
+ - **`v2_reader.py`** — Captures N v2-MQTT snapshots from the current workspace install. Also reaches into the library's accumulator to grab the **raw** `downstream-lugs/active-power`, `imported-energy`, `exported-energy` — pre-derivation — for firmware-fix
13
+ tracking.
14
+ - **`compare.py`** — Driver. Runs both readers in parallel via `uv run`, zips samples by index, prints a side-by-side table per sample, and flags anomalies.
15
+ - **`run_local.sh.example`** — Template for the credentialed wrapper. Copy to `run_local.sh` (which is gitignored), fill in the three credential fields, run.
16
+
17
+ ## Setup
18
+
19
+ 1. Obtain credentials for a live panel:
20
+
21
+ - v1 token: existing pre-issued JWT (no re-registration needed).
22
+ - v2 passphrase: `hopPassphrase` for `/api/v2/auth/register`.
23
+
24
+ 2. Copy `run_local.sh.example` to `run_local.sh` (the latter is gitignored, so credentials won't leak), then edit:
25
+
26
+ ```bash
27
+ HOST="192.168.X.Y"
28
+ V1_TOKEN="..."
29
+ V2_PASSPHRASE="..."
30
+ SAMPLES=5
31
+ INTERVAL=3
32
+ ```
33
+
34
+ 3. Run: `./run_local.sh`.
35
+
36
+ Requires `uv`. The v1 reader is auto-provisioned via `uv run --no-project --with span-panel-api==1.1.15`; the v2 reader uses the current workspace install.
37
+
38
+ ## Output — what to look for
39
+
40
+ Per sample, `compare.py` prints:
41
+
42
+ - **Power table** — `main_power_w`, reported vs derived feedthrough, Σcircuits partitioned (PV vs loads), for both APIs.
43
+ - **v2 `power_flows`** — the panel's own `pv/battery/grid/site` aggregates, as indicators.
44
+ - **v2 downstream-lugs raw (pre-derivation)** — the three raw MQTT properties the library **stopped** reading into the snapshot in 2.6.3. This is the firmware-fix tracker.
45
+ - **Energy net** — `(consumed − produced)` for main, reported feedthrough, Σcircuits, and derived feedthrough.
46
+ - **Flags** — anomalies (see next section).
47
+
48
+ Post-2.6.3, `feedthrough_power_w (reported)` equals `(derived)` by construction on v2 (the library derives internally). That's expected — convergence on that row is itself the confirmation signal that the library-side fix is working. The interesting row
49
+ for ongoing tracking is the **raw** block.
50
+
51
+ ## Flags
52
+
53
+ - **`firmware downstream-lugs active-power still offset by X W vs Kirchhoff — upstream defect present`** — The raw MQTT `active-power` differs from the Kirchhoff-derived feedthrough by more than 100 W. Current state while the upstream firmware defect is
54
+ unpatched.
55
+ - **`firmware downstream-lugs active-power within X W of Kirchhoff — upstream defect MAY be fixed (confirm over sustained samples)`** — The delta has dropped below 50 W. Could be sensor noise on a single sample — confirm across a longer run (e.g.
56
+ `SAMPLES=30 INTERVAL=10`) before declaring the upstream fix has shipped.
57
+ - **`firmware downstream-lugs imported-energy is NEGATIVE (X Wh) — upstream counter still broken`** — The cumulative `imported-energy` counter went negative, which is physically impossible for a monotonic counter. Historically observed; current live panels
58
+ sometimes emit positive values, so the flag is only armed when `< 0`.
59
+ - **`v1 feedthrough_consumed_wh is NEGATIVE (...)`** — The v1 REST feedthrough energy counter is broken on this panel too, independent of the MQTT defect. Included for completeness; v1 is not a viable fallback.
60
+ - **`v1 reported net energy off Kirchhoff by X Wh`** / **`v1 reported feedthrough off Kirchhoff by X W`** — Same shape of check on the v1 side. v1 active-power tends to track Kirchhoff within sensor noise; v1 energy diverges heavily due to the broken
61
+ counter.
62
+ - **`derived feedthrough power diverges across APIs: v1=A vs v2=B`** — The Kirchhoff-derived values from v1 and v2 disagree by more than 100 W. Usually explained by sample-timing skew when load is shifting quickly; a persistent gap would warrant
63
+ investigation of the sign-partitioning logic.
64
+
65
+ ## When the firmware is fixed
66
+
67
+ Watch for the `active-power ... MAY be fixed` flag to fire on every sample across a sustained run (e.g. 30+ samples over several minutes). When that holds:
68
+
69
+ 1. Verify `imported-energy` stays non-negative and its delta from the derived `consumed_wh` stabilizes near zero.
70
+ 2. Consider whether the library should switch back to reading the native `downstream-lugs` values directly, or continue deriving. Deriving is robust regardless of firmware state, so the change is optional — potentially valuable only if the panel's own
71
+ measurement is more precise than the computed one (which is not yet established).
@@ -0,0 +1,311 @@
1
+ """Drive v1 and v2 readers in parallel; derive feedthrough from main minus
2
+ Σcircuits; print a side-by-side comparison for each sample.
3
+
4
+ Usage:
5
+
6
+ python compare.py \
7
+ --host 192.168.65.70 \
8
+ --v1-token "$V1_TOKEN" \
9
+ --v2-passphrase "$V2_PASSPHRASE" \
10
+ --samples 5 --interval 3
11
+
12
+ Physics — Kirchhoff at the main bus (grid-perspective on main/feedthrough,
13
+ load-perspective on branch circuits where positive = consumption):
14
+
15
+ P_main = P_feedthrough + Σ(branches, load-perspective)
16
+ => P_feedthrough_derived = P_main - Σ(branches)
17
+
18
+ PV handling. A solar inverter connected to a branch appears as:
19
+ * v1 REST: two raw physical tab circuits in grid-perspective (positive =
20
+ power flowing INTO the bus from the inverter). No virtual PV entry.
21
+ * v2 MQTT: one synthesized "PV" virtual circuit in load-perspective
22
+ (negative = producing), AND the underlying physical tabs are suppressed.
23
+
24
+ The v1-only circuits (by UUID set-difference with v2) therefore identify the
25
+ physical PV tabs. To get comparable load-perspective totals, we negate them:
26
+
27
+ Σ_v1_load = Σ_v1_raw - 2 * Σ(v1-only circuits)
28
+
29
+ Energy uses the Kirchhoff identity on NET counters:
30
+
31
+ net_feedthrough = (main_consumed − main_produced)
32
+ − Σ(c.consumed − c.produced)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import asyncio
39
+ import json
40
+ from pathlib import Path
41
+ import sys
42
+ from typing import Any
43
+
44
+ HERE = Path(__file__).resolve().parent
45
+ SPAN_API_ROOT = HERE.parent.parent
46
+
47
+
48
+ def _fmt(x: float | None, width: int = 12) -> str:
49
+ if x is None:
50
+ return f"{'—':>{width}}"
51
+ return f"{x:>{width}.2f}"
52
+
53
+
54
+ async def _run(cmd: list[str], cwd: Path) -> dict[str, Any]:
55
+ proc = await asyncio.create_subprocess_exec(
56
+ *cmd,
57
+ cwd=str(cwd),
58
+ stdout=asyncio.subprocess.PIPE,
59
+ stderr=asyncio.subprocess.PIPE,
60
+ )
61
+ stdout_b, stderr_b = await proc.communicate()
62
+ if proc.returncode != 0:
63
+ raise RuntimeError(
64
+ f"reader exited {proc.returncode}: {stderr_b.decode(errors='replace')}"
65
+ )
66
+ return json.loads(stdout_b.decode())
67
+
68
+
69
+ def _partition_v1(v1: dict[str, Any], shared_ids: set[str]) -> dict[str, float]:
70
+ """Partition v1 circuits into 'load' (shared with v2) and 'pv_tabs' (v1-only,
71
+ grid-perspective) and return comparable sums."""
72
+ load_p = load_c = load_pe = 0.0
73
+ pv_p = pv_c = pv_pe = 0.0
74
+ for c in v1["circuits"]:
75
+ p = float(c["instant_power_w"])
76
+ cons = float(c["consumed_energy_wh"])
77
+ prod = float(c["produced_energy_wh"])
78
+ if c["circuit_id"] in shared_ids:
79
+ load_p += p
80
+ load_c += cons
81
+ load_pe += prod
82
+ else:
83
+ pv_p += p
84
+ pv_c += cons
85
+ pv_pe += prod
86
+ return {
87
+ "load_power_w": load_p,
88
+ "load_consumed_wh": load_c,
89
+ "load_produced_wh": load_pe,
90
+ "pv_tabs_power_w_grid": pv_p,
91
+ "pv_tabs_consumed_wh_grid": pv_c,
92
+ "pv_tabs_produced_wh_grid": pv_pe,
93
+ # Load-perspective total for Kirchhoff: flip pv_tabs sign for power.
94
+ # For energy we can't symmetrically swap consumed/produced without
95
+ # knowing which counter corresponds to which direction in raw REST.
96
+ # Power-space correction is what we need for Kirchhoff balance.
97
+ "sigma_load_persp_power_w": load_p - pv_p,
98
+ "sigma_all_raw_power_w": load_p + pv_p,
99
+ }
100
+
101
+
102
+ def _sum_v2_circuits(v2: dict[str, Any]) -> dict[str, float]:
103
+ p = sum(float(c["instant_power_w"]) for c in v2["circuits"])
104
+ cons = sum(float(c["consumed_energy_wh"]) for c in v2["circuits"])
105
+ prod = sum(float(c["produced_energy_wh"]) for c in v2["circuits"])
106
+ return {
107
+ "sigma_power_w": p,
108
+ "sigma_consumed_wh": cons,
109
+ "sigma_produced_wh": prod,
110
+ }
111
+
112
+
113
+ def _print_sample(idx: int, v1: dict[str, Any], v2: dict[str, Any]) -> None:
114
+ shared_ids = {c["circuit_id"] for c in v1["circuits"]} & {
115
+ c["circuit_id"] for c in v2["circuits"]
116
+ }
117
+ p1 = _partition_v1(v1, shared_ids)
118
+ s2 = _sum_v2_circuits(v2)
119
+
120
+ main_v1 = float(v1["main_power_w"])
121
+ main_v2 = float(v2["main_power_w"])
122
+ feed_v1 = float(v1["feedthrough_power_w"])
123
+ feed_v2 = float(v2["feedthrough_power_w"])
124
+
125
+ # Derived feedthrough power (Kirchhoff, load-perspective Σ)
126
+ derived_v1 = main_v1 - p1["sigma_load_persp_power_w"]
127
+ derived_v2 = main_v2 - s2["sigma_power_w"]
128
+
129
+ # Energy nets
130
+ net_main_v1 = float(v1["main_consumed_wh"]) - float(v1["main_produced_wh"])
131
+ net_main_v2 = float(v2["main_consumed_wh"]) - float(v2["main_produced_wh"])
132
+ net_feed_rpt_v1 = float(v1["feedthrough_consumed_wh"]) - float(v1["feedthrough_produced_wh"])
133
+ net_feed_rpt_v2 = float(v2["feedthrough_consumed_wh"]) - float(v2["feedthrough_produced_wh"])
134
+ net_circ_v1 = p1["load_consumed_wh"] + p1["pv_tabs_consumed_wh_grid"] - (
135
+ p1["load_produced_wh"] + p1["pv_tabs_produced_wh_grid"]
136
+ )
137
+ net_circ_v2 = s2["sigma_consumed_wh"] - s2["sigma_produced_wh"]
138
+ net_feed_der_v1 = net_main_v1 - net_circ_v1
139
+ net_feed_der_v2 = net_main_v2 - net_circ_v2
140
+
141
+ dt = float(v2["t"]) - float(v1["t"])
142
+ print(f"\n=== sample {idx} (v2 vs v1 capture offset: {dt:+.2f}s) ===")
143
+ print(f" shared circuits: {len(shared_ids)} "
144
+ f"v1-only (PV tabs): {len(v1['circuits']) - len(shared_ids)} "
145
+ f"v2-only (PV virtual): {len(v2['circuits']) - len(shared_ids)}")
146
+
147
+ pv = v2.get("pv") or {}
148
+ if pv.get("feed_circuit_id"):
149
+ print(f" v2 pv: feed={pv['feed_circuit_id'][:8]} "
150
+ f"vendor={pv.get('vendor_name')} "
151
+ f"capacity={pv.get('nameplate_capacity_w')} W "
152
+ f"position={pv.get('relative_position')}")
153
+
154
+ print("\n power (W):")
155
+ print(f"{' field':<44}{'v1':>12}{'v2':>12}{'Δ(v2-v1)':>12}")
156
+ rows_p: list[tuple[str, float, float]] = [
157
+ ("main_power_w", main_v1, main_v2),
158
+ ("feedthrough_power_w (reported)", feed_v1, feed_v2),
159
+ ("Σ circuits (raw, v1 grid+load mixed)",
160
+ p1["sigma_all_raw_power_w"], s2["sigma_power_w"]),
161
+ ("Σ circuits (load-perspective)",
162
+ p1["sigma_load_persp_power_w"], s2["sigma_power_w"]),
163
+ ("Σ v1-only / v2-only (PV)",
164
+ p1["pv_tabs_power_w_grid"],
165
+ sum(float(c["instant_power_w"]) for c in v2["circuits"]
166
+ if c["circuit_id"] not in shared_ids)),
167
+ ("feedthrough_power_w (derived)", derived_v1, derived_v2),
168
+ ]
169
+ for label, a, b in rows_p:
170
+ print(f"{' ' + label:<44}{_fmt(a)}{_fmt(b)}{_fmt(b - a)}")
171
+
172
+ # v2-only: power flows indicators
173
+ pfp = v2.get("power_flow_pv")
174
+ pfb = v2.get("power_flow_battery")
175
+ pfg = v2.get("power_flow_grid")
176
+ pfs = v2.get("power_flow_site")
177
+ print("\n v2 power_flows (W):")
178
+ print(f" pv={_fmt(pfp, 9)} battery={_fmt(pfb, 9)} "
179
+ f"grid={_fmt(pfg, 9)} site={_fmt(pfs, 9)}")
180
+
181
+ # v2 raw downstream-lugs (pre-derivation) — captured directly from the
182
+ # MQTT accumulator before the library applies its Kirchhoff fix.
183
+ # Watch the delta vs derived to detect when the upstream firmware is
184
+ # patched (spanio/SPAN-API-Client-Docs#13). Because the SPAN API is in
185
+ # beta, there is no version signal that would announce the fix — we
186
+ # detect it empirically.
187
+ raw = v2.get("downstream_lugs_raw") or {}
188
+ raw_ap = raw.get("active_power_w")
189
+ raw_ie = raw.get("imported_energy_wh")
190
+ raw_ee = raw.get("exported_energy_wh")
191
+ print("\n v2 downstream-lugs raw (pre-derivation, upstream-firmware view):")
192
+ delta_ap = (raw_ap - derived_v2) if raw_ap is not None else None
193
+ print(f" active-power: {_fmt(raw_ap, 12)} "
194
+ f"Δ vs derived: {_fmt(delta_ap, 10)}")
195
+ print(f" imported-energy: {_fmt(raw_ie, 12)} "
196
+ f"(library-derived consumed: {_fmt(float(v2['feedthrough_consumed_wh']), 10)})")
197
+ print(f" exported-energy: {_fmt(raw_ee, 12)} "
198
+ f"(library-derived produced: {_fmt(float(v2['feedthrough_produced_wh']), 10)})")
199
+
200
+ print("\n energy net (Wh = consumed - produced):")
201
+ print(f"{' field':<44}{'v1':>12}{'v2':>12}{'Δ(v2-v1)':>12}")
202
+ rows_e: list[tuple[str, float, float]] = [
203
+ ("net_main", net_main_v1, net_main_v2),
204
+ ("net_feedthrough (reported)", net_feed_rpt_v1, net_feed_rpt_v2),
205
+ ("net_Σcircuits", net_circ_v1, net_circ_v2),
206
+ ("net_feedthrough (derived)", net_feed_der_v1, net_feed_der_v2),
207
+ ]
208
+ for label, a, b in rows_e:
209
+ print(f"{' ' + label:<44}{_fmt(a)}{_fmt(b)}{_fmt(b - a)}")
210
+
211
+ # Cross-API consistency: derived should match across v1 and v2.
212
+ # Note: v2 library >= 2.6.3 derives feedthrough, so `feed_v2 ==
213
+ # derived_v2` by construction — the defect signal now lives on the raw
214
+ # downstream-lugs values (see raw_ap / raw_ie / raw_ee above).
215
+ flags: list[str] = []
216
+ if abs(derived_v1 - derived_v2) > 100.0:
217
+ flags.append(
218
+ f"derived feedthrough power diverges across APIs: "
219
+ f"v1={derived_v1:+.1f} W vs v2={derived_v2:+.1f} W"
220
+ )
221
+ dp1 = derived_v1 - feed_v1
222
+ if abs(dp1) > 100.0:
223
+ flags.append(f"v1 reported feedthrough off Kirchhoff by {dp1:+.1f} W")
224
+ if float(v1["feedthrough_consumed_wh"]) < 0:
225
+ flags.append(
226
+ f"v1 feedthrough_consumed_wh is NEGATIVE ({v1['feedthrough_consumed_wh']:.0f}) — "
227
+ f"counter cannot decrease"
228
+ )
229
+ de1 = net_feed_der_v1 - net_feed_rpt_v1
230
+ if abs(de1) > 1000.0:
231
+ flags.append(f"v1 reported net energy off Kirchhoff by {de1:+,.0f} Wh")
232
+
233
+ # Raw downstream-lugs vs derived: this is the upstream-firmware tracker.
234
+ # When SPAN ships the fix, raw_ap will converge to derived_v2 and raw_ie
235
+ # will stop emitting negative values.
236
+ if raw_ap is not None and delta_ap is not None:
237
+ if abs(delta_ap) > 100.0:
238
+ flags.append(
239
+ f"firmware downstream-lugs active-power still offset by "
240
+ f"{delta_ap:+.1f} W vs Kirchhoff — upstream defect present"
241
+ )
242
+ elif abs(delta_ap) < 50.0:
243
+ flags.append(
244
+ f"firmware downstream-lugs active-power within {abs(delta_ap):.1f} W of "
245
+ f"Kirchhoff — upstream defect MAY be fixed (confirm over sustained samples)"
246
+ )
247
+ if raw_ie is not None and raw_ie < 0.0:
248
+ flags.append(
249
+ f"firmware downstream-lugs imported-energy is NEGATIVE ({raw_ie:.0f} Wh) — "
250
+ f"upstream counter still broken"
251
+ )
252
+
253
+ for f in flags:
254
+ print(f" ! {f}")
255
+
256
+
257
+ async def main() -> int:
258
+ parser = argparse.ArgumentParser()
259
+ parser.add_argument("--host", required=True)
260
+ parser.add_argument("--v1-token", required=True)
261
+ parser.add_argument("--v2-passphrase", required=True)
262
+ parser.add_argument("--port", type=int, default=80)
263
+ parser.add_argument("--samples", type=int, default=5)
264
+ parser.add_argument("--interval", type=float, default=3.0)
265
+ parser.add_argument("--dump-json", type=Path)
266
+ args = parser.parse_args()
267
+
268
+ v1_cmd = [
269
+ "uv", "run", "--no-project", "--with", "span-panel-api==1.1.15",
270
+ "python", str(HERE / "v1_reader.py"),
271
+ "--host", args.host,
272
+ "--token", args.v1_token,
273
+ "--port", str(args.port),
274
+ "--samples", str(args.samples),
275
+ "--interval", str(args.interval),
276
+ ]
277
+ v2_cmd = [
278
+ "uv", "run",
279
+ "python", str(HERE / "v2_reader.py"),
280
+ "--host", args.host,
281
+ "--passphrase", args.v2_passphrase,
282
+ "--port", str(args.port),
283
+ "--samples", str(args.samples),
284
+ "--interval", str(args.interval),
285
+ ]
286
+
287
+ v1_task = asyncio.create_task(_run(v1_cmd, cwd=HERE))
288
+ v2_task = asyncio.create_task(_run(v2_cmd, cwd=SPAN_API_ROOT))
289
+ v1_result, v2_result = await asyncio.gather(v1_task, v2_task)
290
+
291
+ v1_samples = v1_result["samples"]
292
+ v2_samples = v2_result["samples"]
293
+ n = min(len(v1_samples), len(v2_samples))
294
+ if n == 0:
295
+ print("no samples captured", file=sys.stderr)
296
+ return 1
297
+
298
+ for i in range(n):
299
+ _print_sample(i, v1_samples[i], v2_samples[i])
300
+
301
+ if args.dump_json is not None:
302
+ args.dump_json.write_text(
303
+ json.dumps({"v1": v1_result, "v2": v2_result}, indent=2)
304
+ )
305
+ print(f"\nraw JSON written to {args.dump_json}")
306
+
307
+ return 0
308
+
309
+
310
+ if __name__ == "__main__":
311
+ sys.exit(asyncio.run(main()))
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # Template for validate_lug_derivation. Copy to `run_local.sh` (gitignored),
3
+ # fill in the three credential fields, and run: `./run_local.sh`.
4
+
5
+ set -euo pipefail
6
+
7
+ HOST="192.168.X.Y"
8
+ V1_TOKEN="<paste v1 JWT here>"
9
+ V2_PASSPHRASE="<paste v2 hopPassphrase here>"
10
+
11
+ SAMPLES=5
12
+ INTERVAL=3
13
+ DUMP="/tmp/lug-readings.json"
14
+
15
+ cd "$(dirname "$0")"
16
+ exec python compare.py \
17
+ --host "$HOST" \
18
+ --v1-token "$V1_TOKEN" \
19
+ --v2-passphrase "$V2_PASSPHRASE" \
20
+ --samples "$SAMPLES" \
21
+ --interval "$INTERVAL" \
22
+ --dump-json "$DUMP"
@@ -0,0 +1,86 @@
1
+ """Read N v1 snapshots from a live panel and print JSON to stdout.
2
+
3
+ Run in an isolated environment with span-panel-api==1.1.15:
4
+
5
+ uv run --no-project --with 'span-panel-api==1.1.15' \
6
+ python v1_reader.py --host 192.168.X.Y --token T --samples 5 --interval 3
7
+
8
+ Emits a single JSON object: {"api": "v1", "samples": [{...}, ...]}.
9
+ Each sample records `t` (unix seconds), main power/energy, feedthrough
10
+ power/energy, and per-circuit instant_power_w / consumed_energy_wh /
11
+ produced_energy_wh.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import json
19
+ import sys
20
+ import time
21
+
22
+ from span_panel_api import SpanPanelClient
23
+
24
+
25
+ def _circuit_to_dict(circuit_id: str, circuit: object) -> dict[str, object]:
26
+ return {
27
+ "circuit_id": circuit_id,
28
+ "name": getattr(circuit, "name", "") or "",
29
+ "instant_power_w": float(getattr(circuit, "instant_power_w", 0.0)),
30
+ "consumed_energy_wh": float(getattr(circuit, "consumed_energy_wh", None) or 0.0),
31
+ "produced_energy_wh": float(getattr(circuit, "produced_energy_wh", None) or 0.0),
32
+ "tabs": list(getattr(circuit, "tabs", None) or []),
33
+ "relay_state": getattr(circuit, "relay_state").value,
34
+ }
35
+
36
+
37
+ async def read_once(client: SpanPanelClient) -> dict[str, object]:
38
+ panel = await client.get_panel_state()
39
+ circuits_out = await client.get_circuits()
40
+
41
+ main_energy = panel.main_meter_energy
42
+ feed_energy = panel.feedthrough_energy
43
+
44
+ circuits = [
45
+ _circuit_to_dict(cid, c)
46
+ for cid, c in circuits_out.circuits.additional_properties.items()
47
+ ]
48
+
49
+ return {
50
+ "t": time.time(),
51
+ "main_power_w": float(panel.instant_grid_power_w),
52
+ "feedthrough_power_w": float(panel.feedthrough_power_w),
53
+ "main_consumed_wh": float(main_energy.consumed_energy_wh),
54
+ "main_produced_wh": float(main_energy.produced_energy_wh),
55
+ "feedthrough_consumed_wh": float(feed_energy.consumed_energy_wh),
56
+ "feedthrough_produced_wh": float(feed_energy.produced_energy_wh),
57
+ "circuits": circuits,
58
+ }
59
+
60
+
61
+ async def main() -> int:
62
+ parser = argparse.ArgumentParser()
63
+ parser.add_argument("--host", required=True)
64
+ parser.add_argument("--token", required=True)
65
+ parser.add_argument("--port", type=int, default=80)
66
+ parser.add_argument("--samples", type=int, default=1)
67
+ parser.add_argument("--interval", type=float, default=3.0)
68
+ args = parser.parse_args()
69
+
70
+ samples: list[dict[str, object]] = []
71
+ async with SpanPanelClient(
72
+ host=args.host, port=args.port, use_ssl=False, timeout=15.0
73
+ ) as client:
74
+ client.set_access_token(args.token)
75
+ for i in range(args.samples):
76
+ if i > 0:
77
+ await asyncio.sleep(args.interval)
78
+ samples.append(await read_once(client))
79
+
80
+ json.dump({"api": "v1", "samples": samples}, sys.stdout)
81
+ sys.stdout.write("\n")
82
+ return 0
83
+
84
+
85
+ if __name__ == "__main__":
86
+ sys.exit(asyncio.run(main()))