Skip to content
90 changes: 48 additions & 42 deletions custom_components/evse_load_balancer/chargers/ocpp_charger.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ class OcppEntityMap:
"""

# Status entities from HAChargerStatuses
Status = "Status"
StatusConnector = "Status.Connector"
Status = "status"
StatusConnector = "status_connector"

# Current limit via number metric
# https://github.com/lbbrhzn/ocpp/blob/main/custom_components/ocpp/number.py
Expand Down Expand Up @@ -70,37 +70,58 @@ def is_charger_device(device: DeviceEntry) -> bool:
async def async_setup(self) -> None:
"""Set up the charger."""

def is_charging(self) -> bool:
"""
True when the OCPP status is 'Charging'.
(Only the actual charging state is counted; 'Preparing' or any 'Suspended*' states are NOT considered charging.)
"""
status = self._get_status()
return status == OcppStatusMap.Charging

Comment thread
juliusvaart marked this conversation as resolved.
def set_phase_mode(self, mode: PhaseMode, _phase: Phase | None = None) -> None:
"""Set the phase mode of the charger."""
if mode not in PhaseMode:
msg = "Invalid mode. Must be 'single' or 'multi'."
raise ValueError(msg)
# Phase mode setting is not currently implemented for OCPP chargers.
# This may require using the OCPP configuration or smart charging profiles.
raise ValueError("Invalid mode. Must be 'single' or 'multi'.")

def _get_ocpp_devid(self) -> str:
"""Extract OCPP charge point identity (devid) from device identifiers."""
for id_domain, ident in self.device_entry.identifiers:
if id_domain == CHARGER_DOMAIN_OCPP:
return ident
return getattr(self.device_entry, "name", None) or self.device_entry.id
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I see this was used in combination with get_ocpp_device to compose an entity_id, but there's already a _get_entity_id_by_key() method which searches through the entities related to the device and grab the one ending with the provided key (maximum_current) for example.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ah ok! This can go. I'm not the best when it comes to RTFM...


async def set_current_limit(self, limit: dict[Phase, int]) -> None:
"""
Set the current limit for the charger.
Set the current limit of the charger using the number.set_value service.

OCPP chargers typically support setting current limits through
the set_charge_rate service or smart charging profiles.
As OCPP may not support per-phase limits, we'll use the minimum value.
Uses the minimum value across all phases, since many chargers
do not support per-phase limits.
"""
min_current = min(limit.values())

# Build the entity_id for the "Maximum current" number entity
entity_id = f"number.{self._get_ocpp_devid()}_maximum_current"
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I was looking at the docs reviewing this PR when I stumbled upon this entry:

When the OCPP integration is added to your Home Assistant, you get a slider to control the maximum charge current named (or one per connector, if your charger has multiple connectors): number.<name_ocpp_charger>_maximum_current

While using this entity in your automation might seem logical, it could potentially lead to permanent damage to your charger in the long run. This entity controls the OCPP ChargePointMaxProfile, which configures the maximum power or current available for the entire charging station. This setting is typically written to non-volatile storage (like EEPROM or flash memory) to persist across reboots. Frequent writes to these types of memory can accelerate wear, potentially shortening the lifespan of your charger. Ten updates per day is no problem at all, 1 update per 10s could break your charger somewhere between 3 days and 3 years depending on the HW solution.

https://github.com/lbbrhzn/ocpp/blob/3ce65ab8351c1228f941c9cfa413d4de891ee1c3/docs/Charge_automation.md#adjusting-the-charge-current

I therefore don't recommend going this approach, as we're basically changing the chargers "global max current" setting (which is what get_max_current_limit() should return) and risk breaking chargers.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I suggest the following, leveraging ChargePointMaxProfile instead of TxProfile to set the entire Charge Points max (ampcontrol.io wrote a nice doc about OCPP)

async def set_current_limit(self, limit: dict[Phase, int]) -> None:
    """
    Set the current limit for the charger.

    OCPP chargers typically support setting current limits through
    the set_charge_rate service or smart charging profiles.
    As OCPP may not support per-phase limits, we'll use the minimum value.
    """
    min_current = min(limit.values())

    transaction_id = self._get_entity_id_by_key(OcppEntityMap.TransactionId)

    try:
        await self.hass.services.async_call(
            domain=CHARGER_DOMAIN_OCPP,
            service="set_charge_rate",
            service_data={
                "custom_profile": {
                    "transactionId": transaction_id,
                    "chargingProfileId": 1,
                    "stackLevel": 0,
                    "chargingProfilePurpose": "ChargePointMaxProfile",
                    "chargingProfileKind": "Relative",
                    "chargingSchedule": {
                        "chargingRateUnit": "A",
                        "chargingSchedulePeriod": [
                            {"startPeriod": 0, "limit": min_current}
                        ]
                    }
                },
                "conn_id": 1
            },
            blocking=True,
        )
    except (ValueError, RuntimeError, TimeoutError) as e:
        _LOGGER.warning(
            "Failed to set current limit for OCPP charger %s: %s",
            self.device_entry.id,
            e,
        )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'll try this! Totally missed this doc from the OCPP integration...


service_data = {
"entity_id": entity_id,
"value": min_current,
}

try:
await self.hass.services.async_call(
domain=CHARGER_DOMAIN_OCPP,
service="set_charge_rate",
service_data={
"device_id": self.device_entry.id,
"limit_amps": min_current,
},
domain="number",
service="set_value",
service_data=service_data,
blocking=True,
)
except (ValueError, RuntimeError, TimeoutError) as e:
_LOGGER.debug(
"Set current limit for charger %s to %s A via number.set_value",
self.device_entry.id,
min_current,
)
except Exception as e:
_LOGGER.warning(
"Failed to set current limit for OCPP charger %s: %s",
"Failed to set current limit for charger %s: %s",
self.device_entry.id,
e,
)
Expand Down Expand Up @@ -153,25 +174,14 @@ def has_synced_phase_limits(self) -> bool:
def _get_status(self) -> str | None:
"""Get the current status of the OCPP charger."""
# Try connector status first, then general status
status = None

try:
status = self._get_entity_state_by_key(
OcppEntityMap.StatusConnector,
)
except ValueError as e:
_LOGGER.debug(
"Failed to get status for OCPP charger by entity '%s': '%s'",
OcppEntityMap.StatusConnector,
e,
)

if status is None:
status = self._get_entity_state_by_key(
OcppEntityMap.Status,
)

return status
for key in (OcppEntityMap.StatusConnector, OcppEntityMap.Status):
try:
val = self._get_entity_state_by_key(key)
if val is not None:
return val
except ValueError:
continue
return None

def car_connected(self) -> bool:
"""Car is connected to the charger and ready to receive charge."""
Expand All @@ -185,20 +195,16 @@ def car_connected(self) -> bool:
OcppStatusMap.SuspendedEV,
OcppStatusMap.Finishing,
]

return status in connected_statuses

def can_charge(self) -> bool:
"""Return whether the car is connected and charging or accepting charge."""
status = self._get_status()

charging_statuses = [
return status in [
OcppStatusMap.Preparing,
OcppStatusMap.Charging,
OcppStatusMap.SuspendedEV,
]

return status in charging_statuses

async def async_unload(self) -> None:
"""Unload the OCPP charger."""
86 changes: 68 additions & 18 deletions custom_components/evse_load_balancer/ha_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@

_LOGGER = logging.getLogger(__name__)

from homeassistant.helpers import entity_registry as er
import re

def _normalize(s: str) -> str:
return re.sub(r'[^a-z0-9]', '', (s or '').lower())

class HaDevice:
"""Base class for HA devices."""
Expand Down Expand Up @@ -68,25 +73,70 @@ def _get_entity_id_by_unique_id(self, entity_unique_id: str) -> str | None:
)
return entity.entity_id

def _get_entity_id_by_key(self, entity_key: str) -> float | None:
"""
Get the entity ID for a given key.
def _get_entity_id_by_key(self, entity_key: str) -> str:
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I see you rewrote this method, but are not using it in your (latest) changes in the actual OcppCharger class. The AI code also feels a bit like a shot in the dark, and makes me wonder if we're not just declaring the wrong OcppEntityMap for StatusConnector and Status).

Since there's a bunch of char replacement (_ for . eg) I feel like we can probably just fix this by changing the entity map. Unfortunately I don't have an OCPP setup myself, so I can't see how the device exposes the entities.

Can you check in your Developer Tools if entities aren't just exposed as sensor.{device_id}_status_connector for example?

If that's the case we don't need these changes in here, but simply just a change in the OcppEntittyMap.

Copy link
Copy Markdown
Author

@juliusvaart juliusvaart Oct 6, 2025

Choose a reason for hiding this comment

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

The default with one socket is: sensor.charger_status_connector and sensor.charger_status so yes: sensor.{device_id}_status_connector ('charger' is the default).

From the OCPP manual:

Your charger exposes a connector status sensor:

Single-connector: sensor.<charger_id>_status_connector

Multi-connector: sensor.<charger_id>_connector_<connector_number>_status_connector

See: https://home-assistant-ocpp.readthedocs.io/en/latest/user-guide.html#understanding-status

wanted = _normalize(entity_key)
ent_reg = er.async_get(self.hass) # echte entity registry van HA

for entity_id, entry in ent_reg.entities.items():
if entry.device_id != self.device_entry.id:
continue

candidates = [
entry.unique_id or "",
entry.original_name or "",
entity_id.split(".")[-1],
]

for c in candidates:
if _normalize(c).endswith(wanted):
return entity_id

# fallback: check met "_" in plaats van "."
wanted_alt = _normalize(entity_key.replace(".", "_"))
if wanted_alt != wanted:
for entity_id, entry in ent_reg.entities.items():
if entry.device_id != self.device_entry.id:
continue

for c in [
entry.unique_id or "",
entry.original_name or "",
entity_id.split(".")[-1],
]:
if _normalize(c).endswith(wanted_alt):
return entity_id

raise ValueError(f"Entity with unique_id ending with '{entity_key}' not found")


def _get_entity_id_by_key(self, entity_key: str) -> str:
# bv "Status.Connector" -> "statusconnector"
wanted = _normalize(entity_key)

for entity_id, ent in self._entities_by_id.items():
candidates = [
getattr(ent, "unique_id", "") or "",
getattr(ent, "original_name", "") or "",
entity_id.split(".")[-1], # entity_id suffix
]
for c in candidates:
if _normalize(c).endswith(wanted):
return entity_id

# probeer ook met '.'->'_' (Status.Connector -> status_connector)
wanted_alt = _normalize(entity_key.replace(".", "_"))
if wanted_alt != wanted:
for entity_id, ent in self._entities_by_id.items():
for c in [
getattr(ent, "unique_id", "") or "",
getattr(ent, "original_name", "") or "",
entity_id.split(".")[-1],
]:
if _normalize(c).endswith(wanted_alt):
return entity_id

raise ValueError(f"Entity with unique_id ending with '{entity_key}' not found")

Looks up the entity by checking all entities associated with the device
whose unique_id end with the provided key.
"""
entity: RegistryEntry | None = next(
(e for e in self.entities if e.unique_id.endswith(f"_{entity_key}")),
None,
)
if entity is None:
msg = f"Entity with unique_id ending with '{entity_key}' not found"
raise ValueError(msg)
if entity.disabled:
_LOGGER.error(
"Required entity %s is disabled. Please enable it!", entity.entity_id
)
return entity.entity_id

def _get_entity_state(
self, entity_id: str, parser_fn: Callable | None = None
Expand Down
Loading