Add River 2 Bluetooth telemetry and diagnostics

Decode River 2 charge, temperature, power flow, and time estimates. Preserve precise charge readings across packet reordering and omit unavailable time estimates.

Require valid PD, EMS, and BMS core heartbeats for diagnostic completion. Report optional packets separately and support raw watch output with reliable disconnect cleanup. Use synthetic regression tests and keep captures, credentials, and external keydata private.

Device details come from command-line arguments or discovery. This commit retains Poetry; the separate packaging commit migrates to uv.
This commit is contained in:
tim
2026-10-08 14:02:45 +02:00
parent 90388d2800
commit 886991caf3
16 changed files with 4093 additions and 11 deletions
+19 -5
View File
@@ -45,6 +45,17 @@ def handle_registration(socket_path: str, client_name: str, debug: int = 0) -> d
return data
def _recv_exact(sock: socket.socket, length: int) -> bytes:
"""Read a complete frame, even when the stream splits it across receives."""
data = bytearray()
while len(data) < length:
chunk = sock.recv(length - len(data))
if not chunk:
raise OSError("Daemon closed the socket before the response was complete")
data.extend(chunk)
return bytes(data)
def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0) -> dict[str, Any]:
"""Send a JSON request to the daemon socket and return the decoded JSON response."""
response_data: dict[str, Any] = dict()
@@ -62,6 +73,8 @@ def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0)
debugger("Failed to connect to bmspy daemon")
else:
debugger("socket client: {}".format(msg))
sock.close()
raise
# Send request
if debug > 2:
@@ -81,13 +94,14 @@ def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0)
if debug > 4:
debugger("socket client: outgoing request: {}".format(request))
except Exception:
sock.close()
debugger("socket client ERROR: unable to encode request")
sys.exit(1)
sock.sendall(request)
# get length of expected json string
response = sock.recv(struct.calcsize("!I"))
try:
sock.sendall(request)
response = _recv_exact(sock, struct.calcsize("!I"))
length = struct.unpack("!I", response)[0]
if debug > 4:
debugger(
@@ -96,18 +110,18 @@ def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0)
)
)
# read length bytes
response = sock.recv(length)
response = _recv_exact(sock, length)
if debug > 3:
debugger("socket client: incoming response: {}".format(response))
response_data = json.loads(response)
except Exception:
debugger("socket client ERROR: unable to decode response")
sys.exit(1)
finally:
sock.close()
if debug > 2:
debugger("socket client: received {!r}".format(response_data))
sock.close()
return response_data
+1033
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
#
# Retrieve EcoFlow user ID by logging in with email and password.
#
import base64
import json
import sys
import urllib.request
import urllib.error
REGIONS = {
"EU": "api-e.ecoflow.com",
"US": "api-a.ecoflow.com",
"default": "api.ecoflow.com",
}
def get_user_id(email: str, password: str, region: str = "default") -> str:
base_url = REGIONS.get(region.upper(), REGIONS["default"])
url = f"https://{base_url}/auth/login"
payload = json.dumps({
"scene": "IOT_APP",
"appVersion": "1.0.0",
"password": base64.b64encode(password.encode()).decode(),
"oauth": {"bundleId": "com.ef.EcoFlow"},
"email": email,
"userType": "ECOFLOW",
}).encode()
req = urllib.request.Request(
url,
data=payload,
headers={"Accept": "application/json", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as e:
raise SystemExit(f"Login failed: HTTP {e.code} {e.reason}")
except urllib.error.URLError as e:
raise SystemExit(f"Connection error: {e.reason}")
if data.get("code") != "0":
msg = data.get("message", "unknown error")
raise SystemExit(f"Login failed: {msg}")
try:
return str(data["data"]["user"]["userId"])
except KeyError:
raise SystemExit(f"Unexpected response structure: {data}")
def main():
import argparse
import getpass
parser = argparse.ArgumentParser(
description="Retrieve EcoFlow user ID via the EcoFlow API",
)
parser.add_argument("email", help="EcoFlow account email")
parser.add_argument(
"--region",
"-r",
default="default",
choices=["default", "EU", "US"],
help="API region (default: global)",
)
args = parser.parse_args()
password = getpass.getpass(f"Password for {args.email}: ")
user_id = get_user_id(args.email, password, args.region)
print(user_id)
if __name__ == "__main__":
main()
+358
View File
@@ -0,0 +1,358 @@
"""
EcoFlow BLE protocol decoder — River 2 (struct-packed) and River 3 (protobuf).
River 2 (serial R601/R603/R611/R613):
Little-endian packed C structs; cmd_set=0x20.
River 3 (serial R651/R653/R654/R655):
Protobuf-encoded (standard_protocol_mr521.proto, package mr521).
cmd_set=0xFE, cmd_id=0x15, src=0x02 → DisplayPropertyUpload.
cmd_set=0xFE, cmd_id=0x16, src=0x02 → RuntimePropertyUpload (cmdId inferred).
Sources:
https://github.com/rabits/ef-ble-reverse
https://github.com/rabits/ha-ef-ble
"""
from __future__ import annotations
import struct
from typing import NamedTuple
# ── Protobuf wire-format decoder ───────────────────────────────────────────────
class ProtoField(NamedTuple):
name: str
unit: str = ""
signed: bool = False # int32 fields that may be negative
def _pb_read_varint(data: bytes, pos: int) -> tuple[int, int]:
result, shift = 0, 0
while pos < len(data):
b = data[pos]; pos += 1
result |= (b & 0x7F) << shift
if not (b & 0x80):
return result, pos
shift += 7
raise ValueError("truncated varint")
def decode_protobuf(
data: bytes,
field_names: dict[int, ProtoField] | None = None,
indent: int = 0,
) -> list[str]:
"""Decode protobuf wire format into human-readable lines.
With *field_names* dict the output uses field names and units instead of
generic "field N: value" labels. Unknown field numbers fall back to the
generic form. Works without any external protobuf library.
"""
lines: list[str] = []
pad = " " * indent
pos = 0
while pos < len(data):
try:
tag, pos = _pb_read_varint(data, pos)
except (ValueError, IndexError):
lines.append(f"{pad}[truncated at byte {pos}]")
break
field_num = tag >> 3
wire_type = tag & 0x07
pf = field_names.get(field_num) if field_names else None
label = pf.name if pf else f"field {field_num:3d}"
unit = f" {pf.unit}" if pf and pf.unit else ""
try:
if wire_type == 0: # varint
val, pos = _pb_read_varint(data, pos)
if pf and pf.signed and val >= (1 << 31):
# proto int32 negative: sign-extended to 64-bit varint
val = val - (1 << 64) if val >= (1 << 63) else val - (1 << 32)
lines.append(f"{pad}{label}: {val}{unit}")
elif wire_type == 1: # 64-bit fixed
raw = data[pos:pos + 8]; pos += 8
lines.append(f"{pad}{label}: 0x{raw.hex()} (64-bit)")
elif wire_type == 2: # length-delimited
length, pos = _pb_read_varint(data, pos)
chunk = data[pos:pos + length]; pos += length
try:
nested = decode_protobuf(chunk, indent=indent + 1)
if nested:
lines.append(f"{pad}{label}: {{")
lines.extend(nested)
lines.append(f"{pad}}}")
else:
raise ValueError("empty")
except Exception:
try:
lines.append(f"{pad}{label}: {chunk.decode()!r}")
except UnicodeDecodeError:
lines.append(f"{pad}{label}: 0x{chunk.hex()}")
elif wire_type == 5: # 32-bit fixed (float)
raw = data[pos:pos + 4]; pos += 4
fval = struct.unpack("<f", raw)[0]
lines.append(f"{pad}{label}: {fval:.3f}{unit}")
else:
lines.append(f"{pad}{label}: unknown wire_type={wire_type}")
break
except (ValueError, IndexError, struct.error) as exc:
lines.append(f"{pad}{label}: parse error ({exc})")
break
return lines
# ── River 2 — struct field descriptor ─────────────────────────────────────────
class FieldDef(NamedTuple):
name: str
fmt: str # struct format char(s)
unit: str = ""
scale: float = 1.0
skip: bool = False
# ── River 3 — DisplayPropertyUpload field map ──────────────────────────────────
# Source: standard_protocol_mr521.proto (package mr521)
# Routing: src=0x02, cmd_set=0xFE, cmd_id=0x15
_DISPLAY_PROPERTY_UPLOAD: dict[int, ProtoField] = {
# ── status / error ──
1: ProtoField("errcode"),
2: ProtoField("sys_status"),
6: ProtoField("energy_backup_state"),
130: ProtoField("dev_online_flag"),
212: ProtoField("dev_sleep_state"),
213: ProtoField("pd_err_code"),
214: ProtoField("llc_err_code"),
215: ProtoField("mppt_err_code"),
232: ProtoField("llc_inv_err_code"),
450: ProtoField("inv_err_code"),
140: ProtoField("bms_err_code"),
# ── total power flows ──
3: ProtoField("pow_in_sum_w", "W"),
4: ProtoField("pow_out_sum_w", "W"),
# ── AC ──
54: ProtoField("pow_get_ac_in", "W"),
368: ProtoField("pow_get_ac_out", "W"),
367: ProtoField("flow_info_ac_out"),
298: ProtoField("ac_out_open"),
211: ProtoField("ac_out_freq", "Hz"),
209: ProtoField("plug_in_info_ac_in_chg_pow_max", "W"),
458: ProtoField("plug_in_info_ac_in_chg_hal_pow_max", "W"),
202: ProtoField("plug_in_info_ac_charger_flag"),
# ── DC / solar input ──
361: ProtoField("pow_get_pv", "W"),
360: ProtoField("flow_info_pv"),
364: ProtoField("plug_in_info_pv_charger_flag"),
356: ProtoField("plug_in_info_pv_dc_amp_max", "A"),
365: ProtoField("plug_in_info_pv_chg_amp_max", "A"),
366: ProtoField("plug_in_info_pv_chg_vol_max", "V"),
148: ProtoField("pv_chg_type"),
# ── DC 12 V output ──
37: ProtoField("pow_get_12v", "W"),
33: ProtoField("flow_info_12v"),
75: ProtoField("btn_dc_12v_out_switch"),
74: ProtoField("dc_out_open"),
# ── USB outputs ──
9: ProtoField("pow_get_qcusb1", "W"),
10: ProtoField("pow_get_qcusb2", "W"),
11: ProtoField("pow_get_typec1", "W"),
12: ProtoField("pow_get_typec2", "W"),
76: ProtoField("btn_usb_switch"),
# ── battery (BMS composite / CMS) ──
158: ProtoField("pow_get_bms", "W"), # +chg / -dsg
262: ProtoField("cms_batt_soc", "%"),
263: ProtoField("cms_batt_soh", "%"),
102: ProtoField("cms_batt_temp", "°C", signed=True),
268: ProtoField("cms_dsg_rem_time", "min"),
269: ProtoField("cms_chg_rem_time", "min"),
270: ProtoField("cms_max_chg_soc", "%"),
271: ProtoField("cms_min_dsg_soc", "%"),
275: ProtoField("cms_bms_run_state"),
282: ProtoField("cms_chg_dsg_state"),
287: ProtoField("cms_batt_full_cap", "mAh"),
288: ProtoField("cms_batt_design_cap", "mAh"),
289: ProtoField("cms_batt_remain_cap", "mAh"),
459: ProtoField("cms_batt_pow_out_max", "W"),
460: ProtoField("cms_batt_pow_in_max", "W"),
462: ProtoField("cms_batt_full_energy", "Wh"),
# ── BMS cell-level ──
242: ProtoField("bms_batt_soc", "%"),
243: ProtoField("bms_batt_soh", "%"),
248: ProtoField("bms_design_cap", "mAh"),
254: ProtoField("bms_dsg_rem_time", "min"),
255: ProtoField("bms_chg_rem_time", "min"),
258: ProtoField("bms_min_cell_temp", "°C", signed=True),
259: ProtoField("bms_max_cell_temp", "°C", signed=True),
260: ProtoField("bms_min_mos_temp", "°C", signed=True),
261: ProtoField("bms_max_mos_temp", "°C", signed=True),
281: ProtoField("bms_chg_dsg_state"),
# ── energy backup ──
7: ProtoField("energy_backup_en"),
8: ProtoField("energy_backup_start_soc", "%"),
# ── standby / display config ──
5: ProtoField("lcd_light"),
17: ProtoField("dev_standby_time", "s"),
18: ProtoField("screen_off_time", "s"),
19: ProtoField("ac_standby_time", "s"),
20: ProtoField("dc_standby_time", "s"),
290: ProtoField("ble_standby_time", "s"),
# ── misc ──
25: ProtoField("xboost_en"),
30: ProtoField("pcs_fan_level"),
195: ProtoField("en_beep"),
358: ProtoField("low_power_alarm", "%"),
355: ProtoField("ups_alram"),
}
# ── River 3 — RuntimePropertyUpload field map ──────────────────────────────────
# Source: standard_protocol_mr521.proto (package mr521)
# Note: cmd_id for RuntimePropertyUpload is not confirmed in ha-ef-ble river3.py;
# packets will be decoded if the device sends them on any cmd_id with these
# field numbers. Add the correct (src, cmd_set, cmd_id) key when known.
_RUNTIME_PROPERTY_UPLOAD: dict[int, ProtoField] = {
# ── temperatures ──
26: ProtoField("temp_pcs_dc", "°C"),
27: ProtoField("temp_pcs_ac", "°C"),
28: ProtoField("temp_pv_h", "°C"),
29: ProtoField("temp_pv_l", "°C"),
379: ProtoField("temp_pv", "°C"),
# ── AC voltages / currents ──
67: ProtoField("plug_in_info_ac_out_vol", "V"),
68: ProtoField("plug_in_info_ac_in_vol", "V"),
223: ProtoField("plug_in_info_ac_in_amp", "A"),
224: ProtoField("plug_in_info_ac_out_amp", "A"),
# ── solar / DC input ──
380: ProtoField("plug_in_info_pv_vol", "V"),
381: ProtoField("plug_in_info_pv_amp", "A"),
41: ProtoField("plug_in_info_pv_h_vol", "V"),
221: ProtoField("plug_in_info_pv_h_amp", "A"),
44: ProtoField("plug_in_info_pv_l_vol", "V"),
222: ProtoField("plug_in_info_pv_l_amp", "A"),
# ── 12 V output ──
382: ProtoField("plug_in_info_12v_vol", "V"),
383: ProtoField("plug_in_info_12v_amp", "A"),
# ── BMS battery voltage / current ──
244: ProtoField("bms_batt_vol", "V"),
245: ProtoField("bms_batt_amp", "A"),
264: ProtoField("cms_batt_vol", "V"),
265: ProtoField("cms_batt_amp", "A"),
# ── LLC / bus internals ──
384: ProtoField("llc_bat_vol", "V"),
385: ProtoField("llc_bat_cur", "A"),
386: ProtoField("llc_bus_vol", "V"),
# ── firmware versions ──
176: ProtoField("pd_firm_ver"),
177: ProtoField("iot_firm_ver"),
178: ProtoField("mppt_firm_ver"),
179: ProtoField("llc_firm_ver"),
241: ProtoField("bms_firm_ver"),
}
# ── Message registry ───────────────────────────────────────────────────────────
#
# Struct-based (River 2): value is ("label", [FieldDef, ...], "struct")
# Protobuf-based (River 3): value is ("label", {field_num: ProtoField}, "proto")
_REGISTRY: dict[tuple[int, int, int], tuple[str, list | dict, str]] = {
# ── River 3: src / cmd_set / cmd_id ────────────────────────────────────────
(0x02, 0xFE, 0x15): ("DisplayPropertyUpload", _DISPLAY_PROPERTY_UPLOAD, "proto"),
(0x02, 0xFE, 0x16): ("RuntimePropertyUpload", _RUNTIME_PROPERTY_UPLOAD, "proto"),
}
# ── Struct decoder ─────────────────────────────────────────────────────────────
def _decode_struct(label: str, fields: list[FieldDef], data: bytes) -> list[str]:
lines: list[str] = [f"[{label}]"]
active = list(fields)
while active:
fmt = "<" + "".join(f.fmt for f in active)
if struct.calcsize(fmt) <= len(data):
break
active.pop()
if not active:
lines.append(f" <payload too short: {len(data)} bytes>")
return lines
fmt = "<" + "".join(f.fmt for f in active)
try:
values = struct.unpack_from(fmt, data)
except struct.error as exc:
lines.append(f" <struct error: {exc}>")
return lines
for field, raw_val in zip(active, values):
if field.skip:
continue
if isinstance(raw_val, bytes):
display = raw_val.hex()
elif isinstance(raw_val, float):
display = f"{raw_val * field.scale:.3f}"
else:
val = int(raw_val) * field.scale
display = f"{int(val)}" if val == int(val) else f"{val:.3f}"
unit_str = f" {field.unit}" if field.unit else ""
lines.append(f" {field.name}: {display}{unit_str}")
tail = len(data) - struct.calcsize(fmt)
if tail > 0:
lines.append(f" <{tail} trailing bytes: {data[-tail:].hex()}>")
return lines
# ── Public API ─────────────────────────────────────────────────────────────────
def decode_payload(
src: int,
cmd_set: int,
cmd_id: int,
payload: bytes,
) -> list[str] | None:
"""Decode a payload by (src, cmd_set, cmd_id).
Returns annotated lines, or None if no definition is registered.
Callers should fall back to the generic protobuf/hex decoder on None.
"""
if not payload:
return None
entry = _message_definition(src, cmd_set, cmd_id)
if entry is None:
return None
label, fields, kind = entry
if kind == "struct":
return _decode_struct(label, fields, payload) # type: ignore[arg-type]
# proto
lines = [f"[{label}]"]
lines += [" " + ln for ln in decode_protobuf(payload, field_names=fields)] # type: ignore[arg-type]
return lines
def describe_message(src: int, cmd_set: int, cmd_id: int) -> str | None:
entry = _message_definition(src, cmd_set, cmd_id)
return entry[0] if entry else None
# Load model definitions only when needed to avoid circular imports.
def _message_definition(src: int, cmd_set: int, cmd_id: int):
from .ecoflow_river2 import MESSAGE_DEFINITIONS
key = (src, cmd_set, cmd_id)
return MESSAGE_DEFINITIONS.get(key) or _REGISTRY.get(key)
def decode_river2_payload(src: int, cmd_set: int, cmd_id: int, payload: bytes):
from .ecoflow_river2 import decode_river2_payload as decode
return decode(src, cmd_set, cmd_id, payload)
format_payload = decode_payload
format_protobuf = decode_protobuf
+367
View File
@@ -0,0 +1,367 @@
"""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
}
_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
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
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)
scalar("ef_input_power_watts", "Total Input Power", values["watts_in_sum"], "W")
scalar("ef_output_power_watts", "Total Output Power", 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 = values["temp"]
snapshot.bms_temperature_celcius = 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)
+71
View File
@@ -0,0 +1,71 @@
"""EcoFlow measurements and diagnostic packets, independent of Bluetooth I/O."""
from collections.abc import Iterator
from dataclasses import dataclass, field, fields as dataclass_fields
from bmspy.classes import BMSField, BMSInfoField, BMSMultiField, BMSScalarField, UPS
@dataclass
class EcoFlowSnapshot(UPS):
"""Data container for a single EcoFlow device telemetry snapshot.
Named scalar/info fields appear as standard BMSFields. Raw packet
payloads received from the device are stored in *_packets* and are
also exposed via *items()*, keyed as ``ef_pkt_<src>_<cmdset>_<cmdid>``.
Decoded protocol fields retain the latest reported value independently,
so partial reports do not erase fields received earlier in the collection.
"""
ef_serial_number: BMSInfoField | None = None
ef_bt_address: BMSInfoField | None = None
ef_connection_state: BMSInfoField | None = None
ef_packets_received: BMSScalarField | None = None
bms_capacity_charge_ratio: BMSScalarField | None = None
bms_temperature_celcius: BMSMultiField | None = None
bms_charge_is_charging: BMSScalarField | None = None
bms_charge_is_discharging: BMSScalarField | None = None
ef_input_power_watts: BMSScalarField | None = None
ef_output_power_watts: BMSScalarField | None = None
ef_remaining_time_minutes: BMSScalarField | None = None
ef_charge_remaining_time_minutes: BMSScalarField | None = None
ef_discharge_remaining_time_minutes: BMSScalarField | None = None
ef_charge_state: BMSScalarField | None = None
_packets: dict[str, BMSInfoField] = field(
default_factory=dict, init=False, repr=False
)
_protocol_fields: dict[str, BMSField] = field(
default_factory=dict, init=False, repr=False
)
def record_protocol_fields(self, namespace: str, fields: dict[str, BMSField]) -> None:
"""Replace reported fields, not the whole message or snapshot."""
self._protocol_fields.update({f"{namespace}_{name}": value for name, value in fields.items()})
self._protocol_fields.pop(f"{namespace}_decode_error", None)
def record_protocol_error(self, namespace: str, error: str) -> None:
"""Retain the last valid fields and expose why this report was rejected."""
self._protocol_fields[f"{namespace}_decode_error"] = BMSInfoField(
help="Protocol report rejected; previous decoded fields retained", info=error,
)
def record_packet(self, src: int, cmd_set: int, cmd_id: int, payload: bytes) -> None:
"""Keep heartbeats from different modules separate, even with the same command."""
key = f"ef_pkt_{src:02x}_{cmd_set:02x}_{cmd_id:02x}"
self._packets[key] = BMSInfoField(
help=f"Packet src=0x{src:02X} cmd_set=0x{cmd_set:02X} cmd_id=0x{cmd_id:02X}",
info=payload.hex(),
)
def items(self) -> Iterator[tuple[str, BMSField]]:
for f in dataclass_fields(self):
if f.name.startswith("_"):
continue
v = getattr(self, f.name)
if v is not None:
yield f.name, v
yield from self._protocol_fields.items()
yield from self._packets.items()
+6 -3
View File
@@ -13,10 +13,12 @@ DAEMON_UPDATE_PERIOD = 30
def prometheus_create_metric(
registry: CollectorRegistry, ups_data: dict[str, UPS]
registry: CollectorRegistry, ups_data: dict[str, UPS],
metric: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Create one Gauge per scalar/multi field and one Info per info field; skip duplicates."""
metric: dict[str, Any] = {}
"""Add metrics for new fields, preserving metrics from earlier snapshots."""
if metric is None:
metric = {}
for ups_name, ups_obj in ups_data.items():
for name, field in ups_obj.items():
if name in metric:
@@ -80,6 +82,7 @@ def prometheus_export(
ups_data = {}
while not ups_data:
ups_data = client.read_data(socket_path, "prometheus", ups=ups, debug=debug)
prometheus_create_metric(registry, ups_data, metric)
prometheus_populate_metric(metric, ups_data)
prometheus_client.generate_latest(registry)
else: