-
-
Notifications
You must be signed in to change notification settings - Fork 17
44 ocpp integration support #67
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
3a3c046
61e9cfa
ce93c7c
d8d6372
31a4ee2
ded0d3f
a04be3e
1554118
ca27bb5
3bc7ed8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||
| 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 | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see this was used in combination with
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
I therefore don't recommend going this approach, as we're basically changing the chargers "global max current" setting (which is what
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| ) | ||
|
|
@@ -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.""" | ||
|
|
@@ -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.""" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.""" | ||
|
|
@@ -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: | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Since there's a bunch of char replacement ( Can you check in your Developer Tools if entities aren't just exposed as If that's the case we don't need these changes in here, but simply just a change in the
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default with one socket is: From the OCPP manual: Your charger exposes a connector status sensor: 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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.