diff --git a/README.md b/README.md index 9daa93c..4c4d63f 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Do not use only the supply or delivery sensor; the total-rate sensor combines bo ## Main entities New Hampshire Rate R keeps short entity IDs. Connecticut Rate 1 uses territory-prefixed IDs such as `sensor.eversource_ct_1_total_electricity_rate`. Massachusetts R1 includes the supply plan (and for EMA, service area), such as `sensor.eversource_wma_r1_fixed_total_electricity_rate` or `sensor.eversource_ema_r1_fixed_cape_total_electricity_rate`. + | Entity (NH Rate R) | Purpose | | --- | --- | | `sensor.eversource_total_electricity_rate` | Supply + variable delivery in USD/kWh; use this in the Energy dashboard | @@ -72,12 +73,79 @@ New Hampshire Rate R keeps short entity IDs. Connecticut Rate 1 uses territory-p Individual delivery components are available as disabled-by-default diagnostic entities. +## Data updates and schedules + +- **Tariff update interval**: Checks public Eversource tariff pages **every 24 hours** by default. +- **Configurable interval**: Open **Settings → Devices & services → Eversource Rates → Configure** to choose: + - 6 hours + - 12 hours + - 24 hours (default) + - 48 hours + - 72 hours + - 168 hours (7 days) +- **Immediate refresh on save**: Changing and saving the update interval automatically reloads the integration and triggers an immediate tariff refresh. +- **Repository schedules**: For clarity, this repository has three distinct schedules: + 1. *Tariff refresh*: 24-hour default in Home Assistant runtime (with 6h–168h user options). + 2. *GitHub Actions validation*: Runs daily in CI against local sanitized test fixtures (not a live scrape). + 3. *Dependabot*: Checks for dependency updates weekly on Mondays. + +## Troubleshooting + +### Enabling debug logging + +To view detailed fetch and parse diagnostics, enable debug logging either via the UI: + +1. Go to **Settings → Devices & services**. +2. Find the **Eversource Rates** card. +3. Click the menu (**⋮**) and select **Enable debug logging**. + +Or add to your `configuration.yaml`: + +```yaml +logger: + logs: + custom_components.eversource_rates: debug +``` + +### Forcing an immediate refresh + +Enabling debug logging does not by itself trigger a tariff refresh. To see log output immediately: + +1. Enable debug logging (via the steps above). +2. Click the menu (**⋮**) on the Eversource Rates card and select **Reload**. +3. Setup will run an immediate coordinator refresh. + +When retrieval succeeds, you will see debug entries similar to: + +```text +DEBUG (MainThread) [custom_components.eversource_rates.api] Refreshing tariff for nh/r +DEBUG (MainThread) [custom_components.eversource_rates.api] Retrieved tariff page from ...: status=200, bytes=... +DEBUG (MainThread) [custom_components.eversource_rates.api] Parsed Eversource nh r tariff with 6 delivery components: supply=0.10444, effective=2026-08-01 +``` + +If you do not see either a `Parsed Eversource ...` message or an `UpdateFailed` warning after reloading, check that debug logging is enabled for the `custom_components.eversource_rates` namespace. + +## Known limitations + +- **Electricity only**: Natural gas tariffs are not supported. +- **Default service only**: Assumes Eversource standard default Basic Service supply. Third-party competitive energy suppliers, electric vehicle / time-of-day tariffs (including CT Rate 7), heat-pump discounts, and financial hardship/assistance rates are not supported. +- **Timezone**: All Eversource territories operate in Eastern Time (`America/New_York`). Tariff effective dates are evaluated using Eastern Time, regardless of the host system or container timezone. +- **Price-only integration**: Does not monitor real-time consumption; a separate cumulative grid energy sensor (kWh) is required for the Home Assistant Energy dashboard. +- **Historical data**: Home Assistant applies current rates to energy recorded going forward; past energy costs in the Energy dashboard are not retroactively recalculated. + +## Uninstalling + +To remove Eversource Rates: + +1. Open **Settings → Dashboards → Energy** and remove `sensor.eversource_total_electricity_rate` from your electricity grid configuration. +2. Go to **Settings → Devices & services**. +3. On the **Eversource Rates** card, click the menu (**⋮**) and select **Delete**. +4. If installed via HACS, you can then remove it from the HACS integration list. + ## Notes Rates are parsed from public Eversource pages using exact decimal arithmetic. If a public page becomes unavailable or changes in an unsafe way, the integration fails closed rather than publishing a fabricated price. Developers can use `tools/fetch_eversource_rates.py` for a manual live fetch/parse check against the public New Hampshire tariff pages. -Home Assistant applies the current price to energy recorded after rate updates; this integration does not retroactively recalculate historical Energy dashboard costs. - This project is unofficial and is not affiliated with, endorsed by, or sponsored by Eversource. For development details, see [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/hacs-integration-design.md](docs/hacs-integration-design.md). diff --git a/custom_components/eversource_rates/api.py b/custom_components/eversource_rates/api.py index 49e7707..ac4d330 100644 --- a/custom_components/eversource_rates/api.py +++ b/custom_components/eversource_rates/api.py @@ -94,7 +94,14 @@ async def _async_fetch(self, url: str) -> str: raise EversourceConnectionError( f"HTTP {response.status} retrieving tariff page" ) - return await response.text() + text = await response.text() + _LOGGER.debug( + "Retrieved tariff page from %s: status=%d, bytes=%d", + url, + response.status, + len(text.encode("utf-8")), + ) + return text except TimeoutError as err: raise EversourceConnectionError("Timed out retrieving tariff page") from err except aiohttp.ClientError as err: @@ -106,15 +113,22 @@ async def async_get_rates(self) -> EversourceRates: raise EversourceUnsupportedTariffError("Unsupported Eversource tariff") source = self._source selection = self._selection + _LOGGER.debug( + "Refreshing tariff for %s/%s", + selection.territory, + selection.rate_class, + ) supply_html, delivery_html = await asyncio.gather( self._async_fetch(source.supply_url), self._async_fetch(source.delivery_url), ) + as_of = datetime.now(source.time_zone).date() try: supply, delivery = parse_tariff( selection, supply_html, delivery_html, + today=as_of, ) except EversourceParseError as err: raise EversourceTariffParseError(str(err)) from err @@ -134,9 +148,12 @@ async def async_get_rates(self) -> EversourceRates: "Total variable rate outside plausible range" ) _LOGGER.debug( - "Parsed Eversource %s %s tariff with %d delivery components", + "Parsed Eversource %s %s tariff with %d delivery components: " + "supply=%s, effective=%s", selection.territory, selection.rate_class, len(delivery.variable_components), + supply.rate, + supply.effective_date.isoformat() if supply.effective_date else "n/a", ) return rates diff --git a/custom_components/eversource_rates/const.py b/custom_components/eversource_rates/const.py index 890df28..9cc503a 100644 --- a/custom_components/eversource_rates/const.py +++ b/custom_components/eversource_rates/const.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any +from zoneinfo import ZoneInfo DOMAIN = "eversource_rates" CONF_TERRITORY = "territory" @@ -17,6 +18,8 @@ UPDATE_INTERVAL_HOUR_CHOICES: tuple[int, ...] = (6, 12, 24, 48, 72, 168) REQUEST_TIMEOUT_SECONDS = 30 +EVERSOURCE_TIME_ZONE = ZoneInfo("America/New_York") + SUPPLY_URL = "https://www.eversource.com/residential/account-billing/manage-bill/about-your-bill/rates-tariffs/electric-supply-rates" DELIVERY_URL = "https://www.eversource.com/residential/account-billing/manage-bill/about-your-bill/rates-tariffs/electric-delivery-rates" @@ -29,6 +32,7 @@ class Territory: name: str segment: str supported_rate_classes: tuple[str, ...] + time_zone: ZoneInfo = EVERSOURCE_TIME_ZONE TERRITORIES = { diff --git a/custom_components/eversource_rates/parsers/__init__.py b/custom_components/eversource_rates/parsers/__init__.py index 7a4a7d0..05ce358 100644 --- a/custom_components/eversource_rates/parsers/__init__.py +++ b/custom_components/eversource_rates/parsers/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +from datetime import date from ..models import DeliveryRates, SupplyRate from ..tariffs import TariffSelection @@ -18,6 +19,8 @@ def get_tariff_parser( selection: TariffSelection, + *, + today: date | None = None, ) -> tuple[ Callable[[str], SupplyRate], Callable[[str], DeliveryRates], @@ -33,13 +36,13 @@ def parse_nh_supply(html: str) -> SupplyRate: case ("ct", "1"): def parse_ct_supply(html: str) -> SupplyRate: - return ct.parse_supply_html(html, selection.rate_class) + return ct.parse_supply_html(html, selection.rate_class, today=today) return parse_ct_supply, ct.parse_delivery_html case ("wma", "r1") | ("ema", "r1"): def parse_ma_supply(html: str) -> SupplyRate: - return ma.parse_supply_html(html, selection) + return ma.parse_supply_html(html, selection, today=today) def parse_ma_delivery(html: str) -> DeliveryRates: return ma.parse_delivery_html(html, selection) @@ -55,7 +58,9 @@ def parse_tariff( selection: TariffSelection, supply_html: str, delivery_html: str, + *, + today: date | None = None, ) -> tuple[SupplyRate, DeliveryRates]: """Parse supply and delivery HTML for one logical tariff identity.""" - parse_supply, parse_delivery = get_tariff_parser(selection) + parse_supply, parse_delivery = get_tariff_parser(selection, today=today) return parse_supply(supply_html), parse_delivery(delivery_html) diff --git a/custom_components/eversource_rates/parsers/ct.py b/custom_components/eversource_rates/parsers/ct.py index c2a7f38..f62e858 100644 --- a/custom_components/eversource_rates/parsers/ct.py +++ b/custom_components/eversource_rates/parsers/ct.py @@ -3,11 +3,12 @@ from __future__ import annotations import re -from datetime import date +from datetime import date, datetime from decimal import Decimal from bs4 import BeautifulSoup, Tag +from ..const import EVERSOURCE_TIME_ZONE from ..models import DeliveryComponent, DeliveryRates, SupplyRate from .common import ( EversourceParseError, @@ -145,7 +146,7 @@ def parse_supply_html( raise EversourceParseError( f"Unsupported supply rate class for CT Rate 1 parser: {rate_class!r}" ) - as_of = today or date.today() + as_of = today or datetime.now(EVERSOURCE_TIME_ZONE).date() table = _find_supply_table(BeautifulSoup(html, "html.parser")) header_cells = table.find("tr") diff --git a/custom_components/eversource_rates/parsers/ma.py b/custom_components/eversource_rates/parsers/ma.py index 42a5c6b..172909a 100644 --- a/custom_components/eversource_rates/parsers/ma.py +++ b/custom_components/eversource_rates/parsers/ma.py @@ -4,11 +4,12 @@ import calendar import re -from datetime import date +from datetime import date, datetime from decimal import Decimal from bs4 import BeautifulSoup, Tag +from ..const import EVERSOURCE_TIME_ZONE from ..models import DeliveryComponent, DeliveryRates, SupplyRate from ..tariffs import TariffSelection from .common import ( @@ -164,7 +165,7 @@ def parse_supply_html( raise EversourceParseError( f"Unsupported MA supply plan: {selection.supply_plan!r}" ) - as_of = today or date.today() + as_of = today or datetime.now(EVERSOURCE_TIME_ZONE).date() if selection.supply_plan == "fixed": return _parse_fixed_supply(html, selection.rate_class, today=as_of) return _parse_monthly_variable_supply(html, selection.rate_class, today=as_of) diff --git a/custom_components/eversource_rates/sources.py b/custom_components/eversource_rates/sources.py index 86649e5..4800535 100644 --- a/custom_components/eversource_rates/sources.py +++ b/custom_components/eversource_rates/sources.py @@ -3,8 +3,9 @@ from __future__ import annotations from dataclasses import dataclass +from zoneinfo import ZoneInfo -from .const import DELIVERY_URL, SUPPLY_URL +from .const import DELIVERY_URL, EVERSOURCE_TIME_ZONE, SUPPLY_URL @dataclass(frozen=True, slots=True) @@ -14,6 +15,7 @@ class TariffSource: supply_url: str delivery_url: str segment: str | None = None # Cookie only when needed + time_zone: ZoneInfo = EVERSOURCE_TIME_ZONE # NH keeps the proven generic URLs + segment cookie. CT uses territory-suffixed diff --git a/docs/hacs-integration-design.md b/docs/hacs-integration-design.md index 79c8c51..a91534e 100644 --- a/docs/hacs-integration-design.md +++ b/docs/hacs-integration-design.md @@ -1,10 +1,8 @@ # Eversource Rates — integration architecture -This document describes the **current** Home Assistant / HACS integration architecture for public Eversource **electricity** tariffs. +This document describes the Home Assistant / HACS integration architecture for public Eversource **electricity** tariffs as of v0.3.0+. -Production code under `custom_components/eversource_rates/` is authoritative. This document is non-normative: it explains how the integration works today and notes future direction. It is **not** a promise of Connecticut, Massachusetts, gas, time-of-day, or third-party supplier support. - -For CT / Eastern MA / Western MA research notes, see [investigation/multi_territory_architecture.md](../investigation/multi_territory_architecture.md). +Production code under `custom_components/eversource_rates/` is authoritative. This document explains how the integration works and notes design decisions. It is **not** a promise of natural gas, time-of-day, or third-party supplier support. --- @@ -14,13 +12,20 @@ For CT / Eastern MA / Western MA research notes, see [investigation/multi_territ | --- | --- | | Domain / path | `eversource_rates` / `custom_components/eversource_rates` | | Commodity | Electricity only (natural gas is out of scope) | -| Territory | New Hampshire | -| Rate class | Residential Rate R | +| Supported territories | New Hampshire, Connecticut, Western Massachusetts, Eastern Massachusetts | +| Supported rate classes | NH: Residential Rate R
CT: Rate 1 (Residential)
WMA: R1 - Residential Non-Heating (Fixed & Monthly Variable Basic Service)
EMA: R1 - Residential Non-Heating (Fixed & Monthly Variable Basic Service; Main & Cape service areas) | | Supply model | Eversource default / basic service from public tariff pages | -| Polling | Every **12 hours** via `DataUpdateCoordinator` (not user-configurable) | -| Auth | None — public HTTP only; Sitefinity audience cookie `.SEGMENT=nh` | +| Timezone | All service territories evaluate tariff calendar dates in Eastern Time (`America/New_York`) | +| Tariff refresh schedule | Every **24 hours** by default; configurable via Options (6 / 12 / 24 / 48 / 72 / 168 hours) | +| Authentication | None — unauthenticated public HTTPS endpoints; Sitefinity audience cookie `.SEGMENT=nh` for NH | + +### Repository schedules -Setup selectors only offer production-supported combinations. Investigated CT/MA identifiers must not appear in the UI until runtime parsers exist. +To avoid confusion between polling and repo maintenance, three distinct schedules exist: + +1. **Home Assistant Tariff Refresh**: Defaults to every **24 hours** via `DataUpdateCoordinator`. Configurable in integration Options to 6, 12, 24, 48, 72, or 168 hours (7 days). Saving new options immediately reloads the entry and performs a refresh. +2. **GitHub Validation Workflow**: Runs **daily** at `00:00 UTC` via GitHub Actions (`validate.yml`). Executes HACS validation, `hassfest`, Ruff linting/formatting, and `pytest` against local test fixtures. It does **not** scrape live Eversource pages. +3. **Dependabot Checks**: Runs **weekly** on Mondays for GitHub Actions, Python packages, and pre-commit hooks. --- @@ -28,18 +33,26 @@ Setup selectors only offer production-supported combinations. Investigated CT/MA ```text custom_components/eversource_rates/ -├── __init__.py # Config-entry setup; builds client + coordinator +├── __init__.py # Config-entry setup; coordinator lifecycle, sensor platform forward ├── manifest.json # Domain, HACS metadata, requirements (version via Release Please) -├── const.py # DOMAIN, URLs, UPDATE_INTERVAL, TERRITORIES -├── config_flow.py # Two-step flow: territory → electric rate class -├── coordinator.py # DataUpdateCoordinator wrapper -├── api.py # EversourceClient (async public fetch) -├── parser.py # Semantic HTML → Decimal models (fail-closed) -├── models.py # Immutable SupplyRate / DeliveryRates / EversourceRates -├── sensor.py # Primary + diagnostic delivery-component sensors -├── entity_ids.py # Stable object-ID strategy (legacy NH Rate R short IDs) -├── strings.json -└── translations/en.json +├── const.py # DOMAIN, URLs, intervals, EVERSOURCE_TIME_ZONE, TERRITORIES +├── tariffs.py # Tariff definitions, selections, and entry-data resolution +├── sources.py # Public endpoint definitions (TariffSource) per territory/rate +├── config_flow.py # Multi-step config flow + OptionsFlowWithReload +├── coordinator.py # DataUpdateCoordinator wrapper (EversourceRatesCoordinator) +├── api.py # EversourceClient (async concurrent fetch & validation) +├── models.py # Immutable slotted dataclasses (SupplyRate, DeliveryRates, EversourceRates) +├── parsers/ # Territory-specific tariff parsers (fail-closed) +│ ├── __init__.py # Parser dispatch (parse_tariff, get_tariff_parser) +│ ├── common.py # Shared parsing helpers (decimal, component_key, is_summary_row) +│ ├── nh.py # New Hampshire Rate R parser +│ ├── ct.py # Connecticut Rate 1 parser +│ └── ma.py # Massachusetts R1 parser (WMA & EMA) +├── sensor.py # Primary rate sensors + diagnostic delivery-component sensors +├── entity_ids.py # Stable object-ID generation (preserves legacy NH Rate R IDs) +├── strings.json # Integration string definitions for hassfest +└── translations/ + └── en.json # Authoritative English UI translations ``` Developer utility (not part of the HA runtime path): @@ -54,84 +67,78 @@ tools/fetch_eversource_rates.py ```text Public supply URL ──┐ - ├── EversourceClient (Cookie: .SEGMENT=) + ├── EversourceClient (aiohttp ClientSession) Public delivery URL ─┘ │ ▼ - parser.py + parsers.parse_tariff() │ ▼ - EversourceRates + EversourceRates │ ▼ - EversourceRatesCoordinator + EversourceRatesCoordinator │ ▼ - Primary + diagnostic sensors + Primary + diagnostic sensors ``` -1. **Fetch** — unauthenticated `GET` of the public supply and delivery tariff pages with the territory’s Sitefinity `.SEGMENT` cookie (NH uses `nh`). -2. **Parse** — BeautifulSoup + regex extract supply and delivery rows into `Decimal` values. Missing required NH riders, malformed units, conflicting duplicates, or conflicting delivery **total/subtotal** rows fail closed. -3. **Coordinate** — Home Assistant’s coordinator retains prior data when a refresh raises `UpdateFailed` (standard HA behavior). Successful refreshes always publish a new snapshot, including an updated `retrieved_at`. -4. **Expose** — sensors publish current prices for the Energy dashboard and diagnostics. +1. **Fetch** — Asynchronous concurrent `GET` of the public supply and delivery tariff pages. NH uses generic URLs with `.SEGMENT=nh` cookie; CT, WMA, and EMA use territory-suffixed URLs (`/ct`, `/wma`, `/ema`). +2. **Timezone Evaluation** — Effective date intervals are evaluated in Eastern Time (`America/New_York`), ensuring consistent period selection regardless of the Home Assistant host system timezone (such as UTC). +3. **Parse** — Territory parsers extract supply and delivery tables into exact `Decimal` values. Missing required riders, malformed units, conflicting duplicates, or mismatching delivery totals fail closed. +4. **Coordinate** — `DataUpdateCoordinator` handles scheduling and retains prior rate data when a refresh encounters an error (`UpdateFailed`). Successful refreshes publish an updated `retrieved_at` timestamp. +5. **Expose** — Sensors publish current rates for the Energy dashboard and diagnostics. --- -## Config flow +## Config and options flow + +The configuration flow dynamically asks for required attributes based on territory: -Two steps (labels in `strings.json` / `translations/en.json`): +1. **Service territory**: NH, CT, WMA, or EMA. +2. **Electric rate class**: Appropriate rate class for the territory (e.g. Rate R, Rate 1, or R1). +3. **Supply plan** *(MA only)*: Fixed Basic Service or Monthly Variable Basic Service. +4. **Service area** *(EMA only)*: Greater Boston / Cambridge / South Shore or Cape Cod / Martha's Vineyard (reflecting differing Energy Efficiency riders). -1. **Service territory** — options from `TERRITORIES`. -2. **Electric rate class** — options from `Territory.supported_rate_classes` for the chosen territory. +Before creating the config entry, the flow tests live connectivity and parsing against Eversource to fail fast if pages are unreachable or changed. -Config entry data keys remain `territory` and `rate_class`. There is no polling-interval option. +### Options flow + +Uses Home Assistant's `OptionsFlowWithReload` pattern. Users can select an update interval: 6, 12, 24, 48, 72, or 168 hours (7 days). Submitting options automatically reloads the entry, immediately executing a fresh coordinator refresh with the new interval. --- ## Entity model -Rate sensors use `SensorStateClass.MEASUREMENT` and **do not** use `SensorDeviceClass.MONETARY` (inappropriate for USD/kWh). - -### Primary entities (NH Rate R object IDs) +Rate sensors use `SensorStateClass.MEASUREMENT` and omit `SensorDeviceClass.MONETARY` (which is inappropriate for USD/kWh). -| Entity ID | Meaning | -| --- | --- | -| `sensor.eversource_supply_rate` | Default-service supply USD/kWh | -| `sensor.eversource_delivery_rate` | Sum of variable delivery components USD/kWh | -| `sensor.eversource_total_electricity_rate` | Supply + variable delivery (Energy dashboard current price) | -| `sensor.eversource_customer_charge` | Fixed monthly customer charge (not a per-kWh price) | +### Primary entities -Non-NH combinations would use `eversource___` via `entity_ids.sensor_object_id()` once supported. +- **Total Electricity Rate** (`sensor.eversource_total_electricity_rate` for NH; territory-prefixed for others): Supply + variable delivery in USD/kWh. This is the entity to configure in the Energy dashboard. +- **Supply Rate**: Current Eversource default-service supply price. +- **Delivery Rate**: Sum of all variable Eversource delivery charges. +- **Customer Charge**: Fixed monthly customer charge (USD/month); intentionally excluded from the per-kWh Energy price. ### Diagnostic delivery components -Disabled-by-default diagnostic sensors expose each parsed `/kWh` delivery rider. Entities are created for components present at setup. If a dynamic rider later disappears from the tariff, the entity stays registered and becomes **unavailable**; if it reappears, it becomes available again. Entity IDs are not deleted or recreated dynamically. +Disabled-by-default diagnostic sensors expose each parsed USD/kWh delivery rider (e.g. Transmission Charge, Distribution Charge, Stranded Cost Recovery). If a rider disappears from the tariff, the entity toggles to unavailable; if it reappears, it becomes available again. --- -## Arithmetic and Energy dashboard +## Energy dashboard arithmetic -All money math uses exact `decimal.Decimal`. +All financial math uses exact `decimal.Decimal`: ```text supply + Σ variable delivery components -= total_variable_rate → sensor.eversource_total_electricity_rate += total_variable_rate → Total Electricity Rate sensor ``` -The fixed monthly customer charge is **excluded** from the Energy dashboard price so Home Assistant’s incremental kWh cost is not distorted. - -This integration provides **price only**. A separate cumulative **kWh** consumption sensor is required for Energy dashboard costs. - ---- - -## Future territory architecture - -Internal models already carry logical `territory` separately from Sitefinity `segment`. Production `TERRITORIES` currently lists New Hampshire Rate R, Connecticut Rate 1, and Massachusetts R1 (WMA/EMA). +The fixed monthly customer charge is **excluded** from the Energy dashboard price so incremental kWh costs are not distorted. --- -## Versioning and CI - -Release Please owns `manifest.json` version, `CHANGELOG.md`, and GitHub tags/releases. Do not manually bump versions. +## Release and supply-chain hygiene -See `.github/workflows/` for validation and release automation. Additional operator notes live in `docs/ci-cd.md` when that document is present on the branch. +- **Release Please** manages `manifest.json` version, `CHANGELOG.md`, and GitHub releases. Never manually bump release versions. +- **Quality gates**: CI enforces 100% passes on HACS validation, `hassfest`, Ruff linting/formatting, and a strict $\ge 96\%$ test coverage threshold. diff --git a/investigation/security_notes.md b/investigation/security_notes.md index ee57d4d..d9feeee 100644 --- a/investigation/security_notes.md +++ b/investigation/security_notes.md @@ -12,7 +12,7 @@ Throughout this investigation: ## 2. Authentication & Authorization Assessment ### Finding: Rate Data Is Inherently Public -Electricity utilities in regulated markets (such as New Hampshire, regulated by the NHPUC) are legally required to publish standard default service supply rates and delivery tariffs. +Electricity utilities in regulated markets (such as New Hampshire, regulated by the NHPUC) are legally required to publish standard default service supply rates and delivery tariffs. - **Rate R** is the standard, default tariff schedule available to any residential electric customer in Eversource's New Hampshire service territory. - Because it is a public tariff of general applicability, Eversource makes the data accessible on public web pages without requiring customer authentication or account registration. diff --git a/tests/test_package_init.py b/tests/test_package_init.py index 0a7b19e..e5b2b92 100644 --- a/tests/test_package_init.py +++ b/tests/test_package_init.py @@ -18,11 +18,14 @@ def guarded(name, globals=None, locals=None, fromlist=(), level=0): # noqa: A00 raise ModuleNotFoundError(name, name=name.split(".", 1)[0]) return real_import(name, globals, locals, fromlist, level) - for key in list(sys.modules): - if key == "homeassistant" or key.startswith( - ("homeassistant.", "custom_components.eversource_rates") - ): - del sys.modules[key] + saved_modules = { + key: sys.modules[key] + for key in list(sys.modules) + if key == "homeassistant" + or key.startswith(("homeassistant.", "custom_components.eversource_rates")) + } + for key in saved_modules: + del sys.modules[key] builtins.__import__ = guarded try: @@ -35,7 +38,7 @@ def guarded(name, globals=None, locals=None, fromlist=(), level=0): # noqa: A00 for key in list(sys.modules): if key.startswith("custom_components.eversource_rates"): del sys.modules[key] - importlib.import_module("custom_components.eversource_rates") + sys.modules.update(saved_modules) def test_package_init_reraises_unrelated_module_not_found() -> None: @@ -47,9 +50,13 @@ def guarded(name, globals=None, locals=None, fromlist=(), level=0): # noqa: A00 raise ModuleNotFoundError("some_unrelated_dep", name="some_unrelated_dep") return real_import(name, globals, locals, fromlist, level) - for key in list(sys.modules): - if key.startswith("custom_components.eversource_rates"): - del sys.modules[key] + saved_modules = { + key: sys.modules[key] + for key in list(sys.modules) + if key.startswith("custom_components.eversource_rates") + } + for key in saved_modules: + del sys.modules[key] builtins.__import__ = guarded try: @@ -60,4 +67,4 @@ def guarded(name, globals=None, locals=None, fromlist=(), level=0): # noqa: A00 for key in list(sys.modules): if key.startswith("custom_components.eversource_rates"): del sys.modules[key] - importlib.import_module("custom_components.eversource_rates") + sys.modules.update(saved_modules) diff --git a/tests/test_tariff_timezone.py b/tests/test_tariff_timezone.py new file mode 100644 index 0000000..b79d448 --- /dev/null +++ b/tests/test_tariff_timezone.py @@ -0,0 +1,128 @@ +"""Regression tests for Eastern Time tariff date evaluation. + +Ensures that tariff effective periods are evaluated against the service +territory's local time (America/New_York) rather than the local system clock +of the host running Home Assistant (e.g. UTC). +""" + +from __future__ import annotations + +import asyncio +from datetime import date +from decimal import Decimal +from pathlib import Path +from zoneinfo import ZoneInfo + +from custom_components.eversource_rates.api import EversourceClient +from custom_components.eversource_rates.const import ( + EVERSOURCE_TIME_ZONE, + TERRITORIES, +) +from custom_components.eversource_rates.parsers import ct, ma +from custom_components.eversource_rates.sources import TARIFF_SOURCES +from custom_components.eversource_rates.tariffs import TariffSelection + +FIXTURES = Path(__file__).parent / "fixtures" + + +class _FakeResponse: + def __init__(self, text: str) -> None: + self._text = text + self.status = 200 + + async def __aenter__(self) -> _FakeResponse: + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + pass + + async def text(self) -> str: + return self._text + + +class _FakeSession: + def __init__(self, supply_html: str, delivery_html: str) -> None: + self.supply_html = supply_html + self.delivery_html = delivery_html + + def get(self, url: str, **kwargs) -> _FakeResponse: + text = self.supply_html if "supply" in url else self.delivery_html + return _FakeResponse(text) + + +def test_territories_and_sources_use_eastern_time() -> None: + """Verify all territories and tariff sources configure Eastern Time.""" + assert EVERSOURCE_TIME_ZONE == ZoneInfo("America/New_York") + for territory in TERRITORIES.values(): + assert territory.time_zone == EVERSOURCE_TIME_ZONE + for source in TARIFF_SOURCES.values(): + assert source.time_zone == EVERSOURCE_TIME_ZONE + + +def test_ct_supply_period_end_boundary(freezer) -> None: + """At 23:59:59 ET on period end, CT parser selects the ending period.""" + # 2026-06-30 23:59:59 EDT == 2026-07-01 03:59:59 UTC + freezer.move_to("2026-07-01 03:59:59+00:00") + html = (FIXTURES / "sanitized_ct_supply.html").read_text() + + # Without explicit today, defaults to current time in America/New_York + supply = ct.parse_supply_html(html) + assert supply.rate == Decimal("0.12641") + assert supply.effective_date == date(2026, 1, 1) + assert supply.expiration_date == date(2026, 6, 30) + + +def test_ct_supply_next_period_boundary(freezer) -> None: + """At 00:00:01 ET on period start, CT parser selects the new period.""" + # 2026-07-01 00:00:01 EDT == 2026-07-01 04:00:01 UTC + freezer.move_to("2026-07-01 04:00:01+00:00") + html = (FIXTURES / "sanitized_ct_supply.html").read_text() + + supply = ct.parse_supply_html(html) + assert supply.rate == Decimal("0.11577") + assert supply.effective_date == date(2026, 7, 1) + assert supply.expiration_date == date(2026, 12, 31) + + +def test_ma_monthly_variable_month_end_boundary_on_utc_host(freezer) -> None: + """A UTC host on 1st of next month evaluates to prior month if still prior in ET.""" + # 2026-08-31 23:59:30 EDT is 2026-09-01 03:59:30 UTC. + # UTC rolled to Sep 1, but in Massachusetts it is still Aug 31. + freezer.move_to("2026-09-01 03:59:30+00:00") + html = (FIXTURES / "sanitized_wma_supply.html").read_text() + selection = TariffSelection("wma", "r1", supply_plan="monthly_variable") + + supply = ma.parse_supply_html(html, selection) + assert supply.rate == Decimal("0.13191") # August 2026 rate + assert supply.effective_date == date(2026, 8, 1) + assert supply.expiration_date == date(2026, 8, 31) + + +def test_ma_monthly_variable_new_month_boundary(freezer) -> None: + """At 00:00:05 ET on the 1st of month, MA monthly variable rolls to new month.""" + # 2026-09-01 00:00:05 EDT == 2026-09-01 04:00:05 UTC + freezer.move_to("2026-09-01 04:00:05+00:00") + html = (FIXTURES / "sanitized_wma_supply.html").read_text() + selection = TariffSelection("wma", "r1", supply_plan="monthly_variable") + + supply = ma.parse_supply_html(html, selection) + assert supply.rate == Decimal("0.11620") # September 2026 rate + assert supply.effective_date == date(2026, 9, 1) + assert supply.expiration_date == date(2026, 9, 30) + + +def test_api_client_evaluates_eastern_time_on_utc_host(freezer) -> None: + """EversourceClient passes ET as_of to parser even when UTC host rolled over.""" + # 2026-08-31 22:30:00 EDT == 2026-09-01 02:30:00 UTC + freezer.move_to("2026-09-01 02:30:00+00:00") + supply_html = (FIXTURES / "sanitized_wma_supply.html").read_text() + delivery_html = (FIXTURES / "sanitized_wma_delivery.html").read_text() + + session = _FakeSession(supply_html, delivery_html) + client = EversourceClient( + session, # type: ignore[arg-type] + selection=TariffSelection("wma", "r1", supply_plan="monthly_variable"), + ) + rates = asyncio.run(client.async_get_rates()) + assert rates.supply.rate == Decimal("0.13191") # August rate, not September + assert rates.supply.effective_date == date(2026, 8, 1)