diff --git a/README.md b/README.md index 6d9ccf1..0bd30af 100644 --- a/README.md +++ b/README.md @@ -73,17 +73,18 @@ curl --fail --location --output bmspy/ecoflow_keydata.py \ This file is ignored by Git and excluded from packages. Type 1 devices do not need it. -#### Dump a River 2 +#### Dump both Rivers Device arguments use `name@ADDRESS/SERIAL/USERID`. Replace the placeholders below -with the device's Bluetooth address, serial number, and your account user ID: +with each device's Bluetooth address, serial number, and your account user ID: ```bash uv run --extra ecoflow-bt bmspy-server --socket /tmp/bmspy-rivers.sock \ - -d 'river2@RIVER2_MAC/RIVER2_SERIAL/YOUR_USER_ID' + -d 'river2@RIVER2_MAC/RIVER2_SERIAL/YOUR_USER_ID' \ + -d 'river3@RIVER3_MAC/RIVER3_SERIAL/YOUR_USER_ID' ``` -Leave the server running. In another terminal, print its snapshot: +Leave the server running. In another terminal, print both snapshots: ```bash uv run bmspy --socket /tmp/bmspy-rivers.sock @@ -98,7 +99,7 @@ before retrying. Unavailable devices are omitted from the response. #### Diagnostics -Stop the server before connecting directly. Scan and collect reports from nearby supported devices: +Stop the server before connecting directly. Scan and collect reports from both Rivers: ```bash uv run --extra ecoflow-bt python -u tools/ecoflow-bt.py --user-id YOUR_USER_ID --scan --timeout 30 @@ -114,18 +115,20 @@ add `--device ADDRESS`. Without scanning, replace `--scan` with Session keys stay internal. The script has no encryption override or key-export option. River 2 collection waits for three valid core reports, not optional inverter or -solar reports. A timeout reports incomplete collection. Use `--watch` to receive -later reports. +solar reports. River 3 waits for 53 baseline fields across several reports. +These checks do not guarantee every possible field. A timeout reports incomplete +collection. Use `--watch` to receive later reports. -River 2 reports charge, temperature, power, and time estimates. Missing measurements -stay absent. See the [River 2 measurement reference](docs/ecoflow-river2-fields.md) -for units, decoding rules, and known limits. +Both models report charge, temperature, power, and time estimates through separate +decoders. Missing measurements stay absent. See the +[River 2 measurement reference](docs/ecoflow-river2-fields.md) and +[River 3 field reference](docs/ecoflow-river3-fields.md) for units, decoding rules, +and known limits. -Client snapshots include all complete River 2 heartbeat fields under -`ef_river2_*`, alongside the shared measurements and raw packets. -Partial reports retain earlier field values. See the -[River 2 field reference](docs/ecoflow-river2-fields.md#reported-fields-available-to-clients) -for names, units, and limits. +Client snapshots also include reported protocol fields under `ef_river2_*` and +`ef_river3_display_*`, alongside the shared measurements and raw packets. +Partial reports retain earlier field values. Nested River 3 groups use JSON text +in their `info` fields. The references above describe names, units, and limits. ### Running via systemd diff --git a/bmspy/ecoflow_bt.py b/bmspy/ecoflow_bt.py index 76f7994..be14f1b 100644 --- a/bmspy/ecoflow_bt.py +++ b/bmspy/ecoflow_bt.py @@ -24,6 +24,9 @@ from bmspy.classes import BMSInfoField, BMSScalarField from bmspy.ecoflow_river2 import ( River2Decoder, SERIAL_PREFIXES as _RIVER2_PREFIXES, decode_river2_payload, ) +from bmspy.ecoflow_river3 import ( + River3Decoder, SERIAL_PREFIXES as _RIVER3_PREFIXES, decode_river3_payload, +) from bmspy.ecoflow_snapshot import EcoFlowSnapshot from bmspy.utilities import debugger @@ -35,8 +38,6 @@ MANUFACTURER_KEY = 0xB5B5 # Packet version and encryption type are independent. R601/R603 use V2 # packets even when the BLE advertisement selects Type 7 encryption. - - def _default_packet_version(serial_number: str) -> int: return 2 if serial_number.startswith(_RIVER2_PREFIXES) else 3 @@ -391,7 +392,11 @@ class EcoFlowBTDevice: _default_packet_version(serial_number) if packet_version is None else packet_version ) self._is_river2 = serial_number.startswith(_RIVER2_PREFIXES) - self._measurement_decoder = River2Decoder() if self._is_river2 else None + self._is_river3 = serial_number.startswith(_RIVER3_PREFIXES) + self._measurement_decoder: River2Decoder | River3Decoder | None = ( + River2Decoder() if self._is_river2 else + River3Decoder() if self._is_river3 else None + ) self._debug = debug self._state: _State = _State.CREATED @@ -543,7 +548,7 @@ class EcoFlowBTDevice: key = hashlib.md5(self._sn.encode()).digest() iv = hashlib.md5(self._sn[::-1].encode()).digest() self._enc = _Type1Encryption(key, iv) - self._assembler = _RawHeaderHelper(self._enc) + self._assembler = _RawHeaderHelper(self._enc, xor_payload=self._is_river3) # Type 1: send probe and authenticate immediately without waiting for # the probe response (mirrors ha-ef-ble's _type_1_session). self._set_state(_State.AUTHENTICATING) @@ -606,7 +611,7 @@ class EcoFlowBTDevice: data = self._enc.decrypt(encrypted_data) session_key = self._derive_session_key(data[16:18], data[:16]) self._enc = _Type7Encryption(session_key, self._enc.iv) - self._assembler = _EncFrameHelper(self._enc) + self._assembler = _EncFrameHelper(self._enc, xor_payload=self._is_river3) await self._do_auth_status() self._auth_fallback_task = asyncio.create_task(self._auth_type7_fallback()) @@ -778,8 +783,9 @@ class EcoFlowBTDevice: class _EncFrameHelper: """Encode/decode EncPacket frames (Type 7, AES-CBC).""" - def __init__(self, enc: _Type7Encryption) -> None: + def __init__(self, enc: _Type7Encryption, xor_payload: bool = False) -> None: self._enc = enc + self._xor_payload = xor_payload self._buf = bytearray() def encode(self, pkt: EcoFlowPacket) -> bytes: @@ -793,7 +799,7 @@ class _EncFrameHelper: packets = [] for raw in raw_list: decrypted = self._enc.decrypt(raw) - pkt = EcoFlowPacket.from_bytes(decrypted) + pkt = EcoFlowPacket.from_bytes(decrypted, xor_payload=self._xor_payload) if pkt is not None: packets.append(pkt) return packets @@ -802,8 +808,9 @@ class _EncFrameHelper: class _RawHeaderHelper: """Encode/decode RawHeader frames (Type 1, AES-CBC).""" - def __init__(self, enc: _Type1Encryption) -> None: + def __init__(self, enc: _Type1Encryption, xor_payload: bool = False) -> None: self._enc = enc + self._xor_payload = xor_payload self._buf = bytearray() def encode(self, pkt: EcoFlowPacket) -> bytes: @@ -820,7 +827,9 @@ class _RawHeaderHelper: version = header[1] inner_overhead = 15 if version >= 3 else 13 inner_len = inner_overhead + payload_length - pkt = EcoFlowPacket.from_bytes(header + decrypted[:inner_len]) + pkt = EcoFlowPacket.from_bytes( + header + decrypted[:inner_len], xor_payload=self._xor_payload, + ) if pkt is not None: packets.append(pkt) return packets diff --git a/bmspy/ecoflow_proto.py b/bmspy/ecoflow_proto.py index 7f87113..bda628c 100644 --- a/bmspy/ecoflow_proto.py +++ b/bmspy/ecoflow_proto.py @@ -1,26 +1,18 @@ -""" -EcoFlow BLE protocol decoder — River 2 (struct-packed) and River 3 (protobuf). +"""Shared EcoFlow binary helpers and diagnostic formatting. -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 +Model-specific layouts, annotations, and measurement decoding live in +``ecoflow_river2`` and ``ecoflow_river3``. Model definitions are loaded only +when formatting needs them, so the models can use these parsing helpers +without circular imports. Legacy ``decode_*`` entry points remain available. """ from __future__ import annotations +import math import struct -from typing import NamedTuple +from typing import Any, NamedTuple - -# ── Protobuf wire-format decoder ─────────────────────────────────────────────── +from .ecoflow_river3_schema import MESSAGES as _MESSAGES, ENUMS as _ENUMS class ProtoField(NamedTuple): @@ -29,27 +21,156 @@ class ProtoField(NamedTuple): signed: bool = False # int32 fields that may be negative +class FieldDef(NamedTuple): + name: str + fmt: str # struct format char(s) + unit: str = "" + scale: float = 1.0 + skip: bool = False + + +class WireField(NamedTuple): + number: int + wire_type: int + encoded: bytes + + +# ── Protobuf wire-format parsing ────────────────────────────────────────────── + + 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 = 0 + for shift in range(0, 70, 7): + if pos >= len(data): + raise ValueError("truncated varint") + b = data[pos] + pos += 1 + if shift == 63 and b > 1: + raise ValueError("varint exceeds 64 bits") result |= (b & 0x7F) << shift if not (b & 0x80): return result, pos - shift += 7 - raise ValueError("truncated varint") + raise ValueError("varint exceeds 64 bits") -def decode_protobuf( +def parse_protobuf_fields(data: bytes) -> list[WireField]: + """Read complete top-level fields, retaining repeated fields and raw bytes. + + Reject the whole message on malformed input. Length-delimited values stay + opaque here: nested messages are not separate top-level measurements. + """ + fields = [] + pos = 0 + while pos < len(data): + start = pos + tag, pos = _pb_read_varint(data, pos) + number, wire_type = tag >> 3, tag & 7 + if not 1 <= number < (1 << 29): + raise ValueError("invalid protobuf field number") + if wire_type == 0: + _, pos = _pb_read_varint(data, pos) + elif wire_type in (1, 5): + pos += 8 if wire_type == 1 else 4 + elif wire_type == 2: + size, pos = _pb_read_varint(data, pos) + pos += size + else: + raise ValueError(f"unsupported protobuf wire type {wire_type}") + if pos > len(data): + raise ValueError("truncated protobuf field") + fields.append(WireField(number, wire_type, data[start:pos])) + return fields + + +def decode_schema_values( + data: bytes, message: str, *, _depth: int = 0, +) -> dict[str, Any]: + """Read reported schema fields as JSON-compatible values, without defaults. + + Repeated fields stay lists and messages stay dictionaries. Enums retain + their code and declared name. Unknown fields retain their wire encoding. + Reject malformed known fields atomically, including malformed children. + """ + if _depth >= 16: + raise ValueError("maximum nesting depth reached") + grouped: dict[int, list[WireField]] = {} + for field in parse_protobuf_fields(data): + grouped.setdefault(field.number, []).append(field) + + def scalar(kind: str, encoded: bytes, pos: int) -> tuple[Any, int]: + value, pos = _protobuf_scalar(kind, encoded, pos) + if isinstance(value, float) and not math.isfinite(value): + raise ValueError(f"non-finite {kind}") + if kind in _ENUMS: + value = {"code": value, "name": _ENUMS[kind].get(value)} + return value, pos + + result = {} + for number, fields in grouped.items(): + definition = _MESSAGES[message].get(number) + if definition is None: + opaque = [] + for field in fields: + entry = {"wire_type": field.wire_type, "raw": field.encoded.hex()} + if field.wire_type == 0: + _, pos = _pb_read_varint(field.encoded, 0) + entry["value"], _ = _pb_read_varint(field.encoded, pos) + opaque.append(entry) + result[f"field_{number}"] = opaque + continue + name, kind, repeated = definition + expected = 2 if kind in _MESSAGES else 0 if kind in _ENUMS else _SCALAR_WIRE_TYPES[kind] + values = [] + chunks = [] + try: + for field in fields: + packed = repeated and expected != 2 and field.wire_type == 2 + if field.wire_type != expected and not packed: + raise ValueError(f"wire type {field.wire_type}, expected {expected}") + _, pos = _pb_read_varint(field.encoded, 0) + if field.wire_type == 2: + length, pos = _pb_read_varint(field.encoded, pos) + chunk = field.encoded[pos:pos + length] + if packed: + cursor = 0 + while cursor < len(chunk): + value, cursor = scalar(kind, chunk, cursor) + values.append(value) + elif kind in _MESSAGES: + chunks.append(chunk) + elif kind == "string": + values.append(chunk.decode("utf-8")) + else: + values.append("0x" + chunk.hex()) + else: + value, _ = scalar(kind, field.encoded, pos) + values.append(value) + if kind in _MESSAGES: + if repeated: + values = [decode_schema_values(chunk, kind, _depth=_depth + 1) for chunk in chunks] + else: + # Protobuf merges multiple occurrences of a singular message + # within one report. A later report replaces the whole field. + values = [decode_schema_values(b"".join(chunks), kind, _depth=_depth + 1)] + result[name] = values if repeated else values[-1] + except (ValueError, struct.error) as exc: + raise ValueError(f"{message}.{name}: {exc}") from exc + return result + + +# ── Diagnostic formatting ───────────────────────────────────────────────────── + + +def format_protobuf( data: bytes, field_names: dict[int, ProtoField] | None = None, indent: int = 0, ) -> list[str]: - """Decode protobuf wire format into human-readable lines. + """Format protobuf wire values as 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. + 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 @@ -64,7 +185,7 @@ def decode_protobuf( 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 "" + unit = f" {pf.unit}" if pf and pf.unit else "" try: if wire_type == 0: # varint val, pos = _pb_read_varint(data, pos) @@ -79,7 +200,7 @@ def decode_protobuf( length, pos = _pb_read_varint(data, pos) chunk = data[pos:pos + length]; pos += length try: - nested = decode_protobuf(chunk, indent=indent + 1) + nested = format_protobuf(chunk, indent=indent + 1) if nested: lines.append(f"{pad}{label}: {{") lines.extend(nested) @@ -104,173 +225,120 @@ def decode_protobuf( 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"), +# Protobuf types, not value guesses, determine how known fields are rendered. +_SCALAR_WIRE_TYPES = { + **dict.fromkeys(("bool", "int32", "int64", "uint32", "uint64", "sint32", "sint64"), 0), + **dict.fromkeys(("double", "fixed64", "sfixed64"), 1), + "string": 2, "bytes": 2, + **dict.fromkeys(("float", "fixed32", "sfixed32"), 5), } - -# ── 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"), +_FIXED_FORMATS = { + "float": "f", "double": "d", "fixed32": "I", "fixed64": "Q", + "sfixed32": "i", "sfixed64": "q", } -# ── 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"), -} +def _protobuf_scalar(kind: str, data: bytes, pos: int) -> tuple[int | float | bool, int]: + if kind in _FIXED_FORMATS: + fmt = "<" + _FIXED_FORMATS[kind] + value = struct.unpack_from(fmt, data, pos)[0] + return value, pos + struct.calcsize(fmt) + value, pos = _pb_read_varint(data, pos) + if kind == "bool": + return bool(value), pos + if kind == "uint32" and value > 0xFFFFFFFF: + raise ValueError("value outside uint32 range") + if kind.startswith("sint"): + value = (value >> 1) ^ -(value & 1) + elif kind.startswith("int") or kind in _ENUMS: + bits = 64 if kind == "int64" else 32 + value &= (1 << bits) - 1 + if value >= (1 << (bits - 1)): + value -= 1 << bits + return value, pos -# ── Struct decoder ───────────────────────────────────────────────────────────── +def _protobuf_scalar_text(kind: str, value: int | float | bool) -> str: + if kind in _ENUMS: + name = _ENUMS[kind].get(value) + return f"{name} ({value})" if name is not None else f"{value} (unknown {kind})" + if kind == "bool": + return "true" if value else "false" + if kind in ("float", "double"): + return f"{value:.3f}" + return str(value) -def _decode_struct(label: str, fields: list[FieldDef], data: bytes) -> list[str]: +def format_schema_protobuf( + data: bytes, message: str, indent: int = 0, *, _depth: int = 0, +) -> list[str]: + """Format known scalar, enum, packed, string and nested message types. + + Unknown fields remain numeric/raw. Never infer a field's type from an empty + value or label a malformed field as a normal measurement. + """ + from .ecoflow_river3 import PROTO_ANNOTATIONS + + pad = " " * indent + if _depth >= 16: + return [f"{pad}"] + try: + fields = parse_protobuf_fields(data) + except ValueError as exc: + return [f"{pad}"] + definitions = _MESSAGES[message] + annotations = PROTO_ANNOTATIONS.get(message, {}) + lines = [] + for field in fields: + _, pos = _pb_read_varint(field.encoded, 0) + definition = definitions.get(field.number) + if definition is None: + if field.wire_type == 0: + value, _ = _pb_read_varint(field.encoded, pos) + value = str(value) + else: + value = f"0x{field.encoded[pos:].hex()} (wire type {field.wire_type}, unknown field)" + lines.append(f"{pad}field {field.number:3d}: {value}") + continue + name, kind, repeated = definition + annotation = annotations.get(field.number) + unit = f" {annotation.unit}" if annotation and annotation.unit else "" + expected = 2 if kind in _MESSAGES else 0 if kind in _ENUMS else _SCALAR_WIRE_TYPES[kind] + packed = repeated and expected != 2 and field.wire_type == 2 + if field.wire_type != expected and not packed: + lines.append(f"{pad}{name}: ") + continue + try: + if field.wire_type == 2: + length, pos = _pb_read_varint(field.encoded, pos) + chunk = field.encoded[pos:pos + length] + if packed: + values, cursor = [], 0 + while cursor < len(chunk): + value, cursor = _protobuf_scalar(kind, chunk, cursor) + values.append(_protobuf_scalar_text(kind, value)) + lines.append(f"{pad}{name}: [{', '.join(values)}]{unit}") + elif kind in _MESSAGES: + if not chunk: + lines.append(f"{pad}{name}: {{}}") + else: + lines.append(f"{pad}{name}: {{") + lines.extend(format_schema_protobuf(chunk, kind, indent + 1, _depth=_depth + 1)) + lines.append(f"{pad}}}") + elif kind == "string": + lines.append(f"{pad}{name}: {chunk.decode('utf-8')!r}") + else: + lines.append(f"{pad}{name}: 0x{chunk.hex()}") + else: + value, _ = _protobuf_scalar(kind, field.encoded, pos) + lines.append(f"{pad}{name}: {_protobuf_scalar_text(kind, value)}{unit}") + except (ValueError, struct.error) as exc: + lines.append(f"{pad}{name}: ") + return lines + + +def _format_struct(label: str, fields: list[FieldDef], data: bytes) -> list[str]: lines: list[str] = [f"[{label}]"] active = list(fields) while active: @@ -305,21 +373,24 @@ def _decode_struct(label: str, fields: list[FieldDef], data: bytes) -> list[str] return lines -# ── Public API ───────────────────────────────────────────────────────────────── +def _message_definition(src: int, cmd_set: int, cmd_id: int) -> tuple[str, list | dict, str] | None: + from .ecoflow_river2 import MESSAGE_DEFINITIONS as river2 + from .ecoflow_river3 import MESSAGE_DEFINITIONS as river3 + + key = (src, cmd_set, cmd_id) + return river2.get(key) or river3.get(key) - - -def decode_payload( +def format_payload( src: int, cmd_set: int, cmd_id: int, payload: bytes, ) -> list[str] | None: - """Decode a payload by (src, cmd_set, cmd_id). + """Format a payload selected 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. + Return annotated lines, or None if no definition is registered. + Callers can fall back to the generic protobuf/hex formatter on None. """ if not payload: return None @@ -328,31 +399,49 @@ def decode_payload( 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 _format_struct(label, fields, payload) # type: ignore[arg-type] + suffix = ", routing unconfirmed" if label == "RuntimePropertyUpload" else "" + lines = [f"[{label}{suffix}]"] + lines += [" " + ln for ln in format_schema_protobuf(payload, label)] return lines +def describe_protobuf_field(src: int, cmd_set: int, cmd_id: int, number: int) -> str | None: + """Return a registered protobuf field name, or None for an unknown field.""" + entry = _message_definition(src, cmd_set, cmd_id) + if entry is None or entry[2] != "proto": + return None + definition = entry[1].get(number) + return definition.name if definition is not None else None + + 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 +# ── Compatibility entry points ──────────────────────────────────────────────── +# Keep existing imports working. New callers use format_* for diagnostic text +# and import numeric decoders from the corresponding model module. - key = (src, cmd_set, cmd_id) - return MESSAGE_DEFINITIONS.get(key) or _REGISTRY.get(key) +decode_protobuf = format_protobuf +decode_schema_protobuf = format_schema_protobuf +decode_payload = format_payload -def decode_river2_payload(src: int, cmd_set: int, cmd_id: int, payload: bytes): +def decode_river2_payload( + src: int, cmd_set: int, cmd_id: int, payload: bytes, +) -> dict[str, int | float] | None: + """Compatibility entry point for the River 2 numeric decoder.""" 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 +def decode_river3_payload( + src: int, cmd_set: int, cmd_id: int, payload: bytes, +) -> dict[str, int | float] | None: + """Compatibility entry point for the River 3 numeric decoder.""" + from .ecoflow_river3 import decode_river3_payload as decode + + return decode(src, cmd_set, cmd_id, payload) diff --git a/bmspy/ecoflow_river3.py b/bmspy/ecoflow_river3.py new file mode 100644 index 0000000..bf1005b --- /dev/null +++ b/bmspy/ecoflow_river3.py @@ -0,0 +1,339 @@ +"""River 3 field annotations, numeric decoding, and measurement interpretation. + +Names and encoded types come from the generated pr705 schema. Units and +measurement mappings are curated here. Runtime routing remains unconfirmed. +""" + +import json +import math +import struct + +from bmspy.classes import BMSField, BMSInfoField, BMSMultiField, BMSScalarField +from bmspy.ecoflow_proto import ( + ProtoField, _pb_read_varint, decode_schema_values, parse_protobuf_fields, +) +from bmspy.ecoflow_river3_schema import ENUMS as _ENUMS, MESSAGES as _MESSAGES +from bmspy.ecoflow_snapshot import EcoFlowSnapshot + +SERIAL_PREFIXES = ("R651", "R653", "R654", "R655") + + +# ── River 3 — DisplayPropertyUpload field map ────────────────────────────────── +# Curated unit annotations. Complete names/types come from the pinned pr705 +# schema below, including fields not yet observed on a physical device. +# 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 ────────────────────────────────── +# Curated unit annotations; complete names/types come from pr705. +# The 0x16 routing is unconfirmed for River 3. Keep it diagnostic-only: +# do not require this message for collection or map it into measurements. + +_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"), +} + + +def _complete_proto_fields(message: str, annotations: dict[int, ProtoField]) -> dict[int, ProtoField]: + result = {} + for number, (name, kind, _) in _MESSAGES[message].items(): + annotation = annotations.get(number) + # Never carry a legacy unit across a renamed or repurposed field. + unit = annotation.unit if annotation and annotation.name == name else "" + result[number] = ProtoField(name, unit, signed=kind in ("int32", "int64")) + return result + + +_DISPLAY_PROPERTY_UPLOAD = _complete_proto_fields("DisplayPropertyUpload", _DISPLAY_PROPERTY_UPLOAD) +_RUNTIME_PROPERTY_UPLOAD = _complete_proto_fields("RuntimePropertyUpload", _RUNTIME_PROPERTY_UPLOAD) + + +PROTO_ANNOTATIONS = { + "DisplayPropertyUpload": _DISPLAY_PROPERTY_UPLOAD, + "RuntimePropertyUpload": _RUNTIME_PROPERTY_UPLOAD, +} + +MESSAGE_DEFINITIONS = { + (0x02, 0xFE, 0x15): ("DisplayPropertyUpload", _DISPLAY_PROPERTY_UPLOAD, "proto"), + (0x02, 0xFE, 0x16): ("RuntimePropertyUpload", _RUNTIME_PROPERTY_UPLOAD, "proto"), +} + + +# Verified DisplayPropertyUpload fields shared with proto/pr705.proto. +# Wire type 5 is float32; wire type 0 is uint32 or signed int32. +_RIVER3_TELEMETRY_WIRE_TYPES = { + 3: 5, 4: 5, 158: 5, 262: 5, + 102: 0, 259: 0, 268: 0, 269: 0, 282: 0, +} + + +def decode_river3_payload( + src: int, + cmd_set: int, + cmd_id: int, + payload: bytes, +) -> dict[str, int | float] | None: + """Read known River 3 measurements, preserving protobuf field presence. + + Reports can be partial: absent fields are not zero measurements. Unknown + fields are skipped, but malformed messages are rejected as a whole. This + decoder does not interpret unconfirmed runtime message routing. + """ + if (src, cmd_set, cmd_id) != (0x02, 0xFE, 0x15): + return None + values: dict[str, int | float] = {} + try: + for field in parse_protobuf_fields(payload): + if _RIVER3_TELEMETRY_WIRE_TYPES.get(field.number) != field.wire_type: + continue + definition = _DISPLAY_PROPERTY_UPLOAD[field.number] + _, pos = _pb_read_varint(field.encoded, 0) + if field.wire_type == 0: + value, _ = _pb_read_varint(field.encoded, pos) + if definition.signed: + value &= 0xFFFFFFFF + if value >= (1 << 31): + value -= 1 << 32 + elif value > 0xFFFFFFFF: + continue + else: + value = struct.unpack_from(" None: + """Expose all reported display fields, not just the normalized summary.""" + namespace = "ef_river3_display" + try: + values = decode_schema_values(payload, "DisplayPropertyUpload") + except ValueError as exc: + snapshot.record_protocol_error(namespace, str(exc)) + return + definitions = { + name: (number, kind, repeated) + for number, (name, kind, repeated) in _MESSAGES["DisplayPropertyUpload"].items() + } + fields: dict[str, BMSField] = {} + for name, value in values.items(): + help = f"River 3 DisplayPropertyUpload.{name} (reported protocol value)" + definition = definitions.get(name) + if definition is None: + help += "; unknown field encoding" + if definition is not None: + number, kind, repeated = definition + annotation = _DISPLAY_PROPERTY_UPLOAD.get(number) + units = annotation.unit or None if annotation else None + if not repeated and kind in _ENUMS: + code, label = value["code"], value["name"] + text = f"{label} ({code})" if label is not None else f"{code} (unknown {kind})" + fields[name] = BMSScalarField(help=help, raw_value=code, value=text, units=units) + continue + if not repeated and isinstance(value, (int, float, bool)): + fields[name] = BMSScalarField(help=help, raw_value=value, value=str(value), units=units) + continue + # JSON preserves nested/repeated groups and distinguishes {}, [], and "". + # Strings stay strings; do not guess nested messages from their contents. + text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, allow_nan=False) + fields[name] = BMSInfoField(help=help, info=text) + snapshot.record_protocol_fields(namespace, fields) + + +class River3Decoder: + """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._river3_temp_priority = 0 + + def update( + self, snapshot: EcoFlowSnapshot, src: int, cmd_set: int, cmd_id: int, payload: bytes, + ) -> None: + if (src, cmd_set, cmd_id) == (0x02, 0xFE, 0x15): + _record_display_fields(snapshot, payload) + values = decode_river3_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, + )) + + soc = values.get("cms_batt_soc") + if soc is not None and math.isfinite(soc) and 0 <= soc <= 100: + scalar("bms_capacity_charge_ratio", "Battery Charge", soc / 100) + for source, name, help in ( + ("pow_in_sum_w", "ef_input_power_watts", "Total Input Power"), + ("pow_out_sum_w", "ef_output_power_watts", "Total Output Power"), + ): + watts = values.get(source) + if watts is not None and math.isfinite(watts) and watts >= 0: + scalar(name, help, watts, "W") + + # Upstream uses the maximum cell temperature. Prefer that measurement + # over the composite temperature, even if separate packets arrive later. + for source, priority in (("cms_batt_temp", 1), ("bms_max_cell_temp", 2)): + temp = values.get(source) + if temp is not None and priority >= self._river3_temp_priority: + snapshot.bms_temperature_celcius = BMSMultiField( + help="Battery Temperature", label="sensor", + raw_values={1: temp}, values={1: str(temp)}, units="°C", + ) + self._river3_temp_priority = priority + + # Signed battery-side watts, not total AC input, determine direction. + watts = values.get("pow_get_bms") + if watts is not None and math.isfinite(watts): + scalar("bms_charge_is_charging", "Battery Charging", watts > 0) + scalar("bms_charge_is_discharging", "Battery Discharging", watts < 0) + if "cms_chg_dsg_state" in values: + scalar("ef_charge_state", "Charge State (device code)", values["cms_chg_dsg_state"]) + for source, name, help in ( + ("cms_chg_rem_time", "ef_charge_remaining_time_minutes", "Time to Charge"), + ("cms_dsg_rem_time", "ef_discharge_remaining_time_minutes", "Time to Discharge"), + ): + if source in values: + if values[source] == 0xFFFFFFFF: + setattr(snapshot, name, None) + else: + # Do not assume River 2's 5999 convention applies here. + scalar(name, help, values[source], "min") + + snapshot.ef_remaining_time_minutes = None + for state, remaining in ( + (snapshot.bms_charge_is_charging, snapshot.ef_charge_remaining_time_minutes), + (snapshot.bms_charge_is_discharging, snapshot.ef_discharge_remaining_time_minutes), + ): + if state is not None and state.raw_value and remaining is not None: + scalar("ef_remaining_time_minutes", "Remaining Time", remaining.raw_value, "min") + break diff --git a/bmspy/ecoflow_river3_schema.py b/bmspy/ecoflow_river3_schema.py new file mode 100644 index 0000000..9978daa --- /dev/null +++ b/bmspy/ecoflow_river3_schema.py @@ -0,0 +1,559 @@ +# SPDX-License-Identifier: Apache-2.0 +# Source: https://github.com/rabits/ha-ef-ble/blob/511e04702c83ddcc90cdcd575a1bbeffe2f061ad/proto/pr705.proto +# Generated by tools/generate_ecoflow_river3_schema.py. Do not edit by hand. + +SCHEMA_COMMIT = '511e04702c83ddcc90cdcd575a1bbeffe2f061ad' + +# Field number: (name, protobuf type, repeated). Units are curated separately. +MESSAGES = { + 'DisplayPropertyUpload': { + 1: ('errcode', 'uint32', False), + 2: ('sys_status', 'uint32', False), + 3: ('pow_in_sum_w', 'float', False), + 4: ('pow_out_sum_w', 'float', False), + 5: ('lcd_light', 'uint32', False), + 6: ('energy_backup_state', 'uint32', False), + 7: ('energy_backup_en', 'bool', False), + 8: ('energy_backup_start_soc', 'uint32', False), + 9: ('pow_get_qcusb1', 'float', False), + 10: ('pow_get_qcusb2', 'float', False), + 11: ('pow_get_typec1', 'float', False), + 12: ('pow_get_typec2', 'float', False), + 13: ('flow_info_qcusb1', 'uint32', False), + 14: ('flow_info_qcusb2', 'uint32', False), + 15: ('flow_info_typec1', 'uint32', False), + 16: ('flow_info_typec2', 'uint32', False), + 17: ('dev_standby_time', 'uint32', False), + 18: ('screen_off_time', 'uint32', False), + 19: ('ac_standby_time', 'uint32', False), + 20: ('dc_standby_time', 'uint32', False), + 22: ('ac_always_on_flag', 'bool', False), + 23: ('ac_always_on_mini_soc', 'uint32', False), + 25: ('xboost_en', 'bool', False), + 30: ('pcs_fan_level', 'uint32', False), + 31: ('flow_info_pv_h', 'uint32', False), + 32: ('flow_info_pv_l', 'uint32', False), + 33: ('flow_info_12v', 'uint32', False), + 34: ('flow_info_24v', 'uint32', False), + 35: ('pow_get_pv_h', 'float', False), + 36: ('pow_get_pv_l', 'float', False), + 37: ('pow_get_12v', 'float', False), + 38: ('pow_get_24v', 'float', False), + 39: ('plug_in_info_pv_h_flag', 'uint32', False), + 40: ('plug_in_info_pv_h_type', 'uint32', False), + 42: ('plug_in_info_pv_l_flag', 'uint32', False), + 43: ('plug_in_info_pv_l_type', 'uint32', False), + 45: ('flow_info_ac2dc', 'uint32', False), + 46: ('flow_info_dc2ac', 'uint32', False), + 47: ('flow_info_ac_in', 'uint32', False), + 48: ('flow_info_ac_hv_out', 'uint32', False), + 49: ('flow_info_ac_lv_out', 'uint32', False), + 50: ('flow_info_5p8_in', 'uint32', False), + 51: ('flow_info_5p8_out', 'uint32', False), + 52: ('pow_get_llc', 'float', False), + 53: ('pow_get_ac', 'float', False), + 54: ('pow_get_ac_in', 'float', False), + 55: ('pow_get_ac_hv_out', 'float', False), + 56: ('pow_get_ac_lv_out', 'float', False), + 57: ('pow_get_ac_lv_tt30_out', 'float', False), + 58: ('pow_get_5p8', 'float', False), + 61: ('plug_in_info_ac_in_flag', 'uint32', False), + 62: ('plug_in_info_ac_in_feq', 'uint32', False), + 63: ('plug_in_info_5p8_flag', 'uint32', False), + 64: ('plug_in_info_5p8_type', 'uint32', False), + 65: ('plug_in_info_5p8_detail', 'uint32', False), + 70: ('pow_get_pv2', 'float', False), + 72: ('btn_ac_out_lv_switch', 'bool', False), + 73: ('btn_ac_out_hv_switch', 'bool', False), + 74: ('dc_out_open', 'bool', False), + 75: ('btn_dc_12v_out_switch', 'bool', False), + 76: ('btn_usb_switch', 'bool', False), + 77: ('pow_get_dcp2', 'float', False), + 78: ('flow_info_dcp2_in', 'uint32', False), + 79: ('flow_info_dcp2_out', 'uint32', False), + 80: ('plug_in_info_pv2_dc_amp_max', 'uint32', False), + 81: ('plug_in_info_pv2_chg_amp_max', 'uint32', False), + 82: ('plug_in_info_pv2_chg_vol_max', 'uint32', False), + 83: ('plug_in_info_dcp2_in_flag', 'bool', False), + 84: ('plug_in_info_dcp2_dsg_chg_type', 'uint32', False), + 85: ('plug_in_info_dcp2_charger_flag', 'bool', False), + 86: ('plug_in_info_dcp2_type', 'uint32', False), + 87: ('plug_in_info_dcp2_detail', 'uint32', False), + 88: ('plug_in_info_dcp2_sn', 'string', False), + 89: ('plug_in_info_dcp2_run_state', 'uint32', False), + 90: ('plug_in_info_dcp2_firm_ver', 'uint32', False), + 91: ('plug_in_info_dcp2_resv', 'ResvInfo', False), + 102: ('cms_batt_temp', 'int32', False), + 105: ('pow_get_dc_bidi', 'float', False), + 106: ('plug_in_info_dc_bidi_flag', 'bool', False), + 107: ('plug_in_info_acp_chg_pow_max', 'uint32', False), + 108: ('plug_in_info_acp_chg_hal_pow_max', 'uint32', False), + 109: ('plug_in_info_acp_dsg_pow_max', 'uint32', False), + 110: ('plug_in_info_acp_flag', 'uint32', False), + 111: ('plug_in_info_acp_dsg_chg', 'uint32', False), + 112: ('plug_in_info_acp_charger_flag', 'bool', False), + 113: ('plug_in_info_acp_type', 'uint32', False), + 114: ('plug_in_info_acp_detail', 'uint32', False), + 115: ('plug_in_info_acp_sn', 'string', False), + 116: ('plug_in_info_acp_run_state', 'uint32', False), + 117: ('plug_in_info_acp_err_code', 'uint32', False), + 118: ('plug_in_info_acp_firm_ver', 'uint32', False), + 119: ('plug_in_info_acp_resv', 'ResvInfo', False), + 123: ('generator_conn_dev_errcode', 'uint32', False), + 124: ('plug_in_info_ac_in_chg_mode', 'AC_IN_CHG_MODE', False), + 125: ('pv_dc_chg_setting_list', 'PvDcChgSettingList', False), + 126: ('current_time_task_v2_item', 'TimeTaskItemV2', False), + 127: ('generator_low_power_en', 'bool', False), + 128: ('generator_low_power_threshold', 'uint32', False), + 129: ('generator_lpg_monitor_en', 'bool', False), + 130: ('dev_online_flag', 'bool', False), + 131: ('fuels_liquefied_gas_lpg_uint', 'uint32', False), + 132: ('fuels_liquefied_gas_lng_uint', 'uint32', False), + 133: ('utc_timezone', 'int32', False), + 134: ('utc_timezone_id', 'string', False), + 135: ('utc_set_mode', 'bool', False), + 136: ('plug_in_info_pv_chg_max_list', 'PvChgMaxList', False), + 137: ('plug_in_info_pv2_chg_max_list', 'PvChgMaxList', False), + 138: ('sp_charger_car_batt_vol_setting', 'uint32', False), + 139: ('sp_charger_car_batt_vol', 'float', False), + 140: ('bms_err_code', 'uint32', False), + 141: ('err_code_record_list', 'ErrcodeRecordList', False), + 142: ('wireless_coordinate_dev_list', 'WirelessCoordinateList', False), + 143: ('wireless_oil_self_start', 'bool', False), + 144: ('wireless_oil_on_soc', 'uint32', False), + 145: ('wireless_oil_off_soc', 'uint32', False), + 146: ('bypass_out_disable', 'bool', False), + 147: ('output_power_off_memory', 'bool', False), + 148: ('pv_chg_type', 'uint32', False), + 152: ('flow_info_bms_dsg', 'uint32', False), + 153: ('flow_info_bms_chg', 'uint32', False), + 154: ('flow_info_4p8_1_in', 'uint32', False), + 155: ('flow_info_4p8_1_out', 'uint32', False), + 156: ('flow_info_4p8_2_in', 'uint32', False), + 157: ('flow_info_4p8_2_out', 'uint32', False), + 158: ('pow_get_bms', 'float', False), + 159: ('pow_get_4p8_1', 'float', False), + 160: ('pow_get_4p8_2', 'float', False), + 161: ('plug_in_info_4p8_1_in_flag', 'uint32', False), + 162: ('plug_in_info_4p8_1_type', 'uint32', False), + 163: ('plug_in_info_4p8_1_detail', 'uint32', False), + 164: ('plug_in_info_4p8_2_in_flag', 'uint32', False), + 165: ('plug_in_info_4p8_2_type', 'uint32', False), + 166: ('plug_in_info_4p8_2_detail', 'uint32', False), + 170: ('plug_in_info_pv_l_charger_flag', 'bool', False), + 171: ('plug_in_info_pv_h_charger_flag', 'bool', False), + 180: ('plug_in_info_pv_l_dc_amp_max', 'uint32', False), + 181: ('plug_in_info_pv_h_dc_amp_max', 'uint32', False), + 182: ('fast_charge_switch', 'uint32', False), + 183: ('plug_in_info_4p8_1_dsg_chg_type', 'uint32', False), + 184: ('plug_in_info_4p8_1_resv', 'ResvInfo', False), + 185: ('plug_in_info_4p8_1_sn', 'string', False), + 186: ('plug_in_info_4p8_1_firm_ver', 'uint32', False), + 187: ('plug_in_info_4p8_2_dsg_chg_type', 'uint32', False), + 188: ('plug_in_info_4p8_2_resv', 'ResvInfo', False), + 189: ('plug_in_info_4p8_2_sn', 'string', False), + 190: ('plug_in_info_4p8_2_firm_ver', 'uint32', False), + 191: ('plug_in_info_5p8_dsg_chg', 'uint32', False), + 192: ('plug_in_info_5p8_resv', 'ResvInfo', False), + 193: ('plug_in_info_5p8_sn', 'string', False), + 194: ('plug_in_info_5p8_firm_ver', 'uint32', False), + 195: ('en_beep', 'bool', False), + 200: ('llc_GFCI_flag', 'bool', False), + 202: ('plug_in_info_ac_charger_flag', 'bool', False), + 203: ('plug_in_info_5p8_charger_flag', 'bool', False), + 204: ('plug_in_info_5p8_run_state', 'uint32', False), + 205: ('plug_in_info_4p8_1_charger_flag', 'bool', False), + 206: ('plug_in_info_4p8_1_run_state', 'uint32', False), + 207: ('plug_in_info_4p8_2_charger_flag', 'bool', False), + 208: ('plug_in_info_4p8_2_run_state', 'uint32', False), + 209: ('plug_in_info_ac_in_chg_pow_max', 'uint32', False), + 210: ('plug_in_info_5p8_chg_pow_max', 'uint32', False), + 211: ('ac_out_freq', 'uint32', False), + 212: ('dev_sleep_state', 'uint32', False), + 213: ('pd_err_code', 'uint32', False), + 214: ('llc_err_code', 'uint32', False), + 215: ('mppt_err_code', 'uint32', False), + 216: ('plug_in_info_5p8_err_code', 'uint32', False), + 217: ('plug_in_info_4p8_1_err_code', 'uint32', False), + 218: ('plug_in_info_4p8_2_err_code', 'uint32', False), + 219: ('time_task_current', 'SetTimeTaskWrite', False), + 226: ('pcs_fan_err_flag', 'uint32', False), + 227: ('llc_hv_lv_flag', 'uint32', False), + 232: ('llc_inv_err_code', 'uint32', False), + 233: ('plug_in_info_pv_h_chg_vol_max', 'uint32', False), + 234: ('plug_in_info_pv_l_chg_vol_max', 'uint32', False), + 235: ('plug_in_info_pv_l_chg_amp_max', 'uint32', False), + 236: ('plug_in_info_pv_h_chg_amp_max', 'uint32', False), + 237: ('plug_in_info_5p8_dsg_pow_max', 'uint32', False), + 238: ('plug_in_info_ac_out_dsg_pow_max', 'uint32', False), + 242: ('bms_batt_soc', 'float', False), + 243: ('bms_batt_soh', 'float', False), + 248: ('bms_design_cap', 'uint32', False), + 254: ('bms_dsg_rem_time', 'uint32', False), + 255: ('bms_chg_rem_time', 'uint32', False), + 258: ('bms_min_cell_temp', 'int32', False), + 259: ('bms_max_cell_temp', 'int32', False), + 260: ('bms_min_mos_temp', 'int32', False), + 261: ('bms_max_mos_temp', 'int32', False), + 262: ('cms_batt_soc', 'float', False), + 263: ('cms_batt_soh', 'float', False), + 268: ('cms_dsg_rem_time', 'uint32', False), + 269: ('cms_chg_rem_time', 'uint32', False), + 270: ('cms_max_chg_soc', 'uint32', False), + 271: ('cms_min_dsg_soc', 'uint32', False), + 272: ('cms_oil_on_soc', 'uint32', False), + 273: ('cms_oil_off_soc', 'uint32', False), + 274: ('cms_oil_self_start', 'bool', False), + 275: ('cms_bms_run_state', 'uint32', False), + 281: ('bms_chg_dsg_state', 'uint32', False), + 282: ('cms_chg_dsg_state', 'uint32', False), + 283: ('ac_hv_always_on', 'bool', False), + 284: ('ac_lv_always_on', 'bool', False), + 285: ('time_task_conflict_flag', 'uint32', False), + 286: ('time_task_change_cnt', 'uint32', False), + 287: ('cms_batt_full_cap', 'uint32', False), + 288: ('cms_batt_design_cap', 'uint32', False), + 289: ('cms_batt_remain_cap', 'uint32', False), + 290: ('ble_standby_time', 'uint32', False), + 297: ('pow_get_dc', 'float', False), + 298: ('ac_out_open', 'bool', False), + 299: ('generator_fuels_type', 'uint32', False), + 300: ('generator_remain_time', 'uint32', False), + 301: ('generator_run_time', 'uint32', False), + 302: ('generator_total_output', 'uint32', False), + 303: ('generator_abnormal_state', 'uint32', False), + 304: ('fuels_oil_val', 'uint32', False), + 305: ('fuels_liquefied_gas_type', 'uint32', False), + 306: ('fuels_liquefied_gas_uint', 'uint32', False), + 307: ('fuels_liquefied_gas_val', 'float', False), + 308: ('fuels_liquefied_gas_consume_per_hour', 'float', False), + 309: ('fuels_liquefied_gas_remain_val', 'uint32', False), + 310: ('generator_perf_mode', 'uint32', False), + 311: ('generator_engine_open', 'uint32', False), + 312: ('generator_out_pow_max', 'uint32', False), + 313: ('generator_ac_out_pow_max', 'uint32', False), + 314: ('generator_dc_out_pow_max', 'uint32', False), + 315: ('generator_sub_battery_temp', 'int32', False), + 316: ('generator_sub_battery_soc', 'uint32', False), + 317: ('generator_sub_battery_state', 'uint32', False), + 319: ('generator_maintence_state', 'bool', False), + 330: ('generator_pcs_err_code', 'uint32', False), + 355: ('ups_alram', 'bool', False), + 356: ('plug_in_info_pv_dc_amp_max', 'uint32', False), + 357: ('led_mode', 'uint32', False), + 358: ('low_power_alarm', 'uint32', False), + 359: ('silence_chg_watt', 'uint32', False), + 360: ('flow_info_pv', 'uint32', False), + 361: ('pow_get_pv', 'float', False), + 362: ('plug_in_info_pv_flag', 'bool', False), + 363: ('plug_in_info_pv_type', 'uint32', False), + 364: ('plug_in_info_pv_charger_flag', 'bool', False), + 365: ('plug_in_info_pv_chg_amp_max', 'uint32', False), + 366: ('plug_in_info_pv_chg_vol_max', 'uint32', False), + 367: ('flow_info_ac_out', 'uint32', False), + 368: ('pow_get_ac_out', 'float', False), + 414: ('flow_info_pv2', 'uint32', False), + 421: ('plug_in_info_pv2_flag', 'bool', False), + 422: ('plug_in_info_pv2_type', 'uint32', False), + 423: ('flow_info_dcp_in', 'uint32', False), + 424: ('flow_info_dcp_out', 'uint32', False), + 425: ('pow_get_dcp', 'float', False), + 426: ('plug_in_info_dcp_in_flag', 'bool', False), + 427: ('plug_in_info_dcp_type', 'uint32', False), + 428: ('plug_in_info_dcp_detail', 'uint32', False), + 430: ('plug_in_info_pv2_charger_flag', 'bool', False), + 431: ('plug_in_info_dcp_dsg_chg_type', 'uint32', False), + 432: ('plug_in_info_dcp_resv', 'ResvInfo', False), + 433: ('plug_in_info_dcp_sn', 'string', False), + 434: ('plug_in_info_dcp_firm_ver', 'uint32', False), + 435: ('plug_in_info_dcp_charger_flag', 'bool', False), + 436: ('plug_in_info_dcp_run_state', 'uint32', False), + 437: ('dcdc_err_code', 'uint32', False), + 438: ('plug_in_info_dcp_err_code', 'uint32', False), + 439: ('plug_in_info_dcp2_err_code', 'uint32', False), + 450: ('inv_err_code', 'uint32', False), + 451: ('generator_pv_hybrid_mode_open', 'bool', False), + 452: ('generator_pv_hybrid_mode_soc_max', 'uint32', False), + 453: ('generator_care_mode_open', 'bool', False), + 454: ('generator_care_mode_start_time', 'uint32', False), + 455: ('ac_energy_saving_open', 'bool', False), + 456: ('multi_bp_chg_dsg_mode', 'uint32', False), + 457: ('plug_in_info_5p8_chg_hal_pow_max', 'uint32', False), + 458: ('plug_in_info_ac_in_chg_hal_pow_max', 'uint32', False), + 459: ('cms_batt_pow_out_max', 'uint32', False), + 460: ('cms_batt_pow_in_max', 'uint32', False), + 462: ('cms_batt_full_energy', 'uint32', False), + 463: ('display_statistics_sum', 'DisplayStatisticsRecordList', False), + 467: ('storm_pattern_enable', 'bool', False), + 468: ('storm_pattern_open_flag', 'bool', False), + 469: ('storm_pattern_end_time', 'uint32', False), + 581: ('sp_charger_chg_mode', 'SP_CHARGER_CHG_MODE', False), + 582: ('sp_charger_run_state', 'uint32', False), + 583: ('sp_charger_is_connect_car', 'bool', False), + 591: ('installment_payment_serve_enable', 'bool', False), + 592: ('serve_middlemen', 'SERVE_MIDDLEMEN', False), + 593: ('installment_payment_overdue_limit', 'INSTALLMENT_PAYMENT_OVERDUE_LIMIT', False), + 594: ('installment_payment_state', 'INSTALLMENT_PAYMENT_STATE', False), + 595: ('installment_payment_start_utc_time', 'uint32', False), + 596: ('installment_payment_overdue_limit_utc_time', 'uint32', False), + 597: ('sp_charger_chg_open', 'bool', False), + 598: ('sp_charger_chg_pow_limit', 'float', False), + 599: ('module_bluetooth_snr', 'float', False), + 600: ('module_bluetooth_rssi', 'float', False), + 601: ('module_wifi_snr', 'float', False), + 602: ('module_wifi_rssi', 'float', False), + 603: ('sp_charger_chg_pow_max', 'float', False), + }, + 'DisplayStatisticsRecordList': { + 1: ('list_info', 'StatisticsRecordItem', True), + }, + 'ErrcodeRecordItem': { + 1: ('errcode', 'uint32', False), + 2: ('errcode_timestamp', 'uint32', False), + }, + 'ErrcodeRecordList': { + 1: ('list_info', 'ErrcodeRecordItem', True), + }, + 'PvChgMaxItem': { + 1: ('pv_chg_vol_type', 'uint32', False), + 2: ('pv_chg_amp_max', 'uint32', False), + 3: ('pv_chg_amp_mini', 'uint32', False), + }, + 'PvChgMaxList': { + 1: ('pv_chg_max_item', 'PvChgMaxItem', True), + }, + 'PvDcChgSetting': { + 1: ('pv_plug_index', 'PV_PLUG_INDEX', False), + 2: ('pv_chg_vol_spec', 'PV_CHG_VOL_SPEC', False), + 3: ('pv_chg_amp_limit', 'uint32', False), + }, + 'PvDcChgSettingList': { + 1: ('list_info', 'PvDcChgSetting', True), + }, + 'ResvInfo': { + 1: ('resv_info', 'uint32', True), + }, + 'RuntimePropertyUpload': { + 21: ('ac_phase_type', 'uint32', False), + 24: ('pcs_work_mode', 'uint32', False), + 26: ('temp_pcs_dc', 'float', False), + 27: ('temp_pcs_ac', 'float', False), + 28: ('temp_pv_h', 'float', False), + 29: ('temp_pv_l', 'float', False), + 41: ('plug_in_info_pv_h_vol', 'float', False), + 44: ('plug_in_info_pv_l_vol', 'float', False), + 59: ('plug_in_info_ac_out_type', 'uint32', False), + 60: ('plug_in_info_ac_out_freq', 'uint32', False), + 66: ('plug_in_info_5p8_freq', 'uint32', False), + 67: ('plug_in_info_ac_out_vol', 'float', False), + 68: ('plug_in_info_ac_in_vol', 'float', False), + 69: ('plug_in_info_5p8_vol', 'float', False), + 71: ('plug_in_info_pv2_amp', 'float', False), + 92: ('plug_in_info_dcp2_vol', 'float', False), + 93: ('plug_in_info_dcp2_amp', 'float', False), + 103: ('plug_in_info_dc_bidi_vol', 'float', False), + 104: ('plug_in_info_dc_bidi_amp', 'float', False), + 120: ('plug_in_info_acp_vol', 'float', False), + 121: ('plug_in_info_acp_amp', 'float', False), + 122: ('plug_in_info_acp_freq', 'uint32', False), + 167: ('plug_in_info_4p8_1_vol', 'float', False), + 168: ('plug_in_info_4p8_2_vol', 'float', False), + 169: ('plug_in_info_bms_vol', 'float', False), + 172: ('pd_mppt_comm_err', 'bool', False), + 173: ('pd_llc_comm_err', 'bool', False), + 174: ('pd_bms_comm_err', 'bool', False), + 175: ('pd_iot_comm_err', 'bool', False), + 176: ('pd_firm_ver', 'uint32', False), + 177: ('iot_firm_ver', 'uint32', False), + 178: ('mppt_firm_ver', 'uint32', False), + 179: ('llc_firm_ver', 'uint32', False), + 221: ('plug_in_info_pv_h_amp', 'float', False), + 222: ('plug_in_info_pv_l_amp', 'float', False), + 223: ('plug_in_info_ac_in_amp', 'float', False), + 224: ('plug_in_info_ac_out_amp', 'float', False), + 225: ('plug_in_info_5p8_amp', 'float', False), + 231: ('llc_inv_firm_ver', 'uint32', False), + 239: ('plug_in_info_4p8_1_amp', 'float', False), + 240: ('plug_in_info_4p8_2_amp', 'float', False), + 241: ('bms_firm_ver', 'uint32', False), + 244: ('bms_batt_vol', 'float', False), + 245: ('bms_batt_amp', 'float', False), + 246: ('bms_bal_state', 'uint32', False), + 247: ('bms_full_cap', 'uint32', False), + 249: ('bms_remain_cap', 'uint32', False), + 250: ('bms_alm_state', 'uint32', False), + 251: ('bms_pro_state', 'uint32', False), + 252: ('bms_flt_state', 'uint32', False), + 253: ('bms_err_code', 'uint32', False), + 256: ('bms_min_cell_vol', 'uint32', False), + 257: ('bms_max_cell_vol', 'uint32', False), + 264: ('cms_batt_vol', 'float', False), + 265: ('cms_batt_amp', 'float', False), + 266: ('cms_chg_req_vol', 'float', False), + 267: ('cms_chg_req_amp', 'float', False), + 276: ('bms_overload_icon', 'uint32', False), + 277: ('bms_warn_icon', 'uint32', False), + 278: ('bms_high_temp_icon', 'uint32', False), + 279: ('bms_low_temp_icon', 'uint32', False), + 280: ('bms_limit_icon', 'uint32', False), + 291: ('bms_alm_state_2', 'uint32', False), + 292: ('bms_pro_state_2', 'uint32', False), + 293: ('display_property_full_upload_period', 'int32', False), + 294: ('display_property_incremental_upload_period', 'int32', False), + 295: ('runtime_property_full_upload_period', 'int32', False), + 296: ('runtime_property_incremental_upload_period', 'int32', False), + 320: ('generator_run_state', 'uint32', False), + 321: ('generator_oil_val_real', 'uint32', False), + 322: ('generator_engine_spd', 'int32', False), + 323: ('generator_engine_oil_val', 'uint32', False), + 324: ('generator_engine_activation_cnt', 'uint32', False), + 325: ('generator_engine_head_temp', 'int32', False), + 326: ('generator_sub_battery_vol', 'uint32', False), + 327: ('generator_sub_battery_amp', 'int32', False), + 328: ('generator_commc_cnt_ems_to_psdr', 'uint32', False), + 329: ('generator_commc_cnt_psdr_to_ems', 'uint32', False), + 331: ('generator_pcs_fan_level', 'uint32', False), + 332: ('generator_pcs_bus_vol', 'float', False), + 333: ('generator_pcs_bus_amp', 'float', False), + 334: ('generator_pcs_temp1', 'int32', False), + 335: ('generator_pcs_temp2', 'int32', False), + 336: ('generator_pcs_fules_type', 'uint32', False), + 379: ('temp_pv', 'float', False), + 380: ('plug_in_info_pv_vol', 'float', False), + 381: ('plug_in_info_pv_amp', 'float', False), + 382: ('plug_in_info_12v_vol', 'float', False), + 383: ('plug_in_info_12v_amp', 'float', False), + 440: ('temp_pv2', 'float', False), + 442: ('plug_in_info_pv2_vol', 'float', False), + 443: ('plug_in_info_dcp_vol', 'float', False), + 444: ('pd_dcdc_comm_err', 'bool', False), + 445: ('pd_inv_comm_err', 'bool', False), + 446: ('dcdc_firm_ver', 'uint32', False), + 447: ('inv_firm_ver', 'uint32', False), + 448: ('plug_in_info_dcp_amp', 'float', False), + 464: ('runtime_statistics_sum', 'RuntimeStatisticsRecordList', False), + }, + 'RuntimeStatisticsRecordList': { + 1: ('list_info', 'StatisticsRecordItem', True), + }, + 'SetTimeTaskWrite': { + 1: ('task_index', 'uint32', False), + 2: ('is_valid', 'bool', False), + 3: ('is_cfg', 'bool', False), + 4: ('is_enable', 'bool', False), + 5: ('conflict_flag', 'uint32', False), + 6: ('type', 'uint32', False), + 7: ('time_mode', 'uint32', False), + 8: ('time_param', 'uint32', False), + 9: ('time_table', 'uint32', True), + }, + 'StatisticsRecordItem': { + 1: ('statistics_object', 'STATISTICS_OBJECT', False), + 2: ('statistics_content', 'uint32', False), + }, + 'TimeTaskItemV2': { + 1: ('task_index', 'uint32', False), + 2: ('is_cfg', 'bool', False), + 3: ('is_enable', 'bool', False), + 4: ('conflict_flag', 'uint32', False), + 5: ('time_mode', 'TIME_TASK_MODE', False), + 6: ('time_param', 'uint32', False), + 7: ('time_table', 'uint32', False), + 8: ('task_type', 'TIME_TASK_TYPE', False), + 9: ('task_param', 'uint32', False), + }, + 'WirelessCoordinateList': { + 1: ('connect_flag', 'bool', False), + 2: ('dev_type', 'uint32', False), + 3: ('dev_detail', 'uint32', False), + 4: ('dev_sn', 'string', False), + 5: ('dev_firm_ver', 'uint32', False), + 6: ('dev_err_code', 'uint32', False), + }, +} + +ENUMS = { + 'AC_IN_CHG_MODE': { + 0: 'AC_IN_CHG_MODE_SELF_DEF_POW', + 1: 'AC_IN_CHG_MODE_BAT_OPTIMAL_POW', + 2: 'AC_IN_CHG_MODE_SILENCE', + }, + 'INSTALLMENT_PAYMENT_OVERDUE_LIMIT': { + 0: 'INSTALLMENT_PAYMENT_OVERDUE_LIMIT_NONE', + 1: 'INSTALLMENT_PAYMENT_OVERDUE_LIMIT_CLOSE_OUTPUT', + }, + 'INSTALLMENT_PAYMENT_STATE': { + 0: 'INSTALLMENT_PAYMENT_STATE_WAIT_FOT_ACTIVATE', + 1: 'INSTALLMENT_PAYMENT_STATE_ACTIVATED', + 2: 'INSTALLMENT_PAYMENT_STATE_OVERDUE', + 3: 'INSTALLMENT_PAYMENT_STATE_SETTLE', + 4: 'INSTALLMENT_PAYMENT_STATE_MANUAL_LOCKDOWN', + }, + 'PV_CHG_VOL_SPEC': { + 0: 'PV_CHG_VOL_SPEC_RESV', + 1: 'PV_CHG_VOL_SPEC_12V', + 2: 'PV_CHG_VOL_SPEC_24V', + 3: 'PV_CHG_VOL_SPEC_48V', + }, + 'PV_PLUG_INDEX': { + 0: 'PV_PLUG_INDEX_RESV', + 1: 'PV_PLUG_INDEX_1', + 2: 'PV_PLUG_INDEX_2', + }, + 'SERVE_MIDDLEMEN': { + 0: 'SERVE_MIDDLEMEN_NONE', + 1: 'SERVE_MIDDLEMEN_SUNNOVA', + }, + 'SP_CHARGER_CHG_MODE': { + 0: 'SP_CHARGER_CHG_MODE_IDLE', + 1: 'SP_CHARGER_CHG_MODE_DRIVING_CHG', + 2: 'SP_CHARGER_CHG_MODE_BAT_MAINTENANCE', + 3: 'SP_CHARGER_CHG_MODE_PARKING_CHG', + }, + 'STATISTICS_OBJECT': { + 0: 'STATISTICS_OBJECT_START', + 1: 'STATISTICS_OBJECT_DEV_WORK_TIME', + 2: 'STATISTICS_OBJECT_AC_OUT_ENERGY', + 3: 'STATISTICS_OBJECT_DC12V_OUT_ENERGY', + 4: 'STATISTICS_OBJECT_TYPEC_OUT_ENERGY', + 5: 'STATISTICS_OBJECT_USBA_OUT_ENERGY', + 6: 'STATISTICS_OBJECT_AC_IN_ENERGY', + 7: 'STATISTICS_OBJECT_PV_IN_ENERGY', + 8: 'STATISTICS_OBJECT_AC_IN_0W_100W_TIME', + 9: 'STATISTICS_OBJECT_AC_IN_OVER_100W_TIME', + 10: 'STATISTICS_OBJECT_AC_OUT_0W_50W_TIME', + 11: 'STATISTICS_OBJECT_AC_OUT_50W_100W_TIME', + 12: 'STATISTICS_OBJECT_AC_OUT_100W_200W_TIME', + 13: 'STATISTICS_OBJECT_AC_OUT_OVER_200W_TIME', + 14: 'STATISTICS_OBJECT_AC_OUT_200W_300W_TIME', + 15: 'STATISTICS_OBJECT_AC_OUT_300W_400W_TIME', + 16: 'STATISTICS_OBJECT_AC_OUT_400W_500W_TIME', + 17: 'STATISTICS_OBJECT_AC_OUT_OVER_500W_TIME', + 18: 'STATISTICS_OBJECT_PV_IN_TIME', + 19: 'STATISTICS_OBJECT_TYPEC_IN_TIME', + 20: 'STATISTICS_OBJECT_DC_OUT_0W_60W_TIME', + 21: 'STATISTICS_OBJECT_DC_OUT_OVER_60W_TIME', + 22: 'STATISTICS_OBJECT_TYPEC_OUT_0W_30W_TIME', + 23: 'STATISTICS_OBJECT_TYPEC_OUT_30W_60W_TIME', + 24: 'STATISTICS_OBJECT_TYPEC_OUT_OVER_60W_TIME', + 25: 'STATISTICS_OBJECT_USBA_OUT_TIME', + 26: 'STATISTICS_OBJECT_LED_OUT_TIME', + }, + 'TIME_TASK_MODE': { + 0: 'TIME_TASK_MODE_RESV', + 1: 'TIME_TASK_MODE_PER_WEEK', + 2: 'TIME_TASK_MODE_ONCE', + }, + 'TIME_TASK_TYPE': { + 0: 'TIME_TASK_TYPE_AC_CHG', + 1: 'TIME_TASK_TYPE_AC_DSG', + 2: 'TIME_TASK_TYPE_AC2_DSG', + 3: 'TIME_TASK_TYPE_DC_CHG', + 4: 'TIME_TASK_TYPE_DC2_CHG', + 5: 'TIME_TASK_TYPE_DC_DSG', + 6: 'TIME_TASK_TYPE_OIL_ON', + 7: 'TIME_TASK_TYPE_OIL_OFF', + 8: 'TIME_TASK_TYPE_USB_CHG', + 9: 'TIME_TASK_TYPE_USB_DSG', + }, +} diff --git a/docs/ecoflow-river3-fields.md b/docs/ecoflow-river3-fields.md new file mode 100644 index 0000000..c596d1f --- /dev/null +++ b/docs/ecoflow-river3-fields.md @@ -0,0 +1,387 @@ +# River 3 diagnostic fields + +This reference explains the fields in the reviewed R651 reports. It covers all +110 distinct top-level fields in that watch capture. It does not change the +53-field completion checklist or claim that these are all possible device fields. + +The source for field numbers, names, and types is +[upstream `proto/pr705.proto`](https://github.com/rabits/ha-ef-ble/blob/511e04702c83ddcc90cdcd575a1bbeffe2f061ad/proto/pr705.proto), +specifically `DisplayPropertyUpload`. These messages arrive at source `0x02`, +command set `0xFE`, command `0x15`. + +A schema describes the message structure. It does not explain every status code, +bit flag, physical connector, or unit conversion. The tables distinguish those +limits from known field names. Units shown for the current checklist follow the +current diagnostic decoder. The live tests do not independently establish every +unit or the accuracy of every measurement. + +## Normal measurements and validation + +Support covers serial prefixes `R651`, `R653`, `R654`, and `R655`. River 3 uses +packet version 3 and a separate decoder from River 2. Both models share the +[snapshot field names](ecoflow-river2-fields.md#normal-snapshot), not their decoding rules. + +Temperature prefers the maximum cell temperature, with composite battery +temperature as a fallback. Signed battery power determines charging or discharging. +Total input power alone does not indicate charging. The general remaining-time +field selects the estimate for the active battery direction. It stays absent +while the direction is idle or unknown. + +Time estimates use minutes. River 2's `5999` rule does not apply. A live R651 +sample reported 4556 minutes while the display showed 75–76 hours. This supports +the time unit, not the accuracy of the prediction. Live checks cover no-load and +USB-C-load operation. Charging and further display comparisons still need validation. + +Diagnostic collection combines each field's latest value across reports, not one +simultaneous measurement. Zero and explicit unavailable values count as received. +Malformed messages and required fields with an unexpected encoding do not count. +Missing fields do not erase earlier measurements. The normal snapshot omits +unusable values even when the diagnostic checklist counts their receipt. + +## Reported fields available to clients + +Clients receive all reported fields from `DisplayPropertyUpload`, beyond the +normal measurements and the completion checklist. The decoder knows 296 top-level +fields from the pinned schema. It exports only fields that the device sends. + +Field names use `ef_river3_display_`, such as +`ef_river3_display_pow_get_typec1` or `ef_river3_display_cms_batt_soh`. +They appear in `EcoFlowSnapshot.items()`, socket responses, and client snapshots. +InfluxDB and Prometheus export them alongside the normal measurements. +Prometheus adds fields that first arrive after the exporter starts. + +Numbers and boolean values use `BMSScalarField.raw_value`. +Enum values are named numeric codes. They retain the code in `raw_value` and the +name in `value`, when the schema defines a name. +Only fields with existing unit annotations carry units. +Do not assume that an untranslated code or an unannotated number is a physical measurement. + +Strings use `BMSInfoField.info` without conversion. +Nested messages and repeated fields use JSON text in `info`. +Clients can use `json.loads(field.info)` for those groups. +This preserves named children, lists, enum codes and names, and explicit empty groups. +Missing children do not become zero values. +Statistics retain their reported numbers without an assumed energy unit. + +Unknown fields use `ef_river3_display_field_`. +Their `info` contains a JSON list with each occurrence's `wire_type` and `raw` +hexadecimal encoding. Integer encodings also include their unsigned `value`. +The decoder does not guess the meaning of unknown fields. + +Separate reports update only the fields that they contain. +A reported nested group replaces its earlier group, while absent top-level fields +retain their earlier values. Each snapshot can therefore combine several reports. +Malformed fields, invalid strings, and non-finite numbers reject the extended report +without changing earlier extended fields. `ef_river3_display_decode_error` records +the reason until the next accepted report. The normal measurement decoder keeps +its separate validation rules. + +Raw packet fields remain available as `ef_pkt___`. +They retain the latest payload, including rejected and unknown reports. +Runtime routing at `0x02/0xFE/0x16` remains unconfirmed. +Those reports stay raw-only in client snapshots, although the diagnostic tool can +show a tentative schema interpretation. They do not supply normal measurements. + +## How to read the reports + +Protobuf is the binary message format used by River 3. Each field has a numeric +identifier. Fields can arrive in separate messages with the same source and +command numbers. Missing fields are not zero values. + +The `bms_` prefix identifies battery-management data. The `cms_` prefix identifies +device-level battery data. Similar fields belong to different reporting layers. +They do not necessarily describe separate batteries or independent measurements. + +SOC means state of charge, the reported charge percentage. SOH means state of +health, a reported battery health estimate. Neither is a direct capacity test. +AC means alternating current. DC means direct current. PV identifies the solar/DC +input in these messages. MPPT identifies the solar-input charging controller. + +A boolean field is a yes/no flag. A raw state code is a number whose individual +meanings remain unverified. In particular, River 2 and River 3 do not share a +verified charge-state code mapping. + +The `measurements` and `configuration` names below are local checklist groups. +They are not message types or exact boundaries between the device's reports. +Several configuration fields arrive in the first report. + +## Current measurement and status checklist: 31 fields + +Every row below is required by the current diagnostic checklist. Receipt does +not guarantee a usable exported measurement. Zero and explicit unavailable values +still establish that a field arrived. + +| Field | Protocol name | Information represented | +|---|---|---| +| 1 | `errcode` | Overall device error code. Individual code meanings remain unverified. | +| 3 | `pow_in_sum_w` | Total device input power, W. This is not necessarily battery charging power. | +| 4 | `pow_out_sum_w` | Total device output power, W. | +| 9 | `pow_get_qcusb1` | Power at USB-A/QC channel 1, W. | +| 10 | `pow_get_qcusb2` | Power at USB-A/QC channel 2, W. | +| 11 | `pow_get_typec1` | Signed power at USB-C channel 1, W. A discharge sample reported a negative value. | +| 12 | `pow_get_typec2` | Power at USB-C channel 2, W. A reported slot does not prove that a physical port exists. | +| 37 | `pow_get_12v` | Power on the 12 V DC channel, W. | +| 54 | `pow_get_ac_in` | AC input power, W. | +| 148 | `pv_chg_type` | Solar/DC charging mode code. The value mapping remains unverified. | +| 158 | `pow_get_bms` | Signed battery power, W. Positive means charging and negative means discharging in the current mapping. | +| 212 | `dev_sleep_state` | Device sleep-state code. The value mapping remains unverified. | +| 242 | `bms_batt_soc` | Battery-management charge estimate, %. | +| 243 | `bms_batt_soh` | Battery-management health estimate, %. | +| 248 | `bms_design_cap` | Battery design capacity. The current diagnostic label is mAh. | +| 254 | `bms_dsg_rem_time` | Battery-management estimate of remaining discharge time, minutes. | +| 255 | `bms_chg_rem_time` | Battery-management estimate of remaining charge time, minutes. | +| 258 | `bms_min_cell_temp` | Minimum battery-cell temperature, degrees Celsius. | +| 259 | `bms_max_cell_temp` | Maximum battery-cell temperature, degrees Celsius. This supplies the normal snapshot temperature when present. | +| 260 | `bms_min_mos_temp` | Minimum temperature at battery switching transistors, degrees Celsius. This is not a cell temperature. | +| 261 | `bms_max_mos_temp` | Maximum temperature at battery switching transistors, degrees Celsius. | +| 262 | `cms_batt_soc` | Device-level charge estimate, %. This supplies the normal snapshot charge ratio. | +| 263 | `cms_batt_soh` | Device-level battery health estimate, %. | +| 268 | `cms_dsg_rem_time` | Device-level estimate of remaining discharge time, minutes. | +| 269 | `cms_chg_rem_time` | Device-level estimate of remaining charge time, minutes. | +| 275 | `cms_bms_run_state` | Battery-system operating-state code. The value mapping remains unverified. | +| 281 | `bms_chg_dsg_state` | Battery-management charge/discharge state code. It is not decoded using River 2 meanings. | +| 282 | `cms_chg_dsg_state` | Device-level charge/discharge state code. Exported separately from power-based direction. | +| 355 | `ups_alram` | Alarm flag associated with uninterruptible power supply operation. The protocol spelling is retained. Exact trigger conditions remain unverified. | +| 361 | `pow_get_pv` | Solar/DC input power, W. | +| 368 | `pow_get_ac_out` | AC output power, W. | + +Individual channel power can be signed even when aggregate output power is +positive. Do not apply one sign convention to every power field. The current +snapshot uses battery power, not an untranslated state code, to select direction. + +The two time estimates can both be present while only one direction is active. +Their presence does not mean simultaneous charging and discharging. A value of +`4294967295` is treated as unavailable for the normal River 3 time measurements. + +## Current configuration checklist: 22 fields + +These values describe settings, limits, and connection state. A configured power +limit is not a measurement of current power. A charger flag alone does not prove +that the battery is receiving power. + +| Field | Protocol name | Information represented | +|---|---|---| +| 7 | `energy_backup_en` | Whether the energy-backup feature is enabled. | +| 8 | `energy_backup_start_soc` | Charge threshold for the energy-backup feature, %. Exact transition behavior remains unverified. | +| 17 | `dev_standby_time` | Device standby timeout setting, seconds in the current decoder. | +| 18 | `screen_off_time` | Display-off timeout setting, seconds in the current decoder. | +| 19 | `ac_standby_time` | AC standby timeout setting, seconds in the current decoder. | +| 25 | `xboost_en` | Whether X-Boost is enabled. This is a setting, not proof that it is active under a load. | +| 33 | `flow_info_12v` | Encoded flow/status information for the 12 V channel. The bits remain unverified. | +| 74 | `dc_out_open` | DC output enabled flag. | +| 195 | `en_beep` | Audible signal enabled flag. | +| 202 | `plug_in_info_ac_charger_flag` | AC charger flag. Its exact relationship to cable presence and active charging remains unverified. | +| 209 | `plug_in_info_ac_in_chg_pow_max` | Reported AC input charging-power limit, W. | +| 211 | `ac_out_freq` | AC output frequency setting, Hz. This is separate from runtime frequency measurements. | +| 270 | `cms_max_chg_soc` | Maximum charge setting, %. | +| 271 | `cms_min_dsg_soc` | Minimum charge setting for discharge, %. | +| 356 | `plug_in_info_pv_dc_amp_max` | Solar/DC input current-limit setting, A in the current decoder. | +| 358 | `low_power_alarm` | Low-battery alarm setting, % in the current decoder. The special meaning of zero remains unverified. | +| 360 | `flow_info_pv` | Encoded flow/status information for the solar/DC input. The bits remain unverified. | +| 364 | `plug_in_info_pv_charger_flag` | Solar/DC charger flag. It is not a power measurement. | +| 365 | `plug_in_info_pv_chg_amp_max` | Reported maximum solar/DC charging current, A in the current decoder. | +| 366 | `plug_in_info_pv_chg_vol_max` | Reported maximum solar/DC charging voltage, V in the current decoder. | +| 367 | `flow_info_ac_out` | Encoded AC output flow/status information. The bits remain unverified. | +| 458 | `plug_in_info_ac_in_chg_hal_pow_max` | Hardware-layer AC charging-power limit, W. Its exact relationship to field 209 needs device testing. | + +## Information supplied by the third report: 20 fields + +The reviewed capture contained three distinct report payloads, each repeated +68 times. The third payload contained the following 20 additional fields. +None belongs to the current 53-field checklist. + +DCP and DCP2 are connector/interface slot names in the schema. Their physical +mapping on R651 remains unverified. Zero or empty values for a slot do not prove +that the device has another physical connector. + +PCS identifies power-conversion equipment in the fan field. PD identifies the +main device controller here, not a USB charging port. LLC identifies a +power-converter module in the schema. + +| Field | Protocol name | Information represented | Value in the reviewed capture | +|---|---|---|---| +| 30 | `pcs_fan_level` | Power-conversion fan level. Not a verified speed in revolutions per minute or a percentage. | 0 | +| 83 | `plug_in_info_dcp2_in_flag` | DCP2 input/connection flag. Exact physical meaning remains unverified. | false | +| 84 | `plug_in_info_dcp2_dsg_chg_type` | DCP2 charge/discharge mode code. | 0 | +| 85 | `plug_in_info_dcp2_charger_flag` | DCP2 charger flag. | false | +| 86 | `plug_in_info_dcp2_type` | DCP2 connected-device/interface type code. | 0 | +| 87 | `plug_in_info_dcp2_detail` | DCP2 type/detail code. | 0 | +| 88 | `plug_in_info_dcp2_sn` | DCP2 associated serial-number string. Treat populated values as private. | Empty string | +| 89 | `plug_in_info_dcp2_run_state` | DCP2 operating-state code. | 0 | +| 90 | `plug_in_info_dcp2_firm_ver` | DCP2 firmware-version number. Version packing remains unverified. | 0 | +| 91 | `plug_in_info_dcp2_resv` | DCP2 reserved information. | Empty message | +| 140 | `bms_err_code` | Battery-management error code. Specific nonzero codes remain unverified. | 0 | +| 141 | `err_code_record_list` | List of error records, each with optional error code and timestamp. Timestamp units and epoch remain unverified. | Ten empty records | +| 213 | `pd_err_code` | Main device-controller error code. | 0 | +| 215 | `mppt_err_code` | Solar/DC charging-controller error code. | 0 | +| 232 | `llc_inv_err_code` | Power-converter/inverter error code. | 0 | +| 432 | `plug_in_info_dcp_resv` | DCP reserved information. | Empty message | +| 434 | `plug_in_info_dcp_firm_ver` | DCP firmware-version number. Version packing remains unverified. | 0 | +| 436 | `plug_in_info_dcp_run_state` | DCP operating-state code. | 0 | +| 438 | `plug_in_info_dcp_err_code` | DCP error code. | 0 | +| 439 | `plug_in_info_dcp2_err_code` | DCP2 error code. | 0 | + +The decoder now names all 20 fields using the pinned schema. Earlier versions +named only fields 30, 140, 213, 215, and 232. The 53-field completion checklist +still does not require this report. + +Field 141 contains a nested list, not one error number. Its outer field 1 is a +repeated `ErrcodeRecordItem`. Each item can contain error code in field 1 and +timestamp in field 2. The ten empty entries in this capture contain neither +value. They do not represent ten detected faults. + +Fields 91 and 432 use `ResvInfo`, a message containing repeated unsigned integers +in field 1. Both messages are empty in this capture. Reserved information has no +verified operational meaning. + +## Other fields already present in the first two reports: 37 fields + +These fields appeared in the earlier short run as well as the watch capture. +They are outside the current checklist. Earlier versions printed numeric labels +for these fields. The decoder now uses the schema names below. + +Together, these 37 fields, the 53 required fields, and the third report's 20 +fields account for all 110 observed top-level fields. This is a capture +inventory, not a universal River 3 field list. + +| Field | Protocol name | Information represented | +|---|---|---| +| 13 | `flow_info_qcusb1` | USB-A/QC channel 1 flow/status code. Bit meanings remain unverified. | +| 14 | `flow_info_qcusb2` | USB-A/QC channel 2 flow/status code. | +| 15 | `flow_info_typec1` | USB-C channel 1 flow/status code. | +| 16 | `flow_info_typec2` | USB-C channel 2 flow/status code. | +| 22 | `ac_always_on_flag` | AC always-on feature flag. | +| 23 | `ac_always_on_mini_soc` | Minimum charge threshold associated with AC always-on operation. | +| 45 | `flow_info_ac2dc` | AC-to-DC conversion flow/status code. | +| 46 | `flow_info_dc2ac` | DC-to-AC conversion flow/status code. | +| 47 | `flow_info_ac_in` | AC input flow/status code. | +| 53 | `pow_get_ac` | AC-side power field. Its exact relationship to separate input/output totals needs testing. | +| 61 | `plug_in_info_ac_in_flag` | AC input connection/status code. | +| 62 | `plug_in_info_ac_in_feq` | AC input frequency field. The protocol spelling is retained. | +| 77 | `pow_get_dcp2` | DCP2 power field. Physical slot mapping and sign convention remain unverified. | +| 78 | `flow_info_dcp2_in` | DCP2 input flow/status code. | +| 79 | `flow_info_dcp2_out` | DCP2 output flow/status code. | +| 147 | `output_power_off_memory` | Setting for retaining output-state information across power-off. Exact restore behavior needs testing. | +| 152 | `flow_info_bms_dsg` | Battery discharge flow/status code. It is not a time estimate or charge percentage. | +| 153 | `flow_info_bms_chg` | Battery charge flow/status code. It is not a time estimate or charge percentage. | +| 219 | `time_task_current` | Current scheduled-task message. Empty in the reviewed capture. | +| 227 | `llc_hv_lv_flag` | Converter high/low-voltage mode indicator. Despite the observed value 230, do not treat it as a measured voltage. | +| 238 | `plug_in_info_ac_out_dsg_pow_max` | Maximum AC output power field, not current output power. | +| 285 | `time_task_conflict_flag` | Scheduled-task conflict status. | +| 286 | `time_task_change_cnt` | Scheduled-task change counter. | +| 357 | `led_mode` | Light/display mode code. Exact modes remain unverified. | +| 359 | `silence_chg_watt` | Quiet-mode charging-power setting. It is not an AC frequency field. | +| 362 | `plug_in_info_pv_flag` | Solar/DC input flag. | +| 363 | `plug_in_info_pv_type` | Solar/DC input type code. | +| 423 | `flow_info_dcp_in` | DCP input flow/status code. | +| 424 | `flow_info_dcp_out` | DCP output flow/status code. | +| 425 | `pow_get_dcp` | DCP power field. Physical slot mapping and sign convention remain unverified. | +| 426 | `plug_in_info_dcp_in_flag` | DCP input/connection flag. | +| 427 | `plug_in_info_dcp_type` | DCP connected-device/interface type code. | +| 428 | `plug_in_info_dcp_detail` | DCP type/detail code. | +| 431 | `plug_in_info_dcp_dsg_chg_type` | DCP charge/discharge mode code. | +| 433 | `plug_in_info_dcp_sn` | DCP associated serial-number string. Empty here, but populated values are private. | +| 435 | `plug_in_info_dcp_charger_flag` | DCP charger flag. | +| 463 | `display_statistics_sum` | List of accumulated statistics. This is not a list of instantaneous power readings. | + +Field 463 contains repeated `StatisticsRecordItem` messages in outer field 1. +Each item uses field 1 for the statistic category and field 2 for its numeric +content. Categories 2 through 7 identify AC output, 12 V output, USB-C output, +USB-A output, AC input, and solar input energy, respectively. + +Those six categories occur in the reviewed capture, along with empty entries. +The schema does not establish the scaling of the recorded energy values. +Do not label the raw numbers as Wh without further evidence. An absent content +field is also distinct from an explicitly received zero. + +The earlier generic decoder displayed both empty strings and empty nested messages +as `''`. The schema-based decoder now distinguishes them. Fields 88 and 433 are +strings and retain `''`. Empty messages in fields 91, 219, and 432 print as `{}`. + +## Why normal collection can omit the third report + +The three observed payloads have the same route, `0x02/0xFE/0x15`. They are not +three separately requested message types. Their sizes and checklist coverage in +this capture were: + +| Observed report | Payload size | Top-level fields | Current required fields supplied | +|---|---|---|---| +| First | 300 bytes | 56 | 43: all 31 measurement/status fields and 12 configuration fields | +| Second | 102 bytes | 34 | 10: the remaining configuration fields | +| Third | 80 bytes | 20 | None | + +The second report completes the current checklist. The collector can therefore +exit before the third report arrives. If the third report arrives before the +next completion check, its fields still appear in the accumulated final view. + +There is no separate, shorter wait period assigned to the second report. +Collection checks progress every half second. It stops when the required +information is present, rather than when a selected report count or idle period +is reached. The watch output does not timestamp each packet, so it cannot +establish the exact interval between these reports. + +Adding fields 30, 140, 213, 215, and 232 to a required status group would increase +the checklist from 53 to 58 fields. With the observed report layout, collection +would then wait for the third report. It would finish on receipt rather than +wait until the full timeout. The third report can still take additional time to +arrive. No fixed arrival time is established by this capture. + +Requiring those five fields would not guarantee receipt of the other 15 fields +on firmware that splits the reports differently. Requiring all 20 would need a +broader, firmware-aware policy, especially for reserved and connector fields. +Neither change is implemented by this reference. The existing 53-field checklist +and verbose missing-field output remain unchanged. + +## Information that remains unresolved + +The watch capture also contains eight-byte messages at `0x35/0x35/0x20`. There +are 17 such messages with seven distinct payloads. Their structure and meaning +remain unverified. They must not be assigned field meanings from the protobuf +schema merely because some bytes resemble protobuf tags. + +This capture contains no confirmed `0x02/0xFE/0x16` runtime report. It therefore +does not establish runtime voltage/current fields from that route. The schema +also contains many fields that this device did not report during the capture. + +Naming a field is separate from interpreting its values or exporting it as a +normal measurement. Verified names and nested structures are now decoded. State +codes, physical slot mappings, units, and error causes still need independent +evidence where this reference marks them as unverified. + +## Schema coverage and maintenance + +`bmspy/ecoflow_river3.py` contains the curated units, numeric decoder, and +measurement rules. `bmspy/ecoflow_proto.py` provides shared parsing and +diagnostic formatting. Its `format_*` functions return readable text, not +measurement values. The older diagnostic `decode_*` names remain aliases. +Bluetooth transport stays in `bmspy/ecoflow_bt.py`. The shared snapshot stays +in `bmspy/ecoflow_snapshot.py`. + +`bmspy/ecoflow_river3_schema.py` contains generated descriptors for all 296 +`DisplayPropertyUpload` fields and all 98 `RuntimePropertyUpload` fields in the +pinned upstream schema. It also includes the 15 reachable message definitions +and ten enum definitions. No network access or protobuf package is needed at runtime. + +The decoder uses declared types for strings, nested messages, booleans, signed +integers, floats, enums, and repeated values. It accepts packed and unpacked +repeated integers. Known fields with an unexpected encoding retain their names +and raw bytes, with an error rather than a misleading interpreted value. + +Unknown future field numbers remain numeric/raw. The eight-byte `0x35/0x35/0x20` +message is still unclassified. Full schema coverage does not establish the +meaning of that separate message or confirm runtime message routing. + +The descriptor source is upstream commit +`511e04702c83ddcc90cdcd575a1bbeffe2f061ad`. Only protocol declarations are retained, +not upstream decoder code or authentication keydata. + +To reproduce the descriptors, run this command from the repository root: + +```bash +python tools/generate_ecoflow_river3_schema.py --ref 511e04702c83ddcc90cdcd575a1bbeffe2f061ad +``` + +The generator retrieves public source files through the GitHub API. Without +`--ref`, it resolves the current upstream main commit. Review the generated diff +before accepting a schema update. Unsupported reachable types cause generation +to fail rather than silently omit fields. diff --git a/pyproject.toml b/pyproject.toml index 37e30f1..a15e40f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,8 @@ build-backend = "uv_build" [tool.uv.build-backend] module-root = "" source-include = [ - "tests/**", "docs/ecoflow-river2-fields.md", - "tools/ecoflow-bt.py", "*.service", "uv.lock", + "tests/**", "docs/ecoflow-river2-fields.md", "docs/ecoflow-river3-fields.md", + "tools/generate_ecoflow_river3_schema.py", "tools/ecoflow-bt.py", "*.service", "uv.lock", ] # Do not distribute the external Type 7 table or the unfinished local USB stub. source-exclude = ["/bmspy/ecoflow_keydata.py", "/bmspy/usbhid.py"] diff --git a/tests/test_backend_modules.py b/tests/test_backend_modules.py new file mode 100644 index 0000000..a5c83fd --- /dev/null +++ b/tests/test_backend_modules.py @@ -0,0 +1,135 @@ +"""Backend import boundaries and compatibility, using synthetic packets only.""" + +from dataclasses import asdict +import json +import struct +import subprocess +import sys + +import pytest + +from bmspy import ecoflow_proto, ecoflow_river2, ecoflow_river3 +from bmspy.ecoflow_snapshot import EcoFlowSnapshot + + +PD_PACKET = (2, 0x20, 2, bytes(14) + struct.pack("