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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
108 changes: 85 additions & 23 deletions custom_components/pecron/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from typing import Any

Expand All @@ -12,21 +13,21 @@
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 (
CoordinatorEntity,
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__)

Expand All @@ -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."""
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions custom_components/pecron/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading
Loading