Files
bmspy/bmspy/ecoflow_river2.py
T
tim 161ae7ee65 Wait for the required EcoFlow reports in each collection
A Bluetooth collection listened for a fixed 10 seconds and returned the data that arrived. If a required report was late, the snapshot missed shared measurements without notice.

Each decoder now tracks the reports behind the shared measurements. A collection listens for 10 seconds, then until those reports arrive, for at most 30 seconds in total. The new ef_missing_measurements field names the reports that did not arrive. The server log shows one line each time that list changes.

The diagnostic tool now uses the River 2 heartbeat list from the decoder.
2026-10-10 09:03:35 +02:00

387 lines
17 KiB
Python

"""River 2 binary layouts, numeric decoding, and measurement interpretation.
Protocol sources: https://github.com/rabits/ef-ble-reverse and
https://github.com/rabits/ha-ef-ble. No Bluetooth I/O.
"""
import math
import struct
from bmspy.classes import BMSField, BMSInfoField, BMSMultiField, BMSScalarField
from bmspy.ecoflow_proto import FieldDef
from bmspy.ecoflow_snapshot import EcoFlowSnapshot
SERIAL_PREFIXES = ("R601", "R603")
# ── River 2 — struct layouts ───────────────────────────────────────────────────
_PD_FIELDS: list[FieldDef] = [
FieldDef("model", "B"),
FieldDef("err_code", "4s", skip=True),
FieldDef("sys_ver", "4s", skip=True),
FieldDef("wifi_ver", "4s", skip=True),
FieldDef("wifi_auto_rcvy", "B"),
FieldDef("soc", "B", "%"),
FieldDef("watts_out_sum", "H", "W"),
FieldDef("watts_in_sum", "H", "W"),
FieldDef("remain_time", "i", "min"),
FieldDef("beep_mode", "B"),
FieldDef("dc_out_state", "B"),
FieldDef("usb1_watts", "B", "W"),
FieldDef("usb2_watts", "B", "W"),
FieldDef("qc_usb1_watts", "B", "W"),
FieldDef("qc_usb2_watts", "B", "W"),
FieldDef("typec1_watts", "B", "W"),
FieldDef("typec2_watts", "B", "W"),
FieldDef("typec1_temp", "B", "°C"),
FieldDef("typec2_temp", "B", "°C"),
FieldDef("car_state", "B"),
FieldDef("car_watts", "B", "W"),
FieldDef("car_temp", "B", "°C"),
FieldDef("standby_min", "H", "min"),
FieldDef("lcd_off_sec", "H", "s"),
FieldDef("lcd_brightness", "B"),
FieldDef("chg_power_dc", "I", "Wh"),
FieldDef("chg_sun_power", "I", "Wh"),
FieldDef("chg_power_ac", "I", "Wh"),
FieldDef("dsg_power_dc", "I", "Wh"),
FieldDef("dsg_power_ac", "I", "Wh"),
FieldDef("usb_used_time", "I", "s"),
FieldDef("usbqc_used_time", "I", "s"),
FieldDef("typec_used_time", "I", "s"),
FieldDef("car_used_time", "I", "s"),
FieldDef("inv_used_time", "I", "s"),
FieldDef("dc_in_used_time", "I", "s"),
FieldDef("mppt_used_time", "I", "s"),
FieldDef("reserved", "2s", skip=True),
FieldDef("ext_rj45_port", "B"),
FieldDef("ext_3p8_port", "B"),
FieldDef("ext_4p8_port", "B"),
FieldDef("sys_chg_dsg_state", "B"),
FieldDef("wifi_rssi", "B"),
FieldDef("wireless_watts", "B", "W"),
FieldDef("screen_state", "14s", skip=True),
FieldDef("ac_auto_config", "B"),
FieldDef("min_ac_out_soc", "B", "%"),
FieldDef("ac_out_pause", "B"),
FieldDef("watth_is_config", "B"),
FieldDef("backup_soc", "B", "%"),
FieldDef("hysteresis_add", "B"),
FieldDef("relay_switch_count", "I"),
]
# R601 captures report pack voltage/current in whole V/A, unlike the
# cell voltages and current limits below, which remain in mV/mA.
# Negative pack current is a signed 32-bit value (discharging).
_EMS_FIELDS: list[FieldDef] = [
FieldDef("chg_state", "B"),
FieldDef("chg_cmd", "B"),
FieldDef("dsg_cmd", "B"),
FieldDef("chg_vol", "I", "V"),
FieldDef("chg_amp", "i", "A"),
FieldDef("fan_level", "B"),
FieldDef("max_charge_soc", "B", "%"),
FieldDef("bms_model", "B"),
FieldDef("lcd_show_soc", "B", "%"),
FieldDef("open_ups_flag", "B"),
FieldDef("bms_warning_state", "B"),
FieldDef("chg_remain_time", "I", "min"),
FieldDef("dsg_remain_time", "I", "min"),
FieldDef("ems_is_normal_flag", "B"),
FieldDef("f32_lcd_show_soc", "f", "%"),
FieldDef("bms_is_connt", "3s", skip=True),
FieldDef("max_available_num", "B"),
FieldDef("open_bms_idx", "B"),
FieldDef("para_vol_min", "I", "mV"),
FieldDef("para_vol_max", "I", "mV"),
FieldDef("min_dsg_soc", "B", "%"),
FieldDef("open_oil_eb_soc", "B", "%"),
FieldDef("close_oil_eb_soc", "B", "%"),
]
_BMS_FIELDS: list[FieldDef] = [
FieldDef("num", "B"),
FieldDef("type", "B"),
FieldDef("cell_id", "B"),
FieldDef("err_code", "I"),
FieldDef("sys_ver", "I"),
FieldDef("soc", "B", "%"),
FieldDef("vol", "I", "V"),
FieldDef("amp", "i", "A"),
FieldDef("temp", "B", "°C"),
FieldDef("open_bms_idx", "B"),
FieldDef("design_cap", "I", "mAh"),
FieldDef("remain_cap", "I", "mAh"),
FieldDef("full_cap", "I", "mAh"),
FieldDef("cycles", "I"),
FieldDef("soh", "B", "%"),
FieldDef("max_cell_vol", "H", "mV"),
FieldDef("min_cell_vol", "H", "mV"),
FieldDef("max_cell_temp", "B", "°C"),
FieldDef("min_cell_temp", "B", "°C"),
FieldDef("max_mos_temp", "B", "°C"),
FieldDef("min_mos_temp", "B", "°C"),
FieldDef("bms_fault", "B"),
FieldDef("bq_sys_stat_reg","B"),
FieldDef("tag_chg_amp", "I", "mA"),
FieldDef("f32_show_soc", "f", "%"),
FieldDef("input_watts", "I", "W"),
FieldDef("output_watts", "I", "W"),
FieldDef("remain_time", "I", "min"),
]
_INV_RIVER_FIELDS: list[FieldDef] = [
FieldDef("err_code", "I"),
FieldDef("sys_ver", "I"),
FieldDef("charger_type", "B"),
FieldDef("input_watts", "H", "W"),
FieldDef("output_watts", "H", "W"),
FieldDef("inv_type", "B"),
FieldDef("inv_out_vol", "I", "mV"),
FieldDef("inv_out_amp", "I", "mA"),
FieldDef("inv_out_freq", "B", "Hz"),
FieldDef("inv_in_vol", "I", "mV"),
FieldDef("inv_in_amp", "I", "mA"),
FieldDef("inv_in_freq", "B", "Hz"),
FieldDef("out_temp", "H", "°C"),
FieldDef("dc_in_vol", "I", "mV"),
FieldDef("dc_in_amp", "I", "mA"),
FieldDef("in_temp", "H", "°C"),
FieldDef("fan_state", "B"),
FieldDef("cfg_ac_enabled", "B"),
FieldDef("cfg_ac_xboost", "B"),
# These INV configuration values are not validated physical units.
# In particular, R601 reports cfg_ac_out_freq=2 with a measured 50 Hz.
FieldDef("cfg_ac_out_voltage", "I"),
FieldDef("cfg_ac_out_freq", "B"),
FieldDef("cfg_ac_chg_mode_flg","B"),
FieldDef("cfg_standby_min", "H", "min"),
FieldDef("cfg_fan_mode", "B"),
FieldDef("ac_auto_config", "B"),
]
_MPPT_MR330_FIELDS: list[FieldDef] = [
FieldDef("fault_code", "I"),
FieldDef("sw_ver", "4s", skip=True),
FieldDef("in_vol", "I", "mV"),
FieldDef("in_amp", "I", "mA"),
FieldDef("in_watts", "H", "W"),
FieldDef("out_vol", "I", "mV"),
FieldDef("out_amp", "I", "mA"),
FieldDef("out_watts", "H", "W"),
FieldDef("mppt_temp", "h", "°C"),
FieldDef("xt60_chg_type", "B"),
FieldDef("cfg_chg_type", "B"),
FieldDef("chg_type", "B"),
FieldDef("chg_state", "B"),
FieldDef("dcdc_12v_vol", "I", "mV"),
FieldDef("dcdc_12v_amp", "I", "mA"),
FieldDef("dcdc_12v_watts", "H", "W"),
FieldDef("car_out_vol", "I", "mV"),
FieldDef("car_out_amp", "I", "mA"),
FieldDef("car_out_watts", "H", "W"),
FieldDef("car_temp", "h", "°C"),
FieldDef("car_state", "B"),
FieldDef("dc24v_temp", "h", "°C"),
FieldDef("dc24v_state", "B"),
FieldDef("chg_pause_flag", "B"),
FieldDef("cfg_dc_chg_current", "I", "mA"),
FieldDef("beep_state", "B"),
FieldDef("cfg_ac_enabled", "B"),
FieldDef("cfg_ac_xboost", "B"),
FieldDef("cfg_ac_out_voltage", "I", "V"),
FieldDef("cfg_ac_out_freq", "B", "Hz"),
FieldDef("cfg_chg_watts", "H", "W"),
FieldDef("ac_standby_mins", "H", "min"),
FieldDef("discharge_type", "B"),
FieldDef("car_standby_mins", "H", "min"),
FieldDef("power_standby_mins", "H", "min"),
FieldDef("screen_standby_mins", "H", "min"),
FieldDef("pay_flag", "H"),
FieldDef("reserved", "8s", skip=True),
]
MESSAGE_DEFINITIONS = {
(0x02, 0x20, 0x02): ("PD heartbeat", _PD_FIELDS, "struct"),
(0x03, 0x20, 0x02): ("EMS heartbeat", _EMS_FIELDS, "struct"),
(0x03, 0x20, 0x32): ("BMS heartbeat", _BMS_FIELDS, "struct"),
(0x04, 0x20, 0x02): ("INV heartbeat", _INV_RIVER_FIELDS, "struct"),
(0x05, 0x20, 0x02): ("MPPT heartbeat", _MPPT_MR330_FIELDS, "struct"),
}
_CORE_MINIMUM_LENGTHS = {
(0x02, 0x20, 0x02): 23, # PD through remain_time
(0x03, 0x20, 0x02): 25, # EMS through dsg_remain_time
(0x03, 0x20, 0x32): 21, # BMS through temp
}
# Heartbeats behind the shared measurements, by raw packet key.
# PD: total power; EMS: time estimates; BMS: battery temperature and direction.
REQUIRED_HEARTBEATS = {
"ef_pkt_02_20_02": "PD",
"ef_pkt_03_20_02": "EMS",
"ef_pkt_03_20_32": "BMS",
}
_PROTOCOL_NAMESPACES = {
(0x02, 0x20, 0x02): "ef_river2_pd",
(0x03, 0x20, 0x02): "ef_river2_ems",
(0x03, 0x20, 0x32): "ef_river2_bms",
(0x04, 0x20, 0x02): "ef_river2_inv",
(0x05, 0x20, 0x02): "ef_river2_mppt",
}
def _record_heartbeat_fields(
snapshot: EcoFlowSnapshot, src: int, cmd_set: int, cmd_id: int, payload: bytes,
) -> None:
"""Retain complete fields from every known heartbeat, including INV/MPPT."""
key = (src, cmd_set, cmd_id)
if key not in MESSAGE_DEFINITIONS or not payload:
return
label, definitions, _ = MESSAGE_DEFINITIONS[key]
namespace = _PROTOCOL_NAMESPACES[key]
minimum = _CORE_MINIMUM_LENGTHS.get(key, struct.calcsize("<" + definitions[0].fmt))
if len(payload) < minimum:
snapshot.record_protocol_error(namespace, f"{label}: payload too short ({len(payload)} < {minimum})")
return
fields: dict[str, BMSField] = {}
offset = 0
for definition in definitions:
size = struct.calcsize("<" + definition.fmt)
if offset + size > len(payload):
break
value = struct.unpack_from("<" + definition.fmt, payload, offset)[0]
offset += size
help = f"River 2 {label}: {definition.name} (reported protocol value)"
if isinstance(value, bytes):
fields[definition.name] = BMSInfoField(help=help + "; encoded bytes", info="0x" + value.hex())
else:
if isinstance(value, float) and not math.isfinite(value):
snapshot.record_protocol_error(namespace, f"{label}.{definition.name}: non-finite value")
return
value = value * definition.scale if definition.scale != 1 else value
fields[definition.name] = BMSScalarField(
help=help, raw_value=value, value=str(value), units=definition.unit or None,
)
if offset < len(payload):
fields["trailing_bytes"] = BMSInfoField(
help=f"River 2 {label}: undecoded trailing bytes", info="0x" + payload[offset:].hex(),
)
snapshot.record_protocol_fields(namespace, fields)
def decode_river2_payload(
src: int,
cmd_set: int,
cmd_id: int,
payload: bytes,
) -> dict[str, int | float] | None:
"""Return numeric River 2 fields without parsing the diagnostic text.
Firmware versions can omit trailing fields. Only complete fields are
returned, and unknown trailing bytes are ignored. A short core heartbeat
is rejected rather than treated as a partial measurement.
"""
key = (src, cmd_set, cmd_id)
minimum = _CORE_MINIMUM_LENGTHS.get(key)
if minimum is None or len(payload) < minimum:
return None
_, fields, _ = MESSAGE_DEFINITIONS[key]
values: dict[str, int | float] = {}
offset = 0
for field in fields:
size = struct.calcsize("<" + field.fmt)
if offset + size > len(payload):
break
value = struct.unpack_from("<" + field.fmt, payload, offset)[0]
offset += size
if not field.skip and isinstance(value, (int, float)):
values[field.name] = value * field.scale if field.scale != 1 else value
return values
class River2Decoder:
"""Apply model measurements while retaining per-connection source priorities."""
def __init__(self) -> None:
self.reset()
def reset(self) -> None:
"""Reset source priorities before a new connection, not the snapshot."""
self._soc_priority = 0
self._received: set[str] = set()
def missing(self) -> set[str]:
"""Name the required heartbeats that this connection has not received."""
return {
f"{label} heartbeat" for key, label in REQUIRED_HEARTBEATS.items()
if key not in self._received
}
def update(
self, snapshot: EcoFlowSnapshot, src: int, cmd_set: int, cmd_id: int, payload: bytes,
) -> None:
_record_heartbeat_fields(snapshot, src, cmd_set, cmd_id, payload)
values = decode_river2_payload(src, cmd_set, cmd_id, payload)
if values is None:
return
# Receipt counts, not the values: an unavailable estimate is still a report.
self._received.add(f"ef_pkt_{src:02x}_{cmd_set:02x}_{cmd_id:02x}")
def scalar(name: str, help: str, value: int | float | bool, units: str | None = None):
setattr(snapshot, name, BMSScalarField(
help=help, raw_value=value, value=str(value), units=units,
))
def charge_ratio(soc: int | float, priority: int) -> None:
# Prefer BMS float > EMS float > integer for this connection.
# Lower-precision heartbeats must not overwrite a precise value
# just because their packets arrive later. Same-source updates
# still replace the previous reading as charge changes.
if math.isfinite(soc) and 0 <= soc <= 100 and priority >= self._soc_priority:
scalar("bms_capacity_charge_ratio", "Battery Charge", soc / 100)
self._soc_priority = priority
def remaining(name: str, help: str, value: int | float):
# 5999 (99h59m) appears for inactive estimates in R601 captures.
# Treat it as unavailable/capped, not an exact duration. Raw
# packet diagnostics retain the value for further investigation.
if value < 0 or value in (5999, 0xFFFFFFFF):
setattr(snapshot, name, None)
else:
scalar(name, help, value, "min")
if src == 0x02: # Total device power, not just battery power.
charge_ratio(values["soc"], priority=1)
# Float, as on River 3: one measurement must keep one type, or
# InfluxDB rejects the other backend's points.
scalar("ef_input_power_watts", "Total Input Power", float(values["watts_in_sum"]), "W")
scalar("ef_output_power_watts", "Total Output Power", float(values["watts_out_sum"]), "W")
remaining("ef_remaining_time_minutes", "Remaining Time", values["remain_time"])
elif cmd_id == 0x02: # EMS has separate charge/discharge estimates.
if "f32_lcd_show_soc" in values:
charge_ratio(values["f32_lcd_show_soc"], priority=2)
scalar("ef_charge_state", "Charge State (device code)", values["chg_state"])
remaining("ef_charge_remaining_time_minutes", "Time to Charge", values["chg_remain_time"])
remaining("ef_discharge_remaining_time_minutes", "Time to Discharge", values["dsg_remain_time"])
else: # BMS battery temperature and battery-side power flow.
if "f32_show_soc" in values:
charge_ratio(values["f32_show_soc"], priority=3)
else:
charge_ratio(values["soc"], priority=1)
temp = float(values["temp"]) # Float, as from the JBD sensors.
snapshot.bms_temperature_celsius = BMSMultiField(
help="Battery Temperature", label="sensor",
raw_values={1: temp}, values={1: str(temp)}, units="°C",
)
# Do not infer battery charging from AC input: bypass can power
# a load without charging the battery.
for field_name, power_name, help in (
("bms_charge_is_charging", "input_watts", "Battery Charging"),
("bms_charge_is_discharging", "output_watts", "Battery Discharging"),
):
if power_name in values:
scalar(field_name, help, values[power_name] > 0)