Skip to content

Commit c6702d0

Browse files
mgazzaclaudespringfall2008pre-commit-ci-lite[bot]
authored
fix(gateway): pin EV charger entity ids to the charge point id (#4489)
* fix(gateway): pin EV entity ids to the charge point id _ev_suffix() returned a bare "ev" when a single charger was visible and "ev_<id>" only when several were, so the suffix depended on how many chargers happened to be in one status message. Entity ids therefore moved under the user: a site with one charger produced sensor.predbat_gateway_ev_power, but the moment a second charger appeared — or the same charger was briefly seen twice across a reconnect — everything silently renamed to sensor.predbat_gateway_ev_<id>_power, orphaning the old entities and every dashboard and automation bound to them. Seen in production: one hub accumulated both a predbat_gateway_ev_536212_* set and a predbat_gateway_ev_* set for the same physical charger. Always identify the charger by the last 6 chars of its charge point id, falling back to "ev" only when the charger has not reported one yet (no BootNotification). Renames entities on single-charger sites from sensor.predbat_gateway_ev_* to sensor.predbat_gateway_ev_<id>_*. These entities only exist on PredBat Gateway hubs running an OCPP charger; at the time of writing that is two hubs (one of them a test rig), so no migration is warranted — but any dashboard bound to the unsuffixed names needs repointing, and the old entities are left orphaned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(gateway): harden EV entity naming and suppress stale offline telemetry Follow-up to the entity-id pinning, from an adversarial review of that change. - Slugify the charge point id before truncating. An OCPP chargePointSerialNumber is an arbitrary vendor string and may contain spaces, dots, slashes, dashes or non-ASCII, none of which are legal in a Home Assistant entity id, and nothing downstream sanitised it. `CP/AB-123` previously produced sensor.predbat_gateway_ev_ab-123_power, which HA rejects — while auto-config still pointed car_charging_* at that nonexistent namespace. - Suppress live-session fields while a charger is disconnected. Only the derived `connected` sensor was gated on ev.connected; session_active, power, session energy and the SoC fallback were published straight from the payload. Since car_charging_now is wired to session_active and PredBat plans whenever it is true, a disconnected charger carrying a stale session_active would schedule a charging slot for a charger that is not there. Gateway firmware now zeroes these, but PredBat and firmware version independently, so gate here too. - Prefer a connected charger in _register_ev_car() instead of blindly taking chargers[0]. The gateway now reports known-but-offline chargers rather than omitting them, so slot 0 can be a stale entry; a site with an old disconnected charger listed first would have bound all car_charging_* args to it and ignored the live one. Falls back to the first entry so a charger that is merely offline right now still registers. - Update docs/car-charging.md, which still listed all ten entities under the old unsuffixed names. Known limitation, documented in the docstring: a 6-char tail is not collision-proof — two chargers whose slugified ids share a tail map to one namespace. Identity stability was the priority; anything collision-proof either depends on which other chargers are present (the churn this fixes) or is an opaque digest. Tests: 269 pass, including slugification cases and assertions that a disconnected charger's session_active/power/session_energy are forced idle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Update car-charging.md for clarity on EV charger entities Clarified entity naming and registration details for GivEnergy Gateway OCPP EV charger in documentation. * [pre-commit.ci lite] apply automatic fixes --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent 85975ef commit c6702d0

4 files changed

Lines changed: 182 additions & 34 deletions

File tree

.cspell/custom-dictionary-workspace.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,10 @@ sigencloud
442442
sigenergy
443443
sigenstor
444444
Slee
445+
slugified
446+
slugifies
447+
slugify
448+
slugifying
445449
snakeviz
446450
socb
447451
socketloop

apps/predbat/gateway.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -946,20 +946,46 @@ def _inject_inverter_entities(self, inv, suffix):
946946
def _ev_suffix(ev, multi):
947947
"""Build the entity suffix for an EV charger.
948948
949-
Single charger (the v1 case) uses a stable "ev" suffix; with more than one
950-
charger present, disambiguate by the last 6 chars of the OCPP charge point id.
949+
Always identifies the charger by the last 6 chars of its OCPP charge point
950+
id, so a given charger keeps the same entity ids for its whole life.
951+
952+
This used to return a bare "ev" whenever only one charger was present, and
953+
only disambiguate when several were. That made the suffix a function of how
954+
many chargers happened to be visible in a single status message, so entity
955+
ids moved under the user: a site with one charger produced
956+
``sensor.predbat_gateway_ev_power``, but the moment a second charger
957+
appeared — or the same charger was briefly seen twice across a reconnect —
958+
everything silently renamed to ``sensor.predbat_gateway_ev_<id>_power``,
959+
orphaning the old entities and every dashboard and automation bound to
960+
them. Pinning to the charge point id removes that whole class of churn.
961+
962+
Falls back to "ev" only when the charger reports no id at all (a charger
963+
that has not completed its BootNotification yet).
964+
965+
The id is slugified first: an OCPP chargePointSerialNumber is an arbitrary
966+
vendor string and may contain spaces, dots, slashes, dashes or non-ASCII,
967+
none of which are legal in a Home Assistant entity id. Anything outside
968+
[a-z0-9_] is dropped, then the last 6 characters are taken — slugifying
969+
before truncating keeps the result stable and legal.
970+
971+
Note: 6 characters is not collision-proof. Two chargers whose slugified ids
972+
share a tail (or differ only by case) map to one entity namespace and the
973+
later one wins. Identity stability was the priority here — anything
974+
collision-proof either depends on the other chargers present (which is the
975+
churn this fixes) or is an opaque digest.
951976
952977
Args:
953978
ev: An EvCharger protobuf message.
954-
multi: True when more than one charger is present in the status.
979+
multi: Unused; retained for call-site compatibility. The suffix no
980+
longer depends on how many chargers are present.
955981
956982
Returns:
957-
str: The entity suffix (e.g. "ev" or "ev_b749").
983+
str: The entity suffix (e.g. "ev_b749", or "ev" when the id is unknown
984+
or slugifies to nothing).
958985
"""
959-
if not multi:
960-
return "ev"
961986
charge_point_id = ev.charge_point_id or ""
962-
return f"ev_{charge_point_id[-6:].lower()}" if charge_point_id else "ev"
987+
slug = "".join(c for c in charge_point_id.lower() if c.isascii() and (c.isalnum() or c == "_"))
988+
return f"ev_{slug[-6:]}" if slug else "ev"
963989

964990
@staticmethod
965991
def _ev_charge_rate_kw(ev):
@@ -994,11 +1020,21 @@ def _inject_ev_entities(self, status):
9941020
self.dashboard_item(f"binary_sensor.{pfx}_online", ev.connected, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_online", {}), app="gateway")
9951021
ev_car_connected = ev.connected and ev.status in {"Preparing", "Charging", "SuspendedEV", "SuspendedEVSE", "Finishing"}
9961022
self.dashboard_item(f"binary_sensor.{pfx}_connected", ev_car_connected, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_connected", {}), app="gateway")
997-
self.dashboard_item(f"binary_sensor.{pfx}_session_active", ev.session_active, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_session_active", {}), app="gateway")
1023+
# Live-session fields are only meaningful while the charger is connected.
1024+
# The gateway now reports known-but-offline chargers instead of omitting
1025+
# them, and older firmware can leave session_active/power/energy at their
1026+
# last values. car_charging_now is wired to session_active and PredBat
1027+
# plans whenever it is true, so an offline charger with a stale
1028+
# session_active would create a charging slot for a charger that is not
1029+
# there. Force them to their idle values rather than trusting the payload.
1030+
session_active = ev.connected and ev.session_active
1031+
power_w = ev.power_w if ev.connected else 0
1032+
session_energy_wh = ev.session_energy_wh if ev.connected else 0
1033+
self.dashboard_item(f"binary_sensor.{pfx}_session_active", session_active, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_session_active", {}), app="gateway")
9981034
if ev.status:
9991035
self.dashboard_item(f"sensor.{pfx}_status", ev.status, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_status", {}), app="gateway")
1000-
self.dashboard_item(f"sensor.{pfx}_power", ev.power_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_power", {}), app="gateway")
1001-
self.dashboard_item(f"sensor.{pfx}_session_energy", round(ev.session_energy_wh / 1000.0, 2), attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_session_energy", {}), app="gateway")
1036+
self.dashboard_item(f"sensor.{pfx}_power", power_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_power", {}), app="gateway")
1037+
self.dashboard_item(f"sensor.{pfx}_session_energy", round(session_energy_wh / 1000.0, 2), attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_session_energy", {}), app="gateway")
10021038
if ev.current_limit_a:
10031039
self.dashboard_item(f"sensor.{pfx}_current_limit", ev.current_limit_a, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_current_limit", {}), app="gateway")
10041040
if ev.soc_percent:
@@ -1011,7 +1047,9 @@ def _inject_ev_entities(self, status):
10111047
battery_size_kwh = float(battery_size_kwh)
10121048
except (ValueError, TypeError):
10131049
battery_size_kwh = 100.0
1014-
ev_soc = round(min((ev.session_energy_wh / 1000.0) / battery_size_kwh * 100.0, 100.0), 1)
1050+
# session_energy_wh is forced to 0 above while disconnected, so an
1051+
# offline charger estimates 0% rather than replaying the last session.
1052+
ev_soc = round(min((session_energy_wh / 1000.0) / battery_size_kwh * 100.0, 100.0), 1)
10151053
self.dashboard_item(f"sensor.{pfx}_soc", ev_soc, attributes=GATEWAY_ATTRIBUTE_TABLE.get("ev_soc", {}), app="gateway")
10161054
if ev.max_current_a:
10171055
self._ev_max_current[ev.charge_point_id or ""] = ev.max_current_a
@@ -1305,12 +1343,17 @@ def _register_ev_car(self, status):
13051343
if not self.gateway_evc_automatic:
13061344
return
13071345

1346+
# Prefer a connected charger. Gateway firmware now reports known-but-offline
1347+
# chargers with connected=false (previously it omitted them entirely), so
1348+
# slot 0 can be a stale entry — registering that as the car would wire
1349+
# car_charging_* to a charger that is not there. Fall back to the first
1350+
# entry so a charger that is merely offline right now still registers.
13081351
chargers = list(status.ev_chargers)
13091352
if not chargers:
13101353
return
13111354

13121355
# For now we overwrite the first charger only; multi-charger support needs work
1313-
ev = chargers[0]
1356+
ev = next((c for c in chargers if c.connected), chargers[0])
13141357
pfx = f"{self.prefix}_gateway_{self._ev_suffix(ev, multi=False)}"
13151358
self.set_arg("num_cars", 1)
13161359
# Entity-reference args (resolved by get_arg indirect lookup at fetch time).

apps/predbat/tests/test_gateway.py

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3945,7 +3945,7 @@ def test_ev_entities_published(self):
39453945
gw = self._make_gateway()
39463946
gw._inject_ev_entities(self._status_with_ev())
39473947

3948-
base = "predbat_gateway_ev"
3948+
base = "predbat_gateway_ev_3xb749"
39493949
assert gw._dashboard_calls[f"binary_sensor.{base}_online"][0] is True
39503950
assert gw._dashboard_calls[f"binary_sensor.{base}_connected"][0] is True # status="Charging" → car connected
39513951
assert gw._dashboard_calls[f"binary_sensor.{base}_session_active"][0] is True
@@ -3961,13 +3961,78 @@ def test_ev_entities_published(self):
39613961
# Derived charge-rate capability in kW: 32 A × 240 V / 1000
39623962
assert approx_equal(gw._dashboard_calls[f"sensor.{base}_charge_rate"][0], 7.68)
39633963

3964+
def test_ev_suffix_is_stable_regardless_of_charger_count(self):
3965+
"""A charger keeps the same entity ids whether or not others are present.
3966+
3967+
Regression: the suffix used to be a bare "ev" when a single charger was
3968+
visible and "ev_<id>" only when several were, so entity ids silently
3969+
renamed the moment a second charger appeared (or the same charger was
3970+
briefly seen twice across a reconnect), orphaning dashboards bound to them.
3971+
"""
3972+
from gateway import GatewayMQTT
3973+
3974+
ev = self._status_with_ev().ev_chargers[0]
3975+
assert GatewayMQTT._ev_suffix(ev, False) == "ev_3xb749"
3976+
assert GatewayMQTT._ev_suffix(ev, True) == "ev_3xb749"
3977+
3978+
def test_ev_suffix_falls_back_when_id_unknown(self):
3979+
"""A charger that has not sent its BootNotification yet still gets a suffix."""
3980+
from gateway import GatewayMQTT
3981+
3982+
ev = self._status_with_ev(charge_point_id="").ev_chargers[0]
3983+
assert GatewayMQTT._ev_suffix(ev, False) == "ev"
3984+
3985+
def test_disconnected_charger_reports_offline(self):
3986+
"""A charger the gateway reports as disconnected surfaces as fully idle.
3987+
3988+
The gateway now sends known-but-disconnected chargers with connected=false
3989+
instead of omitting them, so these entities must go False rather than
3990+
freezing at their last values (which is how a 2-day EV outage went unseen).
3991+
3992+
Critically this asserts the LIVE-SESSION fields too, not just online/connected:
3993+
car_charging_now is wired to session_active and PredBat plans a charging slot
3994+
whenever it is true, so a disconnected charger carrying a stale
3995+
session_active would schedule charging for a charger that is not there.
3996+
The fixture deliberately supplies session_active=True, power_w=7200 and
3997+
session_energy_wh=12400 alongside connected=False — the inconsistent payload
3998+
older firmware can emit.
3999+
"""
4000+
gw = self._make_gateway(battery_size=100)
4001+
gw._inject_ev_entities(self._status_with_ev(connected=False, status="Unavailable"))
4002+
4003+
base = "predbat_gateway_ev_3xb749"
4004+
assert gw._dashboard_calls[f"binary_sensor.{base}_online"][0] is False
4005+
assert gw._dashboard_calls[f"binary_sensor.{base}_connected"][0] is False
4006+
# Stale live-session values must be suppressed, not republished
4007+
assert gw._dashboard_calls[f"binary_sensor.{base}_session_active"][0] is False
4008+
assert gw._dashboard_calls[f"sensor.{base}_power"][0] == 0
4009+
assert gw._dashboard_calls[f"sensor.{base}_session_energy"][0] == 0
4010+
4011+
def test_suffix_slugifies_unsafe_charge_point_ids(self):
4012+
"""Charge point ids are vendor strings; entity ids must stay legal.
4013+
4014+
Spaces, dots, slashes, dashes and non-ASCII are not valid in a Home Assistant
4015+
entity id, and nothing downstream sanitises the value.
4016+
"""
4017+
from gateway import GatewayMQTT
4018+
4019+
for cp_id, expected in [
4020+
("CP/AB-123", "ev_pab123"), # punctuation dropped, last 6 kept
4021+
("cp 42.7", "ev_cp427"), # -> "cp427", shorter than 6
4022+
("ABC", "ev_abc"), # shorter than 6 -> whole id
4023+
("charge_point_Ω", "ev_point_"), # non-ASCII dropped, underscore kept
4024+
("///", "ev"), # slugifies to nothing -> fallback
4025+
]:
4026+
ev = self._status_with_ev(charge_point_id=cp_id).ev_chargers[0]
4027+
assert GatewayMQTT._ev_suffix(ev, False) == expected, cp_id
4028+
39644029
def test_ev_entity_attributes_from_table(self):
39654030
"""Published EV entities carry their GATEWAY_ATTRIBUTE_TABLE attributes."""
39664031
from gateway import GATEWAY_ATTRIBUTE_TABLE
39674032

39684033
gw = self._make_gateway()
39694034
gw._inject_ev_entities(self._status_with_ev())
3970-
_, attrs = gw._dashboard_calls["sensor.predbat_gateway_ev_power"]
4035+
_, attrs = gw._dashboard_calls["sensor.predbat_gateway_ev_3xb749_power"]
39714036
assert attrs == GATEWAY_ATTRIBUTE_TABLE["ev_power"]
39724037

39734038
def test_not_reported_fields_skipped(self):
@@ -3977,7 +4042,7 @@ def test_not_reported_fields_skipped(self):
39774042
status = self._status_with_ev(soc_percent=0, voltage_v=0, max_current_a=0, current_limit_a=0, eco_mode="", status="")
39784043
gw._inject_ev_entities(status)
39794044

3980-
base = "predbat_gateway_ev"
4045+
base = "predbat_gateway_ev_3xb749"
39814046
# soc is now always published — falls back to session_energy / battery_size * 100
39824047
assert approx_equal(gw._dashboard_calls[f"sensor.{base}_soc"][0], 12.4)
39834048
assert f"sensor.{base}_voltage" not in gw._dashboard_calls
@@ -3997,14 +4062,14 @@ def test_soc_fallback_uses_battery_size(self):
39974062
gw = self._make_gateway(battery_size=50)
39984063
# session_energy_wh=12400 → 12.4 kWh; battery_size=50 → 12.4/50*100 = 24.8%
39994064
gw._inject_ev_entities(self._status_with_ev(soc_percent=0))
4000-
assert approx_equal(gw._dashboard_calls["sensor.predbat_gateway_ev_soc"][0], 24.8)
4065+
assert approx_equal(gw._dashboard_calls["sensor.predbat_gateway_ev_3xb749_soc"][0], 24.8)
40014066

40024067
def test_charge_rate_uses_230v_when_voltage_missing(self):
40034068
"""With max current but no voltage, charge rate assumes 230 V."""
40044069
gw = self._make_gateway()
40054070
gw._inject_ev_entities(self._status_with_ev(max_current_a=16, voltage_v=0))
40064071
# 16 A × 230 V / 1000
4007-
assert approx_equal(gw._dashboard_calls["sensor.predbat_gateway_ev_charge_rate"][0], 3.68)
4072+
assert approx_equal(gw._dashboard_calls["sensor.predbat_gateway_ev_3xb749_charge_rate"][0], 3.68)
40084073

40094074
def test_no_chargers_publishes_nothing(self):
40104075
"""A status with no EV chargers publishes no EV entities."""
@@ -4067,20 +4132,52 @@ def _status_with_ev(self, charge_point_id="CP1", max_current_a=32, voltage_v=240
40674132
ev.voltage_v = voltage_v
40684133
return status
40694134

4135+
def test_registers_connected_charger_not_a_stale_slot(self):
4136+
"""A disconnected charger in slot 0 must not be registered over a connected one.
4137+
4138+
Gateway firmware now reports known-but-offline chargers with connected=false
4139+
(it used to omit them), so the first entry is no longer guaranteed to be live.
4140+
"""
4141+
status = pb.GatewayStatus()
4142+
stale = status.ev_chargers.add()
4143+
stale.connected = False
4144+
stale.charge_point_id = "CP0000"
4145+
live = status.ev_chargers.add()
4146+
live.connected = True
4147+
live.charge_point_id = "CP0001"
4148+
4149+
gw = self._make_gateway(ev_enable=True, num_cars=0)
4150+
gw._register_ev_car(status)
4151+
4152+
assert gw._args["car_charging_planned"] == ["binary_sensor.predbat_gateway_ev_cp0001_connected"]
4153+
4154+
def test_registers_offline_charger_when_none_connected(self):
4155+
"""A charger that is merely offline right now still registers, so it reappears."""
4156+
status = pb.GatewayStatus()
4157+
offline = status.ev_chargers.add()
4158+
offline.connected = False
4159+
offline.charge_point_id = "CP0002"
4160+
4161+
gw = self._make_gateway(ev_enable=True, num_cars=0)
4162+
gw._register_ev_car(status)
4163+
4164+
assert gw._args["num_cars"] == 1
4165+
assert gw._args["car_charging_planned"] == ["binary_sensor.predbat_gateway_ev_cp0002_connected"]
4166+
40704167
def test_registers_car_when_none_configured(self):
40714168
"""With the flag on and no existing cars, the charger is registered as car 1."""
40724169
gw = self._make_gateway(ev_enable=True, num_cars=0)
40734170
gw._register_ev_car(self._status_with_ev())
40744171

40754172
assert gw._args["num_cars"] == 1
4076-
assert gw._args["car_charging_planned"] == ["binary_sensor.predbat_gateway_ev_connected"]
4077-
assert gw._args["car_charging_now"] == ["binary_sensor.predbat_gateway_ev_session_active"]
4078-
assert gw._args["car_charging_soc"] == ["sensor.predbat_gateway_ev_soc"]
4173+
assert gw._args["car_charging_planned"] == ["binary_sensor.predbat_gateway_ev_cp1_connected"]
4174+
assert gw._args["car_charging_now"] == ["binary_sensor.predbat_gateway_ev_cp1_session_active"]
4175+
assert gw._args["car_charging_soc"] == ["sensor.predbat_gateway_ev_cp1_soc"]
40794176
# car_charging_rate is a UI config item — set via expose_config, not set_arg
40804177
assert "car_charging_rate" not in gw._args
40814178
gw.base.expose_config.assert_called_once_with("car_charging_rate", 7.68) # 32A * 240V / 1000
40824179
# Session energy sensor for subtracting EV load from history
4083-
assert gw._args["car_charging_energy"] == "sensor.predbat_gateway_ev_session_energy"
4180+
assert gw._args["car_charging_energy"] == "sensor.predbat_gateway_ev_cp1_session_energy"
40844181
# Battery size and target limit are left to the existing car_charging_* settings
40854182
assert "car_charging_battery_size" not in gw._args
40864183
assert "car_charging_limit" not in gw._args
@@ -4102,7 +4199,7 @@ def test_car_charging_now_set_when_not_controlling(self):
41024199
"""car_charging_now is wired to session_active when gateway_evc_control is False."""
41034200
gw = self._make_gateway(ev_enable=True, num_cars=0, evc_control=False)
41044201
gw._register_ev_car(self._status_with_ev())
4105-
assert gw._args["car_charging_now"] == ["binary_sensor.predbat_gateway_ev_session_active"]
4202+
assert gw._args["car_charging_now"] == ["binary_sensor.predbat_gateway_ev_cp1_session_active"]
41064203

41074204
def test_car_charging_now_omitted_when_controlling(self):
41084205
"""car_charging_now is not set when gateway_evc_control is True to prevent feedback loop."""

0 commit comments

Comments
 (0)