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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,34 @@ An error will be placed in the logs

The current charge config, discharge config and charging range will only update once the API is re-called (can be up to 1 min)

### EV charger controls

The integration exposes EV charger controls (start/stop and current setting) when an EV charger is detected.

- Start/stop commands are now validated against the current EV charger status before being sent.
- If a command is not valid for the current state, the integration skips the API call and logs a clear message.
- If notifications are enabled for that inverter, a persistent notification is shown when a command is rejected.
- Two EV diagnostic binary sensors are exposed:
- `Can Start Charging`
- `Can Stop Charging`

These track whether a start/stop command is currently valid based on the latest EV charger status.

### Currency and daily history sensors

- Monetary sensors now use ISO 4217 currency codes when provided by the API, and fall back to Home Assistant's configured currency when not available.
- Diagnostic currency sensors are available for troubleshooting:
- `Currency Code`
- Daily history energy breakdown sensors are exposed:
- `Daily PV Generation`
- `Daily Grid Consumption`
- `Daily Feed-in`
- `Daily Grid Charge`
- `Daily Battery Charge`
- `Daily Battery Discharge`
- `Daily EV Charging Energy`
- `Daily Energy Date`

If you want to adjust the restrictions yourself, you are able to by modifying the `ALPHA_POST_REQUEST_RESTRICTION` varible in const.py to the amount of seconds allowed per call

## Local Inverter Support
Expand Down
77 changes: 77 additions & 0 deletions custom_components/alphaess/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import asyncio
import ipaddress
import logging
from datetime import timedelta

import voluptuous as vol

Expand All @@ -17,10 +18,14 @@
import homeassistant.helpers.config_validation as cv

from .const import (
CONF_SCAN_INTERVAL_SECONDS,
CONF_DISABLE_NOTIFICATIONS,
CONF_EV_CHARGER_MODEL,
CONF_INVERTER_MODEL,
CONF_IP_ADDRESS,
DEFAULT_SCAN_INTERVAL_SECONDS,
MAX_SCAN_INTERVAL_SECONDS,
MIN_SCAN_INTERVAL_SECONDS,
CONF_PARENT_INVERTER,
CONF_SERIAL_NUMBER,
DOMAIN,
Expand All @@ -29,6 +34,7 @@
SUBENTRY_TYPE_INVERTER,
)
from .coordinator import AlphaESSDataUpdateCoordinator
from .enums import AlphaESSNames

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -140,6 +146,53 @@ def _migrate_entity_ids(hass: HomeAssistant, entry: ConfigEntry) -> None:
ent_reg.async_update_entity(entity_entry.entity_id, new_entity_id=desired_id)


def _cleanup_stale_ev_entities(
hass: HomeAssistant,
entry: ConfigEntry,
coordinator: AlphaESSDataUpdateCoordinator,
) -> None:
"""Remove stale EV entities that are no longer supported for each inverter.

This is a one-time migration helper to remove old EV entities from the
registry when the latest coordinator data indicates they should not exist.
"""
ent_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(ent_reg, entry.entry_id)
prefix = f"{entry.entry_id}_"

connector_name_to_key = {
AlphaESSNames.ElectricVehiclePowerOne.value: AlphaESSNames.ElectricVehiclePowerOne,
AlphaESSNames.ElectricVehiclePowerTwo.value: AlphaESSNames.ElectricVehiclePowerTwo,
AlphaESSNames.ElectricVehiclePowerThree.value: AlphaESSNames.ElectricVehiclePowerThree,
AlphaESSNames.ElectricVehiclePowerFour.value: AlphaESSNames.ElectricVehiclePowerFour,
}

for entity_entry in entities:
uid = entity_entry.unique_id
if not uid or not uid.startswith(prefix) or " - " not in uid:
continue

remainder = uid[len(prefix):]
serial, entity_name = remainder.split(" - ", 1)
serial_data = coordinator.data.get(serial)
if not serial_data:
continue

ev_present = serial_data.get(AlphaESSNames.evchargersn) is not None
connector_one_present = serial_data.get(AlphaESSNames.ElectricVehiclePowerOne) is not None

should_remove = False
if entity_name == AlphaESSNames.pev.value:
should_remove = (not ev_present) or (not connector_one_present)
elif entity_name in connector_name_to_key:
connector_key = connector_name_to_key[entity_name]
should_remove = (not ev_present) or (serial_data.get(connector_key) is None)

if should_remove:
_LOGGER.info("Removing stale EV entity %s", entity_entry.entity_id)
ent_reg.async_remove(entity_entry.entity_id)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Alpha ESS from a config entry."""

Expand Down Expand Up @@ -206,6 +259,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

inverter_models = _build_inverter_model_list(entry)

scan_interval_seconds = entry.options.get(
CONF_SCAN_INTERVAL_SECONDS,
DEFAULT_SCAN_INTERVAL_SECONDS,
)
try:
scan_interval_seconds = int(scan_interval_seconds)
except (TypeError, ValueError):
scan_interval_seconds = DEFAULT_SCAN_INTERVAL_SECONDS

scan_interval_seconds = max(
MIN_SCAN_INTERVAL_SECONDS,
min(MAX_SCAN_INTERVAL_SECONDS, scan_interval_seconds),
)

await asyncio.sleep(1)

_coordinator = AlphaESSDataUpdateCoordinator(
Expand All @@ -214,6 +281,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
ip_address_map=ip_address_map,
inverter_models=inverter_models,
entry=entry,
scan_interval=timedelta(seconds=scan_interval_seconds),
)
await _coordinator.async_config_entry_first_refresh()

Expand Down Expand Up @@ -242,6 +310,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.config_entries.async_add_subentry(entry, ev_subentry)
existing_ev_serials.add(ev_sn)

# One-time cleanup: remove stale EV entities no longer supported by data.
# Only run when cloud data is available; in local-fallback mode EV keys are
# intentionally absent and we must not remove valid entities.
cloud_available = getattr(_coordinator, "cloud_available", True)
if cloud_available and not entry.options.get("_ev_entity_cleanup_done", False):
_cleanup_stale_ev_entities(hass, entry, _coordinator)
new_options = {**entry.options, "_ev_entity_cleanup_done": True}
hass.config_entries.async_update_entry(entry, options=new_options)

# One-time cleanup: remove old device associations from pre-subentry era.
# Old devices were registered with (config_entry_id, None) - no subentry.
# Remove them so platforms recreate devices with proper subentry associations.
Expand Down
152 changes: 152 additions & 0 deletions custom_components/alphaess/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Binary sensor platform for AlphaESS integration."""
from typing import List
import logging

from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import (
DOMAIN,
CONF_SERIAL_NUMBER,
SUBENTRY_TYPE_INVERTER,
SUBENTRY_TYPE_EV_CHARGER,
CONF_PARENT_INVERTER,
)
from .coordinator import AlphaESSDataUpdateCoordinator
from .sensorlist import EV_CHARGER_BINARY_SENSORS
from .device import build_ev_charger_device_info

Comment on lines +16 to +18
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

binary_sensor.py imports _build_ev_charger_device_info from sensor.py, but the leading underscore indicates it is a private helper. Since it’s now used across modules, consider moving it to a shared utility module (or renaming it without the underscore) to make the intended public usage explicit and avoid future refactors accidentally breaking the binary sensor platform.

Copilot uses AI. Check for mistakes.
_LOGGER: logging.Logger = logging.getLogger(__package__)


async def async_setup_entry(hass, entry, async_add_entities) -> None:
"""Set up EV charger readiness binary sensors."""
coordinator: AlphaESSDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]

ev_binary_supported_states = {
description.key: description for description in EV_CHARGER_BINARY_SENSORS
}

for subentry in entry.subentries.values():
if subentry.subentry_type == SUBENTRY_TYPE_INVERTER:
serial = subentry.data.get(CONF_SERIAL_NUMBER)
if not serial or serial not in coordinator.data:
continue

data = coordinator.data[serial]
ev_charger = data.get("EV Charger S/N")
if not ev_charger:
continue

ev_subentry_serials = {
sub.data.get(CONF_SERIAL_NUMBER)
for sub in entry.subentries.values()
if sub.subentry_type == SUBENTRY_TYPE_EV_CHARGER
}
if ev_charger in ev_subentry_serials:
continue

ev_device_info = build_ev_charger_device_info(data)
ev_entities: List[BinarySensorEntity] = []
for description in ev_binary_supported_states.values():
ev_entities.append(
AlphaEVReadinessBinarySensor(
coordinator,
serial,
entry,
description,
ev_serial=ev_charger,
device_info=ev_device_info,
)
)

if ev_entities:
async_add_entities(ev_entities, config_subentry_id=subentry.subentry_id)

elif subentry.subentry_type == SUBENTRY_TYPE_EV_CHARGER:
parent_serial = subentry.data.get(CONF_PARENT_INVERTER)
if not parent_serial or parent_serial not in coordinator.data:
continue

data = coordinator.data[parent_serial]
ev_charger = data.get("EV Charger S/N")
if not ev_charger:
continue

ev_device_info = build_ev_charger_device_info(data)
ev_entities: List[BinarySensorEntity] = []
for description in ev_binary_supported_states.values():
ev_entities.append(
AlphaEVReadinessBinarySensor(
coordinator,
parent_serial,
entry,
description,
ev_serial=ev_charger,
device_info=ev_device_info,
)
)

if ev_entities:
async_add_entities(ev_entities, config_subentry_id=subentry.subentry_id)


class AlphaEVReadinessBinarySensor(CoordinatorEntity, BinarySensorEntity):
"""Readiness sensor for EV charger start/stop commands."""

def __init__(self, coordinator, serial, config, description, ev_serial=None, device_info=None):
super().__init__(coordinator)
self._coordinator = coordinator
self._serial = serial
self._config = config
self._description = description
self._ev_serial = ev_serial
self._name = description.name
self._icon = description.icon
self._entity_category = description.entity_category
self._direction = description.direction

if device_info:
self._attr_device_info = device_info

@property
def is_on(self) -> bool | None:
"""Return readiness to execute EV command."""
if self._direction is None:
return None

if self._coordinator.get_ev_charger_status_raw(self._serial) is None:
return None

return self._coordinator.can_control_ev(self._serial, self._direction)

@property
def available(self) -> bool:
"""Readiness sensors require cloud EV data."""
if not self.coordinator.last_update_success:
return False
if not self._coordinator.cloud_available:
return False

serial_data = self._coordinator.data.get(self._serial, {})
return serial_data.get("EV Charger S/N") is not None

@property
def unique_id(self):
return f"{self._config.entry_id}_{self._serial} - {self._name}"

@property
def name(self):
return f"{self._name}"

@property
def suggested_object_id(self):
return f"{self._serial} {self._name}"

@property
def entity_category(self):
return self._entity_category

@property
def icon(self):
return self._icon
30 changes: 25 additions & 5 deletions custom_components/alphaess/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@
from typing import List
import logging
from homeassistant.components.button import ButtonEntity, ButtonDeviceClass
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN, ALPHA_POST_REQUEST_RESTRICTION, INVERTER_SETTING_BLACKLIST, CONF_SERIAL_NUMBER, \
SUBENTRY_TYPE_INVERTER, SUBENTRY_TYPE_EV_CHARGER, CONF_PARENT_INVERTER, CONF_DISABLE_NOTIFICATIONS
from .coordinator import AlphaESSDataUpdateCoordinator
from .sensorlist import SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS, EV_DISCHARGE_AND_CHARGE_BUTTONS
from .enums import AlphaESSNames
from .sensor import _build_inverter_device_info, _build_ev_charger_device_info
from .device import build_inverter_device_info, build_ev_charger_device_info

_LOGGER: logging.Logger = logging.getLogger(__package__)

Expand Down Expand Up @@ -47,7 +46,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:

data = coordinator.data[serial]
model = data.get("Model")
inverter_device_info = _build_inverter_device_info(coordinator, serial, data)
inverter_device_info = build_inverter_device_info(serial, data)

inverter_buttons: List[ButtonEntity] = []

Expand All @@ -70,7 +69,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
if sub.subentry_type == SUBENTRY_TYPE_EV_CHARGER
}
if ev_charger and ev_charger not in ev_subentry_serials:
ev_device_info = _build_ev_charger_device_info(coordinator, data)
ev_device_info = build_ev_charger_device_info(data)
for description in ev_charging_supported_states:
inverter_buttons.append(
AlphaESSBatteryButton(
Expand Down Expand Up @@ -99,7 +98,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
if not ev_charger:
continue

ev_device_info = _build_ev_charger_device_info(coordinator, data)
ev_device_info = build_ev_charger_device_info(data)
ev_buttons: List[ButtonEntity] = []
for description in ev_charging_supported_states:
ev_buttons.append(
Expand Down Expand Up @@ -168,7 +167,23 @@ def _notifications_disabled(self) -> bool:

async def async_press(self) -> None:

async def _notify_invalid_ev_command(action: str) -> None:
if not self._notifications_disabled:
await create_persistent_notification(
self.hass,
message=(
f"EV charger cannot {action.lower()} right now for {self._serial}. "
"Refresh and check EV Charger Status before retrying."
),
title=f"{self._serial} EV Charger",
)

if self._key == AlphaESSNames.stopcharging:
if not self._coordinator.can_control_ev(self._serial, 0):
_LOGGER.info("Stop charging ignored for %s due to EV state mismatch", self._serial)
await _notify_invalid_ev_command("Stop")
return

_LOGGER.info("Stopped charging")
self._movement_state = None
await self._coordinator.control_ev(self._serial, self._ev_serial, 0)
Expand All @@ -179,6 +194,11 @@ async def async_press(self) -> None:
return

if self._key == AlphaESSNames.startcharging:
if not self._coordinator.can_control_ev(self._serial, 1):
_LOGGER.info("Start charging ignored for %s due to EV state mismatch", self._serial)
await _notify_invalid_ev_command("Start")
return

_LOGGER.info("started charging")
self._movement_state = None
await self._coordinator.control_ev(self._serial, self._ev_serial, 1)
Expand Down
Loading
Loading