diff --git a/custom_components/national_grid/coordinator.py b/custom_components/national_grid/coordinator.py index 9b3333b..b932df1 100644 --- a/custom_components/national_grid/coordinator.py +++ b/custom_components/national_grid/coordinator.py @@ -28,8 +28,10 @@ AmiEnergyUsage, Bill, BillingAccount, + ElectricBillRecord, EnergyUsage, EnergyUsageCost, + GasBillRecord, IntervalRead, Meter, ) @@ -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 @@ -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), @@ -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. @@ -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, @@ -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. diff --git a/custom_components/national_grid/sensor.py b/custom_components/national_grid/sensor.py index 31c2f1d..ec485b0 100644 --- a/custom_components/national_grid/sensor.py +++ b/custom_components/national_grid/sensor.py @@ -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 @@ -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() @@ -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: @@ -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", @@ -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, + ), ) diff --git a/custom_components/national_grid/strings.json b/custom_components/national_grid/strings.json index 0b12e49..f2b3fdd 100644 --- a/custom_components/national_grid/strings.json +++ b/custom_components/national_grid/strings.json @@ -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": { diff --git a/custom_components/national_grid/translations/en.json b/custom_components/national_grid/translations/en.json index 0b12e49..f2b3fdd 100644 --- a/custom_components/national_grid/translations/en.json +++ b/custom_components/national_grid/translations/en.json @@ -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": { diff --git a/requirements_test.txt b/requirements_test.txt index 97ede79..ae33df1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,2 +1,3 @@ pytest-homeassistant-custom-component pytest-cov +py-nationalgrid==0.6.3 diff --git a/tests/conftest.py b/tests/conftest.py index ecef33f..7c3ce6f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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": [ { @@ -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", diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 1334447..45d9aa4 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -33,6 +33,8 @@ _mock_billing_account, _mock_bills, _mock_costs, + _mock_electric_bill_history, + _mock_gas_bill_history, _mock_usages, ) @@ -75,6 +77,10 @@ def _make_api() -> AsyncMock: api.get_linked_accounts = AsyncMock(return_value=_mock_account_links()) api.get_interval_reads = AsyncMock(return_value=[]) api.get_bills = AsyncMock(return_value=_mock_bills()) + api.get_electric_bill_history = AsyncMock( + return_value=_mock_electric_bill_history() + ) + api.get_gas_bill_history = AsyncMock(return_value=_mock_gas_bill_history()) return api @@ -1079,3 +1085,181 @@ async def test_seed_from_previous_preserves_bills(hass: HomeAssistant) -> None: data2 = await coordinator._async_update_data() assert MOCK_ACCOUNT_ID in data2.bills + + +# --------------------------------------------------------------------------- +# Bill history (_fetch_bill_history / get_latest_*_bill_record) tests +# --------------------------------------------------------------------------- + + +async def test_fetch_bill_history_electric(hass: HomeAssistant) -> None: + """Test electric bill history is fetched and stored keyed by account_id.""" + api = _make_api() + coordinator = _make_coordinator(hass, api) + + coordinator.data = await coordinator._async_update_data() + + records = coordinator.data.electric_bill_history.get(MOCK_ACCOUNT_ID, []) + assert len(records) == 2 + assert records[0]["utilityCharges"] == 98.40 + assert records[0]["supplierCharges"] == 47.10 + assert records[0]["avgDailyUsage"] == 16.77 + # customerNumber must be coerced to str before the API call (model returns int) + api.get_electric_bill_history.assert_called_once_with(MOCK_ACCOUNT_ID, "987654321") + + +async def test_fetch_bill_history_gas(hass: HomeAssistant) -> None: + """Test gas bill history is fetched and stored keyed by account_id.""" + api = _make_api() + coordinator = _make_coordinator(hass, api) + + coordinator.data = await coordinator._async_update_data() + + records = coordinator.data.gas_bill_history.get(MOCK_ACCOUNT_ID, []) + assert len(records) == 1 + assert records[0]["utilityCharges"] == 28.80 + assert records[0]["avgDailyUsage"] == 1.03 + # customerNumber must be coerced to str before the API call (model returns int) + api.get_gas_bill_history.assert_called_once_with(MOCK_ACCOUNT_ID, "987654321") + + +async def test_fetch_bill_history_electric_api_failure( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test electric bill history failure is logged as WARNING and does not raise.""" + api = _make_api() + api.get_electric_bill_history = AsyncMock( + side_effect=Exception("business portal unavailable") + ) + coordinator = _make_coordinator(hass, api) + + with caplog.at_level(logging.WARNING): + coordinator.data = await coordinator._async_update_data() + + assert "Failed to fetch electric bill history" in caplog.text + assert coordinator.data.electric_bill_history.get(MOCK_ACCOUNT_ID) is None + + +async def test_fetch_bill_history_gas_api_failure( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test gas bill history failure is logged as WARNING and does not raise.""" + api = _make_api() + api.get_gas_bill_history = AsyncMock( + side_effect=Exception("business portal unavailable") + ) + coordinator = _make_coordinator(hass, api) + + with caplog.at_level(logging.WARNING): + coordinator.data = await coordinator._async_update_data() + + assert "Failed to fetch gas bill history" in caplog.text + assert coordinator.data.gas_bill_history.get(MOCK_ACCOUNT_ID) is None + + +async def test_get_latest_electric_bill_record(hass: HomeAssistant) -> None: + """Test get_latest_electric_bill_record returns the first (most recent) record.""" + api = _make_api() + coordinator = _make_coordinator(hass, api) + coordinator.data = await coordinator._async_update_data() + + record = coordinator.get_latest_electric_bill_record(MOCK_ACCOUNT_ID) + assert record is not None + assert record["utilityCharges"] == 98.40 + + +async def test_get_latest_electric_bill_record_empty(hass: HomeAssistant) -> None: + """Test get_latest_electric_bill_record returns None when no history.""" + api = _make_api() + api.get_electric_bill_history = AsyncMock(return_value=[]) + coordinator = _make_coordinator(hass, api) + coordinator.data = await coordinator._async_update_data() + + assert coordinator.get_latest_electric_bill_record(MOCK_ACCOUNT_ID) is None + + +async def test_get_latest_gas_bill_record(hass: HomeAssistant) -> None: + """Test get_latest_gas_bill_record returns the first (most recent) record.""" + api = _make_api() + coordinator = _make_coordinator(hass, api) + coordinator.data = await coordinator._async_update_data() + + record = coordinator.get_latest_gas_bill_record(MOCK_ACCOUNT_ID) + assert record is not None + assert record["utilityCharges"] == 28.80 + + +async def test_get_latest_electric_bill_record_none_data(hass: HomeAssistant) -> None: + """Test get_latest_electric_bill_record returns None when data is None.""" + api = _make_api() + coordinator = _make_coordinator(hass, api) + coordinator.data = None + assert coordinator.get_latest_electric_bill_record(MOCK_ACCOUNT_ID) is None + + +async def test_get_latest_gas_bill_record_none_data(hass: HomeAssistant) -> None: + """Test get_latest_gas_bill_record returns None when coordinator has no data.""" + api = _make_api() + coordinator = _make_coordinator(hass, api) + coordinator.data = None + assert coordinator.get_latest_gas_bill_record(MOCK_ACCOUNT_ID) is None + + +async def test_fetch_bill_history_skipped_without_customer_number( + hass: HomeAssistant, +) -> None: + """Test bill history is not fetched when customerNumber is absent.""" + api = _make_api() + billing = _mock_billing_account() + del billing["customerNumber"] + api.get_billing_account = AsyncMock(return_value=billing) + coordinator = _make_coordinator(hass, api) + + coordinator.data = await coordinator._async_update_data() + + api.get_electric_bill_history.assert_not_called() + api.get_gas_bill_history.assert_not_called() + + +async def test_fetch_bill_history_skipped_in_interval_only_mode( + hass: HomeAssistant, +) -> None: + """Test bill history is not fetched in interval-only mode.""" + api = _make_api() + coordinator = _make_coordinator(hass, api) + coordinator._is_first_refresh = False + await coordinator.async_refresh_interval_only() + + api.get_electric_bill_history.assert_not_called() + api.get_gas_bill_history.assert_not_called() + + +async def test_fetch_bill_history_skipped_when_account_not_loaded( + hass: HomeAssistant, +) -> None: + """Test _fetch_bill_history is a no-op when account is not in data.accounts.""" + from custom_components.national_grid.coordinator import NationalGridCoordinatorData + + api = _make_api() + coordinator = _make_coordinator(hass, api) + coordinator.data = NationalGridCoordinatorData(accounts={}) + + await coordinator._fetch_bill_history(coordinator.data, "nonexistent_account") + + api.get_electric_bill_history.assert_not_called() + api.get_gas_bill_history.assert_not_called() + + +async def test_seed_from_previous_preserves_bill_history(hass: HomeAssistant) -> None: + """Test bill history is preserved across incremental refreshes when API errors.""" + api = _make_api() + coordinator = _make_coordinator(hass, api) + + coordinator.data = await coordinator._async_update_data() + assert MOCK_ACCOUNT_ID in coordinator.data.electric_bill_history + + api.get_electric_bill_history = AsyncMock(side_effect=Exception("portal down")) + coordinator._is_first_refresh = False + data2 = await coordinator._async_update_data() + + assert MOCK_ACCOUNT_ID in data2.electric_bill_history diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 12bd601..5367898 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -14,15 +14,19 @@ SENSOR_DESCRIPTIONS, NationalGridAccountSensor, NationalGridSensor, + _get_avg_daily_usage, + _get_bill_history_record, _get_cost_per_unit, _get_cost_per_unit_unit, _get_current_bill_amount, _get_current_bill_attributes, - _get_energy_cost, _get_energy_device_class, _get_energy_unit, _get_energy_usage, _get_next_reading_date, + _get_supplier_charges, + _get_total_charges, + _get_utility_charges, ) @@ -78,21 +82,20 @@ def test_energy_usage_none() -> None: def test_energy_cost() -> None: - """Test cost value extraction.""" - meter_data = _make_meter_data() + """Test energy_cost sensor uses totalCharges from bill history.""" + meter_data = _make_meter_data("Electric") coordinator = MagicMock() - coordinator.get_latest_cost.return_value = {"amount": 120.50} - result = _get_energy_cost(coordinator, meter_data) - assert result == 120.50 + electric_rec = _make_electric_bill_record() + coordinator.get_latest_electric_bill_record.return_value = electric_rec + assert _get_total_charges(coordinator, meter_data) == 145.50 def test_energy_cost_none() -> None: - """Test cost returns None when no data.""" - meter_data = _make_meter_data() + """Test energy_cost sensor returns None when no bill history record.""" + meter_data = _make_meter_data("Electric") coordinator = MagicMock() - coordinator.get_latest_cost.return_value = None - result = _get_energy_cost(coordinator, meter_data) - assert result is None + coordinator.get_latest_electric_bill_record.return_value = None + assert _get_total_charges(coordinator, meter_data) is None def test_gas_meter_units() -> None: @@ -358,3 +361,184 @@ def test_cost_per_unit_in_sensor_descriptions() -> None: """Test that cost_per_unit appears in SENSOR_DESCRIPTIONS.""" keys = [d.key for d in SENSOR_DESCRIPTIONS] assert "cost_per_unit" in keys + + +# --------------------------------------------------------------------------- +# Bill history sensor tests +# --------------------------------------------------------------------------- + + +def _make_electric_bill_record() -> dict: + return { + "readDate": "2025-01-28", + "readFromDate": "2024-12-28", + "totalKwh": 520.0, + "utilityCharges": 98.40, + "supplierCharges": 47.10, + "totalCharges": 145.50, + "avgDailyUsage": 16.77, + } + + +def _make_gas_bill_record() -> dict: + return { + "readDate": "2025-01-28", + "readFromDate": "2024-12-28", + "totalTherms": 32.0, + "utilityCharges": 28.80, + "supplierCharges": 16.20, + "totalCharges": 45.00, + "avgDailyUsage": 1.03, + } + + +def test_get_bill_history_record_electric() -> None: + """Test _get_bill_history_record returns electric record for electric meter.""" + meter_data = _make_meter_data("Electric") + coordinator = MagicMock() + electric_rec = _make_electric_bill_record() + coordinator.get_latest_electric_bill_record.return_value = electric_rec + result = _get_bill_history_record(coordinator, meter_data) + assert result is not None + assert result["utilityCharges"] == 98.40 + + +def test_get_bill_history_record_gas() -> None: + """Test _get_bill_history_record returns gas record for gas meter.""" + meter_data = _make_meter_data("Gas") + coordinator = MagicMock() + coordinator.get_latest_gas_bill_record.return_value = _make_gas_bill_record() + result = _get_bill_history_record(coordinator, meter_data) + assert result is not None + assert result["utilityCharges"] == 28.80 + + +def test_get_bill_history_record_unknown_fuel() -> None: + """Test _get_bill_history_record returns None for unknown fuel type.""" + meter_data = _make_meter_data("Solar") + coordinator = MagicMock() + assert _get_bill_history_record(coordinator, meter_data) is None + + +def test_get_utility_charges_electric() -> None: + """Test _get_utility_charges returns utilityCharges from electric record.""" + meter_data = _make_meter_data("Electric") + coordinator = MagicMock() + electric_rec = _make_electric_bill_record() + coordinator.get_latest_electric_bill_record.return_value = electric_rec + assert _get_utility_charges(coordinator, meter_data) == 98.40 + + +def test_get_utility_charges_gas() -> None: + """Test _get_utility_charges returns utilityCharges from gas record.""" + meter_data = _make_meter_data("Gas") + coordinator = MagicMock() + coordinator.get_latest_gas_bill_record.return_value = _make_gas_bill_record() + assert _get_utility_charges(coordinator, meter_data) == 28.80 + + +def test_get_utility_charges_none_when_no_record() -> None: + """Test _get_utility_charges returns None when no bill history record available.""" + meter_data = _make_meter_data("Electric") + coordinator = MagicMock() + coordinator.get_latest_electric_bill_record.return_value = None + assert _get_utility_charges(coordinator, meter_data) is None + + +def test_get_supplier_charges_electric() -> None: + """Test _get_supplier_charges returns supplierCharges from electric record.""" + meter_data = _make_meter_data("Electric") + coordinator = MagicMock() + electric_rec = _make_electric_bill_record() + coordinator.get_latest_electric_bill_record.return_value = electric_rec + assert _get_supplier_charges(coordinator, meter_data) == 47.10 + + +def test_get_supplier_charges_none_when_no_record() -> None: + """Test _get_supplier_charges returns None when no bill history record available.""" + meter_data = _make_meter_data("Electric") + coordinator = MagicMock() + coordinator.get_latest_electric_bill_record.return_value = None + assert _get_supplier_charges(coordinator, meter_data) is None + + +def test_get_supplier_charges_gas() -> None: + """Test _get_supplier_charges returns supplierCharges from gas record.""" + meter_data = _make_meter_data("Gas") + coordinator = MagicMock() + coordinator.get_latest_gas_bill_record.return_value = _make_gas_bill_record() + assert _get_supplier_charges(coordinator, meter_data) == 16.20 + + +def test_get_supplier_charges_gas_none() -> None: + """Test _get_supplier_charges returns None when no gas bill history record.""" + meter_data = _make_meter_data("Gas") + coordinator = MagicMock() + coordinator.get_latest_gas_bill_record.return_value = None + assert _get_supplier_charges(coordinator, meter_data) is None + + +def test_get_avg_daily_usage_electric() -> None: + """Test _get_avg_daily_usage returns avgDailyUsage from electric record.""" + meter_data = _make_meter_data("Electric") + coordinator = MagicMock() + electric_rec = _make_electric_bill_record() + coordinator.get_latest_electric_bill_record.return_value = electric_rec + assert _get_avg_daily_usage(coordinator, meter_data) == 16.77 + + +def test_get_avg_daily_usage_gas() -> None: + """Test _get_avg_daily_usage returns avgDailyUsage from gas record.""" + meter_data = _make_meter_data("Gas") + coordinator = MagicMock() + coordinator.get_latest_gas_bill_record.return_value = _make_gas_bill_record() + assert _get_avg_daily_usage(coordinator, meter_data) == 1.03 + + +def test_get_avg_daily_usage_none_when_no_record() -> None: + """Test _get_avg_daily_usage returns None when no bill history record available.""" + meter_data = _make_meter_data("Electric") + coordinator = MagicMock() + coordinator.get_latest_electric_bill_record.return_value = None + assert _get_avg_daily_usage(coordinator, meter_data) is None + + +def test_avg_daily_usage_uses_energy_unit_fn() -> None: + """Test last_bill_avg_daily_usage sensor uses _get_energy_unit for its unit_fn.""" + desc = next(d for d in SENSOR_DESCRIPTIONS if d.key == "last_bill_avg_daily_usage") + assert desc.unit_fn is not None + assert desc.unit_fn(_make_meter_data("Electric")) == UNIT_KWH + assert desc.unit_fn(_make_meter_data("Gas")) == UNIT_CCF + + +def test_get_total_charges_electric() -> None: + """Test _get_total_charges returns totalCharges from electric record.""" + meter_data = _make_meter_data("Electric") + coordinator = MagicMock() + electric_rec = _make_electric_bill_record() + coordinator.get_latest_electric_bill_record.return_value = electric_rec + assert _get_total_charges(coordinator, meter_data) == 145.50 + + +def test_get_total_charges_gas() -> None: + """Test _get_total_charges returns totalCharges from gas record.""" + meter_data = _make_meter_data("Gas") + coordinator = MagicMock() + coordinator.get_latest_gas_bill_record.return_value = _make_gas_bill_record() + assert _get_total_charges(coordinator, meter_data) == 45.00 + + +def test_get_total_charges_none_when_no_record() -> None: + """Test _get_total_charges returns None when no bill history record available.""" + meter_data = _make_meter_data("Electric") + coordinator = MagicMock() + coordinator.get_latest_electric_bill_record.return_value = None + assert _get_total_charges(coordinator, meter_data) is None + + +def test_bill_history_sensors_in_descriptions() -> None: + """Test all bill history sensors appear in SENSOR_DESCRIPTIONS.""" + keys = [d.key for d in SENSOR_DESCRIPTIONS] + assert "last_bill_utility_charges" in keys + assert "last_bill_supplier_charges" in keys + assert "last_bill_avg_daily_usage" in keys