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)