Add River 3 telemetry and schema-based diagnostics

Decode River 3 separately and accumulate partial reports against a field-level checklist. Keep watch mode running after completion.

Label fields and nested records from the pinned pr705 schema. Preserve unknown values and document unverified meanings and routing.

Add synthetic regressions, the River 3 field reference, schema-generation tooling, and upstream attribution.
This commit is contained in:
tim
2026-10-08 14:08:20 +02:00
parent bac953bf48
commit c2faeb669a
14 changed files with 3055 additions and 263 deletions
+140 -31
View File
@@ -4,11 +4,12 @@ Connect to EcoFlow River 2 and River 3 via Bluetooth LE and print a telemetry
snapshot.
Supply your device details on the command line, or use --scan to discover them.
After authentication, devices push telemetry packets. The script waits for the
expected packet types or the --timeout limit, then prints the snapshot and exits.
River 2 requires valid PD, EMS, and BMS core payloads. Optional heartbeats and
other packets are listed separately. Use --watch to print every decoded/raw
packet until Ctrl+C. Watch output can contain private identifiers.
After authentication, devices push telemetry packets. River 2 collection waits
for its expected heartbeat types. River 3 prints updates as they arrive and tracks
required measurement and configuration fields across partial reports. Normal
collection finishes when the required information arrives; --timeout is only a
failure limit. Use --watch to print every packet from either model until Ctrl+C,
including raw payloads for investigation. Watch output can contain private data.
Usage:
uv run --extra ecoflow-bt python tools/ecoflow-bt.py --user-id <id> --scan
@@ -24,13 +25,33 @@ import sys
try:
from bmspy.ecoflow_bt import EcoFlowBTManager, EcoFlowBTDevice, EcoFlowPacket, scan_for_devices
from bmspy.ecoflow_login import get_user_id
from bmspy.ecoflow_proto import decode_payload, decode_protobuf, decode_river2_payload
from bmspy.ecoflow_proto import (
format_payload, format_protobuf, describe_protobuf_field, parse_protobuf_fields,
)
from bmspy.ecoflow_river2 import decode_river2_payload
except ImportError as exc:
sys.exit(f"Missing dependency: {exc}\n"
"Run: uv sync --extra ecoflow-bt")
# ── Model profiles (no device-specific identifiers) ──────────────────────────
# Baseline from the named fields in observed R651 measurement/configuration
# reports, not every optional field in the protocol schema. Other firmware can
# omit fields: report those as missing rather than claiming a complete report.
# Values are protobuf wire types (0: varint, 5: float32).
RIVER3_REQUIRED_GROUPS = {
"measurements": {
**dict.fromkeys((1, 148, 212, 248, 254, 255, 258, 259, 260, 261,
268, 269, 275, 281, 282, 355), 0),
**dict.fromkeys((3, 4, 9, 10, 11, 12, 37, 54, 158, 242, 243,
262, 263, 361, 368), 5),
},
"configuration": dict.fromkeys(
(7, 8, 17, 18, 19, 25, 33, 74, 195, 202, 209, 211, 270, 271,
356, 358, 360, 364, 365, 366, 367, 458), 0,
),
}
RIVER2_REQUIRED_HEARTBEATS = {
"ef_pkt_02_20_02": "PD",
"ef_pkt_03_20_02": "EMS",
@@ -46,8 +67,14 @@ MODEL_PROFILES = {
"label": "River 3",
"enc_type": 7,
"src_candidates": [0x02],
# cmd_set=0xFE: DisplayPropertyUpload (0x15) + RuntimePropertyUpload (0x16)
"expected_fields": {"ef_pkt_02_fe_15", "ef_pkt_02_fe_16"},
"required_groups": RIVER3_REQUIRED_GROUPS,
# DisplayPropertyUpload can arrive as several partial updates.
# RuntimePropertyUpload (0x16) is not confirmed for River 3.
"expected_fields": {
"ef_pkt_02_fe_15", "bms_capacity_charge_ratio",
"bms_temperature_celcius", "ef_input_power_watts",
"ef_output_power_watts", "bms_charge_is_charging",
},
},
"R601": {
"label": "River 2",
@@ -60,6 +87,8 @@ MODEL_PROFILES = {
}
MODEL_PROFILES["R603"] = {**MODEL_PROFILES["R601"], "label": "River 2 Max"}
for prefix in ("R653", "R654", "R655"):
MODEL_PROFILES[prefix] = {**MODEL_PROFILES["R651"]}
# ── Packet decoder ─────────────────────────────────────────────────────────────
@@ -92,12 +121,12 @@ def _decode_snapshot_packet(
return [f" {header}: (empty payload)"]
for src in src_candidates:
lines = decode_payload(src, cmd_set, cmd_id, payload)
lines = format_payload(src, cmd_set, cmd_id, payload)
if lines is not None:
return [" " + ln for ln in lines]
# Fallback: generic protobuf decode
pb = decode_protobuf(payload)
# Fallback: generic protobuf formatting
pb = format_protobuf(payload)
if pb:
return [f" {header}"] + [" " + ln for ln in pb]
return [f" {header}: {payload_hex[:120]}{'...' if len(payload_hex) > 120 else ''}"]
@@ -106,7 +135,10 @@ def _decode_snapshot_packet(
# ── Snapshot printer ───────────────────────────────────────────────────────────
def print_snapshot(label: str, address: str, snap, src_candidates: list[int]) -> None:
def print_snapshot(
label: str, address: str, snap, src_candidates: list[int],
decoded_packets: dict[str, list[str]] | None = None,
) -> None:
print(f"\n{'━' * 60}")
print(f" {label} ({address})")
print(f"{'━' * 60}")
@@ -125,28 +157,42 @@ def print_snapshot(label: str, address: str, snap, src_candidates: list[int]) ->
for key, payload_hex in pkt_keys:
print(f"\n {key}:")
for line in _decode_snapshot_packet(key, payload_hex, src_candidates):
lines = (decoded_packets or {}).get(key)
if lines is None:
lines = _decode_snapshot_packet(key, payload_hex, src_candidates)
for line in lines:
print(line)
print()
# ── Collection logic ───────────────────────────────────────────────────────────
# ── Live diagnostics and field-level collection ────────────────────────────────
class PacketCollector:
"""Track valid River 2 heartbeats and optionally print each raw packet."""
"""Track valid River 2 heartbeats or accumulated River 3 fields."""
def __init__(self, address: str, profile: dict, watch: bool = False) -> None:
self.address = address
self.label = profile["label"]
self.required_groups = profile.get("required_groups", {})
self.required_heartbeats = profile.get("required_heartbeats", {})
self.valid_heartbeats: set[str] = set()
self.required_types = {
number: wire_type for fields in self.required_groups.values()
for number, wire_type in fields.items()
}
self.watch = watch
self.seen: set[tuple[int, int]] = set()
self.fields: dict[int, bytes] = {}
self.error: Exception | None = None
def observe_snapshot(self, snap) -> None:
"""Seed receipt tracking for callers that start after reception."""
"""Seed River 2 receipt tracking for callers that start after reception.
Normal CLI runs register receive() before connecting. Snapshot seeding
also supports direct collect() callers without packet listeners.
"""
for key, field in snap.items():
if key not in self.required_heartbeats or key in self.valid_heartbeats:
continue
@@ -164,17 +210,30 @@ class PacketCollector:
return True
def missing(self) -> set[str]:
if self.required_heartbeats:
return {
f"{label} ({key})" for key, label in self.required_heartbeats.items()
if key not in self.valid_heartbeats
}
return {
f"{label} ({key})" for key, label in self.required_heartbeats.items()
if key not in self.valid_heartbeats
f"{group}:{describe_protobuf_field(2, 0xFE, 0x15, number) or number}"
for group, fields in self.required_groups.items()
for number, wire_type in fields.items()
if (number, wire_type) not in self.seen
}
def progress(self) -> str:
return (f"{len(self.valid_heartbeats)}/{len(self.required_heartbeats)} "
"required heartbeats received")
if self.required_heartbeats:
return (f"{len(self.valid_heartbeats)}/{len(self.required_heartbeats)} "
"required heartbeats received")
return ", ".join(
f"{group} {sum((n, w) in self.seen for n, w in fields.items())}/{len(fields)}"
for group, fields in self.required_groups.items()
)
def receive(self, packet: EcoFlowPacket) -> None:
# Re-raise callback/output failures from collect(), which owns cleanup.
# Device callbacks cannot propagate failures through the BLE dispatcher.
# The collection loop re-raises output errors and closes the connections.
if self.error is not None:
return
try:
@@ -187,7 +246,7 @@ class PacketCollector:
key = f"ef_pkt_{packet.src:02x}_{packet.cmd_set:02x}_{packet.cmd_id:02x}"
header = f"\n{self.label} ({self.address}) {key}:"
if self.watch:
lines = decode_payload(*route, packet.payload)
lines = format_payload(*route, packet.payload)
print("\n".join([
header,
f" version=0x{packet.version:02x} dst=0x{packet.dst:02x} "
@@ -195,9 +254,43 @@ class PacketCollector:
f" raw_payload: {packet.payload.hex() or '(empty)'}",
*(" " + line for line in (lines or ["No decoded fields; retain raw payload for review."])),
]), flush=True)
if key in self.required_heartbeats and not self._accept_heartbeat(key, packet.payload):
print(f"{header}\n {self.required_heartbeats[key]} heartbeat rejected by decoder. "
"Not counted toward completion", flush=True)
if not self.required_groups or route != (0x02, 0xFE, 0x15):
return
try:
parsed = parse_protobuf_fields(packet.payload)
except ValueError as exc:
print(f"{header}\n Malformed report, not counted: {exc}", flush=True)
return
# Keep repeated/nested values together. A subsequent report replaces a
# whole top-level field, rather than flattening repeated child fields.
grouped: dict[int, bytes] = {}
for field in parsed:
expected_type = self.required_types.get(field.number)
if expected_type is not None and field.wire_type != expected_type:
print(f"{header}\n Field {field.number}: unexpected wire type "
f"{field.wire_type}, not counted", flush=True)
continue
self.seen.add((field.number, field.wire_type))
grouped[field.number] = grouped.get(field.number, b"") + field.encoded
changed = {n: value for n, value in grouped.items() if self.fields.get(n) != value}
self.fields.update(grouped)
if changed and not self.watch:
lines = format_payload(2, 0xFE, 0x15, b"".join(changed[n] for n in sorted(changed)))
print("\n".join([header, *(" " + line for line in lines)]), flush=True)
def decoded_packets(self) -> dict[str, list[str]]:
if not self.fields:
return {}
payload = b"".join(self.fields[n] for n in sorted(self.fields))
lines = format_payload(2, 0xFE, 0x15, payload)
return {"ef_pkt_02_fe_15": [
" [DisplayPropertyUpload: latest fields collected this run]",
*(" " + line for line in lines[1:]),
]}
def print_river2_packet_summary(snap) -> None:
@@ -216,13 +309,19 @@ def print_river2_packet_summary(snap) -> None:
print(f" Other packet types seen: {', '.join(descriptions)}", flush=True)
# ── Collection logic ───────────────────────────────────────────────────────────
async def collect(
devices: list[tuple[str, EcoFlowBTDevice, set[str]]],
max_wait: float,
collectors: dict[str, PacketCollector] | None = None,
watch: bool = False,
) -> None:
"""Finish on required information, or keep watching until interrupted."""
"""Finish on required information, or keep watching until interrupted.
The deadline is a failure limit, not evidence that a report is complete.
"""
collectors = dict(collectors or {})
for addr, dev, expected in devices:
if addr not in collectors and expected and expected <= RIVER2_REQUIRED_HEARTBEATS.keys():
@@ -238,8 +337,9 @@ async def collect(
def missing_fields(addr, dev, expected):
collector = collectors.get(addr)
if collector is not None and collector.required_heartbeats:
collector.observe_snapshot(dev.snapshot)
if collector is not None and (collector.required_groups or collector.required_heartbeats):
if collector.required_heartbeats:
collector.observe_snapshot(dev.snapshot)
return collector.missing()
return expected - {name for name, _ in dev.snapshot.items()}
@@ -256,11 +356,15 @@ async def collect(
missing = missing_fields(addr, dev, expected)
if missing:
if last_missing.get(addr) != missing:
if not watch:
if collector is not None and collector.required_groups:
print(f" {addr}: {collector.progress()}; missing {sorted(missing)}", flush=True)
elif not watch:
print(f" {addr}: waiting for {missing}", flush=True)
last_missing[addr] = missing
else:
if collector is not None and collector.required_heartbeats:
if collector is not None and collector.required_groups:
print(f" {addr}: all required information received ({collector.progress()})", flush=True)
elif collector is not None and collector.required_heartbeats:
labels = ", ".join(collector.required_heartbeats.values())
print(f" {addr}: {collector.progress()} ({labels})", flush=True)
print_river2_packet_summary(dev.snapshot)
@@ -343,15 +447,16 @@ async def run(user_id: str, args: argparse.Namespace) -> None:
encrypt_type=d["enc_type"],
)
devices.append((addr, dev, d["expected_fields"]))
if watch or d.get("required_heartbeats"):
if watch or d.get("required_groups") or d.get("required_heartbeats"):
collector = PacketCollector(addr, d, watch=watch)
collectors[addr] = collector
# Register before connecting so the first notification is not lost.
dev.on_packet(collector.receive)
print(f"Registered {d['label']} {address} serial={d['serial_number']}")
try:
if watch:
print("\nWatching until Ctrl+C; telemetry timeout is ignored.\n"
print("\nWatching both models until Ctrl+C; telemetry timeout is ignored.\n"
"Watch output can contain private identifiers. Redact it before sharing.", flush=True)
print("\nConnecting…", flush=True)
await manager.connect_all(auth_timeout=30.0)
@@ -362,7 +467,11 @@ async def run(user_id: str, args: argparse.Namespace) -> None:
print("\nSnapshot:")
for addr, dev, _ in devices:
print_snapshot(labels[addr], addr, dev.snapshot, src_by_addr[addr])
if addr in collectors and collectors[addr].required_groups:
print_snapshot(labels[addr], addr, dev.snapshot, src_by_addr[addr],
decoded_packets=collectors[addr].decoded_packets())
else:
print_snapshot(labels[addr], addr, dev.snapshot, src_by_addr[addr])
finally:
try:
print("Disconnecting…", flush=True)
@@ -393,7 +502,7 @@ def main() -> None:
parser.add_argument("--timeout", type=float, default=120.0, metavar="SEC",
help="Failure limit for required information (default: 120 s; ignored by --watch)")
parser.add_argument("--watch", action="store_true",
help="Print decoded and raw packets until Ctrl+C, including unknown information")
help="Print decoded and raw packets from either model until Ctrl+C, including unknown information")
parser.add_argument("--debug", type=int, default=0, metavar="N")
args = parser.parse_args()