Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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).
21 changes: 19 additions & 2 deletions custom_components/eversource_rates/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
4 changes: 4 additions & 0 deletions custom_components/eversource_rates/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand All @@ -29,6 +32,7 @@ class Territory:
name: str
segment: str
supported_rate_classes: tuple[str, ...]
time_zone: ZoneInfo = EVERSOURCE_TIME_ZONE


TERRITORIES = {
Expand Down
11 changes: 8 additions & 3 deletions custom_components/eversource_rates/parsers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,8 @@

def get_tariff_parser(
selection: TariffSelection,
*,
today: date | None = None,
) -> tuple[
Callable[[str], SupplyRate],
Callable[[str], DeliveryRates],
Expand All @@ -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)
Expand All @@ -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)
5 changes: 3 additions & 2 deletions custom_components/eversource_rates/parsers/ct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 3 additions & 2 deletions custom_components/eversource_rates/parsers/ma.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion custom_components/eversource_rates/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading