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
91 changes: 91 additions & 0 deletions custom_components/national_grid/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
AmiEnergyUsage,
Bill,
BillingAccount,
ElectricBillRecord,
EnergyUsage,
EnergyUsageCost,
GasBillRecord,
IntervalRead,
Meter,
)
Expand Down Expand Up @@ -61,6 +63,10 @@ class NationalGridCoordinatorData:
meters: dict[str, MeterData] = field(default_factory=dict)
reading_dates: dict[str, str | None] = field(default_factory=dict)
usages: dict[str, list[EnergyUsage]] = field(default_factory=dict)
electric_bill_history: dict[str, list[ElectricBillRecord]] = field(
default_factory=dict
)
gas_bill_history: dict[str, list[GasBillRecord]] = field(default_factory=dict)
is_first_refresh: bool = False
# Midnight refresh: force full hourly import + clear/reimport interval stats
is_midnight_refresh: bool = False
Expand Down Expand Up @@ -278,6 +284,8 @@ def _seed_from_previous(self) -> NationalGridCoordinatorData:
ami_usages=dict(prev.ami_usages),
bills=dict(prev.bills),
costs=dict(prev.costs),
electric_bill_history=dict(prev.electric_bill_history),
gas_bill_history=dict(prev.gas_bill_history),
interval_reads=dict(prev.interval_reads),
meters=dict(prev.meters),
reading_dates=dict(prev.reading_dates),
Expand Down Expand Up @@ -332,6 +340,9 @@ async def _fetch_account_data(
# Fetch bill history.
data.bills[account_id] = await self._fetch_bills(account_id)

# Fetch business portal bill history (utility/supplier charge breakdown).
await self._fetch_bill_history(data, account_id)

# Fetch AMI and interval read data for AMI-capable meters.
# In interval-only mode: skips slow GraphQL AMI fetch, does interval reads only.
# In full mode: fetches both AMI 15-min data and interval reads.
Expand Down Expand Up @@ -435,6 +446,70 @@ async def _fetch_bills(self, account_id: str) -> list[Bill]:
else:
return bills

async def _fetch_bill_history(
self,
data: NationalGridCoordinatorData,
account_id: str,
) -> None:
"""Fetch per-period bill history from the business portal for an account.

Calls get_electric_bill_history / get_gas_bill_history based on the
fuelTypes reported by the billing account. Failures are logged as
warnings and the history dict for that fuel type is left unchanged
(preserving stale data from the previous refresh).
"""
billing_account = data.accounts.get(account_id)
if not billing_account:
return
customer_number = billing_account.get("customerNumber")
if customer_number is None:
_LOGGER.warning(
"Account %s has no customerNumber — skipping bill history fetch",
account_id,
)
return
customer_number = str(customer_number)
fuel_types = [
str(ft.get("type", "")).upper()
for ft in billing_account.get("fuelTypes", [])
]

if "ELECTRIC" in fuel_types:
try:
data.electric_bill_history[
account_id
] = await self.api.get_electric_bill_history(
account_id, customer_number
)
_LOGGER.debug(
"Fetched %s electric bill history records for account %s",
len(data.electric_bill_history[account_id]),
account_id,
)
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Failed to fetch electric bill history for account %s: %s",
account_id,
err,
)

if "GAS" in fuel_types:
try:
data.gas_bill_history[account_id] = await self.api.get_gas_bill_history(
account_id, customer_number
)
_LOGGER.debug(
"Fetched %s gas bill history records for account %s",
len(data.gas_bill_history[account_id]),
account_id,
)
except Exception as err: # noqa: BLE001
_LOGGER.warning(
"Failed to fetch gas bill history for account %s: %s",
account_id,
err,
)

async def _fetch_ami_data( # noqa: PLR0913
self,
billing_account: BillingAccount,
Expand Down Expand Up @@ -811,6 +886,22 @@ def get_latest_ami_usage(self, service_point_number: str) -> AmiEnergyUsage | No
return None
return max(readings, key=lambda r: r.get("date", ""))

def get_latest_electric_bill_record(
self, account_id: str
) -> ElectricBillRecord | None:
"""Return the most recent electric bill history record for an account."""
if self.data is None:
return None
records = self.data.electric_bill_history.get(account_id, [])
return records[0] if records else None

def get_latest_gas_bill_record(self, account_id: str) -> GasBillRecord | None:
"""Return the most recent gas bill history record for an account."""
if self.data is None:
return None
records = self.data.gas_bill_history.get(account_id, [])
return records[0] if records else None

def reset_to_first_refresh(self) -> None:
"""Reset the coordinator to perform a full historical data import.

Expand Down
83 changes: 71 additions & 12 deletions custom_components/national_grid/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from py_nationalgrid.models import ElectricBillRecord, GasBillRecord

from .coordinator import MeterData, NationalGridDataUpdateCoordinator
from .data import NationalGridConfigEntry
Expand Down Expand Up @@ -65,17 +66,6 @@ def _get_energy_usage(
return None


def _get_energy_cost(
coordinator: NationalGridDataUpdateCoordinator, meter_data: MeterData
) -> float | None:
"""Get the latest energy cost for a meter."""
fuel_type = meter_data.meter.get("fuelType")
cost = coordinator.get_latest_cost(meter_data.account_id, fuel_type)
if cost:
return cost.get("amount")
return None


def _get_energy_unit(meter_data: MeterData) -> str:
"""Get the appropriate energy unit based on fuel type."""
fuel_type = meter_data.meter.get("fuelType", "").upper()
Expand Down Expand Up @@ -144,6 +134,51 @@ def _get_cost_per_unit(
return round(total_cost / total_usage, 4)


def _get_bill_history_record(
coordinator: NationalGridDataUpdateCoordinator, meter_data: MeterData
) -> ElectricBillRecord | GasBillRecord | None:
"""Return the most recent bill history record for the meter's fuel type."""
fuel = meter_data.meter.get("fuelType", "").lower()
account_id = meter_data.account_id
if fuel == "electric":
return coordinator.get_latest_electric_bill_record(account_id)
if fuel == "gas":
return coordinator.get_latest_gas_bill_record(account_id)
return None


def _get_utility_charges(
coordinator: NationalGridDataUpdateCoordinator, meter_data: MeterData
) -> float | None:
"""Return the utility charge component from the most recent bill period."""
rec = _get_bill_history_record(coordinator, meter_data)
return rec.get("utilityCharges") if rec else None


def _get_supplier_charges(
coordinator: NationalGridDataUpdateCoordinator, meter_data: MeterData
) -> float | None:
"""Return the supplier charge component from the most recent bill period."""
rec = _get_bill_history_record(coordinator, meter_data)
return rec.get("supplierCharges") if rec else None


def _get_avg_daily_usage(
coordinator: NationalGridDataUpdateCoordinator, meter_data: MeterData
) -> float | None:
"""Return average daily usage from the most recent bill period."""
rec = _get_bill_history_record(coordinator, meter_data)
return rec.get("avgDailyUsage") if rec else None


def _get_total_charges(
coordinator: NationalGridDataUpdateCoordinator, meter_data: MeterData
) -> float | None:
"""Return total charges from the most recent bill period."""
rec = _get_bill_history_record(coordinator, meter_data)
return rec.get("totalCharges") if rec else None


def _get_current_bill_amount(
coordinator: NationalGridDataUpdateCoordinator, account_id: str
) -> float | None:
Expand Down Expand Up @@ -206,7 +241,7 @@ def _get_next_reading_date(
translation_key="energy_cost",
native_unit_of_measurement="USD",
device_class=SensorDeviceClass.MONETARY,
value_fn=_get_energy_cost,
value_fn=_get_total_charges,
),
NationalGridSensorEntityDescription(
key="energy_usage",
Expand All @@ -223,6 +258,30 @@ def _get_next_reading_date(
value_fn=_get_cost_per_unit,
unit_fn=_get_cost_per_unit_unit,
),
NationalGridSensorEntityDescription(
key="last_bill_utility_charges",
translation_key="last_bill_utility_charges",
native_unit_of_measurement="USD",
device_class=SensorDeviceClass.MONETARY,
value_fn=_get_utility_charges,
available_fn=lambda _: True,
),
NationalGridSensorEntityDescription(
key="last_bill_supplier_charges",
translation_key="last_bill_supplier_charges",
native_unit_of_measurement="USD",
device_class=SensorDeviceClass.MONETARY,
value_fn=_get_supplier_charges,
available_fn=lambda _: True,
),
NationalGridSensorEntityDescription(
key="last_bill_avg_daily_usage",
translation_key="last_bill_avg_daily_usage",
value_fn=_get_avg_daily_usage,
unit_fn=_get_energy_unit,
device_class_fn=_get_energy_device_class,
available_fn=lambda _: True,
),
)


Expand Down
9 changes: 9 additions & 0 deletions custom_components/national_grid/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@
},
"cost_per_unit": {
"name": "Avg Cost per Unit"
},
"last_bill_utility_charges": {
"name": "Last Bill Utility Charges"
},
"last_bill_supplier_charges": {
"name": "Last Bill Supplier Charges"
},
"last_bill_avg_daily_usage": {
"name": "Last Bill Avg Daily Usage"
}
},
"binary_sensor": {
Expand Down
9 changes: 9 additions & 0 deletions custom_components/national_grid/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@
},
"cost_per_unit": {
"name": "Avg Cost per Unit"
},
"last_bill_utility_charges": {
"name": "Last Bill Utility Charges"
},
"last_bill_supplier_charges": {
"name": "Last Bill Supplier Charges"
},
"last_bill_avg_daily_usage": {
"name": "Last Bill Avg Daily Usage"
}
},
"binary_sensor": {
Expand Down
1 change: 1 addition & 0 deletions requirements_test.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pytest-homeassistant-custom-component
pytest-cov
py-nationalgrid==0.6.3
50 changes: 50 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def _mock_billing_account(account_id: str = MOCK_ACCOUNT_ID) -> dict:
"billingAccountId": account_id,
"region": "KEDNY",
"premiseNumber": "PREM001",
"customerNumber": 987654321,
"fuelTypes": [{"type": "Electric"}, {"type": "Gas"}],
"meter": {
"nodes": [
{
Expand Down Expand Up @@ -132,6 +134,54 @@ def _mock_bills(account_id: str = MOCK_ACCOUNT_ID) -> list[dict]:
]


def _mock_electric_bill_history() -> list[dict]:
"""Return mock electric bill history records (newest first)."""
return [
{
"readDate": "2025-01-28",
"readFromDate": "2024-12-28",
"readDays": 31,
"readType": "Actual",
"totalKwh": 520.0,
"utilityCharges": 98.40,
"supplierCharges": 47.10,
"latePayment": 0.0,
"totalCharges": 145.50,
"avgDailyUsage": 16.77,
},
{
"readDate": "2024-12-28",
"readFromDate": "2024-11-28",
"readDays": 30,
"readType": "Actual",
"totalKwh": 490.0,
"utilityCharges": 92.00,
"supplierCharges": 44.00,
"latePayment": 0.0,
"totalCharges": 136.00,
"avgDailyUsage": 16.33,
},
]


def _mock_gas_bill_history() -> list[dict]:
"""Return mock gas bill history records (newest first)."""
return [
{
"readDate": "2025-01-28",
"readFromDate": "2024-12-28",
"readDays": 31,
"readType": "Actual",
"totalTherms": 32.0,
"utilityCharges": 28.80,
"supplierCharges": 16.20,
"latePayment": 0.0,
"totalCharges": 45.00,
"avgDailyUsage": 1.03,
},
]


def _mock_account_links(
account_id: str = MOCK_ACCOUNT_ID,
next_reading_date: str | None = "2025-02-15",
Expand Down
Loading