diff --git a/CHANGELOG.md b/CHANGELOG.md index 49079e1..65a37e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Battery pack sensors**: Exposes battery voltage in volts, signed battery current in amperes, calculated battery power in watts, and battery temperature in degrees Celsius when supported by the device TSL. Battery power is positive while charging and negative while discharging. + ### Changed +- Battery voltage defaults to two decimal places and battery power defaults to one decimal place in Home Assistant displays. - Upgraded to unofficial-pecron-api v0.4.1 (adds `eco_onoff_us` as an alternate property code for Eco Silent Mode on some device models) ### Fixed +- Read battery voltage, current, and temperature from the nested battery packet exposed by the Pecron API and discover all three sensors through its `host_packet_data_jdb` TSL property. - **Crash on setup when no devices are usable or initial fetch fails**: replaced all uses of the removed `hass.components.persistent_notification` accessor with the current `homeassistant.components.persistent_notification.async_create(hass, ...)` API. Previously, any code path that tried to show a persistent notification (no devices found, initial connection failure, invalid/read-only property, failed switch/select control) raised `AttributeError: 'HomeAssistant' object has no attribute 'components'` on modern Home Assistant, aborting integration setup entirely instead of surfacing the intended message (#8) ## [0.5.0] - 2026-04-10 diff --git a/README.md b/README.md index b8a5cd5..a7cf2f4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A Home Assistant community integration for Pecron portable power stations. Monit ## Features - **Device Control** - Turn AC and DC outputs on/off directly from Home Assistant -- **Real-time Monitoring** - Battery percentage, input/output power, and device status +- **Real-time Monitoring** - Battery percentage, voltage, current, temperature, input/output power, and device status - **Multi-device Support** - Manage multiple Pecron stations from one account - **Smart Entity Discovery** - Automatically creates only the entities your device supports - **Advanced Control Service** - `pecron.set_property` for controlling any writable device property @@ -72,6 +72,10 @@ The integration creates the following entities for each device: ### Sensors - **Battery Percentage** - Current battery level (%) +- **Battery Voltage** - Current battery pack voltage (V) +- **Battery Current** - Signed battery pack charge/discharge current (A) +- **Battery Power** - Current battery power (W), positive while charging and negative while discharging +- **Battery Temperature** - Current battery pack temperature (°C) - **Input Power** - Total power being drawn from all input sources (W) - **AC Input Power** - Power from grid/AC charging (W) - **DC Input Power** - Power from solar/DC input (W) diff --git a/custom_components/pecron/sensor.py b/custom_components/pecron/sensor.py index e7b4716..9cc9b8c 100644 --- a/custom_components/pecron/sensor.py +++ b/custom_components/pecron/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import math from dataclasses import dataclass from typing import Any @@ -12,7 +13,13 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, UnitOfTime +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -20,13 +27,7 @@ DataUpdateCoordinator, ) -from .const import ( - ATTR_DEVICE_KEY, - ATTR_FIRMWARE_VERSION, - ATTR_PRODUCT_KEY, - ATTR_PRODUCT_NAME, - DOMAIN, -) +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -35,10 +36,12 @@ class PecronSensorDescription(SensorEntityDescription): """Describe a Pecron sensor.""" + icon: str | None = None always_create: bool = False # Bypass TSL filtering smart_availability: bool = False # Use smart logic for availability struct_property: str | None = None # Parent property name if value is inside a STRUCT dict struct_field: str | None = None # Key within the struct dict to extract + tsl_property: str | None = None # TSL property that supplies this sensor's value def __post_init__(self) -> None: """Post init.""" @@ -62,6 +65,51 @@ def __post_init__(self) -> None: state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="%", ), + PecronSensorDescription( + key="battery_voltage", + name="Battery Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, + icon="mdi:battery-heart-variant", + struct_property="battery_pack", + struct_field="host_packet_voltage", + tsl_property="host_packet_data_jdb", + ), + PecronSensorDescription( + key="battery_current", + name="Battery Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + icon="mdi:current-dc", + struct_property="battery_pack", + struct_field="host_packet_current", + tsl_property="host_packet_data_jdb", + ), + PecronSensorDescription( + key="battery_power", + name="Battery Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=1, + icon="mdi:battery-charging", + struct_property="battery_pack", + tsl_property="host_packet_data_jdb", + ), + PecronSensorDescription( + key="battery_temperature", + name="Battery Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + icon="mdi:thermometer", + struct_property="battery_pack", + struct_field="host_packet_temp", + tsl_property="host_packet_data_jdb", + ), PecronSensorDescription( key="total_input_power", name="Input Power", @@ -144,16 +192,16 @@ def create_sensors_for_device(device_key: str, device_data: dict) -> list: for sensor_desc in PECRON_SENSORS: # Always create sensors marked with always_create flag - # Otherwise check both property name and _hm variant (API maps xxx_hm -> xxx) - # For struct sensors, also check the TSL code with _data_ infix - # (e.g., ac_input -> ac_data_input_hm) - tsl_key = sensor_desc.key - tsl_key_hm = f"{sensor_desc.key}_hm" - tsl_key_data_hm = f"{tsl_key.replace('_input', '_data_input')}_hm" if "_input" in tsl_key else None - if (sensor_desc.always_create or - tsl_key in tsl_property_codes or - tsl_key_hm in tsl_property_codes or - (tsl_key_data_hm and tsl_key_data_hm in tsl_property_codes)): + # Otherwise check the API property name and common TSL variants. + tsl_keys = { + sensor_desc.key, + f"{sensor_desc.key}_hm", + sensor_desc.tsl_property or sensor_desc.key, + } + if "_input" in sensor_desc.key: + tsl_keys.add(f"{sensor_desc.key.replace('_input', '_data_input')}_hm") + + if sensor_desc.always_create or tsl_property_codes.intersection(tsl_keys): sensors.append( PecronSensor( coordinator, @@ -164,11 +212,10 @@ def create_sensors_for_device(device_key: str, device_data: dict) -> list: ) else: _LOGGER.debug( - "Skipping sensor '%s' for %s - not in TSL (checked '%s' and '%s_hm')", + "Skipping sensor '%s' for %s - not in TSL (checked %s)", sensor_desc.key, device_data["device"].device_name, - sensor_desc.key, - sensor_desc.key, + sorted(tsl_keys), ) else: # Fallback: create all sensors if TSL is not available @@ -264,6 +311,23 @@ def native_value(self) -> int | float | None: props = self.coordinator.data[self._device_key]["properties"] + # Battery power is derived from the signed current and voltage in the + # battery packet. The current sign makes charging positive and + # discharging negative. + if self.entity_description.key == "battery_power": + battery_pack = getattr(props, "battery_pack", None) + if not battery_pack or not isinstance(battery_pack, dict): + return None + + try: + voltage = float(battery_pack["host_packet_voltage"]) + current = float(battery_pack["host_packet_current"]) + except (KeyError, TypeError, ValueError): + return None + + power = voltage * current + return power if math.isfinite(power) else None + # For struct sensors, extract the value from the parent dict if self.entity_description.struct_property and self.entity_description.struct_field: struct_dict = getattr(props, self.entity_description.struct_property, None) @@ -301,8 +365,6 @@ def native_value(self) -> int | float | None: is_idle = input_power == 0 and output_power == 0 is_charging_only = input_power > 0 and output_power == 0 is_discharging_only = input_power == 0 and output_power > 0 - is_ups_mode = input_power > 0 and output_power > 0 - # Time to Full logic if self.entity_description.key == "remain_charging_time": if is_discharging_only or is_idle: diff --git a/custom_components/pecron/strings.json b/custom_components/pecron/strings.json index 21ab6bf..576242f 100644 --- a/custom_components/pecron/strings.json +++ b/custom_components/pecron/strings.json @@ -32,6 +32,18 @@ "battery_percentage": { "name": "Battery percentage" }, + "battery_voltage": { + "name": "Battery voltage" + }, + "battery_current": { + "name": "Battery current" + }, + "battery_power": { + "name": "Battery power" + }, + "battery_temperature": { + "name": "Battery temperature" + }, "total_input_power": { "name": "Input power" }, diff --git a/tests/test_battery_sensors.py b/tests/test_battery_sensors.py new file mode 100644 index 0000000..4125ee3 --- /dev/null +++ b/tests/test_battery_sensors.py @@ -0,0 +1,189 @@ +"""Tests for battery electrical measurement sensors.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfPower, + UnitOfTemperature, +) + +from custom_components.pecron.const import DOMAIN +from custom_components.pecron.sensor import PECRON_SENSORS, PecronSensor, async_setup_entry + + +def _sensor_description(key: str): + """Return the description for a sensor key.""" + return next(description for description in PECRON_SENSORS if description.key == key) + + +def _sensor_with_battery_pack(battery_pack: dict | None) -> tuple[MagicMock, MagicMock]: + """Create a battery sensor whose coordinator reports a battery packet.""" + device = MagicMock( + device_key="test_device", device_name="Test Device", product_name="Test Product" + ) + properties = SimpleNamespace(battery_pack=battery_pack) + coordinator = MagicMock(data={"test_device": {"device": device, "properties": properties}}) + return coordinator, device + + +def test_battery_voltage_sensor_metadata_and_value() -> None: + """Battery voltage is exposed as a voltage measurement in volts.""" + description = _sensor_description("battery_voltage") + + assert description.device_class is SensorDeviceClass.VOLTAGE + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfElectricPotential.VOLT + assert description.suggested_display_precision == 2 + assert description.tsl_property == "host_packet_data_jdb" + assert description.struct_property == "battery_pack" + assert description.struct_field == "host_packet_voltage" + + coordinator, device = _sensor_with_battery_pack({"host_packet_voltage": "51.2"}) + sensor = PecronSensor(coordinator, "test_device", device, description) + assert sensor.native_value == 51.2 + + +def test_battery_current_sensor_metadata_and_value() -> None: + """Battery current is exposed as a current measurement in amperes.""" + description = _sensor_description("battery_current") + + assert description.device_class is SensorDeviceClass.CURRENT + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfElectricCurrent.AMPERE + assert description.tsl_property == "host_packet_data_jdb" + assert description.struct_property == "battery_pack" + assert description.struct_field == "host_packet_current" + + coordinator, device = _sensor_with_battery_pack({"host_packet_current": "-12.5"}) + sensor = PecronSensor(coordinator, "test_device", device, description) + assert sensor.native_value == -12.5 + + +@pytest.mark.parametrize( + ("current", "expected"), + [ + ("10", 512.0), + ("-12.5", -640.0), + ("0", 0.0), + ], +) +def test_battery_power_sensor_metadata_and_signed_value(current: str, expected: float) -> None: + """Battery power is voltage times signed current in watts.""" + description = _sensor_description("battery_power") + + assert description.device_class is SensorDeviceClass.POWER + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfPower.WATT + assert description.suggested_display_precision == 1 + assert description.tsl_property == "host_packet_data_jdb" + assert description.struct_property == "battery_pack" + + coordinator, device = _sensor_with_battery_pack( + {"host_packet_voltage": "51.2", "host_packet_current": current} + ) + sensor = PecronSensor(coordinator, "test_device", device, description) + + assert sensor.native_value == expected + + +@pytest.mark.parametrize( + "battery_pack", + [ + None, + {}, + {"host_packet_voltage": "51.2"}, + {"host_packet_current": "10"}, + {"host_packet_voltage": None, "host_packet_current": "10"}, + {"host_packet_voltage": "invalid", "host_packet_current": "10"}, + {"host_packet_voltage": "51.2", "host_packet_current": "invalid"}, + {"host_packet_voltage": "nan", "host_packet_current": "10"}, + {"host_packet_voltage": "51.2", "host_packet_current": "inf"}, + ], +) +def test_battery_power_sensor_handles_missing_and_invalid_values( + battery_pack: dict | None, +) -> None: + """Battery power is unavailable unless voltage and current are numeric.""" + description = _sensor_description("battery_power") + coordinator, device = _sensor_with_battery_pack(battery_pack) + sensor = PecronSensor(coordinator, "test_device", device, description) + + assert sensor.native_value is None + + +def test_battery_temperature_sensor_metadata_and_value() -> None: + """Battery temperature is exposed as a temperature measurement in Celsius.""" + description = _sensor_description("battery_temperature") + + assert description.device_class is SensorDeviceClass.TEMPERATURE + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfTemperature.CELSIUS + assert description.tsl_property == "host_packet_data_jdb" + assert description.struct_property == "battery_pack" + assert description.struct_field == "host_packet_temp" + + coordinator, device = _sensor_with_battery_pack({"host_packet_temp": "31.5"}) + sensor = PecronSensor(coordinator, "test_device", device, description) + assert sensor.native_value == 31.5 + + +@pytest.mark.parametrize( + ("battery_pack", "expected"), + [ + (None, None), + ({}, None), + ({"host_packet_voltage": None}, None), + ({"host_packet_voltage": "not-a-number"}, None), + ({"host_packet_voltage": "24"}, 24), + ], +) +def test_battery_sensor_handles_missing_and_invalid_values( + battery_pack: dict | None, expected: int | None +) -> None: + """Missing or invalid battery pack telemetry produces an unavailable value.""" + description = _sensor_description("battery_voltage") + coordinator, device = _sensor_with_battery_pack(battery_pack) + sensor = PecronSensor(coordinator, "test_device", device, description) + + assert sensor.native_value == expected + + +@pytest.mark.asyncio +async def test_battery_sensors_created_from_battery_packet_tsl() -> None: + """Battery sensors are created when the device TSL exposes the battery packet.""" + device = MagicMock( + device_key="test_device", + device_name="Test Device", + product_name="Test Product", + ) + coordinator = MagicMock( + data={ + "test_device": { + "device": device, + "properties": SimpleNamespace( + battery_pack={ + "host_packet_voltage": "51.2", + "host_packet_current": "-12.5", + } + ), + "tsl": [SimpleNamespace(code="host_packet_data_jdb")], + } + } + ) + entry = MagicMock(entry_id="test_entry") + hass = MagicMock(data={DOMAIN: {entry.entry_id: coordinator}}) + async_add_entities = MagicMock() + + await async_setup_entry(hass, entry, async_add_entities) + + sensors = async_add_entities.call_args.args[0] + sensor_keys = {sensor.entity_description.key for sensor in sensors} + assert "battery_voltage" in sensor_keys + assert "battery_current" in sensor_keys + assert "battery_power" in sensor_keys + assert "battery_temperature" in sensor_keys