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.
387 lines
17 KiB
Python
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)
|