Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
82 changes: 57 additions & 25 deletions custom_components/ocpp/chargepoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,8 +646,13 @@ def get_authorization_status(self, id_tag):
)
return auth_status

def process_phases(self, data: list[MeasurandValue], connector_id: int | None = 0):
"""Process phase data from meter values."""
def process_phases(self, data: list[MeasurandValue], connector_id: int = 0):
"""Process phase data from meter values.

- Voltage: average (L1-N/L2-N/L3-N or L-L / √3).
- Current.*: average of L1/L2/L3 (ignore N).
- Other (e.g. Power.*): sum of L1/L2/L3 (ignore N).
"""
# For single-connector chargers, use connector 1.
n_connectors = getattr(self, CONF_NUM_CONNECTORS, DEFAULT_NUM_CONNECTORS) or 1
if connector_id in (None, 0):
Expand Down Expand Up @@ -684,52 +689,79 @@ def average_of_nonzero(values):
context
)

line_phases = [Phase.l1.value, Phase.l2.value, Phase.l3.value, Phase.n.value]
line_phases_all = [
Phase.l1.value,
Phase.l2.value,
Phase.l3.value,
Phase.n.value,
]
phases_l123 = [Phase.l1.value, Phase.l2.value, Phase.l3.value]
line_to_neutral_phases = [Phase.l1_n.value, Phase.l2_n.value, Phase.l3_n.value]
line_to_line_phases = [Phase.l1_l2.value, Phase.l2_l3.value, Phase.l3_l1.value]

def _avg_l123(phase_info: dict) -> float:
return average_of_nonzero(
[phase_info.get(phase, 0.0) for phase in phases_l123]
)

def _sum_l123(phase_info: dict) -> float:
return sum(phase_info.get(phase, 0.0) for phase in phases_l123)

for metric, phase_info in measurand_data.items():
metric_value = None
mname = str(metric)

if metric in [Measurand.voltage.value]:
if not phase_info.keys().isdisjoint(line_to_neutral_phases):
# Line to neutral voltages are averaged
metric_value = average_of_nonzero(
[phase_info.get(phase, 0) for phase in line_to_neutral_phases]
[phase_info.get(phase, 0.0) for phase in line_to_neutral_phases]
)
elif not phase_info.keys().isdisjoint(line_to_line_phases):
# Line to line voltages are averaged and converted to line to neutral
metric_value = average_of_nonzero(
[phase_info.get(phase, 0) for phase in line_to_line_phases]
[phase_info.get(phase, 0.0) for phase in line_to_line_phases]
) / sqrt(3)
elif not phase_info.keys().isdisjoint(line_phases):
elif not phase_info.keys().isdisjoint(line_phases_all):
# Workaround for chargers that don't follow engineering convention
# Assumes voltages are line to neutral
metric_value = average_of_nonzero(
[phase_info.get(phase, 0) for phase in line_phases]
)
metric_value = _avg_l123(phase_info)

else:
if not phase_info.keys().isdisjoint(line_phases):
metric_value = sum(
phase_info.get(phase, 0) for phase in line_phases
)
elif not phase_info.keys().isdisjoint(line_to_neutral_phases):
# Workaround for some chargers that erroneously use line to neutral for current
metric_value = sum(
phase_info.get(phase, 0) for phase in line_to_neutral_phases
)
is_current = mname.lower().startswith("current")
if is_current:
# Current.* shown per phase -> avg of L1/L2/L3, ignore N
if not phase_info.keys().isdisjoint(phases_l123):
metric_value = _avg_l123(phase_info)
elif not phase_info.keys().isdisjoint(line_to_neutral_phases):
# Workaround for some chargers that erroneously use line to neutral for current
metric_value = average_of_nonzero(
[
phase_info.get(phase, 0.0)
for phase in line_to_neutral_phases
]
)
else:
# Other (e.g. Power.*): total is sum over phases
if not phase_info.keys().isdisjoint(phases_l123):
metric_value = _sum_l123(phase_info)
elif not phase_info.keys().isdisjoint(line_to_neutral_phases):
metric_value = sum(
phase_info.get(phase, 0.0)
for phase in line_to_neutral_phases
)

if metric_value is not None:
metric_unit = phase_info.get(om.unit.value)
m = self._metrics[(target_cid, metric)]
if metric_unit == DEFAULT_POWER_UNIT:
m.value = metric_value / 1000
m.unit = HA_POWER_UNIT
self._metrics[(target_cid, metric)].value = metric_value / 1000
self._metrics[(target_cid, metric)].unit = HA_POWER_UNIT
elif metric_unit == DEFAULT_ENERGY_UNIT:
m.value = metric_value / 1000
m.unit = HA_ENERGY_UNIT
self._metrics[(target_cid, metric)].value = metric_value / 1000
self._metrics[(target_cid, metric)].unit = HA_ENERGY_UNIT
else:
m.value = metric_value
m.unit = metric_unit
self._metrics[(target_cid, metric)].value = metric_value
self._metrics[(target_cid, metric)].unit = metric_unit

@staticmethod
def get_energy_kwh(measurand_value: MeasurandValue) -> float:
Expand Down
136 changes: 87 additions & 49 deletions custom_components/ocpp/ocppv16.py
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,7 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
# does not have values either, generate new ones.
if self._metrics[(connector_id, csess.meter_start.value)].value is None:
restored = self.get_ha_metric(csess.meter_start.value, connector_id)
if restored is None:
if restored is None and not tx_has_id:
restored = self._metrics[(connector_id, DEFAULT_MEASURAND)].value
else:
try:
Expand Down Expand Up @@ -800,47 +800,61 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
)
meter_values.append(measurands)

# Write main meter value (EAIR) to connector 0 if this message is missing transactionId
if not tx_has_id:
for bucket in meter_values:
for item in bucket:
measurand = item.measurand or DEFAULT_MEASURAND
if measurand == DEFAULT_MEASURAND:
eair_kwh = cp.get_energy_kwh(item) # Wh→kWh if necessary
# Aggregate (connector 0) carries the latest main meter value
self._metrics[(0, DEFAULT_MEASURAND)].value = eair_kwh
self._metrics[(0, DEFAULT_MEASURAND)].unit = HA_ENERGY_UNIT
if item.location is not None:
self._metrics[(0, DEFAULT_MEASURAND)].extra_attr[
om.location.value
] = item.location
if item.context is not None:
self._metrics[(0, DEFAULT_MEASURAND)].extra_attr[
om.context.value
] = item.context
else:
# Update EAIR on the specific connector during active transactions as well
for bucket in meter_values:
for item in bucket:
measurand = item.measurand or DEFAULT_MEASURAND
if measurand == DEFAULT_MEASURAND:
eair_kwh = cp.get_energy_kwh(item)
self._metrics[
(connector_id, DEFAULT_MEASURAND)
].value = eair_kwh
self._metrics[
(connector_id, DEFAULT_MEASURAND)
].unit = HA_ENERGY_UNIT
if item.location is not None:
self._metrics[(connector_id, DEFAULT_MEASURAND)].extra_attr[
om.location.value
] = item.location
if item.context is not None:
self._metrics[(connector_id, DEFAULT_MEASURAND)].extra_attr[
om.context.value
] = item.context
# Robust EAIR update:
# - choose the best candidate per bucket with context prio
# - ignore Transaction.Begin for EAIR
# - never decrease
def _ctx_priority(ctx: str | None) -> int:
if ctx == "Transaction.End":
return 3
if ctx == "Sample.Periodic":
return 2
if ctx == "Sample.Clock":
return 1
return 0 # other/None

target_cid = 0 if not tx_has_id else connector_id
for bucket in meter_values:
candidates: list[tuple[int, float, MeasurandValue]] = []
for item in bucket:
measurand = item.measurand or DEFAULT_MEASURAND
if measurand != DEFAULT_MEASURAND:
continue
# Ignore Transaction.Begin for EAIR (can be 0 in the same batch)
if item.context == "Transaction.Begin":
continue
try:
val_kwh = float(cp.get_energy_kwh(item))
except Exception:
continue
if val_kwh < 0.0 or (val_kwh != val_kwh):
continue
candidates.append((_ctx_priority(item.context), val_kwh, item))

if not candidates:
continue

# Choose highest prio; same prio -> highest energy
_, best_val, best_item = max(candidates, key=lambda x: (x[0], x[1]))
m = self._metrics[(target_cid, DEFAULT_MEASURAND)]
m.value = best_val
m.unit = HA_ENERGY_UNIT
if best_item.location is not None:
m.extra_attr[om.location.value] = best_item.location
if best_item.context is not None:
m.extra_attr[om.context.value] = best_item.context

mv_wo_eair: list[list[MeasurandValue]] = []
for bucket in meter_values:
filtered = [
it
for it in bucket
if (it.measurand or DEFAULT_MEASURAND) != DEFAULT_MEASURAND
]
if filtered:
mv_wo_eair.append(filtered)

self.process_measurands(meter_values, transaction_matches, connector_id)
self.process_measurands(mv_wo_eair, transaction_matches, connector_id)

# Update session time if ongoing transaction
if active_tx_for_conn not in (None, 0):
Expand All @@ -855,23 +869,47 @@ def on_meter_values(self, connector_id: int, meter_value: dict, **kwargs):
(connector_id, csess.session_time.value)
].unit = UnitOfTime.MINUTES

# Update Energy.Session ONLY from EAIR in this message if txId exists and matches
# Update Energy.Session from "best" EAIR in this message if txId exists and matches
if tx_has_id and transaction_matches:
eair_kwh_in_msg: float | None = None
best_ctx_prio = -1
for bucket in meter_values:
for item in bucket:
measurand = item.measurand or DEFAULT_MEASURAND
if measurand == DEFAULT_MEASURAND:
eair_kwh_in_msg = cp.get_energy_kwh(item)
if measurand != DEFAULT_MEASURAND:
continue
if item.context == "Transaction.Begin":
continue
try:
val_kwh = float(cp.get_energy_kwh(item))
except Exception:
continue
if val_kwh < 0.0 or (val_kwh != val_kwh):
continue
pr = _ctx_priority(item.context)
if (pr > best_ctx_prio) or (
pr == best_ctx_prio
and (eair_kwh_in_msg is None or val_kwh > eair_kwh_in_msg)
):
best_ctx_prio = pr
eair_kwh_in_msg = val_kwh
if eair_kwh_in_msg is not None:
raw_start = self._metrics[(connector_id, csess.meter_start.value)].value
try:
meter_start_kwh = float(
self._metrics[(connector_id, csess.meter_start.value)].value
or 0.0
meter_start_kwh = (
float(raw_start) if raw_start is not None else None
)
except Exception:
meter_start_kwh = 0.0
session_kwh = max(0.0, eair_kwh_in_msg - meter_start_kwh)
meter_start_kwh = None

if meter_start_kwh is None:
self._metrics[
(connector_id, csess.meter_start.value)
].value = eair_kwh_in_msg
session_kwh = 0.0
else:
session_kwh = max(0.0, eair_kwh_in_msg - meter_start_kwh)

self._metrics[
(connector_id, csess.session_energy.value)
].value = session_kwh
Expand Down
Loading
Loading