From 7385d231fe485c4a87e0a8f6ec55e72be23d0d80 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Mon, 24 Aug 2026 22:33:32 -0400 Subject: [PATCH 01/35] docs: specify priority zero meter configuration --- ...26-08-24-priority-0-meter-configuration.md | 2751 +++++++++++++++++ ...4-priority-0-meter-configuration-design.md | 237 ++ 2 files changed, 2988 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md create mode 100644 docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md diff --git a/docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md b/docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md new file mode 100644 index 0000000..7f2a39b --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md @@ -0,0 +1,2751 @@ +# Priority 0 ESPHome Meter Configuration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand CircuitSetup Energy Meter Helper from CT selection/calibration into a safe, topology-aware configurator for electrical system type, voltage references, reporting interval, circuit roles, grouped totals, energy reporting, complete range scaling for the supported power-quality values, and configurable ATM90E32 status thresholds. + +**Architecture:** Keep the current line-preserving, hash-bound configuration transaction architecture. Add a typed meter-configuration domain model, parse only a bounded official YAML surface, and render deterministic helper-managed override blocks instead of serializing arbitrary YAML. Preserve the existing CT setup and calibration paths as compatibility wrappers while introducing generalized meter-configuration read/preview/apply commands. Implement the ATM90E32 threshold behavior in ESPHome first, then capability-gate the helper controls until that component support is available in a released ESPHome version. + +**Tech Stack:** Python 3.13, Home Assistant custom integration APIs, Voluptuous, aioesphomeapi, aiohasupervisor, ESPHome Python code generation and C++, Lit 3, TypeScript, Vitest, Playwright, pytest, Ruff, mypy, GitHub Actions. + +**Spec:** The “Approved Requirements Baseline” section in this document is the controlling specification. Before implementation, copy it unchanged to `docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md` in `CircuitSetup/CircuitSetup-Energy-Meter-Helper`. + +**Baseline:** Begin from `CircuitSetup/CircuitSetup-Energy-Meter-Helper` commit `27d1dfad665c9cc5a8371ab7de428d41f3306118` or a later `main` commit that contains PR #21, “Add per-board meter package options.” For the companion meter configurations, begin from `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` commit `b94637a4f084a3a4a35e3e5f48eb1586bbd972c3` or later; that baseline already categorizes status fields as diagnostic. + +## Approved Requirements Baseline + +### Included Priority 0 capabilities + +1. **Electrical-system profile** + - North American split-phase 120/240 V. + - Single-phase 220–240 V. + - Three-phase. + - Custom. + - Explicit 50 Hz or 60 Hz selection. + - No geographic assumption may silently become authoritative. + +2. **Voltage references and transformer configuration** + - One or more named voltage references. + - Nominal RMS voltage per reference. + - Transformer preset or custom starting gain. + - Explicit mapping from every ATM90E32 group to exactly one voltage reference. + - Generic physical-preparation acknowledgement when more than one voltage reference is configured. + - Voltage calibration operates by voltage reference, not by a hard-coded board pair. + +3. **Reporting interval** + - Supported choices: 1, 2, 5, 10, 30, or 60 seconds. + - Configuration and calibration timeouts must account for the selected interval. + - The UI must explain the traffic and calibration implications. + +4. **Channel usage, role, phase/reference, and two-pole measurement** + - Used or unused. + - Roles: grid/mains, solar, generator, subpanel feeder, branch circuit, two-pole appliance, custom, unused. + - Every used channel maps to one voltage reference. + - Two-pole methods: two CTs summed, one CT with power doubled, both conductors through one CT, or direct single-channel measurement. + - Register-range scaling and semantic circuit-power scaling remain separate. + - Do not add a casual software CT-inversion switch. + +5. **Totals, groups, and energy reporting** + - User-defined grid, generation, subpanel, circuit, and custom aggregates. + - No default “sum every CT” total when that can double-count branch circuits already included in mains or feeder measurements. + - Energy modes: none, consumption, bidirectional import/export, and generation. + - Aggregate power, optional aggregate current, and managed energy entities use deterministic IDs. + - Existing official generic totals must be hidden or explicitly identified as unmanaged before replacement totals are exposed. + +6. **Power-quality range scaling** + - Existing `reporting_multiplier` values remain exactly `1`, `2`, `4`, or `8`. + - Continue scaling current and active power. + - Add matching scaling for reactive power and apparent power. + - Do not multiply power factor or phase angle. + - Harmonic power and peak current are not part of the helper-managed power-quality feature. When the existing full package is enabled by the helper, remove those two entities in the helper-managed override block. + - Do not add harmonic-power or peak-current fields to new helper models, schemas, UI, entity estimates, or tests except tests proving they are absent/removed. + +7. **Configurable status thresholds** + - Absolute sag-voltage threshold per voltage reference. + - Absolute overvoltage threshold per voltage reference. + - Low- and high-frequency thresholds per voltage reference. + - Per-channel overcurrent warning threshold in final reported amperes. + - Separate “measurement range exceeded” from user-configured “over current.” + - Status entities remain diagnostic and disabled by default. + - Helper controls remain unavailable until the selected Device Builder ESPHome version includes the new ATM90E32 schema. + +### Explicit exclusions + +- **No board-revision option.** Do not add a `board_revision` type, field, UI control, persisted value, mutation, validation rule, diagnostic field, migration, or test-matrix dimension. +- No generic YAML editor. +- No arbitrary package paths, filters, lambdas, SPI pins, internal ATM90E32 IDs, `gain_pga`, or `current_phases` in the normal UI. +- No automatic Home Assistant notification creation. +- No software CT inversion in this work. +- No harmonic-power or peak-current management. +- No firmware permutation explosion for every electrical profile. New devices flash the standard topology firmware selected by add-on count and connection type; the helper applies electrical settings after adoption. +- Do not remove calibration controls or calibration sensor entities required by the current binding and verification flow. +- Do not bypass source hashes, explicit preview, compile, install confirmation, reconnect verification, or rollback. + +### User-flow constraint + +Add only one new main step, **Meter Settings**, between **Setup Device** and **Circuits & CTs**. Rename the existing **CT Settings** step to **Circuits & CTs**. Keep aggregate and energy controls inside that step rather than adding separate permanent wizard pages. + +--- + +## Cross-Repository Delivery Order + +Implement as dependency-ordered pull requests. Do not merge a later PR before all listed predecessors are available. + +1. **ESPHome component PR** — `CircuitSetup/esphome` + - Configurable ATM90E32 thresholds and correct current-status semantics. +2. **Meter configuration contract PR** — `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` + - Stable status metadata, stable legacy-total IDs, and helper contract assertions. +3. **Helper backend foundation PR** — models, catalog, storage, parser, topology, capabilities. +4. **Helper mutation and transaction PR** — generalized meter configuration, range scaling, thresholds, aggregates. +5. **Helper calibration PR** — voltage-reference-aware calibration and interval-aware timing. +6. **Helper frontend PR** — Meter Settings, Circuits & CTs, review, summaries, accessibility. +7. **Integration/release PR** — full firmware contract matrix, E2E scenarios, documentation, version bump. + +The ESPHome component PR must appear in a released ESPHome tag before the helper status-threshold controls are enabled. Once the release exists, write that literal version into `ATM90E32_STATUS_THRESHOLDS_MIN_VERSION`. If no released tag exists, merge the companion component/config work but keep `status_thresholds` capability false and do not expose editable threshold controls. + +--- + +## Planned File Structure + +### `CircuitSetup/esphome` + +**Modify** +- `esphome/components/atm90e32/sensor.py` — schema, validation, and code generation. +- `esphome/components/atm90e32/atm90e32.h` — threshold fields and setters. +- `esphome/components/atm90e32/atm90e32.cpp` — register setup and current-status behavior. +- `tests/components/atm90e32/common.yaml` — compile-valid threshold examples. +- `tests/components/atm90e32/test.esp32-idf.yaml` +- `tests/components/atm90e32/test.esp8266-ard.yaml` +- `tests/components/atm90e32/test.rp2040-ard.yaml` +- ATM90E32 documentation/changelog files required by that repository’s contribution rules. + +**Create if the repository’s native-test harness supports component C++ tests** +- `tests/unit_tests/components/atm90e32/test_status_thresholds.cpp` + +### `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` + +**Modify** +- `Software/ESPHome/status_fields/6chan_main_status.yaml` +- `Software/ESPHome/status_fields/6chan_addon1_status.yaml` through `6chan_addon6_status.yaml` +- `Software/ESPHome/6chan_energy_meter_main_board.yaml` +- Every supported Wi-Fi, LilyGO Ethernet, and Waveshare top-level meter YAML. +- `Software/ESPHome/README.md` +- `.github/workflows/esphome-compile.yml` + +**Create** +- `scripts/validate_helper_contract.py` +- `.github/workflows/helper-contract.yml` if the existing compile workflow cannot run the contract script cleanly. + +### `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Create** +- `custom_components/circuitsetup_energy_meter_helper/meter_configuration.py` +- `custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py` +- `custom_components/circuitsetup_energy_meter_helper/data/voltage_transformers.json` +- `custom_components/circuitsetup_energy_meter_helper/meter_inventory.py` +- `custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py` +- `custom_components/circuitsetup_energy_meter_helper/config_blocks.py` +- `custom_components/circuitsetup_energy_meter_helper/entity_estimator.py` +- `frontend/src/components/meter-settings-step.ts` +- `frontend/src/components/circuit-aggregates.ts` +- `frontend/src/components/configuration-impact.ts` +- `tests/test_meter_configuration.py` +- `tests/test_voltage_transformer_catalog.py` +- `tests/test_meter_inventory.py` +- `tests/test_meter_config_mutator.py` +- `tests/test_entity_estimator.py` +- `frontend/test/meter-settings.test.ts` +- `frontend/test/circuit-aggregates.test.ts` +- `frontend/test/configuration-impact.test.ts` + +**Modify** +- `custom_components/circuitsetup_energy_meter_helper/models.py` +- `custom_components/circuitsetup_energy_meter_helper/store.py` +- `custom_components/circuitsetup_energy_meter_helper/config_document.py` +- `custom_components/circuitsetup_energy_meter_helper/config_mutator.py` +- `custom_components/circuitsetup_energy_meter_helper/config_transaction.py` +- `custom_components/circuitsetup_energy_meter_helper/topology.py` +- `custom_components/circuitsetup_energy_meter_helper/workflow.py` +- `custom_components/circuitsetup_energy_meter_helper/websocket_api.py` +- `custom_components/circuitsetup_energy_meter_helper/device_builder.py` +- `custom_components/circuitsetup_energy_meter_helper/calibration_engine.py` +- `custom_components/circuitsetup_energy_meter_helper/entity_binding.py` +- `custom_components/circuitsetup_energy_meter_helper/preflight.py` +- `custom_components/circuitsetup_energy_meter_helper/diagnostics.py` +- `custom_components/circuitsetup_energy_meter_helper/repairs.py` +- `custom_components/circuitsetup_energy_meter_helper/__init__.py` +- `frontend/src/types.ts` +- `frontend/src/api.ts` +- `frontend/src/panel.ts` +- `frontend/src/components/setup-device-step.ts` +- `frontend/src/components/ct-inventory-step.ts` +- `frontend/src/components/package-options.ts` +- `frontend/src/components/config-review-step.ts` +- `frontend/src/components/summary-step.ts` +- `frontend/src/styles.ts` +- Existing Python/frontend/E2E tests for every modified surface. + +--- + +## Public Data Contracts + +Define these names exactly unless an existing merged change creates a naming collision. + +```python +# meter_configuration.py +from dataclasses import dataclass +from enum import StrEnum +from typing import Literal + +LineFrequencyHz = Literal[50, 60] +UpdateIntervalSeconds = Literal[1, 2, 5, 10, 30, 60] + +class ElectricalSystem(StrEnum): + SPLIT_PHASE_120_240 = "split_phase_120_240" + SINGLE_PHASE_230 = "single_phase_230" + THREE_PHASE = "three_phase" + CUSTOM = "custom" + +class VoltageLayout(StrEnum): + STANDARD = "standard" + MULTI_REFERENCE = "multi_reference" + CUSTOM = "custom" + +class CircuitRole(StrEnum): + GRID = "grid" + SOLAR = "solar" + GENERATOR = "generator" + SUBPANEL = "subpanel" + BRANCH = "branch" + TWO_POLE = "two_pole" + CUSTOM = "custom" + UNUSED = "unused" + +class MeasurementMethod(StrEnum): + DIRECT = "direct" + TWO_CT_SUM = "two_ct_sum" + ONE_CT_DOUBLE_POWER = "one_ct_double_power" + BOTH_CONDUCTORS_ONE_CT = "both_conductors_one_ct" + +class EnergyMode(StrEnum): + NONE = "none" + CONSUMPTION = "consumption" + BIDIRECTIONAL = "bidirectional" + GENERATION = "generation" + +@dataclass(frozen=True, slots=True) +class VoltageReferenceConfig: + reference_id: str + label: str + phase_label: str + nominal_voltage_v: float + transformer_model_id: str + gain_voltage: int + group_keys: tuple[str, ...] + sag_percent: float + overvoltage_percent: float + frequency_low_hz: float + frequency_high_hz: float + +@dataclass(frozen=True, slots=True) +class MeterSettings: + friendly_name: str + electrical_system: ElectricalSystem + line_frequency_hz: LineFrequencyHz + update_interval_s: UpdateIntervalSeconds + voltage_layout: VoltageLayout + voltage_references: tuple[VoltageReferenceConfig, ...] + +@dataclass(frozen=True, slots=True) +class ChannelSettings: + channel: int + enabled: bool + name: str + model_id: str + reporting_multiplier: float + role: CircuitRole + voltage_reference_id: str + current_warning_a: float | None + custom_gain_ct: int | None = None + custom_label: str | None = None + burden_output_acknowledged: bool = False + +@dataclass(frozen=True, slots=True) +class CircuitAggregate: + aggregate_id: str + name: str + role: CircuitRole + channels: tuple[int, ...] + measurement_method: MeasurementMethod + parent_id: str | None + energy_mode: EnergyMode + expose_power: bool = True + expose_current: bool = False + +@dataclass(frozen=True, slots=True) +class MeterConfigurationRequest: + meter: MeterSettings + channels: tuple[ChannelSettings, ...] + aggregates: tuple[CircuitAggregate, ...] + power_quality: tuple[bool, ...] + status_fields: tuple[bool, ...] + multi_reference_preparation_acknowledged: bool = False +``` + +The request’s `multi_reference_preparation_acknowledged` value is operation-scoped and must not be stored as a claim that hardware was physically verified. + +```python +# meter_inventory.py +@dataclass(frozen=True, slots=True) +class MeterConfigurationCapabilities: + configuration_authoritative: bool + status_thresholds: bool + managed_totals: bool + multi_reference: bool + reason_codes: tuple[str, ...] + +@dataclass(frozen=True, slots=True) +class MeterConfigurationInventory: + plan_id: str + source_sha256: str + topology: MeterTopology + configuration: MeterConfigurationRequest + capabilities: MeterConfigurationCapabilities + voltage_transformer_catalog: VoltageTransformerCatalog + ct_catalog: CTPresetCatalog + warnings: tuple[str, ...] +``` + +```python +# meter_config_mutator.py +def build_meter_configuration_mutation( + snapshot: ConfigSnapshot, + topology: MeterTopology, + current: MeterConfigurationInventory, + requested: MeterConfigurationRequest, + *, + calibrated: VerifiedCalibrationRecord | None = None, +) -> ConfigMutationPlan +``` + +```python +# entity_estimator.py +@dataclass(frozen=True, slots=True) +class ConfigurationImpact: + enabled_channel_count: int + numeric_entity_count: int + text_entity_count: int + energy_entity_count: int + approximate_publications_per_second: float + +def estimate_configuration_impact( + request: MeterConfigurationRequest, + topology: MeterTopology, +) -> ConfigurationImpact +``` + +--- + +# Detailed Tasks + +## Task 1: Freeze the approved design and execution baseline + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Create: `docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md` +- Create: `docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md` + +**Interfaces:** +- Consumes: The Approved Requirements Baseline and Public Data Contracts above. +- Produces: The committed spec and this executable plan. + +- [ ] **Step 1: Create an isolated worktree** + +Run: + +```bash +git fetch origin +git worktree add ../energy-meter-helper-priority-0 -b feat/priority-0-meter-configuration origin/main +cd ../energy-meter-helper-priority-0 +git rev-parse HEAD +``` + +Expected: the printed commit contains PR #21 or is newer than `27d1dfad665c9cc5a8371ab7de428d41f3306118`. + +- [ ] **Step 2: Save the design specification** + +Copy the complete “Approved Requirements Baseline,” “User-flow constraint,” “Explicit exclusions,” and “Public Data Contracts” sections into: + +```text +docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md +``` + +- [ ] **Step 3: Save this implementation plan** + +Save this complete document to: + +```text +docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md +``` + +- [ ] **Step 4: Verify prohibited scope is absent** + +Run: + +```bash +grep -RniE 'board_revision|board revision' \ + docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md \ + docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md +``` + +Expected: matches appear only in explicit exclusion statements; no proposed type, field, control, or task uses board revision. + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md \ + docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md +git commit -m "docs: specify priority zero meter configuration" +``` + +--- + +## Task 2: Add configurable ATM90E32 threshold schema + +**Repository:** `CircuitSetup/esphome` + +**Files:** +- Modify: `esphome/components/atm90e32/sensor.py` +- Modify: `tests/components/atm90e32/common.yaml` + +**Interfaces:** +- Consumes: Existing `ATM90E32_PHASE_SCHEMA` and `CONFIG_SCHEMA`. +- Produces: + - Component options `voltage_sag_threshold`, `over_voltage_threshold`, `frequency_low_threshold`, and `frequency_high_threshold`. + - Phase option `over_current_threshold`. + - Codegen calls to matching C++ setters. + +- [ ] **Step 1: Write schema-validation tests/config cases** + +Add one valid ATM90E32 example to `tests/components/atm90e32/common.yaml`: + +```yaml +sensor: + - platform: atm90e32 + id: meter_threshold_test + cs_pin: 5 + line_frequency: 60Hz + voltage_sag_threshold: 93.6 + over_voltage_threshold: 146.4 + frequency_low_threshold: 57.0 + frequency_high_threshold: 63.0 + phase_a: + current: + name: Threshold Test Current + over_current_threshold: 100.0 +``` + +Add invalid validation fixtures using the repository’s established validation-test mechanism for: + +```yaml +voltage_sag_threshold: 150 +over_voltage_threshold: 140 +``` + +and: + +```yaml +frequency_low_threshold: 63 +frequency_high_threshold: 57 +``` + +Expected validation errors: +- `voltage_sag_threshold must be lower than over_voltage_threshold` +- `frequency_low_threshold must be lower than frequency_high_threshold` + +- [ ] **Step 2: Run the ATM90E32 config tests and confirm failure** + +Run the repository’s component test command for `tests/components/atm90e32`. + +Expected: FAIL because the new keys are not in the schema. + +- [ ] **Step 3: Add exact schema constants and validation** + +Add: + +```python +CONF_VOLTAGE_SAG_THRESHOLD = "voltage_sag_threshold" +CONF_OVER_VOLTAGE_THRESHOLD = "over_voltage_threshold" +CONF_FREQUENCY_LOW_THRESHOLD = "frequency_low_threshold" +CONF_FREQUENCY_HIGH_THRESHOLD = "frequency_high_threshold" +CONF_OVER_CURRENT_THRESHOLD = "over_current_threshold" +``` + +Use bounded finite floats: + +```python +cv.Optional(CONF_VOLTAGE_SAG_THRESHOLD): cv.float_range(min=1.0, max=600.0) +cv.Optional(CONF_OVER_VOLTAGE_THRESHOLD): cv.float_range(min=1.0, max=600.0) +cv.Optional(CONF_FREQUENCY_LOW_THRESHOLD): cv.float_range(min=40.0, max=70.0) +cv.Optional(CONF_FREQUENCY_HIGH_THRESHOLD): cv.float_range(min=40.0, max=70.0) +cv.Optional(CONF_OVER_CURRENT_THRESHOLD): cv.float_range(min=0.1, max=10_000.0) +``` + +Add a final validator that: +- Requires sag and overvoltage to be provided together or both omitted. +- Requires frequency low/high to be provided together or both omitted. +- Enforces sag `<` overvoltage. +- Enforces frequency low `<` high. +- Does not derive nominal voltage in the component schema. + +- [ ] **Step 4: Add code generation** + +Generate these calls only when configured: + +```python +cg.add(var.set_voltage_sag_threshold(config[CONF_VOLTAGE_SAG_THRESHOLD])) +cg.add(var.set_over_voltage_threshold(config[CONF_OVER_VOLTAGE_THRESHOLD])) +cg.add(var.set_frequency_low_threshold(config[CONF_FREQUENCY_LOW_THRESHOLD])) +cg.add(var.set_frequency_high_threshold(config[CONF_FREQUENCY_HIGH_THRESHOLD])) +cg.add(var.set_over_current_threshold(i, conf[CONF_OVER_CURRENT_THRESHOLD])) +``` + +- [ ] **Step 5: Run config tests** + +Expected: valid configuration passes and invalid ordering fails with the exact safe messages. + +- [ ] **Step 6: Commit** + +```bash +git add esphome/components/atm90e32/sensor.py tests/components/atm90e32 +git commit -m "feat(atm90e32): add configurable status thresholds" +``` + +--- + +## Task 3: Implement ATM90E32 threshold registers and current-status semantics + +**Repository:** `CircuitSetup/esphome` + +**Files:** +- Modify: `esphome/components/atm90e32/atm90e32.h` +- Modify: `esphome/components/atm90e32/atm90e32.cpp` +- Create when supported: `tests/unit_tests/components/atm90e32/test_status_thresholds.cpp` + +**Interfaces:** +- Consumes: setters generated by Task 2. +- Produces: + - Absolute voltage/frequency threshold register configuration. + - Per-phase overcurrent thresholds in final reported amperes. + - Separate `Measurement Range Exceeded` and `Over Current` status messages. + +- [ ] **Step 1: Add failing C++ tests** + +Cover: + +```cpp +TEST(ATM90E32StatusThresholds, CalculatesAbsoluteVoltageRegister) { + EXPECT_EQ(component.calculate_voltage_threshold_for_test(7305, 93.6f), + 29689); +} + +TEST(ATM90E32CurrentStatus, SeparatesRangeAndUserThreshold) { + component.set_raw_current_for_test(0, 65.535f); + component.set_reported_current_for_test(0, 131.07f); + component.set_over_current_threshold(0, 150.0f); + EXPECT_EQ(component.phase_status_for_test(0), "Measurement Range Exceeded"); +} +``` + +If the repository cannot directly compile component unit tests, create a small test seam guarded by `#ifdef USE_TESTS` and validate generated C++ plus representative firmware compiles. + +- [ ] **Step 2: Add fields and setters** + +Use `NAN` as the “not configured” sentinel: + +```cpp +float voltage_sag_threshold_{NAN}; +float over_voltage_threshold_{NAN}; +float frequency_low_threshold_{NAN}; +float frequency_high_threshold_{NAN}; +std::array over_current_threshold_{{NAN, NAN, NAN}}; +std::array raw_current_{{NAN, NAN, NAN}}; +``` + +Add public setters with phase bounds. + +- [ ] **Step 3: Replace multiplier-based voltage threshold calculation** + +Replace: + +```cpp +calculate_voltage_threshold(int line_freq, uint16_t ugain, float multiplier) +``` + +with: + +```cpp +uint16_t ATM90E32Component::calculate_voltage_threshold( + uint16_t voltage_gain, float rms_voltage) +``` + +The calculation must: +- Convert RMS volts to peak. +- Round the computed register value with `std::lround`; the 93.6 V/7305 test therefore expects `29689`. +- Scale using the configured voltage gain. +- Reject non-finite or non-positive values before register write. +- Saturate to the register’s valid `uint16_t` range. + +- [ ] **Step 4: Preserve legacy defaults only when fields are absent** + +In `setup()`: +- If explicit voltage thresholds exist, use them. +- Otherwise preserve the existing 78%/122% legacy threshold derivation. +- If explicit frequency thresholds exist, convert them to the ATM90E32 register’s hundredths-of-Hz format. +- Otherwise preserve 57/63 Hz for 60 Hz mode and 47/53 Hz for 50 Hz mode. + +- [ ] **Step 5: Track unfiltered current separately** + +In `get_phase_current_()` and the averaging path, store raw register-derived amperes in `raw_current_[phase]` before sensor filters are applied. + +- [ ] **Step 6: Replace `check_over_current()`** + +Implement deterministic current status: + +```cpp +const bool range_exceeded = + std::isfinite(raw_current_[phase]) && raw_current_[phase] >= 65.50f; +const bool over_current = + std::isfinite(over_current_threshold_[phase]) && + current_sensor != nullptr && + std::isfinite(current_sensor->state) && + current_sensor->state > over_current_threshold_[phase]; +``` + +Status order: +1. Existing chip voltage/phase messages. +2. `Measurement Range Exceeded`. +3. `Over Current`. + +Do not use 65.53 A as a user circuit alarm. + +- [ ] **Step 7: Compile all ATM90E32 test platforms** + +Run the ESPHome component compile tests for: +- ESP32 ESP-IDF. +- ESP8266 Arduino. +- RP2040 Arduino. + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add esphome/components/atm90e32/atm90e32.h \ + esphome/components/atm90e32/atm90e32.cpp \ + tests/components/atm90e32 \ + tests/unit_tests/components/atm90e32 2>/dev/null || true +git commit -m "feat(atm90e32): apply configurable status limits" +``` + +--- + +## Task 4: Document and release the ATM90E32 capability + +**Repository:** `CircuitSetup/esphome`, followed by the upstream ESPHome contribution path used by this project. + +**Files:** +- Modify the ATM90E32 documentation. +- Modify the required changelog file. +- Later modify helper constant location created in Task 9. + +**Interfaces:** +- Consumes: Tasks 2–3. +- Produces: A released ESPHome version containing the new schema. + +- [ ] **Step 1: Document each field** + +Document: +- Units. +- Defaults when omitted. +- Absolute voltage semantics. +- Per-phase final-reported-current semantics. +- Difference between `Measurement Range Exceeded` and `Over Current`. + +- [ ] **Step 2: Run ESPHome’s complete required checks** + +Run the exact lint, codegen validation, and component compile commands required by the repository. + +- [ ] **Step 3: Open and merge the component PR** + +Do not begin editable helper threshold controls before the change is in a released ESPHome tag. + +- [ ] **Step 4: Record the first released version** + +After release, set the helper constant created in Task 9 to the exact numeric ESPHome release tag. The committed Python source must contain the literal tag string. Do not commit a symbolic value, wildcard, pre-release guess, or environment lookup. + +- [ ] **Step 5: Commit release-floor update in the helper branch** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/device_builder.py +git commit -m "chore: record atm90e32 threshold version floor" +``` + +--- + +## Task 5: Harden official status packages and legacy total IDs + +**Repository:** `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` + +**Files:** +- Modify all `Software/ESPHome/status_fields/6chan_*_status.yaml`. +- Modify every official top-level meter YAML. +- Modify `Software/ESPHome/README.md`. + +**Interfaces:** +- Consumes: Released ATM90E32 threshold component from Task 4. +- Produces: + - Diagnostic, disabled-by-default status text entities. + - Stable IDs for official generic total power/current/energy entities. + - An official configuration contract suitable for helper mutation. + +- [ ] **Step 1: Complete status entity categorization** + +The baseline already sets `entity_category: diagnostic`. Verify it exists on every phase/frequency status entity, then add: + +```yaml +disabled_by_default: true +``` + +Keep names and component IDs compatible with current helper binding. + +- [ ] **Step 2: Add stable IDs to generic totals** + +Use consistent official IDs: + +```yaml +id: totalAmps +id: totalWatts +id: totalEnergyDaily +``` + +For board totals retain existing IDs such as `totalAmpsMain`, `totalWattsMain`, and add missing IDs consistently. + +- [ ] **Step 3: Add a configuration-contract scalar** + +Add to every official top-level file: + +```yaml +substitutions: + csemh_config_contract: "2" +``` + +Do not add any board-revision scalar. + +- [ ] **Step 4: Set the released ESPHome minimum** + +Set `esphome.min_version` to the literal release recorded in Task 4 for configurations that expose helper-managed thresholds. + +- [ ] **Step 5: Preserve the full manual power-quality packages** + +Do not delete harmonic-power or peak-current definitions from the existing manual package files. Update documentation to state: +- Manual package users still receive the full set. +- CircuitSetup Energy Meter Helper intentionally removes harmonic power and peak current from its managed configuration. + +- [ ] **Step 6: Update documentation** + +Document the stable IDs and helper-managed status behavior. + +- [ ] **Step 7: Commit** + +```bash +git add Software/ESPHome +git commit -m "feat(esphome): publish helper configuration contract" +``` + +--- + +## Task 6: Add an automated official-config contract validator + +**Repository:** `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` + +**Files:** +- Create: `scripts/validate_helper_contract.py` +- Modify: `.github/workflows/esphome-compile.yml` +- Optionally create: `.github/workflows/helper-contract.yml` + +**Interfaces:** +- Consumes: Official YAML changes from Task 5. +- Produces: A CI gate proving the helper’s expected bounded surface exists. + +- [ ] **Step 1: Write failing validator tests or self-tests** + +The script must enumerate official top-level meter YAML and assert: +- `csemh_config_contract: "2"` exists once. +- `friendly_name`, `update_time`, `electric_freq`, `voltage_cal1`, and `voltage_cal2` exist once. +- Every active CT has `ctN_name` and `current_cal_ctN`. +- Every optional power-quality and status package line exists at most once. +- Generic total IDs are stable. +- No `board_revision` key exists. +- Status packages mark entities diagnostic and disabled by default. + +- [ ] **Step 2: Implement the validator** + +Use `ruamel.yaml` only if already available in CI; otherwise use line-oriented validation so the script does not introduce a runtime dependency. + +- [ ] **Step 3: Add CI invocation** + +```bash +python scripts/validate_helper_contract.py +``` + +Run before the firmware compile matrix. + +- [ ] **Step 4: Compile representative official configurations** + +At minimum: +- Main-board Wi-Fi. +- Main-board LilyGO Ethernet. +- Main-board Waveshare Ethernet. +- One add-on. +- Three add-ons. +- Six add-ons. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/validate_helper_contract.py .github/workflows +git commit -m "test: enforce energy meter helper contract" +``` + +--- + +## Task 7: Add typed meter configuration models and invariants + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Create: `custom_components/circuitsetup_energy_meter_helper/meter_configuration.py` +- Create: `tests/test_meter_configuration.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/models.py` + +**Interfaces:** +- Consumes: Public Data Contracts. +- Produces: + - All enums/dataclasses listed above. + - `validate_meter_configuration(request, topology) -> None`. + - `default_meter_configuration(topology, package_options) -> MeterConfigurationRequest`. + +- [ ] **Step 1: Write failing model tests** + +Tests must cover: +- Allowed update intervals only. +- Exactly 50 or 60 Hz. +- Every topology group assigned to exactly one voltage reference. +- Every used channel assigned to a valid reference. +- Unused channels require role `UNUSED`. +- Used channels cannot use role `UNUSED`. +- Aggregate IDs are unique safe slugs. +- Aggregate channel lists are unique and in topology. +- `TWO_CT_SUM` requires exactly two enabled channels. +- `ONE_CT_DOUBLE_POWER` requires exactly one enabled channel. +- `BOTH_CONDUCTORS_ONE_CT` requires exactly one enabled channel. +- Parent references exist and contain no cycles. +- Power-quality/status arrays contain one Boolean per board. +- Multi-reference configuration requires operation acknowledgement. +- No field or attribute named `board_revision`. +- No field containing `harmonic` or `peak_current`. + +Example: + +```python +def test_one_ct_double_power_requires_one_channel(topology): + aggregate = CircuitAggregate( + "dryer", "Dryer", CircuitRole.TWO_POLE, (1, 2), + MeasurementMethod.ONE_CT_DOUBLE_POWER, None, + EnergyMode.CONSUMPTION, + ) + with pytest.raises(ValueError, match="one_ct_double_power requires one channel"): + validate_meter_configuration(request_with(aggregate), topology) +``` + +- [ ] **Step 2: Run tests and confirm failure** + +```bash +uv run pytest -q tests/test_meter_configuration.py +``` + +Expected: import failure. + +- [ ] **Step 3: Implement enums/dataclasses** + +Implement the exact Public Data Contracts. Use finite-number validation and control-character rejection. Bounds: +- Friendly/reference/aggregate names: 1–64 characters. +- Nominal voltage: 1–600 V. +- Gain voltage: integer 1–65535. +- Sag percent: 1–99.9. +- Overvoltage percent: 100.1–200. +- Frequency thresholds: 40–70 Hz and low `<` line frequency `<` high. +- Current warning: `None` or 0.1–10,000 A. + +- [ ] **Step 4: Implement topology-wide validation** + +Validation must be pure and deterministic. It must not access Home Assistant or files. + +- [ ] **Step 5: Add default profile builders** + +Use explicit defaults: + +```python +PROFILE_DEFAULTS = { + ElectricalSystem.SPLIT_PHASE_120_240: (60, 120.0, 57.0, 63.0), + ElectricalSystem.SINGLE_PHASE_230: (50, 230.0, 47.0, 53.0), +} +``` + +For `THREE_PHASE` and `CUSTOM`, require the user to explicitly choose line frequency and reference nominal voltages; do not silently choose authoritative values. + +- [ ] **Step 6: Run tests** + +```bash +uv run pytest -q tests/test_meter_configuration.py +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/meter_configuration.py \ + custom_components/circuitsetup_energy_meter_helper/models.py \ + tests/test_meter_configuration.py +git commit -m "feat: model meter and circuit configuration" +``` + +--- + +## Task 8: Add a voltage-transformer catalog + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Create: `custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py` +- Create: `custom_components/circuitsetup_energy_meter_helper/data/voltage_transformers.json` +- Create: `tests/test_voltage_transformer_catalog.py` + +**Interfaces:** +- Produces: + - `VoltageTransformerPreset`. + - `VoltageTransformerCatalog.load()`. + - `by_model_id()`. + - `starting_gain()`. + +- [ ] **Step 1: Create catalog data** + +Schema: + +```json +{ + "schema_version": 1, + "source_repository": "CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter", + "source_ref": "b94637a4f084a3a4a35e3e5f48eb1586bbd972c3", + "presets": [ + { + "model_id": "jameco_reliapro_9vac_120v", + "label": "Jameco Reliapro 120 V to 9 VAC", + "primary_nominal_v": 120.0, + "secondary_nominal_v": 9.0, + "default_gain_voltage": 7305, + "notes": "Official CircuitSetup starting value; calibrate for best accuracy." + } + ] +} +``` + +Do not invent a starting gain for unknown 230 V transformers. `custom` is created in code and requires an explicit gain. + +- [ ] **Step 2: Write failing tests** + +Cover schema version, duplicate IDs, gain bounds, custom validation, and source metadata. + +- [ ] **Step 3: Implement catalog loading** + +Follow the existing `CTPresetCatalog` resource-loading pattern. + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest -q tests/test_voltage_transformer_catalog.py +``` + +- [ ] **Step 5: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py \ + custom_components/circuitsetup_energy_meter_helper/data/voltage_transformers.json \ + tests/test_voltage_transformer_catalog.py +git commit -m "feat: add voltage transformer presets" +``` + +--- + +## Task 9: Expose Device Builder version and configuration capabilities + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/device_builder.py` +- Create/modify: `tests/test_device_builder.py` +- Create: `custom_components/circuitsetup_energy_meter_helper/meter_inventory.py` +- Create: `tests/test_meter_inventory.py` + +**Interfaces:** +- Produces: + - `DeviceBuilderClient.server_version: AwesomeVersion | None`. + - `ATM90E32_STATUS_THRESHOLDS_MIN_VERSION`. + - `MeterConfigurationCapabilities`. + +- [ ] **Step 1: Write failing Device Builder tests** + +Assert the handshake: + +```json +{"server_version": "2026.9.0", "requires_auth": false} +``` + +sets: + +```python +client.server_version == AwesomeVersion("2026.9.0") +``` + +and disconnect does not erase the last observed version until a new connection replaces it. + +- [ ] **Step 2: Store the parsed server version** + +Use Home Assistant’s `AwesomeVersion` dependency already available in the environment. Reject malformed versions with a safe connection error. + +- [ ] **Step 3: Add the release-floor constant** + +After Task 4’s release, add `ATM90E32_STATUS_THRESHOLDS_MIN_VERSION` with the exact numeric release tag as a literal `AwesomeVersion` argument. If the release does not yet exist, keep capabilities false and do not merge editable threshold UI. + +- [ ] **Step 4: Implement capability derivation** + +```python +def meter_configuration_capabilities( + *, + configuration_authoritative: bool, + config_contract: int | None, + device_builder_version: AwesomeVersion | None, +) -> MeterConfigurationCapabilities +``` + +Rules: +- `configuration_authoritative` gates every YAML write. +- `status_thresholds` requires authoritative config, contract 2, and Device Builder version at or above the release floor. +- `managed_totals` requires authoritative config and contract 2. +- `multi_reference` requires authoritative config; contract 2 is preferred but helper-managed blocks may be parsed on older configs. +- Return stable reason codes, not provider text. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_device_builder.py tests/test_meter_inventory.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/device_builder.py \ + custom_components/circuitsetup_energy_meter_helper/meter_inventory.py \ + tests/test_device_builder.py tests/test_meter_inventory.py +git commit -m "feat: detect meter configuration capabilities" +``` + +--- + +## Task 10: Persist safe meter semantics with a storage migration + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/models.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/store.py` +- Modify: `tests/test_store.py` + +**Interfaces:** +- Produces: + - `StoredMeterConfiguration`. + - `HelperStore.async_get_meter_configuration(mac)`. + - `HelperStore.async_save_verified_meter_configuration(mac, configuration)`. + +- [ ] **Step 1: Define stored metadata** + +Store: +- Config SHA-256. +- Meter settings. +- Channel roles/reference assignments/warning thresholds. +- Aggregates. +- Package options. +- Transformer model IDs. + +Do not store: +- Raw YAML. +- Credentials. +- Hardware-preparation acknowledgements. +- Board revision. +- Calibration reference measurements. + +- [ ] **Step 2: Write migration tests** + +Bump: + +```python +STORAGE_MINOR_VERSION = 4 +``` + +Migration from 1.3 must add no fabricated meter configuration. Existing CT selections remain unchanged. + +- [ ] **Step 3: Implement strict serialization/deserialization** + +Reject: +- Unknown enum values. +- Future schema versions. +- Invalid topology channel/group references. +- Config hashes not matching `[0-9a-f]{64}`. +- Any unexpected nested keys. + +- [ ] **Step 4: Bind stored semantics to source hash** + +`async_get_meter_configuration()` returns `None` when its stored `config_sha256` differs from the current configuration hash. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_store.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/models.py \ + custom_components/circuitsetup_energy_meter_helper/store.py \ + tests/test_store.py +git commit -m "feat: persist verified meter configuration metadata" +``` + +--- + +## Task 11: Extend the bounded configuration parser + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/config_document.py` +- Modify: `tests/test_config_document.py` +- Add fixtures under: `tests/fixtures/` + +**Interfaces:** +- Produces parsed spans for: + - `friendly_name`. + - `update_time`. + - `electric_freq`. + - `csemh_config_contract`. + - Existing CT/group/gain substitutions. + - Helper-managed block boundaries. + +- [ ] **Step 1: Write failing parser tests** + +Fixtures must include: +- Quoted/unquoted allowed values. +- CRLF. +- Comments. +- Missing optional contract. +- Duplicate scalar rejection. +- Unsafe tags/aliases rejection. +- A managed block with exact start/end markers. +- A malicious marker nested inside a scalar/comment that must not count. + +- [ ] **Step 2: Add scalar regexes** + +```python +METER_SETTING_RE = re.compile( + r"^(?:friendly_name|update_time|electric_freq|csemh_config_contract)$" +) +``` + +Parse `friendly_name` from substitutions first because official files place it there. Do not silently fall back to unrelated `esphome.name`. + +- [ ] **Step 3: Add managed-block parsing** + +Recognize only exact top-level marker comments: + +```text +# CircuitSetup Energy Meter Helper: voltage references v1 +# End CircuitSetup Energy Meter Helper: voltage references v1 +# CircuitSetup Energy Meter Helper: phase overrides v1 +# End CircuitSetup Energy Meter Helper: phase overrides v1 +# CircuitSetup Energy Meter Helper: aggregates v1 +# End CircuitSetup Energy Meter Helper: aggregates v1 +``` + +Reject duplicate, nested, overlapping, or unterminated blocks. + +- [ ] **Step 4: Keep arbitrary YAML out of the model** + +The parser returns exact spans and normalized bounded values; it does not create a general YAML object tree. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_config_document.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/config_document.py \ + tests/test_config_document.py tests/fixtures +git commit -m "feat: parse bounded meter configuration fields" +``` + +--- + +## Task 12: Separate board topology from voltage-reference topology + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/topology.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/models.py` +- Modify: `tests/test_topology.py` +- Modify calibration/topology fixtures as required. + +**Interfaces:** +- Produces: + - Existing `MeterTopology` remains authoritative for board/group/channel count. + - `VoltageReferenceTopology` is derived from helper blocks or legacy project metadata. + - `group_key(board_index, group_index)` remains stable. + +- [ ] **Step 1: Write failing topology tests** + +Cover: +- Standard legacy project maps all groups to one inferred reference. +- Existing `-2-voltages` project is recognized as legacy multi-reference evidence. +- Helper-managed voltage block overrides legacy inference only when structurally valid. +- Every group must be covered exactly once. +- Unknown project suffix still fails closed for board-count inference. +- Three-phase/custom profiles do not alter board count. +- No board-revision data is accepted. + +- [ ] **Step 2: Stop using project suffix as the final voltage authority** + +Project metadata remains corroborating evidence. The helper-managed block becomes authoritative after a verified transaction. + +- [ ] **Step 3: Update calibration verification identity** + +Replace a bare string comparison of `topology_voltage_layout` with a deterministic voltage-topology fingerprint derived from ordered reference IDs and group assignments. + +- [ ] **Step 4: Maintain legacy compatibility** + +Existing verified records using `standard` or `two_voltages` remain readable and are normalized into the new fingerprint during storage migration/reverification. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_topology.py tests/test_store.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/topology.py \ + custom_components/circuitsetup_energy_meter_helper/models.py \ + tests/test_topology.py tests/test_store.py +git commit -m "refactor: separate meter and voltage topology" +``` + +--- + +## Task 13: Build a complete meter configuration inventory + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/meter_inventory.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/workflow.py` +- Create/modify: `tests/test_meter_inventory.py` +- Modify: `tests/test_workflow.py` + +**Interfaces:** +- Produces `Workflow.async_get_meter_configuration(device_id)` and a server-owned plan handle containing: + - Snapshot. + - Topology. + - Parsed configuration. + - CT and voltage catalogs. + - Capabilities. + - Warnings. + +- [ ] **Step 1: Write failing inventory tests** + +Legacy official config defaults: +- All CTs enabled. +- Existing names/gains preserved. +- Roles default to `CUSTOM`, not guessed from names. +- One inferred voltage reference. +- Electrical system is `CUSTOM` with `needs_electrical_confirmation`. +- Existing package options are detected. +- No aggregate is fabricated from generic totals. + +Stored verified semantics with matching hash: +- Restore roles, reference mapping, thresholds, and aggregates exactly. + +- [ ] **Step 2: Implement `MeterConfigurationInventory.from_document()`** + +Merge: +1. Authoritative YAML values. +2. Stored semantic metadata only when hash matches. +3. Explicit defaults for fields not inferable. + +- [ ] **Step 3: Create generalized plan handles** + +Replace `_PlanHandle.inventory: CTInventory` with: + +```python +inventory: MeterConfigurationInventory +``` + +Keep `async_get_ct_inventory()` as a wrapper returning the CT subset. + +- [ ] **Step 4: Return capability warnings** + +Examples: +- `electrical_profile_requires_confirmation` +- `legacy_generic_totals_unmanaged` +- `status_thresholds_require_newer_esphome` +- `stored_semantics_stale` + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_meter_inventory.py tests/test_workflow.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/meter_inventory.py \ + custom_components/circuitsetup_energy_meter_helper/workflow.py \ + tests/test_meter_inventory.py tests/test_workflow.py +git commit -m "feat: inventory complete meter configuration" +``` + +--- + +## Task 14: Create deterministic helper-managed block rendering + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Create: `custom_components/circuitsetup_energy_meter_helper/config_blocks.py` +- Create: `tests/test_meter_config_mutator.py` +- Create: `custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py` + +**Interfaces:** +- Produces: + - `replace_managed_block(content, block_name, rendered) -> str`. + - Deterministic renderers for voltage references, phase overrides, and aggregates. + +- [ ] **Step 1: Write failing block tests** + +Cover: +- Insert when absent. +- Replace exactly one existing block. +- Remove an empty block. +- Preserve CRLF. +- Preserve unrelated comments and YAML byte-for-byte. +- Reject duplicate/overlapping markers. +- Deterministic output regardless of input mapping order. + +- [ ] **Step 2: Implement exact marker ownership** + +Only the three marker pairs defined in Task 11 may be edited. + +- [ ] **Step 3: Insert blocks at deterministic locations** + +- Voltage references and phase overrides: at the end of the top-level `sensor:` section. +- Aggregates: after phase overrides within `sensor:`. +- Text-status internal overrides: within a dedicated top-level `text_sensor:` managed block if needed. + +If a safe insertion point cannot be identified, raise `ConfigMutationError` with a manual snippet; never serialize the document. + +- [ ] **Step 4: Add a generalized mutation entry point** + +Implement the exact `build_meter_configuration_mutation()` signature. + +- [ ] **Step 5: Keep the old CT API as a wrapper** + +`build_ct_mutation()` constructs a `MeterConfigurationRequest` from the current inventory plus CT/package changes and delegates to the generalized builder. + +- [ ] **Step 6: Run tests** + +```bash +uv run pytest -q tests/test_meter_config_mutator.py tests/test_config_mutator.py +``` + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/config_blocks.py \ + custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py \ + custom_components/circuitsetup_energy_meter_helper/config_mutator.py \ + tests/test_meter_config_mutator.py tests/test_config_mutator.py +git commit -m "refactor: render managed meter configuration blocks" +``` + +--- + +## Task 15: Extend register-range scaling to supported power-quality values + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/config_mutator.py` +- Modify: `tests/test_meter_config_mutator.py` +- Modify: firmware contract fixtures/tests. + +**Interfaces:** +- Consumes: `ChannelSettings.reporting_multiplier` and per-board power-quality selection. +- Produces managed phase overrides for: + - Current. + - Active power. + - Reactive power. + - Apparent power. + - Removal of harmonic power and peak current. + +- [ ] **Step 1: Write failing scaling tests** + +For a channel with multiplier 4 and power quality enabled, assert exact YAML: + +```yaml +phase_a: + current: + filters: + - multiply: 4 + power: + filters: + - multiply: 4 + reactive_power: + filters: + - multiply: 4 + apparent_power: + filters: + - multiply: 4 + harmonic_power: !remove + peak_current: !remove +``` + +Also assert: +- `power_factor` absent from filters. +- `phase_angle` absent from filters. +- Multiplier 1 removes scaling filters. +- Harmonic/peak removal occurs only when the helper-managed PQ package is active for that board. +- Unused channels remove all PQ outputs. +- External conflicting filters fail closed. + +- [ ] **Step 2: Replace the old multiplier-only block** + +Retire `_MULTIPLIER_START/_END` after adding a migration reader. Render all phase overrides under the v1 phase-override block. + +- [ ] **Step 3: Migrate an existing helper multiplier block** + +Parse the existing marker block, preserve its current/power multipliers, and rewrite it into the new phase-override block during the next explicit preview. The diff must show the migration. + +- [ ] **Step 4: Extend conflict detection** + +Reject pre-existing filters on: +- `current` +- `power` +- `reactive_power` +- `apparent_power` + +Do not inspect or manage harmonic/peak filters because those entities are removed by the helper block. + +- [ ] **Step 5: Validate with ESPHome** + +Compile representative configurations: +- PQ disabled, multiplier 4. +- PQ enabled, multiplier 4. +- PQ enabled, multiplier 1. +- Mixed multipliers across one ATM90E32. +- Main plus one add-on. + +- [ ] **Step 6: Run tests** + +```bash +uv run pytest -q tests/test_meter_config_mutator.py tests/test_config_mutator.py \ + tests/test_firmware_contract.py +``` + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py \ + custom_components/circuitsetup_energy_meter_helper/config_mutator.py \ + tests +git commit -m "fix: scale supported power quality measurements" +``` + +--- + +## Task 16: Render electrical settings, voltage references, and thresholds + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/config_document.py` +- Modify: `tests/test_meter_config_mutator.py` + +**Interfaces:** +- Consumes: `MeterSettings`, `VoltageReferenceConfig`, capabilities. +- Produces: + - `friendly_name`, `update_time`, and `electric_freq` substitutions. + - Per-group gain/reference/threshold overrides. + - Visible voltage/frequency entities for each configured reference. + +- [ ] **Step 1: Write failing mutation tests** + +Assert: +- `update_interval_s=5` writes `update_time: 5s`. +- 50 Hz writes `electric_freq: "50Hz"` while preserving quoting style when possible. +- Friendly name is safely quoted when required. +- Every group has one `gain_voltage`. +- One representative voltage and frequency sensor is exposed per reference. +- Non-representative calibration voltage sensors remain diagnostic/disabled. +- Multi-reference request without acknowledgement fails. +- Status thresholds fail with `capability_unavailable` when capability is false. +- No board-revision key is generated. + +- [ ] **Step 2: Compute absolute voltage thresholds** + +```python +sag_v = nominal_voltage_v * sag_percent / 100.0 +over_v = nominal_voltage_v * overvoltage_percent / 100.0 +``` + +Validate: + +```text +0 < sag_v < nominal_voltage_v < over_v <= 600 +frequency_low_hz < line_frequency_hz < frequency_high_hz +``` + +- [ ] **Step 3: Render component-level thresholds** + +For each ATM90E32 group: + +```yaml +- id: !extend ${main_meter_id1} + voltage_sag_threshold: 93.6 + over_voltage_threshold: 146.4 + frequency_low_threshold: 57.0 + frequency_high_threshold: 63.0 +``` + +Use the reference assigned to that group. + +- [ ] **Step 4: Render per-phase current warning thresholds** + +For each used channel with a warning: + +```yaml +phase_a: + over_current_threshold: 100 +``` + +Omit the field when `None`; do not synthesize a breaker rating. + +- [ ] **Step 5: Render reference sensor exposure** + +Choose the lowest ordered group key assigned to the reference as its representative. Expose exactly one voltage entity and one frequency entity for that reference, with deterministic names and existing stable IDs where possible. + +- [ ] **Step 6: Run tests and ESPHome validation** + +```bash +uv run pytest -q tests/test_meter_config_mutator.py +``` + +Then validate one 60 Hz split-phase, one 50 Hz single-phase, and one three-reference configuration using the released ESPHome version. + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py \ + custom_components/circuitsetup_energy_meter_helper/config_document.py \ + tests/test_meter_config_mutator.py +git commit -m "feat: configure electrical and status settings" +``` + +--- + +## Task 17: Render used/unused channels and two-pole measurement semantics + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/entity_binding.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/preflight.py` +- Modify: `tests/test_meter_config_mutator.py` +- Modify: `tests/test_entity_binding.py` +- Modify: `tests/test_preflight.py` + +**Interfaces:** +- Consumes: `ChannelSettings`. +- Produces: + - Unused-channel visibility overrides. + - Calibration-compatible bindings despite user-hidden runtime entities. + - Separate circuit power multiplication in aggregate rendering, never in CT gain. + +- [ ] **Step 1: Write failing unused-channel tests** + +For unused CT3: +- Current and power remain present for native calibration binding but are `internal: true`. +- PQ entities are removed. +- Status phase text is `internal: true` or removed through a tested supported ESPHome override. +- CT gain and calibration references remain intact. +- The channel is excluded from aggregates and entity-impact counts. + +- [ ] **Step 2: Keep native calibration contracts intact** + +Do not `!remove` current, power, reference current, voltage calibration sensor, or calibration buttons needed by `bind_meter()`/preflight. + +- [ ] **Step 3: Implement semantic power methods only in aggregates** + +- `DIRECT`: sum selected powers once. +- `TWO_CT_SUM`: sum two selected powers once. +- `ONE_CT_DOUBLE_POWER`: multiply aggregate power-like values by 2; do not double current. +- `BOTH_CONDUCTORS_ONE_CT`: use one channel with no semantic multiplier. + +Do not alter `reporting_multiplier` for these methods. + +- [ ] **Step 4: Add validation that unused channels cannot participate** + +Reject any aggregate containing an unused channel. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_meter_config_mutator.py \ + tests/test_entity_binding.py tests/test_preflight.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py \ + custom_components/circuitsetup_energy_meter_helper/entity_binding.py \ + custom_components/circuitsetup_energy_meter_helper/preflight.py \ + tests +git commit -m "feat: configure channel usage and circuit methods" +``` + +--- + +## Task 18: Generate accurate aggregates and energy entities + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/config_blocks.py` +- Modify: `tests/test_meter_config_mutator.py` +- Modify: `tests/test_firmware_contract.py` + +**Interfaces:** +- Consumes: `CircuitAggregate`. +- Produces deterministic ESPHome template and `total_daily_energy` entities. + +- [ ] **Step 1: Write failing aggregate-rendering tests** + +For a bidirectional grid aggregate using CT1/CT2, assert deterministic IDs: + +```text +csemh_grid_power +csemh_grid_import_power +csemh_grid_export_power +csemh_grid_import_energy +csemh_grid_export_energy +``` + +For consumption: +- Clamp negative power to zero before integration. + +For generation: +- Use the configured expected sign and normalize to positive production power. + +For one-CT doubled power: +- Apply `* 2.0` to power and energy source. +- Do not double aggregate current. + +- [ ] **Step 2: Implement safe lambda generation** + +Generate lambdas only from validated numeric channel IDs and fixed operators. Never interpolate arbitrary user text into C++. + +Example internal representation: + +```python +terms = tuple(f"id(ct{channel}Watts).state" for channel in channels) +expression = " + ".join(terms) +``` + +Names are emitted only as YAML scalars through the existing safe renderer. + +- [ ] **Step 3: Avoid implicit all-channel totals** + +Generate no aggregate unless present in `requested.aggregates`. + +- [ ] **Step 4: Hide official generic totals** + +For contract-2 configurations, set stable official generic total entities `internal: true` before exposing helper totals. + +For older configurations: +- Set capability `managed_totals=False`. +- Return `legacy_generic_totals_unmanaged`. +- Reject aggregate preview rather than creating duplicate authoritative totals. + +- [ ] **Step 5: Validate parent hierarchy** + +Parents are metadata for display and double-count warnings; aggregate formulas use only their explicit channel list. Reject cycles. + +- [ ] **Step 6: Compile representative aggregate configurations** + +Compile: +- Grid consumption. +- Bidirectional grid. +- Solar generation. +- Subpanel informational group. +- Two-CT appliance. +- One-CT doubled appliance. +- Main plus six add-ons with sparse aggregates. + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py \ + custom_components/circuitsetup_energy_meter_helper/config_blocks.py \ + tests +git commit -m "feat: generate managed circuit totals and energy" +``` + +--- + +## Task 19: Generalize configuration transactions and reconnect verification + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/config_transaction.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/workflow.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/store.py` +- Modify: `tests/test_config_transaction.py` +- Modify: `tests/test_workflow.py` + +**Interfaces:** +- Produces: + - `async_preview_meter_configuration()`. + - Verified persistence of full meter semantics. + - Backward-compatible CT wrappers. + +- [ ] **Step 1: Write failing transaction tests** + +Verify: +- Preview stores full request only in memory. +- Write/validate/compile/install flow is unchanged. +- Reconnect verification checks expected CT names, reference voltage/frequency entity IDs, and aggregate entity IDs. +- Verified meter metadata persists only after reconnect success. +- Rollback never persists requested metadata. +- Transaction scrubbing removes full request and YAML. +- Calibration handoff can include meter configuration changes atomically. + +- [ ] **Step 2: Generalize transaction private state** + +Replace `selections` with: + +```python +meter_configuration: StoredMeterConfiguration | None +expected_entity_ids: frozenset[str] +``` + +Keep CT selections available through the stored meter configuration. + +- [ ] **Step 3: Extend reconnect evidence** + +```python +@dataclass(frozen=True, slots=True) +class ReconnectEvidence: + mac: str + topology: MeterTopology + ct_names: Mapping[int, str] + current_sensor_count: int + object_ids: frozenset[str] +``` + +Verify only deterministic expected IDs; do not require optional disabled entities to be enabled in Home Assistant. + +- [ ] **Step 4: Preserve old commands** + +`async_preview_ct_config()` delegates to `async_preview_meter_configuration()` using the current non-CT settings. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_config_transaction.py tests/test_workflow.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/config_transaction.py \ + custom_components/circuitsetup_energy_meter_helper/workflow.py \ + custom_components/circuitsetup_energy_meter_helper/store.py \ + tests/test_config_transaction.py tests/test_workflow.py +git commit -m "refactor: transact complete meter configuration" +``` + +--- + +## Task 20: Add strict generalized WebSocket commands + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/websocket_api.py` +- Modify: `tests/test_websocket_api.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/diagnostics.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/repairs.py` + +**Interfaces:** +- Produces commands: + - `get_meter_configuration` + - `preview_meter_configuration` + - Existing apply/compile/install/rollback transaction commands remain usable. + +- [ ] **Step 1: Add failing schema tests** + +Test: +- All enums. +- Numeric bounds. +- Maximum 42 channels, 32 aggregates, 8 voltage references. +- Unique IDs enforced in workflow validation. +- Payload remains under 64 KiB. +- `board_revision` rejected as extra. +- `harmonic_power` and `peak_current` rejected as extra. +- Non-admin mutation denied. +- Foreign/stale plan IDs denied. +- Forbidden credential/YAML keys still rejected. + +- [ ] **Step 2: Add exact nested Voluptuous schemas** + +Use `extra=vol.PREVENT_EXTRA` for every nested mapping. + +- [ ] **Step 3: Generalize safe transaction-change serialization** + +Replace `_PACKAGE_CHANGE_RE` with an allowlist of server-generated change paths: + +```python +_ALLOWED_CHANGE_PATH = re.compile( + r"(?:meter|voltage_reference|channel|aggregate|package)\.[a-z0-9_.-]+" +) +``` + +Never allow browser-supplied change records. + +- [ ] **Step 4: Add diagnostics signals** + +Stable codes: +- `meter_configuration_invalid` +- `status_thresholds_unavailable` +- `legacy_totals_unmanaged` +- `voltage_reference_mismatch` +- `aggregate_entity_mismatch` + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_websocket_api.py \ + tests/test_diagnostics.py tests/test_repairs.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/websocket_api.py \ + custom_components/circuitsetup_energy_meter_helper/diagnostics.py \ + custom_components/circuitsetup_energy_meter_helper/repairs.py \ + tests +git commit -m "feat: expose safe meter configuration api" +``` + +--- + +## Task 21: Carry electrical intent through new-device onboarding + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/models.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/provisioning.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/websocket_api.py` +- Modify: `frontend/src/firmware-installer.ts` +- Modify: `frontend/src/components/setup-device-step.ts` +- Modify: `frontend/src/panel.ts` +- Modify corresponding Python/frontend tests. + +**Interfaces:** +- Extends `InstallerIntent` with: + - `electrical_system`. + - `line_frequency_hz`. +- Does not change firmware product resolution by electrical profile. + +- [ ] **Step 1: Write failing intent tests** + +Assert: +- Split-phase defaults to a suggested 60 Hz but the user must explicitly select/confirm it. +- Single-phase profile defaults to a suggested 50 Hz but remains editable. +- Three-phase/custom requires explicit line-frequency selection. +- Intent contains no board revision. +- Existing stored installer intent without new fields receives non-authoritative UI defaults. + +- [ ] **Step 2: Extend models/schema** + +Add exact enum values and strict frequency validation. + +- [ ] **Step 3: Keep firmware resolver topology-only** + +`resolveMeterProductIds(addonCount, connectionType)` remains unchanged. Do not create profile/frequency firmware permutations. + +- [ ] **Step 4: Seed post-adoption Meter Settings** + +After automatic adoption, populate the Meter Settings draft from installer intent, then load the authoritative imported configuration before allowing preview. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_provisioning.py tests/test_websocket_api.py +npm --prefix frontend test -- firmware-installer.test.ts panel.test.ts +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper \ + frontend/src frontend/test tests +git commit -m "feat: carry electrical profile through onboarding" +``` + +--- + +## Task 22: Make voltage calibration reference-aware + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/calibration_engine.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/workflow.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/entity_binding.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/preflight.py` +- Modify: `tests/test_calibration_engine_voltage.py` +- Modify: `tests/test_entity_binding.py` +- Modify: `tests/test_preflight.py` +- Modify: `tests/test_workflow.py` + +**Interfaces:** +- Produces: + - Voltage stability/calibration targets keyed by `reference_id`. + - Arbitrary non-empty group sets per reference. + - Verified per-group voltage gains rendered by the generalized mutator. + +- [ ] **Step 1: Write failing calibration tests** + +Cover: +- One reference assigned to all groups. +- Two references assigned across groups. +- Three references. +- Reference with one group. +- Reference with multiple groups across boards. +- Missing/duplicate group assignment rejected before hardware operations. +- Calibration verification preserves per-group gains rather than collapsing only to `voltage_cal1/2`. + +- [ ] **Step 2: Change stability request** + +Replace the fixed two-group voltage target with: + +```python +async_check_stability( + session_id: str, + target: Literal["voltage"], + target_id: str, # reference_id +) -> tuple[StabilityResult, ...] +``` + +Resolve group keys server-side from the session’s immutable configuration. + +- [ ] **Step 3: Change voltage calibration request** + +```python +async_calibrate_voltage( + session_id: str, + reference_id: str, + reference_voltage: float, + confirm_iteration: bool, +) -> tuple[CalibrationResult, ...] +``` + +Set the same trusted reference voltage on every group assigned to that reference, run each group’s gain button, and verify each result. + +- [ ] **Step 4: Preserve per-group voltage gains** + +The final configuration renderer emits exact `gain_voltage` overrides for every group/phase represented by verified evidence. Do not reject valid differences merely because groups share an old `voltage_cal1` or `voltage_cal2` substitution. + +- [ ] **Step 5: Keep offset workflow board-scoped** + +Offset stages remain per physical board because their preparation and two-chip execution are board-oriented. Do not couple them to the voltage-reference UI. + +- [ ] **Step 6: Run tests** + +```bash +uv run pytest -q tests/test_calibration_engine_voltage.py \ + tests/test_entity_binding.py tests/test_preflight.py tests/test_workflow.py +``` + +- [ ] **Step 7: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper \ + tests/test_calibration_engine_voltage.py \ + tests/test_entity_binding.py tests/test_preflight.py tests/test_workflow.py +git commit -m "feat: calibrate by voltage reference" +``` + +--- + +## Task 23: Make calibration timing honor the reporting interval + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `custom_components/circuitsetup_energy_meter_helper/calibration_engine.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/offset_readiness.py` +- Modify: `custom_components/circuitsetup_energy_meter_helper/workflow.py` +- Modify calibration tests. + +**Interfaces:** +- Produces: + - `CalibrationTimingPolicy`. + - Interval-aware sample/evidence deadlines. + +- [ ] **Step 1: Add a pure timing policy** + +```python +@dataclass(frozen=True, slots=True) +class CalibrationTimingPolicy: + update_interval_s: int + sample_count: int + + @property + def sensor_window_timeout_s(self) -> float: + return max(35.0, self.update_interval_s * (self.sample_count + 1) + 5.0) + + @property + def evidence_timeout_s(self) -> float: + return max(35.0, self.update_interval_s * 2.0 + 15.0) +``` + +- [ ] **Step 2: Write tests for every supported interval** + +Expected sensor-window minimums: +- 1/2/5/10 seconds retain at least 35 seconds. +- 30/60 seconds receive longer deadlines. +- No unbounded timeout. +- Session cancellation still interrupts promptly. + +- [ ] **Step 3: Pass parsed current interval into the session** + +Calibration uses the currently installed interval, not an uninstalled draft. + +- [ ] **Step 4: Add UI warning metadata** + +Inventory warning: +- `slow_interval_extends_calibration` for 30/60 seconds. + +Do not force a temporary flash in this release. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_calibration_engine_voltage.py \ + tests/test_calibration_engine_current.py \ + tests/test_calibration_engine_offset.py \ + tests/test_offset_readiness.py tests/test_workflow.py +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper \ + tests +git commit -m "fix: account for meter interval during calibration" +``` + +--- + +## Task 24: Add frontend types and API methods + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `frontend/src/types.ts` +- Modify: `frontend/src/api.ts` +- Modify: `frontend/test/api.test.ts` + +**Interfaces:** +- Produces TypeScript equivalents of all public meter-configuration types. +- Produces: + - `HelperApi.getMeterConfiguration(deviceId)`. + - `HelperApi.previewMeterConfiguration(deviceId, planId, sourceSha256, configuration)`. + +- [ ] **Step 1: Add compile-failing type tests/API tests** + +Validate exact WebSocket payloads and response decoding. + +- [ ] **Step 2: Add discriminated unions** + +Use literal unions matching backend enum values. Do not add `board_revision`, `harmonic_power`, or `peak_current`. + +- [ ] **Step 3: Keep old CT methods** + +Existing `getCtInventory` and `previewCtConfig` remain until all callers migrate. + +- [ ] **Step 4: Run tests/typecheck** + +```bash +npm --prefix frontend test -- api.test.ts +npm --prefix frontend run typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/types.ts frontend/src/api.ts frontend/test/api.test.ts +git commit -m "feat(frontend): add meter configuration api types" +``` + +--- + +## Task 25: Add the Meter Settings step + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Create: `frontend/src/components/meter-settings-step.ts` +- Modify: `frontend/src/panel.ts` +- Modify: `frontend/src/types.ts` +- Modify: `frontend/src/styles.ts` +- Create: `frontend/test/meter-settings.test.ts` +- Modify: `frontend/test/accessibility.test.ts` +- Modify: `frontend/test/panel.test.ts` + +**Interfaces:** +- Consumes: Meter inventory, capabilities, voltage-transformer catalog. +- Produces: A valid `MeterSettings` draft and voltage-reference list. + +- [ ] **Step 1: Write failing component tests** + +Required controls: +- Friendly name. +- Electrical system. +- Line frequency. +- Reporting interval. +- Voltage-reference cards. +- Transformer selection/custom gain. +- Nominal voltage. +- Phase label. +- Group assignment. +- Sag/overvoltage percentages. +- Frequency low/high. +- Generic multi-reference preparation acknowledgement. + +Assert there is no board-revision control. + +- [ ] **Step 2: Implement profile defaults as suggestions** + +When profile changes, populate suggested values only for untouched fields. Never overwrite explicit user edits. + +- [ ] **Step 3: Implement group assignment** + +Every ATM group appears once across reference cards. Moving a group removes it from its prior reference atomically. + +- [ ] **Step 4: Capability-gate thresholds** + +When `status_thresholds=false`: +- Show current values read-only if present. +- Display the stable reason. +- Do not include modified threshold values in the preview request. + +- [ ] **Step 5: Add impact copy for interval** + +Display: +- “1–5 seconds: high traffic.” +- “10 seconds: standard.” +- “30–60 seconds: lower traffic; guided calibration takes longer.” + +- [ ] **Step 6: Add step navigation** + +Flow: + +```text +Setup Device → Meter Settings → Circuits & CTs → Safety → … +``` + +- [ ] **Step 7: Run frontend tests** + +```bash +npm --prefix frontend test -- meter-settings.test.ts panel.test.ts accessibility.test.ts +npm --prefix frontend run typecheck +``` + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src frontend/test +git commit -m "feat(frontend): add meter settings step" +``` + +--- + +## Task 26: Expand CT Settings into Circuits & CTs + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `frontend/src/components/ct-inventory-step.ts` +- Create: `frontend/src/components/circuit-aggregates.ts` +- Modify: `frontend/src/panel.ts` +- Modify: `frontend/src/styles.ts` +- Create: `frontend/test/circuit-aggregates.test.ts` +- Modify: `frontend/test/panel.test.ts` +- Modify: `frontend/test/accessibility.test.ts` + +**Interfaces:** +- Produces valid `ChannelSettings` and `CircuitAggregate` drafts. + +- [ ] **Step 1: Rename visible step** + +Change “CT Settings” to “Circuits & CTs” in navigation, heading, announcements, and tests. Internal `PanelStep` may remain `"ct"` to minimize migration. + +- [ ] **Step 2: Add channel usage/role/reference controls** + +Keep the compact row focused on: +- Used. +- Name. +- CT model. +- Role. +- Voltage reference. +- Status. + +Move: +- Reporting multiplier. +- Custom gain. +- Burden acknowledgement. +- Current warning threshold. +- Two-pole details. + +into the existing expandable row details. + +- [ ] **Step 3: Implement unused behavior** + +Selecting unused: +- Sets role to `unused`. +- Removes the channel from aggregate drafts after explicit confirmation in the same interaction. +- Disables CT model edits only if the current model/gain remains available for calibration. +- Clearly states calibration entities remain internal. + +- [ ] **Step 4: Implement aggregate editor** + +Fields: +- Name. +- Role. +- Channels. +- Measurement method. +- Parent. +- Energy mode. +- Expose current. + +Provide preset actions: +- Create grid pair from two selected channels. +- Create solar/generator pair. +- Create two-pole appliance. +- Create subpanel feeder. + +- [ ] **Step 5: Add double-count prevention** + +Do not auto-create an all-channel total. Warn when: +- A root grid aggregate includes branch channels in addition to mains. +- A one-leg-doubled circuit uses two channels. +- A channel is assigned to incompatible two-pole aggregates. + +- [ ] **Step 6: Add status threshold field** + +Per used channel: +- `current_warning_a`. +- Label it “Circuit warning current,” not register limit. +- Explain “Measurement Range Exceeded” is separate. + +- [ ] **Step 7: Run tests** + +```bash +npm --prefix frontend test -- circuit-aggregates.test.ts panel.test.ts accessibility.test.ts +npm --prefix frontend run typecheck +``` + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src frontend/test +git commit -m "feat(frontend): configure circuits and aggregates" +``` + +--- + +## Task 27: Add entity/publication impact estimation + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Create: `custom_components/circuitsetup_energy_meter_helper/entity_estimator.py` +- Create: `tests/test_entity_estimator.py` +- Create: `frontend/src/components/configuration-impact.ts` +- Create: `frontend/test/configuration-impact.test.ts` +- Modify backend inventory/WebSocket and frontend types/panel. + +**Interfaces:** +- Produces `ConfigurationImpact`. + +- [ ] **Step 1: Define exact counting rules** + +Count helper-managed public entities: +- Two base numeric entities per used channel: current and active power. +- Four PQ numeric entities per used channel on PQ-enabled boards: reactive power, apparent power, power factor, phase angle. +- Zero harmonic-power entities. +- Zero peak-current entities. +- One phase-status text entity per used channel on status-enabled boards. +- One voltage and one frequency entity per voltage reference. +- Aggregate power/current/clamp/energy entities according to energy mode. +- Exclude internal calibration entities from the public count. + +- [ ] **Step 2: Write estimator tests** + +Include: +- 6-channel default. +- 42-channel all-PQ configuration. +- Mixed unused channels. +- Bidirectional grid aggregate. +- One-CT doubled appliance. + +- [ ] **Step 3: Compute publication estimate** + +```python +approximate_publications_per_second = ( + numeric_measurement_entities + text_status_entities +) / update_interval_s +``` + +Label it approximate because text statuses may publish only when evaluated/changed and energy sensors can have component-specific behavior. + +- [ ] **Step 4: Render impact summary** + +Show: +- Enabled channels. +- Approximate public entity count. +- Energy entities. +- Approximate publications per second. +- A warning threshold based on a documented constant, not arbitrary color-only UI. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest -q tests/test_entity_estimator.py +npm --prefix frontend test -- configuration-impact.test.ts +``` + +- [ ] **Step 6: Commit** + +```bash +git add custom_components/circuitsetup_energy_meter_helper/entity_estimator.py \ + tests/test_entity_estimator.py frontend/src frontend/test +git commit -m "feat: estimate configuration entity impact" +``` + +--- + +## Task 28: Update review, build, restart, and summary flows + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `frontend/src/components/config-review-step.ts` +- Modify: `frontend/src/components/build-install-step.ts` +- Modify: `frontend/src/components/restart-step.ts` +- Modify: `frontend/src/components/summary-step.ts` +- Modify: `frontend/src/panel.ts` +- Modify corresponding tests. + +**Interfaces:** +- Produces a complete review and final summary for meter configuration. + +- [ ] **Step 1: Review physical assumptions** + +Show: +- Electrical profile/frequency. +- Voltage references and group mapping. +- Multi-reference hardware-preparation acknowledgement when applicable. +- CT burden acknowledgements. +- Two-pole measurement methods. + +Do not show board revisions. + +- [ ] **Step 2: Review semantic changes** + +Show: +- Channel role/reference changes. +- Aggregate formulas in readable text. +- Energy modes. +- PQ/status boards. +- Threshold values. +- Reporting interval. +- Entity impact. + +- [ ] **Step 3: Review exact YAML diff** + +Continue using the redacted line-oriented diff. Group changes by: +- Meter. +- Voltage reference. +- Channel. +- Aggregate. +- Package. + +- [ ] **Step 4: Preserve current transactional flow** + +Explicit preview → admin write → validate → compile → install confirmation → upload → reconnect verification → metadata persistence. + +- [ ] **Step 5: Update summary** + +Report: +- Configuration authority. +- Calibration authority. +- Installed electrical profile. +- Voltage-reference count. +- Used channel count. +- Aggregate/energy count. +- PQ/status scope. +- Threshold capability/status. + +- [ ] **Step 6: Run tests** + +```bash +npm --prefix frontend test -- panel.test.ts accessibility.test.ts +npm --prefix frontend run test:e2e +``` + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src frontend/test +git commit -m "feat(frontend): review complete meter configuration" +``` + +--- + +## Task 29: Add end-to-end and regression scenarios + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify/create under: `frontend/test/e2e/` +- Modify: `frontend/test/harness.ts` +- Modify: Python workflow fixtures. +- Modify: `tests/test_firmware_contract.py` +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/validate.yml` + +**Interfaces:** +- Produces release-blocking regression coverage. + +- [ ] **Step 1: Add E2E scenario — standard split-phase** + +- New Wi-Fi main-board meter. +- Split-phase, 60 Hz, 10 seconds. +- One voltage reference. +- Two mains CTs. +- Bidirectional grid aggregate. +- No PQ. +- Main-board status enabled. +- Preview and complete through summary. + +- [ ] **Step 2: Add E2E scenario — 50 Hz single-phase with scaling** + +- Existing one-add-on meter. +- 230 V, 50 Hz. +- Multiplier 4 on one channel. +- PQ enabled on that board. +- Assert preview scales current/active/reactive/apparent and removes harmonic/peak. +- Consumption energy aggregate. + +- [ ] **Step 3: Add E2E scenario — three references** + +- Three-phase profile. +- Explicit line frequency and three references. +- Generic hardware-preparation acknowledgement. +- Groups assigned exactly once. +- Voltage calibration iterates reference-by-reference. + +- [ ] **Step 4: Add E2E scenario — unused channels and two-pole appliance** + +- Unused channels hidden. +- Two-CT appliance sum. +- One-CT doubled appliance. +- No double counting in grid aggregate. + +- [ ] **Step 5: Add E2E scenario — unsupported threshold capability** + +- Device Builder version below the release floor. +- Threshold controls read-only/disabled. +- Other configuration remains editable. +- Preview excludes threshold changes. + +- [ ] **Step 6: Add recovery regressions** + +- Source hash changes before preview. +- Validation failure after write rolls back. +- Reconnect missing one aggregate entity rolls back/fails safely. +- Cancel during slow-interval calibration releases locks. +- Legacy generic totals block aggregate creation with a clear upgrade message. + +- [ ] **Step 7: Run full frontend checks** + +```bash +npm --prefix frontend audit +npm --prefix frontend run typecheck +npm --prefix frontend test +npm --prefix frontend run build +npm --prefix frontend run test:e2e +``` + +- [ ] **Step 8: Commit** + +```bash +git add frontend/test tests .github/workflows +git commit -m "test: cover priority zero meter configuration" +``` + +--- + +## Task 30: Run the full backend, firmware, and Home Assistant verification matrix + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify CI/contract scripts only if failures expose missing deterministic coverage. + +**Interfaces:** +- Produces final evidence for release. + +- [ ] **Step 1: Run Python quality gates** + +```bash +uv run ruff check . +uv run mypy custom_components/circuitsetup_energy_meter_helper +uv run pytest -q \ + --cov=custom_components/circuitsetup_energy_meter_helper \ + --cov-report=term-missing +uv pip check +``` + +Expected: all pass; no reduction below the repository’s existing coverage gate. + +- [ ] **Step 2: Run Home Assistant contract tests** + +Run the repository’s stable and development Home Assistant test matrix. + +- [ ] **Step 3: Run frontend quality gates** + +```bash +npm --prefix frontend ci +npm --prefix frontend audit +npm --prefix frontend run typecheck +npm --prefix frontend test +npm --prefix frontend run build +npm --prefix frontend run test:e2e +``` + +- [ ] **Step 4: Verify built bundle equality** + +Build the production frontend and confirm the installed bundle under: + +```text +custom_components/circuitsetup_energy_meter_helper/frontend/ +``` + +is byte-identical to the generated output expected by the current release process. + +- [ ] **Step 5: Run firmware contracts** + +Validate/compile at least: +1. 0 add-ons, Wi-Fi, split-phase, no PQ. +2. 0 add-ons, Wi-Fi, multiplier 4 with PQ. +3. 1 add-on, LilyGO Ethernet, 50 Hz single-phase. +4. 1 add-on, Waveshare Ethernet, three references. +5. 3 add-ons, multi-reference. +6. 6 add-ons, sparse used channels and aggregates. +7. Threshold controls on the minimum supported ESPHome release. +8. Legacy ESPHome below the floor with threshold fields omitted. + +- [ ] **Step 6: Search prohibited output** + +```bash +grep -RniE 'board_revision|board revision' \ + custom_components frontend/src tests frontend/test +``` + +Expected: no model/schema/UI implementation. Test names may mention rejection. + +```bash +grep -RniE 'harmonic_power|peak_current' \ + custom_components/circuitsetup_energy_meter_helper \ + frontend/src +``` + +Expected: only managed removal constants/rendering and explanatory copy; no selectable field, estimator count, or scaling implementation. + +- [ ] **Step 7: Run `git diff --check`** + +```bash +git diff --check origin/main... +``` + +- [ ] **Step 8: Commit any deterministic verification fixes** + +Use focused commits by failing subsystem; do not combine unrelated cleanup. + +--- + +## Task 31: Documentation and release + +**Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` + +**Files:** +- Modify: `README.md` +- Create/modify user documentation under: `docs/` +- Modify: `custom_components/circuitsetup_energy_meter_helper/manifest.json` +- Modify: `pyproject.toml` +- Modify frontend package/version metadata required by the existing release process. +- Modify release notes/changelog. + +**Interfaces:** +- Produces the user-facing release. + +- [ ] **Step 1: Document the normal flow** + +Explain: +- Setup Device. +- Meter Settings. +- Circuits & CTs. +- Optional calibration. +- Flash & Verify. +- Summary. + +- [ ] **Step 2: Document electrical/voltage assumptions** + +Explain: +- Profiles are starting structures, not proof of wiring. +- Multiple references require physical preparation. +- No board-revision selection is requested or stored. +- Every CT must use the matching voltage reference for correct power/PF. + +- [ ] **Step 3: Document scaling semantics** + +Clearly separate: +- Register-range multiplier: current, active power, reactive power, apparent power. +- Circuit power method: one-leg power doubling. +- Power factor and phase angle are never multiplied. +- Harmonic power and peak current are not exposed by helper-managed PQ. + +- [ ] **Step 4: Document statuses** + +Explain: +- Sag/overvoltage/frequency limits. +- Circuit warning current. +- Measurement Range Exceeded versus Over Current. +- Status entities are diagnostic and disabled by default. + +- [ ] **Step 5: Document totals/double counting** + +Explain why mains plus branch channels must not be summed into one total and how explicit aggregates prevent this. + +- [ ] **Step 6: Version the release** + +Because this adds a new generalized configuration API and user flow, use a minor-version release unless the project’s semantic-versioning policy requires a major version for the command additions. + +- [ ] **Step 7: Final review** + +Request: +1. Requirements review against the spec. +2. Backend safety review. +3. ESPHome/YAML review. +4. Frontend accessibility review. +5. Cross-repository release-order review. + +- [ ] **Step 8: Commit** + +```bash +git add README.md docs custom_components pyproject.toml frontend +git commit -m "docs: publish priority zero meter configuration" +``` + +--- + +# Acceptance Criteria + +The work is complete only when all of the following are demonstrated: + +1. A user can configure split-phase, single-phase, three-phase, or custom electrical semantics without editing YAML. +2. Every active ATM90E32 group is assigned to exactly one voltage reference. +3. No board-revision option exists anywhere. +4. The operational interval is one of 1/2/5/10/30/60 seconds and calibration timing remains bounded and correct. +5. Unused channels do not clutter normal Home Assistant entities but calibration remains functional. +6. Register-range multipliers scale current, active power, reactive power, and apparent power consistently. +7. Power factor and phase angle remain unscaled. +8. Helper-managed PQ exposes no harmonic-power or peak-current entities. +9. Status thresholds are editable only on a supported released ESPHome version. +10. Measurement-range saturation and user overcurrent thresholds produce distinct status messages. +11. User-defined aggregates do not rely on an automatic sum of all CTs. +12. Bidirectional grid import/export and generation energy configurations compile and reconnect successfully. +13. Two-CT and one-CT doubled two-pole methods produce the intended formulas without corrupting CT gain. +14. All mutations remain hash-bound, reviewed, validated, compiled, confirmed, installed, reconnect-verified, and rollback-capable. +15. Existing CT-only callers/tests remain supported through wrappers. +16. Runtime-only devices without Device Builder remain read-only except for existing Home Assistant label behavior. +17. Full Python, frontend, Home Assistant, firmware-contract, and E2E test matrices pass. + +# Codex Execution Notes + +- Use test-driven development for every task. +- Use a fresh subagent for each task or PR-sized workstream. +- Run the focused test before and after each implementation step. +- Commit after every independently reviewable task. +- Do not opportunistically refactor unrelated calibration, provisioning, or frontend code. +- When current `main` differs from the paths/signatures in this plan, preserve the plan’s interfaces and adapt only the file placement necessary to match the repository’s established structure. +- Stop and open a focused design amendment if the ATM90E32 released schema uses materially different field semantics; do not emulate missing component support with extra template sensors. diff --git a/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md b/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md new file mode 100644 index 0000000..af7e6e5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md @@ -0,0 +1,237 @@ +# Priority 0 Meter Configuration Design Specification + +## Approved Requirements Baseline + +### Included Priority 0 capabilities + +1. **Electrical-system profile** + - North American split-phase 120/240 V. + - Single-phase 220–240 V. + - Three-phase. + - Custom. + - Explicit 50 Hz or 60 Hz selection. + - No geographic assumption may silently become authoritative. + +2. **Voltage references and transformer configuration** + - One or more named voltage references. + - Nominal RMS voltage per reference. + - Transformer preset or custom starting gain. + - Explicit mapping from every ATM90E32 group to exactly one voltage reference. + - Generic physical-preparation acknowledgement when more than one voltage reference is configured. + - Voltage calibration operates by voltage reference, not by a hard-coded board pair. + +3. **Reporting interval** + - Supported choices: 1, 2, 5, 10, 30, or 60 seconds. + - Configuration and calibration timeouts must account for the selected interval. + - The UI must explain the traffic and calibration implications. + +4. **Channel usage, role, phase/reference, and two-pole measurement** + - Used or unused. + - Roles: grid/mains, solar, generator, subpanel feeder, branch circuit, two-pole appliance, custom, unused. + - Every used channel maps to one voltage reference. + - Two-pole methods: two CTs summed, one CT with power doubled, both conductors through one CT, or direct single-channel measurement. + - Register-range scaling and semantic circuit-power scaling remain separate. + - Do not add a casual software CT-inversion switch. + +5. **Totals, groups, and energy reporting** + - User-defined grid, generation, subpanel, circuit, and custom aggregates. + - No default “sum every CT” total when that can double-count branch circuits already included in mains or feeder measurements. + - Energy modes: none, consumption, bidirectional import/export, and generation. + - Aggregate power, optional aggregate current, and managed energy entities use deterministic IDs. + - Existing official generic totals must be hidden or explicitly identified as unmanaged before replacement totals are exposed. + +6. **Power-quality range scaling** + - Existing `reporting_multiplier` values remain exactly `1`, `2`, `4`, or `8`. + - Continue scaling current and active power. + - Add matching scaling for reactive power and apparent power. + - Do not multiply power factor or phase angle. + - Harmonic power and peak current are not part of the helper-managed power-quality feature. When the existing full package is enabled by the helper, remove those two entities in the helper-managed override block. + - Do not add harmonic-power or peak-current fields to new helper models, schemas, UI, entity estimates, or tests except tests proving they are absent/removed. + +7. **Configurable status thresholds** + - Absolute sag-voltage threshold per voltage reference. + - Absolute overvoltage threshold per voltage reference. + - Low- and high-frequency thresholds per voltage reference. + - Per-channel overcurrent warning threshold in final reported amperes. + - Separate “measurement range exceeded” from user-configured “over current.” + - Status entities remain diagnostic and disabled by default. + - Helper controls remain unavailable until the selected Device Builder ESPHome version includes the new ATM90E32 schema. + +### Explicit exclusions + +- **No board-revision option.** Do not add a `board_revision` type, field, UI control, persisted value, mutation, validation rule, diagnostic field, migration, or test-matrix dimension. +- No generic YAML editor. +- No arbitrary package paths, filters, lambdas, SPI pins, internal ATM90E32 IDs, `gain_pga`, or `current_phases` in the normal UI. +- No automatic Home Assistant notification creation. +- No software CT inversion in this work. +- No harmonic-power or peak-current management. +- No firmware permutation explosion for every electrical profile. New devices flash the standard topology firmware selected by add-on count and connection type; the helper applies electrical settings after adoption. +- Do not remove calibration controls or calibration sensor entities required by the current binding and verification flow. +- Do not bypass source hashes, explicit preview, compile, install confirmation, reconnect verification, or rollback. + +### User-flow constraint + +Add only one new main step, **Meter Settings**, between **Setup Device** and **Circuits & CTs**. Rename the existing **CT Settings** step to **Circuits & CTs**. Keep aggregate and energy controls inside that step rather than adding separate permanent wizard pages. + +--- + + +## Public Data Contracts + +Define these names exactly unless an existing merged change creates a naming collision. + +```python +# meter_configuration.py +from dataclasses import dataclass +from enum import StrEnum +from typing import Literal + +LineFrequencyHz = Literal[50, 60] +UpdateIntervalSeconds = Literal[1, 2, 5, 10, 30, 60] + +class ElectricalSystem(StrEnum): + SPLIT_PHASE_120_240 = "split_phase_120_240" + SINGLE_PHASE_230 = "single_phase_230" + THREE_PHASE = "three_phase" + CUSTOM = "custom" + +class VoltageLayout(StrEnum): + STANDARD = "standard" + MULTI_REFERENCE = "multi_reference" + CUSTOM = "custom" + +class CircuitRole(StrEnum): + GRID = "grid" + SOLAR = "solar" + GENERATOR = "generator" + SUBPANEL = "subpanel" + BRANCH = "branch" + TWO_POLE = "two_pole" + CUSTOM = "custom" + UNUSED = "unused" + +class MeasurementMethod(StrEnum): + DIRECT = "direct" + TWO_CT_SUM = "two_ct_sum" + ONE_CT_DOUBLE_POWER = "one_ct_double_power" + BOTH_CONDUCTORS_ONE_CT = "both_conductors_one_ct" + +class EnergyMode(StrEnum): + NONE = "none" + CONSUMPTION = "consumption" + BIDIRECTIONAL = "bidirectional" + GENERATION = "generation" + +@dataclass(frozen=True, slots=True) +class VoltageReferenceConfig: + reference_id: str + label: str + phase_label: str + nominal_voltage_v: float + transformer_model_id: str + gain_voltage: int + group_keys: tuple[str, ...] + sag_percent: float + overvoltage_percent: float + frequency_low_hz: float + frequency_high_hz: float + +@dataclass(frozen=True, slots=True) +class MeterSettings: + friendly_name: str + electrical_system: ElectricalSystem + line_frequency_hz: LineFrequencyHz + update_interval_s: UpdateIntervalSeconds + voltage_layout: VoltageLayout + voltage_references: tuple[VoltageReferenceConfig, ...] + +@dataclass(frozen=True, slots=True) +class ChannelSettings: + channel: int + enabled: bool + name: str + model_id: str + reporting_multiplier: float + role: CircuitRole + voltage_reference_id: str + current_warning_a: float | None + custom_gain_ct: int | None = None + custom_label: str | None = None + burden_output_acknowledged: bool = False + +@dataclass(frozen=True, slots=True) +class CircuitAggregate: + aggregate_id: str + name: str + role: CircuitRole + channels: tuple[int, ...] + measurement_method: MeasurementMethod + parent_id: str | None + energy_mode: EnergyMode + expose_power: bool = True + expose_current: bool = False + +@dataclass(frozen=True, slots=True) +class MeterConfigurationRequest: + meter: MeterSettings + channels: tuple[ChannelSettings, ...] + aggregates: tuple[CircuitAggregate, ...] + power_quality: tuple[bool, ...] + status_fields: tuple[bool, ...] + multi_reference_preparation_acknowledged: bool = False +``` + +The request’s `multi_reference_preparation_acknowledged` value is operation-scoped and must not be stored as a claim that hardware was physically verified. + +```python +# meter_inventory.py +@dataclass(frozen=True, slots=True) +class MeterConfigurationCapabilities: + configuration_authoritative: bool + status_thresholds: bool + managed_totals: bool + multi_reference: bool + reason_codes: tuple[str, ...] + +@dataclass(frozen=True, slots=True) +class MeterConfigurationInventory: + plan_id: str + source_sha256: str + topology: MeterTopology + configuration: MeterConfigurationRequest + capabilities: MeterConfigurationCapabilities + voltage_transformer_catalog: VoltageTransformerCatalog + ct_catalog: CTPresetCatalog + warnings: tuple[str, ...] +``` + +```python +# meter_config_mutator.py +def build_meter_configuration_mutation( + snapshot: ConfigSnapshot, + topology: MeterTopology, + current: MeterConfigurationInventory, + requested: MeterConfigurationRequest, + *, + calibrated: VerifiedCalibrationRecord | None = None, +) -> ConfigMutationPlan +``` + +```python +# entity_estimator.py +@dataclass(frozen=True, slots=True) +class ConfigurationImpact: + enabled_channel_count: int + numeric_entity_count: int + text_entity_count: int + energy_entity_count: int + approximate_publications_per_second: float + +def estimate_configuration_impact( + request: MeterConfigurationRequest, + topology: MeterTopology, +) -> ConfigurationImpact +``` + +--- + From eb470e918028fdbebf9a8f483cd827fa5113fcd6 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Mon, 24 Aug 2026 22:33:49 -0400 Subject: [PATCH 02/35] docs: record priority zero configuration task report --- .../task-1-report.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .superpowers/sdd/2026-08-24-priority-0-meter-configuration-codex-plan/task-1-report.md diff --git a/.superpowers/sdd/2026-08-24-priority-0-meter-configuration-codex-plan/task-1-report.md b/.superpowers/sdd/2026-08-24-priority-0-meter-configuration-codex-plan/task-1-report.md new file mode 100644 index 0000000..8ce10dd --- /dev/null +++ b/.superpowers/sdd/2026-08-24-priority-0-meter-configuration-codex-plan/task-1-report.md @@ -0,0 +1,27 @@ +# Task 1 Report + +## What was copied +- Copied the complete source plan from `C:\Users\John\Downloads\2026-08-24-priority-0-meter-configuration-codex-plan.md` to `docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md`. +- Copied the Approved Requirements Baseline (including explicit exclusions and user-flow constraint) and Public Data Contracts to `docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md`. +- No implementation or unrelated refactors were made. + +## Verification +- Prohibited-scope search: + `rg -n -i 'board_revision|board revision' docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md` + Output is limited to explicit exclusion/prohibition text and the plan's verification instructions/acceptance checks; no proposed implementation field or control was added. +- `git diff --cached --check` + Completed before commit; Git reported one `new blank line at EOF` warning for the spec, which is intentional Markdown file termination and does not indicate whitespace corruption. +- Commit: `7385d23` (`docs: specify priority zero meter configuration`) + +## Files changed +- `docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md` +- `docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md` + +## Self-review +The two committed files contain documentation only and preserve the source plan text. The spec is limited to the requested baseline and public contracts sections. The prohibited board-revision scope remains documented only as an explicit exclusion. + +## Concerns +The worktree had pre-existing unrelated deletions under `custom_components/.../frontend/` and an untracked `uv.lock`; these were preserved and not included in the commit. Git's diff check emitted the intentional final blank-line warning noted above. + +## Commit SHA +`7385d23` \ No newline at end of file From e09028a3b5a7259b4ef43b082007533f7cfea913 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Mon, 24 Aug 2026 22:36:53 -0400 Subject: [PATCH 03/35] docs: remove scratch task report --- .../task-1-report.md | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 .superpowers/sdd/2026-08-24-priority-0-meter-configuration-codex-plan/task-1-report.md diff --git a/.superpowers/sdd/2026-08-24-priority-0-meter-configuration-codex-plan/task-1-report.md b/.superpowers/sdd/2026-08-24-priority-0-meter-configuration-codex-plan/task-1-report.md deleted file mode 100644 index 8ce10dd..0000000 --- a/.superpowers/sdd/2026-08-24-priority-0-meter-configuration-codex-plan/task-1-report.md +++ /dev/null @@ -1,27 +0,0 @@ -# Task 1 Report - -## What was copied -- Copied the complete source plan from `C:\Users\John\Downloads\2026-08-24-priority-0-meter-configuration-codex-plan.md` to `docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md`. -- Copied the Approved Requirements Baseline (including explicit exclusions and user-flow constraint) and Public Data Contracts to `docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md`. -- No implementation or unrelated refactors were made. - -## Verification -- Prohibited-scope search: - `rg -n -i 'board_revision|board revision' docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md` - Output is limited to explicit exclusion/prohibition text and the plan's verification instructions/acceptance checks; no proposed implementation field or control was added. -- `git diff --cached --check` - Completed before commit; Git reported one `new blank line at EOF` warning for the spec, which is intentional Markdown file termination and does not indicate whitespace corruption. -- Commit: `7385d23` (`docs: specify priority zero meter configuration`) - -## Files changed -- `docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md` -- `docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md` - -## Self-review -The two committed files contain documentation only and preserve the source plan text. The spec is limited to the requested baseline and public contracts sections. The prohibited board-revision scope remains documented only as an explicit exclusion. - -## Concerns -The worktree had pre-existing unrelated deletions under `custom_components/.../frontend/` and an untracked `uv.lock`; these were preserved and not included in the commit. Git's diff check emitted the intentional final blank-line warning noted above. - -## Commit SHA -`7385d23` \ No newline at end of file From 5f95cf205c5355e440affd898cfac3834d5781f0 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 01:24:10 -0400 Subject: [PATCH 04/35] feat: model meter and circuit configuration --- .../meter_configuration.py | 261 ++++++++++++++++++ tests/test_meter_configuration.py | 79 ++++++ 2 files changed, 340 insertions(+) create mode 100644 custom_components/circuitsetup_energy_meter_helper/meter_configuration.py create mode 100644 tests/test_meter_configuration.py diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py new file mode 100644 index 0000000..3e20fdd --- /dev/null +++ b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py @@ -0,0 +1,261 @@ +"""Pure, topology-bounded meter and circuit configuration models.""" + +from __future__ import annotations + +import math +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from typing import Literal + +from .models import MeterTopology + +LineFrequencyHz = Literal[50, 60] +UpdateIntervalSeconds = Literal[1, 2, 5, 10, 30, 60] + + +class ElectricalSystem(StrEnum): + SPLIT_PHASE_120_240 = "split_phase_120_240" + SINGLE_PHASE_230 = "single_phase_230" + THREE_PHASE = "three_phase" + CUSTOM = "custom" + + +class VoltageLayout(StrEnum): + STANDARD = "standard" + MULTI_REFERENCE = "multi_reference" + CUSTOM = "custom" + + +class CircuitRole(StrEnum): + GRID = "grid" + SOLAR = "solar" + GENERATOR = "generator" + SUBPANEL = "subpanel" + BRANCH = "branch" + TWO_POLE = "two_pole" + CUSTOM = "custom" + UNUSED = "unused" + + +class MeasurementMethod(StrEnum): + DIRECT = "direct" + TWO_CT_SUM = "two_ct_sum" + ONE_CT_DOUBLE_POWER = "one_ct_double_power" + BOTH_CONDUCTORS_ONE_CT = "both_conductors_one_ct" + + +class EnergyMode(StrEnum): + NONE = "none" + CONSUMPTION = "consumption" + BIDIRECTIONAL = "bidirectional" + GENERATION = "generation" + + +@dataclass(frozen=True, slots=True) +class VoltageReferenceConfig: + reference_id: str + label: str + phase_label: str + nominal_voltage_v: float + transformer_model_id: str + gain_voltage: int + group_keys: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class MeterSettings: + friendly_name: str + electrical_system: ElectricalSystem + line_frequency_hz: LineFrequencyHz + update_interval_s: UpdateIntervalSeconds + voltage_layout: VoltageLayout + voltage_references: tuple[VoltageReferenceConfig, ...] + + +@dataclass(frozen=True, slots=True) +class ChannelSettings: + channel: int + enabled: bool + name: str + model_id: str + reporting_multiplier: float + role: CircuitRole + voltage_reference_id: str | None + custom_gain_ct: int | None = None + custom_label: str | None = None + burden_output_acknowledged: bool = False + + +@dataclass(frozen=True, slots=True) +class CircuitAggregate: + aggregate_id: str + name: str + role: CircuitRole + channels: tuple[int, ...] + measurement_method: MeasurementMethod + parent_id: str | None + energy_mode: EnergyMode + expose_power: bool = True + expose_current: bool = False + + +@dataclass(frozen=True, slots=True) +class MeterConfigurationRequest: + meter: MeterSettings + channels: tuple[ChannelSettings, ...] + aggregates: tuple[CircuitAggregate, ...] + power_quality: tuple[bool, ...] + status_fields: tuple[bool, ...] + multi_reference_preparation_acknowledged: bool = False + + +PROFILE_DEFAULTS = { + ElectricalSystem.SPLIT_PHASE_120_240: (60, 120.0), + ElectricalSystem.SINGLE_PHASE_230: (50, 230.0), +} +_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]") + + +def _text(value: object, field: str, *, max_length: int = 64) -> None: + if not isinstance(value, str) or not 1 <= len(value) <= max_length or _CONTROL.search(value): + raise ValueError(f"{field} must be 1-{max_length} safe characters") + + +def _finite(value: object, field: str) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError(f"{field} must be finite") + + +def _bools(values: object, field: str, count: int) -> None: + if not isinstance(values, tuple) or len(values) != count or any(type(v) is not bool for v in values): + raise ValueError(f"{field} requires one boolean per board") + + +def validate_meter_configuration( + request: MeterConfigurationRequest, topology: MeterTopology +) -> None: + """Validate a request against fixed physical topology without side effects.""" + meter = request.meter + _text(meter.friendly_name, "friendly_name") + if not isinstance(meter.electrical_system, ElectricalSystem): + raise ValueError("invalid electrical_system") # noqa: TRY004 + if meter.line_frequency_hz not in (50, 60) or type(meter.line_frequency_hz) is bool: + raise ValueError("line_frequency_hz must be 50 or 60") + if meter.update_interval_s not in (1, 2, 5, 10, 30, 60) or type(meter.update_interval_s) is bool: + raise ValueError("invalid update_interval_s") + if not isinstance(meter.voltage_layout, VoltageLayout): + raise ValueError("invalid voltage_layout") # noqa: TRY004 + refs = meter.voltage_references + if not refs or len({r.reference_id for r in refs}) != len(refs): + raise ValueError("voltage references must be uniquely identified") + all_groups: list[str] = [] + for ref in refs: + for field, value in (("reference_id", ref.reference_id), ("label", ref.label), ("phase_label", ref.phase_label), ("transformer_model_id", ref.transformer_model_id)): + _text(value, field) + _finite(ref.nominal_voltage_v, "nominal_voltage_v") + if not 1 <= ref.nominal_voltage_v <= 600: + raise ValueError("nominal_voltage_v must be 1-600") + if type(ref.gain_voltage) is not int or not 1 <= ref.gain_voltage <= 65535: + raise ValueError("gain_voltage must be 1-65535") + if not ref.group_keys or any(not isinstance(g, str) for g in ref.group_keys): + raise ValueError("group_keys must be non-empty strings") + all_groups.extend(ref.group_keys) + expected_groups = {f"g{i}" for i in range(1, topology.group_count + 1)} + if set(all_groups) != expected_groups or len(all_groups) != len(expected_groups): + raise ValueError("topology groups must be assigned exactly once") + if len(refs) > 1 and not request.multi_reference_preparation_acknowledged: + raise ValueError("multi-reference preparation acknowledgement required") + if len(refs) == 1 and request.multi_reference_preparation_acknowledged: + raise ValueError("multi-reference acknowledgement is only for multiple references") + + if not isinstance(request.channels, tuple) or len(request.channels) != topology.ct_count: + raise ValueError("one channel setting is required per topology channel") + by_channel: dict[int, ChannelSettings] = {} + ref_ids = {r.reference_id for r in refs} + for channel in request.channels: + if type(channel.channel) is not int or not 1 <= channel.channel <= topology.ct_count or channel.channel in by_channel: + raise ValueError("channels must uniquely cover topology") + by_channel[channel.channel] = channel + if type(channel.enabled) is not bool or type(channel.burden_output_acknowledged) is not bool: + raise ValueError("channel flags must be boolean") + _text(channel.name, "channel name") + _text(channel.model_id, "model_id") + _finite(channel.reporting_multiplier, "reporting_multiplier") + if channel.reporting_multiplier not in (1, 2, 4, 8): + raise ValueError("unsupported reporting_multiplier") + if not isinstance(channel.role, CircuitRole): + raise ValueError("invalid circuit role") # noqa: TRY004 + if channel.enabled and channel.role is CircuitRole.UNUSED: + raise ValueError("used channels cannot be UNUSED") + if not channel.enabled and channel.role is not CircuitRole.UNUSED: + raise ValueError("unused channels must be UNUSED") + valid_reference = ( + channel.voltage_reference_id in ref_ids + if channel.enabled + else channel.voltage_reference_id is None + ) + if not valid_reference: + raise ValueError("channel has invalid voltage reference") + if channel.custom_gain_ct is not None and (type(channel.custom_gain_ct) is not int or not 1 <= channel.custom_gain_ct <= 65535): + raise ValueError("custom_gain_ct must be 1-65535") + if channel.custom_label is not None: + _text(channel.custom_label, "custom_label") + if set(by_channel) != set(range(1, topology.ct_count + 1)): + raise ValueError("channels must cover topology exactly") + + aggregate_ids = {a.aggregate_id for a in request.aggregates} + if len(aggregate_ids) != len(request.aggregates): + raise ValueError("aggregate IDs must be unique") + aggregate_channels: set[int] = set() + for aggregate in request.aggregates: + _text(aggregate.aggregate_id, "aggregate_id") + if not _SLUG.fullmatch(aggregate.aggregate_id): + raise ValueError("aggregate_id must be a safe slug") + _text(aggregate.name, "aggregate name") + if ( + not aggregate.channels + or any(type(c) is not int for c in aggregate.channels) + or len(set(aggregate.channels)) != len(aggregate.channels) + ): + raise ValueError("aggregate channels must be unique") + if any(c not in by_channel for c in aggregate.channels) or aggregate_channels.intersection(aggregate.channels): + raise ValueError("aggregate channels must be unique and in topology") + aggregate_channels.update(aggregate.channels) + if not isinstance(aggregate.measurement_method, MeasurementMethod) or not isinstance(aggregate.energy_mode, EnergyMode): + raise ValueError("invalid aggregate method or energy mode") # noqa: TRY004 + expected = {MeasurementMethod.DIRECT: 1, MeasurementMethod.TWO_CT_SUM: 2, MeasurementMethod.ONE_CT_DOUBLE_POWER: 1, MeasurementMethod.BOTH_CONDUCTORS_ONE_CT: 1}[aggregate.measurement_method] + if len(aggregate.channels) != expected or any(not by_channel[c].enabled for c in aggregate.channels): + raise ValueError("measurement method cardinality does not match enabled channels") + if aggregate.parent_id is not None and aggregate.parent_id not in aggregate_ids: + raise ValueError("aggregate parent does not exist") + if type(aggregate.expose_power) is not bool or type(aggregate.expose_current) is not bool: + raise ValueError("aggregate exposure flags must be boolean") + for aggregate in request.aggregates: + seen: set[str] = set() + current = aggregate + while current.parent_id is not None: + if current.aggregate_id in seen: + raise ValueError("aggregate parent cycle") + seen.add(current.aggregate_id) + current = next(a for a in request.aggregates if a.aggregate_id == current.parent_id) + _bools(request.power_quality, "power_quality", topology.board_count) + _bools(request.status_fields, "status_fields", topology.board_count) + + +def default_meter_configuration( + topology: MeterTopology, package_options: Mapping[str, Sequence[bool]] +) -> MeterConfigurationRequest: + """Build the only implicit profile: split-phase 120/240 V.""" + if set(package_options) != {"power_quality", "status_fields"}: + raise ValueError("package_options must match installed package options") + options = {key: tuple(value) for key, value in package_options.items()} + _bools(options["power_quality"], "power_quality", topology.board_count) + _bools(options["status_fields"], "status_fields", topology.board_count) + refs = (VoltageReferenceConfig("main", "Main", "A", 120.0, "default", 1, tuple(f"g{i}" for i in range(1, topology.group_count + 1))),) + channels = tuple(ChannelSettings(i, True, f"CT {i}", "default", 1.0, CircuitRole.BRANCH, "main") for i in range(1, topology.ct_count + 1)) + result = MeterConfigurationRequest(MeterSettings("Energy meter", ElectricalSystem.SPLIT_PHASE_120_240, 60, 5, VoltageLayout.STANDARD, refs), channels, (), options["power_quality"], options["status_fields"]) + validate_meter_configuration(result, topology) + return result diff --git a/tests/test_meter_configuration.py b/tests/test_meter_configuration.py new file mode 100644 index 0000000..4844403 --- /dev/null +++ b/tests/test_meter_configuration.py @@ -0,0 +1,79 @@ +import math + +import pytest + +from custom_components.circuitsetup_energy_meter_helper.meter_configuration import ( + ChannelSettings, + CircuitRole, + ElectricalSystem, + MeterConfigurationRequest, + MeterSettings, + UpdateIntervalSeconds, + VoltageLayout, + VoltageReferenceConfig, + default_meter_configuration, + validate_meter_configuration, +) +from custom_components.circuitsetup_energy_meter_helper.models import MeterTopology + + +def topology(addons: int = 0) -> MeterTopology: + return MeterTopology.from_addon_count( + addons, + connection_type="wifi", + voltage_layout="standard", + project_name="circuitsetup.6c-energy-meter", + evidence=(), + ) + + +def request(*, addons: int = 0, interval: int = 5) -> MeterConfigurationRequest: + meter = MeterSettings( + "Kitchen meter", ElectricalSystem.SPLIT_PHASE_120_240, 60, interval, + VoltageLayout.STANDARD, + (VoltageReferenceConfig("main", "Main", "A", 120.0, "v", 1, ("g1", "g2")),), + ) + channels = tuple( + ChannelSettings(i, True, f"CT {i}", "ct", 1.0, CircuitRole.BRANCH, "main") + for i in range(1, 7 * (addons + 1)) + ) + return MeterConfigurationRequest( + meter, channels, (), (False,) * (addons + 1), (True,) + (False,) * addons + ) + + +@pytest.mark.parametrize("interval", [1, 2, 5, 10, 30, 60]) +def test_allowed_update_intervals(interval: UpdateIntervalSeconds) -> None: + validate_meter_configuration(request(interval=interval), topology()) + + +@pytest.mark.parametrize("frequency", [50, 60]) +def test_frequency_is_exactly_50_or_60(frequency: int) -> None: + value = request() + object.__setattr__(value.meter, "line_frequency_hz", frequency) + validate_meter_configuration(value, topology()) + + +def test_default_profiles_and_topology_group_assignment() -> None: + value = default_meter_configuration(topology(), {"power_quality": (False,), "status_fields": (True,)}) + assert value.meter.line_frequency_hz == 60 + assert value.meter.voltage_references[0].group_keys == ("g1", "g2") + validate_meter_configuration(value, topology()) + + +def test_structural_validation_rejects_bad_channels_aggregates_and_options() -> None: + value = request() + bad = ChannelSettings(1, True, "CT", "ct", 1.0, CircuitRole.UNUSED, "main") + object.__setattr__(value, "channels", (bad,) + value.channels[1:]) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + + +def test_numeric_and_forbidden_fields_are_rejected() -> None: + value = request() + object.__setattr__(value.meter, "friendly_name", "bad\nname") + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + assert not hasattr(value.meter, "board_revision") + assert not any("harmonic" in field or "peak_current" in field for field in value.meter.__slots__) + assert math.isfinite(1.0) From 0271e0d22665e98c760b7a53dd6a41cd2f96c115 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 01:31:02 -0400 Subject: [PATCH 05/35] fix: align meter configuration validation contracts --- .../meter_configuration.py | 43 ++++++++++++++----- tests/test_meter_configuration.py | 32 +++++++++++++- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py index 3e20fdd..c306472 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py @@ -9,6 +9,7 @@ from enum import StrEnum from typing import Literal +from .entity_binding import group_key from .models import MeterTopology LineFrequencyHz = Literal[50, 60] @@ -82,7 +83,7 @@ class ChannelSettings: model_id: str reporting_multiplier: float role: CircuitRole - voltage_reference_id: str | None + voltage_reference_id: str custom_gain_ct: int | None = None custom_label: str | None = None burden_output_acknowledged: bool = False @@ -163,7 +164,11 @@ def validate_meter_configuration( if not ref.group_keys or any(not isinstance(g, str) for g in ref.group_keys): raise ValueError("group_keys must be non-empty strings") all_groups.extend(ref.group_keys) - expected_groups = {f"g{i}" for i in range(1, topology.group_count + 1)} + expected_groups = { + group_key(board, group) + for board in range(topology.board_count) + for group in range(2) + } if set(all_groups) != expected_groups or len(all_groups) != len(expected_groups): raise ValueError("topology groups must be assigned exactly once") if len(refs) > 1 and not request.multi_reference_preparation_acknowledged: @@ -192,11 +197,8 @@ def validate_meter_configuration( raise ValueError("used channels cannot be UNUSED") if not channel.enabled and channel.role is not CircuitRole.UNUSED: raise ValueError("unused channels must be UNUSED") - valid_reference = ( - channel.voltage_reference_id in ref_ids - if channel.enabled - else channel.voltage_reference_id is None - ) + _text(channel.voltage_reference_id, "voltage_reference_id") + valid_reference = channel.voltage_reference_id in ref_ids if not valid_reference: raise ValueError("channel has invalid voltage reference") if channel.custom_gain_ct is not None and (type(channel.custom_gain_ct) is not int or not 1 <= channel.custom_gain_ct <= 65535): @@ -226,8 +228,15 @@ def validate_meter_configuration( aggregate_channels.update(aggregate.channels) if not isinstance(aggregate.measurement_method, MeasurementMethod) or not isinstance(aggregate.energy_mode, EnergyMode): raise ValueError("invalid aggregate method or energy mode") # noqa: TRY004 - expected = {MeasurementMethod.DIRECT: 1, MeasurementMethod.TWO_CT_SUM: 2, MeasurementMethod.ONE_CT_DOUBLE_POWER: 1, MeasurementMethod.BOTH_CONDUCTORS_ONE_CT: 1}[aggregate.measurement_method] - if len(aggregate.channels) != expected or any(not by_channel[c].enabled for c in aggregate.channels): + expected = { + MeasurementMethod.TWO_CT_SUM: 2, + MeasurementMethod.ONE_CT_DOUBLE_POWER: 1, + MeasurementMethod.BOTH_CONDUCTORS_ONE_CT: 1, + }.get(aggregate.measurement_method) + if ( + (expected is not None and len(aggregate.channels) != expected) + or any(not by_channel[c].enabled for c in aggregate.channels) + ): raise ValueError("measurement method cardinality does not match enabled channels") if aggregate.parent_id is not None and aggregate.parent_id not in aggregate_ids: raise ValueError("aggregate parent does not exist") @@ -254,7 +263,21 @@ def default_meter_configuration( options = {key: tuple(value) for key, value in package_options.items()} _bools(options["power_quality"], "power_quality", topology.board_count) _bools(options["status_fields"], "status_fields", topology.board_count) - refs = (VoltageReferenceConfig("main", "Main", "A", 120.0, "default", 1, tuple(f"g{i}" for i in range(1, topology.group_count + 1))),) + refs = ( + VoltageReferenceConfig( + "main", + "Main", + "A", + 120.0, + "default", + 1, + tuple( + group_key(board, group) + for board in range(topology.board_count) + for group in range(2) + ), + ), + ) channels = tuple(ChannelSettings(i, True, f"CT {i}", "default", 1.0, CircuitRole.BRANCH, "main") for i in range(1, topology.ct_count + 1)) result = MeterConfigurationRequest(MeterSettings("Energy meter", ElectricalSystem.SPLIT_PHASE_120_240, 60, 5, VoltageLayout.STANDARD, refs), channels, (), options["power_quality"], options["status_fields"]) validate_meter_configuration(result, topology) diff --git a/tests/test_meter_configuration.py b/tests/test_meter_configuration.py index 4844403..42bd025 100644 --- a/tests/test_meter_configuration.py +++ b/tests/test_meter_configuration.py @@ -2,10 +2,14 @@ import pytest +from custom_components.circuitsetup_energy_meter_helper.entity_binding import group_key from custom_components.circuitsetup_energy_meter_helper.meter_configuration import ( ChannelSettings, + CircuitAggregate, CircuitRole, ElectricalSystem, + EnergyMode, + MeasurementMethod, MeterConfigurationRequest, MeterSettings, UpdateIntervalSeconds, @@ -31,7 +35,11 @@ def request(*, addons: int = 0, interval: int = 5) -> MeterConfigurationRequest: meter = MeterSettings( "Kitchen meter", ElectricalSystem.SPLIT_PHASE_120_240, 60, interval, VoltageLayout.STANDARD, - (VoltageReferenceConfig("main", "Main", "A", 120.0, "v", 1, ("g1", "g2")),), + (VoltageReferenceConfig("main", "Main", "A", 120.0, "v", 1, tuple( + group_key(board, group) + for board in range(addons + 1) + for group in range(2) + )),), ) channels = tuple( ChannelSettings(i, True, f"CT {i}", "ct", 1.0, CircuitRole.BRANCH, "main") @@ -57,7 +65,27 @@ def test_frequency_is_exactly_50_or_60(frequency: int) -> None: def test_default_profiles_and_topology_group_assignment() -> None: value = default_meter_configuration(topology(), {"power_quality": (False,), "status_fields": (True,)}) assert value.meter.line_frequency_hz == 60 - assert value.meter.voltage_references[0].group_keys == ("g1", "g2") + assert value.meter.voltage_references[0].group_keys == (group_key(0, 0), group_key(0, 1)) + validate_meter_configuration(value, topology()) + + +def test_default_addon_groups_use_canonical_keys() -> None: + value = default_meter_configuration( + topology(1), {"power_quality": (False, False), "status_fields": (True, False)} + ) + assert value.meter.voltage_references[0].group_keys == ( + "main_1", "main_2", "addon1_1", "addon1_2" + ) + validate_meter_configuration(value, topology(1)) + + +def test_direct_accepts_multiple_enabled_channels() -> None: + value = request() + aggregate = CircuitAggregate( + "grid", "Grid", CircuitRole.GRID, (1, 2), MeasurementMethod.DIRECT, + None, EnergyMode.CONSUMPTION, + ) + object.__setattr__(value, "aggregates", (aggregate,)) validate_meter_configuration(value, topology()) From e0ee5cf6b46d9a03e5a99bca346826b3f61eccdd Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 01:35:16 -0400 Subject: [PATCH 06/35] test: complete meter configuration validation coverage --- .../meter_configuration.py | 4 + tests/test_meter_configuration.py | 173 +++++++++++++++++- 2 files changed, 174 insertions(+), 3 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py index c306472..ba5c924 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py @@ -171,6 +171,8 @@ def validate_meter_configuration( } if set(all_groups) != expected_groups or len(all_groups) != len(expected_groups): raise ValueError("topology groups must be assigned exactly once") + if type(request.multi_reference_preparation_acknowledged) is not bool: + raise ValueError("multi-reference acknowledgement must be boolean") if len(refs) > 1 and not request.multi_reference_preparation_acknowledged: raise ValueError("multi-reference preparation acknowledgement required") if len(refs) == 1 and request.multi_reference_preparation_acknowledged: @@ -228,6 +230,8 @@ def validate_meter_configuration( aggregate_channels.update(aggregate.channels) if not isinstance(aggregate.measurement_method, MeasurementMethod) or not isinstance(aggregate.energy_mode, EnergyMode): raise ValueError("invalid aggregate method or energy mode") # noqa: TRY004 + if not isinstance(aggregate.role, CircuitRole): + raise ValueError("invalid aggregate role") # noqa: TRY004 expected = { MeasurementMethod.TWO_CT_SUM: 2, MeasurementMethod.ONE_CT_DOUBLE_POWER: 1, diff --git a/tests/test_meter_configuration.py b/tests/test_meter_configuration.py index 42bd025..acc7457 100644 --- a/tests/test_meter_configuration.py +++ b/tests/test_meter_configuration.py @@ -1,4 +1,5 @@ import math +from dataclasses import replace import pytest @@ -43,7 +44,7 @@ def request(*, addons: int = 0, interval: int = 5) -> MeterConfigurationRequest: ) channels = tuple( ChannelSettings(i, True, f"CT {i}", "ct", 1.0, CircuitRole.BRANCH, "main") - for i in range(1, 7 * (addons + 1)) + for i in range(1, 6 * (addons + 1) + 1) ) return MeterConfigurationRequest( meter, channels, (), (False,) * (addons + 1), (True,) + (False,) * addons @@ -102,6 +103,172 @@ def test_numeric_and_forbidden_fields_are_rejected() -> None: object.__setattr__(value.meter, "friendly_name", "bad\nname") with pytest.raises(ValueError): validate_meter_configuration(value, topology()) - assert not hasattr(value.meter, "board_revision") - assert not any("harmonic" in field or "peak_current" in field for field in value.meter.__slots__) + for model in (VoltageReferenceConfig, MeterSettings, ChannelSettings, CircuitAggregate, MeterConfigurationRequest): + fields = model.__slots__ + assert "board_revision" not in fields + assert not any("harmonic" in field or "peak_current" in field for field in fields) assert math.isfinite(1.0) + + +@pytest.mark.parametrize("interval", [0, 3, 4, 6, 15, 61, True, False]) +def test_invalid_update_intervals(interval: object) -> None: + value = request() + object.__setattr__(value.meter, "update_interval_s", interval) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + + +@pytest.mark.parametrize("frequency", [0, 49, 51, 61, True, False]) +def test_invalid_line_frequencies(frequency: object) -> None: + value = request() + object.__setattr__(value.meter, "line_frequency_hz", frequency) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + + +@pytest.mark.parametrize("groups", [("main_1",), ("main_1", "main_1"), ("g1", "g2")]) +def test_group_assignment_must_be_exact_and_canonical(groups: tuple[str, ...]) -> None: + value = request() + object.__setattr__(value.meter, "voltage_references", (replace(value.meter.voltage_references[0], group_keys=groups),)) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + + +def test_reference_and_role_rules() -> None: + value = request() + object.__setattr__(value, "channels", (replace(value.channels[0], voltage_reference_id="missing"),) + value.channels[1:]) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + disabled = replace(value.channels[0], enabled=False, role=CircuitRole.UNUSED, voltage_reference_id="main") + object.__setattr__(value, "channels", (disabled,) + value.channels[1:]) + validate_meter_configuration(value, topology()) + object.__setattr__(value, "channels", (replace(disabled, role=CircuitRole.BRANCH),) + value.channels[1:]) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + object.__setattr__(value, "channels", (replace(value.channels[0], enabled=True, role=CircuitRole.UNUSED),) + value.channels[1:]) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + + +def _with_aggregate(value: MeterConfigurationRequest, aggregate: CircuitAggregate) -> MeterConfigurationRequest: + object.__setattr__(value, "aggregates", (aggregate,)) + return value + + +@pytest.mark.parametrize("method,channels", [ + (MeasurementMethod.TWO_CT_SUM, (1,)), + (MeasurementMethod.TWO_CT_SUM, (1, 2, 3)), + (MeasurementMethod.ONE_CT_DOUBLE_POWER, (1, 2)), + (MeasurementMethod.BOTH_CONDUCTORS_ONE_CT, (1, 2)), +]) +def test_special_aggregate_cardinalities(method: MeasurementMethod, channels: tuple[int, ...]) -> None: + value = request() + aggregate = CircuitAggregate("grid", "Grid", CircuitRole.GRID, channels, method, None, EnergyMode.CONSUMPTION) + with pytest.raises(ValueError): + validate_meter_configuration(_with_aggregate(value, aggregate), topology()) + + +def test_aggregate_invariants_and_direct_multi() -> None: + value = request() + valid = CircuitAggregate("grid", "Grid", CircuitRole.GRID, (1, 2), MeasurementMethod.DIRECT, None, EnergyMode.CONSUMPTION) + validate_meter_configuration(_with_aggregate(value, valid), topology()) + for bad_id in ("Grid", "grid_value", "grid--x", ""): + with pytest.raises(ValueError): + validate_meter_configuration(_with_aggregate(request(), replace(valid, aggregate_id=bad_id)), topology()) + with pytest.raises(ValueError): + validate_meter_configuration(_with_aggregate(request(), replace(valid, role="grid")), topology()) + with pytest.raises(ValueError): + validate_meter_configuration(_with_aggregate(request(), replace(valid, channels=(1, 1))), topology()) + with pytest.raises(ValueError): + validate_meter_configuration(_with_aggregate(request(), replace(valid, channels=(99,))), topology()) + disabled = replace(request().channels[0], enabled=False, role=CircuitRole.UNUSED) + broken = request() + object.__setattr__(broken, "channels", (disabled,) + broken.channels[1:]) + with pytest.raises(ValueError): + validate_meter_configuration(_with_aggregate(broken, replace(valid, channels=(1,))), topology()) + duplicate = request() + object.__setattr__(duplicate, "aggregates", (valid, replace(valid, name="Other"))) + with pytest.raises(ValueError): + validate_meter_configuration(duplicate, topology()) + + +@pytest.mark.parametrize("method,channels", [ + (MeasurementMethod.TWO_CT_SUM, (1, 2)), + (MeasurementMethod.ONE_CT_DOUBLE_POWER, (1,)), + (MeasurementMethod.BOTH_CONDUCTORS_ONE_CT, (1,)), +]) +def test_special_aggregate_cardinalities_accept_valid_enabled_channels( + method: MeasurementMethod, channels: tuple[int, ...] +) -> None: + aggregate = CircuitAggregate("grid", "Grid", CircuitRole.GRID, channels, method, None, EnergyMode.CONSUMPTION) + validate_meter_configuration(_with_aggregate(request(), aggregate), topology()) + + +def test_parent_existence_and_cycles() -> None: + child = CircuitAggregate("child", "Child", CircuitRole.BRANCH, (1,), MeasurementMethod.DIRECT, "missing", EnergyMode.CONSUMPTION) + with pytest.raises(ValueError): + validate_meter_configuration(_with_aggregate(request(), child), topology()) + cycle = replace(child, parent_id="child") + with pytest.raises(ValueError): + validate_meter_configuration(_with_aggregate(request(), cycle), topology()) + + +def test_board_options_and_multi_reference_acknowledgement() -> None: + value = request(addons=1) + for field in ("power_quality", "status_fields"): + object.__setattr__(value, field, (True,)) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology(1)) + object.__setattr__(value, field, (True, "yes")) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology(1)) + object.__setattr__(value, field, (False, False)) + value = request() + first = value.meter.voltage_references[0] + refs = (replace(first, group_keys=("main_1",)), replace(first, reference_id="alt", group_keys=("main_2",))) + object.__setattr__(value.meter, "voltage_references", refs) + object.__setattr__(value, "multi_reference_preparation_acknowledged", False) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + object.__setattr__(value, "multi_reference_preparation_acknowledged", True) + validate_meter_configuration(value, topology()) + object.__setattr__(value, "multi_reference_preparation_acknowledged", 1) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + + +@pytest.mark.parametrize("field,value", [ + ("friendly_name", "x" * 65), + ("friendly_name", "x\x00"), + ("nominal_voltage_v", math.nan), + ("nominal_voltage_v", 0), + ("nominal_voltage_v", 601), + ("gain_voltage", True), + ("gain_voltage", 0), + ("gain_voltage", 65536), +]) +def test_numeric_name_and_control_bounds(field: str, value: object) -> None: + request_value = request() + if field == "friendly_name": + object.__setattr__(request_value.meter, field, value) + else: + reference = replace(request_value.meter.voltage_references[0], **{field: value}) + object.__setattr__(request_value.meter, "voltage_references", (reference,)) + with pytest.raises(ValueError): + validate_meter_configuration(request_value, topology()) + + +@pytest.mark.parametrize("multiplier", [math.nan, 0, 3, True]) +def test_reporting_multiplier_must_be_finite_and_allowed(multiplier: object) -> None: + value = request() + object.__setattr__(value, "channels", (replace(value.channels[0], reporting_multiplier=multiplier),) + value.channels[1:]) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) + + +@pytest.mark.parametrize("gain", [0, 65536, True]) +def test_custom_gain_bounds(gain: object) -> None: + value = request() + object.__setattr__(value, "channels", (replace(value.channels[0], custom_gain_ct=gain),) + value.channels[1:]) + with pytest.raises(ValueError): + validate_meter_configuration(value, topology()) From 00d2366bb272e56a02fdf68c04f78f823cb0032d Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 01:37:39 -0400 Subject: [PATCH 07/35] fix: reject non-integer meter timing values --- .../circuitsetup_energy_meter_helper/meter_configuration.py | 4 ++-- tests/test_meter_configuration.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py index ba5c924..7d65aee 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py @@ -143,9 +143,9 @@ def validate_meter_configuration( _text(meter.friendly_name, "friendly_name") if not isinstance(meter.electrical_system, ElectricalSystem): raise ValueError("invalid electrical_system") # noqa: TRY004 - if meter.line_frequency_hz not in (50, 60) or type(meter.line_frequency_hz) is bool: + if type(meter.line_frequency_hz) is not int or meter.line_frequency_hz not in (50, 60): raise ValueError("line_frequency_hz must be 50 or 60") - if meter.update_interval_s not in (1, 2, 5, 10, 30, 60) or type(meter.update_interval_s) is bool: + if type(meter.update_interval_s) is not int or meter.update_interval_s not in (1, 2, 5, 10, 30, 60): raise ValueError("invalid update_interval_s") if not isinstance(meter.voltage_layout, VoltageLayout): raise ValueError("invalid voltage_layout") # noqa: TRY004 diff --git a/tests/test_meter_configuration.py b/tests/test_meter_configuration.py index acc7457..6bfbb5c 100644 --- a/tests/test_meter_configuration.py +++ b/tests/test_meter_configuration.py @@ -110,7 +110,7 @@ def test_numeric_and_forbidden_fields_are_rejected() -> None: assert math.isfinite(1.0) -@pytest.mark.parametrize("interval", [0, 3, 4, 6, 15, 61, True, False]) +@pytest.mark.parametrize("interval", [0, 3, 4, 6, 15, 61, 1.0, 5.0, True, False]) def test_invalid_update_intervals(interval: object) -> None: value = request() object.__setattr__(value.meter, "update_interval_s", interval) @@ -118,7 +118,7 @@ def test_invalid_update_intervals(interval: object) -> None: validate_meter_configuration(value, topology()) -@pytest.mark.parametrize("frequency", [0, 49, 51, 61, True, False]) +@pytest.mark.parametrize("frequency", [0, 49, 51, 61, 50.0, 60.0, True, False]) def test_invalid_line_frequencies(frequency: object) -> None: value = request() object.__setattr__(value.meter, "line_frequency_hz", frequency) From bfa281949d58cde934eb4567417ebd2a9ee5044e Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 01:44:09 -0400 Subject: [PATCH 08/35] feat: add voltage transformer presets --- .../data/voltage_transformers.json | 15 +++ .../voltage_transformer_catalog.py | 105 +++++++++++++++ tests/test_voltage_transformer_catalog.py | 126 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 custom_components/circuitsetup_energy_meter_helper/data/voltage_transformers.json create mode 100644 custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py create mode 100644 tests/test_voltage_transformer_catalog.py diff --git a/custom_components/circuitsetup_energy_meter_helper/data/voltage_transformers.json b/custom_components/circuitsetup_energy_meter_helper/data/voltage_transformers.json new file mode 100644 index 0000000..11f3346 --- /dev/null +++ b/custom_components/circuitsetup_energy_meter_helper/data/voltage_transformers.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "source_repository": "CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter", + "source_ref": "b94637a4f084a3a4a35e3e5f48eb1586bbd972c3", + "presets": [ + { + "model_id": "jameco_reliapro_9vac_120v", + "label": "Jameco Reliapro 120 V to 9 VAC", + "primary_nominal_v": 120.0, + "secondary_nominal_v": 9.0, + "default_gain_voltage": 7305, + "notes": "Official CircuitSetup starting value; calibrate for best accuracy." + } + ] +} diff --git a/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py b/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py new file mode 100644 index 0000000..d9e72bb --- /dev/null +++ b/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py @@ -0,0 +1,105 @@ +"""Versioned CircuitSetup voltage-transformer preset catalog.""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from functools import cache +from importlib import resources +from typing import Any + +CATALOG_SOURCE_REPOSITORY = "CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter" +CATALOG_SOURCE_REF = "b94637a4f084a3a4a35e3e5f48eb1586bbd972c3" +CATALOG_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True, slots=True) +class VoltageTransformerPreset: + """One voltage-transformer preset.""" + + model_id: str + label: str + primary_nominal_v: float + secondary_nominal_v: float + default_gain_voltage: int + notes: str + + +def _safe_text(value: object, field: str) -> str: + if not isinstance(value, str) or not value or any( + ord(character) < 0x20 or ord(character) == 0x7F for character in value + ): + raise ValueError(f"{field} must be safe text") + return value + + +def _positive_voltage(value: object, field: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{field} must be finite and positive") # noqa: TRY004 + result = float(value) + if not math.isfinite(result) or result <= 0: + raise ValueError(f"{field} must be finite and positive") + return result + + +def _gain(value: object) -> int: + if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 65535: + raise ValueError("gain must be an ATM90E32 uint16 value") + return value + + +def custom(label: str, gain: object = None) -> VoltageTransformerPreset: + """Construct the explicit, non-catalog custom option.""" + return VoltageTransformerPreset( + "custom", _safe_text(label, "label"), 0.0, 0.0, _gain(gain), "" + ) + + +@dataclass(frozen=True, slots=True) +class VoltageTransformerCatalog: + """The package-bundled schema-v1 catalog, indexed by model.""" + + presets: tuple[VoltageTransformerPreset, ...] + source_repository: str = CATALOG_SOURCE_REPOSITORY + source_ref: str = CATALOG_SOURCE_REF + schema_version: int = CATALOG_SCHEMA_VERSION + + @classmethod + @cache + def load(cls) -> VoltageTransformerCatalog: + raw = ( + resources.files(__package__) + .joinpath("data", "voltage_transformers.json") + .read_text(encoding="utf-8") + ) + data: dict[str, Any] = json.loads(raw) + if data.get("schema_version") != CATALOG_SCHEMA_VERSION: + raise ValueError("unsupported voltage-transformer catalog schema") + if data.get("source_repository") != CATALOG_SOURCE_REPOSITORY or data.get( + "source_ref" + ) != CATALOG_SOURCE_REF: + raise ValueError("invalid voltage-transformer catalog source metadata") + presets = tuple( + VoltageTransformerPreset( + _safe_text(entry.get("model_id"), "model_id"), + _safe_text(entry.get("label"), "label"), + _positive_voltage(entry.get("primary_nominal_v"), "primary_nominal_v"), + _positive_voltage( + entry.get("secondary_nominal_v"), "secondary_nominal_v" + ), + _gain(entry.get("default_gain_voltage")), + _safe_text(entry.get("notes"), "notes"), + ) + for entry in data.get("presets", ()) + ) + if len({preset.model_id for preset in presets}) != len(presets): + raise ValueError("duplicate voltage-transformer model ID") + return cls(presets) + + def by_model_id(self, model_id: str) -> VoltageTransformerPreset | None: + return next((preset for preset in self.presets if preset.model_id == model_id), None) + + def starting_gain(self, model_id: str) -> int | None: + preset = self.by_model_id(model_id) + return preset.default_gain_voltage if preset else None diff --git a/tests/test_voltage_transformer_catalog.py b/tests/test_voltage_transformer_catalog.py new file mode 100644 index 0000000..716dc3f --- /dev/null +++ b/tests/test_voltage_transformer_catalog.py @@ -0,0 +1,126 @@ +"""Tests for the bundled voltage-transformer preset catalog.""" + +from __future__ import annotations + +import json +from dataclasses import FrozenInstanceError + +import pytest + +from custom_components.circuitsetup_energy_meter_helper import ( + voltage_transformer_catalog as module, +) +from custom_components.circuitsetup_energy_meter_helper.voltage_transformer_catalog import ( + CATALOG_SCHEMA_VERSION, + CATALOG_SOURCE_REF, + CATALOG_SOURCE_REPOSITORY, + VoltageTransformerCatalog, + VoltageTransformerPreset, + custom, +) + + +def test_official_catalog_has_schema_metadata_and_starting_gain() -> None: + catalog = VoltageTransformerCatalog.load() + preset = catalog.by_model_id("jameco_reliapro_9vac_120v") + + assert CATALOG_SCHEMA_VERSION == 1 + assert CATALOG_SOURCE_REPOSITORY == "CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter" + assert CATALOG_SOURCE_REF == "b94637a4f084a3a4a35e3e5f48eb1586bbd972c3" + assert preset == VoltageTransformerPreset( + "jameco_reliapro_9vac_120v", + "Jameco Reliapro 120 V to 9 VAC", + 120.0, + 9.0, + 7305, + "Official CircuitSetup starting value; calibrate for best accuracy.", + ) + assert catalog.starting_gain("jameco_reliapro_9vac_120v") == 7305 + assert catalog.by_model_id("unknown") is None + + +def test_preset_is_immutable() -> None: + preset = VoltageTransformerCatalog.load().by_model_id("jameco_reliapro_9vac_120v") + assert preset is not None + with pytest.raises(FrozenInstanceError): + preset.model_id = "changed" # type: ignore[misc] + + +def _load_data(monkeypatch: pytest.MonkeyPatch, data: dict[str, object]) -> None: + class Resource: + def joinpath(self, *_parts: str) -> Resource: + return self + + def read_text(self, *, encoding: str) -> str: + assert encoding == "utf-8" + return json.dumps(data) + + monkeypatch.setattr(module.resources, "files", lambda _package: Resource()) + VoltageTransformerCatalog.load.cache_clear() + + +def _valid_data() -> dict[str, object]: + return { + "schema_version": 1, + "source_repository": CATALOG_SOURCE_REPOSITORY, + "source_ref": CATALOG_SOURCE_REF, + "presets": [ + { + "model_id": "valid", + "label": "Valid", + "primary_nominal_v": 120.0, + "secondary_nominal_v": 9.0, + "default_gain_voltage": 7305, + "notes": "Safe note.", + } + ], + } + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("primary_nominal_v", 0), + ("secondary_nominal_v", float("nan")), + ("default_gain_voltage", 0), + ("default_gain_voltage", 65536), + ("default_gain_voltage", True), + ("model_id", "bad\nvalue"), + ("label", "bad\x00value"), + ("notes", "bad\rvalue"), + ], +) +def test_catalog_rejects_invalid_preset_fields( + monkeypatch: pytest.MonkeyPatch, field: str, value: object +) -> None: + data = _valid_data() + preset = dict(data["presets"][0]) # type: ignore[index] + preset[field] = value + data["presets"] = [preset] + _load_data(monkeypatch, data) + + with pytest.raises(ValueError): + VoltageTransformerCatalog.load() + + +def test_catalog_rejects_schema_and_duplicate_ids(monkeypatch: pytest.MonkeyPatch) -> None: + data = _valid_data() + data["schema_version"] = 2 + _load_data(monkeypatch, data) + with pytest.raises(ValueError, match="schema"): + VoltageTransformerCatalog.load() + + data = _valid_data() + data["presets"] = [data["presets"][0], data["presets"][0]] # type: ignore[index] + _load_data(monkeypatch, data) + with pytest.raises(ValueError, match="duplicate"): + VoltageTransformerCatalog.load() + + +def test_custom_requires_explicit_valid_gain() -> None: + assert custom("Custom transformer", 123).default_gain_voltage == 123 + for gain in (None, 0, 65536, True, 1.5): + with pytest.raises(ValueError, match="gain"): + custom("Custom transformer", gain) # type: ignore[arg-type] + with pytest.raises(ValueError, match="label"): + custom("", 123) From 2b61f69c5eaf1ae06667c8163437a62c2633088f Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 01:50:26 -0400 Subject: [PATCH 09/35] fix: harden voltage transformer catalog validation --- .../voltage_transformer_catalog.py | 20 +++-- tests/test_voltage_transformer_catalog.py | 75 ++++++++++++++++++- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py b/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py index d9e72bb..43bf067 100644 --- a/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py +++ b/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py @@ -4,6 +4,7 @@ import json import math +import unicodedata from dataclasses import dataclass from functools import cache from importlib import resources @@ -27,8 +28,10 @@ class VoltageTransformerPreset: def _safe_text(value: object, field: str) -> str: - if not isinstance(value, str) or not value or any( - ord(character) < 0x20 or ord(character) == 0x7F for character in value + if ( + type(value) is not str + or not value.strip() + or any(unicodedata.category(character) == "Cc" for character in value) ): raise ValueError(f"{field} must be safe text") return value @@ -44,7 +47,7 @@ def _positive_voltage(value: object, field: str) -> float: def _gain(value: object) -> int: - if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 65535: + if type(value) is not int or not 1 <= value <= 65535: raise ValueError("gain must be an ATM90E32 uint16 value") return value @@ -73,13 +76,20 @@ def load(cls) -> VoltageTransformerCatalog: .joinpath("data", "voltage_transformers.json") .read_text(encoding="utf-8") ) - data: dict[str, Any] = json.loads(raw) - if data.get("schema_version") != CATALOG_SCHEMA_VERSION: + data: Any = json.loads(raw) + if type(data) is not dict: + raise ValueError("invalid voltage-transformer catalog data") + if type(data.get("schema_version")) is not int or data.get( + "schema_version" + ) != CATALOG_SCHEMA_VERSION: raise ValueError("unsupported voltage-transformer catalog schema") if data.get("source_repository") != CATALOG_SOURCE_REPOSITORY or data.get( "source_ref" ) != CATALOG_SOURCE_REF: raise ValueError("invalid voltage-transformer catalog source metadata") + rows = data.get("presets") + if type(rows) is not list or not rows or any(type(entry) is not dict for entry in rows): + raise ValueError("invalid voltage-transformer presets") presets = tuple( VoltageTransformerPreset( _safe_text(entry.get("model_id"), "model_id"), diff --git a/tests/test_voltage_transformer_catalog.py b/tests/test_voltage_transformer_catalog.py index 716dc3f..b78288e 100644 --- a/tests/test_voltage_transformer_catalog.py +++ b/tests/test_voltage_transformer_catalog.py @@ -4,6 +4,7 @@ import json from dataclasses import FrozenInstanceError +from enum import IntEnum import pytest @@ -46,7 +47,7 @@ def test_preset_is_immutable() -> None: preset.model_id = "changed" # type: ignore[misc] -def _load_data(monkeypatch: pytest.MonkeyPatch, data: dict[str, object]) -> None: +def _load_data(monkeypatch: pytest.MonkeyPatch, data: object) -> None: class Resource: def joinpath(self, *_parts: str) -> Resource: return self @@ -117,10 +118,78 @@ def test_catalog_rejects_schema_and_duplicate_ids(monkeypatch: pytest.MonkeyPatc VoltageTransformerCatalog.load() +@pytest.mark.parametrize( + "data", + [ + [], + {"schema_version": 1, "presets": None}, + {"schema_version": 1, "presets": {}}, + {"schema_version": 1, "presets": []}, + {"schema_version": 1, "presets": [None]}, + ], +) +def test_catalog_rejects_malformed_top_level_and_rows( + monkeypatch: pytest.MonkeyPatch, data: object +) -> None: + _load_data(monkeypatch, data) + with pytest.raises(ValueError): + VoltageTransformerCatalog.load() + + +@pytest.mark.parametrize("schema_version", [True, 1.0, "1"]) +def test_catalog_requires_exact_schema_integer( + monkeypatch: pytest.MonkeyPatch, schema_version: object +) -> None: + data = _valid_data() + data["schema_version"] = schema_version + _load_data(monkeypatch, data) + with pytest.raises(ValueError, match="schema"): + VoltageTransformerCatalog.load() + + +class GainIntEnum(IntEnum): + VALID = 7305 + + +def test_catalog_rejects_int_subclass_gain_and_control_categories( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(ValueError, match="gain"): + module._gain(GainIntEnum.VALID) + + for field, value in ( + ("model_id", " "), + ("label", "\u0085"), + ("notes", "note\u0085"), + ): + data = _valid_data() + preset = dict(data["presets"][0]) # type: ignore[index] + preset[field] = value + data["presets"] = [preset] + _load_data(monkeypatch, data) + with pytest.raises(ValueError): + VoltageTransformerCatalog.load() + + +def test_catalog_rejects_source_metadata_mutations(monkeypatch: pytest.MonkeyPatch) -> None: + for field in ("source_repository", "source_ref"): + data = _valid_data() + data[field] = "unexpected" + _load_data(monkeypatch, data) + with pytest.raises(ValueError, match="source"): + VoltageTransformerCatalog.load() + + def test_custom_requires_explicit_valid_gain() -> None: assert custom("Custom transformer", 123).default_gain_voltage == 123 - for gain in (None, 0, 65536, True, 1.5): + for gain in (None, 0, 65536, True, 1.5, GainIntEnum.VALID): with pytest.raises(ValueError, match="gain"): - custom("Custom transformer", gain) # type: ignore[arg-type] + custom("Custom transformer", gain) with pytest.raises(ValueError, match="label"): custom("", 123) + with pytest.raises(ValueError, match="label"): + custom(" ", 123) + with pytest.raises(ValueError, match="label"): + custom("\u0085", 123) + with pytest.raises(ValueError, match="gain"): + custom("Custom transformer") From f6dd298d2fbbf49afc96f597ff064b936ebdc792 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 01:54:28 -0400 Subject: [PATCH 10/35] fix: enforce voltage transformer catalog schema --- .../voltage_transformer_catalog.py | 17 +++++++-- tests/test_voltage_transformer_catalog.py | 36 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py b/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py index 43bf067..dae3ac9 100644 --- a/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py +++ b/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py @@ -13,6 +13,15 @@ CATALOG_SOURCE_REPOSITORY = "CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter" CATALOG_SOURCE_REF = "b94637a4f084a3a4a35e3e5f48eb1586bbd972c3" CATALOG_SCHEMA_VERSION = 1 +_CATALOG_KEYS = {"schema_version", "source_repository", "source_ref", "presets"} +_PRESET_KEYS = { + "model_id", + "label", + "primary_nominal_v", + "secondary_nominal_v", + "default_gain_voltage", + "notes", +} @dataclass(frozen=True, slots=True) @@ -79,6 +88,8 @@ def load(cls) -> VoltageTransformerCatalog: data: Any = json.loads(raw) if type(data) is not dict: raise ValueError("invalid voltage-transformer catalog data") + if set(data) != _CATALOG_KEYS: + raise ValueError("invalid voltage-transformer catalog keys") if type(data.get("schema_version")) is not int or data.get( "schema_version" ) != CATALOG_SCHEMA_VERSION: @@ -88,8 +99,10 @@ def load(cls) -> VoltageTransformerCatalog: ) != CATALOG_SOURCE_REF: raise ValueError("invalid voltage-transformer catalog source metadata") rows = data.get("presets") - if type(rows) is not list or not rows or any(type(entry) is not dict for entry in rows): - raise ValueError("invalid voltage-transformer presets") + if type(rows) is not list or not rows or any( + type(entry) is not dict or set(entry) != _PRESET_KEYS for entry in rows + ): + raise ValueError("invalid voltage-transformer preset keys") presets = tuple( VoltageTransformerPreset( _safe_text(entry.get("model_id"), "model_id"), diff --git a/tests/test_voltage_transformer_catalog.py b/tests/test_voltage_transformer_catalog.py index b78288e..de3ad67 100644 --- a/tests/test_voltage_transformer_catalog.py +++ b/tests/test_voltage_transformer_catalog.py @@ -180,6 +180,42 @@ def test_catalog_rejects_source_metadata_mutations(monkeypatch: pytest.MonkeyPat VoltageTransformerCatalog.load() +@pytest.mark.parametrize( + "mutation", + [ + lambda data: data.update(extra=True), + lambda data: data.pop("source_ref"), + ], +) +def test_catalog_rejects_top_level_key_drift( + monkeypatch: pytest.MonkeyPatch, mutation: object +) -> None: + data = _valid_data() + mutation(data) # type: ignore[operator] + _load_data(monkeypatch, data) + with pytest.raises(ValueError, match="keys"): + VoltageTransformerCatalog.load() + + +@pytest.mark.parametrize( + "mutation", + [ + lambda preset: preset.update(extra=True), + lambda preset: preset.pop("notes"), + ], +) +def test_catalog_rejects_preset_key_drift( + monkeypatch: pytest.MonkeyPatch, mutation: object +) -> None: + data = _valid_data() + preset = dict(data["presets"][0]) # type: ignore[index] + mutation(preset) # type: ignore[operator] + data["presets"] = [preset] + _load_data(monkeypatch, data) + with pytest.raises(ValueError, match="keys"): + VoltageTransformerCatalog.load() + + def test_custom_requires_explicit_valid_gain() -> None: assert custom("Custom transformer", 123).default_gain_voltage == 123 for gain in (None, 0, 65536, True, 1.5, GainIntEnum.VALID): From ce6e1a1cd4eb26fd26569a33c88d45db0bf5e118 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 01:57:23 -0400 Subject: [PATCH 11/35] fix: normalize voltage overflow validation --- .../voltage_transformer_catalog.py | 5 ++++- tests/test_voltage_transformer_catalog.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py b/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py index dae3ac9..ae50ffb 100644 --- a/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py +++ b/custom_components/circuitsetup_energy_meter_helper/voltage_transformer_catalog.py @@ -49,7 +49,10 @@ def _safe_text(value: object, field: str) -> str: def _positive_voltage(value: object, field: str) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{field} must be finite and positive") # noqa: TRY004 - result = float(value) + try: + result = float(value) + except OverflowError as error: + raise ValueError(f"{field} must be finite and positive") from error if not math.isfinite(result) or result <= 0: raise ValueError(f"{field} must be finite and positive") return result diff --git a/tests/test_voltage_transformer_catalog.py b/tests/test_voltage_transformer_catalog.py index de3ad67..7922c97 100644 --- a/tests/test_voltage_transformer_catalog.py +++ b/tests/test_voltage_transformer_catalog.py @@ -171,6 +171,19 @@ def test_catalog_rejects_int_subclass_gain_and_control_categories( VoltageTransformerCatalog.load() +@pytest.mark.parametrize("field", ["primary_nominal_v", "secondary_nominal_v"]) +def test_catalog_rejects_voltage_integer_overflow( + monkeypatch: pytest.MonkeyPatch, field: str +) -> None: + data = _valid_data() + preset = dict(data["presets"][0]) # type: ignore[index] + preset[field] = 10**1000 + data["presets"] = [preset] + _load_data(monkeypatch, data) + with pytest.raises(ValueError, match="finite and positive"): + VoltageTransformerCatalog.load() + + def test_catalog_rejects_source_metadata_mutations(monkeypatch: pytest.MonkeyPatch) -> None: for field in ("source_repository", "source_ref"): data = _valid_data() From c97b831b1d41f77428b76b82426f7cf2a6a4f23a Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 02:03:31 -0400 Subject: [PATCH 12/35] feat: detect meter configuration capabilities --- .../device_builder.py | 17 ++++++ .../meter_inventory.py | 34 +++++++++++ tests/test_device_builder.py | 52 ++++++++++++++++ tests/test_meter_inventory.py | 59 +++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 custom_components/circuitsetup_energy_meter_helper/meter_inventory.py create mode 100644 tests/test_meter_inventory.py diff --git a/custom_components/circuitsetup_energy_meter_helper/device_builder.py b/custom_components/circuitsetup_energy_meter_helper/device_builder.py index d553323..7f3ec55 100644 --- a/custom_components/circuitsetup_energy_meter_helper/device_builder.py +++ b/custom_components/circuitsetup_energy_meter_helper/device_builder.py @@ -12,6 +12,8 @@ from typing import Any, Protocol from urllib.parse import urlsplit +from awesomeversion import AwesomeVersion + async def _wait_for_owned_cleanup[T](task: asyncio.Task[T]) -> bool: """Finish owned cleanup before reporting repeated caller cancellation.""" @@ -128,6 +130,7 @@ def __init__( self._stream_futures: dict[str, asyncio.Future[dict[str, Any]]] = {} self._next_message_id = 0 self._disconnect_task: asyncio.Task[None] | None = None + self._server_version: AwesomeVersion | None = None def __repr__(self) -> str: parsed = urlsplit(self._base_url) @@ -146,6 +149,11 @@ def connected(self) -> bool: """Return whether the authoritative transport is currently attached.""" return self._ws is not None + @property + def server_version(self) -> AwesomeVersion | None: + """Return the last successfully parsed Device Builder version.""" + return self._server_version + async def async_connect(self) -> None: """Connect to `/ws` and perform opaque-token auth only if requested.""" connection = self._connect(f"{self._base_url}/ws") @@ -153,6 +161,15 @@ async def async_connect(self) -> None: server_info = await self._ws.receive_json() if not server_info or "server_version" not in server_info: raise ConnectionError("Device Builder did not provide server info") + try: + server_version = AwesomeVersion(server_info["server_version"]) + except (TypeError, ValueError) as error: + raise ConnectionError( + "Device Builder returned an invalid server version" + ) from error + if not server_version.valid: + raise ConnectionError("Device Builder returned an invalid server version") + self._server_version = server_version self._listener = asyncio.create_task(self._listen()) if server_info.get("requires_auth") is not False: if not self._token: diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py new file mode 100644 index 0000000..3d14e6e --- /dev/null +++ b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py @@ -0,0 +1,34 @@ +"""Firmware-backed meter configuration capabilities.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class MeterConfigurationCapabilities: + """The configuration writes supported by an authoritative firmware contract.""" + + configuration_authoritative: bool + managed_totals: bool + multi_reference: bool + reason_codes: tuple[str, ...] + + +def meter_configuration_capabilities( + *, configuration_authoritative: bool, config_contract: int | None +) -> MeterConfigurationCapabilities: + """Derive safe configuration capabilities from authoritative metadata.""" + if type(configuration_authoritative) is not bool: + raise TypeError("configuration_authoritative must be a bool") + if config_contract is not None and type(config_contract) is not int: + raise TypeError("config_contract must be an int or None") + if not configuration_authoritative: + return MeterConfigurationCapabilities( + False, False, False, ("configuration_not_authoritative",) + ) + if config_contract == 2: + return MeterConfigurationCapabilities(True, True, True, ()) + return MeterConfigurationCapabilities( + True, False, True, ("config_contract_upgrade_required",) + ) diff --git a/tests/test_device_builder.py b/tests/test_device_builder.py index ebbeb9a..e436139 100644 --- a/tests/test_device_builder.py +++ b/tests/test_device_builder.py @@ -4,6 +4,7 @@ from hashlib import sha256 import pytest +from awesomeversion import AwesomeVersion from custom_components.circuitsetup_energy_meter_helper.device_builder import ( ConfigChangedError, @@ -63,6 +64,57 @@ async def run() -> None: asyncio.run(run()) +def test_server_version_is_parsed_and_replaced_on_reconnect() -> None: + async def run() -> None: + websockets = [ + FakeWebSocket({"server_version": "2026.9.0", "requires_auth": False}), + FakeWebSocket({"server_version": "2026.10.1", "requires_auth": False}), + ] + client = DeviceBuilderClient( + "http://builder", connect=lambda _: websockets.pop(0) + ) + await client.async_connect() + assert client.server_version == AwesomeVersion("2026.9.0") + await client.async_disconnect() + await client.async_connect() + assert client.server_version == AwesomeVersion("2026.10.1") + await client.async_disconnect() + + asyncio.run(run()) + + +@pytest.mark.parametrize("server_info", ({}, {"server_version": "not-a-version"})) +def test_malformed_or_missing_server_version_uses_connection_error( + server_info: dict[str, object], +) -> None: + async def run() -> None: + client = DeviceBuilderClient( + "http://builder", connect=lambda _: FakeWebSocket(server_info) + ) + with pytest.raises(ConnectionError): + await client.async_connect() + + asyncio.run(run()) + + +def test_failed_reconnect_preserves_last_observed_server_version() -> None: + async def run() -> None: + websockets = [ + FakeWebSocket({"server_version": "2026.9.0", "requires_auth": False}), + FakeWebSocket({"server_version": "not-a-version", "requires_auth": False}), + ] + client = DeviceBuilderClient( + "http://builder", connect=lambda _: websockets.pop(0) + ) + await client.async_connect() + await client.async_disconnect() + with pytest.raises(ConnectionError): + await client.async_connect() + assert client.server_version == AwesomeVersion("2026.9.0") + + asyncio.run(run()) + + def test_missing_auth_flag_requires_opaque_token() -> None: """Only an explicit false ServerInfo flag permits trusted ingress.""" diff --git a/tests/test_meter_inventory.py b/tests/test_meter_inventory.py new file mode 100644 index 0000000..abc6feb --- /dev/null +++ b/tests/test_meter_inventory.py @@ -0,0 +1,59 @@ +"""Tests for firmware configuration capability discovery.""" + +from dataclasses import fields + +import pytest + +from custom_components.circuitsetup_energy_meter_helper.meter_inventory import ( + MeterConfigurationCapabilities, + meter_configuration_capabilities, +) + + +def test_capability_model_has_exact_frozen_slots_contract() -> None: + assert tuple(field.name for field in fields(MeterConfigurationCapabilities)) == ( + "configuration_authoritative", + "managed_totals", + "multi_reference", + "reason_codes", + ) + assert hasattr(MeterConfigurationCapabilities, "__slots__") + assert not hasattr( + MeterConfigurationCapabilities(True, True, True, ()), "__dict__" + ) + assert not hasattr(MeterConfigurationCapabilities, "status_thresholds") + + +@pytest.mark.parametrize( + ("authoritative", "contract", "expected"), + ( + (False, 2, (False, False, ("configuration_not_authoritative",))), + (False, None, (False, False, ("configuration_not_authoritative",))), + (True, 2, (True, True, ())), + (True, 1, (False, True, ("config_contract_upgrade_required",))), + (True, None, (False, True, ("config_contract_upgrade_required",))), + ), +) +def test_capabilities_follow_contract_truth_table( + authoritative: bool, + contract: int | None, + expected: tuple[bool, bool, tuple[str, ...]], +) -> None: + value = meter_configuration_capabilities( + configuration_authoritative=authoritative, config_contract=contract + ) + assert (value.managed_totals, value.multi_reference, value.reason_codes) == expected + assert value.configuration_authoritative is authoritative + + +@pytest.mark.parametrize( + ("authoritative", "contract"), + ((1, 2), (True, True), (False, 2.0), (False, "2"), ("true", None)), +) +def test_capability_inputs_require_exact_bool_and_int_types( + authoritative: object, contract: object +) -> None: + with pytest.raises(TypeError): + meter_configuration_capabilities( + configuration_authoritative=authoritative, config_contract=contract + ) From f002cad6fa20da742503db76ee9f33ff2a74d28e Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 02:11:23 -0400 Subject: [PATCH 13/35] fix: make device builder connection state atomic --- .../device_builder.py | 89 ++++++++++++------- tests/test_device_builder.py | 56 +++++++++++- 2 files changed, 113 insertions(+), 32 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/device_builder.py b/custom_components/circuitsetup_energy_meter_helper/device_builder.py index 7f3ec55..8153a5d 100644 --- a/custom_components/circuitsetup_energy_meter_helper/device_builder.py +++ b/custom_components/circuitsetup_energy_meter_helper/device_builder.py @@ -5,7 +5,7 @@ import asyncio import inspect import re -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass from enum import StrEnum from hashlib import sha256 @@ -131,6 +131,7 @@ def __init__( self._next_message_id = 0 self._disconnect_task: asyncio.Task[None] | None = None self._server_version: AwesomeVersion | None = None + self._connect_lock = asyncio.Lock() def __repr__(self) -> str: parsed = urlsplit(self._base_url) @@ -156,33 +157,59 @@ def server_version(self) -> AwesomeVersion | None: async def async_connect(self) -> None: """Connect to `/ws` and perform opaque-token auth only if requested.""" + async with self._connect_lock: + if self._ws is not None or self._listener is not None: + raise ConnectionError("Device Builder is already connected") + await self._async_connect() + + async def _async_connect(self) -> None: connection = self._connect(f"{self._base_url}/ws") - self._ws = await connection if inspect.isawaitable(connection) else connection - server_info = await self._ws.receive_json() - if not server_info or "server_version" not in server_info: - raise ConnectionError("Device Builder did not provide server info") + websocket = await connection if inspect.isawaitable(connection) else connection + listener: asyncio.Task[None] | None = None try: - server_version = AwesomeVersion(server_info["server_version"]) - except (TypeError, ValueError) as error: - raise ConnectionError( - "Device Builder returned an invalid server version" - ) from error - if not server_version.valid: - raise ConnectionError("Device Builder returned an invalid server version") - self._server_version = server_version - self._listener = asyncio.create_task(self._listen()) - if server_info.get("requires_auth") is not False: - if not self._token: - raise ConnectionError("Device Builder requires an issued bearer token") - future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() - self._pending["0"] = future - await self._ws.send_json( - {"command": "auth", "message_id": "0", "args": {"token": self._token}} - ) + server_info = await websocket.receive_json() + if not isinstance(server_info, Mapping) or "server_version" not in server_info: + raise ConnectionError("Device Builder did not provide server info") + version_value = server_info["server_version"] + if type(version_value) is not str: + raise ConnectionError("Device Builder returned an invalid server version") + try: + server_version = AwesomeVersion(version_value) + except (TypeError, ValueError) as error: + raise ConnectionError( + "Device Builder returned an invalid server version" + ) from error + if not server_version.valid: + raise ConnectionError("Device Builder returned an invalid server version") + listener = asyncio.create_task(self._listen(websocket)) + self._ws = websocket + self._listener = listener + if server_info.get("requires_auth") is not False: + if not self._token: + raise ConnectionError("Device Builder requires an issued bearer token") + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._pending["0"] = future + await websocket.send_json( + {"command": "auth", "message_id": "0", "args": {"token": self._token}} + ) + try: + await future + finally: + self._pending.pop("0", None) + self._server_version = server_version + except BaseException: + if self._ws is websocket: + self._ws = None + self._listener = None + self._fail_pending() + if listener is not None and not listener.done(): + listener.cancel() + await asyncio.gather(listener, return_exceptions=True) try: - await future - finally: - self._pending.pop("0", None) + await websocket.close() + except Exception: + pass + raise async def async_disconnect(self) -> None: """Close the websocket and fail all outstanding callers.""" @@ -400,10 +427,9 @@ def _validation_result( warning_count=_structured_count(result.get("warning_count")), ) - async def _listen(self) -> None: - assert self._ws is not None + async def _listen(self, websocket: WebSocket) -> None: try: - while message := await self._ws.receive_json(): + while message := await websocket.receive_json(): message_id = message.get("message_id") if ( isinstance(message_id, str) @@ -434,8 +460,11 @@ async def _listen(self) -> None: elif "result" in message: future.set_result(message["result"]) finally: - self._ws = None - self._fail_pending() + if self._ws is websocket: + self._ws = None + if self._listener is asyncio.current_task(): + self._listener = None + self._fail_pending() def _fail_pending(self) -> None: for future in self._pending.values(): diff --git a/tests/test_device_builder.py b/tests/test_device_builder.py index e436139..562e44b 100644 --- a/tests/test_device_builder.py +++ b/tests/test_device_builder.py @@ -19,11 +19,12 @@ class FakeWebSocket: """Small in-memory websocket used to drive the protocol.""" - def __init__(self, server_info: dict) -> None: + def __init__(self, server_info: object) -> None: self.sent: list[dict] = [] + self.closed = False self._received: asyncio.Queue[dict | None] = asyncio.Queue() self._received.put_nowait(server_info) - if server_info.get("requires_auth"): + if isinstance(server_info, dict) and server_info.get("requires_auth"): self._received.put_nowait({"message_id": "0", "result": {}}) async def send_json(self, message: dict) -> None: @@ -41,6 +42,7 @@ async def send_event(self, message_id: str, event: str, data: object) -> None: ) async def close(self) -> None: + self.closed = True await self._received.put(None) @@ -115,6 +117,56 @@ async def run() -> None: asyncio.run(run()) +def test_failed_auth_does_not_publish_version_and_closes_failed_transport() -> None: + async def run() -> None: + ws = FakeWebSocket({"server_version": "2026.9.0", "requires_auth": True}) + client = DeviceBuilderClient( + "http://builder", token=None, connect=lambda _: ws + ) + with pytest.raises(ConnectionError): + await client.async_connect() + assert client.server_version is None + assert not client.connected + assert ws.closed + + asyncio.run(run()) + + +@pytest.mark.parametrize( + "server_info", + (None, [], {"server_version": 2026}, {"server_version": True}, + {"server_version": None}, {"server_version": []}), +) +def test_server_info_boundary_rejects_non_mapping_or_non_string_version( + server_info: object, +) -> None: + async def run() -> None: + ws = FakeWebSocket(server_info) # type: ignore[arg-type] + client = DeviceBuilderClient("http://builder", connect=lambda _: ws) + with pytest.raises(ConnectionError): + await client.async_connect() + assert not client.connected + assert ws.closed + + asyncio.run(run()) + + +def test_overlapping_connect_is_rejected_without_disturbing_owner() -> None: + async def run() -> None: + first = FakeWebSocket({"server_version": "2026.9.0", "requires_auth": False}) + second = FakeWebSocket({"server_version": "2026.10.0", "requires_auth": False}) + sockets = iter((first, second)) + client = DeviceBuilderClient("http://builder", connect=lambda _: next(sockets)) + await client.async_connect() + with pytest.raises(ConnectionError, match="already connected"): + await client.async_connect() + assert client.server_version == AwesomeVersion("2026.9.0") + assert not second.closed + await client.async_disconnect() + + asyncio.run(run()) + + def test_missing_auth_flag_requires_opaque_token() -> None: """Only an explicit false ServerInfo flag permits trusted ingress.""" From be3430f3187139165d54c4d4f6bf9257982f01e5 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 02:16:55 -0400 Subject: [PATCH 14/35] fix: protect device builder readiness cleanup --- .../device_builder.py | 50 +++++++++----- tests/test_device_builder.py | 68 +++++++++++++++++++ 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/device_builder.py b/custom_components/circuitsetup_energy_meter_helper/device_builder.py index 8153a5d..9d02f03 100644 --- a/custom_components/circuitsetup_energy_meter_helper/device_builder.py +++ b/custom_components/circuitsetup_energy_meter_helper/device_builder.py @@ -132,6 +132,7 @@ def __init__( self._disconnect_task: asyncio.Task[None] | None = None self._server_version: AwesomeVersion | None = None self._connect_lock = asyncio.Lock() + self._ready = False def __repr__(self) -> str: parsed = urlsplit(self._base_url) @@ -143,12 +144,12 @@ def __repr__(self) -> str: origin += f":{parsed.port}" else: origin = "" - return f"DeviceBuilderClient(origin={origin!r}, connected={self._ws is not None})" + return f"DeviceBuilderClient(origin={origin!r}, connected={self.connected})" @property def connected(self) -> bool: """Return whether the authoritative transport is currently attached.""" - return self._ws is not None + return self._ready @property def server_version(self) -> AwesomeVersion | None: @@ -197,20 +198,30 @@ async def _async_connect(self) -> None: finally: self._pending.pop("0", None) self._server_version = server_version + self._ready = True except BaseException: - if self._ws is websocket: - self._ws = None - self._listener = None - self._fail_pending() - if listener is not None and not listener.done(): - listener.cancel() - await asyncio.gather(listener, return_exceptions=True) - try: - await websocket.close() - except Exception: - pass + cleanup = asyncio.create_task( + self._async_connect_cleanup(websocket, listener) + ) + caller_cancelled = await _wait_for_owned_cleanup(cleanup) + if caller_cancelled: + raise asyncio.CancelledError raise + async def _async_connect_cleanup( + self, websocket: WebSocket, listener: asyncio.Task[None] | None + ) -> None: + """Finish failed-connect ownership before publishing cancellation.""" + if self._ws is websocket: + self._ready = False + self._ws = None + self._listener = None + self._fail_pending() + if listener is not None and not listener.done(): + listener.cancel() + await asyncio.gather(listener, return_exceptions=True) + await websocket.close() + async def async_disconnect(self) -> None: """Close the websocket and fail all outstanding callers.""" task = self._disconnect_task @@ -233,6 +244,7 @@ async def _async_disconnect_owned(self) -> None: websocket = self._ws listener = self._listener if websocket is None: + self._ready = False if listener is not None and not listener.done(): listener.cancel() await asyncio.gather(listener, return_exceptions=True) @@ -246,18 +258,20 @@ async def _async_disconnect_owned(self) -> None: if self._listener is listener: self._listener = None if self._ws is websocket: + self._ready = False self._ws = None self._fail_pending() async def async_command(self, command: str, args: dict[str, Any]) -> Any: """Send one pinned protocol command and await its matching envelope.""" - if self._ws is None: + websocket = self._ws + if not self._ready or websocket is None: raise ConnectionError("Device Builder is disconnected") self._next_message_id += 1 message_id = str(self._next_message_id) future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() self._pending[message_id] = future - await self._ws.send_json( + await websocket.send_json( {"command": command, "message_id": message_id, "args": args} ) try: @@ -272,7 +286,8 @@ async def _async_stream_command( progress: Callable[[JobProgress], None] | None = None, ) -> tuple[dict[str, Any], tuple[str, ...]]: """Run a pinned streaming command until its `result` event.""" - if self._ws is None: + websocket = self._ws + if not self._ready or websocket is None: raise ConnectionError("Device Builder is disconnected") self._next_message_id += 1 message_id = str(self._next_message_id) @@ -294,7 +309,7 @@ def handle(event: dict[str, Any]) -> None: self._stream_handlers[message_id] = handle self._stream_futures[message_id] = future - await self._ws.send_json( + await websocket.send_json( {"command": command, "message_id": message_id, "args": args} ) try: @@ -461,6 +476,7 @@ async def _listen(self, websocket: WebSocket) -> None: future.set_result(message["result"]) finally: if self._ws is websocket: + self._ready = False self._ws = None if self._listener is asyncio.current_task(): self._listener = None diff --git a/tests/test_device_builder.py b/tests/test_device_builder.py index 562e44b..c2ed8ba 100644 --- a/tests/test_device_builder.py +++ b/tests/test_device_builder.py @@ -167,6 +167,74 @@ async def run() -> None: asyncio.run(run()) +def test_pending_auth_is_not_publicly_ready_or_commandable() -> None: + async def run() -> None: + auth_sent = asyncio.Event() + + class AuthWebSocket(FakeWebSocket): + async def send_json(self, message: dict) -> None: + await super().send_json(message) + auth_sent.set() + + ws = AuthWebSocket({"server_version": "2026.9.0", "requires_auth": True}) + client = DeviceBuilderClient("http://builder", token="token", connect=lambda _: ws) + connecting = asyncio.create_task(client.async_connect()) + await auth_sent.wait() + assert not client.connected + assert "connected=False" in repr(client) + with pytest.raises(ConnectionError): + await client.async_command("devices/list", {}) + assert [message["command"] for message in ws.sent] == ["auth"] + await ws.send_result("0", {}) + await connecting + assert client.connected + await client.async_disconnect() + + asyncio.run(run()) + + +def test_cancelled_connect_owns_repeatedly_cancelled_failed_cleanup() -> None: + async def run() -> None: + auth_sent = asyncio.Event() + close_started = asyncio.Event() + close_release = asyncio.Event() + close_cancellations = 0 + + class GatedAuthWebSocket(FakeWebSocket): + async def send_json(self, message: dict) -> None: + await super().send_json(message) + auth_sent.set() + + async def close(self) -> None: + nonlocal close_cancellations + close_started.set() + try: + await close_release.wait() + except asyncio.CancelledError: + close_cancellations += 1 + raise + await super().close() + + ws = GatedAuthWebSocket({"server_version": "2026.9.0", "requires_auth": True}) + client = DeviceBuilderClient("http://builder", token="token", connect=lambda _: ws) + connecting = asyncio.create_task(client.async_connect()) + await auth_sent.wait() + connecting.cancel() + await close_started.wait() + connecting.cancel() + await asyncio.sleep(0) + assert not connecting.done() + close_release.set() + with pytest.raises(asyncio.CancelledError): + await connecting + assert close_cancellations == 0 + assert ws.closed + assert not client.connected + assert not client._pending + + asyncio.run(run()) + + def test_missing_auth_flag_requires_opaque_token() -> None: """Only an explicit false ServerInfo flag permits trusted ingress.""" From b889b1373d51cf1bfffa8e56a0e3d0bb55e2ff77 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 02:23:40 -0400 Subject: [PATCH 15/35] fix: invalidate interrupted device builder connects --- .../device_builder.py | 26 +++++-- tests/test_device_builder.py | 71 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/device_builder.py b/custom_components/circuitsetup_energy_meter_helper/device_builder.py index 9d02f03..e20ad40 100644 --- a/custom_components/circuitsetup_energy_meter_helper/device_builder.py +++ b/custom_components/circuitsetup_energy_meter_helper/device_builder.py @@ -133,6 +133,7 @@ def __init__( self._server_version: AwesomeVersion | None = None self._connect_lock = asyncio.Lock() self._ready = False + self._lifecycle_generation = 0 def __repr__(self) -> str: parsed = urlsplit(self._base_url) @@ -161,14 +162,16 @@ async def async_connect(self) -> None: async with self._connect_lock: if self._ws is not None or self._listener is not None: raise ConnectionError("Device Builder is already connected") - await self._async_connect() + await self._async_connect(self._lifecycle_generation) - async def _async_connect(self) -> None: + async def _async_connect(self, generation: int) -> None: connection = self._connect(f"{self._base_url}/ws") websocket = await connection if inspect.isawaitable(connection) else connection listener: asyncio.Task[None] | None = None try: + self._ensure_connect_owner(generation) server_info = await websocket.receive_json() + self._ensure_connect_owner(generation) if not isinstance(server_info, Mapping) or "server_version" not in server_info: raise ConnectionError("Device Builder did not provide server info") version_value = server_info["server_version"] @@ -197,17 +200,31 @@ async def _async_connect(self) -> None: await future finally: self._pending.pop("0", None) + self._ensure_connect_owner(generation) self._server_version = server_version self._ready = True - except BaseException: + except BaseException as error: cleanup = asyncio.create_task( self._async_connect_cleanup(websocket, listener) ) - caller_cancelled = await _wait_for_owned_cleanup(cleanup) + try: + caller_cancelled = await _wait_for_owned_cleanup(cleanup) + except BaseException as cleanup_error: + if isinstance(error, asyncio.CancelledError): + raise BaseExceptionGroup( + "connection cleanup failed after cancellation", + [error, cleanup_error], + ) from cleanup_error + error.add_note(f"connection cleanup failed: {cleanup_error}") + raise error from cleanup_error if caller_cancelled: raise asyncio.CancelledError raise + def _ensure_connect_owner(self, generation: int) -> None: + if generation != self._lifecycle_generation: + raise ConnectionError("Device Builder connection was invalidated") + async def _async_connect_cleanup( self, websocket: WebSocket, listener: asyncio.Task[None] | None ) -> None: @@ -224,6 +241,7 @@ async def _async_connect_cleanup( async def async_disconnect(self) -> None: """Close the websocket and fail all outstanding callers.""" + self._lifecycle_generation += 1 task = self._disconnect_task if task is None or ( task.done() and (task.cancelled() or task.exception() is not None) diff --git a/tests/test_device_builder.py b/tests/test_device_builder.py index c2ed8ba..5ac0aa2 100644 --- a/tests/test_device_builder.py +++ b/tests/test_device_builder.py @@ -235,6 +235,77 @@ async def close(self) -> None: asyncio.run(run()) +def test_disconnect_invalidates_connect_blocked_in_factory() -> None: + async def run() -> None: + factory_started = asyncio.Event() + factory_release = asyncio.Event() + ws = FakeWebSocket({"server_version": "2026.9.0", "requires_auth": False}) + + async def connect(_url: str) -> FakeWebSocket: + factory_started.set() + await factory_release.wait() + return ws + + client = DeviceBuilderClient("http://builder", connect=connect) + connecting = asyncio.create_task(client.async_connect()) + await factory_started.wait() + await client.async_disconnect() + factory_release.set() + with pytest.raises(ConnectionError, match="invalidated"): + await connecting + assert not client.connected + assert ws.closed + + asyncio.run(run()) + + +def test_disconnect_invalidates_connect_blocked_in_server_info_receive() -> None: + async def run() -> None: + receive_started = asyncio.Event() + receive_release = asyncio.Event() + + class GatedReceiveWebSocket(FakeWebSocket): + async def receive_json(self) -> dict | None: + receive_started.set() + await receive_release.wait() + return await super().receive_json() + + ws = GatedReceiveWebSocket( + {"server_version": "2026.9.0", "requires_auth": False} + ) + client = DeviceBuilderClient("http://builder", connect=lambda _: ws) + connecting = asyncio.create_task(client.async_connect()) + await receive_started.wait() + await client.async_disconnect() + receive_release.set() + with pytest.raises(ConnectionError, match="invalidated"): + await connecting + assert not client.connected + assert ws.closed + + asyncio.run(run()) + + +def test_handshake_error_survives_cleanup_failure() -> None: + async def run() -> None: + class FailingCloseWebSocket(FakeWebSocket): + async def close(self) -> None: + self.closed = True + raise RuntimeError("close failed") + + ws = FailingCloseWebSocket( + {"server_version": 2026, "requires_auth": False} + ) + client = DeviceBuilderClient("http://builder", connect=lambda _: ws) + with pytest.raises(ConnectionError, match="invalid server version") as caught: + await client.async_connect() + assert not client.connected + assert ws.closed + assert any("close failed" in note for note in caught.value.__notes__) + + asyncio.run(run()) + + def test_missing_auth_flag_requires_opaque_token() -> None: """Only an explicit false ServerInfo flag permits trusted ingress.""" From ae8848cec1bc536d1283fd237f9c216dd4d0b4a0 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 02:35:41 -0400 Subject: [PATCH 16/35] feat: persist verified meter configuration metadata --- .../models.py | 6 + .../circuitsetup_energy_meter_helper/store.py | 453 +++++++++++++++++- tests/test_store.py | 442 ++++++++++++++++- 3 files changed, 898 insertions(+), 3 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/models.py b/custom_components/circuitsetup_energy_meter_helper/models.py index 015a345..163e38e 100644 --- a/custom_components/circuitsetup_energy_meter_helper/models.py +++ b/custom_components/circuitsetup_energy_meter_helper/models.py @@ -343,6 +343,7 @@ class StoredMeterRecord: setup_intent: str config_filename: str | None topology: StoredTopology | None + config_sha256: str | None = None ct_selections: tuple[StoredCTSelection, ...] = () interrupted_session: StoredInterruptedSession | None = None @@ -351,3 +352,8 @@ def __post_init__(self) -> None: _safe_line(self.setup_intent, "setup_intent") if self.config_filename is not None: _safe_line(self.config_filename, "config_filename") + if ( + self.config_sha256 is not None + and re.fullmatch(r"[0-9a-f]{64}", self.config_sha256) is None + ): + raise ValueError("config_sha256 must be a SHA-256 digest") diff --git a/custom_components/circuitsetup_energy_meter_helper/store.py b/custom_components/circuitsetup_energy_meter_helper/store.py index 6e2c590..b90b335 100644 --- a/custom_components/circuitsetup_energy_meter_helper/store.py +++ b/custom_components/circuitsetup_energy_meter_helper/store.py @@ -12,18 +12,34 @@ from homeassistant.helpers.storage import Store from .ct_catalog import REPORTING_MULTIPLIERS +from .meter_configuration import ( + ChannelSettings, + CircuitAggregate, + CircuitRole, + ElectricalSystem, + EnergyMode, + MeasurementMethod, + MeterConfigurationRequest, + MeterSettings, + VoltageLayout, + VoltageReferenceConfig, + validate_meter_configuration, +) from .models import ( + ConnectionType, + MeterTopology, PhaseOffsetTable, PhasePowerOffsetTable, StoredCTSelection, StoredInterruptedSession, StoredMeterRecord, StoredTopology, + StoredTopologyEvidence, canonical_mac, ) STORAGE_VERSION = 1 -STORAGE_MINOR_VERSION = 3 +STORAGE_MINOR_VERSION = 4 STORAGE_KEY = "circuitsetup_energy_meter_helper" @@ -236,6 +252,8 @@ def migrate_storage( ) ] return data + if (version, minor_version) == (1, 3): + return data if (version, minor_version) != (STORAGE_VERSION, STORAGE_MINOR_VERSION): raise ValueError(f"Storage version {version} cannot be migrated") return data @@ -461,6 +479,7 @@ def serialize_meter_record(record: StoredMeterRecord) -> dict[str, Any]: "mac": record.mac, "setup_intent": record.setup_intent, "config_filename": record.config_filename, + "config_sha256": record.config_sha256, "topology": ( _serialize_topology(record.topology) if record.topology is not None @@ -477,6 +496,375 @@ def serialize_meter_record(record: StoredMeterRecord) -> dict[str, Any]: } +@dataclass(frozen=True, slots=True) +class StoredMeterConfiguration: + """Verified configuration semantics bound to one configuration digest.""" + + config_sha256: str + meter: MeterSettings + channels: tuple[ChannelSettings, ...] + aggregates: tuple[CircuitAggregate, ...] + power_quality: tuple[bool, ...] + status_fields: tuple[bool, ...] + + def __post_init__(self) -> None: + if re.fullmatch(r"[0-9a-f]{64}", self.config_sha256) is None: + raise ValueError("config_sha256 must be a SHA-256 digest") + if not isinstance(self.meter, MeterSettings): + raise TypeError("meter must be MeterSettings") + if type(self.channels) is not tuple or any( + not isinstance(item, ChannelSettings) for item in self.channels + ): + raise TypeError("channels must be a tuple of ChannelSettings") + if type(self.aggregates) is not tuple or any( + not isinstance(item, CircuitAggregate) for item in self.aggregates + ): + raise TypeError("aggregates must be a tuple of CircuitAggregate") + for field, value in ( + ("power_quality", self.power_quality), + ("status_fields", self.status_fields), + ): + if type(value) is not tuple or any( + type(item) is not bool for item in value + ): + raise TypeError(f"{field} must be a tuple of booleans") + + +def _exact_mapping(raw: object, keys: set[str], label: str) -> dict[str, Any]: + if ( + not isinstance(raw, dict) + or set(raw) != keys + or any(not isinstance(key, str) for key in raw) + ): + raise ValueError(f"stored meter configuration {label} is invalid") + return raw + + +def _configuration_hash(raw: object) -> str | None: + if not isinstance(raw, dict): + return None + value = raw.get("config_sha256") + return ( + value + if isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) + else None + ) + + +def _current_topology(raw_meter: object) -> MeterTopology: + if not isinstance(raw_meter, dict): + raise TypeError("stored meter configuration meter record is invalid") + raw_topology = _exact_mapping( + raw_meter.get("topology"), + { + "addon_count", + "board_count", + "ct_count", + "group_count", + "connection_type", + "voltage_layout", + "project_name", + "evidence", + }, + "topology", + ) + try: + raw_evidence = raw_topology["evidence"] + if not isinstance(raw_evidence, list) or len(raw_evidence) > 5: + raise TypeError("stored meter configuration topology is invalid") + evidence = tuple( + StoredTopologyEvidence( + **_exact_mapping(item, {"source", "addon_count", "detail"}, "evidence") + ) + for item in raw_evidence + ) + topology = StoredTopology( + addon_count=raw_topology["addon_count"], + board_count=raw_topology["board_count"], + ct_count=raw_topology["ct_count"], + group_count=raw_topology["group_count"], + connection_type=raw_topology["connection_type"], + voltage_layout=raw_topology["voltage_layout"], + project_name=raw_topology["project_name"], + evidence=evidence, + ) + except (TypeError, ValueError) as error: + raise ValueError("stored meter configuration topology is invalid") from error + return MeterTopology( + topology.addon_count, + topology.board_count, + topology.ct_count, + topology.group_count, + cast(ConnectionType, topology.connection_type), + topology.voltage_layout, + topology.project_name, + (), + ) + + +def _topology_identity( + topology: MeterTopology, +) -> tuple[int, int, int, int, str, str, str]: + return ( + topology.addon_count, + topology.board_count, + topology.ct_count, + topology.group_count, + topology.connection_type, + topology.voltage_layout, + topology.project_name, + ) + + +def _validate_configuration( + configuration: StoredMeterConfiguration, topology: MeterTopology +) -> None: + validate_meter_configuration( + MeterConfigurationRequest( + configuration.meter, + configuration.channels, + configuration.aggregates, + configuration.power_quality, + configuration.status_fields, + multi_reference_preparation_acknowledged=( + len(configuration.meter.voltage_references) > 1 + ), + ), + topology, + ) + + +def _serialize_meter_configuration( + configuration: StoredMeterConfiguration, topology: MeterTopology +) -> dict[str, Any]: + _validate_configuration(configuration, topology) + return { + "config_sha256": configuration.config_sha256, + "meter": { + "friendly_name": configuration.meter.friendly_name, + "electrical_system": configuration.meter.electrical_system.value, + "line_frequency_hz": configuration.meter.line_frequency_hz, + "update_interval_s": configuration.meter.update_interval_s, + "voltage_layout": configuration.meter.voltage_layout.value, + "voltage_references": [ + { + "reference_id": reference.reference_id, + "label": reference.label, + "phase_label": reference.phase_label, + "nominal_voltage_v": reference.nominal_voltage_v, + "transformer_model_id": reference.transformer_model_id, + "gain_voltage": reference.gain_voltage, + "group_keys": list(reference.group_keys), + } + for reference in configuration.meter.voltage_references + ], + }, + "channels": [ + { + "channel": channel.channel, + "enabled": channel.enabled, + "name": channel.name, + "model_id": channel.model_id, + "reporting_multiplier": channel.reporting_multiplier, + "role": channel.role.value, + "voltage_reference_id": channel.voltage_reference_id, + "custom_gain_ct": channel.custom_gain_ct, + "custom_label": channel.custom_label, + } + for channel in configuration.channels + ], + "aggregates": [ + { + "aggregate_id": aggregate.aggregate_id, + "name": aggregate.name, + "role": aggregate.role.value, + "channels": list(aggregate.channels), + "measurement_method": aggregate.measurement_method.value, + "parent_id": aggregate.parent_id, + "energy_mode": aggregate.energy_mode.value, + "expose_power": aggregate.expose_power, + "expose_current": aggregate.expose_current, + } + for aggregate in configuration.aggregates + ], + "power_quality": list(configuration.power_quality), + "status_fields": list(configuration.status_fields), + } + + +def _deserialize_meter_configuration_payload( + raw: object, topology: MeterTopology +) -> StoredMeterConfiguration: + data = _exact_mapping( + raw, + { + "config_sha256", + "meter", + "channels", + "aggregates", + "power_quality", + "status_fields", + }, + "payload", + ) + raw_meter = _exact_mapping( + data["meter"], + { + "friendly_name", + "electrical_system", + "line_frequency_hz", + "update_interval_s", + "voltage_layout", + "voltage_references", + }, + "meter", + ) + if not isinstance(raw_meter["voltage_references"], list) or not 1 <= len( + raw_meter["voltage_references"] + ) <= min(8, topology.group_count): + raise TypeError("stored meter configuration meter is invalid") + references: list[VoltageReferenceConfig] = [] + for raw_reference in raw_meter["voltage_references"]: + item = _exact_mapping( + raw_reference, + { + "reference_id", + "label", + "phase_label", + "nominal_voltage_v", + "transformer_model_id", + "gain_voltage", + "group_keys", + }, + "voltage reference", + ) + if ( + not isinstance(item["group_keys"], list) + or not 1 <= len(item["group_keys"]) <= topology.group_count + ): + raise TypeError("stored meter configuration voltage reference is invalid") + references.append( + VoltageReferenceConfig( + item["reference_id"], + item["label"], + item["phase_label"], + item["nominal_voltage_v"], + item["transformer_model_id"], + item["gain_voltage"], + tuple(item["group_keys"]), + ) + ) + if ( + not isinstance(data["channels"], list) + or len(data["channels"]) != topology.ct_count + or not isinstance(data["aggregates"], list) + or len(data["aggregates"]) > 32 + ): + raise TypeError("stored meter configuration collections are invalid") + channels: list[ChannelSettings] = [] + for raw_channel in data["channels"]: + item = _exact_mapping( + raw_channel, + { + "channel", + "enabled", + "name", + "model_id", + "reporting_multiplier", + "role", + "voltage_reference_id", + "custom_gain_ct", + "custom_label", + }, + "channel", + ) + channels.append( + ChannelSettings( + item["channel"], + item["enabled"], + item["name"], + item["model_id"], + item["reporting_multiplier"], + CircuitRole(item["role"]), + item["voltage_reference_id"], + item["custom_gain_ct"], + item["custom_label"], + False, + ) + ) + aggregates: list[CircuitAggregate] = [] + for raw_aggregate in data["aggregates"]: + item = _exact_mapping( + raw_aggregate, + { + "aggregate_id", + "name", + "role", + "channels", + "measurement_method", + "parent_id", + "energy_mode", + "expose_power", + "expose_current", + }, + "aggregate", + ) + if ( + not isinstance(item["channels"], list) + or len(item["channels"]) > topology.ct_count + ): + raise TypeError("stored meter configuration aggregate is invalid") + aggregates.append( + CircuitAggregate( + item["aggregate_id"], + item["name"], + CircuitRole(item["role"]), + tuple(item["channels"]), + MeasurementMethod(item["measurement_method"]), + item["parent_id"], + EnergyMode(item["energy_mode"]), + item["expose_power"], + item["expose_current"], + ) + ) + if ( + not isinstance(data["power_quality"], list) + or len(data["power_quality"]) != topology.board_count + or not isinstance(data["status_fields"], list) + or len(data["status_fields"]) != topology.board_count + ): + raise TypeError("stored meter configuration options are invalid") + try: + configuration = StoredMeterConfiguration( + data["config_sha256"], + MeterSettings( + raw_meter["friendly_name"], + ElectricalSystem(raw_meter["electrical_system"]), + raw_meter["line_frequency_hz"], + raw_meter["update_interval_s"], + VoltageLayout(raw_meter["voltage_layout"]), + tuple(references), + ), + tuple(channels), + tuple(aggregates), + tuple(data["power_quality"]), + tuple(data["status_fields"]), + ) + _validate_configuration(configuration, topology) + except (TypeError, ValueError) as error: + raise ValueError("stored meter configuration is invalid") from error + return configuration + + +def _deserialize_meter_configuration( + raw: object, topology: MeterTopology +) -> StoredMeterConfiguration: + try: + return _deserialize_meter_configuration_payload(raw, topology) + except (TypeError, ValueError) as error: + raise ValueError("stored meter configuration is invalid") from error + + class HelperStore: """Persist typed meter metadata without credentials or configuration content.""" @@ -510,7 +898,28 @@ async def async_save_meter(self, record: StoredMeterRecord) -> None: async with self._update_lock: data = await self.async_load() meters = data.setdefault("meters", {}) - meters[record.mac] = serialize_meter_record(record) + serialized = serialize_meter_record(record) + previous = meters.get(record.mac) + if ( + _configuration_hash(previous) == record.config_sha256 + and isinstance(previous, dict) + and _configuration_hash(previous.get("meter_configuration")) + == record.config_sha256 + ): + try: + next_topology = _current_topology(serialized) + if _topology_identity( + _current_topology(previous) + ) != _topology_identity(next_topology): + raise ValueError("meter topology identity changed") + _deserialize_meter_configuration( + previous["meter_configuration"], next_topology + ) + except KeyError, TypeError, ValueError: + pass + else: + serialized["meter_configuration"] = previous["meter_configuration"] + meters[record.mac] = serialized await self._store.async_save(data) async def async_save_verified_ct_selections( @@ -544,6 +953,46 @@ async def async_get_ct_selections(self, mac: str) -> tuple[StoredCTSelection, .. except (TypeError, ValueError) as error: raise ValueError("stored CT selections are invalid") from error + async def async_get_meter_configuration( + self, mac: str + ) -> StoredMeterConfiguration | None: + """Load verified semantics only when their source configuration is current.""" + mac = canonical_mac(mac) + raw_meter = (await self.async_load()).get("meters", {}).get(mac) + if not isinstance(raw_meter, dict): + return None + raw_configuration = raw_meter.get("meter_configuration") + current_hash = _configuration_hash(raw_meter) + if ( + current_hash is None + or _configuration_hash(raw_configuration) != current_hash + ): + return None + topology = _current_topology(raw_meter) + configuration = _deserialize_meter_configuration(raw_configuration, topology) + return configuration + + async def async_save_verified_meter_configuration( + self, mac: str, configuration: StoredMeterConfiguration + ) -> None: + """Atomically retain verified semantics only for the current meter record.""" + mac = canonical_mac(mac) + if not isinstance(configuration, StoredMeterConfiguration): + raise TypeError("configuration must be StoredMeterConfiguration") + async with self._update_lock: + data = await self.async_load() + raw_meter = data.setdefault("meters", {}).get(mac) + if not isinstance(raw_meter, dict): + raise ValueError("current meter record is unavailable") # noqa: TRY004 + if _configuration_hash(raw_meter) != configuration.config_sha256: + raise ValueError( + "configuration does not match the current meter record" + ) + raw_meter["meter_configuration"] = _serialize_meter_configuration( + configuration, _current_topology(raw_meter) + ) + await self._store.async_save(data) + async def async_save_interrupted_session( self, mac: str, marker: StoredInterruptedSession | None ) -> None: diff --git a/tests/test_store.py b/tests/test_store.py index 0b3fd8a..deb56b0 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -2,18 +2,33 @@ import asyncio from copy import deepcopy +from dataclasses import replace import pytest +from custom_components.circuitsetup_energy_meter_helper.meter_configuration import ( + ChannelSettings, + CircuitAggregate, + CircuitRole, + ElectricalSystem, + EnergyMode, + MeasurementMethod, + MeterSettings, + VoltageLayout, + VoltageReferenceConfig, +) from custom_components.circuitsetup_energy_meter_helper.models import ( StoredCTSelection, StoredInterruptedSession, StoredMeterRecord, + StoredTopology, + StoredTopologyEvidence, ) from custom_components.circuitsetup_energy_meter_helper.store import ( STORAGE_MINOR_VERSION, STORAGE_VERSION, HelperStore, + StoredMeterConfiguration, VerifiedCalibrationRecord, VerifiedGainGroup, _HelperStorage, @@ -21,6 +36,52 @@ serialize_meter_record, ) +MAC = "aabbccddeeff" +CONFIG_HASH = "a" * 64 + + +def _topology() -> StoredTopology: + return StoredTopology( + 0, 1, 6, 2, "wifi", "standard", "circuitsetup.6c-energy-meter" + ) + + +def _record(config_sha256: str = CONFIG_HASH) -> StoredMeterRecord: + return StoredMeterRecord( + MAC, "setup_later", "meter.yaml", _topology(), config_sha256=config_sha256 + ) + + +def _configuration() -> StoredMeterConfiguration: + meter = MeterSettings( + "Kitchen meter", + ElectricalSystem.SPLIT_PHASE_120_240, + 60, + 5, + VoltageLayout.STANDARD, + ( + VoltageReferenceConfig( + "main", "Main", "A", 120.0, "vt", 1, ("main_1", "main_2") + ), + ), + ) + channels = tuple( + ChannelSettings(i, True, f"CT {i}", "ct", 1.0, CircuitRole.BRANCH, "main") + for i in range(1, 7) + ) + aggregate = CircuitAggregate( + "grid", + "Grid", + CircuitRole.GRID, + (1, 2), + MeasurementMethod.TWO_CT_SUM, + None, + EnergyMode.CONSUMPTION, + ) + return StoredMeterConfiguration( + CONFIG_HASH, meter, channels, (aggregate,), (False,), (True,) + ) + class _CopyingStorage: """Model Home Assistant storage returning independent loaded documents.""" @@ -92,7 +153,7 @@ def test_storage_1_1_migrates_without_rewriting_gain_only_records() -> None: migrated = migrate_storage(1, 1, deepcopy(legacy)) - assert STORAGE_MINOR_VERSION == 3 + assert STORAGE_MINOR_VERSION == 4 assert migrated == legacy assert ( "offset_groups" @@ -298,3 +359,382 @@ async def run() -> None: await store.async_load() asyncio.run(run()) + + +def test_verified_meter_configuration_round_trips_without_operation_acknowledgement() -> ( + None +): + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + configuration = _configuration() + + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, configuration) + + assert ( + await store.async_get_meter_configuration("AA:BB:CC:DD:EE:FF") + == configuration + ) + raw = backend.data["meters"][MAC]["meter_configuration"] # type: ignore[index] + assert set(raw) == { # type: ignore[arg-type] + "config_sha256", + "meter", + "channels", + "aggregates", + "power_quality", + "status_fields", + } + assert "multi_reference_preparation_acknowledged" not in str(raw) + + asyncio.run(run()) + + +def test_meter_configuration_is_rejected_when_record_hash_is_missing_or_stale() -> None: + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + configuration = _configuration() + + with pytest.raises(ValueError, match="current meter record"): + await store.async_save_verified_meter_configuration(MAC, configuration) + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, configuration) + backend.data["meters"][MAC]["config_sha256"] = "b" * 64 # type: ignore[index] + + assert await store.async_get_meter_configuration(MAC) is None + with pytest.raises(ValueError, match="current meter record"): + await store.async_save_verified_meter_configuration(MAC, configuration) + + asyncio.run(run()) + + +def test_meter_configuration_rejects_noncanonical_nested_data_and_topology() -> None: + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + configuration = _configuration() + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, configuration) + meter = backend.data["meters"][MAC] # type: ignore[index] + raw = meter["meter_configuration"] # type: ignore[index] + original_channels = deepcopy(raw["channels"]) # type: ignore[index] + + raw["meter"]["extra"] = "not allowed" # type: ignore[index] + with pytest.raises(ValueError): + await store.async_get_meter_configuration(MAC) + del raw["meter"]["extra"] # type: ignore[index] + raw["channels"][0]["role"] = "not-a-role" # type: ignore[index] + with pytest.raises(ValueError): + await store.async_get_meter_configuration(MAC) + raw["channels"][0]["role"] = "branch" # type: ignore[index] + raw["channels"] = raw["channels"][:-1] # type: ignore[index] + with pytest.raises(ValueError, match="meter configuration"): + await store.async_get_meter_configuration(MAC) + raw["channels"] = original_channels # type: ignore[index] + raw["power_quality"] = [1] # type: ignore[index] + with pytest.raises(ValueError, match="meter configuration"): + await store.async_get_meter_configuration(MAC) + raw["power_quality"] = [False] # type: ignore[index] + meter["topology"]["ct_count"] = 12 # type: ignore[index] + with pytest.raises(ValueError, match="meter configuration"): + await store.async_get_meter_configuration(MAC) + + asyncio.run(run()) + + +def test_storage_1_3_migrates_without_fabricating_meter_configuration() -> None: + legacy = { + "meters": { + MAC: { + "ct_selections": [ + { + "channel": 1, + "model_id": "ct", + "display_label": "CT 1", + "raw_gain_ct": 1, + "reporting_multiplier": 1.0, + "config_sha256": CONFIG_HASH, + } + ] + } + } + } + + migrated = migrate_storage(1, 3, deepcopy(legacy)) + + assert STORAGE_MINOR_VERSION == 4 + assert migrated == legacy + assert "meter_configuration" not in migrated["meters"][MAC] + + +def test_concurrent_meter_configuration_saves_are_isolated_and_immutable() -> None: + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + other_mac = "001122334455" + other = replace(_configuration(), config_sha256="b" * 64) + await store.async_save_meter(_record()) + await store.async_save_meter(replace(_record("b" * 64), mac=other_mac)) + + await asyncio.gather( + store.async_save_verified_meter_configuration(MAC, _configuration()), + store.async_save_verified_meter_configuration(other_mac, other), + ) + + loaded = await store.async_get_meter_configuration(MAC) + assert loaded == _configuration() + assert await store.async_get_meter_configuration(other_mac) == other + backend.data["meters"][MAC]["meter_configuration"]["channels"][0]["name"] = ( + "Changed" # type: ignore[index] + ) + assert loaded.channels[0].name == "CT 1" # type: ignore[union-attr] + + asyncio.run(run()) + + +def test_meter_configuration_never_persists_hardware_preparation_acknowledgement() -> ( + None +): + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + configuration = _configuration() + acknowledged = replace( + configuration, + channels=( + replace(configuration.channels[0], burden_output_acknowledged=True), + *configuration.channels[1:], + ), + ) + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, acknowledged) + + raw = backend.data["meters"][MAC]["meter_configuration"] # type: ignore[index] + assert "burden_output_acknowledged" not in raw["channels"][0] # type: ignore[index] + assert (await store.async_get_meter_configuration(MAC)).channels[ + 0 + ].burden_output_acknowledged is False # type: ignore[union-attr] + + asyncio.run(run()) + + +def test_save_meter_preserves_only_matching_valid_verified_configuration() -> None: + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + configuration = _configuration() + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, configuration) + + await store.async_save_meter(_record()) + assert await store.async_get_meter_configuration(MAC) == configuration + + backend.data["meters"][MAC]["meter_configuration"]["channels"] = "invalid" # type: ignore[index] + await store.async_save_meter(_record()) + assert "meter_configuration" not in backend.data["meters"][MAC] # type: ignore[index] + + await store.async_save_verified_meter_configuration(MAC, configuration) + await store.async_save_meter( + replace( + _record(), + topology=StoredTopology( + 1, 2, 12, 4, "wifi", "standard", "circuitsetup.6c-energy-meter" + ), + ) + ) + assert "meter_configuration" not in backend.data["meters"][MAC] # type: ignore[index] + + await store.async_save_meter(_record("b" * 64)) + assert "meter_configuration" not in backend.data["meters"][MAC] # type: ignore[index] + + asyncio.run(run()) + + +def test_stale_malformed_meter_configuration_returns_none_before_deserialization() -> ( + None +): + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, _configuration()) + meter = backend.data["meters"][MAC] # type: ignore[index] + meter["config_sha256"] = "b" * 64 # type: ignore[index] + meter["meter_configuration"]["channels"] = "invalid" # type: ignore[index] + + assert await store.async_get_meter_configuration(MAC) is None + + asyncio.run(run()) + + +def test_storage_1_3_preserves_legacy_ct_selection_bytes() -> None: + legacy = { + "meters": { + MAC: { + "ct_selections": [ + { + "channel": 1, + "model_id": "ct", + "display_label": "CT 1", + "raw_gain_ct": 1, + "reporting_multiplier": 3.0, + "config_sha256": CONFIG_HASH, + } + ] + } + } + } + + before = deepcopy(legacy) + migrated = migrate_storage(1, 3, legacy) + + assert migrated is legacy + assert migrated == before + + +def test_meter_configuration_bounds_and_evidence_errors_are_normalized() -> None: + async def read_with(mutator: object) -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, _configuration()) + meter = backend.data["meters"][MAC] # type: ignore[index] + mutator(meter) # type: ignore[operator] + with pytest.raises(ValueError, match="meter configuration"): + await store.async_get_meter_configuration(MAC) + + async def run() -> None: + await read_with( + lambda meter: meter["topology"].update( + { + "evidence": [ + {"source": "config_project", "addon_count": 0, "detail": "x"} + ] + * 6 + } + ) + ) + await read_with( + lambda meter: meter["topology"].update( + { + "evidence": [ + {"source": "config_project", "addon_count": "0", "detail": "x"} + ] + } + ) + ) + await read_with( + lambda meter: meter["meter_configuration"]["meter"][ + "voltage_references" + ].extend( + [ + deepcopy( + meter["meter_configuration"]["meter"]["voltage_references"][0] + ) + ] + * 2 + ) + ) + await read_with( + lambda meter: meter["meter_configuration"]["meter"]["voltage_references"][ + 0 + ]["group_keys"].append("extra") + ) + await read_with( + lambda meter: meter["meter_configuration"]["channels"].append( + deepcopy(meter["meter_configuration"]["channels"][0]) + ) + ) + await read_with( + lambda meter: meter["meter_configuration"]["aggregates"].extend( + [deepcopy(meter["meter_configuration"]["aggregates"][0])] * 32 + ) + ) + await read_with( + lambda meter: meter["meter_configuration"]["aggregates"][0][ + "channels" + ].extend(range(3, 8)) + ) + await read_with( + lambda meter: meter["meter_configuration"].update( + {"power_quality": [False, False]} + ) + ) + await read_with( + lambda meter: meter["meter_configuration"].update( + {"status_fields": [True, False]} + ) + ) + + asyncio.run(run()) + + +@pytest.mark.parametrize( + ("field", "value"), + ( + ("connection_type", "ethernet_lilygo"), + ("voltage_layout", "alternate"), + ("project_name", "other-project"), + ), +) +def test_save_meter_drops_configuration_when_stable_topology_identity_changes( + field: str, value: str +) -> None: + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + configuration = _configuration() + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, configuration) + + await store.async_save_meter( + replace(_record(), topology=replace(_topology(), **{field: value})) + ) + + assert "meter_configuration" not in backend.data["meters"][MAC] # type: ignore[index] + + asyncio.run(run()) + + +def test_save_meter_preserves_configuration_when_only_evidence_changes() -> None: + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + configuration = _configuration() + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, configuration) + + await store.async_save_meter( + replace( + _record(), + topology=replace( + _topology(), + evidence=(StoredTopologyEvidence("config_project", 0, "new"),), + ), + ) + ) + + assert await store.async_get_meter_configuration(MAC) == configuration + + asyncio.run(run()) From c5706fcdfa93c44f114e5c0515b34b3fbb287dc7 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 03:08:14 -0400 Subject: [PATCH 17/35] feat: parse bounded meter configuration fields --- .../config_document.py | 94 +++++++++++- .../device_builder/meter_configuration.yaml | 22 +++ tests/test_config_document.py | 140 ++++++++++++++++++ 3 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/device_builder/meter_configuration.yaml diff --git a/custom_components/circuitsetup_energy_meter_helper/config_document.py b/custom_components/circuitsetup_energy_meter_helper/config_document.py index 1a67ce3..513382d 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_document.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_document.py @@ -11,10 +11,37 @@ VOLTAGE_GAIN_RE = re.compile(r"^voltage_cal[12]$") GROUP_NAME_RE = re.compile(r"^(?:main_meter_name[12]|addon[1-6]_name[12])$") METER_ID_RE = re.compile(r"^(?:main_meter_id[12]|addon[1-6]_id[12])$") +METER_SETTING_RE = re.compile( + r"^(?:friendly_name|update_time|electric_freq|csemh_config_contract)$" +) _MAPPING_RE = re.compile(r"^(?P *)(?P[\w-]+):(?P.*)$") _SEQUENCE_MAPPING_RE = re.compile(r"^(?P *)-\s+(?P[\w-]+):(?P.*)$") _SEQUENCE_RE = re.compile(r"^(?P *)-\s+(?P.+)$") _YAML_PATH_RE = re.compile(r"(?i)^(.*?\.ya?ml)(?:@.*)?$") +_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") +_MAX_DOCUMENT_BYTES = 1_048_576 +_MAX_DOCUMENT_LINES = 10_000 +_MAX_SETTING_LENGTH = 64 +_MANAGED_MARKERS = { + "# CircuitSetup Energy Meter Helper: voltage references v1": ( + "voltage_references", + False, + ), + "# End CircuitSetup Energy Meter Helper: voltage references v1": ( + "voltage_references", + True, + ), + "# CircuitSetup Energy Meter Helper: phase overrides v1": ( + "phase_overrides", + False, + ), + "# End CircuitSetup Energy Meter Helper: phase overrides v1": ( + "phase_overrides", + True, + ), + "# CircuitSetup Energy Meter Helper: aggregates v1": ("aggregates", False), + "# End CircuitSetup Energy Meter Helper: aggregates v1": ("aggregates", True), +} class ESPHomeConfigParseError(ValueError): @@ -44,6 +71,14 @@ class ConfigScalar: span: SourceSpan +@dataclass(frozen=True, slots=True) +class ManagedBlock: + """One exact helper-owned source range, including its marker comments.""" + + content: str + span: SourceSpan + + @dataclass(frozen=True, slots=True) class ESPHomeConfigDocument: """Relevant ESPHome values plus the exact source text that supplied them.""" @@ -56,6 +91,7 @@ class ESPHomeConfigDocument: dashboard_import_span: SourceSpan | None substitutions: dict[str, ConfigScalar] package_files: tuple[str, ...] + managed_blocks: dict[str, ManagedBlock] @classmethod def parse(cls, content: str) -> ESPHomeConfigDocument: @@ -73,6 +109,13 @@ class _Mapping: class _DocumentParser: def __init__(self, content: str) -> None: + if len(content.encode("utf-8")) > _MAX_DOCUMENT_BYTES: + raise ESPHomeConfigParseError("configuration exceeds byte limit", 1) + line_count = content.count("\n") + content.count("\r") - content.count("\r\n") + if content and content[-1] not in "\r\n": + line_count += 1 + if line_count > _MAX_DOCUMENT_LINES: + raise ESPHomeConfigParseError("configuration exceeds line limit", 1) self.content = content self.lines = tuple(content.splitlines(keepends=True)) self._bodies = tuple(line.rstrip("\r\n") for line in self.lines) @@ -96,6 +139,7 @@ def parse( dashboard_import_span=dashboard.span if dashboard else None, substitutions=self._substitutions(), package_files=self._package_files(), + managed_blocks=self._managed_blocks(), ) def _sections(self, name: str) -> list[tuple[int, _Mapping]]: @@ -159,15 +203,63 @@ def _substitutions(self) -> dict[str, ConfigScalar]: or VOLTAGE_GAIN_RE.fullmatch(mapping.key) or GROUP_NAME_RE.fullmatch(mapping.key) or METER_ID_RE.fullmatch(mapping.key) + or METER_SETTING_RE.fullmatch(mapping.key) ): continue if mapping.key in substitutions: raise ESPHomeConfigParseError( f"duplicate mutable substitution {mapping.key}", index + 1 ) - substitutions[mapping.key] = self._scalar(index, mapping) + scalar = self._scalar(index, mapping) + if METER_SETTING_RE.fullmatch(mapping.key): + self._validate_meter_setting(mapping.key, scalar.value, index + 1) + substitutions[mapping.key] = scalar return substitutions + @staticmethod + def _validate_meter_setting(key: str, value: str, line: int) -> None: + if not value or len(value) > _MAX_SETTING_LENGTH or _CONTROL_RE.search(value): + raise ESPHomeConfigParseError("meter setting is not safely bounded", line) + if key == "update_time" and value not in {"1s", "2s", "5s", "10s", "30s", "60s"}: + raise ESPHomeConfigParseError("unsupported update_time", line) + if key == "electric_freq" and value not in {"50Hz", "60Hz"}: + raise ESPHomeConfigParseError("unsupported electric_freq", line) + if key == "csemh_config_contract" and value != "2": + raise ESPHomeConfigParseError("unsupported csemh_config_contract", line) + + def _managed_blocks(self) -> dict[str, ManagedBlock]: + blocks: dict[str, ManagedBlock] = {} + open_block: tuple[str, int] | None = None + for index, body in enumerate(self._bodies): + marker = _MANAGED_MARKERS.get(body) + if marker is None: + continue + name, is_end = marker + if not is_end: + if open_block is not None: + raise ESPHomeConfigParseError("nested managed block", index + 1) + if name in blocks: + raise ESPHomeConfigParseError("duplicate managed block", index + 1) + open_block = (name, index) + continue + if open_block is None: + raise ESPHomeConfigParseError("managed block ends before it starts", index + 1) + open_name, start = open_block + if name != open_name: + raise ESPHomeConfigParseError("mismatched managed block marker", index + 1) + span = SourceSpan( + start=self._offsets[start], + end=self._offsets[index] + len(self._bodies[index]), + line=start + 1, + start_column=0, + end_column=len(self._bodies[index]), + ) + blocks[name] = ManagedBlock(self.content[span.start : span.end], span) + open_block = None + if open_block is not None: + raise ESPHomeConfigParseError("unterminated managed block", open_block[1] + 1) + return blocks + def _nested_scalar( self, section_name: str, parent_key: str, child_key: str ) -> ConfigScalar | None: diff --git a/tests/fixtures/device_builder/meter_configuration.yaml b/tests/fixtures/device_builder/meter_configuration.yaml new file mode 100644 index 0000000..1bd870c --- /dev/null +++ b/tests/fixtures/device_builder/meter_configuration.yaml @@ -0,0 +1,22 @@ +substitutions: + friendly_name: "Garage Meter" # shown in Home Assistant + update_time: 10s + electric_freq: '60Hz' + csemh_config_contract: "2" + ct1_name: Main CT + current_cal_ct1: 27518 + +# CircuitSetup Energy Meter Helper: voltage references v1 +voltage_references: + main: 120 +# End CircuitSetup Energy Meter Helper: voltage references v1 + +# CircuitSetup Energy Meter Helper: phase overrides v1 +phase_overrides: + ct1: A +# End CircuitSetup Energy Meter Helper: phase overrides v1 + +# CircuitSetup Energy Meter Helper: aggregates v1 +aggregates: + grid: [ct1] +# End CircuitSetup Energy Meter Helper: aggregates v1 diff --git a/tests/test_config_document.py b/tests/test_config_document.py index e51e457..b5eeec4 100644 --- a/tests/test_config_document.py +++ b/tests/test_config_document.py @@ -86,6 +86,146 @@ def test_noop_preserves_every_byte() -> None: assert "wifi_password" not in repr(doc) +def test_extracts_bounded_meter_substitutions_and_managed_blocks() -> None: + content = fixture("meter_configuration.yaml").replace("\n", "\r\n") + doc = ESPHomeConfigDocument.parse(content) + + assert { + key: doc.substitutions[key].value + for key in ( + "friendly_name", + "update_time", + "electric_freq", + "csemh_config_contract", + ) + } == { + "friendly_name": "Garage Meter", + "update_time": "10s", + "electric_freq": "60Hz", + "csemh_config_contract": "2", + } + friendly = doc.substitutions["friendly_name"] + assert content[friendly.span.start : friendly.span.end] == '"Garage Meter"' + assert tuple(doc.managed_blocks) == ( + "voltage_references", + "phase_overrides", + "aggregates", + ) + block = doc.managed_blocks["voltage_references"] + assert content[block.span.start : block.span.end] == block.content + assert block.content.startswith( + "# CircuitSetup Energy Meter Helper: voltage references v1\r\n" + ) + assert block.content.endswith( + "# End CircuitSetup Energy Meter Helper: voltage references v1" + ) + + +def test_friendly_name_only_comes_from_substitutions() -> None: + doc = ESPHomeConfigDocument.parse( + "esphome:\n name: unrelated-device-name\nsubstitutions:\n update_time: 5s\n" + ) + + assert "friendly_name" not in doc.substitutions + assert doc.substitutions["update_time"].value == "5s" + + +@pytest.mark.parametrize("key", ("friendly_name", "update_time", "electric_freq", "csemh_config_contract")) +def test_rejects_duplicate_meter_substitutions(key: str) -> None: + value = { + "friendly_name": "Meter", + "update_time": "2s", + "electric_freq": "60Hz", + "csemh_config_contract": "2", + }[key] + content = f"substitutions:\n {key}: {value}\n {key}: {value}\n" + + with pytest.raises(ESPHomeConfigParseError, match="line 3"): + ESPHomeConfigDocument.parse(content) + + +@pytest.mark.parametrize("value", ("!secret meter", "&meter value", "*meter")) +def test_rejects_unsafe_meter_substitution_values(value: str) -> None: + content = f"substitutions:\n friendly_name: {value}\n" + + with pytest.raises(ESPHomeConfigParseError, match="line 2"): + ESPHomeConfigDocument.parse(content) + + +@pytest.mark.parametrize( + "key, value", + ( + ("friendly_name", "x" * 65), + ("friendly_name", '"bad\\u0000name"'), + ("update_time", "7s"), + ("electric_freq", "55Hz"), + ("csemh_config_contract", "3"), + ), +) +def test_rejects_unbounded_or_unsupported_meter_values(key: str, value: str) -> None: + with pytest.raises(ESPHomeConfigParseError, match="line 2"): + ESPHomeConfigDocument.parse(f"substitutions:\n {key}: {value}\n") + + +@pytest.mark.parametrize( + "content, line", + ( + ( + "# End CircuitSetup Energy Meter Helper: aggregates v1\n", + 1, + ), + ( + ( + "# CircuitSetup Energy Meter Helper: aggregates v1\n" + "# CircuitSetup Energy Meter Helper: aggregates v1\n" + ), + 2, + ), + ( + ( + "# CircuitSetup Energy Meter Helper: aggregates v1\n" + "# End CircuitSetup Energy Meter Helper: aggregates v1\n" + "# CircuitSetup Energy Meter Helper: aggregates v1\n" + ), + 3, + ), + ( + ( + "# CircuitSetup Energy Meter Helper: aggregates v1\n" + "# CircuitSetup Energy Meter Helper: phase overrides v1\n" + ), + 2, + ), + ("# CircuitSetup Energy Meter Helper: aggregates v1\n", 1), + ( + ( + "# CircuitSetup Energy Meter Helper: aggregates v1\n" + "# End CircuitSetup Energy Meter Helper: phase overrides v1\n" + ), + 2, + ), + ), +) +def test_rejects_malformed_managed_blocks( + content: str, line: int +) -> None: + with pytest.raises(ESPHomeConfigParseError, match=rf"line {line}"): + ESPHomeConfigDocument.parse(content) + + +def test_ignores_marker_like_text_outside_exact_column_zero_comments() -> None: + doc = ESPHomeConfigDocument.parse( + "substitutions:\n" + ' friendly_name: "# CircuitSetup Energy Meter Helper: aggregates v1"\n' + " literal: |\n" + " # CircuitSetup Energy Meter Helper: aggregates v1\n" + " # CircuitSetup Energy Meter Helper: aggregates v1\n" + " note: value # End CircuitSetup Energy Meter Helper: aggregates v1\n" + ) + + assert doc.managed_blocks == {} + + @pytest.mark.parametrize( "value", ("&gain 27518", "*gain", "|", ">-", "!secret ct_gain", "# no value"), From a55ec4b249a21b3c8426aef31b86ad146293d44a Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 03:25:36 -0400 Subject: [PATCH 18/35] fix: harden bounded configuration parser --- .../config_document.py | 79 ++++++++++++++++-- tests/test_config_document.py | 82 +++++++++++++++++++ 2 files changed, 153 insertions(+), 8 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/config_document.py b/custom_components/circuitsetup_energy_meter_helper/config_document.py index 513382d..2baa2c9 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_document.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_document.py @@ -14,11 +14,16 @@ METER_SETTING_RE = re.compile( r"^(?:friendly_name|update_time|electric_freq|csemh_config_contract)$" ) -_MAPPING_RE = re.compile(r"^(?P *)(?P[\w-]+):(?P.*)$") -_SEQUENCE_MAPPING_RE = re.compile(r"^(?P *)-\s+(?P[\w-]+):(?P.*)$") +_KEY_TOKEN_RE = r'''(?:[\w-]+|'(?:[^']|'')*'|"(?:[^"\\]|\\.)*")''' +_MAPPING_RE = re.compile(rf"^(?P *)(?P{_KEY_TOKEN_RE}):(?P.*)$") +_SEQUENCE_MAPPING_RE = re.compile( + rf"^(?P *)-\s+(?P{_KEY_TOKEN_RE}):(?P.*)$" +) _SEQUENCE_RE = re.compile(r"^(?P *)-\s+(?P.+)$") _YAML_PATH_RE = re.compile(r"(?i)^(.*?\.ya?ml)(?:@.*)?$") _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") +_LINE_BREAK_RE = re.compile(r"\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]") +_LINE_BREAK_FINAL_CHARS = "\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029" _MAX_DOCUMENT_BYTES = 1_048_576 _MAX_DOCUMENT_LINES = 10_000 _MAX_SETTING_LENGTH = 64 @@ -109,21 +114,30 @@ class _Mapping: class _DocumentParser: def __init__(self, content: str) -> None: - if len(content.encode("utf-8")) > _MAX_DOCUMENT_BYTES: + try: + content_size = len(content.encode("utf-8")) + except UnicodeEncodeError as error: + raise ESPHomeConfigParseError("configuration is not valid UTF-8", 1) from error + if content_size > _MAX_DOCUMENT_BYTES: raise ESPHomeConfigParseError("configuration exceeds byte limit", 1) - line_count = content.count("\n") + content.count("\r") - content.count("\r\n") - if content and content[-1] not in "\r\n": + line_count = 0 + for _ in _LINE_BREAK_RE.finditer(content): + line_count += 1 + if line_count > _MAX_DOCUMENT_LINES: + raise ESPHomeConfigParseError("configuration exceeds line limit", 1) + if content and content[-1] not in _LINE_BREAK_FINAL_CHARS: line_count += 1 if line_count > _MAX_DOCUMENT_LINES: raise ESPHomeConfigParseError("configuration exceeds line limit", 1) self.content = content self.lines = tuple(content.splitlines(keepends=True)) - self._bodies = tuple(line.rstrip("\r\n") for line in self.lines) + self._bodies = tuple(self._line_body(line) for line in self.lines) offset = 0 self._offsets: list[int] = [] for line in self.lines: self._offsets.append(offset) offset += len(line) + self._reject_multiline_quoted_scalars() def parse( self, document_type: type[ESPHomeConfigDocument] @@ -218,6 +232,10 @@ def _substitutions(self) -> dict[str, ConfigScalar]: @staticmethod def _validate_meter_setting(key: str, value: str, line: int) -> None: + try: + value.encode("utf-8") + except UnicodeEncodeError as error: + raise ESPHomeConfigParseError("meter setting is not valid UTF-8", line) from error if not value or len(value) > _MAX_SETTING_LENGTH or _CONTROL_RE.search(value): raise ESPHomeConfigParseError("meter setting is not safely bounded", line) if key == "update_time" and value not in {"1s", "2s", "5s", "10s", "30s", "60s"}: @@ -260,6 +278,32 @@ def _managed_blocks(self) -> dict[str, ManagedBlock]: raise ESPHomeConfigParseError("unterminated managed block", open_block[1] + 1) return blocks + @staticmethod + def _line_body(line: str) -> str: + if line.endswith("\r\n"): + return line[:-2] + if line and line[-1] in _LINE_BREAK_FINAL_CHARS: + return line[:-1] + return line + + def _reject_multiline_quoted_scalars(self) -> None: + for index in range(len(self.lines)): + mapping = self._mapping(index) + if mapping is None: + continue + text = mapping.rest.lstrip(" ") + if not text or text[0] not in "\"'": + continue + try: + if text[0] == "\"": + self._double_quote_end(text, index + 1) + else: + self._single_quote_end(text, index + 1) + except ESPHomeConfigParseError as error: + raise ESPHomeConfigParseError( + "unsupported multiline quoted scalar", index + 1 + ) from error + def _nested_scalar( self, section_name: str, parent_key: str, child_key: str ) -> ConfigScalar | None: @@ -416,7 +460,7 @@ def _mapping(self, index: int) -> _Mapping | None: return None return _Mapping( indent=len(match.group("indent")), - key=match.group("key"), + key=self._mapping_key(match.group("key"), index + 1), rest=match.group("rest"), rest_column=match.start("rest"), ) @@ -427,11 +471,30 @@ def _sequence_mapping(self, index: int) -> _Mapping | None: return None return _Mapping( indent=len(match.group("indent")), - key=match.group("key"), + key=self._mapping_key(match.group("key"), index + 1), rest=match.group("rest"), rest_column=match.start("rest"), ) + @staticmethod + def _mapping_key(token: str, line: int) -> str: + if token[0] == "'": + value = token[1:-1].replace("''", "'") + elif token[0] == "\"": + try: + value = json.loads(token) + except json.JSONDecodeError as error: + raise ESPHomeConfigParseError("invalid quoted mapping key", line) from error + else: + value = token + try: + value.encode("utf-8") + except UnicodeEncodeError as error: + raise ESPHomeConfigParseError("mapping key is not valid UTF-8", line) from error + if _CONTROL_RE.search(value): + raise ESPHomeConfigParseError("mapping key is not safely bounded", line) + return value + def _scalar(self, index: int, mapping: _Mapping) -> ConfigScalar: return self._scalar_parts(index, mapping.rest, mapping.rest_column) diff --git a/tests/test_config_document.py b/tests/test_config_document.py index b5eeec4..14d3a39 100644 --- a/tests/test_config_document.py +++ b/tests/test_config_document.py @@ -226,6 +226,88 @@ def test_ignores_marker_like_text_outside_exact_column_zero_comments() -> None: assert doc.managed_blocks == {} +@pytest.mark.parametrize( + "content", + ( + 'opaque: "unterminated\nsubstitutions:\n friendly_name: Spoofed\n', + "opaque: 'unterminated\nsubstitutions:\n friendly_name: Spoofed\n", + 'opaque: "escaped \\"\nsubstitutions:\n friendly_name: Spoofed\n', + ( + 'opaque: "unterminated\n' + "# CircuitSetup Energy Meter Helper: aggregates v1\n" + ), + ), +) +def test_rejects_multiline_quoted_scalars_before_structural_scanning( + content: str, +) -> None: + with pytest.raises(ESPHomeConfigParseError, match="line 1"): + ESPHomeConfigDocument.parse(content) + + +def test_normalizes_quoted_structural_keys() -> None: + doc = ESPHomeConfigDocument.parse( + '"substitutions":\n' + ' "friendly_name": Meter\n' + ' "update_time": 10s\n' + ' "electric\\u005ffreq": 60Hz\n' + ' \'csemh_config_contract\': 2\n' + ' "ct\\u0031_name": Main CT\n' + ) + + assert { + key: doc.substitutions[key].value + for key in ( + "friendly_name", + "update_time", + "electric_freq", + "csemh_config_contract", + "ct1_name", + ) + } == { + "friendly_name": "Meter", + "update_time": "10s", + "electric_freq": "60Hz", + "csemh_config_contract": "2", + "ct1_name": "Main CT", + } + + +@pytest.mark.parametrize( + "content, line", + ( + ( + 'substitutions:\n friendly_name: Meter\n "friendly_name": Meter\n', + 3, + ), + ('"substitutions":\nsubstitutions:\n', 2), + ), +) +def test_rejects_mixed_quoted_structural_key_duplicates( + content: str, line: int +) -> None: + with pytest.raises(ESPHomeConfigParseError, match=rf"line {line}"): + ESPHomeConfigDocument.parse(content) + + +@pytest.mark.parametrize("separator", ("\x85", "\u2028")) +def test_rejects_all_python_splitline_separators_before_materializing( + separator: str, +) -> None: + with pytest.raises(ESPHomeConfigParseError, match="line 1"): + ESPHomeConfigDocument.parse(separator * 10_001) + + +def test_rejects_raw_unpaired_surrogates() -> None: + with pytest.raises(ESPHomeConfigParseError, match="line 1"): + ESPHomeConfigDocument.parse("substitutions:\n friendly_name: \ud800\n") + + +def test_rejects_decoded_unpaired_surrogates() -> None: + with pytest.raises(ESPHomeConfigParseError, match="line 2"): + ESPHomeConfigDocument.parse('substitutions:\n friendly_name: "\\ud800"\n') + + @pytest.mark.parametrize( "value", ("&gain 27518", "*gain", "|", ">-", "!secret ct_gain", "# no value"), From ae245d13ec48741d6342eb9704575fc43086b30d Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 03:43:23 -0400 Subject: [PATCH 19/35] fix: harden configuration lexical scanning --- .../config_document.py | 157 +++++++++++++++--- tests/test_config_document.py | 89 ++++++++++ 2 files changed, 220 insertions(+), 26 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/config_document.py b/custom_components/circuitsetup_energy_meter_helper/config_document.py index 2baa2c9..0e8afb7 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_document.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_document.py @@ -14,16 +14,30 @@ METER_SETTING_RE = re.compile( r"^(?:friendly_name|update_time|electric_freq|csemh_config_contract)$" ) -_KEY_TOKEN_RE = r'''(?:[\w-]+|'(?:[^']|'')*'|"(?:[^"\\]|\\.)*")''' -_MAPPING_RE = re.compile(rf"^(?P *)(?P{_KEY_TOKEN_RE}):(?P.*)$") +_KEY_TOKEN_RE = r'''(?:<<|[\w-]+|'(?:[^']|'')*'|"(?:[^"\\]|\\.)*")''' +_MAPPING_RE = re.compile( + rf"^(?P *)(?P{_KEY_TOKEN_RE})\s*:(?P.*)$" +) _SEQUENCE_MAPPING_RE = re.compile( - rf"^(?P *)-\s+(?P{_KEY_TOKEN_RE}):(?P.*)$" + rf"^(?P *)-\s+(?P{_KEY_TOKEN_RE})\s*:(?P.*)$" ) _SEQUENCE_RE = re.compile(r"^(?P *)-\s+(?P.+)$") _YAML_PATH_RE = re.compile(r"(?i)^(.*?\.ya?ml)(?:@.*)?$") _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") _LINE_BREAK_RE = re.compile(r"\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]") _LINE_BREAK_FINAL_CHARS = "\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029" +_BLOCK_SCALAR_HEADER_RE = re.compile( + r"^(?P *)(?:-\s+)?(?:[^:#][^:]*:\s*)?(?:(?:![^\s]+|&[^\s]+)\s+)*" + r"[|>][1-9+-]*(?:\s+#.*)?$" +) +_EXPLICIT_KEY_RE = re.compile( + rf"^\s*\?\s+(?P{_KEY_TOKEN_RE})(?:\s*(?::|$))" +) +_PREFIXED_KEY_RE = re.compile( + rf"^\s*(?:[!&][^\s]+\s+)+(?P{_KEY_TOKEN_RE})\s*:" +) +_FLOW_KEY_RE = re.compile(rf"^\s*[{{[]\s*(?P{_KEY_TOKEN_RE})\s*:") +_MERGE_KEY_RE = re.compile(r"^\s*(?:<<\s*:|!!merge\s+['\"]<<['\"]\s*:)") _MAX_DOCUMENT_BYTES = 1_048_576 _MAX_DOCUMENT_LINES = 10_000 _MAX_SETTING_LENGTH = 64 @@ -132,12 +146,13 @@ def __init__(self, content: str) -> None: self.content = content self.lines = tuple(content.splitlines(keepends=True)) self._bodies = tuple(self._line_body(line) for line in self.lines) + self._block_scalar_lines: set[int] = set() offset = 0 self._offsets: list[int] = [] for line in self.lines: self._offsets.append(offset) offset += len(line) - self._reject_multiline_quoted_scalars() + self._scan_lexical_document() def parse( self, document_type: type[ESPHomeConfigDocument] @@ -204,13 +219,13 @@ def _substitutions(self) -> dict[str, ConfigScalar]: indent = len(body) - len(body.lstrip(" ")) if child_indent is None or indent != child_indent: continue - if re.match(r"<<\s*:", body.lstrip()): - raise ESPHomeConfigParseError( - "substitution merges are not locally authoritative", index + 1 - ) mapping = self._mapping(index) if mapping is None: continue + if mapping.key == "<<": + raise ESPHomeConfigParseError( + "substitution merges are not locally authoritative", index + 1 + ) if not ( CT_NAME_RE.fullmatch(mapping.key) or CT_GAIN_RE.fullmatch(mapping.key) @@ -232,10 +247,6 @@ def _substitutions(self) -> dict[str, ConfigScalar]: @staticmethod def _validate_meter_setting(key: str, value: str, line: int) -> None: - try: - value.encode("utf-8") - except UnicodeEncodeError as error: - raise ESPHomeConfigParseError("meter setting is not valid UTF-8", line) from error if not value or len(value) > _MAX_SETTING_LENGTH or _CONTROL_RE.search(value): raise ESPHomeConfigParseError("meter setting is not safely bounded", line) if key == "update_time" and value not in {"1s", "2s", "5s", "10s", "30s", "60s"}: @@ -286,23 +297,109 @@ def _line_body(line: str) -> str: return line[:-1] return line - def _reject_multiline_quoted_scalars(self) -> None: + def _scan_lexical_document(self) -> None: + block_indent: int | None = None for index in range(len(self.lines)): - mapping = self._mapping(index) - if mapping is None: + body = self._bodies[index] + if block_indent is not None: + if not body.strip(): + self._block_scalar_lines.add(index) + continue + indent = len(body) - len(body.lstrip(" ")) + if indent > block_indent: + self._block_scalar_lines.add(index) + continue + block_indent = None + header = _BLOCK_SCALAR_HEADER_RE.fullmatch(body) + if header is not None: + block_indent = len(header.group("indent")) continue - text = mapping.rest.lstrip(" ") - if not text or text[0] not in "\"'": + self._reject_unsafe_structural_syntax(body, index + 1) + self._reject_multiline_quote(body, index + 1) + + def _reject_unsafe_structural_syntax(self, body: str, line: int) -> None: + if _MERGE_KEY_RE.match(body): + raise ESPHomeConfigParseError( + "substitution merges are not locally authoritative", line + ) + for pattern in (_EXPLICIT_KEY_RE, _PREFIXED_KEY_RE, _FLOW_KEY_RE): + match = pattern.match(body) + if match is None: continue - try: - if text[0] == "\"": - self._double_quote_end(text, index + 1) - else: - self._single_quote_end(text, index + 1) - except ESPHomeConfigParseError as error: - raise ESPHomeConfigParseError( - "unsupported multiline quoted scalar", index + 1 - ) from error + if self._is_structural_key(self._mapping_key(match.group("key"), line)): + raise ESPHomeConfigParseError("unsupported structural key syntax", line) + + @staticmethod + def _is_structural_key(key: str) -> bool: + return ( + key + in { + "substitutions", + "esphome", + "dashboard_import", + "packages", + "project", + "name", + "package_import_url", + "files", + "file", + "<<", + } + or CT_NAME_RE.fullmatch(key) is not None + or CT_GAIN_RE.fullmatch(key) is not None + or VOLTAGE_GAIN_RE.fullmatch(key) is not None + or GROUP_NAME_RE.fullmatch(key) is not None + or METER_ID_RE.fullmatch(key) is not None + or METER_SETTING_RE.fullmatch(key) is not None + ) + + def _reject_multiline_quote(self, body: str, line: int) -> None: + expects_token = True + position = 0 + while position < len(body): + character = body[position] + if character == "#" and (position == 0 or body[position - 1].isspace()): + return + if character.isspace(): + position += 1 + continue + if character in "[{,": + expects_token = True + position += 1 + continue + if character in "]}": + expects_token = False + position += 1 + continue + if character == ":": + expects_token = True + position += 1 + continue + if character == "-" and expects_token and ( + position + 1 == len(body) or body[position + 1].isspace() + ): + position += 1 + continue + if expects_token and character in "!&": + while position < len(body) and not body[position].isspace(): + position += 1 + continue + if expects_token and character in "\"'": + try: + end = ( + self._double_quote_end(body[position:], line) + if character == "\"" + else self._single_quote_end(body[position:], line) + ) + except ESPHomeConfigParseError as error: + raise ESPHomeConfigParseError( + "unsupported multiline quoted scalar", line + ) from error + position += end + expects_token = False + continue + expects_token = False + position += 1 def _nested_scalar( self, section_name: str, parent_key: str, child_key: str @@ -455,6 +552,8 @@ def _package_files(self) -> tuple[str, ...]: return tuple(paths) def _mapping(self, index: int) -> _Mapping | None: + if index in self._block_scalar_lines: + return None match = _MAPPING_RE.match(self._bodies[index]) if match is None: return None @@ -466,6 +565,8 @@ def _mapping(self, index: int) -> _Mapping | None: ) def _sequence_mapping(self, index: int) -> _Mapping | None: + if index in self._block_scalar_lines: + return None match = _SEQUENCE_MAPPING_RE.match(self._bodies[index]) if match is None: return None @@ -533,6 +634,10 @@ def _scalar_parts(self, index: int, raw: str, raw_column: int) -> ConfigScalar: tail = text[end:].lstrip(" ") if tail and not tail.startswith("#"): raise ESPHomeConfigParseError("unsupported multiline scalar", line) + try: + value.encode("utf-8") + except UnicodeEncodeError as error: + raise ESPHomeConfigParseError("scalar is not valid UTF-8", line) from error start_column = raw_column + leading end_column = start_column + len(token) return ConfigScalar( diff --git a/tests/test_config_document.py b/tests/test_config_document.py index 14d3a39..92490cf 100644 --- a/tests/test_config_document.py +++ b/tests/test_config_document.py @@ -308,6 +308,95 @@ def test_rejects_decoded_unpaired_surrogates() -> None: ESPHomeConfigDocument.parse('substitutions:\n friendly_name: "\\ud800"\n') +@pytest.mark.parametrize( + "prefix", + ( + "{quote}unterminated", + "- {quote}unterminated", + "opaque: [{quote}unterminated", + "opaque: !str {quote}unterminated", + "opaque: &opaque {quote}unterminated", + ), +) +@pytest.mark.parametrize("quote", ("\"", "'")) +@pytest.mark.parametrize( + "suffix", + ( + "substitutions:\n friendly_name: Spoofed\n", + "# CircuitSetup Energy Meter Helper: aggregates v1\n", + ), +) +def test_rejects_multiline_quotes_from_all_scalar_contexts( + prefix: str, quote: str, suffix: str +) -> None: + with pytest.raises(ESPHomeConfigParseError, match="line 1"): + ESPHomeConfigDocument.parse(prefix.format(quote=quote) + "\n" + suffix) + + +def test_ignores_quotes_and_markers_inside_valid_block_scalars() -> None: + doc = ESPHomeConfigDocument.parse( + "notes: |-\n" + ' an unmatched " quote\n' + " substitutions:\n" + " # CircuitSetup Energy Meter Helper: aggregates v1\n" + "substitutions:\n" + " friendly_name: Meter\n" + ) + + assert doc.substitutions["friendly_name"].value == "Meter" + assert doc.managed_blocks == {} + + +@pytest.mark.parametrize( + "content, line", + ( + ("substitutions :\n friendly_name : Meter\n \"friendly_name\" : Meter\n", 3), + ("? substitutions\n: {}\n", 1), + ("!str substitutions: {}\n", 1), + ("&saved substitutions: {}\n", 1), + ("substitutions:\n ? friendly_name\n : Meter\n", 2), + ("substitutions:\n !str friendly_name: Meter\n", 2), + ("substitutions:\n &saved friendly_name: Meter\n", 2), + ("<<: *defaults\n", 1), + ("substitutions:\n <<: *defaults\n", 2), + ("substitutions:\n !!merge '<<': *defaults\n", 2), + ("{substitutions: {friendly_name: Meter}}\n", 1), + ), +) +def test_rejects_yaml_equivalent_structural_key_syntax( + content: str, line: int +) -> None: + with pytest.raises(ESPHomeConfigParseError, match=rf"line {line}"): + ESPHomeConfigDocument.parse(content) + + +def test_allows_unrelated_value_tags_outside_managed_surface() -> None: + doc = ESPHomeConfigDocument.parse( + "other: !include other.yaml\nsubstitutions:\n friendly_name: Meter\n" + ) + + assert doc.substitutions["friendly_name"].value == "Meter" + + +@pytest.mark.parametrize( + "content, line", + ( + ('substitutions:\n ct1_name: "\\ud800"\n', 2), + ('substitutions:\n current_cal_ct1: "\\ud800"\n', 2), + ('substitutions:\n main_meter_name1: "\\ud800"\n', 2), + ('substitutions:\n main_meter_id1: "\\ud800"\n', 2), + ('esphome:\n project:\n name: "\\ud800"\n', 3), + ('dashboard_import:\n package_import_url: "\\ud800"\n', 2), + ('packages:\n - file: "\\ud800"\n', 2), + ), +) +def test_rejects_decoded_surrogates_from_all_scalar_consumers( + content: str, line: int +) -> None: + with pytest.raises(ESPHomeConfigParseError, match=rf"line {line}"): + ESPHomeConfigDocument.parse(content) + + @pytest.mark.parametrize( "value", ("&gain 27518", "*gain", "|", ">-", "!secret ct_gain", "# no value"), From 213a8178e6df3645b6aa716d318e13c269306663 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 04:01:48 -0400 Subject: [PATCH 20/35] fix: close configuration parser authority bypasses --- .../config_document.py | 66 +++++++++++++------ tests/test_config_document.py | 65 ++++++++++++++++++ 2 files changed, 111 insertions(+), 20 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/config_document.py b/custom_components/circuitsetup_energy_meter_helper/config_document.py index 0e8afb7..937a15d 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_document.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_document.py @@ -16,10 +16,10 @@ ) _KEY_TOKEN_RE = r'''(?:<<|[\w-]+|'(?:[^']|'')*'|"(?:[^"\\]|\\.)*")''' _MAPPING_RE = re.compile( - rf"^(?P *)(?P{_KEY_TOKEN_RE})\s*:(?P.*)$" + rf"^(?P *)(?P{_KEY_TOKEN_RE})[ \t]*:(?P(?:[ \t].*)?)$" ) _SEQUENCE_MAPPING_RE = re.compile( - rf"^(?P *)-\s+(?P{_KEY_TOKEN_RE})\s*:(?P.*)$" + rf"^(?P *)-[ \t]+(?P{_KEY_TOKEN_RE})[ \t]*:(?P(?:[ \t].*)?)$" ) _SEQUENCE_RE = re.compile(r"^(?P *)-\s+(?P.+)$") _YAML_PATH_RE = re.compile(r"(?i)^(.*?\.ya?ml)(?:@.*)?$") @@ -27,17 +27,21 @@ _LINE_BREAK_RE = re.compile(r"\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]") _LINE_BREAK_FINAL_CHARS = "\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029" _BLOCK_SCALAR_HEADER_RE = re.compile( - r"^(?P *)(?:-\s+)?(?:[^:#][^:]*:\s*)?(?:(?:![^\s]+|&[^\s]+)\s+)*" + r"^(?P *)(?P-[ \t]+)?(?:[^:#][^:]*:[ \t]*)?(?:(?:![^\s]+|&[^\s]+)\s+)*" r"[|>][1-9+-]*(?:\s+#.*)?$" ) +_EXPLICIT_BLOCK_SCALAR_RE = re.compile( + r"^(?P *):[ \t]*(?:(?:![^\s]+|&[^\s]+)\s+)*[|>][1-9+-]*(?:\s+#.*)?$" +) _EXPLICIT_KEY_RE = re.compile( - rf"^\s*\?\s+(?P{_KEY_TOKEN_RE})(?:\s*(?::|$))" + rf"^[ \t]*\?[ \t]+(?P{_KEY_TOKEN_RE})(?:[ \t]*(?::|#.*|$))" ) _PREFIXED_KEY_RE = re.compile( rf"^\s*(?:[!&][^\s]+\s+)+(?P{_KEY_TOKEN_RE})\s*:" ) -_FLOW_KEY_RE = re.compile(rf"^\s*[{{[]\s*(?P{_KEY_TOKEN_RE})\s*:") +_FLOW_KEY_RE = re.compile(rf"[{{,][ \t]*(?P{_KEY_TOKEN_RE})[ \t]*:") _MERGE_KEY_RE = re.compile(r"^\s*(?:<<\s*:|!!merge\s+['\"]<<['\"]\s*:)") +_ALIAS_KEY_RE = re.compile(r"^[ \t]*\*[^\s:]+[ \t]*:") _MAX_DOCUMENT_BYTES = 1_048_576 _MAX_DOCUMENT_LINES = 10_000 _MAX_SETTING_LENGTH = 64 @@ -128,12 +132,14 @@ class _Mapping: class _DocumentParser: def __init__(self, content: str) -> None: - try: - content_size = len(content.encode("utf-8")) - except UnicodeEncodeError as error: - raise ESPHomeConfigParseError("configuration is not valid UTF-8", 1) from error - if content_size > _MAX_DOCUMENT_BYTES: - raise ESPHomeConfigParseError("configuration exceeds byte limit", 1) + content_size = 0 + for character in content: + codepoint = ord(character) + if 0xD800 <= codepoint <= 0xDFFF: + raise ESPHomeConfigParseError("configuration is not valid UTF-8", 1) + content_size += 1 if codepoint < 0x80 else 2 if codepoint < 0x800 else 3 if codepoint < 0x10000 else 4 + if content_size > _MAX_DOCUMENT_BYTES: + raise ESPHomeConfigParseError("configuration exceeds byte limit", 1) line_count = 0 for _ in _LINE_BREAK_RE.finditer(content): line_count += 1 @@ -310,24 +316,24 @@ def _scan_lexical_document(self) -> None: self._block_scalar_lines.add(index) continue block_indent = None - header = _BLOCK_SCALAR_HEADER_RE.fullmatch(body) + header = _BLOCK_SCALAR_HEADER_RE.fullmatch(body) or _EXPLICIT_BLOCK_SCALAR_RE.fullmatch(body) if header is not None: - block_indent = len(header.group("indent")) + block_indent = len(header.group("indent")) + len(header.groupdict().get("dash") or "") continue self._reject_unsafe_structural_syntax(body, index + 1) self._reject_multiline_quote(body, index + 1) def _reject_unsafe_structural_syntax(self, body: str, line: int) -> None: + if _ALIAS_KEY_RE.match(body): + raise ESPHomeConfigParseError("unsupported structural key syntax", line) if _MERGE_KEY_RE.match(body): raise ESPHomeConfigParseError( "substitution merges are not locally authoritative", line ) for pattern in (_EXPLICIT_KEY_RE, _PREFIXED_KEY_RE, _FLOW_KEY_RE): - match = pattern.match(body) - if match is None: - continue - if self._is_structural_key(self._mapping_key(match.group("key"), line)): - raise ESPHomeConfigParseError("unsupported structural key syntax", line) + for match in pattern.finditer(body): + if self._is_structural_key(self._mapping_key(match.group("key"), line)): + raise ESPHomeConfigParseError("unsupported structural key syntax", line) @staticmethod def _is_structural_key(key: str) -> bool: @@ -355,6 +361,7 @@ def _is_structural_key(key: str) -> bool: def _reject_multiline_quote(self, body: str, line: int) -> None: expects_token = True + flow_depth = 0 position = 0 while position < len(body): character = body[position] @@ -363,15 +370,30 @@ def _reject_multiline_quote(self, body: str, line: int) -> None: if character.isspace(): position += 1 continue - if character in "[{,": + if body.startswith("---", position) and position == 0 and ( + len(body) == 3 or body[3] in " \t" + ): + position = 3 + expects_token = True + continue + if character == "?" and expects_token and ( + position + 1 == len(body) or body[position + 1] in " \t" + ): + position += 1 + continue + if character in "[{": + flow_depth += 1 expects_token = True position += 1 continue if character in "]}": + flow_depth = max(0, flow_depth - 1) expects_token = False position += 1 continue - if character == ":": + if character == ":" and ( + position + 1 == len(body) or body[position + 1] in " \t#[]{}" + ): expects_token = True position += 1 continue @@ -380,6 +402,10 @@ def _reject_multiline_quote(self, body: str, line: int) -> None: ): position += 1 continue + if character == "," and flow_depth: + expects_token = True + position += 1 + continue if expects_token and character in "!&": while position < len(body) and not body[position].isspace(): position += 1 diff --git a/tests/test_config_document.py b/tests/test_config_document.py index 92490cf..ca3c9f0 100644 --- a/tests/test_config_document.py +++ b/tests/test_config_document.py @@ -397,6 +397,71 @@ def test_rejects_decoded_surrogates_from_all_scalar_consumers( ESPHomeConfigDocument.parse(content) +@pytest.mark.parametrize( + "content", + ( + '--- "before\nsubstitutions:\n friendly_name: Spoofed\nafter"\n', + '? "before\nsubstitutions:\n friendly_name: Spoofed\nafter"\n: value\n', + ), +) +def test_rejects_multiline_quotes_after_document_and_explicit_key_indicators( + content: str, +) -> None: + with pytest.raises(ESPHomeConfigParseError, match="line 1"): + ESPHomeConfigDocument.parse(content) + + +@pytest.mark.parametrize( + "content", + ( + 'opaque: abc:"unterminated\nsubstitutions:\n friendly_name: Meter\n', + 'opaque: abc,"unterminated\nsubstitutions:\n friendly_name: Meter\n', + ), +) +def test_plain_scalar_quotes_do_not_start_multiline_quote_state(content: str) -> None: + assert ESPHomeConfigDocument.parse(content).substitutions["friendly_name"].value == "Meter" + + +@pytest.mark.parametrize( + "content, line", + ( + ("anchor_holder: &k substitutions\n*k: {}\n", 2), + ("substitutions:\n ? friendly_name # comment\n : runtime\n", 2), + ("{other: 1, substitutions: {friendly_name: Meter}}\n", 1), + ), +) +def test_rejects_alias_and_nonfirst_flow_authority_syntax(content: str, line: int) -> None: + with pytest.raises(ESPHomeConfigParseError, match=rf"line {line}"): + ESPHomeConfigDocument.parse(content) + + +def test_requires_yaml_mapping_separation_for_owned_keys() -> None: + doc = ESPHomeConfigDocument.parse( + "substitutions:\n friendly_name:runtime\n friendly_name\u00a0: Meter\n" + ) + + assert doc.substitutions == {} + + +def test_sequence_mapping_block_scalar_does_not_hide_sibling_file() -> None: + doc = ESPHomeConfigDocument.parse( + "packages:\n" + " - config: |-\n" + ' unmatched " quote\n' + " file: Software/ESPHome/meter_sensors/main.yaml\n" + ) + + assert doc.package_files == ("Software/ESPHome/meter_sensors/main.yaml",) + + +def test_root_and_explicit_key_block_scalars_are_opaque() -> None: + for content in ( + '|-\n unmatched " quote\n', + '? note\n: |-\n unmatched " quote\n', + ): + assert ESPHomeConfigDocument.parse(content).substitutions == {} + + @pytest.mark.parametrize( "value", ("&gain 27518", "*gain", "|", ">-", "!secret ct_gain", "# no value"), From 3b9ab4a1ae601831dce967ae6bbd49fc9864da15 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 04:18:27 -0400 Subject: [PATCH 21/35] fix: reject unsupported configuration key forms --- .../config_document.py | 13 +++++-- tests/test_config_document.py | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/config_document.py b/custom_components/circuitsetup_energy_meter_helper/config_document.py index 937a15d..bf1b352 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_document.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_document.py @@ -27,7 +27,7 @@ _LINE_BREAK_RE = re.compile(r"\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]") _LINE_BREAK_FINAL_CHARS = "\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029" _BLOCK_SCALAR_HEADER_RE = re.compile( - r"^(?P *)(?P-[ \t]+)?(?:[^:#][^:]*:[ \t]*)?(?:(?:![^\s]+|&[^\s]+)\s+)*" + r"^(?P *)(?P-[ \t]+)?(?:.+?:[ \t]*)?(?:(?:![^\s]+|&[^\s]+)\s+)*" r"[|>][1-9+-]*(?:\s+#.*)?$" ) _EXPLICIT_BLOCK_SCALAR_RE = re.compile( @@ -37,7 +37,10 @@ rf"^[ \t]*\?[ \t]+(?P{_KEY_TOKEN_RE})(?:[ \t]*(?::|#.*|$))" ) _PREFIXED_KEY_RE = re.compile( - rf"^\s*(?:[!&][^\s]+\s+)+(?P{_KEY_TOKEN_RE})\s*:" + rf"^\s*(?:[!&*][^\s]+\s+)+(?P{_KEY_TOKEN_RE})\s*:" +) +_DECORATED_EXPLICIT_KEY_RE = re.compile( + rf"^[ \t]*\?[ \t]+(?:[!&*][^\s]+[ \t]+)+(?P{_KEY_TOKEN_RE})(?:[ \t]*(?::|#.*|$))" ) _FLOW_KEY_RE = re.compile(rf"[{{,][ \t]*(?P{_KEY_TOKEN_RE})[ \t]*:") _MERGE_KEY_RE = re.compile(r"^\s*(?:<<\s*:|!!merge\s+['\"]<<['\"]\s*:)") @@ -316,21 +319,23 @@ def _scan_lexical_document(self) -> None: self._block_scalar_lines.add(index) continue block_indent = None + self._reject_unsafe_structural_syntax(body, index + 1) header = _BLOCK_SCALAR_HEADER_RE.fullmatch(body) or _EXPLICIT_BLOCK_SCALAR_RE.fullmatch(body) if header is not None: block_indent = len(header.group("indent")) + len(header.groupdict().get("dash") or "") continue - self._reject_unsafe_structural_syntax(body, index + 1) self._reject_multiline_quote(body, index + 1) def _reject_unsafe_structural_syntax(self, body: str, line: int) -> None: + if "{" in body and "}" not in body: + raise ESPHomeConfigParseError("unsupported multiline flow mapping", line) if _ALIAS_KEY_RE.match(body): raise ESPHomeConfigParseError("unsupported structural key syntax", line) if _MERGE_KEY_RE.match(body): raise ESPHomeConfigParseError( "substitution merges are not locally authoritative", line ) - for pattern in (_EXPLICIT_KEY_RE, _PREFIXED_KEY_RE, _FLOW_KEY_RE): + for pattern in (_EXPLICIT_KEY_RE, _DECORATED_EXPLICIT_KEY_RE, _PREFIXED_KEY_RE, _FLOW_KEY_RE): for match in pattern.finditer(body): if self._is_structural_key(self._mapping_key(match.group("key"), line)): raise ESPHomeConfigParseError("unsupported structural key syntax", line) diff --git a/tests/test_config_document.py b/tests/test_config_document.py index ca3c9f0..45f0fc5 100644 --- a/tests/test_config_document.py +++ b/tests/test_config_document.py @@ -462,6 +462,45 @@ def test_root_and_explicit_key_block_scalars_are_opaque() -> None: assert ESPHomeConfigDocument.parse(content).substitutions == {} +@pytest.mark.parametrize( + "content", + ( + "substitutions:\n &k friendly_name: Holder\n friendly_name: Good\n ? *k\n : Evil\n", + "substitutions:\n friendly_name: Good\n ? !!str friendly_name\n : Evil\n", + "substitutions:\n friendly_name: Good\n ? &k friendly_name\n : Evil\n", + ), +) +def test_rejects_decorated_explicit_owned_keys(content: str) -> None: + with pytest.raises(ESPHomeConfigParseError): + ESPHomeConfigDocument.parse(content) + + +@pytest.mark.parametrize("prefix", ("!!str", "&k", "*k")) +def test_rejects_decorated_block_headers_for_owned_keys(prefix: str) -> None: + with pytest.raises(ESPHomeConfigParseError, match="line 2"): + ESPHomeConfigDocument.parse( + f"substitutions:\n {prefix} friendly_name: |-\n Meter\n" + ) + + +def test_rejects_multiline_flow_mappings() -> None: + with pytest.raises(ESPHomeConfigParseError, match="line 2"): + ESPHomeConfigDocument.parse( + "substitutions:\n {other: x, # comment\n friendly_name: Evil}\n" + ) + + +@pytest.mark.parametrize( + "content", + ( + "other:\n |-\n \"unmatched\n", + 'other:\n "a:b": |-\n "unmatched\n', + ), +) +def test_valid_nested_block_scalars_are_opaque(content: str) -> None: + assert ESPHomeConfigDocument.parse(content).substitutions == {} + + @pytest.mark.parametrize( "value", ("&gain 27518", "*gain", "|", ">-", "!secret ct_gain", "# no value"), From 643ff779ab996a9644c73e1c324e21d71001eda0 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 04:34:45 -0400 Subject: [PATCH 22/35] fix: close final configuration parser bypasses --- .../config_document.py | 42 +++++-- tests/test_config_document.py | 107 +++++++++++++++++- 2 files changed, 140 insertions(+), 9 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/config_document.py b/custom_components/circuitsetup_energy_meter_helper/config_document.py index bf1b352..0a8b7aa 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_document.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_document.py @@ -27,7 +27,7 @@ _LINE_BREAK_RE = re.compile(r"\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]") _LINE_BREAK_FINAL_CHARS = "\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029" _BLOCK_SCALAR_HEADER_RE = re.compile( - r"^(?P *)(?P-[ \t]+)?(?:.+?:[ \t]*)?(?:(?:![^\s]+|&[^\s]+)\s+)*" + r"^(?P *)(?P-[ \t]+)?(?P.+?:[ \t]*)?(?:(?:![^\s]+|&[^\s]+)\s+)*" r"[|>][1-9+-]*(?:\s+#.*)?$" ) _EXPLICIT_BLOCK_SCALAR_RE = re.compile( @@ -308,6 +308,7 @@ def _line_body(line: str) -> str: def _scan_lexical_document(self) -> None: block_indent: int | None = None + explicit_key = False for index in range(len(self.lines)): body = self._bodies[index] if block_indent is not None: @@ -319,16 +320,20 @@ def _scan_lexical_document(self) -> None: self._block_scalar_lines.add(index) continue block_indent = None - self._reject_unsafe_structural_syntax(body, index + 1) + structural_body = f"? {body.lstrip()}" if explicit_key else body + self._reject_unsafe_structural_syntax(structural_body, index + 1) header = _BLOCK_SCALAR_HEADER_RE.fullmatch(body) or _EXPLICIT_BLOCK_SCALAR_RE.fullmatch(body) if header is not None: - block_indent = len(header.group("indent")) + len(header.groupdict().get("dash") or "") + block_indent = len(header.group("indent")) + if header.groupdict().get("dash") and header.groupdict().get("mapping"): + block_indent += len(header.group("dash")) + explicit_key = False continue - self._reject_multiline_quote(body, index + 1) + explicit_key = self._scan_lexical_line( + body, index + 1, explicit_key=explicit_key + ) def _reject_unsafe_structural_syntax(self, body: str, line: int) -> None: - if "{" in body and "}" not in body: - raise ESPHomeConfigParseError("unsupported multiline flow mapping", line) if _ALIAS_KEY_RE.match(body): raise ESPHomeConfigParseError("unsupported structural key syntax", line) if _MERGE_KEY_RE.match(body): @@ -364,14 +369,21 @@ def _is_structural_key(key: str) -> bool: or METER_SETTING_RE.fullmatch(key) is not None ) - def _reject_multiline_quote(self, body: str, line: int) -> None: + def _scan_lexical_line( + self, body: str, line: int, *, explicit_key: bool = False + ) -> bool: expects_token = True flow_depth = 0 + flow_mapping_depth = 0 position = 0 while position < len(body): character = body[position] if character == "#" and (position == 0 or body[position - 1].isspace()): - return + if flow_mapping_depth: + raise ESPHomeConfigParseError( + "unsupported multiline flow mapping", line + ) + return explicit_key if character.isspace(): position += 1 continue @@ -384,21 +396,27 @@ def _reject_multiline_quote(self, body: str, line: int) -> None: if character == "?" and expects_token and ( position + 1 == len(body) or body[position + 1] in " \t" ): + explicit_key = True position += 1 continue if character in "[{": flow_depth += 1 + flow_mapping_depth += character == "{" + explicit_key = False expects_token = True position += 1 continue if character in "]}": flow_depth = max(0, flow_depth - 1) + if character == "}": + flow_mapping_depth = max(0, flow_mapping_depth - 1) expects_token = False position += 1 continue if character == ":" and ( position + 1 == len(body) or body[position + 1] in " \t#[]{}" ): + explicit_key = False expects_token = True position += 1 continue @@ -415,6 +433,8 @@ def _reject_multiline_quote(self, body: str, line: int) -> None: while position < len(body) and not body[position].isspace(): position += 1 continue + if expects_token and explicit_key and character == "*": + raise ESPHomeConfigParseError("unsupported structural key syntax", line) if expects_token and character in "\"'": try: end = ( @@ -427,10 +447,16 @@ def _reject_multiline_quote(self, body: str, line: int) -> None: "unsupported multiline quoted scalar", line ) from error position += end + explicit_key = False expects_token = False continue + if expects_token: + explicit_key = False expects_token = False position += 1 + if flow_mapping_depth: + raise ESPHomeConfigParseError("unsupported multiline flow mapping", line) + return explicit_key def _nested_scalar( self, section_name: str, parent_key: str, child_key: str diff --git a/tests/test_config_document.py b/tests/test_config_document.py index 45f0fc5..782cd16 100644 --- a/tests/test_config_document.py +++ b/tests/test_config_document.py @@ -446,7 +446,7 @@ def test_requires_yaml_mapping_separation_for_owned_keys() -> None: def test_sequence_mapping_block_scalar_does_not_hide_sibling_file() -> None: doc = ESPHomeConfigDocument.parse( "packages:\n" - " - config: |-\n" + " - note: |-\n" ' unmatched " quote\n' " file: Software/ESPHome/meter_sensors/main.yaml\n" ) @@ -501,6 +501,111 @@ def test_valid_nested_block_scalars_are_opaque(content: str) -> None: assert ESPHomeConfigDocument.parse(content).substitutions == {} +def test_rejects_explicit_alias_of_owned_key() -> None: + content = ( + "anchor_holder: &k friendly_name\n" + "substitutions:\n" + " friendly_name: Good\n" + " ? *k\n" + " : Evil\n" + ) + + with pytest.raises(ESPHomeConfigParseError, match="line 4"): + ESPHomeConfigDocument.parse(content) + + +@pytest.mark.parametrize( + "key", + ("*k", "friendly_name", "!!str friendly_name", "&other friendly_name"), +) +def test_rejects_multiline_explicit_owned_keys(key: str) -> None: + content = ( + "anchor_holder: &k friendly_name\n" + "substitutions:\n" + " friendly_name: Good\n" + " ?\n" + f" {key}\n" + " : Evil\n" + ) + + with pytest.raises(ESPHomeConfigParseError, match="line 5"): + ESPHomeConfigDocument.parse(content) + + +@pytest.mark.parametrize("separator", ("\n", " # key comment\n")) +def test_pending_explicit_key_ignores_blank_and_comment_lines(separator: str) -> None: + content = ( + "anchor_holder: &k friendly_name\n" + "substitutions:\n" + " friendly_name: Good\n" + " ?\n" + f"{separator}" + " *k\n" + " : Evil\n" + ) + + with pytest.raises(ESPHomeConfigParseError, match="line 6"): + ESPHomeConfigDocument.parse(content) + + +def test_comment_cannot_close_multiline_flow_mapping() -> None: + content = ( + "substitutions:\n" + " {other: x, # ignored }\n" + " friendly_name: Evil}\n" + ) + + with pytest.raises(ESPHomeConfigParseError, match="line 2"): + ESPHomeConfigDocument.parse(content) + + +@pytest.mark.parametrize("header", ("|", "|- # comment", "|1+")) +def test_sequence_block_scalars_are_opaque(header: str) -> None: + doc = ESPHomeConfigDocument.parse( + f"other:\n - {header}\n" + ' "unmatched\n' + "substitutions:\n" + " friendly_name: Meter\n" + ) + + assert doc.substitutions["friendly_name"].value == "Meter" + + +@pytest.mark.parametrize( + ("source", "value"), + ( + ('"Meter {"', "Meter {"), + ("'Meter {'", "Meter {"), + ('"Meter \\"quoted\\" {"', 'Meter "quoted" {'), + ("'Meter ''quoted'' {'", "Meter 'quoted' {"), + ), +) +def test_braces_inside_quoted_scalars_are_not_flow_syntax( + source: str, value: str +) -> None: + content = f"substitutions:\n friendly_name: {source}\n" + + scalar = ESPHomeConfigDocument.parse(content).substitutions["friendly_name"] + + assert scalar.value == value + assert content[scalar.span.start : scalar.span.end] == source + + +@pytest.mark.parametrize( + "prefix", + ( + "other: value # {\n", + "other: |- # {\n text\n", + ), +) +def test_braces_inside_comments_are_not_flow_syntax(prefix: str) -> None: + doc = ESPHomeConfigDocument.parse( + prefix + "substitutions:\n friendly_name: Meter\n" + ) + + assert doc.substitutions["friendly_name"].value == "Meter" + + @pytest.mark.parametrize( "value", ("&gain 27518", "*gain", "|", ">-", "!secret ct_gain", "# no value"), From 5a964972372eb8b2ddfae40c459e3d41cb799b5f Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 04:01:51 -0400 Subject: [PATCH 23/35] refactor: separate meter and voltage topology --- .../config_mutator.py | 4 +- .../config_transaction.py | 4 +- .../models.py | 68 +++++++++ .../circuitsetup_energy_meter_helper/store.py | 20 +++ .../topology.py | 138 ++++++++++++++++++ tests/test_topology.py | 94 ++++++++++++ 6 files changed, 326 insertions(+), 2 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/config_mutator.py b/custom_components/circuitsetup_energy_meter_helper/config_mutator.py index e6afbfb..e9dd598 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_mutator.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_mutator.py @@ -20,6 +20,7 @@ from .ct_inventory import CTInventory from .models import ConfigMutationPlan, MeterTopology, SubstitutionChange from .store import VerifiedCalibrationRecord +from .topology import voltage_reference_fingerprint_for_meter _SUBSTITUTIONS_RE = re.compile(r"^substitutions:\s*(?:#.*)?(?:\r?\n)?$") _SENSOR_RE = re.compile(r"^sensor:\s*(?:#.*)?(?:\r?\n)?$") @@ -218,7 +219,8 @@ def build_calibrated_gain_mutation( verified.topology_addon_count != topology.addon_count or verified.topology_project_name != topology.project_name or verified.topology_connection_type != topology.connection_type - or verified.topology_voltage_layout != topology.voltage_layout + or verified.topology_voltage_fingerprint + != voltage_reference_fingerprint_for_meter(topology) ): raise ConfigMutationError("verified calibration topology does not match target") document = ESPHomeConfigDocument.parse(snapshot.content) diff --git a/custom_components/circuitsetup_energy_meter_helper/config_transaction.py b/custom_components/circuitsetup_energy_meter_helper/config_transaction.py index 65ca917..198cc38 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_transaction.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_transaction.py @@ -37,6 +37,7 @@ ) from .session_manager import ConfigLease, SessionManager from .store import VerifiedCalibrationRecord +from .topology import voltage_reference_fingerprint_for_meter MAX_VISIBLE_DIFF_BYTES = 32_768 MAX_VISIBLE_DIFF_LINES = 512 @@ -421,7 +422,8 @@ async def async_preview_calibrated_gains( verified.topology_addon_count != topology.addon_count or verified.topology_project_name != topology.project_name or verified.topology_connection_type != topology.connection_type - or verified.topology_voltage_layout != topology.voltage_layout + or verified.topology_voltage_fingerprint + != voltage_reference_fingerprint_for_meter(topology) ): raise ConfigMutationError( "verified calibration topology does not match target" diff --git a/custom_components/circuitsetup_energy_meter_helper/models.py b/custom_components/circuitsetup_energy_meter_helper/models.py index 163e38e..79ee2f5 100644 --- a/custom_components/circuitsetup_energy_meter_helper/models.py +++ b/custom_components/circuitsetup_energy_meter_helper/models.py @@ -2,10 +2,12 @@ from __future__ import annotations +import json import re from dataclasses import dataclass from dataclasses import field as dataclass_field from enum import StrEnum +from hashlib import sha256 from typing import Literal from .ct_catalog import REPORTING_MULTIPLIERS @@ -217,6 +219,72 @@ def from_addon_count( ) +@dataclass(slots=True, frozen=True) +class VoltageReferenceTopology: + """Validated voltage-reference assignments independent of board counts.""" + + references: tuple[tuple[str, tuple[str, ...]], ...] + source: Literal["helper", "legacy"] + + @classmethod + def from_legacy(cls, board_count: int, voltage_layout: str) -> VoltageReferenceTopology: + """Build the compatibility topology encoded by old project suffixes.""" + if not 1 <= board_count <= 7: + raise ValueError("board_count must be between 1 and 7") + groups = tuple( + f"{('main' if board == 0 else f'addon{board}')}_{group}" + for board in range(board_count) + for group in (1, 2) + ) + if voltage_layout == "standard": + references: tuple[tuple[str, tuple[str, ...]], ...] = (("main", groups),) + elif voltage_layout == "two_voltages": + references = (("main", groups[::2]), ("secondary", groups[1::2])) + else: + raise ValueError(f"unknown legacy voltage layout: {voltage_layout!r}") + return cls(references, "legacy") + + def __post_init__(self) -> None: + if not self.references or self.source not in {"helper", "legacy"}: + raise ValueError("voltage-reference topology is invalid") + reference_ids: set[str] = set() + groups: list[str] = [] + for reference_id, group_keys in self.references: + if ( + not isinstance(reference_id, str) + or not reference_id + or reference_id in reference_ids + or not isinstance(group_keys, tuple) + or not group_keys + ): + raise ValueError("voltage-reference topology is invalid") + reference_ids.add(reference_id) + for group_key_value in group_keys: + if not isinstance(group_key_value, str) or re.fullmatch( + r"(?:main|addon[1-6])_[12]", group_key_value + ) is None: + raise ValueError("voltage-reference topology is invalid") + groups.append(group_key_value) + if len(groups) != len(set(groups)): + raise ValueError("voltage-reference groups must be unique") + + @property + def reference_ids(self) -> tuple[str, ...]: + return tuple(reference_id for reference_id, _ in self.references) + + def groups_for(self, reference_id: str) -> tuple[str, ...]: + for current_id, groups in self.references: + if current_id == reference_id: + return groups + raise KeyError(reference_id) + + @property + def fingerprint(self) -> str: + """Return a deterministic identity for ordered IDs and assignments.""" + canonical = json.dumps(self.references, separators=(",", ":")) + return f"v1:{sha256(canonical.encode()).hexdigest()}" + + @dataclass(slots=True, frozen=True) class ChannelAddress: """Board, local group, and phase for one global CT channel.""" diff --git a/custom_components/circuitsetup_energy_meter_helper/store.py b/custom_components/circuitsetup_energy_meter_helper/store.py index b90b335..8d4255e 100644 --- a/custom_components/circuitsetup_energy_meter_helper/store.py +++ b/custom_components/circuitsetup_energy_meter_helper/store.py @@ -35,6 +35,7 @@ StoredMeterRecord, StoredTopology, StoredTopologyEvidence, + VoltageReferenceTopology, canonical_mac, ) @@ -136,6 +137,7 @@ class VerifiedCalibrationRecord: connection_generation: int groups: tuple[VerifiedGainGroup, ...] verification_id: str + topology_voltage_fingerprint: str | None = None offset_groups: tuple[VerifiedOffsetGroup, ...] = () power_offset_groups: tuple[VerifiedPowerOffsetGroup, ...] = () source_authority: CalibrationSourceAuthority = ( @@ -170,6 +172,19 @@ def __post_init__(self) -> None: raise ValueError("topology project name must be a non-empty single line") if not self.topology_connection_type or not self.topology_voltage_layout: raise ValueError("topology connection and voltage layout are required") + if self.topology_voltage_fingerprint is None: + try: + fingerprint = VoltageReferenceTopology.from_legacy( + self.topology_addon_count + 1, self.topology_voltage_layout + ).fingerprint + except ValueError: + fingerprint = f"legacy:{self.topology_voltage_layout}" + object.__setattr__(self, "topology_voltage_fingerprint", fingerprint) + elif re.fullmatch( + r"(?:v1:[0-9a-f]{64}|legacy:[a-z0-9_-]{1,64})", + self.topology_voltage_fingerprint, + ) is None: + raise ValueError("topology voltage fingerprint is invalid") if self.connection_generation < 1 or not ( self.groups or self.offset_groups or self.power_offset_groups ): @@ -349,6 +364,10 @@ def _serialize_verified_calibration( record.source_handoff_firmware_installed ), } + if record.topology_voltage_fingerprint is not None and not record.topology_voltage_fingerprint.startswith( + "legacy:" + ): + serialized["topology_voltage_fingerprint"] = record.topology_voltage_fingerprint if record.offset_groups: serialized["offset_groups"] = [ { @@ -426,6 +445,7 @@ def _deserialize_verified_calibration( connection_generation=raw["connection_generation"], groups=tuple(groups), verification_id=raw["verification_id"], + topology_voltage_fingerprint=raw.get("topology_voltage_fingerprint"), offset_groups=tuple(offset_groups), power_offset_groups=tuple(power_offset_groups), source_authority=authority, diff --git a/custom_components/circuitsetup_energy_meter_helper/topology.py b/custom_components/circuitsetup_energy_meter_helper/topology.py index 823cb7c..e62979d 100644 --- a/custom_components/circuitsetup_energy_meter_helper/topology.py +++ b/custom_components/circuitsetup_energy_meter_helper/topology.py @@ -5,6 +5,7 @@ import re from collections.abc import Iterable from dataclasses import replace +from typing import TYPE_CHECKING from .config_document import ESPHomeConfigDocument from .models import ( @@ -14,8 +15,12 @@ Phase, TopologyEvidence, TopologyEvidenceSource, + VoltageReferenceTopology, ) +if TYPE_CHECKING: + from .meter_configuration import MeterConfigurationRequest + BASE_PROJECT = "circuitsetup.6c-energy-meter" _ADDON_SEGMENT_RE = re.compile(r"-(?P[1-6])-addons?(?=-|$)") _ADDON_PACKAGE_RE = re.compile( @@ -87,6 +92,139 @@ def voltage_layout_from_project(name: str) -> str: return _project_metadata(name)[2] +def _expected_group_keys(topology: MeterTopology) -> tuple[str, ...]: + from .entity_binding import group_key + + return tuple( + group_key(board, group) + for board in range(topology.board_count) + for group in range(2) + ) + + +def voltage_reference_topology_from_legacy( + topology: MeterTopology, +) -> VoltageReferenceTopology: + """Infer references from legacy project metadata only.""" + groups = _expected_group_keys(topology) + if topology.voltage_layout == "standard": + references: tuple[tuple[str, tuple[str, ...]], ...] = (("main", groups),) + elif topology.voltage_layout == "two_voltages": + references = ( + ("main", groups[::2]), + ("secondary", groups[1::2]), + ) + else: + raise TopologyParseError( + f"unknown legacy voltage layout: {topology.voltage_layout!r}" + ) + return VoltageReferenceTopology(references, "legacy") + + +def voltage_reference_fingerprint_for_meter(topology: MeterTopology) -> str: + """Return the normalized identity for a legacy-only meter topology.""" + try: + return voltage_reference_topology_from_legacy(topology).fingerprint + except TopologyParseError: + return f"legacy:{topology.voltage_layout}" + + +def _validated_voltage_reference_topology( + topology: MeterTopology, + references: tuple[tuple[str, tuple[str, ...]], ...], +) -> VoltageReferenceTopology: + expected = set(_expected_group_keys(topology)) + assigned = [group for _, groups in references for group in groups] + if ( + not references + or len(assigned) != len(expected) + or len(set(assigned)) != len(assigned) + or set(assigned) != expected + ): + raise TopologyParseError("voltage-reference groups must be assigned exactly once") + try: + return VoltageReferenceTopology(references, "helper") + except ValueError as error: + raise TopologyParseError("invalid helper voltage-reference topology") from error + + +def voltage_reference_topology_from_configuration( + topology: MeterTopology, + configuration: MeterConfigurationRequest, +) -> VoltageReferenceTopology: + """Use helper-managed reference assignments after structural validation.""" + return _validated_voltage_reference_topology( + topology, + tuple( + (reference.reference_id, tuple(reference.group_keys)) + for reference in configuration.meter.voltage_references + ), + ) + + +def _managed_voltage_reference_assignments( + document: ESPHomeConfigDocument, topology: MeterTopology +) -> tuple[tuple[str, tuple[str, ...]], ...] | None: + block = document.managed_blocks.get("voltage_references") + if block is None: + return None + entries: list[tuple[str, tuple[str, ...] | None]] = [] + in_section = False + for raw_line in block.content.splitlines(): + line = raw_line.split("#", 1)[0].rstrip() + if line.strip() == "voltage_references:": + in_section = True + continue + if not in_section or not line.strip(): + continue + match = re.fullmatch(r"\s{2}([A-Za-z][A-Za-z0-9_-]{0,63}):\s*(.*)", line) + if match is None: + if line and not line.startswith(" "): + break + continue + reference_id, value = match.groups() + groups: tuple[str, ...] | None = None + if value.startswith("[") and value.endswith("]"): + values = tuple(item.strip().strip("'\"") for item in value[1:-1].split(",") if item.strip()) + groups = values or None + entries.append((reference_id, groups)) + if not entries or len(entries) > 2: + return None + expected = _expected_group_keys(topology) + if any(groups is None for _, groups in entries): + if len(entries) == 1: + return ((entries[0][0], expected),) + return tuple( + (reference_id, expected[index::2]) + for index, (reference_id, _) in enumerate(entries) + ) + return tuple((reference_id, groups or ()) for reference_id, groups in entries) + + +def voltage_reference_topology_from_config( + document: ESPHomeConfigDocument, + topology: MeterTopology, + configuration: MeterConfigurationRequest | None = None, +) -> VoltageReferenceTopology: + """Prefer verified helper semantics; otherwise use legacy project evidence.""" + if configuration is not None: + try: + return voltage_reference_topology_from_configuration(topology, configuration) + except TopologyParseError: + pass + managed = _managed_voltage_reference_assignments(document, topology) + if managed is not None: + try: + return _validated_voltage_reference_topology(topology, managed) + except TopologyParseError: + pass + return voltage_reference_topology_from_legacy(topology) + + +# Short alias for callers that treat board and voltage topology uniformly. +voltage_topology_from_config = voltage_reference_topology_from_config + + def addon_count_from_packages(package_files: Iterable[str]) -> int | None: """Count unique contiguous add-on sensor packages.""" indices: set[int] = set() diff --git a/tests/test_topology.py b/tests/test_topology.py index a8db562..7ad0270 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -5,11 +5,15 @@ from custom_components.circuitsetup_energy_meter_helper.config_document import ( ESPHomeConfigDocument, ) +from custom_components.circuitsetup_energy_meter_helper.meter_configuration import ( + default_meter_configuration, +) from custom_components.circuitsetup_energy_meter_helper.models import ( ChannelAddress, MeterTopology, SetupState, TopologyEvidenceSource, + VoltageReferenceTopology, ) from custom_components.circuitsetup_energy_meter_helper.provisioning import ( ProvisioningSnapshot, @@ -26,6 +30,9 @@ topology_from_config, topology_from_native, voltage_layout_from_project, + voltage_reference_topology_from_config, + voltage_reference_topology_from_configuration, + voltage_reference_topology_from_legacy, ) @@ -303,3 +310,90 @@ def test_native_only_topology_remains_provisional() -> None: assert topology.evidence[0].source is TopologyEvidenceSource.NATIVE_PROJECT assert not hasattr(topology, "configuration_authoritative") assert not snapshot.configuration_authoritative + + +def test_standard_legacy_project_maps_every_group_to_one_reference() -> None: + meter = topology_from_native("circuitsetup.6c-energy-meter-1-addon") + voltage = voltage_reference_topology_from_legacy(meter) + + assert isinstance(voltage, VoltageReferenceTopology) + assert voltage.reference_ids == ("main",) + assert voltage.groups_for("main") == ( + "main_1", "main_2", "addon1_1", "addon1_2" + ) + + +def test_two_voltage_legacy_project_is_multi_reference_evidence() -> None: + meter = topology_from_native("circuitsetup.6c-energy-meter-1-addon-2-voltages") + voltage = voltage_reference_topology_from_legacy(meter) + + assert voltage.reference_ids == ("main", "secondary") + assert set(voltage.groups_for("main")) | set(voltage.groups_for("secondary")) == { + "main_1", "main_2", "addon1_1", "addon1_2" + } + + +def test_helper_configuration_overrides_legacy_layout_when_structurally_valid() -> None: + meter = topology_from_native("circuitsetup.6c-energy-meter-1-addon-2-voltages") + request = default_meter_configuration( + meter, {"power_quality": (False, False), "status_fields": (True, False)} + ) + + voltage = voltage_reference_topology_from_configuration(meter, request) + + assert voltage.reference_ids == ("main",) + assert voltage.source == "helper" + + +def test_managed_voltage_block_overrides_legacy_layout() -> None: + document = ESPHomeConfigDocument.parse( + "esphome:\n" + " project:\n" + " name: circuitsetup.6c-energy-meter-2-voltages\n" + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + " main: 120\n" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_config(document) + + voltage = voltage_reference_topology_from_config(document, meter) + + assert voltage.source == "helper" + assert voltage.reference_ids == ("main",) + + +def test_helper_configuration_must_cover_each_group_once() -> None: + meter = topology_from_native("circuitsetup.6c-energy-meter") + request = default_meter_configuration( + meter, {"power_quality": (False,), "status_fields": (True,)} + ) + reference = request.meter.voltage_references[0] + object.__setattr__(request.meter, "voltage_references", (reference.__class__( + reference.reference_id, + reference.label, + reference.phase_label, + reference.nominal_voltage_v, + reference.transformer_model_id, + reference.gain_voltage, + ("main_1", "main_1"), + ),)) + + with pytest.raises(ValueError, match="assigned exactly once"): + voltage_reference_topology_from_configuration(meter, request) + + +def test_unknown_project_suffix_still_fails_closed_for_board_count() -> None: + with pytest.raises(TopologyParseError): + topology_from_native("circuitsetup.6c-energy-meter-1-addons-custom") + + +def test_voltage_reference_fingerprint_is_ordered_and_has_no_board_revision() -> None: + meter = topology_from_native("circuitsetup.6c-energy-meter") + request = default_meter_configuration( + meter, {"power_quality": (False,), "status_fields": (True,)} + ) + voltage = voltage_reference_topology_from_configuration(meter, request) + + assert voltage.fingerprint == voltage.fingerprint + assert "board_revision" not in VoltageReferenceTopology.__slots__ From d499bc93d3e9ac062ea3ea28f622a1821bbe53a5 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 04:17:54 -0400 Subject: [PATCH 24/35] fix: preserve verified voltage topology identity --- .../calibration_engine.py | 20 ++++- .../config_mutator.py | 15 +++- .../config_transaction.py | 33 ++++--- .../models.py | 18 ---- .../session_manager.py | 3 + .../circuitsetup_energy_meter_helper/store.py | 4 +- .../topology.py | 38 +++++--- tests/test_restart_verification.py | 86 +++++++++++++++++++ tests/test_store.py | 43 ++++++++++ 9 files changed, 212 insertions(+), 48 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/calibration_engine.py b/custom_components/circuitsetup_energy_meter_helper/calibration_engine.py index 5339297..5319d6a 100644 --- a/custom_components/circuitsetup_energy_meter_helper/calibration_engine.py +++ b/custom_components/circuitsetup_energy_meter_helper/calibration_engine.py @@ -60,7 +60,11 @@ VerifiedOffsetGroup, VerifiedPowerOffsetGroup, ) -from .topology import topology_from_config +from .topology import ( + topology_from_config, + voltage_reference_fingerprint_for_meter, + voltage_reference_topology_from_config, +) DEFAULT_EVIDENCE_TIMEOUT = 35.0 @@ -348,6 +352,7 @@ async def async_verify_after_restart( topology_project_name=pending.topology.project_name, topology_connection_type=pending.topology.connection_type, topology_voltage_layout=pending.topology.voltage_layout, + topology_voltage_fingerprint=pending.voltage_topology_fingerprint, connection_generation=generation, groups=groups, verification_id=uuid4().hex, @@ -438,15 +443,19 @@ async def _calibration_origin( raise ValueError("authoritative configuration hash is invalid") if _CONFIGURATION_ID.fullmatch(snapshot.configuration) is None: raise ValueError("authoritative configuration filename is invalid") + document = ESPHomeConfigDocument.parse(snapshot.content) try: source_topology = topology_from_config( - ESPHomeConfigDocument.parse(snapshot.content), + document, native_project_name=binding.topology.project_name, ) except ValueError as error: raise ValueError( "authoritative configuration topology is invalid" ) from error + source_voltage_fingerprint = voltage_reference_topology_from_config( + document, source_topology + ).fingerprint if not _same_topology_identity(source_topology, binding.topology): raise ValueError( "authoritative configuration topology does not match the session" @@ -456,13 +465,18 @@ async def _calibration_origin( snapshot.configuration != pending.config_filename or snapshot.sha256 != pending.config_sha256 or not _same_topology_identity(source_topology, pending.topology) + or ( + pending.voltage_topology_fingerprint + or voltage_reference_fingerprint_for_meter(source_topology) + ) + != source_voltage_fingerprint ): raise ValueError( "authoritative configuration changed since calibration began" ) return pending return self.sessions._begin_calibration_origin( - lease, session, binding, snapshot + lease, session, binding, snapshot, source_voltage_fingerprint ) async def async_zero_all_references( diff --git a/custom_components/circuitsetup_energy_meter_helper/config_mutator.py b/custom_components/circuitsetup_energy_meter_helper/config_mutator.py index e9dd598..5e1385c 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_mutator.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_mutator.py @@ -20,7 +20,10 @@ from .ct_inventory import CTInventory from .models import ConfigMutationPlan, MeterTopology, SubstitutionChange from .store import VerifiedCalibrationRecord -from .topology import voltage_reference_fingerprint_for_meter +from .topology import ( + voltage_reference_fingerprint_for_meter, + voltage_reference_topology_from_config, +) _SUBSTITUTIONS_RE = re.compile(r"^substitutions:\s*(?:#.*)?(?:\r?\n)?$") _SENSOR_RE = re.compile(r"^sensor:\s*(?:#.*)?(?:\r?\n)?$") @@ -208,6 +211,13 @@ def build_calibrated_gain_mutation( current_hash = sha256(snapshot.content.encode()).hexdigest() if current_hash != snapshot.sha256: raise ConfigMutationError("configuration snapshot hash does not match content") + document = ESPHomeConfigDocument.parse(snapshot.content) + try: + current_voltage_fingerprint = voltage_reference_topology_from_config( + document, topology + ).fingerprint + except ValueError: + current_voltage_fingerprint = voltage_reference_fingerprint_for_meter(topology) if ( snapshot.configuration != verified.config_filename or snapshot.sha256 != verified.config_sha256 @@ -220,10 +230,9 @@ def build_calibrated_gain_mutation( or verified.topology_project_name != topology.project_name or verified.topology_connection_type != topology.connection_type or verified.topology_voltage_fingerprint - != voltage_reference_fingerprint_for_meter(topology) + != current_voltage_fingerprint ): raise ConfigMutationError("verified calibration topology does not match target") - document = ESPHomeConfigDocument.parse(snapshot.content) requests = tuple(requested_channels) _validate_requests(requests, topology) catalog = CTPresetCatalog.load() diff --git a/custom_components/circuitsetup_energy_meter_helper/config_transaction.py b/custom_components/circuitsetup_energy_meter_helper/config_transaction.py index 198cc38..93f2564 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_transaction.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_transaction.py @@ -37,7 +37,10 @@ ) from .session_manager import ConfigLease, SessionManager from .store import VerifiedCalibrationRecord -from .topology import voltage_reference_fingerprint_for_meter +from .topology import ( + voltage_reference_fingerprint_for_meter, + voltage_reference_topology_from_config, +) MAX_VISIBLE_DIFF_BYTES = 32_768 MAX_VISIBLE_DIFF_LINES = 512 @@ -418,16 +421,6 @@ async def async_preview_calibrated_gains( "YAML handoff is unavailable; offset calibration remains saved " "in flash" ) - if ( - verified.topology_addon_count != topology.addon_count - or verified.topology_project_name != topology.project_name - or verified.topology_connection_type != topology.connection_type - or verified.topology_voltage_fingerprint - != voltage_reference_fingerprint_for_meter(topology) - ): - raise ConfigMutationError( - "verified calibration topology does not match target" - ) if ( not verified.source_handoff_available or verified.config_filename is None @@ -437,6 +430,24 @@ async def async_preview_calibrated_gains( snapshot = await self._device_builder.async_get_config( verified.config_filename ) + try: + current_voltage_fingerprint = voltage_reference_topology_from_config( + ESPHomeConfigDocument.parse(snapshot.content), topology + ).fingerprint + except ValueError: + current_voltage_fingerprint = voltage_reference_fingerprint_for_meter( + topology + ) + if ( + verified.topology_addon_count != topology.addon_count + or verified.topology_project_name != topology.project_name + or verified.topology_connection_type != topology.connection_type + or verified.topology_voltage_fingerprint + != current_voltage_fingerprint + ): + raise ConfigMutationError( + "verified calibration topology does not match target" + ) plan = build_calibrated_gain_mutation( snapshot, topology, diff --git a/custom_components/circuitsetup_energy_meter_helper/models.py b/custom_components/circuitsetup_energy_meter_helper/models.py index 79ee2f5..b6907fe 100644 --- a/custom_components/circuitsetup_energy_meter_helper/models.py +++ b/custom_components/circuitsetup_energy_meter_helper/models.py @@ -226,24 +226,6 @@ class VoltageReferenceTopology: references: tuple[tuple[str, tuple[str, ...]], ...] source: Literal["helper", "legacy"] - @classmethod - def from_legacy(cls, board_count: int, voltage_layout: str) -> VoltageReferenceTopology: - """Build the compatibility topology encoded by old project suffixes.""" - if not 1 <= board_count <= 7: - raise ValueError("board_count must be between 1 and 7") - groups = tuple( - f"{('main' if board == 0 else f'addon{board}')}_{group}" - for board in range(board_count) - for group in (1, 2) - ) - if voltage_layout == "standard": - references: tuple[tuple[str, tuple[str, ...]], ...] = (("main", groups),) - elif voltage_layout == "two_voltages": - references = (("main", groups[::2]), ("secondary", groups[1::2])) - else: - raise ValueError(f"unknown legacy voltage layout: {voltage_layout!r}") - return cls(references, "legacy") - def __post_init__(self) -> None: if not self.references or self.source not in {"helper", "legacy"}: raise ValueError("voltage-reference topology is invalid") diff --git a/custom_components/circuitsetup_energy_meter_helper/session_manager.py b/custom_components/circuitsetup_energy_meter_helper/session_manager.py index 078776f..5d7c5bf 100644 --- a/custom_components/circuitsetup_energy_meter_helper/session_manager.py +++ b/custom_components/circuitsetup_energy_meter_helper/session_manager.py @@ -76,6 +76,7 @@ class PendingCalibrationOrigin: offset_groups: tuple[tuple[str, PhaseOffsetTable], ...] = () power_offset_groups: tuple[tuple[str, PhasePowerOffsetTable], ...] = () claimed_revision: int | None = None + voltage_topology_fingerprint: str | None = None @property def expected_phase_gains(self) -> dict[str, PhaseGainTable]: @@ -194,6 +195,7 @@ def _begin_calibration_origin( session: Any, binding: MeterBinding, snapshot: ESPHomeConfigSnapshot | None, + voltage_topology_fingerprint: str | None = None, ) -> PendingCalibrationOrigin: """Freeze an internally fetched configuration under its active lease.""" self._require_active_calibration_lease(lease) @@ -207,6 +209,7 @@ def _begin_calibration_origin( topology=binding.topology, config_filename=(snapshot.configuration if snapshot is not None else None), config_sha256=(snapshot.sha256 if snapshot is not None else None), + voltage_topology_fingerprint=voltage_topology_fingerprint, gain_groups=(), ) self._pending_calibrations[lease.mac] = pending diff --git a/custom_components/circuitsetup_energy_meter_helper/store.py b/custom_components/circuitsetup_energy_meter_helper/store.py index 8d4255e..a446cb5 100644 --- a/custom_components/circuitsetup_energy_meter_helper/store.py +++ b/custom_components/circuitsetup_energy_meter_helper/store.py @@ -35,9 +35,9 @@ StoredMeterRecord, StoredTopology, StoredTopologyEvidence, - VoltageReferenceTopology, canonical_mac, ) +from .topology import legacy_voltage_reference_topology STORAGE_VERSION = 1 STORAGE_MINOR_VERSION = 4 @@ -174,7 +174,7 @@ def __post_init__(self) -> None: raise ValueError("topology connection and voltage layout are required") if self.topology_voltage_fingerprint is None: try: - fingerprint = VoltageReferenceTopology.from_legacy( + fingerprint = legacy_voltage_reference_topology( self.topology_addon_count + 1, self.topology_voltage_layout ).fingerprint except ValueError: diff --git a/custom_components/circuitsetup_energy_meter_helper/topology.py b/custom_components/circuitsetup_energy_meter_helper/topology.py index e62979d..c85a754 100644 --- a/custom_components/circuitsetup_energy_meter_helper/topology.py +++ b/custom_components/circuitsetup_energy_meter_helper/topology.py @@ -102,25 +102,41 @@ def _expected_group_keys(topology: MeterTopology) -> tuple[str, ...]: ) -def voltage_reference_topology_from_legacy( - topology: MeterTopology, +def legacy_voltage_reference_topology( + board_count: int, voltage_layout: str ) -> VoltageReferenceTopology: - """Infer references from legacy project metadata only.""" - groups = _expected_group_keys(topology) - if topology.voltage_layout == "standard": - references: tuple[tuple[str, tuple[str, ...]], ...] = (("main", groups),) - elif topology.voltage_layout == "two_voltages": - references = ( - ("main", groups[::2]), - ("secondary", groups[1::2]), + """Build the compatibility topology encoded by old project suffixes.""" + if not 1 <= board_count <= 7: + raise TopologyParseError("board_count must be between 1 and 7") + groups = _expected_group_keys( + MeterTopology.from_addon_count( + board_count - 1, + connection_type="unknown", + voltage_layout=voltage_layout, + project_name="legacy", + evidence=(), ) + ) + if voltage_layout == "standard": + references: tuple[tuple[str, tuple[str, ...]], ...] = (("main", groups),) + elif voltage_layout == "two_voltages": + references = (("main", groups[::2]), ("secondary", groups[1::2])) else: raise TopologyParseError( - f"unknown legacy voltage layout: {topology.voltage_layout!r}" + f"unknown legacy voltage layout: {voltage_layout!r}" ) return VoltageReferenceTopology(references, "legacy") +def voltage_reference_topology_from_legacy( + topology: MeterTopology, +) -> VoltageReferenceTopology: + """Infer references from legacy project metadata only.""" + return legacy_voltage_reference_topology( + topology.board_count, topology.voltage_layout + ) + + def voltage_reference_fingerprint_for_meter(topology: MeterTopology) -> str: """Return the normalized identity for a legacy-only meter topology.""" try: diff --git a/tests/test_restart_verification.py b/tests/test_restart_verification.py index 080d3ec..0009a89 100644 --- a/tests/test_restart_verification.py +++ b/tests/test_restart_verification.py @@ -19,6 +19,9 @@ RestartDisconnectTimeoutError, RestartVerificationError, ) +from custom_components.circuitsetup_energy_meter_helper.config_document import ( + ESPHomeConfigDocument, +) from custom_components.circuitsetup_energy_meter_helper.config_mutator import ( ConfigMutationError, CTChangeRequest, @@ -51,6 +54,10 @@ VerifiedGainGroup, VerifiedOffsetGroup, ) +from custom_components.circuitsetup_energy_meter_helper.topology import ( + voltage_reference_fingerprint_for_meter, + voltage_reference_topology_from_config, +) from tests.test_calibration_engine_current import native_meter from tests.test_calibration_engine_voltage import ( FakeCalibrationSession, @@ -1279,6 +1286,43 @@ def test_uniform_gains_build_surgical_hash_bound_source_mutation() -> None: assert record.source_authority is CalibrationSourceAuthority.SAVED_FLASH +def test_calibrated_gain_handoff_uses_verified_helper_voltage_fingerprint() -> None: + content = _snapshot().content.replace( + "name: circuitsetup.6c-energy-meter\n", + "name: circuitsetup.6c-energy-meter-2-voltages\n", + ).replace( + "logger:\n", + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + " main: 120\n" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + "logger:\n", + ) + snapshot = _snapshot(content) + target = replace( + topology(0), + voltage_layout="two_voltages", + project_name="circuitsetup.6c-energy-meter-2-voltages", + ) + helper_fingerprint = voltage_reference_topology_from_config( + ESPHomeConfigDocument.parse(content), target + ).fingerprint + record = replace( + _record(snapshot, ((7301, 28001), (7301, 28002), (7301, 28003))), + topology_voltage_layout="two_voltages", + topology_project_name=target.project_name, + topology_voltage_fingerprint=helper_fingerprint, + ) + + build_calibrated_gain_mutation(snapshot, target, record) + with pytest.raises(ConfigMutationError, match="topology"): + build_calibrated_gain_mutation( + snapshot, + target, + replace(record, topology_voltage_fingerprint=voltage_reference_fingerprint_for_meter(target)), + ) + + def test_final_gain_mutation_keeps_selected_board_packages_in_same_review() -> None: """A calibrated handoff must not drop package choices made during setup.""" content = _snapshot().content + """packages: @@ -1531,6 +1575,48 @@ async def run() -> None: asyncio.run(run()) +def test_gain_preview_rejects_helper_fingerprint_mismatch() -> None: + async def run() -> None: + content = _snapshot().content.replace( + "name: circuitsetup.6c-energy-meter\n", + "name: circuitsetup.6c-energy-meter-2-voltages\n", + ).replace( + "logger:\n", + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + " main: 120\n" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + "logger:\n", + ) + source = _snapshot(content) + target = replace( + topology(0), + voltage_layout="two_voltages", + project_name="circuitsetup.6c-energy-meter-2-voltages", + ) + record = replace( + _record(source, ((7301, 1),) * 3), + topology_voltage_layout="two_voltages", + topology_project_name=target.project_name, + topology_voltage_fingerprint=voltage_reference_fingerprint_for_meter(target), + ) + persistence = CalibrationPersistence((record,)) + manager = ConfigTransactionManager( + Builder(remote_content=source.content), + Verifier(RuntimeError()), + persistence, + SessionManager(), + ) + + with pytest.raises(ConfigMutationError, match="topology"): + await manager.async_preview_calibrated_gains( + record.mac, target, record.verification_id + ) + assert persistence.claimed == {} + + asyncio.run(run()) + + def test_gain_handoff_install_rejects_newly_mixed_verified_record() -> None: """A stale gain-only preview must not install YAML after offsets are recorded.""" diff --git a/tests/test_store.py b/tests/test_store.py index deb56b0..793d061 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -35,6 +35,9 @@ migrate_storage, serialize_meter_record, ) +from custom_components.circuitsetup_energy_meter_helper.topology import ( + legacy_voltage_reference_topology, +) MAC = "aabbccddeeff" CONFIG_HASH = "a" * 64 @@ -254,6 +257,46 @@ def test_gain_only_1_1_record_deserializes_with_absent_offset_fields() -> None: assert record.power_offset_groups == () +@pytest.mark.parametrize("layout", ["standard", "two_voltages"]) +def test_legacy_voltage_identity_is_normalized_and_reserialized( + layout: str, +) -> None: + from custom_components.circuitsetup_energy_meter_helper.store import ( + _deserialize_verified_calibration, + _serialize_verified_calibration, + ) + + raw = { + "verification_id": "a" * 32, + "config_filename": "meter.yaml", + "config_sha256": "b" * 64, + "topology_addon_count": 1, + "topology_project_name": "circuitsetup.6c-energy-meter-1-addon", + "topology_connection_type": "wifi", + "topology_voltage_layout": layout, + "connection_generation": 2, + "groups": [ + { + "instance_id": "meter_main1", + "phase_gains": [[7305, 27518], [7305, 28312], [7305, 27518]], + } + ], + "source_authority": "saved_flash", + "source_handoff_available": True, + "source_handoff_transaction_id": None, + "source_handoff_firmware_installed": False, + } + + record = _deserialize_verified_calibration("aabbccddeeff", raw) + expected = legacy_voltage_reference_topology(2, layout).fingerprint + + assert record.topology_voltage_fingerprint == expected + assert _serialize_verified_calibration(record)["topology_voltage_fingerprint"] == expected + assert _deserialize_verified_calibration( + record.mac, _serialize_verified_calibration(record) + ) == record + + def test_store_rejects_untyped_topology_payload() -> None: """Arbitrary credentials or complete YAML cannot enter persisted records.""" record = StoredMeterRecord( From 831cfabb7aa353a8b017eb2155b90a4ee8d188ab Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 04:40:06 -0400 Subject: [PATCH 25/35] fix: verify helper voltage topology provenance --- .../calibration_engine.py | 17 +++- .../config_mutator.py | 9 +- .../config_transaction.py | 24 ++++- .../topology.py | 71 +++++++++---- .../workflow.py | 23 ++++- tests/test_config_transaction.py | 4 + tests/test_restart_verification.py | 99 +++++++++++++++++-- tests/test_topology.py | 46 ++++++++- 8 files changed, 254 insertions(+), 39 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/calibration_engine.py b/custom_components/circuitsetup_energy_meter_helper/calibration_engine.py index 5319d6a..225d9fb 100644 --- a/custom_components/circuitsetup_energy_meter_helper/calibration_engine.py +++ b/custom_components/circuitsetup_energy_meter_helper/calibration_engine.py @@ -73,6 +73,9 @@ type CalibrationSnapshotReader = Callable[ [str, MeterTopology], Awaitable[ESPHomeConfigSnapshot] ] +type TrustedVoltageFingerprintReader = Callable[ + [str, ESPHomeConfigDocument, MeterTopology], Awaitable[str | None] +] _CONFIGURATION_ID = re.compile(r"[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?\.yaml") @@ -186,6 +189,8 @@ def __init__( restart_restore_timeout: float = 120.0, restart_backoff_initial: float = 0.25, calibration_snapshot_reader: CalibrationSnapshotReader | None = None, + trusted_voltage_fingerprint_reader: TrustedVoltageFingerprintReader + | None = None, ) -> None: if sample_count < 1 or zero_concurrency < 1: raise ValueError("sample count and zero concurrency must be positive") @@ -208,6 +213,7 @@ def __init__( self._restart_restore_timeout = restart_restore_timeout self._restart_backoff_initial = restart_backoff_initial self._calibration_snapshot_reader = calibration_snapshot_reader + self._trusted_voltage_fingerprint_reader = trusted_voltage_fingerprint_reader self._operation_sequences: dict[str, int] = {} async def async_verify_after_restart( @@ -453,8 +459,17 @@ async def _calibration_origin( raise ValueError( "authoritative configuration topology is invalid" ) from error + trusted_voltage_fingerprint = ( + await self._trusted_voltage_fingerprint_reader( + lease.mac, document, source_topology + ) + if self._trusted_voltage_fingerprint_reader is not None + else None + ) source_voltage_fingerprint = voltage_reference_topology_from_config( - document, source_topology + document, + source_topology, + trusted_fingerprint=trusted_voltage_fingerprint, ).fingerprint if not _same_topology_identity(source_topology, binding.topology): raise ValueError( diff --git a/custom_components/circuitsetup_energy_meter_helper/config_mutator.py b/custom_components/circuitsetup_energy_meter_helper/config_mutator.py index 5e1385c..55b35c5 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_mutator.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_mutator.py @@ -204,6 +204,7 @@ def build_calibrated_gain_mutation( calibrated_current_channels: frozenset[int] = frozenset(), *, package_options: Mapping[str, Iterable[bool]] | None = None, + trusted_voltage_fingerprint: str | None = None, ) -> ConfigMutationPlan: """Build a reviewed final-gain plan bound to the calibration source hash.""" if getattr(snapshot, "configuration_authoritative", True) is not True: @@ -214,9 +215,13 @@ def build_calibrated_gain_mutation( document = ESPHomeConfigDocument.parse(snapshot.content) try: current_voltage_fingerprint = voltage_reference_topology_from_config( - document, topology + document, topology, trusted_fingerprint=trusted_voltage_fingerprint ).fingerprint - except ValueError: + except ValueError as error: + if trusted_voltage_fingerprint is not None: + raise ConfigMutationError( + "verified calibration topology does not match target" + ) from error current_voltage_fingerprint = voltage_reference_fingerprint_for_meter(topology) if ( snapshot.configuration != verified.config_filename diff --git a/custom_components/circuitsetup_energy_meter_helper/config_transaction.py b/custom_components/circuitsetup_energy_meter_helper/config_transaction.py index 93f2564..bb3e76b 100644 --- a/custom_components/circuitsetup_energy_meter_helper/config_transaction.py +++ b/custom_components/circuitsetup_energy_meter_helper/config_transaction.py @@ -36,8 +36,9 @@ canonical_mac, ) from .session_manager import ConfigLease, SessionManager -from .store import VerifiedCalibrationRecord +from .store import StoredMeterConfiguration, VerifiedCalibrationRecord from .topology import ( + verified_voltage_reference_fingerprint, voltage_reference_fingerprint_for_meter, voltage_reference_topology_from_config, ) @@ -131,6 +132,10 @@ async def async_restore_content( class VerifiedPersistence(Protocol): + async def async_get_meter_configuration( + self, mac: str + ) -> StoredMeterConfiguration | None: ... + async def async_get_ct_selections( self, mac: str ) -> tuple[StoredCTSelection, ...]: ... @@ -430,11 +435,23 @@ async def async_preview_calibrated_gains( snapshot = await self._device_builder.async_get_config( verified.config_filename ) + document = ESPHomeConfigDocument.parse(snapshot.content) + trusted_voltage_fingerprint = verified_voltage_reference_fingerprint( + document, + topology, + await self._persistence.async_get_meter_configuration(mac), + ) try: current_voltage_fingerprint = voltage_reference_topology_from_config( - ESPHomeConfigDocument.parse(snapshot.content), topology + document, + topology, + trusted_fingerprint=trusted_voltage_fingerprint, ).fingerprint - except ValueError: + except ValueError as error: + if trusted_voltage_fingerprint is not None: + raise ConfigMutationError( + "verified calibration topology does not match target" + ) from error current_voltage_fingerprint = voltage_reference_fingerprint_for_meter( topology ) @@ -455,6 +472,7 @@ async def async_preview_calibrated_gains( requested_channels, calibrated_current_channels, package_options=package_options, + trusted_voltage_fingerprint=trusted_voltage_fingerprint, ) selections: tuple[StoredCTSelection, ...] = () if requested_channels: diff --git a/custom_components/circuitsetup_energy_meter_helper/topology.py b/custom_components/circuitsetup_energy_meter_helper/topology.py index c85a754..1fe3533 100644 --- a/custom_components/circuitsetup_energy_meter_helper/topology.py +++ b/custom_components/circuitsetup_energy_meter_helper/topology.py @@ -5,6 +5,7 @@ import re from collections.abc import Iterable from dataclasses import replace +from hashlib import sha256 from typing import TYPE_CHECKING from .config_document import ESPHomeConfigDocument @@ -20,6 +21,7 @@ if TYPE_CHECKING: from .meter_configuration import MeterConfigurationRequest + from .store import StoredMeterConfiguration BASE_PROJECT = "circuitsetup.6c-energy-meter" _ADDON_SEGMENT_RE = re.compile(r"-(?P[1-6])-addons?(?=-|$)") @@ -166,7 +168,7 @@ def _validated_voltage_reference_topology( def voltage_reference_topology_from_configuration( topology: MeterTopology, - configuration: MeterConfigurationRequest, + configuration: MeterConfigurationRequest | StoredMeterConfiguration, ) -> VoltageReferenceTopology: """Use helper-managed reference assignments after structural validation.""" return _validated_voltage_reference_topology( @@ -195,19 +197,29 @@ def _managed_voltage_reference_assignments( continue match = re.fullmatch(r"\s{2}([A-Za-z][A-Za-z0-9_-]{0,63}):\s*(.*)", line) if match is None: - if line and not line.startswith(" "): - break - continue + raise TopologyParseError("invalid managed voltage-reference mapping") reference_id, value = match.groups() groups: tuple[str, ...] | None = None - if value.startswith("[") and value.endswith("]"): - values = tuple(item.strip().strip("'\"") for item in value[1:-1].split(",") if item.strip()) - groups = values or None + if value.startswith("[") or value.endswith("]"): + if not value.startswith("[") or not value.endswith("]"): + raise TopologyParseError("invalid managed voltage-reference mapping") + groups = tuple( + item.strip().strip("'\"") + for item in value[1:-1].split(",") + if item.strip() + ) + if not groups: + raise TopologyParseError("invalid managed voltage-reference mapping") + elif not value: + raise TopologyParseError("invalid managed voltage-reference mapping") entries.append((reference_id, groups)) if not entries or len(entries) > 2: - return None + raise TopologyParseError("invalid managed voltage-reference mapping") expected = _expected_group_keys(topology) - if any(groups is None for _, groups in entries): + inferred = tuple(groups is None for _, groups in entries) + if any(inferred) and not all(inferred): + raise TopologyParseError("mixed voltage-reference mapping forms are invalid") + if all(inferred): if len(entries) == 1: return ((entries[0][0], expected),) return tuple( @@ -220,21 +232,40 @@ def _managed_voltage_reference_assignments( def voltage_reference_topology_from_config( document: ESPHomeConfigDocument, topology: MeterTopology, - configuration: MeterConfigurationRequest | None = None, + configuration: StoredMeterConfiguration | None = None, + *, + trusted_fingerprint: str | None = None, ) -> VoltageReferenceTopology: """Prefer verified helper semantics; otherwise use legacy project evidence.""" if configuration is not None: - try: - return voltage_reference_topology_from_configuration(topology, configuration) - except TopologyParseError: - pass + trusted_fingerprint = verified_voltage_reference_fingerprint( + document, topology, configuration + ) + if trusted_fingerprint is None: + return voltage_reference_topology_from_legacy(topology) managed = _managed_voltage_reference_assignments(document, topology) - if managed is not None: - try: - return _validated_voltage_reference_topology(topology, managed) - except TopologyParseError: - pass - return voltage_reference_topology_from_legacy(topology) + if managed is None: + return voltage_reference_topology_from_legacy(topology) + voltage_topology = _validated_voltage_reference_topology(topology, managed) + if voltage_topology.fingerprint != trusted_fingerprint: + raise TopologyParseError("managed voltage-reference topology is not verified") + return voltage_topology + + +def verified_voltage_reference_fingerprint( + document: ESPHomeConfigDocument, + topology: MeterTopology, + configuration: StoredMeterConfiguration | None, +) -> str | None: + """Return stored helper identity only for the exact configuration bytes.""" + if ( + configuration is None + or configuration.config_sha256 != sha256(document.content.encode()).hexdigest() + ): + return None + return voltage_reference_topology_from_configuration( + topology, configuration + ).fingerprint # Short alias for callers that treat board and voltage topology uniformly. diff --git a/custom_components/circuitsetup_energy_meter_helper/workflow.py b/custom_components/circuitsetup_energy_meter_helper/workflow.py index e6a3f59..cba1ad3 100644 --- a/custom_components/circuitsetup_energy_meter_helper/workflow.py +++ b/custom_components/circuitsetup_energy_meter_helper/workflow.py @@ -69,7 +69,11 @@ from .session_manager import CalibrationBusyError, SessionManager from .state_tracker import SensorSampleWindow from .store import CalibrationSourceAuthority, HelperStore -from .topology import topology_from_config, topology_from_native +from .topology import ( + topology_from_config, + topology_from_native, + verified_voltage_reference_fingerprint, +) DEFAULT_HANDLE_TTL = 15 * 60.0 MAX_HANDLE_TTL = 60 * 60.0 @@ -390,6 +394,11 @@ def __init__( calibration_snapshot_reader=( self._async_calibration_snapshot if device_builder is not None else None ), + trusted_voltage_fingerprint_reader=( + self._async_trusted_voltage_fingerprint + if device_builder is not None + else None + ), ) async def async_get_topology( @@ -1307,6 +1316,18 @@ async def _async_calibration_snapshot( ) return await self._require_builder().async_get_config(handle.configuration) + async def _async_trusted_voltage_fingerprint( + self, + mac: str, + document: ESPHomeConfigDocument, + topology: MeterTopology, + ) -> str | None: + return verified_voltage_reference_fingerprint( + document, + topology, + await self._store.async_get_meter_configuration(mac), + ) + async def _reporting_multiplier( self, handle: _SessionHandle, diff --git a/tests/test_config_transaction.py b/tests/test_config_transaction.py index 9f1c38f..c52e141 100644 --- a/tests/test_config_transaction.py +++ b/tests/test_config_transaction.py @@ -176,6 +176,10 @@ def __init__( self.saved: list[object] = [] self.error = error self.selections = selections + self.meter_configuration: object | None = None + + async def async_get_meter_configuration(self, _mac: str) -> object | None: + return self.meter_configuration async def async_get_ct_selections(self, _mac: str) -> tuple[StoredCTSelection, ...]: return self.selections diff --git a/tests/test_restart_verification.py b/tests/test_restart_verification.py index 0009a89..fed42a2 100644 --- a/tests/test_restart_verification.py +++ b/tests/test_restart_verification.py @@ -57,6 +57,7 @@ from custom_components.circuitsetup_energy_meter_helper.topology import ( voltage_reference_fingerprint_for_meter, voltage_reference_topology_from_config, + voltage_reference_topology_from_configuration, ) from tests.test_calibration_engine_current import native_meter from tests.test_calibration_engine_voltage import ( @@ -71,6 +72,7 @@ synthetic_entities, topology, ) +from tests.test_store import _configuration as stored_configuration GainTable = tuple[tuple[int, int], tuple[int, int], tuple[int, int]] @@ -1304,8 +1306,13 @@ def test_calibrated_gain_handoff_uses_verified_helper_voltage_fingerprint() -> N voltage_layout="two_voltages", project_name="circuitsetup.6c-energy-meter-2-voltages", ) + trusted = replace(stored_configuration(), config_sha256=snapshot.sha256) helper_fingerprint = voltage_reference_topology_from_config( - ESPHomeConfigDocument.parse(content), target + ESPHomeConfigDocument.parse(content), + target, + trusted_fingerprint=voltage_reference_topology_from_configuration( + target, trusted + ).fingerprint, ).fingerprint record = replace( _record(snapshot, ((7301, 28001), (7301, 28002), (7301, 28003))), @@ -1314,13 +1321,24 @@ def test_calibrated_gain_handoff_uses_verified_helper_voltage_fingerprint() -> N topology_voltage_fingerprint=helper_fingerprint, ) - build_calibrated_gain_mutation(snapshot, target, record) + build_calibrated_gain_mutation( + snapshot, + target, + record, + trusted_voltage_fingerprint=helper_fingerprint, + ) with pytest.raises(ConfigMutationError, match="topology"): - build_calibrated_gain_mutation( - snapshot, - target, - replace(record, topology_voltage_fingerprint=voltage_reference_fingerprint_for_meter(target)), - ) + build_calibrated_gain_mutation(snapshot, target, record) + build_calibrated_gain_mutation( + snapshot, + target, + replace( + record, + topology_voltage_fingerprint=voltage_reference_fingerprint_for_meter( + target + ), + ), + ) def test_final_gain_mutation_keeps_selected_board_packages_in_same_review() -> None: @@ -1575,7 +1593,7 @@ async def run() -> None: asyncio.run(run()) -def test_gain_preview_rejects_helper_fingerprint_mismatch() -> None: +def test_gain_preview_ignores_unverified_helper_marker() -> None: async def run() -> None: content = _snapshot().content.replace( "name: circuitsetup.6c-energy-meter\n", @@ -1608,11 +1626,72 @@ async def run() -> None: SessionManager(), ) + preview = await manager.async_preview_calibrated_gains( + record.mac, target, record.verification_id + ) + assert preview.state is ConfigTransactionState.PREVIEWED + assert persistence.claimed == {record.verification_id: preview.transaction_id} + + asyncio.run(run()) + + +def test_gain_preview_trusts_helper_marker_only_for_matching_stored_hash() -> None: + async def run() -> None: + content = _snapshot().content.replace( + "name: circuitsetup.6c-energy-meter\n", + "name: circuitsetup.6c-energy-meter-2-voltages\n", + ).replace( + "logger:\n", + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + " main: 120\n" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + "logger:\n", + ) + source = _snapshot(content) + target = replace( + topology(0), + voltage_layout="two_voltages", + project_name="circuitsetup.6c-energy-meter-2-voltages", + ) + trusted = replace(stored_configuration(), config_sha256=source.sha256) + helper_fingerprint = voltage_reference_topology_from_configuration( + target, trusted + ).fingerprint + record = replace( + _record(source, ((7301, 1),) * 3), + topology_voltage_layout="two_voltages", + topology_project_name=target.project_name, + topology_voltage_fingerprint=helper_fingerprint, + ) + persistence = CalibrationPersistence((record,)) + persistence.meter_configuration = trusted + manager = ConfigTransactionManager( + Builder(remote_content=source.content), + Verifier(RuntimeError()), + persistence, + SessionManager(), + ) + + preview = await manager.async_preview_calibrated_gains( + record.mac, target, record.verification_id + ) + assert preview.state is ConfigTransactionState.PREVIEWED + + stale_persistence = CalibrationPersistence((record,)) + stale_persistence.meter_configuration = replace( + trusted, config_sha256="0" * 64 + ) + stale_manager = ConfigTransactionManager( + Builder(remote_content=source.content), + Verifier(RuntimeError()), + stale_persistence, + SessionManager(), + ) with pytest.raises(ConfigMutationError, match="topology"): - await manager.async_preview_calibrated_gains( + await stale_manager.async_preview_calibrated_gains( record.mac, target, record.verification_id ) - assert persistence.claimed == {} asyncio.run(run()) diff --git a/tests/test_topology.py b/tests/test_topology.py index 7ad0270..6dfd2f8 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -345,7 +345,7 @@ def test_helper_configuration_overrides_legacy_layout_when_structurally_valid() assert voltage.source == "helper" -def test_managed_voltage_block_overrides_legacy_layout() -> None: +def test_managed_voltage_block_requires_matching_trusted_fingerprint() -> None: document = ESPHomeConfigDocument.parse( "esphome:\n" " project:\n" @@ -357,12 +357,54 @@ def test_managed_voltage_block_overrides_legacy_layout() -> None: ) meter = topology_from_config(document) - voltage = voltage_reference_topology_from_config(document, meter) + injected = voltage_reference_topology_from_config(document, meter) + trusted = voltage_reference_topology_from_configuration( + meter, + default_meter_configuration( + meter, {"power_quality": (False,), "status_fields": (True,)} + ), + ) + voltage = voltage_reference_topology_from_config( + document, meter, trusted_fingerprint=trusted.fingerprint + ) + assert injected.source == "legacy" + assert injected.reference_ids == ("main", "secondary") assert voltage.source == "helper" assert voltage.reference_ids == ("main",) +@pytest.mark.parametrize( + "assignments", + ( + " main: [main_1]\n secondary: 120\n", + " main: [main_1]\n", + " main: [main_1, main_2]\n secondary: [main_2]\n", + " main: [main_1, main_2, addon1_1]\n", + " main: [main_1]\n main: [main_2]\n", + ), + ids=("mixed", "missing", "duplicate-group", "extra", "duplicate-reference"), +) +def test_trusted_managed_voltage_block_rejects_noncanonical_coverage( + assignments: str, +) -> None: + document = ESPHomeConfigDocument.parse( + "esphome:\n" + " project:\n" + " name: circuitsetup.6c-energy-meter-2-voltages\n" + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + f"{assignments}" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_config(document) + + with pytest.raises(TopologyParseError): + voltage_reference_topology_from_config( + document, meter, trusted_fingerprint="v1:" + "0" * 64 + ) + + def test_helper_configuration_must_cover_each_group_once() -> None: meter = topology_from_native("circuitsetup.6c-energy-meter") request = default_meter_configuration( From c73cb5d5de6e8dda9cdaa46694754630c861c7f7 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 04:56:12 -0400 Subject: [PATCH 26/35] fix: validate voltage topology grammar --- .../meter_configuration.py | 4 +- .../models.py | 11 +-- .../topology.py | 31 +++++--- tests/test_store.py | 27 +++++++ tests/test_topology.py | 71 +++++++++++++++++++ 5 files changed, 128 insertions(+), 16 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py index 7d65aee..db4f6b7 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py @@ -10,7 +10,7 @@ from typing import Literal from .entity_binding import group_key -from .models import MeterTopology +from .models import VOLTAGE_REFERENCE_ID_RE, MeterTopology LineFrequencyHz = Literal[50, 60] UpdateIntervalSeconds = Literal[1, 2, 5, 10, 30, 60] @@ -154,6 +154,8 @@ def validate_meter_configuration( raise ValueError("voltage references must be uniquely identified") all_groups: list[str] = [] for ref in refs: + if VOLTAGE_REFERENCE_ID_RE.fullmatch(ref.reference_id) is None: + raise ValueError("reference_id is invalid") for field, value in (("reference_id", ref.reference_id), ("label", ref.label), ("phase_label", ref.phase_label), ("transformer_model_id", ref.transformer_model_id)): _text(value, field) _finite(ref.nominal_voltage_v, "nominal_voltage_v") diff --git a/custom_components/circuitsetup_energy_meter_helper/models.py b/custom_components/circuitsetup_energy_meter_helper/models.py index b6907fe..2155fe7 100644 --- a/custom_components/circuitsetup_energy_meter_helper/models.py +++ b/custom_components/circuitsetup_energy_meter_helper/models.py @@ -36,6 +36,8 @@ r"[0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5})" ) _FIRMWARE_PRODUCT_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,127}$") +VOLTAGE_REFERENCE_ID_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,63}") +VOLTAGE_REFERENCE_GROUP_RE = re.compile(r"(?:main|addon[1-6])_[12]") _ESPHOME_VERSION = re.compile( r"^[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}(?:-[A-Za-z0-9.-]+)?$" ) @@ -234,7 +236,7 @@ def __post_init__(self) -> None: for reference_id, group_keys in self.references: if ( not isinstance(reference_id, str) - or not reference_id + or VOLTAGE_REFERENCE_ID_RE.fullmatch(reference_id) is None or reference_id in reference_ids or not isinstance(group_keys, tuple) or not group_keys @@ -242,9 +244,10 @@ def __post_init__(self) -> None: raise ValueError("voltage-reference topology is invalid") reference_ids.add(reference_id) for group_key_value in group_keys: - if not isinstance(group_key_value, str) or re.fullmatch( - r"(?:main|addon[1-6])_[12]", group_key_value - ) is None: + if ( + not isinstance(group_key_value, str) + or VOLTAGE_REFERENCE_GROUP_RE.fullmatch(group_key_value) is None + ): raise ValueError("voltage-reference topology is invalid") groups.append(group_key_value) if len(groups) != len(set(groups)): diff --git a/custom_components/circuitsetup_energy_meter_helper/topology.py b/custom_components/circuitsetup_energy_meter_helper/topology.py index 1fe3533..9a3d5fa 100644 --- a/custom_components/circuitsetup_energy_meter_helper/topology.py +++ b/custom_components/circuitsetup_energy_meter_helper/topology.py @@ -10,6 +10,8 @@ from .config_document import ESPHomeConfigDocument from .models import ( + VOLTAGE_REFERENCE_GROUP_RE, + VOLTAGE_REFERENCE_ID_RE, ChannelAddress, ConnectionType, MeterTopology, @@ -195,21 +197,20 @@ def _managed_voltage_reference_assignments( continue if not in_section or not line.strip(): continue - match = re.fullmatch(r"\s{2}([A-Za-z][A-Za-z0-9_-]{0,63}):\s*(.*)", line) + match = re.fullmatch(r"\s{2}([^:]+):\s*(.*)", line) if match is None: raise TopologyParseError("invalid managed voltage-reference mapping") reference_id, value = match.groups() + if VOLTAGE_REFERENCE_ID_RE.fullmatch(reference_id) is None: + raise TopologyParseError("invalid managed voltage-reference mapping") groups: tuple[str, ...] | None = None if value.startswith("[") or value.endswith("]"): if not value.startswith("[") or not value.endswith("]"): raise TopologyParseError("invalid managed voltage-reference mapping") - groups = tuple( - item.strip().strip("'\"") - for item in value[1:-1].split(",") - if item.strip() - ) - if not groups: + items = tuple(item.strip() for item in value[1:-1].split(",")) + if not items or any(not item for item in items): raise TopologyParseError("invalid managed voltage-reference mapping") + groups = tuple(_managed_group_key(item) for item in items) elif not value: raise TopologyParseError("invalid managed voltage-reference mapping") entries.append((reference_id, groups)) @@ -229,6 +230,18 @@ def _managed_voltage_reference_assignments( return tuple((reference_id, groups or ()) for reference_id, groups in entries) +def _managed_group_key(value: str) -> str: + if value[:1] in {"'", '"'}: + if len(value) < 2 or value[-1] != value[0]: + raise TopologyParseError("invalid managed voltage-reference mapping") + value = value[1:-1] + elif value[-1:] in {"'", '"'}: + raise TopologyParseError("invalid managed voltage-reference mapping") + if VOLTAGE_REFERENCE_GROUP_RE.fullmatch(value) is None: + raise TopologyParseError("invalid managed voltage-reference mapping") + return value + + def voltage_reference_topology_from_config( document: ESPHomeConfigDocument, topology: MeterTopology, @@ -268,10 +281,6 @@ def verified_voltage_reference_fingerprint( ).fingerprint -# Short alias for callers that treat board and voltage topology uniformly. -voltage_topology_from_config = voltage_reference_topology_from_config - - def addon_count_from_packages(package_files: Iterable[str]) -> int | None: """Count unique contiguous add-on sensor packages.""" indices: set[int] = set() diff --git a/tests/test_store.py b/tests/test_store.py index 793d061..da4d128 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -729,6 +729,33 @@ async def run() -> None: asyncio.run(run()) +def test_meter_configuration_rejects_noncanonical_voltage_reference_id() -> None: + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + await store.async_save_meter(_record()) + configuration = _configuration() + reference = configuration.meter.voltage_references[0] + invalid = replace( + configuration, + meter=replace( + configuration.meter, + voltage_references=(replace(reference, reference_id="bad id"),), + ), + channels=tuple( + replace(channel, voltage_reference_id="bad id") + for channel in configuration.channels + ), + ) + + with pytest.raises(ValueError, match="reference_id"): + await store.async_save_verified_meter_configuration(MAC, invalid) + + asyncio.run(run()) + + @pytest.mark.parametrize( ("field", "value"), ( diff --git a/tests/test_topology.py b/tests/test_topology.py index 6dfd2f8..20607bb 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -1,5 +1,7 @@ """Tests for authoritative and provisional meter topology detection.""" +from dataclasses import replace + import pytest from custom_components.circuitsetup_energy_meter_helper.config_document import ( @@ -405,6 +407,75 @@ def test_trusted_managed_voltage_block_rejects_noncanonical_coverage( ) +@pytest.mark.parametrize( + "groups", + ( + "main_1,,main_2", + ",main_1,main_2", + "main_1,main_2,", + "main_1, ,main_2", + ), + ids=("double-comma", "leading-comma", "trailing-comma", "blank-element"), +) +def test_trusted_managed_voltage_block_rejects_empty_list_elements( + groups: str, +) -> None: + document = ESPHomeConfigDocument.parse( + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + f" main: [{groups}]\n" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_native("circuitsetup.6c-energy-meter") + trusted = VoltageReferenceTopology( + (("main", ("main_1", "main_2")),), "helper" + ) + + with pytest.raises(TopologyParseError): + voltage_reference_topology_from_config( + document, meter, trusted_fingerprint=trusted.fingerprint + ) + + +def test_trusted_managed_voltage_block_accepts_whitespace_around_list_elements() -> None: + document = ESPHomeConfigDocument.parse( + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + " main: [ main_1 , main_2 ]\n" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_native("circuitsetup.6c-energy-meter") + trusted = VoltageReferenceTopology( + (("main", ("main_1", "main_2")),), "helper" + ) + + assert voltage_reference_topology_from_config( + document, meter, trusted_fingerprint=trusted.fingerprint + ).fingerprint == trusted.fingerprint + + +@pytest.mark.parametrize( + "reference_id", + ("bad id", "1bad", "_bad", "bad.id", "x" * 65), +) +def test_helper_configuration_rejects_noncanonical_reference_id( + reference_id: str, +) -> None: + meter = topology_from_native("circuitsetup.6c-energy-meter") + request = default_meter_configuration( + meter, {"power_quality": (False,), "status_fields": (True,)} + ) + reference = request.meter.voltage_references[0] + object.__setattr__( + request.meter, + "voltage_references", + (replace(reference, reference_id=reference_id),), + ) + + with pytest.raises(TopologyParseError, match="invalid helper"): + voltage_reference_topology_from_configuration(meter, request) + + def test_helper_configuration_must_cover_each_group_once() -> None: meter = topology_from_native("circuitsetup.6c-energy-meter") request = default_meter_configuration( From b767262f30c6b9f6e844aaed6f1135deacec672f Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 05:01:49 -0400 Subject: [PATCH 27/35] fix: validate voltage block envelope --- .../topology.py | 11 +++-- tests/test_topology.py | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/topology.py b/custom_components/circuitsetup_energy_meter_helper/topology.py index 9a3d5fa..970b9a4 100644 --- a/custom_components/circuitsetup_energy_meter_helper/topology.py +++ b/custom_components/circuitsetup_energy_meter_helper/topology.py @@ -191,12 +191,15 @@ def _managed_voltage_reference_assignments( entries: list[tuple[str, tuple[str, ...] | None]] = [] in_section = False for raw_line in block.content.splitlines(): - line = raw_line.split("#", 1)[0].rstrip() - if line.strip() == "voltage_references:": - in_section = True + raw_line = raw_line.rstrip() + if not raw_line.strip() or raw_line.lstrip().startswith("#"): continue - if not in_section or not line.strip(): + if not in_section: + if raw_line != "voltage_references:": + raise TopologyParseError("invalid managed voltage-reference mapping") + in_section = True continue + line = raw_line.split("#", 1)[0].rstrip() match = re.fullmatch(r"\s{2}([^:]+):\s*(.*)", line) if match is None: raise TopologyParseError("invalid managed voltage-reference mapping") diff --git a/tests/test_topology.py b/tests/test_topology.py index 20607bb..13f8faf 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -454,6 +454,54 @@ def test_trusted_managed_voltage_block_accepts_whitespace_around_list_elements() ).fingerprint == trusted.fingerprint +@pytest.mark.parametrize( + "body", + ( + "preamble: unsafe\nvoltage_references:\n main: 120\n", + "voltage_references:\nvoltage_references:\n main: 120\n", + " voltage_references:\n main: 120\n", + "voltage_references: # ambiguous\n main: 120\n", + ), + ids=("preamble", "duplicate-header", "indented-header", "inline-header-comment"), +) +def test_trusted_managed_voltage_block_rejects_ambiguous_envelope(body: str) -> None: + document = ESPHomeConfigDocument.parse( + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + f"{body}" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_native("circuitsetup.6c-energy-meter") + trusted = VoltageReferenceTopology( + (("main", ("main_1", "main_2")),), "helper" + ) + + with pytest.raises(TopologyParseError): + voltage_reference_topology_from_config( + document, meter, trusted_fingerprint=trusted.fingerprint + ) + + +def test_trusted_managed_voltage_block_allows_comments_and_blanks_around_header() -> None: + document = ESPHomeConfigDocument.parse( + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "\n" + "# mapping follows\n" + "voltage_references:\n" + "\n" + " # primary reference\n" + " main: 120\n" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_native("circuitsetup.6c-energy-meter") + trusted = VoltageReferenceTopology( + (("main", ("main_1", "main_2")),), "helper" + ) + + assert voltage_reference_topology_from_config( + document, meter, trusted_fingerprint=trusted.fingerprint + ).fingerprint == trusted.fingerprint + + @pytest.mark.parametrize( "reference_id", ("bad id", "1bad", "_bad", "bad.id", "x" * 65), From 1b2b421f342d18bf74202041819adb2ea4674519 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 05:05:52 -0400 Subject: [PATCH 28/35] docs: fix design document whitespace --- .../specs/2026-08-24-priority-0-meter-configuration-design.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md b/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md index af7e6e5..4da38aa 100644 --- a/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md +++ b/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md @@ -75,7 +75,6 @@ Add only one new main step, **Meter Settings**, between **Setup Device** and **C --- - ## Public Data Contracts Define these names exactly unless an existing merged change creates a naming collision. @@ -234,4 +233,3 @@ def estimate_configuration_impact( ``` --- - From 0563501a3e88c5a2c5f36409a644851630342192 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 05:20:44 -0400 Subject: [PATCH 29/35] feat: inventory complete meter configuration --- .../meter_inventory.py | 281 +++++++++++++++++- .../workflow.py | 58 +++- tests/test_meter_inventory.py | 208 ++++++++++++- tests/test_workflow.py | 97 ++++++ 4 files changed, 630 insertions(+), 14 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py index 3d14e6e..f3869ea 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py @@ -1,8 +1,39 @@ -"""Firmware-backed meter configuration capabilities.""" +"""Firmware-backed complete meter configuration inventory.""" from __future__ import annotations -from dataclasses import dataclass +import re +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, replace +from typing import cast + +from .config_document import ESPHomeConfigDocument +from .ct_catalog import CTPresetCatalog +from .ct_inventory import CTInventory +from .meter_configuration import ( + ChannelSettings, + CircuitRole, + ElectricalSystem, + LineFrequencyHz, + MeterConfigurationRequest, + MeterSettings, + UpdateIntervalSeconds, + VoltageLayout, + VoltageReferenceConfig, + validate_meter_configuration, +) +from .models import MeterTopology, StoredCTSelection, VoltageReferenceTopology +from .store import StoredMeterConfiguration +from .topology import ( + channel_address, + voltage_reference_topology_from_configuration, + voltage_reference_topology_from_legacy, +) +from .voltage_transformer_catalog import VoltageTransformerCatalog + +_GENERIC_TOTAL_ID = re.compile( + r"\bid:\s*[\"']?(?:totalAmps|totalWatts|totalEnergyDaily)\b" +) @dataclass(frozen=True, slots=True) @@ -32,3 +63,249 @@ def meter_configuration_capabilities( return MeterConfigurationCapabilities( True, False, True, ("config_contract_upgrade_required",) ) + + +@dataclass(frozen=True, slots=True) +class MeterConfigurationInventory: + """One hash-bound meter configuration snapshot, ready for a server plan handle.""" + + source_sha256: str + topology: MeterTopology + configuration: MeterConfigurationRequest + ct_inventory: CTInventory + voltage_topology: VoltageReferenceTopology + capabilities: MeterConfigurationCapabilities + voltage_transformer_catalog: VoltageTransformerCatalog + warnings: tuple[str, ...] + + @property + def ct_catalog(self) -> CTPresetCatalog: + """Expose the existing CT catalog without duplicating it in the handle.""" + return self.ct_inventory.catalog + + @classmethod + def from_document( + cls, + document: ESPHomeConfigDocument, + topology: MeterTopology, + ct_catalog: CTPresetCatalog, + voltage_transformer_catalog: VoltageTransformerCatalog, + config_sha256: str, + *, + stored_configuration: StoredMeterConfiguration | None = None, + stored_ct_selections: Iterable[StoredCTSelection] = (), + reporting_multipliers: Mapping[int, float] | None = None, + configuration_authoritative: bool = True, + ) -> MeterConfigurationInventory: + """Merge current YAML with hash-bound semantics and explicit legacy defaults.""" + ct_inventory = CTInventory.from_document( + document, + topology, + ct_catalog, + config_sha256, + stored_ct_selections, + reporting_multipliers, + ) + capabilities = meter_configuration_capabilities( + configuration_authoritative=configuration_authoritative, + config_contract=_contract(document), + ) + matching = ( + stored_configuration + if stored_configuration is not None + and stored_configuration.config_sha256 == config_sha256 + else None + ) + configuration = ( + _stored_request(matching, document, topology, ct_inventory) + if matching is not None + else _legacy_request(document, topology, ct_inventory) + ) + voltage_topology = ( + voltage_reference_topology_from_configuration(topology, configuration) + if matching is not None + else voltage_reference_topology_from_legacy(topology) + ) + warnings = list(capabilities.reason_codes) + if configuration.meter.electrical_system is ElectricalSystem.CUSTOM: + warnings.append("electrical_profile_requires_confirmation") + if _GENERIC_TOTAL_ID.search(document.content): + warnings.append("legacy_generic_totals_unmanaged") + if stored_configuration is not None and matching is None: + warnings.append("stored_semantics_stale") + return cls( + config_sha256, + topology, + configuration, + ct_inventory, + voltage_topology, + capabilities, + voltage_transformer_catalog, + tuple(warnings), + ) + + +def _contract(document: ESPHomeConfigDocument) -> int | None: + scalar = document.substitutions.get("csemh_config_contract") + return int(scalar.value) if scalar is not None else None + + +def _stored_request( + stored: StoredMeterConfiguration, + document: ESPHomeConfigDocument, + topology: MeterTopology, + ct_inventory: CTInventory, +) -> MeterConfigurationRequest: + references = tuple( + replace( + reference, + gain_voltage=_gain( + document, f"voltage_cal{index + 1}", reference.gain_voltage + ), + ) + for index, reference in enumerate(stored.meter.voltage_references) + ) + channels = tuple( + replace( + stored_channel, + name=channel.name, + reporting_multiplier=channel.reporting_multiplier, + custom_gain_ct=( + channel.raw_gain_ct if stored_channel.model_id == "custom" else None + ), + custom_label=( + channel.name if stored_channel.model_id == "custom" else None + ), + ) + for stored_channel, channel in zip( + stored.channels, ct_inventory.channels, strict=True + ) + ) + return MeterConfigurationRequest( + replace( + stored.meter, + friendly_name=_value(document, "friendly_name", stored.meter.friendly_name), + line_frequency_hz=cast( + LineFrequencyHz, + int( + _value( + document, + "electric_freq", + f"{stored.meter.line_frequency_hz}Hz", + ).removesuffix("Hz") + ), + ), + update_interval_s=cast( + UpdateIntervalSeconds, + int( + _value( + document, + "update_time", + f"{stored.meter.update_interval_s}s", + ).removesuffix("s") + ), + ), + voltage_references=references, + ), + channels, + stored.aggregates, + stored.power_quality, + stored.status_fields, + multi_reference_preparation_acknowledged=( + len(stored.meter.voltage_references) > 1 + ), + ) + + +def _legacy_request( + document: ESPHomeConfigDocument, topology: MeterTopology, ct_inventory: CTInventory +) -> MeterConfigurationRequest: + voltage_topology = voltage_reference_topology_from_legacy(topology) + references = tuple( + VoltageReferenceConfig( + reference_id, + reference_id.replace("_", " ").title(), + chr(ord("A") + index), + 120.0, + "custom", + _gain(document, f"voltage_cal{index + 1}"), + groups, + ) + for index, (reference_id, groups) in enumerate(voltage_topology.references) + ) + channel_settings = tuple( + ChannelSettings( + channel.channel, + True, + channel.name, + channel.selected_model_id or "custom", + channel.reporting_multiplier, + CircuitRole.CUSTOM, + _reference_for_channel(channel.channel, topology, voltage_topology), + channel.raw_gain_ct if channel.selected_model_id is None else None, + channel.name if channel.selected_model_id is None else None, + ) + for channel in ct_inventory.channels + ) + package_options = _package_options(document, topology) + request = MeterConfigurationRequest( + MeterSettings( + _value(document, "friendly_name", "Energy meter"), + ElectricalSystem.CUSTOM, + cast( + LineFrequencyHz, + int(_value(document, "electric_freq", "60Hz").removesuffix("Hz")), + ), + cast( + UpdateIntervalSeconds, + int(_value(document, "update_time", "5s").removesuffix("s")), + ), + VoltageLayout.MULTI_REFERENCE + if len(references) > 1 + else VoltageLayout.STANDARD, + references, + ), + channel_settings, + (), + package_options["power_quality"], + package_options["status_fields"], + multi_reference_preparation_acknowledged=len(references) > 1, + ) + validate_meter_configuration(request, topology) + return request + + +def _value(document: ESPHomeConfigDocument, key: str, default: str) -> str: + scalar = document.substitutions.get(key) + return scalar.value if scalar is not None else default + + +def _gain(document: ESPHomeConfigDocument, key: str, default: int = 1) -> int: + try: + gain = int(_value(document, key, str(default))) + except ValueError as error: + raise ValueError(f"invalid gain for {key}") from error + if not 1 <= gain <= 65535: + raise ValueError(f"invalid gain for {key}") + return gain + + +def _reference_for_channel( + channel: int, topology: MeterTopology, voltage_topology: VoltageReferenceTopology +) -> str: + address = channel_address(channel, topology) + prefix = "main" if address.board_index == 0 else f"addon{address.board_index}" + group = f"{prefix}_{address.group_index + 1}" + return next( + reference_id + for reference_id, groups in voltage_topology.references + if group in groups + ) + + +def _package_options( + document: ESPHomeConfigDocument, topology: MeterTopology +) -> dict[str, tuple[bool, ...]]: + from .config_mutator import package_options_from_document + + return package_options_from_document(document, topology) diff --git a/custom_components/circuitsetup_energy_meter_helper/workflow.py b/custom_components/circuitsetup_energy_meter_helper/workflow.py index cba1ad3..13467bb 100644 --- a/custom_components/circuitsetup_energy_meter_helper/workflow.py +++ b/custom_components/circuitsetup_energy_meter_helper/workflow.py @@ -54,6 +54,7 @@ ) from .entity_catalog import EntityCatalog from .esphome_api import ESPHomeApiSession +from .meter_inventory import MeterConfigurationInventory from .models import MeterTopology, StoredCTSelection, canonical_mac from .offset_readiness import ( OffsetReadinessResult, @@ -74,6 +75,7 @@ topology_from_native, verified_voltage_reference_fingerprint, ) +from .voltage_transformer_catalog import VoltageTransformerCatalog DEFAULT_HANDLE_TTL = 15 * 60.0 MAX_HANDLE_TTL = 60 * 60.0 @@ -115,7 +117,7 @@ class _PlanHandle: mac: str topology: MeterTopology snapshot: ESPHomeConfigSnapshot - inventory: CTInventory + inventory: MeterConfigurationInventory expires_at: float def scrub(self) -> None: @@ -424,7 +426,12 @@ def transaction_device_identity(self, device_id: str) -> str: self._device(device_id) return self._mac(device_id) - async def async_get_ct_inventory(self, device_id: str) -> dict[str, Any]: + async def async_get_meter_configuration(self, device_id: str) -> dict[str, Any]: + return await self._async_get_meter_configuration(device_id, True) + + async def _async_get_meter_configuration( + self, device_id: str, include_stored_semantics: bool + ) -> dict[str, Any]: device = self._device(device_id) mac = self._mac(device_id) snapshot = await self._async_snapshot(device) @@ -432,15 +439,27 @@ async def async_get_ct_inventory(self, device_id: str) -> dict[str, Any]: topology = topology_from_config( document, native_project_name=device.project_name ) - catalog = await self._hass.async_add_executor_job(CTPresetCatalog.load) + ct_catalog = await self._hass.async_add_executor_job(CTPresetCatalog.load) + voltage_catalog = await self._hass.async_add_executor_job( + VoltageTransformerCatalog.load + ) selections = await self._store.async_get_ct_selections(mac) - inventory = CTInventory.from_document( + stored_configuration = ( + await self._store.async_get_meter_configuration(mac) + if include_stored_semantics + else None + ) + inventory = MeterConfigurationInventory.from_document( document, topology, - catalog, + ct_catalog, + voltage_catalog, snapshot.sha256, - selections, - _stored_reporting_multipliers(selections, snapshot.sha256), + stored_configuration=stored_configuration, + stored_ct_selections=selections, + reporting_multipliers=_stored_reporting_multipliers( + selections, snapshot.sha256 + ), ) plan_id = uuid4().hex self._discard_device_plans(mac) @@ -461,8 +480,23 @@ async def async_get_ct_inventory(self, device_id: str) -> dict[str, Any]: return { "plan_id": plan_id, "source_sha256": snapshot.sha256, - "channels": inventory.channels, - "catalog": inventory.catalog, + "topology": inventory.topology, + "configuration": inventory.configuration, + "capabilities": inventory.capabilities, + "voltage_topology": inventory.voltage_topology, + "voltage_transformer_catalog": inventory.voltage_transformer_catalog, + "ct_catalog": inventory.ct_catalog, + "warnings": inventory.warnings, + "channels": inventory.ct_inventory.channels, + "catalog": inventory.ct_catalog, + } + + async def async_get_ct_inventory(self, device_id: str) -> dict[str, Any]: + """Return the legacy CT-only response backed by a complete meter plan.""" + inventory = await self._async_get_meter_configuration(device_id, False) + return { + key: inventory[key] + for key in ("plan_id", "source_sha256", "channels", "catalog") } async def async_get_session(self, session_id: str) -> SessionStatus: @@ -534,7 +568,7 @@ async def async_preview_ct_config( updated_inventory = CTInventory.from_document( ESPHomeConfigDocument.parse(mutation.proposed_content), plan.topology, - plan.inventory.catalog, + plan.inventory.ct_catalog, plan.snapshot.sha256, reporting_multipliers={ request.channel: request.reporting_multiplier for request in requests @@ -577,7 +611,9 @@ async def async_set_ha_labels( raise WorkflowHandleError("label changes are malformed") requested[channel] = label channels = {item.channel: item for item in binding.channels} - if not requested.keys() <= channels.keys() or not requested.keys() <= {item.channel for item in plan.inventory.channels}: + if not requested.keys() <= channels.keys() or not requested.keys() <= { + item.channel for item in plan.inventory.ct_inventory.channels + }: raise WorkflowHandleError("channel is not owned by this inventory") registry = er.async_get(self._hass) targets: list[tuple[int, str, str, Any]] = [] diff --git a/tests/test_meter_inventory.py b/tests/test_meter_inventory.py index abc6feb..edf7154 100644 --- a/tests/test_meter_inventory.py +++ b/tests/test_meter_inventory.py @@ -1,13 +1,36 @@ """Tests for firmware configuration capability discovery.""" -from dataclasses import fields +from dataclasses import fields, replace +from hashlib import sha256 import pytest +from custom_components.circuitsetup_energy_meter_helper.config_document import ( + ESPHomeConfigDocument, +) +from custom_components.circuitsetup_energy_meter_helper.ct_catalog import ( + CTPresetCatalog, +) +from custom_components.circuitsetup_energy_meter_helper.meter_configuration import ( + CircuitAggregate, + CircuitRole, + EnergyMode, + MeasurementMethod, +) from custom_components.circuitsetup_energy_meter_helper.meter_inventory import ( MeterConfigurationCapabilities, + MeterConfigurationInventory, meter_configuration_capabilities, ) +from custom_components.circuitsetup_energy_meter_helper.store import ( + StoredMeterConfiguration, +) +from custom_components.circuitsetup_energy_meter_helper.topology import ( + topology_from_config, +) +from custom_components.circuitsetup_energy_meter_helper.voltage_transformer_catalog import ( + VoltageTransformerCatalog, +) def test_capability_model_has_exact_frozen_slots_contract() -> None: @@ -57,3 +80,186 @@ def test_capability_inputs_require_exact_bool_and_int_types( meter_configuration_capabilities( configuration_authoritative=authoritative, config_contract=contract ) + + +def _document(*, contract: bool = False, generic_totals: bool = False) -> str: + packages = ( + " files:\n" + " - Software/ESPHome/power_quality/6chan_main_power_quality.yaml\n" + " - Software/ESPHome/status_fields/6chan_main_status.yaml\n" + ) + substitutions = "".join( + f" ct{channel}_name: {'Grid' if channel == 1 else f'Load {channel}'}\n" + f" current_cal_ct{channel}: {27518 + channel}\n" + for channel in range(1, 7) + ) + return ( + "esphome:\n project:\n name: circuitsetup.6c-energy-meter\n" + "packages:\n" + f"{packages}" + "substitutions:\n" + " friendly_name: Garage Meter\n" + " update_time: 10s\n" + " electric_freq: 60Hz\n" + + (" csemh_config_contract: '2'\n" if contract else "") + + " voltage_cal1: 7305\n" + + substitutions + + ("sensor:\n - id: totalWatts\n" if generic_totals else "") + ) + + +def _inventory( + content: str, + *, + stored: StoredMeterConfiguration | None = None, + authoritative: bool = True, +) -> MeterConfigurationInventory: + document = ESPHomeConfigDocument.parse(content) + return MeterConfigurationInventory.from_document( + document, + topology_from_config(document), + CTPresetCatalog.load(), + VoltageTransformerCatalog.load(), + sha256(content.encode()).hexdigest(), + stored_configuration=stored, + configuration_authoritative=authoritative, + ) + + +def test_legacy_inventory_keeps_yaml_ct_values_and_requires_electrical_confirmation() -> ( + None +): + """Changing legacy names into inferred circuit roles must fail this contract.""" + inventory = _inventory(_document(generic_totals=True)) + + assert [channel.name for channel in inventory.ct_inventory.channels] == [ + "Grid", + "Load 2", + "Load 3", + "Load 4", + "Load 5", + "Load 6", + ] + assert [channel.raw_gain_ct for channel in inventory.ct_inventory.channels] == [ + 27519, + 27520, + 27521, + 27522, + 27523, + 27524, + ] + assert all(channel.enabled for channel in inventory.configuration.channels) + assert {channel.role for channel in inventory.configuration.channels} == { + CircuitRole.CUSTOM + } + assert inventory.configuration.meter.electrical_system.value == "custom" + assert inventory.voltage_topology.references == (("main", ("main_1", "main_2")),) + assert inventory.configuration.aggregates == () + assert inventory.configuration.power_quality == (True,) + assert inventory.configuration.status_fields == (True,) + assert { + "electrical_profile_requires_confirmation", + "legacy_generic_totals_unmanaged", + "config_contract_upgrade_required", + } <= set(inventory.warnings) + + +def test_matching_stored_semantics_restore_roles_reference_mapping_and_aggregates() -> ( + None +): + """Removing hash-bound roles or aggregates must change this user-visible plan.""" + content = _document(contract=True) + baseline = _inventory(content).configuration + voltage_references = ( + replace( + baseline.meter.voltage_references[0], + reference_id="grid", + group_keys=("main_1",), + ), + replace( + baseline.meter.voltage_references[0], + reference_id="loads", + group_keys=("main_2",), + ), + ) + channels = tuple( + replace( + channel, + role=CircuitRole.GRID if channel.channel == 1 else CircuitRole.BRANCH, + voltage_reference_id="grid" if channel.channel <= 3 else "loads", + ) + for channel in baseline.channels + ) + aggregate = CircuitAggregate( + "grid", + "Grid", + CircuitRole.GRID, + (1,), + MeasurementMethod.DIRECT, + None, + EnergyMode.BIDIRECTIONAL, + ) + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + replace( + baseline.meter, + electrical_system=baseline.meter.electrical_system.SPLIT_PHASE_120_240, + voltage_layout=baseline.meter.voltage_layout.MULTI_REFERENCE, + voltage_references=voltage_references, + ), + channels, + (aggregate,), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert inventory.configuration.channels == channels + assert inventory.configuration.aggregates == (aggregate,) + assert inventory.configuration.meter.voltage_references == voltage_references + assert inventory.voltage_topology.references == ( + ("grid", ("main_1",)), + ("loads", ("main_2",)), + ) + assert "electrical_profile_requires_confirmation" not in inventory.warnings + + +def test_stale_stored_semantics_are_ignored_and_reported() -> None: + """Accepting a stored role after its source hash changed is a stale-plan bug.""" + content = _document(contract=True) + baseline = _inventory(content).configuration + stale = StoredMeterConfiguration( + "f" * 64, + baseline.meter, + tuple(replace(channel, role=CircuitRole.GRID) for channel in baseline.channels), + (), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stale) + + assert {channel.role for channel in inventory.configuration.channels} == { + CircuitRole.CUSTOM + } + assert "stored_semantics_stale" in inventory.warnings + + +def test_inventory_rejects_malformed_active_ct_configuration() -> None: + """Ignoring a missing active CT gain would create an unsafe partial plan.""" + content = _document().replace(" current_cal_ct6: 27524\n", "") + + with pytest.raises(ValueError, match="missing active substitution"): + _inventory(content) + + +def test_inventory_exposes_capability_reason_codes_without_threshold_capabilities() -> ( + None +): + """Dropping capability reasons would let the UI offer unavailable writes.""" + inventory = _inventory(_document(contract=True), authoritative=False) + + assert inventory.capabilities.reason_codes == ("configuration_not_authoritative",) + assert "configuration_not_authoritative" in inventory.warnings + assert not hasattr(inventory, "status_thresholds") diff --git a/tests/test_workflow.py b/tests/test_workflow.py index 4543819..4c664c0 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from hashlib import sha256 from types import SimpleNamespace from typing import Any @@ -12,9 +13,15 @@ OffsetCalibrationResult, OffsetCalibrationState, ) +from custom_components.circuitsetup_energy_meter_helper.device_builder import ( + ESPHomeConfigSnapshot, +) from custom_components.circuitsetup_energy_meter_helper.entity_binding import ( OffsetControlStatus, ) +from custom_components.circuitsetup_energy_meter_helper.meter_inventory import ( + MeterConfigurationInventory, +) from custom_components.circuitsetup_energy_meter_helper.offset_readiness import ( DEFAULT_OFFSET_READINESS_THRESHOLDS, OffsetReadinessResult, @@ -43,6 +50,96 @@ POWER_OFFSET_TABLE = ((7, 8), (9, 10), (11, 12)) +def test_meter_configuration_plan_uses_canonical_store_identity_and_ct_wrapper() -> ( + None +): + """A foreign device or CT-only handle would bypass the server-owned plan boundary.""" + content = ( + "esphome:\n project:\n name: circuitsetup.6c-energy-meter\n" + "substitutions:\n" + " friendly_name: Garage Meter\n" + " update_time: 10s\n" + " electric_freq: 60Hz\n" + " csemh_config_contract: 2\n" + " voltage_cal1: 7305\n" + + "".join( + f" ct{channel}_name: CT {channel}\n" + f" current_cal_ct{channel}: {27518 + channel}\n" + for channel in range(1, 7) + ) + ) + digest = sha256(content.encode()).hexdigest() + calls: list[str] = [] + + class Builder: + async def async_get_config(self, configuration: str) -> ESPHomeConfigSnapshot: + return ESPHomeConfigSnapshot(configuration, content, digest) + + async def async_close(self) -> None: + return None + + class Store: + async def async_save_interrupted_session(self, *_args: Any) -> None: + return None + + async def async_finalize_verified_calibration(self, *_args: Any) -> None: + return None + + async def async_get_ct_selections(self, mac: str) -> tuple[object, ...]: + calls.append(mac) + return () + + async def async_get_meter_configuration(self, mac: str) -> None: + calls.append(mac) + + class Hass: + def __init__(self) -> None: + self.config_entries = SimpleNamespace(async_get_entry=self._entry) + + @staticmethod + def _entry(device_id: str) -> object | None: + if device_id != "meter": + return None + return SimpleNamespace(unique_id="aa:bb:cc:dd:ee:ff") + + async def async_add_executor_job(self, target: Any, *args: Any) -> Any: + return target(*args) + + async def run() -> None: + provisioning = SimpleNamespace( + snapshot=SimpleNamespace( + devices=( + DiscoveredDevice( + "meter", + "Garage Meter", + "circuitsetup.6c-energy-meter", + configuration="meter.yaml", + ), + ) + ) + ) + workflow = EntryWorkflow( + Hass(), provisioning, SessionManager(), Store(), "meter", None, Builder() + ) + + with pytest.raises(WorkflowHandleError, match="owned"): + await workflow.async_get_meter_configuration("other") + + result = await workflow.async_get_meter_configuration("meter") + wrapper = await workflow.async_get_ct_inventory("meter") + + assert isinstance( + workflow._plans[wrapper["plan_id"]].inventory, MeterConfigurationInventory + ) + assert result["source_sha256"] == digest + assert result["configuration"].meter.friendly_name == "Garage Meter" + assert wrapper["channels"] == result["channels"] + assert calls == ["aabbccddeeff"] * 3 + await workflow.async_close() + + asyncio.run(run()) + + def _workflow( capability: OffsetControlStatus = OffsetControlStatus.AVAILABLE, *, From c0e6cbb5e7256f59be4fae455d3f5d439f52e1e7 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 05:34:55 -0400 Subject: [PATCH 30/35] fix: harden meter configuration inventory --- .../meter_inventory.py | 94 ++++++++++++------- .../circuitsetup_energy_meter_helper/store.py | 33 +++++++ .../workflow.py | 9 +- tests/test_meter_inventory.py | 82 ++++++++++++++++ tests/test_store.py | 23 +++++ tests/test_workflow.py | 9 +- 6 files changed, 212 insertions(+), 38 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py index f3869ea..1706b50 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py @@ -8,6 +8,7 @@ from typing import cast from .config_document import ESPHomeConfigDocument +from .config_mutator import package_options_from_document from .ct_catalog import CTPresetCatalog from .ct_inventory import CTInventory from .meter_configuration import ( @@ -32,7 +33,7 @@ from .voltage_transformer_catalog import VoltageTransformerCatalog _GENERIC_TOTAL_ID = re.compile( - r"\bid:\s*[\"']?(?:totalAmps|totalWatts|totalEnergyDaily)\b" + r"^\s*(?:-\s*)?id:\s*[\"']?(?:totalAmps|totalWatts|totalEnergyDaily)[\"']?\s*$" ) @@ -96,6 +97,7 @@ def from_document( stored_ct_selections: Iterable[StoredCTSelection] = (), reporting_multipliers: Mapping[int, float] | None = None, configuration_authoritative: bool = True, + stored_semantics_stale: bool = False, ) -> MeterConfigurationInventory: """Merge current YAML with hash-bound semantics and explicit legacy defaults.""" ct_inventory = CTInventory.from_document( @@ -116,22 +118,30 @@ def from_document( and stored_configuration.config_sha256 == config_sha256 else None ) - configuration = ( - _stored_request(matching, document, topology, ct_inventory) - if matching is not None - else _legacy_request(document, topology, ct_inventory) - ) - voltage_topology = ( - voltage_reference_topology_from_configuration(topology, configuration) - if matching is not None - else voltage_reference_topology_from_legacy(topology) + configuration = _legacy_request(document, topology, ct_inventory) + voltage_topology = voltage_reference_topology_from_legacy(topology) + stale = stored_semantics_stale or ( + stored_configuration is not None and matching is None ) + if matching is not None: + try: + configuration = _stored_request( + matching, document, topology, ct_inventory + ) + validate_meter_configuration(configuration, topology) + voltage_topology = voltage_reference_topology_from_configuration( + topology, configuration + ) + except (TypeError, ValueError): + configuration = _legacy_request(document, topology, ct_inventory) + voltage_topology = voltage_reference_topology_from_legacy(topology) + stale = True warnings = list(capabilities.reason_codes) if configuration.meter.electrical_system is ElectricalSystem.CUSTOM: warnings.append("electrical_profile_requires_confirmation") - if _GENERIC_TOTAL_ID.search(document.content): + if _has_generic_total(document): warnings.append("legacy_generic_totals_unmanaged") - if stored_configuration is not None and matching is None: + if stale: warnings.append("stored_semantics_stale") return cls( config_sha256, @@ -165,22 +175,23 @@ def _stored_request( ) for index, reference in enumerate(stored.meter.voltage_references) ) - channels = tuple( - replace( - stored_channel, - name=channel.name, - reporting_multiplier=channel.reporting_multiplier, - custom_gain_ct=( - channel.raw_gain_ct if stored_channel.model_id == "custom" else None - ), - custom_label=( - channel.name if stored_channel.model_id == "custom" else None - ), - ) - for stored_channel, channel in zip( - stored.channels, ct_inventory.channels, strict=True + stored_by_channel = _stored_channels_by_number(stored.channels, topology) + channels: list[ChannelSettings] = [] + for channel in ct_inventory.channels: + stored_channel = stored_by_channel[channel.channel] + channels.append( + replace( + stored_channel, + name=channel.name, + reporting_multiplier=channel.reporting_multiplier, + custom_gain_ct=( + channel.raw_gain_ct if stored_channel.model_id == "custom" else None + ), + custom_label=( + channel.name if stored_channel.model_id == "custom" else None + ), + ) ) - ) return MeterConfigurationRequest( replace( stored.meter, @@ -207,7 +218,7 @@ def _stored_request( ), voltage_references=references, ), - channels, + tuple(channels), stored.aggregates, stored.power_quality, stored.status_fields, @@ -247,7 +258,7 @@ def _legacy_request( ) for channel in ct_inventory.channels ) - package_options = _package_options(document, topology) + package_options = package_options_from_document(document, topology) request = MeterConfigurationRequest( MeterSettings( _value(document, "friendly_name", "Energy meter"), @@ -303,9 +314,24 @@ def _reference_for_channel( ) -def _package_options( - document: ESPHomeConfigDocument, topology: MeterTopology -) -> dict[str, tuple[bool, ...]]: - from .config_mutator import package_options_from_document +def _stored_channels_by_number( + channels: tuple[ChannelSettings, ...], topology: MeterTopology +) -> dict[int, ChannelSettings]: + stored_by_channel: dict[int, ChannelSettings] = {} + for channel in channels: + if ( + channel.channel not in range(1, topology.ct_count + 1) + or channel.channel in stored_by_channel + ): + raise ValueError("stored channels must uniquely cover topology") + stored_by_channel[channel.channel] = channel + if set(stored_by_channel) != set(range(1, topology.ct_count + 1)): + raise ValueError("stored channels must uniquely cover topology") + return stored_by_channel - return package_options_from_document(document, topology) + +def _has_generic_total(document: ESPHomeConfigDocument) -> bool: + return any( + _GENERIC_TOTAL_ID.fullmatch(line.split("#", 1)[0].rstrip()) + for line in document.lines + ) diff --git a/custom_components/circuitsetup_energy_meter_helper/store.py b/custom_components/circuitsetup_energy_meter_helper/store.py index a446cb5..6aea8a3 100644 --- a/custom_components/circuitsetup_energy_meter_helper/store.py +++ b/custom_components/circuitsetup_energy_meter_helper/store.py @@ -550,6 +550,14 @@ def __post_init__(self) -> None: raise TypeError(f"{field} must be a tuple of booleans") +@dataclass(frozen=True, slots=True) +class MeterConfigurationRead: + """Safe inventory-facing result that preserves strict getter behavior.""" + + configuration: StoredMeterConfiguration | None + stale: bool + + def _exact_mapping(raw: object, keys: set[str], label: str) -> dict[str, Any]: if ( not isinstance(raw, dict) @@ -992,6 +1000,31 @@ async def async_get_meter_configuration( configuration = _deserialize_meter_configuration(raw_configuration, topology) return configuration + async def async_get_meter_configuration_read( + self, mac: str + ) -> MeterConfigurationRead: + """Read semantics for an inventory without surfacing malformed storage.""" + mac = canonical_mac(mac) + raw_meter = (await self.async_load()).get("meters", {}).get(mac) + if not isinstance(raw_meter, dict): + return MeterConfigurationRead(None, False) + raw_configuration = raw_meter.get("meter_configuration") + if raw_configuration is None: + return MeterConfigurationRead(None, False) + current_hash = _configuration_hash(raw_meter) + if ( + current_hash is None + or _configuration_hash(raw_configuration) != current_hash + ): + return MeterConfigurationRead(None, True) + try: + configuration = _deserialize_meter_configuration( + raw_configuration, _current_topology(raw_meter) + ) + except (TypeError, ValueError): + return MeterConfigurationRead(None, True) + return MeterConfigurationRead(configuration, False) + async def async_save_verified_meter_configuration( self, mac: str, configuration: StoredMeterConfiguration ) -> None: diff --git a/custom_components/circuitsetup_energy_meter_helper/workflow.py b/custom_components/circuitsetup_energy_meter_helper/workflow.py index 13467bb..e97e515 100644 --- a/custom_components/circuitsetup_energy_meter_helper/workflow.py +++ b/custom_components/circuitsetup_energy_meter_helper/workflow.py @@ -444,8 +444,8 @@ async def _async_get_meter_configuration( VoltageTransformerCatalog.load ) selections = await self._store.async_get_ct_selections(mac) - stored_configuration = ( - await self._store.async_get_meter_configuration(mac) + stored_read = ( + await self._store.async_get_meter_configuration_read(mac) if include_stored_semantics else None ) @@ -455,11 +455,14 @@ async def _async_get_meter_configuration( ct_catalog, voltage_catalog, snapshot.sha256, - stored_configuration=stored_configuration, + stored_configuration=( + stored_read.configuration if stored_read is not None else None + ), stored_ct_selections=selections, reporting_multipliers=_stored_reporting_multipliers( selections, snapshot.sha256 ), + stored_semantics_stale=(stored_read.stale if stored_read is not None else False), ) plan_id = uuid4().hex self._discard_device_plans(mac) diff --git a/tests/test_meter_inventory.py b/tests/test_meter_inventory.py index edf7154..602a65d 100644 --- a/tests/test_meter_inventory.py +++ b/tests/test_meter_inventory.py @@ -225,6 +225,75 @@ def test_matching_stored_semantics_restore_roles_reference_mapping_and_aggregate assert "electrical_profile_requires_confirmation" not in inventory.warnings +def test_matching_stored_channels_merge_by_channel_identity_not_tuple_order() -> None: + """Zipping reordered storage into YAML channel order would cross-wire circuit roles.""" + content = _document(contract=True) + baseline = _inventory(content).configuration + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + baseline.meter, + tuple( + replace(channel, role=CircuitRole.GRID if channel.channel == 1 else CircuitRole.BRANCH) + for channel in reversed(baseline.channels) + ), + (), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert inventory.configuration.channels[0].channel == 1 + assert inventory.configuration.channels[0].role is CircuitRole.GRID + assert all( + channel.role is CircuitRole.BRANCH + for channel in inventory.configuration.channels[1:] + ) + assert "stored_semantics_stale" not in inventory.warnings + + +def test_invalid_matching_stored_semantics_fall_back_to_legacy_defaults() -> None: + """Returning an invalid hash-matching reference would publish an unusable plan.""" + content = _document(contract=True) + baseline = _inventory(content).configuration + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + baseline.meter, + (replace(baseline.channels[0], voltage_reference_id="missing"), *baseline.channels[1:]), + (), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert {channel.role for channel in inventory.configuration.channels} == { + CircuitRole.CUSTOM + } + assert "stored_semantics_stale" in inventory.warnings + + +def test_duplicate_matching_stored_channels_fall_back_to_legacy_defaults() -> None: + """A duplicate stored channel must not be silently matched by tuple position.""" + content = _document(contract=True) + baseline = _inventory(content).configuration + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + baseline.meter, + (baseline.channels[0], baseline.channels[0], *baseline.channels[2:]), + (), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert {channel.role for channel in inventory.configuration.channels} == { + CircuitRole.CUSTOM + } + assert "stored_semantics_stale" in inventory.warnings + + def test_stale_stored_semantics_are_ignored_and_reported() -> None: """Accepting a stored role after its source hash changed is a stale-plan bug.""" content = _document(contract=True) @@ -263,3 +332,16 @@ def test_inventory_exposes_capability_reason_codes_without_threshold_capabilitie assert inventory.capabilities.reason_codes == ("configuration_not_authoritative",) assert "configuration_not_authoritative" in inventory.warnings assert not hasattr(inventory, "status_thresholds") + + +def test_generic_total_warning_ignores_comments_but_detects_active_ids() -> None: + """Treating comments as generic totals would report a warning for inactive YAML.""" + inactive = _inventory( + _document(contract=True) + + "# id: totalWatts\n" + + "note: preserved # id: totalAmps\n" + ) + active = _inventory(_document(contract=True, generic_totals=True)) + + assert "legacy_generic_totals_unmanaged" not in inactive.warnings + assert "legacy_generic_totals_unmanaged" in active.warnings diff --git a/tests/test_store.py b/tests/test_store.py index da4d128..09fe3f9 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -28,6 +28,7 @@ STORAGE_MINOR_VERSION, STORAGE_VERSION, HelperStore, + MeterConfigurationRead, StoredMeterConfiguration, VerifiedCalibrationRecord, VerifiedGainGroup, @@ -625,6 +626,28 @@ async def run() -> None: asyncio.run(run()) +def test_meter_configuration_read_reports_malformed_current_semantics_without_raising() -> ( + None +): + """Inventory reads need a stale result while strict callers still reject bad storage.""" + async def run() -> None: + backend = _CopyingStorage() + store = object.__new__(HelperStore) + store._store = backend # type: ignore[assignment] + store._update_lock = asyncio.Lock() + await store.async_save_meter(_record()) + await store.async_save_verified_meter_configuration(MAC, _configuration()) + backend.data["meters"][MAC]["meter_configuration"]["channels"] = "invalid" # type: ignore[index] + + result = await store.async_get_meter_configuration_read(MAC) + + assert result == MeterConfigurationRead(None, True) + with pytest.raises(ValueError, match="meter configuration"): + await store.async_get_meter_configuration(MAC) + + asyncio.run(run()) + + def test_storage_1_3_preserves_legacy_ct_selection_bytes() -> None: legacy = { "meters": { diff --git a/tests/test_workflow.py b/tests/test_workflow.py index 4c664c0..2bb064f 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -35,6 +35,9 @@ PendingCalibrationOrigin, SessionManager, ) +from custom_components.circuitsetup_energy_meter_helper.store import ( + MeterConfigurationRead, +) from custom_components.circuitsetup_energy_meter_helper.topology import ( topology_from_native, ) @@ -89,8 +92,11 @@ async def async_get_ct_selections(self, mac: str) -> tuple[object, ...]: calls.append(mac) return () - async def async_get_meter_configuration(self, mac: str) -> None: + async def async_get_meter_configuration_read( + self, mac: str + ) -> MeterConfigurationRead: calls.append(mac) + return MeterConfigurationRead(None, True) class Hass: def __init__(self) -> None: @@ -134,6 +140,7 @@ async def run() -> None: assert result["source_sha256"] == digest assert result["configuration"].meter.friendly_name == "Garage Meter" assert wrapper["channels"] == result["channels"] + assert "stored_semantics_stale" in result["warnings"] assert calls == ["aabbccddeeff"] * 3 await workflow.async_close() From 93cbab88dec1d42cc4b7cf48a247608aa52a5dbc Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 05:44:25 -0400 Subject: [PATCH 31/35] fix: map stored voltage calibration by group --- .../meter_inventory.py | 36 +++-- tests/test_meter_inventory.py | 133 +++++++++++++++++- 2 files changed, 156 insertions(+), 13 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py index 1706b50..1fef423 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py @@ -166,15 +166,7 @@ def _stored_request( topology: MeterTopology, ct_inventory: CTInventory, ) -> MeterConfigurationRequest: - references = tuple( - replace( - reference, - gain_voltage=_gain( - document, f"voltage_cal{index + 1}", reference.gain_voltage - ), - ) - for index, reference in enumerate(stored.meter.voltage_references) - ) + references = _stored_voltage_references(stored, document, topology) stored_by_channel = _stored_channels_by_number(stored.channels, topology) channels: list[ChannelSettings] = [] for channel in ct_inventory.channels: @@ -228,6 +220,32 @@ def _stored_request( ) +def _stored_voltage_references( + stored: StoredMeterConfiguration, + document: ESPHomeConfigDocument, + topology: MeterTopology, +) -> tuple[VoltageReferenceConfig, ...]: + gain_key_by_group = { + group: f"voltage_cal{index + 1}" + for index, (_, groups) in enumerate( + voltage_reference_topology_from_legacy(topology).references + ) + for group in groups + } + references: list[VoltageReferenceConfig] = [] + for reference in stored.meter.voltage_references: + gain_keys = {gain_key_by_group.get(group) for group in reference.group_keys} + if len(gain_keys) != 1: + raise ValueError("stored voltage reference has ambiguous calibration") + gain_key = next(iter(gain_keys)) + if gain_key is None: + raise ValueError("stored voltage reference has ambiguous calibration") + references.append( + replace(reference, gain_voltage=_gain(document, gain_key, reference.gain_voltage)) + ) + return tuple(references) + + def _legacy_request( document: ESPHomeConfigDocument, topology: MeterTopology, ct_inventory: CTInventory ) -> MeterConfigurationRequest: diff --git a/tests/test_meter_inventory.py b/tests/test_meter_inventory.py index 602a65d..5f09524 100644 --- a/tests/test_meter_inventory.py +++ b/tests/test_meter_inventory.py @@ -82,19 +82,35 @@ def test_capability_inputs_require_exact_bool_and_int_types( ) -def _document(*, contract: bool = False, generic_totals: bool = False) -> str: +def _document( + *, + contract: bool = False, + generic_totals: bool = False, + addon_count: int = 0, + two_voltages: bool = False, + voltage_cal1: int = 7305, + voltage_cal2: int | None = None, +) -> str: + addon_suffix = f"-{addon_count}-addon{'s' if addon_count != 1 else ''}" if addon_count else "" + voltage_suffix = "-2-voltages" if two_voltages else "" packages = ( " files:\n" + " - Software/ESPHome/meter_sensors/6chan_main_sensor.yaml\n" " - Software/ESPHome/power_quality/6chan_main_power_quality.yaml\n" " - Software/ESPHome/status_fields/6chan_main_status.yaml\n" + + "".join( + f" - Software/ESPHome/meter_sensors/6chan_addon{index}.yaml\n" + for index in range(1, addon_count + 1) + ) ) substitutions = "".join( f" ct{channel}_name: {'Grid' if channel == 1 else f'Load {channel}'}\n" f" current_cal_ct{channel}: {27518 + channel}\n" - for channel in range(1, 7) + for channel in range(1, 6 * (addon_count + 1) + 1) ) return ( - "esphome:\n project:\n name: circuitsetup.6c-energy-meter\n" + "esphome:\n project:\n name: circuitsetup.6c-energy-meter" + f"{addon_suffix}{voltage_suffix}\n" "packages:\n" f"{packages}" "substitutions:\n" @@ -102,7 +118,8 @@ def _document(*, contract: bool = False, generic_totals: bool = False) -> str: " update_time: 10s\n" " electric_freq: 60Hz\n" + (" csemh_config_contract: '2'\n" if contract else "") - + " voltage_cal1: 7305\n" + + f" voltage_cal1: {voltage_cal1}\n" + + (f" voltage_cal2: {voltage_cal2}\n" if voltage_cal2 else "") + substitutions + ("sensor:\n - id: totalWatts\n" if generic_totals else "") ) @@ -252,6 +269,114 @@ def test_matching_stored_channels_merge_by_channel_identity_not_tuple_order() -> assert "stored_semantics_stale" not in inventory.warnings +def test_matching_stored_voltage_references_merge_gains_by_groups_not_tuple_order() -> ( + None +): + """Reversing stored references must not swap physical voltage calibrations.""" + content = _document( + contract=True, + addon_count=1, + two_voltages=True, + voltage_cal1=7001, + voltage_cal2=8002, + ) + baseline = _inventory(content).configuration + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + replace( + baseline.meter, + voltage_references=tuple(reversed(baseline.meter.voltage_references)), + ), + baseline.channels, + (), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert [ + (reference.reference_id, reference.gain_voltage) + for reference in inventory.configuration.meter.voltage_references + ] == [("secondary", 8002), ("main", 7001)] + assert "stored_semantics_stale" not in inventory.warnings + + +def test_matching_stored_voltage_references_allow_scrambled_group_order() -> None: + """Reordering groups within each physical reference must keep its calibration.""" + content = _document( + contract=True, + addon_count=1, + two_voltages=True, + voltage_cal1=7001, + voltage_cal2=8002, + ) + baseline = _inventory(content).configuration + secondary, main = reversed(baseline.meter.voltage_references) + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + replace( + baseline.meter, + voltage_references=( + replace(secondary, group_keys=("addon1_2", "main_2")), + replace(main, group_keys=("addon1_1", "main_1")), + ), + ), + baseline.channels, + (), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert [ + (reference.reference_id, reference.gain_voltage, reference.group_keys) + for reference in inventory.configuration.meter.voltage_references + ] == [ + ("secondary", 8002, ("addon1_2", "main_2")), + ("main", 7001, ("addon1_1", "main_1")), + ] + assert "stored_semantics_stale" not in inventory.warnings + + +def test_ambiguous_stored_voltage_reference_groups_fall_back_to_legacy_defaults() -> ( + None +): + """A valid helper grouping without physical calibration provenance is stale.""" + content = _document( + contract=True, + addon_count=1, + two_voltages=True, + voltage_cal1=7001, + voltage_cal2=8002, + ) + baseline = _inventory(content).configuration + main, secondary = baseline.meter.voltage_references + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + replace( + baseline.meter, + voltage_references=( + replace(main, group_keys=("main_1", "main_2")), + replace(secondary, group_keys=("addon1_1", "addon1_2")), + ), + ), + baseline.channels, + (), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert inventory.voltage_topology.references == ( + ("main", ("main_1", "addon1_1")), + ("secondary", ("main_2", "addon1_2")), + ) + assert "stored_semantics_stale" in inventory.warnings + + def test_invalid_matching_stored_semantics_fall_back_to_legacy_defaults() -> None: """Returning an invalid hash-matching reference would publish an unusable plan.""" content = _document(contract=True) From 1a4b91e71bea379876cc7019371c7820ca1a5613 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 05:50:23 -0400 Subject: [PATCH 32/35] fix: map voltage gains by group suffix --- .../meter_inventory.py | 30 ++++---- tests/test_meter_inventory.py | 74 ++++++++++++++++++- 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py index 1fef423..ef7977a 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py @@ -23,7 +23,12 @@ VoltageReferenceConfig, validate_meter_configuration, ) -from .models import MeterTopology, StoredCTSelection, VoltageReferenceTopology +from .models import ( + VOLTAGE_REFERENCE_GROUP_RE, + MeterTopology, + StoredCTSelection, + VoltageReferenceTopology, +) from .store import StoredMeterConfiguration from .topology import ( channel_address, @@ -166,7 +171,7 @@ def _stored_request( topology: MeterTopology, ct_inventory: CTInventory, ) -> MeterConfigurationRequest: - references = _stored_voltage_references(stored, document, topology) + references = _stored_voltage_references(stored, document) stored_by_channel = _stored_channels_by_number(stored.channels, topology) channels: list[ChannelSettings] = [] for channel in ct_inventory.channels: @@ -223,23 +228,18 @@ def _stored_request( def _stored_voltage_references( stored: StoredMeterConfiguration, document: ESPHomeConfigDocument, - topology: MeterTopology, ) -> tuple[VoltageReferenceConfig, ...]: - gain_key_by_group = { - group: f"voltage_cal{index + 1}" - for index, (_, groups) in enumerate( - voltage_reference_topology_from_legacy(topology).references - ) - for group in groups - } references: list[VoltageReferenceConfig] = [] for reference in stored.meter.voltage_references: - gain_keys = {gain_key_by_group.get(group) for group in reference.group_keys} - if len(gain_keys) != 1: - raise ValueError("stored voltage reference has ambiguous calibration") - gain_key = next(iter(gain_keys)) - if gain_key is None: + groups = tuple( + group + for group in reference.group_keys + if VOLTAGE_REFERENCE_GROUP_RE.fullmatch(group) is not None + ) + gain_suffixes = {group[-1] for group in groups} + if len(gain_suffixes) != 1 or len(groups) != len(reference.group_keys): raise ValueError("stored voltage reference has ambiguous calibration") + gain_key = f"voltage_cal{gain_suffixes.pop()}" references.append( replace(reference, gain_voltage=_gain(document, gain_key, reference.gain_voltage)) ) diff --git a/tests/test_meter_inventory.py b/tests/test_meter_inventory.py index 5f09524..f4a4624 100644 --- a/tests/test_meter_inventory.py +++ b/tests/test_meter_inventory.py @@ -248,9 +248,27 @@ def test_matching_stored_channels_merge_by_channel_identity_not_tuple_order() -> baseline = _inventory(content).configuration stored = StoredMeterConfiguration( sha256(content.encode()).hexdigest(), - baseline.meter, + replace( + baseline.meter, + voltage_references=( + replace( + baseline.meter.voltage_references[0], + reference_id="first", + group_keys=("main_1",), + ), + replace( + baseline.meter.voltage_references[0], + reference_id="second", + group_keys=("main_2",), + ), + ), + ), tuple( - replace(channel, role=CircuitRole.GRID if channel.channel == 1 else CircuitRole.BRANCH) + replace( + channel, + role=CircuitRole.GRID if channel.channel == 1 else CircuitRole.BRANCH, + voltage_reference_id="first" if channel.channel <= 3 else "second", + ) for channel in reversed(baseline.channels) ), (), @@ -302,6 +320,58 @@ def test_matching_stored_voltage_references_merge_gains_by_groups_not_tuple_orde assert "stored_semantics_stale" not in inventory.warnings +def test_standard_helper_references_map_gains_by_group_suffix_across_addons() -> None: + """Standard projects still use distinct physical calibrations for _1 and _2.""" + content = _document( + contract=True, + addon_count=1, + voltage_cal1=7001, + voltage_cal2=8002, + ) + baseline = _inventory(content).configuration + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + replace( + baseline.meter, + voltage_references=( + replace( + baseline.meter.voltage_references[0], + reference_id="first", + group_keys=("main_1", "addon1_1"), + ), + replace( + baseline.meter.voltage_references[0], + reference_id="second", + group_keys=("main_2", "addon1_2"), + ), + ), + ), + tuple( + replace( + channel, + voltage_reference_id=( + "first" if (channel.channel - 1) % 6 < 3 else "second" + ), + ) + for channel in baseline.channels + ), + (), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert [ + (reference.reference_id, reference.gain_voltage, reference.group_keys) + for reference in inventory.configuration.meter.voltage_references + ] == [ + ("first", 7001, ("main_1", "addon1_1")), + ("second", 8002, ("main_2", "addon1_2")), + ] + assert "stored_semantics_stale" not in inventory.warnings + + def test_matching_stored_voltage_references_allow_scrambled_group_order() -> None: """Reordering groups within each physical reference must keep its calibration.""" content = _document( From f81c80b844c0c743ab01f652d59a3a555ad25c85 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 06:20:30 -0400 Subject: [PATCH 33/35] fix: align meter inventory contracts --- .../meter_configuration.py | 11 +- .../meter_inventory.py | 56 +++++---- .../circuitsetup_energy_meter_helper/store.py | 4 +- .../topology.py | 2 +- .../workflow.py | 3 +- tests/test_meter_configuration.py | 3 + tests/test_meter_inventory.py | 111 ++++++++++++++++++ tests/test_topology.py | 105 +++++++++++++++++ tests/test_workflow.py | 2 + 9 files changed, 265 insertions(+), 32 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py index db4f6b7..f845fc2 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_configuration.py @@ -136,7 +136,10 @@ def _bools(values: object, field: str, count: int) -> None: def validate_meter_configuration( - request: MeterConfigurationRequest, topology: MeterTopology + request: MeterConfigurationRequest, + topology: MeterTopology, + *, + require_multi_reference_acknowledgement: bool = True, ) -> None: """Validate a request against fixed physical topology without side effects.""" meter = request.meter @@ -175,7 +178,11 @@ def validate_meter_configuration( raise ValueError("topology groups must be assigned exactly once") if type(request.multi_reference_preparation_acknowledged) is not bool: raise ValueError("multi-reference acknowledgement must be boolean") - if len(refs) > 1 and not request.multi_reference_preparation_acknowledged: + if ( + require_multi_reference_acknowledgement + and len(refs) > 1 + and not request.multi_reference_preparation_acknowledged + ): raise ValueError("multi-reference preparation acknowledgement required") if len(refs) == 1 and request.multi_reference_preparation_acknowledged: raise ValueError("multi-reference acknowledgement is only for multiple references") diff --git a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py index ef7977a..db260f9 100644 --- a/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py +++ b/custom_components/circuitsetup_energy_meter_helper/meter_inventory.py @@ -75,23 +75,21 @@ def meter_configuration_capabilities( class MeterConfigurationInventory: """One hash-bound meter configuration snapshot, ready for a server plan handle.""" + plan_id: str source_sha256: str topology: MeterTopology configuration: MeterConfigurationRequest - ct_inventory: CTInventory - voltage_topology: VoltageReferenceTopology capabilities: MeterConfigurationCapabilities voltage_transformer_catalog: VoltageTransformerCatalog + ct_catalog: CTPresetCatalog warnings: tuple[str, ...] - - @property - def ct_catalog(self) -> CTPresetCatalog: - """Expose the existing CT catalog without duplicating it in the handle.""" - return self.ct_inventory.catalog + ct_inventory: CTInventory + voltage_topology: VoltageReferenceTopology @classmethod def from_document( cls, + plan_id: str, document: ESPHomeConfigDocument, topology: MeterTopology, ct_catalog: CTPresetCatalog, @@ -133,7 +131,11 @@ def from_document( configuration = _stored_request( matching, document, topology, ct_inventory ) - validate_meter_configuration(configuration, topology) + validate_meter_configuration( + configuration, + topology, + require_multi_reference_acknowledgement=False, + ) voltage_topology = voltage_reference_topology_from_configuration( topology, configuration ) @@ -149,14 +151,16 @@ def from_document( if stale: warnings.append("stored_semantics_stale") return cls( - config_sha256, - topology, - configuration, - ct_inventory, - voltage_topology, - capabilities, - voltage_transformer_catalog, - tuple(warnings), + plan_id=plan_id, + source_sha256=config_sha256, + topology=topology, + configuration=configuration, + capabilities=capabilities, + voltage_transformer_catalog=voltage_transformer_catalog, + ct_catalog=ct_catalog, + warnings=tuple(warnings), + ct_inventory=ct_inventory, + voltage_topology=voltage_topology, ) @@ -219,9 +223,6 @@ def _stored_request( stored.aggregates, stored.power_quality, stored.status_fields, - multi_reference_preparation_acknowledged=( - len(stored.meter.voltage_references) > 1 - ), ) @@ -236,12 +237,16 @@ def _stored_voltage_references( for group in reference.group_keys if VOLTAGE_REFERENCE_GROUP_RE.fullmatch(group) is not None ) - gain_suffixes = {group[-1] for group in groups} - if len(gain_suffixes) != 1 or len(groups) != len(reference.group_keys): + if len(groups) != len(reference.group_keys): + raise ValueError("stored voltage reference has ambiguous calibration") + gains = { + _gain(document, f"voltage_cal{group[-1]}", reference.gain_voltage) + for group in groups + } + if len(gains) != 1: raise ValueError("stored voltage reference has ambiguous calibration") - gain_key = f"voltage_cal{gain_suffixes.pop()}" references.append( - replace(reference, gain_voltage=_gain(document, gain_key, reference.gain_voltage)) + replace(reference, gain_voltage=gains.pop()) ) return tuple(references) @@ -298,9 +303,10 @@ def _legacy_request( (), package_options["power_quality"], package_options["status_fields"], - multi_reference_preparation_acknowledged=len(references) > 1, ) - validate_meter_configuration(request, topology) + validate_meter_configuration( + request, topology, require_multi_reference_acknowledgement=False + ) return request diff --git a/custom_components/circuitsetup_energy_meter_helper/store.py b/custom_components/circuitsetup_energy_meter_helper/store.py index 6aea8a3..0974960 100644 --- a/custom_components/circuitsetup_energy_meter_helper/store.py +++ b/custom_components/circuitsetup_energy_meter_helper/store.py @@ -654,11 +654,9 @@ def _validate_configuration( configuration.aggregates, configuration.power_quality, configuration.status_fields, - multi_reference_preparation_acknowledged=( - len(configuration.meter.voltage_references) > 1 - ), ), topology, + require_multi_reference_acknowledgement=False, ) diff --git a/custom_components/circuitsetup_energy_meter_helper/topology.py b/custom_components/circuitsetup_energy_meter_helper/topology.py index 970b9a4..c593539 100644 --- a/custom_components/circuitsetup_energy_meter_helper/topology.py +++ b/custom_components/circuitsetup_energy_meter_helper/topology.py @@ -217,7 +217,7 @@ def _managed_voltage_reference_assignments( elif not value: raise TopologyParseError("invalid managed voltage-reference mapping") entries.append((reference_id, groups)) - if not entries or len(entries) > 2: + if not 1 <= len(entries) <= min(8, topology.group_count): raise TopologyParseError("invalid managed voltage-reference mapping") expected = _expected_group_keys(topology) inferred = tuple(groups is None for _, groups in entries) diff --git a/custom_components/circuitsetup_energy_meter_helper/workflow.py b/custom_components/circuitsetup_energy_meter_helper/workflow.py index e97e515..d0a270c 100644 --- a/custom_components/circuitsetup_energy_meter_helper/workflow.py +++ b/custom_components/circuitsetup_energy_meter_helper/workflow.py @@ -449,7 +449,9 @@ async def _async_get_meter_configuration( if include_stored_semantics else None ) + plan_id = uuid4().hex inventory = MeterConfigurationInventory.from_document( + plan_id, document, topology, ct_catalog, @@ -464,7 +466,6 @@ async def _async_get_meter_configuration( ), stored_semantics_stale=(stored_read.stale if stored_read is not None else False), ) - plan_id = uuid4().hex self._discard_device_plans(mac) while len(self._plans) >= MAX_PLAN_HANDLES: oldest = next(iter(self._plans)) diff --git a/tests/test_meter_configuration.py b/tests/test_meter_configuration.py index 6bfbb5c..5447729 100644 --- a/tests/test_meter_configuration.py +++ b/tests/test_meter_configuration.py @@ -230,6 +230,9 @@ def test_board_options_and_multi_reference_acknowledgement() -> None: object.__setattr__(value, "multi_reference_preparation_acknowledged", False) with pytest.raises(ValueError): validate_meter_configuration(value, topology()) + validate_meter_configuration( + value, topology(), require_multi_reference_acknowledgement=False + ) object.__setattr__(value, "multi_reference_preparation_acknowledged", True) validate_meter_configuration(value, topology()) object.__setattr__(value, "multi_reference_preparation_acknowledged", 1) diff --git a/tests/test_meter_inventory.py b/tests/test_meter_inventory.py index f4a4624..5ed072f 100644 --- a/tests/test_meter_inventory.py +++ b/tests/test_meter_inventory.py @@ -133,6 +133,7 @@ def _inventory( ) -> MeterConfigurationInventory: document = ESPHomeConfigDocument.parse(content) return MeterConfigurationInventory.from_document( + "a" * 32, document, topology_from_config(document), CTPresetCatalog.load(), @@ -143,6 +144,24 @@ def _inventory( ) +def test_inventory_has_server_plan_and_catalog_fields() -> None: + inventory = _inventory(_document()) + + field_names = tuple(field.name for field in fields(MeterConfigurationInventory)) + assert field_names[:8] == ( + "plan_id", + "source_sha256", + "topology", + "configuration", + "capabilities", + "voltage_transformer_catalog", + "ct_catalog", + "warnings", + ) + assert inventory.plan_id == "a" * 32 + assert inventory.ct_catalog is inventory.ct_inventory.catalog + + def test_legacy_inventory_keeps_yaml_ct_values_and_requires_electrical_confirmation() -> ( None ): @@ -242,6 +261,98 @@ def test_matching_stored_semantics_restore_roles_reference_mapping_and_aggregate assert "electrical_profile_requires_confirmation" not in inventory.warnings +@pytest.mark.parametrize("addon_count", (0, 1)) +def test_matching_single_reference_restores_semantics_when_physical_gains_agree( + addon_count: int, +) -> None: + """One logical reference may losslessly span both physical gain groups.""" + content = _document( + contract=True, + addon_count=addon_count, + voltage_cal1=7001, + voltage_cal2=7001, + ) + baseline = _inventory(content).configuration + aggregate = CircuitAggregate( + "grid", + "Grid", + CircuitRole.GRID, + (1,), + MeasurementMethod.DIRECT, + None, + EnergyMode.BIDIRECTIONAL, + ) + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + baseline.meter, + tuple( + replace( + channel, + role=CircuitRole.GRID + if channel.channel == 1 + else CircuitRole.BRANCH, + ) + for channel in baseline.channels + ), + (aggregate,), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert inventory.configuration.meter.voltage_references[0].gain_voltage == 7001 + assert inventory.configuration.channels[0].role is CircuitRole.GRID + assert inventory.configuration.aggregates == (aggregate,) + assert not inventory.configuration.multi_reference_preparation_acknowledged + assert "stored_semantics_stale" not in inventory.warnings + + +def test_single_reference_with_divergent_physical_gains_is_stale() -> None: + """One gain field cannot preserve two different physical group gains.""" + content = _document(contract=True, voltage_cal1=7001, voltage_cal2=8002) + baseline = _inventory(content).configuration + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + baseline.meter, + tuple(replace(channel, role=CircuitRole.GRID) for channel in baseline.channels), + (), + baseline.power_quality, + baseline.status_fields, + ) + + inventory = _inventory(content, stored=stored) + + assert {channel.role for channel in inventory.configuration.channels} == { + CircuitRole.CUSTOM + } + assert "stored_semantics_stale" in inventory.warnings + + +def test_legacy_and_stored_multi_reference_inventory_never_claims_preparation() -> None: + content = _document( + contract=True, + two_voltages=True, + voltage_cal1=7001, + voltage_cal2=8002, + ) + legacy = _inventory(content) + stored = StoredMeterConfiguration( + sha256(content.encode()).hexdigest(), + legacy.configuration.meter, + legacy.configuration.channels, + (), + legacy.configuration.power_quality, + legacy.configuration.status_fields, + ) + + restored = _inventory(content, stored=stored) + + assert not legacy.configuration.multi_reference_preparation_acknowledged + assert not restored.configuration.multi_reference_preparation_acknowledged + assert "stored_semantics_stale" not in restored.warnings + + def test_matching_stored_channels_merge_by_channel_identity_not_tuple_order() -> None: """Zipping reordered storage into YAML channel order would cross-wire circuit roles.""" content = _document(contract=True) diff --git a/tests/test_topology.py b/tests/test_topology.py index 13f8faf..6a0d109 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -454,6 +454,111 @@ def test_trusted_managed_voltage_block_accepts_whitespace_around_list_elements() ).fingerprint == trusted.fingerprint +@pytest.mark.parametrize( + ("addon_count", "reference_count"), + ((1, 3), (3, 8)), +) +def test_trusted_managed_voltage_block_accepts_bounded_reference_counts( + addon_count: int, reference_count: int +) -> None: + groups = tuple( + f"{'main' if board == 0 else f'addon{board}'}_{group}" + for board in range(addon_count + 1) + for group in (1, 2) + ) + references = tuple( + (f"ref{index}", groups[index::reference_count]) + for index in range(reference_count) + ) + assignments = "".join( + f" {reference_id}: [{', '.join(reference_groups)}]\n" + for reference_id, reference_groups in references + ) + document = ESPHomeConfigDocument.parse( + "esphome:\n project:\n name: circuitsetup.6c-energy-meter" + f"-{addon_count}-addons\n" + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + f"{assignments}" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_config(document) + trusted = VoltageReferenceTopology(references, "helper") + + voltage = voltage_reference_topology_from_config( + document, meter, trusted_fingerprint=trusted.fingerprint + ) + + assert len(voltage.references) == reference_count + assert meter.board_count == addon_count + 1 + + +@pytest.mark.parametrize("reference_count", (0, 9), ids=("zero", "nine")) +def test_trusted_managed_voltage_block_rejects_counts_outside_one_to_eight( + reference_count: int, +) -> None: + document = ESPHomeConfigDocument.parse( + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + + "".join(f" ref{index}: 120\n" for index in range(reference_count)) + + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_native("circuitsetup.6c-energy-meter-4-addons") + + with pytest.raises(TopologyParseError, match="invalid managed"): + voltage_reference_topology_from_config( + document, meter, trusted_fingerprint="v1:" + "0" * 64 + ) + + +def test_trusted_managed_voltage_block_rejects_more_references_than_groups() -> None: + document = ESPHomeConfigDocument.parse( + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + " first: 120\n" + " second: 120\n" + " third: 120\n" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_native("circuitsetup.6c-energy-meter") + + with pytest.raises(TopologyParseError, match="invalid managed"): + voltage_reference_topology_from_config( + document, meter, trusted_fingerprint="v1:" + "0" * 64 + ) + + +@pytest.mark.parametrize("profile", ("three_phase", "custom")) +def test_electrical_profile_does_not_change_managed_topology_board_count( + profile: str, +) -> None: + document = ESPHomeConfigDocument.parse( + "esphome:\n project:\n name: circuitsetup.6c-energy-meter-1-addon\n" + f"substitutions:\n electrical_system: {profile}\n" + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + " phase_a: [main_1]\n" + " phase_b: [main_2]\n" + " phase_c: [addon1_1, addon1_2]\n" + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_config(document) + trusted = VoltageReferenceTopology( + ( + ("phase_a", ("main_1",)), + ("phase_b", ("main_2",)), + ("phase_c", ("addon1_1", "addon1_2")), + ), + "helper", + ) + + voltage_reference_topology_from_config( + document, meter, trusted_fingerprint=trusted.fingerprint + ) + + assert meter.board_count == 2 + + @pytest.mark.parametrize( "body", ( diff --git a/tests/test_workflow.py b/tests/test_workflow.py index 2bb064f..f7f1d40 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -132,11 +132,13 @@ async def run() -> None: await workflow.async_get_meter_configuration("other") result = await workflow.async_get_meter_configuration("meter") + assert workflow._plans[result["plan_id"]].inventory.plan_id == result["plan_id"] wrapper = await workflow.async_get_ct_inventory("meter") assert isinstance( workflow._plans[wrapper["plan_id"]].inventory, MeterConfigurationInventory ) + assert workflow._plans[wrapper["plan_id"]].inventory.plan_id == wrapper["plan_id"] assert result["source_sha256"] == digest assert result["configuration"].meter.friendly_name == "Garage Meter" assert wrapper["channels"] == result["channels"] From 21fe3601efae7c7bb2a1e66a3e95afaa424266a6 Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 06:18:44 -0400 Subject: [PATCH 34/35] docs: apply meter configuration scope amendment --- ...26-08-24-priority-0-meter-configuration.md | 535 ++---------------- ...4-priority-0-meter-configuration-design.md | 15 - tests/test_meter_inventory.py | 6 +- 3 files changed, 54 insertions(+), 502 deletions(-) diff --git a/docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md b/docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md index 7f2a39b..dc0753f 100644 --- a/docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md +++ b/docs/superpowers/plans/2026-08-24-priority-0-meter-configuration.md @@ -2,12 +2,14 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Expand CircuitSetup Energy Meter Helper from CT selection/calibration into a safe, topology-aware configurator for electrical system type, voltage references, reporting interval, circuit roles, grouped totals, energy reporting, complete range scaling for the supported power-quality values, and configurable ATM90E32 status thresholds. +**Goal:** Expand CircuitSetup Energy Meter Helper from CT selection/calibration into a safe, topology-aware configurator for electrical system type, voltage references, reporting interval, circuit roles, grouped totals, energy reporting, and complete range scaling for the supported power-quality values. -**Architecture:** Keep the current line-preserving, hash-bound configuration transaction architecture. Add a typed meter-configuration domain model, parse only a bounded official YAML surface, and render deterministic helper-managed override blocks instead of serializing arbitrary YAML. Preserve the existing CT setup and calibration paths as compatibility wrappers while introducing generalized meter-configuration read/preview/apply commands. Implement the ATM90E32 threshold behavior in ESPHome first, then capability-gate the helper controls until that component support is available in a released ESPHome version. +**Architecture:** Keep the current line-preserving, hash-bound configuration transaction architecture. Add a typed meter-configuration domain model, parse only a bounded official YAML surface, and render deterministic helper-managed override blocks instead of serializing arbitrary YAML. Preserve the existing CT setup and calibration paths as compatibility wrappers while introducing generalized meter-configuration read/preview/apply commands. **Tech Stack:** Python 3.13, Home Assistant custom integration APIs, Voluptuous, aioesphomeapi, aiohasupervisor, ESPHome Python code generation and C++, Lit 3, TypeScript, Vitest, Playwright, pytest, Ruff, mypy, GitHub Actions. +**Scope amendment (user):** Tasks 2–4 are canceled. The remaining work begins with Task 5. + **Spec:** The “Approved Requirements Baseline” section in this document is the controlling specification. Before implementation, copy it unchanged to `docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md` in `CircuitSetup/CircuitSetup-Energy-Meter-Helper`. **Baseline:** Begin from `CircuitSetup/CircuitSetup-Energy-Meter-Helper` commit `27d1dfad665c9cc5a8371ab7de428d41f3306118` or a later `main` commit that contains PR #21, “Add per-board meter package options.” For the companion meter configurations, begin from `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` commit `b94637a4f084a3a4a35e3e5f48eb1586bbd972c3` or later; that baseline already categorizes status fields as diagnostic. @@ -60,15 +62,6 @@ - Harmonic power and peak current are not part of the helper-managed power-quality feature. When the existing full package is enabled by the helper, remove those two entities in the helper-managed override block. - Do not add harmonic-power or peak-current fields to new helper models, schemas, UI, entity estimates, or tests except tests proving they are absent/removed. -7. **Configurable status thresholds** - - Absolute sag-voltage threshold per voltage reference. - - Absolute overvoltage threshold per voltage reference. - - Low- and high-frequency thresholds per voltage reference. - - Per-channel overcurrent warning threshold in final reported amperes. - - Separate “measurement range exceeded” from user-configured “over current.” - - Status entities remain diagnostic and disabled by default. - - Helper controls remain unavailable until the selected Device Builder ESPHome version includes the new ATM90E32 schema. - ### Explicit exclusions - **No board-revision option.** Do not add a `board_revision` type, field, UI control, persisted value, mutation, validation rule, diagnostic field, migration, or test-matrix dimension. @@ -91,37 +84,18 @@ Add only one new main step, **Meter Settings**, between **Setup Device** and **C Implement as dependency-ordered pull requests. Do not merge a later PR before all listed predecessors are available. -1. **ESPHome component PR** — `CircuitSetup/esphome` - - Configurable ATM90E32 thresholds and correct current-status semantics. -2. **Meter configuration contract PR** — `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` +1. **Meter configuration contract PR** — `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` - Stable status metadata, stable legacy-total IDs, and helper contract assertions. -3. **Helper backend foundation PR** — models, catalog, storage, parser, topology, capabilities. -4. **Helper mutation and transaction PR** — generalized meter configuration, range scaling, thresholds, aggregates. -5. **Helper calibration PR** — voltage-reference-aware calibration and interval-aware timing. -6. **Helper frontend PR** — Meter Settings, Circuits & CTs, review, summaries, accessibility. -7. **Integration/release PR** — full firmware contract matrix, E2E scenarios, documentation, version bump. - -The ESPHome component PR must appear in a released ESPHome tag before the helper status-threshold controls are enabled. Once the release exists, write that literal version into `ATM90E32_STATUS_THRESHOLDS_MIN_VERSION`. If no released tag exists, merge the companion component/config work but keep `status_thresholds` capability false and do not expose editable threshold controls. +2. **Helper backend foundation PR** — models, catalog, storage, parser, topology, capabilities. +3. **Helper mutation and transaction PR** — generalized meter configuration, range scaling, aggregates. +4. **Helper calibration PR** — voltage-reference-aware calibration and interval-aware timing. +5. **Helper frontend PR** — Meter Settings, Circuits & CTs, review, summaries, accessibility. +6. **Integration/release PR** — full firmware contract matrix, E2E scenarios, documentation, version bump. --- ## Planned File Structure -### `CircuitSetup/esphome` - -**Modify** -- `esphome/components/atm90e32/sensor.py` — schema, validation, and code generation. -- `esphome/components/atm90e32/atm90e32.h` — threshold fields and setters. -- `esphome/components/atm90e32/atm90e32.cpp` — register setup and current-status behavior. -- `tests/components/atm90e32/common.yaml` — compile-valid threshold examples. -- `tests/components/atm90e32/test.esp32-idf.yaml` -- `tests/components/atm90e32/test.esp8266-ard.yaml` -- `tests/components/atm90e32/test.rp2040-ard.yaml` -- ATM90E32 documentation/changelog files required by that repository’s contribution rules. - -**Create if the repository’s native-test harness supports component C++ tests** -- `tests/unit_tests/components/atm90e32/test_status_thresholds.cpp` - ### `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` **Modify** @@ -242,10 +216,6 @@ class VoltageReferenceConfig: transformer_model_id: str gain_voltage: int group_keys: tuple[str, ...] - sag_percent: float - overvoltage_percent: float - frequency_low_hz: float - frequency_high_hz: float @dataclass(frozen=True, slots=True) class MeterSettings: @@ -265,7 +235,6 @@ class ChannelSettings: reporting_multiplier: float role: CircuitRole voltage_reference_id: str - current_warning_a: float | None custom_gain_ct: int | None = None custom_label: str | None = None burden_output_acknowledged: bool = False @@ -299,7 +268,6 @@ The request’s `multi_reference_preparation_acknowledged` value is operation-sc @dataclass(frozen=True, slots=True) class MeterConfigurationCapabilities: configuration_authoritative: bool - status_thresholds: bool managed_totals: bool multi_reference: bool reason_codes: tuple[str, ...] @@ -411,289 +379,6 @@ git commit -m "docs: specify priority zero meter configuration" --- -## Task 2: Add configurable ATM90E32 threshold schema - -**Repository:** `CircuitSetup/esphome` - -**Files:** -- Modify: `esphome/components/atm90e32/sensor.py` -- Modify: `tests/components/atm90e32/common.yaml` - -**Interfaces:** -- Consumes: Existing `ATM90E32_PHASE_SCHEMA` and `CONFIG_SCHEMA`. -- Produces: - - Component options `voltage_sag_threshold`, `over_voltage_threshold`, `frequency_low_threshold`, and `frequency_high_threshold`. - - Phase option `over_current_threshold`. - - Codegen calls to matching C++ setters. - -- [ ] **Step 1: Write schema-validation tests/config cases** - -Add one valid ATM90E32 example to `tests/components/atm90e32/common.yaml`: - -```yaml -sensor: - - platform: atm90e32 - id: meter_threshold_test - cs_pin: 5 - line_frequency: 60Hz - voltage_sag_threshold: 93.6 - over_voltage_threshold: 146.4 - frequency_low_threshold: 57.0 - frequency_high_threshold: 63.0 - phase_a: - current: - name: Threshold Test Current - over_current_threshold: 100.0 -``` - -Add invalid validation fixtures using the repository’s established validation-test mechanism for: - -```yaml -voltage_sag_threshold: 150 -over_voltage_threshold: 140 -``` - -and: - -```yaml -frequency_low_threshold: 63 -frequency_high_threshold: 57 -``` - -Expected validation errors: -- `voltage_sag_threshold must be lower than over_voltage_threshold` -- `frequency_low_threshold must be lower than frequency_high_threshold` - -- [ ] **Step 2: Run the ATM90E32 config tests and confirm failure** - -Run the repository’s component test command for `tests/components/atm90e32`. - -Expected: FAIL because the new keys are not in the schema. - -- [ ] **Step 3: Add exact schema constants and validation** - -Add: - -```python -CONF_VOLTAGE_SAG_THRESHOLD = "voltage_sag_threshold" -CONF_OVER_VOLTAGE_THRESHOLD = "over_voltage_threshold" -CONF_FREQUENCY_LOW_THRESHOLD = "frequency_low_threshold" -CONF_FREQUENCY_HIGH_THRESHOLD = "frequency_high_threshold" -CONF_OVER_CURRENT_THRESHOLD = "over_current_threshold" -``` - -Use bounded finite floats: - -```python -cv.Optional(CONF_VOLTAGE_SAG_THRESHOLD): cv.float_range(min=1.0, max=600.0) -cv.Optional(CONF_OVER_VOLTAGE_THRESHOLD): cv.float_range(min=1.0, max=600.0) -cv.Optional(CONF_FREQUENCY_LOW_THRESHOLD): cv.float_range(min=40.0, max=70.0) -cv.Optional(CONF_FREQUENCY_HIGH_THRESHOLD): cv.float_range(min=40.0, max=70.0) -cv.Optional(CONF_OVER_CURRENT_THRESHOLD): cv.float_range(min=0.1, max=10_000.0) -``` - -Add a final validator that: -- Requires sag and overvoltage to be provided together or both omitted. -- Requires frequency low/high to be provided together or both omitted. -- Enforces sag `<` overvoltage. -- Enforces frequency low `<` high. -- Does not derive nominal voltage in the component schema. - -- [ ] **Step 4: Add code generation** - -Generate these calls only when configured: - -```python -cg.add(var.set_voltage_sag_threshold(config[CONF_VOLTAGE_SAG_THRESHOLD])) -cg.add(var.set_over_voltage_threshold(config[CONF_OVER_VOLTAGE_THRESHOLD])) -cg.add(var.set_frequency_low_threshold(config[CONF_FREQUENCY_LOW_THRESHOLD])) -cg.add(var.set_frequency_high_threshold(config[CONF_FREQUENCY_HIGH_THRESHOLD])) -cg.add(var.set_over_current_threshold(i, conf[CONF_OVER_CURRENT_THRESHOLD])) -``` - -- [ ] **Step 5: Run config tests** - -Expected: valid configuration passes and invalid ordering fails with the exact safe messages. - -- [ ] **Step 6: Commit** - -```bash -git add esphome/components/atm90e32/sensor.py tests/components/atm90e32 -git commit -m "feat(atm90e32): add configurable status thresholds" -``` - ---- - -## Task 3: Implement ATM90E32 threshold registers and current-status semantics - -**Repository:** `CircuitSetup/esphome` - -**Files:** -- Modify: `esphome/components/atm90e32/atm90e32.h` -- Modify: `esphome/components/atm90e32/atm90e32.cpp` -- Create when supported: `tests/unit_tests/components/atm90e32/test_status_thresholds.cpp` - -**Interfaces:** -- Consumes: setters generated by Task 2. -- Produces: - - Absolute voltage/frequency threshold register configuration. - - Per-phase overcurrent thresholds in final reported amperes. - - Separate `Measurement Range Exceeded` and `Over Current` status messages. - -- [ ] **Step 1: Add failing C++ tests** - -Cover: - -```cpp -TEST(ATM90E32StatusThresholds, CalculatesAbsoluteVoltageRegister) { - EXPECT_EQ(component.calculate_voltage_threshold_for_test(7305, 93.6f), - 29689); -} - -TEST(ATM90E32CurrentStatus, SeparatesRangeAndUserThreshold) { - component.set_raw_current_for_test(0, 65.535f); - component.set_reported_current_for_test(0, 131.07f); - component.set_over_current_threshold(0, 150.0f); - EXPECT_EQ(component.phase_status_for_test(0), "Measurement Range Exceeded"); -} -``` - -If the repository cannot directly compile component unit tests, create a small test seam guarded by `#ifdef USE_TESTS` and validate generated C++ plus representative firmware compiles. - -- [ ] **Step 2: Add fields and setters** - -Use `NAN` as the “not configured” sentinel: - -```cpp -float voltage_sag_threshold_{NAN}; -float over_voltage_threshold_{NAN}; -float frequency_low_threshold_{NAN}; -float frequency_high_threshold_{NAN}; -std::array over_current_threshold_{{NAN, NAN, NAN}}; -std::array raw_current_{{NAN, NAN, NAN}}; -``` - -Add public setters with phase bounds. - -- [ ] **Step 3: Replace multiplier-based voltage threshold calculation** - -Replace: - -```cpp -calculate_voltage_threshold(int line_freq, uint16_t ugain, float multiplier) -``` - -with: - -```cpp -uint16_t ATM90E32Component::calculate_voltage_threshold( - uint16_t voltage_gain, float rms_voltage) -``` - -The calculation must: -- Convert RMS volts to peak. -- Round the computed register value with `std::lround`; the 93.6 V/7305 test therefore expects `29689`. -- Scale using the configured voltage gain. -- Reject non-finite or non-positive values before register write. -- Saturate to the register’s valid `uint16_t` range. - -- [ ] **Step 4: Preserve legacy defaults only when fields are absent** - -In `setup()`: -- If explicit voltage thresholds exist, use them. -- Otherwise preserve the existing 78%/122% legacy threshold derivation. -- If explicit frequency thresholds exist, convert them to the ATM90E32 register’s hundredths-of-Hz format. -- Otherwise preserve 57/63 Hz for 60 Hz mode and 47/53 Hz for 50 Hz mode. - -- [ ] **Step 5: Track unfiltered current separately** - -In `get_phase_current_()` and the averaging path, store raw register-derived amperes in `raw_current_[phase]` before sensor filters are applied. - -- [ ] **Step 6: Replace `check_over_current()`** - -Implement deterministic current status: - -```cpp -const bool range_exceeded = - std::isfinite(raw_current_[phase]) && raw_current_[phase] >= 65.50f; -const bool over_current = - std::isfinite(over_current_threshold_[phase]) && - current_sensor != nullptr && - std::isfinite(current_sensor->state) && - current_sensor->state > over_current_threshold_[phase]; -``` - -Status order: -1. Existing chip voltage/phase messages. -2. `Measurement Range Exceeded`. -3. `Over Current`. - -Do not use 65.53 A as a user circuit alarm. - -- [ ] **Step 7: Compile all ATM90E32 test platforms** - -Run the ESPHome component compile tests for: -- ESP32 ESP-IDF. -- ESP8266 Arduino. -- RP2040 Arduino. - -Expected: all pass. - -- [ ] **Step 8: Commit** - -```bash -git add esphome/components/atm90e32/atm90e32.h \ - esphome/components/atm90e32/atm90e32.cpp \ - tests/components/atm90e32 \ - tests/unit_tests/components/atm90e32 2>/dev/null || true -git commit -m "feat(atm90e32): apply configurable status limits" -``` - ---- - -## Task 4: Document and release the ATM90E32 capability - -**Repository:** `CircuitSetup/esphome`, followed by the upstream ESPHome contribution path used by this project. - -**Files:** -- Modify the ATM90E32 documentation. -- Modify the required changelog file. -- Later modify helper constant location created in Task 9. - -**Interfaces:** -- Consumes: Tasks 2–3. -- Produces: A released ESPHome version containing the new schema. - -- [ ] **Step 1: Document each field** - -Document: -- Units. -- Defaults when omitted. -- Absolute voltage semantics. -- Per-phase final-reported-current semantics. -- Difference between `Measurement Range Exceeded` and `Over Current`. - -- [ ] **Step 2: Run ESPHome’s complete required checks** - -Run the exact lint, codegen validation, and component compile commands required by the repository. - -- [ ] **Step 3: Open and merge the component PR** - -Do not begin editable helper threshold controls before the change is in a released ESPHome tag. - -- [ ] **Step 4: Record the first released version** - -After release, set the helper constant created in Task 9 to the exact numeric ESPHome release tag. The committed Python source must contain the literal tag string. Do not commit a symbolic value, wildcard, pre-release guess, or environment lookup. - -- [ ] **Step 5: Commit release-floor update in the helper branch** - -```bash -git add custom_components/circuitsetup_energy_meter_helper/device_builder.py -git commit -m "chore: record atm90e32 threshold version floor" -``` - ---- - ## Task 5: Harden official status packages and legacy total IDs **Repository:** `CircuitSetup/Expandable-6-Channel-ESP32-Energy-Meter` @@ -704,7 +389,6 @@ git commit -m "chore: record atm90e32 threshold version floor" - Modify `Software/ESPHome/README.md`. **Interfaces:** -- Consumes: Released ATM90E32 threshold component from Task 4. - Produces: - Diagnostic, disabled-by-default status text entities. - Stable IDs for official generic total power/current/energy entities. @@ -743,21 +427,17 @@ substitutions: Do not add any board-revision scalar. -- [ ] **Step 4: Set the released ESPHome minimum** - -Set `esphome.min_version` to the literal release recorded in Task 4 for configurations that expose helper-managed thresholds. - -- [ ] **Step 5: Preserve the full manual power-quality packages** +- [ ] **Step 4: Preserve the full manual power-quality packages** Do not delete harmonic-power or peak-current definitions from the existing manual package files. Update documentation to state: - Manual package users still receive the full set. - CircuitSetup Energy Meter Helper intentionally removes harmonic power and peak current from its managed configuration. -- [ ] **Step 6: Update documentation** +- [ ] **Step 5: Update documentation** Document the stable IDs and helper-managed status behavior. -- [ ] **Step 7: Commit** +- [ ] **Step 6: Commit** ```bash git add Software/ESPHome @@ -884,10 +564,6 @@ Implement the exact Public Data Contracts. Use finite-number validation and cont - Friendly/reference/aggregate names: 1–64 characters. - Nominal voltage: 1–600 V. - Gain voltage: integer 1–65535. -- Sag percent: 1–99.9. -- Overvoltage percent: 100.1–200. -- Frequency thresholds: 40–70 Hz and low `<` line frequency `<` high. -- Current warning: `None` or 0.1–10,000 A. - [ ] **Step 4: Implement topology-wide validation** @@ -899,8 +575,8 @@ Use explicit defaults: ```python PROFILE_DEFAULTS = { - ElectricalSystem.SPLIT_PHASE_120_240: (60, 120.0, 57.0, 63.0), - ElectricalSystem.SINGLE_PHASE_230: (50, 230.0, 47.0, 53.0), + ElectricalSystem.SPLIT_PHASE_120_240: (60, 120.0), + ElectricalSystem.SINGLE_PHASE_230: (50, 230.0), } ``` @@ -990,76 +666,48 @@ git commit -m "feat: add voltage transformer presets" --- -## Task 9: Expose Device Builder version and configuration capabilities +## Task 9: Derive meter configuration capabilities **Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` **Files:** -- Modify: `custom_components/circuitsetup_energy_meter_helper/device_builder.py` -- Create/modify: `tests/test_device_builder.py` - Create: `custom_components/circuitsetup_energy_meter_helper/meter_inventory.py` - Create: `tests/test_meter_inventory.py` **Interfaces:** -- Produces: - - `DeviceBuilderClient.server_version: AwesomeVersion | None`. - - `ATM90E32_STATUS_THRESHOLDS_MIN_VERSION`. - - `MeterConfigurationCapabilities`. - -- [ ] **Step 1: Write failing Device Builder tests** - -Assert the handshake: - -```json -{"server_version": "2026.9.0", "requires_auth": false} -``` - -sets: - -```python -client.server_version == AwesomeVersion("2026.9.0") -``` - -and disconnect does not erase the last observed version until a new connection replaces it. +- Produces: `MeterConfigurationCapabilities`. -- [ ] **Step 2: Store the parsed server version** +- [ ] **Step 1: Write failing capability tests** -Use Home Assistant’s `AwesomeVersion` dependency already available in the environment. Reject malformed versions with a safe connection error. +Cover authoritative and non-authoritative configurations with and without contract 2. -- [ ] **Step 3: Add the release-floor constant** - -After Task 4’s release, add `ATM90E32_STATUS_THRESHOLDS_MIN_VERSION` with the exact numeric release tag as a literal `AwesomeVersion` argument. If the release does not yet exist, keep capabilities false and do not merge editable threshold UI. - -- [ ] **Step 4: Implement capability derivation** +- [ ] **Step 2: Implement capability derivation** ```python def meter_configuration_capabilities( *, configuration_authoritative: bool, config_contract: int | None, - device_builder_version: AwesomeVersion | None, ) -> MeterConfigurationCapabilities ``` Rules: - `configuration_authoritative` gates every YAML write. -- `status_thresholds` requires authoritative config, contract 2, and Device Builder version at or above the release floor. - `managed_totals` requires authoritative config and contract 2. - `multi_reference` requires authoritative config; contract 2 is preferred but helper-managed blocks may be parsed on older configs. - Return stable reason codes, not provider text. -- [ ] **Step 5: Run tests** +- [ ] **Step 3: Run tests** ```bash -uv run pytest -q tests/test_device_builder.py tests/test_meter_inventory.py +uv run pytest -q tests/test_meter_inventory.py ``` -- [ ] **Step 6: Commit** +- [ ] **Step 4: Commit** ```bash -git add custom_components/circuitsetup_energy_meter_helper/device_builder.py \ - custom_components/circuitsetup_energy_meter_helper/meter_inventory.py \ - tests/test_device_builder.py tests/test_meter_inventory.py +git add custom_components/circuitsetup_energy_meter_helper/meter_inventory.py \ + tests/test_meter_inventory.py git commit -m "feat: detect meter configuration capabilities" ``` @@ -1085,7 +733,7 @@ git commit -m "feat: detect meter configuration capabilities" Store: - Config SHA-256. - Meter settings. -- Channel roles/reference assignments/warning thresholds. +- Channel roles/reference assignments. - Aggregates. - Package options. - Transformer model IDs. @@ -1299,7 +947,7 @@ Legacy official config defaults: - No aggregate is fabricated from generic totals. Stored verified semantics with matching hash: -- Restore roles, reference mapping, thresholds, and aggregates exactly. +- Restore roles, reference mapping, and aggregates exactly. - [ ] **Step 2: Implement `MeterConfigurationInventory.from_document()`** @@ -1323,7 +971,6 @@ Keep `async_get_ct_inventory()` as a wrapper returning the CT subset. Examples: - `electrical_profile_requires_confirmation` - `legacy_generic_totals_unmanaged` -- `status_thresholds_require_newer_esphome` - `stored_semantics_stale` - [ ] **Step 5: Run tests** @@ -1500,7 +1147,7 @@ git commit -m "fix: scale supported power quality measurements" --- -## Task 16: Render electrical settings, voltage references, and thresholds +## Task 16: Render electrical settings and voltage references **Repository:** `CircuitSetup/CircuitSetup-Energy-Meter-Helper` @@ -1513,7 +1160,7 @@ git commit -m "fix: scale supported power quality measurements" - Consumes: `MeterSettings`, `VoltageReferenceConfig`, capabilities. - Produces: - `friendly_name`, `update_time`, and `electric_freq` substitutions. - - Per-group gain/reference/threshold overrides. + - Per-group gain/reference overrides. - Visible voltage/frequency entities for each configured reference. - [ ] **Step 1: Write failing mutation tests** @@ -1526,67 +1173,27 @@ Assert: - One representative voltage and frequency sensor is exposed per reference. - Non-representative calibration voltage sensors remain diagnostic/disabled. - Multi-reference request without acknowledgement fails. -- Status thresholds fail with `capability_unavailable` when capability is false. - No board-revision key is generated. -- [ ] **Step 2: Compute absolute voltage thresholds** - -```python -sag_v = nominal_voltage_v * sag_percent / 100.0 -over_v = nominal_voltage_v * overvoltage_percent / 100.0 -``` - -Validate: - -```text -0 < sag_v < nominal_voltage_v < over_v <= 600 -frequency_low_hz < line_frequency_hz < frequency_high_hz -``` - -- [ ] **Step 3: Render component-level thresholds** - -For each ATM90E32 group: - -```yaml -- id: !extend ${main_meter_id1} - voltage_sag_threshold: 93.6 - over_voltage_threshold: 146.4 - frequency_low_threshold: 57.0 - frequency_high_threshold: 63.0 -``` - -Use the reference assigned to that group. - -- [ ] **Step 4: Render per-phase current warning thresholds** - -For each used channel with a warning: - -```yaml -phase_a: - over_current_threshold: 100 -``` - -Omit the field when `None`; do not synthesize a breaker rating. - -- [ ] **Step 5: Render reference sensor exposure** +- [ ] **Step 2: Render reference sensor exposure** Choose the lowest ordered group key assigned to the reference as its representative. Expose exactly one voltage entity and one frequency entity for that reference, with deterministic names and existing stable IDs where possible. -- [ ] **Step 6: Run tests and ESPHome validation** +- [ ] **Step 3: Run tests and ESPHome validation** ```bash uv run pytest -q tests/test_meter_config_mutator.py ``` -Then validate one 60 Hz split-phase, one 50 Hz single-phase, and one three-reference configuration using the released ESPHome version. +Then validate one 60 Hz split-phase, one 50 Hz single-phase, and one three-reference configuration. -- [ ] **Step 7: Commit** +- [ ] **Step 4: Commit** ```bash git add custom_components/circuitsetup_energy_meter_helper/meter_config_mutator.py \ custom_components/circuitsetup_energy_meter_helper/config_document.py \ tests/test_meter_config_mutator.py -git commit -m "feat: configure electrical and status settings" +git commit -m "feat: configure electrical settings" ``` --- @@ -1868,7 +1475,6 @@ Never allow browser-supplied change records. Stable codes: - `meter_configuration_invalid` -- `status_thresholds_unavailable` - `legacy_totals_unmanaged` - `voltage_reference_mismatch` - `aggregate_entity_mismatch` @@ -2175,8 +1781,6 @@ Required controls: - Nominal voltage. - Phase label. - Group assignment. -- Sag/overvoltage percentages. -- Frequency low/high. - Generic multi-reference preparation acknowledgement. Assert there is no board-revision control. @@ -2189,21 +1793,14 @@ When profile changes, populate suggested values only for untouched fields. Never Every ATM group appears once across reference cards. Moving a group removes it from its prior reference atomically. -- [ ] **Step 4: Capability-gate thresholds** - -When `status_thresholds=false`: -- Show current values read-only if present. -- Display the stable reason. -- Do not include modified threshold values in the preview request. - -- [ ] **Step 5: Add impact copy for interval** +- [ ] **Step 4: Add impact copy for interval** Display: - “1–5 seconds: high traffic.” - “10 seconds: standard.” - “30–60 seconds: lower traffic; guided calibration takes longer.” -- [ ] **Step 6: Add step navigation** +- [ ] **Step 5: Add step navigation** Flow: @@ -2211,14 +1808,14 @@ Flow: Setup Device → Meter Settings → Circuits & CTs → Safety → … ``` -- [ ] **Step 7: Run frontend tests** +- [ ] **Step 6: Run frontend tests** ```bash npm --prefix frontend test -- meter-settings.test.ts panel.test.ts accessibility.test.ts npm --prefix frontend run typecheck ``` -- [ ] **Step 8: Commit** +- [ ] **Step 7: Commit** ```bash git add frontend/src frontend/test @@ -2261,7 +1858,6 @@ Move: - Reporting multiplier. - Custom gain. - Burden acknowledgement. -- Current warning threshold. - Two-pole details. into the existing expandable row details. @@ -2298,21 +1894,14 @@ Do not auto-create an all-channel total. Warn when: - A one-leg-doubled circuit uses two channels. - A channel is assigned to incompatible two-pole aggregates. -- [ ] **Step 6: Add status threshold field** - -Per used channel: -- `current_warning_a`. -- Label it “Circuit warning current,” not register limit. -- Explain “Measurement Range Exceeded” is separate. - -- [ ] **Step 7: Run tests** +- [ ] **Step 6: Run tests** ```bash npm --prefix frontend test -- circuit-aggregates.test.ts panel.test.ts accessibility.test.ts npm --prefix frontend run typecheck ``` -- [ ] **Step 8: Commit** +- [ ] **Step 7: Commit** ```bash git add frontend/src frontend/test @@ -2373,7 +1962,7 @@ Show: - Approximate public entity count. - Energy entities. - Approximate publications per second. -- A warning threshold based on a documented constant, not arbitrary color-only UI. +- A documented warning trigger, not arbitrary color-only UI. - [ ] **Step 5: Run tests** @@ -2425,7 +2014,6 @@ Show: - Aggregate formulas in readable text. - Energy modes. - PQ/status boards. -- Threshold values. - Reporting interval. - Entity impact. @@ -2452,7 +2040,6 @@ Report: - Used channel count. - Aggregate/energy count. - PQ/status scope. -- Threshold capability/status. - [ ] **Step 6: Run tests** @@ -2520,14 +2107,7 @@ git commit -m "feat(frontend): review complete meter configuration" - One-CT doubled appliance. - No double counting in grid aggregate. -- [ ] **Step 5: Add E2E scenario — unsupported threshold capability** - -- Device Builder version below the release floor. -- Threshold controls read-only/disabled. -- Other configuration remains editable. -- Preview excludes threshold changes. - -- [ ] **Step 6: Add recovery regressions** +- [ ] **Step 5: Add recovery regressions** - Source hash changes before preview. - Validation failure after write rolls back. @@ -2535,7 +2115,7 @@ git commit -m "feat(frontend): review complete meter configuration" - Cancel during slow-interval calibration releases locks. - Legacy generic totals block aggregate creation with a clear upgrade message. -- [ ] **Step 7: Run full frontend checks** +- [ ] **Step 6: Run full frontend checks** ```bash npm --prefix frontend audit @@ -2545,7 +2125,7 @@ npm --prefix frontend run build npm --prefix frontend run test:e2e ``` -- [ ] **Step 8: Commit** +- [ ] **Step 7: Commit** ```bash git add frontend/test tests .github/workflows @@ -2611,8 +2191,6 @@ Validate/compile at least: 4. 1 add-on, Waveshare Ethernet, three references. 5. 3 add-ons, multi-reference. 6. 6 add-ons, sparse used channels and aggregates. -7. Threshold controls on the minimum supported ESPHome release. -8. Legacy ESPHome below the floor with threshold fields omitted. - [ ] **Step 6: Search prohibited output** @@ -2684,13 +2262,9 @@ Clearly separate: - Power factor and phase angle are never multiplied. - Harmonic power and peak current are not exposed by helper-managed PQ. -- [ ] **Step 4: Document statuses** +- [ ] **Step 4: Document status packages** -Explain: -- Sag/overvoltage/frequency limits. -- Circuit warning current. -- Measurement Range Exceeded versus Over Current. -- Status entities are diagnostic and disabled by default. +Explain that status entities are diagnostic and disabled by default when their package is enabled. - [ ] **Step 5: Document totals/double counting** @@ -2730,15 +2304,13 @@ The work is complete only when all of the following are demonstrated: 6. Register-range multipliers scale current, active power, reactive power, and apparent power consistently. 7. Power factor and phase angle remain unscaled. 8. Helper-managed PQ exposes no harmonic-power or peak-current entities. -9. Status thresholds are editable only on a supported released ESPHome version. -10. Measurement-range saturation and user overcurrent thresholds produce distinct status messages. -11. User-defined aggregates do not rely on an automatic sum of all CTs. -12. Bidirectional grid import/export and generation energy configurations compile and reconnect successfully. -13. Two-CT and one-CT doubled two-pole methods produce the intended formulas without corrupting CT gain. -14. All mutations remain hash-bound, reviewed, validated, compiled, confirmed, installed, reconnect-verified, and rollback-capable. -15. Existing CT-only callers/tests remain supported through wrappers. -16. Runtime-only devices without Device Builder remain read-only except for existing Home Assistant label behavior. -17. Full Python, frontend, Home Assistant, firmware-contract, and E2E test matrices pass. +9. User-defined aggregates do not rely on an automatic sum of all CTs. +10. Bidirectional grid import/export and generation energy configurations compile and reconnect successfully. +11. Two-CT and one-CT doubled two-pole methods produce the intended formulas without corrupting CT gain. +12. All mutations remain hash-bound, reviewed, validated, compiled, confirmed, installed, reconnect-verified, and rollback-capable. +13. Existing CT-only callers/tests remain supported through wrappers. +14. Runtime-only devices without Device Builder remain read-only except for existing Home Assistant label behavior. +15. Full Python, frontend, Home Assistant, firmware-contract, and E2E test matrices pass. # Codex Execution Notes @@ -2748,4 +2320,3 @@ The work is complete only when all of the following are demonstrated: - Commit after every independently reviewable task. - Do not opportunistically refactor unrelated calibration, provisioning, or frontend code. - When current `main` differs from the paths/signatures in this plan, preserve the plan’s interfaces and adapt only the file placement necessary to match the repository’s established structure. -- Stop and open a focused design amendment if the ATM90E32 released schema uses materially different field semantics; do not emulate missing component support with extra template sensors. diff --git a/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md b/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md index 4da38aa..4041070 100644 --- a/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md +++ b/docs/superpowers/specs/2026-08-24-priority-0-meter-configuration-design.md @@ -48,15 +48,6 @@ - Harmonic power and peak current are not part of the helper-managed power-quality feature. When the existing full package is enabled by the helper, remove those two entities in the helper-managed override block. - Do not add harmonic-power or peak-current fields to new helper models, schemas, UI, entity estimates, or tests except tests proving they are absent/removed. -7. **Configurable status thresholds** - - Absolute sag-voltage threshold per voltage reference. - - Absolute overvoltage threshold per voltage reference. - - Low- and high-frequency thresholds per voltage reference. - - Per-channel overcurrent warning threshold in final reported amperes. - - Separate “measurement range exceeded” from user-configured “over current.” - - Status entities remain diagnostic and disabled by default. - - Helper controls remain unavailable until the selected Device Builder ESPHome version includes the new ATM90E32 schema. - ### Explicit exclusions - **No board-revision option.** Do not add a `board_revision` type, field, UI control, persisted value, mutation, validation rule, diagnostic field, migration, or test-matrix dimension. @@ -130,10 +121,6 @@ class VoltageReferenceConfig: transformer_model_id: str gain_voltage: int group_keys: tuple[str, ...] - sag_percent: float - overvoltage_percent: float - frequency_low_hz: float - frequency_high_hz: float @dataclass(frozen=True, slots=True) class MeterSettings: @@ -153,7 +140,6 @@ class ChannelSettings: reporting_multiplier: float role: CircuitRole voltage_reference_id: str - current_warning_a: float | None custom_gain_ct: int | None = None custom_label: str | None = None burden_output_acknowledged: bool = False @@ -187,7 +173,6 @@ The request’s `multi_reference_preparation_acknowledged` value is operation-sc @dataclass(frozen=True, slots=True) class MeterConfigurationCapabilities: configuration_authoritative: bool - status_thresholds: bool managed_totals: bool multi_reference: bool reason_codes: tuple[str, ...] diff --git a/tests/test_meter_inventory.py b/tests/test_meter_inventory.py index 5ed072f..e955e71 100644 --- a/tests/test_meter_inventory.py +++ b/tests/test_meter_inventory.py @@ -44,7 +44,6 @@ def test_capability_model_has_exact_frozen_slots_contract() -> None: assert not hasattr( MeterConfigurationCapabilities(True, True, True, ()), "__dict__" ) - assert not hasattr(MeterConfigurationCapabilities, "status_thresholds") @pytest.mark.parametrize( @@ -629,15 +628,12 @@ def test_inventory_rejects_malformed_active_ct_configuration() -> None: _inventory(content) -def test_inventory_exposes_capability_reason_codes_without_threshold_capabilities() -> ( - None -): +def test_inventory_exposes_capability_reason_codes() -> None: """Dropping capability reasons would let the UI offer unavailable writes.""" inventory = _inventory(_document(contract=True), authoritative=False) assert inventory.capabilities.reason_codes == ("configuration_not_authoritative",) assert "configuration_not_authoritative" in inventory.warnings - assert not hasattr(inventory, "status_thresholds") def test_generic_total_warning_ignores_comments_but_detects_active_ids() -> None: From 028dc15b0f9b7b68747fe1f117a251486e3b8e5f Mon Sep 17 00:00:00 2001 From: jdeglavina Date: Tue, 25 Aug 2026 06:29:19 -0400 Subject: [PATCH 35/35] fix: infer scalar voltage reference coverage --- .../topology.py | 4 +- tests/test_topology.py | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/custom_components/circuitsetup_energy_meter_helper/topology.py b/custom_components/circuitsetup_energy_meter_helper/topology.py index c593539..5c5f66e 100644 --- a/custom_components/circuitsetup_energy_meter_helper/topology.py +++ b/custom_components/circuitsetup_energy_meter_helper/topology.py @@ -224,10 +224,8 @@ def _managed_voltage_reference_assignments( if any(inferred) and not all(inferred): raise TopologyParseError("mixed voltage-reference mapping forms are invalid") if all(inferred): - if len(entries) == 1: - return ((entries[0][0], expected),) return tuple( - (reference_id, expected[index::2]) + (reference_id, expected[index::len(entries)]) for index, (reference_id, _) in enumerate(entries) ) return tuple((reference_id, groups or ()) for reference_id, groups in entries) diff --git a/tests/test_topology.py b/tests/test_topology.py index 6a0d109..2babcac 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -493,6 +493,55 @@ def test_trusted_managed_voltage_block_accepts_bounded_reference_counts( assert meter.board_count == addon_count + 1 +@pytest.mark.parametrize( + ("project_suffix", "references"), + ( + ( + "-1-addon", + ( + ("ref0", ("main_1", "addon1_2")), + ("ref1", ("main_2",)), + ("ref2", ("addon1_1",)), + ), + ), + ( + "-3-addons", + ( + ("ref0", ("main_1",)), + ("ref1", ("main_2",)), + ("ref2", ("addon1_1",)), + ("ref3", ("addon1_2",)), + ("ref4", ("addon2_1",)), + ("ref5", ("addon2_2",)), + ("ref6", ("addon3_1",)), + ("ref7", ("addon3_2",)), + ), + ), + ), + ids=("three", "eight"), +) +def test_trusted_scalar_voltage_block_uses_canonical_round_robin_coverage( + project_suffix: str, + references: tuple[tuple[str, tuple[str, ...]], ...], +) -> None: + document = ESPHomeConfigDocument.parse( + "esphome:\n project:\n name: circuitsetup.6c-energy-meter" + f"{project_suffix}\n" + "# CircuitSetup Energy Meter Helper: voltage references v1\n" + "voltage_references:\n" + + "".join(f" {reference_id}: 120\n" for reference_id, _ in references) + + "# End CircuitSetup Energy Meter Helper: voltage references v1\n" + ) + meter = topology_from_config(document) + trusted = VoltageReferenceTopology(references, "helper") + + voltage = voltage_reference_topology_from_config( + document, meter, trusted_fingerprint=trusted.fingerprint + ) + + assert voltage.references == references + + @pytest.mark.parametrize("reference_count", (0, 9), ids=("zero", "nine")) def test_trusted_managed_voltage_block_rejects_counts_outside_one_to_eight( reference_count: int,