Add River 3 telemetry and schema-based diagnostics
Decode River 3 separately and accumulate partial reports against a field-level checklist. Keep watch mode running after completion. Label fields and nested records from the pinned pr705 schema. Preserve unknown values and document unverified meanings and routing. Add synthetic regressions, the River 3 field reference, schema-generation tooling, and upstream attribution.
This commit is contained in:
@@ -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.
|
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
|
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
|
```bash
|
||||||
uv run --extra ecoflow-bt bmspy-server --socket /tmp/bmspy-rivers.sock \
|
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
|
```bash
|
||||||
uv run bmspy --socket /tmp/bmspy-rivers.sock
|
uv run bmspy --socket /tmp/bmspy-rivers.sock
|
||||||
@@ -98,7 +99,7 @@ before retrying. Unavailable devices are omitted from the response.
|
|||||||
|
|
||||||
#### Diagnostics
|
#### 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
|
```bash
|
||||||
uv run --extra ecoflow-bt python -u tools/ecoflow-bt.py --user-id YOUR_USER_ID --scan --timeout 30
|
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.
|
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
|
River 2 collection waits for three valid core reports, not optional inverter or
|
||||||
solar reports. A timeout reports incomplete collection. Use `--watch` to receive
|
solar reports. River 3 waits for 53 baseline fields across several reports.
|
||||||
later 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
|
Both models report charge, temperature, power, and time estimates through separate
|
||||||
stay absent. See the [River 2 measurement reference](docs/ecoflow-river2-fields.md)
|
decoders. Missing measurements stay absent. See the
|
||||||
for units, decoding rules, and known limits.
|
[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
|
Client snapshots also include reported protocol fields under `ef_river2_*` and
|
||||||
`ef_river2_*`, alongside the shared measurements and raw packets.
|
`ef_river3_display_*`, alongside the shared measurements and raw packets.
|
||||||
Partial reports retain earlier field values. See the
|
Partial reports retain earlier field values. Nested River 3 groups use JSON text
|
||||||
[River 2 field reference](docs/ecoflow-river2-fields.md#reported-fields-available-to-clients)
|
in their `info` fields. The references above describe names, units, and limits.
|
||||||
for names, units, and limits.
|
|
||||||
|
|
||||||
### Running via systemd
|
### Running via systemd
|
||||||
|
|
||||||
|
|||||||
+18
-9
@@ -24,6 +24,9 @@ from bmspy.classes import BMSInfoField, BMSScalarField
|
|||||||
from bmspy.ecoflow_river2 import (
|
from bmspy.ecoflow_river2 import (
|
||||||
River2Decoder, SERIAL_PREFIXES as _RIVER2_PREFIXES, decode_river2_payload,
|
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.ecoflow_snapshot import EcoFlowSnapshot
|
||||||
from bmspy.utilities import debugger
|
from bmspy.utilities import debugger
|
||||||
|
|
||||||
@@ -35,8 +38,6 @@ MANUFACTURER_KEY = 0xB5B5
|
|||||||
|
|
||||||
# Packet version and encryption type are independent. R601/R603 use V2
|
# Packet version and encryption type are independent. R601/R603 use V2
|
||||||
# packets even when the BLE advertisement selects Type 7 encryption.
|
# packets even when the BLE advertisement selects Type 7 encryption.
|
||||||
|
|
||||||
|
|
||||||
def _default_packet_version(serial_number: str) -> int:
|
def _default_packet_version(serial_number: str) -> int:
|
||||||
return 2 if serial_number.startswith(_RIVER2_PREFIXES) else 3
|
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
|
_default_packet_version(serial_number) if packet_version is None else packet_version
|
||||||
)
|
)
|
||||||
self._is_river2 = serial_number.startswith(_RIVER2_PREFIXES)
|
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._debug = debug
|
||||||
|
|
||||||
self._state: _State = _State.CREATED
|
self._state: _State = _State.CREATED
|
||||||
@@ -543,7 +548,7 @@ class EcoFlowBTDevice:
|
|||||||
key = hashlib.md5(self._sn.encode()).digest()
|
key = hashlib.md5(self._sn.encode()).digest()
|
||||||
iv = hashlib.md5(self._sn[::-1].encode()).digest()
|
iv = hashlib.md5(self._sn[::-1].encode()).digest()
|
||||||
self._enc = _Type1Encryption(key, iv)
|
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
|
# Type 1: send probe and authenticate immediately without waiting for
|
||||||
# the probe response (mirrors ha-ef-ble's _type_1_session).
|
# the probe response (mirrors ha-ef-ble's _type_1_session).
|
||||||
self._set_state(_State.AUTHENTICATING)
|
self._set_state(_State.AUTHENTICATING)
|
||||||
@@ -606,7 +611,7 @@ class EcoFlowBTDevice:
|
|||||||
data = self._enc.decrypt(encrypted_data)
|
data = self._enc.decrypt(encrypted_data)
|
||||||
session_key = self._derive_session_key(data[16:18], data[:16])
|
session_key = self._derive_session_key(data[16:18], data[:16])
|
||||||
self._enc = _Type7Encryption(session_key, self._enc.iv)
|
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()
|
await self._do_auth_status()
|
||||||
self._auth_fallback_task = asyncio.create_task(self._auth_type7_fallback())
|
self._auth_fallback_task = asyncio.create_task(self._auth_type7_fallback())
|
||||||
|
|
||||||
@@ -778,8 +783,9 @@ class EcoFlowBTDevice:
|
|||||||
class _EncFrameHelper:
|
class _EncFrameHelper:
|
||||||
"""Encode/decode EncPacket frames (Type 7, AES-CBC)."""
|
"""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._enc = enc
|
||||||
|
self._xor_payload = xor_payload
|
||||||
self._buf = bytearray()
|
self._buf = bytearray()
|
||||||
|
|
||||||
def encode(self, pkt: EcoFlowPacket) -> bytes:
|
def encode(self, pkt: EcoFlowPacket) -> bytes:
|
||||||
@@ -793,7 +799,7 @@ class _EncFrameHelper:
|
|||||||
packets = []
|
packets = []
|
||||||
for raw in raw_list:
|
for raw in raw_list:
|
||||||
decrypted = self._enc.decrypt(raw)
|
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:
|
if pkt is not None:
|
||||||
packets.append(pkt)
|
packets.append(pkt)
|
||||||
return packets
|
return packets
|
||||||
@@ -802,8 +808,9 @@ class _EncFrameHelper:
|
|||||||
class _RawHeaderHelper:
|
class _RawHeaderHelper:
|
||||||
"""Encode/decode RawHeader frames (Type 1, AES-CBC)."""
|
"""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._enc = enc
|
||||||
|
self._xor_payload = xor_payload
|
||||||
self._buf = bytearray()
|
self._buf = bytearray()
|
||||||
|
|
||||||
def encode(self, pkt: EcoFlowPacket) -> bytes:
|
def encode(self, pkt: EcoFlowPacket) -> bytes:
|
||||||
@@ -820,7 +827,9 @@ class _RawHeaderHelper:
|
|||||||
version = header[1]
|
version = header[1]
|
||||||
inner_overhead = 15 if version >= 3 else 13
|
inner_overhead = 15 if version >= 3 else 13
|
||||||
inner_len = inner_overhead + payload_length
|
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:
|
if pkt is not None:
|
||||||
packets.append(pkt)
|
packets.append(pkt)
|
||||||
return packets
|
return packets
|
||||||
|
|||||||
+291
-202
@@ -1,26 +1,18 @@
|
|||||||
"""
|
"""Shared EcoFlow binary helpers and diagnostic formatting.
|
||||||
EcoFlow BLE protocol decoder — River 2 (struct-packed) and River 3 (protobuf).
|
|
||||||
|
|
||||||
River 2 (serial R601/R603/R611/R613):
|
Model-specific layouts, annotations, and measurement decoding live in
|
||||||
Little-endian packed C structs; cmd_set=0x20.
|
``ecoflow_river2`` and ``ecoflow_river3``. Model definitions are loaded only
|
||||||
|
when formatting needs them, so the models can use these parsing helpers
|
||||||
River 3 (serial R651/R653/R654/R655):
|
without circular imports. Legacy ``decode_*`` entry points remain available.
|
||||||
Protobuf-encoded (standard_protocol_mr521.proto, package mr521).
|
|
||||||
cmd_set=0xFE, cmd_id=0x15, src=0x02 → DisplayPropertyUpload.
|
|
||||||
cmd_set=0xFE, cmd_id=0x16, src=0x02 → RuntimePropertyUpload (cmdId inferred).
|
|
||||||
|
|
||||||
Sources:
|
|
||||||
https://github.com/rabits/ef-ble-reverse
|
|
||||||
https://github.com/rabits/ha-ef-ble
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import struct
|
import struct
|
||||||
from typing import NamedTuple
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
|
from .ecoflow_river3_schema import MESSAGES as _MESSAGES, ENUMS as _ENUMS
|
||||||
# ── Protobuf wire-format decoder ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class ProtoField(NamedTuple):
|
class ProtoField(NamedTuple):
|
||||||
@@ -29,23 +21,152 @@ class ProtoField(NamedTuple):
|
|||||||
signed: bool = False # int32 fields that may be negative
|
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]:
|
def _pb_read_varint(data: bytes, pos: int) -> tuple[int, int]:
|
||||||
result, shift = 0, 0
|
result = 0
|
||||||
while pos < len(data):
|
for shift in range(0, 70, 7):
|
||||||
b = data[pos]; pos += 1
|
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
|
result |= (b & 0x7F) << shift
|
||||||
if not (b & 0x80):
|
if not (b & 0x80):
|
||||||
return result, pos
|
return result, pos
|
||||||
shift += 7
|
raise ValueError("varint exceeds 64 bits")
|
||||||
raise ValueError("truncated varint")
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
data: bytes,
|
||||||
field_names: dict[int, ProtoField] | None = None,
|
field_names: dict[int, ProtoField] | None = None,
|
||||||
indent: int = 0,
|
indent: int = 0,
|
||||||
) -> list[str]:
|
) -> 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
|
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 "field N: value" labels. Unknown field numbers fall back to the
|
||||||
@@ -79,7 +200,7 @@ def decode_protobuf(
|
|||||||
length, pos = _pb_read_varint(data, pos)
|
length, pos = _pb_read_varint(data, pos)
|
||||||
chunk = data[pos:pos + length]; pos += length
|
chunk = data[pos:pos + length]; pos += length
|
||||||
try:
|
try:
|
||||||
nested = decode_protobuf(chunk, indent=indent + 1)
|
nested = format_protobuf(chunk, indent=indent + 1)
|
||||||
if nested:
|
if nested:
|
||||||
lines.append(f"{pad}{label}: {{")
|
lines.append(f"{pad}{label}: {{")
|
||||||
lines.extend(nested)
|
lines.extend(nested)
|
||||||
@@ -104,173 +225,120 @@ def decode_protobuf(
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
# ── River 2 — struct field descriptor ─────────────────────────────────────────
|
# Protobuf types, not value guesses, determine how known fields are rendered.
|
||||||
|
_SCALAR_WIRE_TYPES = {
|
||||||
|
**dict.fromkeys(("bool", "int32", "int64", "uint32", "uint64", "sint32", "sint64"), 0),
|
||||||
class FieldDef(NamedTuple):
|
**dict.fromkeys(("double", "fixed64", "sfixed64"), 1),
|
||||||
name: str
|
"string": 2, "bytes": 2,
|
||||||
fmt: str # struct format char(s)
|
**dict.fromkeys(("float", "fixed32", "sfixed32"), 5),
|
||||||
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"),
|
|
||||||
}
|
}
|
||||||
|
_FIXED_FORMATS = {
|
||||||
# ── River 3 — RuntimePropertyUpload field map ──────────────────────────────────
|
"float": "f", "double": "d", "fixed32": "I", "fixed64": "Q",
|
||||||
# Source: standard_protocol_mr521.proto (package mr521)
|
"sfixed32": "i", "sfixed64": "q",
|
||||||
# Note: cmd_id for RuntimePropertyUpload is not confirmed in ha-ef-ble river3.py;
|
|
||||||
# packets will be decoded if the device sends them on any cmd_id with these
|
|
||||||
# field numbers. Add the correct (src, cmd_set, cmd_id) key when known.
|
|
||||||
|
|
||||||
_RUNTIME_PROPERTY_UPLOAD: dict[int, ProtoField] = {
|
|
||||||
# ── temperatures ──
|
|
||||||
26: ProtoField("temp_pcs_dc", "°C"),
|
|
||||||
27: ProtoField("temp_pcs_ac", "°C"),
|
|
||||||
28: ProtoField("temp_pv_h", "°C"),
|
|
||||||
29: ProtoField("temp_pv_l", "°C"),
|
|
||||||
379: ProtoField("temp_pv", "°C"),
|
|
||||||
# ── AC voltages / currents ──
|
|
||||||
67: ProtoField("plug_in_info_ac_out_vol", "V"),
|
|
||||||
68: ProtoField("plug_in_info_ac_in_vol", "V"),
|
|
||||||
223: ProtoField("plug_in_info_ac_in_amp", "A"),
|
|
||||||
224: ProtoField("plug_in_info_ac_out_amp", "A"),
|
|
||||||
# ── solar / DC input ──
|
|
||||||
380: ProtoField("plug_in_info_pv_vol", "V"),
|
|
||||||
381: ProtoField("plug_in_info_pv_amp", "A"),
|
|
||||||
41: ProtoField("plug_in_info_pv_h_vol", "V"),
|
|
||||||
221: ProtoField("plug_in_info_pv_h_amp", "A"),
|
|
||||||
44: ProtoField("plug_in_info_pv_l_vol", "V"),
|
|
||||||
222: ProtoField("plug_in_info_pv_l_amp", "A"),
|
|
||||||
# ── 12 V output ──
|
|
||||||
382: ProtoField("plug_in_info_12v_vol", "V"),
|
|
||||||
383: ProtoField("plug_in_info_12v_amp", "A"),
|
|
||||||
# ── BMS battery voltage / current ──
|
|
||||||
244: ProtoField("bms_batt_vol", "V"),
|
|
||||||
245: ProtoField("bms_batt_amp", "A"),
|
|
||||||
264: ProtoField("cms_batt_vol", "V"),
|
|
||||||
265: ProtoField("cms_batt_amp", "A"),
|
|
||||||
# ── LLC / bus internals ──
|
|
||||||
384: ProtoField("llc_bat_vol", "V"),
|
|
||||||
385: ProtoField("llc_bat_cur", "A"),
|
|
||||||
386: ProtoField("llc_bus_vol", "V"),
|
|
||||||
# ── firmware versions ──
|
|
||||||
176: ProtoField("pd_firm_ver"),
|
|
||||||
177: ProtoField("iot_firm_ver"),
|
|
||||||
178: ProtoField("mppt_firm_ver"),
|
|
||||||
179: ProtoField("llc_firm_ver"),
|
|
||||||
241: ProtoField("bms_firm_ver"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Message registry ───────────────────────────────────────────────────────────
|
def _protobuf_scalar(kind: str, data: bytes, pos: int) -> tuple[int | float | bool, int]:
|
||||||
#
|
if kind in _FIXED_FORMATS:
|
||||||
# Struct-based (River 2): value is ("label", [FieldDef, ...], "struct")
|
fmt = "<" + _FIXED_FORMATS[kind]
|
||||||
# Protobuf-based (River 3): value is ("label", {field_num: ProtoField}, "proto")
|
value = struct.unpack_from(fmt, data, pos)[0]
|
||||||
|
return value, pos + struct.calcsize(fmt)
|
||||||
_REGISTRY: dict[tuple[int, int, int], tuple[str, list | dict, str]] = {
|
value, pos = _pb_read_varint(data, pos)
|
||||||
# ── River 3: src / cmd_set / cmd_id ────────────────────────────────────────
|
if kind == "bool":
|
||||||
(0x02, 0xFE, 0x15): ("DisplayPropertyUpload", _DISPLAY_PROPERTY_UPLOAD, "proto"),
|
return bool(value), pos
|
||||||
(0x02, 0xFE, 0x16): ("RuntimePropertyUpload", _RUNTIME_PROPERTY_UPLOAD, "proto"),
|
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}<maximum nesting depth reached, raw: {data.hex()}>"]
|
||||||
|
try:
|
||||||
|
fields = parse_protobuf_fields(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
return [f"{pad}<malformed {message}: {exc}, raw: {data.hex()}>"]
|
||||||
|
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}: <wire type {field.wire_type}, expected {expected}, "
|
||||||
|
f"raw: {field.encoded.hex()}>")
|
||||||
|
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}: <invalid {kind}: {exc}, raw: {field.encoded.hex()}>")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _format_struct(label: str, fields: list[FieldDef], data: bytes) -> list[str]:
|
||||||
lines: list[str] = [f"[{label}]"]
|
lines: list[str] = [f"[{label}]"]
|
||||||
active = list(fields)
|
active = list(fields)
|
||||||
while active:
|
while active:
|
||||||
@@ -305,21 +373,24 @@ def _decode_struct(label: str, fields: list[FieldDef], data: bytes) -> list[str]
|
|||||||
return lines
|
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 format_payload(
|
||||||
|
|
||||||
def decode_payload(
|
|
||||||
src: int,
|
src: int,
|
||||||
cmd_set: int,
|
cmd_set: int,
|
||||||
cmd_id: int,
|
cmd_id: int,
|
||||||
payload: bytes,
|
payload: bytes,
|
||||||
) -> list[str] | None:
|
) -> 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.
|
Return annotated lines, or None if no definition is registered.
|
||||||
Callers should fall back to the generic protobuf/hex decoder on None.
|
Callers can fall back to the generic protobuf/hex formatter on None.
|
||||||
"""
|
"""
|
||||||
if not payload:
|
if not payload:
|
||||||
return None
|
return None
|
||||||
@@ -328,31 +399,49 @@ def decode_payload(
|
|||||||
return None
|
return None
|
||||||
label, fields, kind = entry
|
label, fields, kind = entry
|
||||||
if kind == "struct":
|
if kind == "struct":
|
||||||
return _decode_struct(label, fields, payload) # type: ignore[arg-type]
|
return _format_struct(label, fields, payload) # type: ignore[arg-type]
|
||||||
# proto
|
suffix = ", routing unconfirmed" if label == "RuntimePropertyUpload" else ""
|
||||||
lines = [f"[{label}]"]
|
lines = [f"[{label}{suffix}]"]
|
||||||
lines += [" " + ln for ln in decode_protobuf(payload, field_names=fields)] # type: ignore[arg-type]
|
lines += [" " + ln for ln in format_schema_protobuf(payload, label)]
|
||||||
return lines
|
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:
|
def describe_message(src: int, cmd_set: int, cmd_id: int) -> str | None:
|
||||||
entry = _message_definition(src, cmd_set, cmd_id)
|
entry = _message_definition(src, cmd_set, cmd_id)
|
||||||
return entry[0] if entry else None
|
return entry[0] if entry else None
|
||||||
|
|
||||||
|
|
||||||
# Load model definitions only when needed to avoid circular imports.
|
# ── Compatibility entry points ────────────────────────────────────────────────
|
||||||
def _message_definition(src: int, cmd_set: int, cmd_id: int):
|
# Keep existing imports working. New callers use format_* for diagnostic text
|
||||||
from .ecoflow_river2 import MESSAGE_DEFINITIONS
|
# and import numeric decoders from the corresponding model module.
|
||||||
|
|
||||||
key = (src, cmd_set, cmd_id)
|
decode_protobuf = format_protobuf
|
||||||
return MESSAGE_DEFINITIONS.get(key) or _REGISTRY.get(key)
|
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
|
from .ecoflow_river2 import decode_river2_payload as decode
|
||||||
|
|
||||||
return decode(src, cmd_set, cmd_id, payload)
|
return decode(src, cmd_set, cmd_id, payload)
|
||||||
|
|
||||||
|
|
||||||
format_payload = decode_payload
|
def decode_river3_payload(
|
||||||
format_protobuf = decode_protobuf
|
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)
|
||||||
|
|||||||
@@ -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("<f", field.encoded, pos)[0]
|
||||||
|
values[definition.name] = value
|
||||||
|
except (ValueError, struct.error):
|
||||||
|
return None
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _record_display_fields(snapshot: EcoFlowSnapshot, payload: bytes) -> 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
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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_<protocol_name>`, 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_<number>`.
|
||||||
|
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_<src>_<cmdset>_<cmdid>`.
|
||||||
|
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.
|
||||||
+2
-2
@@ -44,8 +44,8 @@ build-backend = "uv_build"
|
|||||||
[tool.uv.build-backend]
|
[tool.uv.build-backend]
|
||||||
module-root = ""
|
module-root = ""
|
||||||
source-include = [
|
source-include = [
|
||||||
"tests/**", "docs/ecoflow-river2-fields.md",
|
"tests/**", "docs/ecoflow-river2-fields.md", "docs/ecoflow-river3-fields.md",
|
||||||
"tools/ecoflow-bt.py", "*.service", "uv.lock",
|
"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.
|
# Do not distribute the external Type 7 table or the unfinished local USB stub.
|
||||||
source-exclude = ["/bmspy/ecoflow_keydata.py", "/bmspy/usbhid.py"]
|
source-exclude = ["/bmspy/ecoflow_keydata.py", "/bmspy/usbhid.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("<BHHi", 47, 0, 0, 5999))
|
||||||
|
EMS_PACKET = (3, 0x20, 2, bytes(26) + struct.pack("<f", 87.5))
|
||||||
|
CELL_TEMP_PACKET = (2, 0xFE, 0x15, b"\x98\x10\x1d") # field 259: 29 C
|
||||||
|
COMPOSITE_TEMP_PACKET = (2, 0xFE, 0x15, b"\xb0\x06\x14") # field 102: 20 C
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("first", [
|
||||||
|
"ecoflow_proto", "ecoflow_river2", "ecoflow_river3", "ecoflow_snapshot",
|
||||||
|
])
|
||||||
|
def test_decoders_import_in_any_order_without_transport_dependencies(first):
|
||||||
|
# A fresh interpreter catches import cycles hidden by pytest's import order.
|
||||||
|
script = """
|
||||||
|
import importlib
|
||||||
|
import importlib.abc
|
||||||
|
import sys
|
||||||
|
|
||||||
|
class NoTransportImports(importlib.abc.MetaPathFinder):
|
||||||
|
def find_spec(self, fullname, path=None, target=None):
|
||||||
|
if fullname.split('.')[0] in {'serial', 'bleak', 'ecdsa', 'Crypto'}:
|
||||||
|
raise AssertionError('Unexpected optional dependency: ' + fullname)
|
||||||
|
if fullname in {'bmspy.ecoflow_bt', 'bmspy.jbd_bms'}:
|
||||||
|
raise AssertionError('Unexpected transport import: ' + fullname)
|
||||||
|
|
||||||
|
sys.meta_path.insert(0, NoTransportImports())
|
||||||
|
importlib.import_module('bmspy.' + sys.argv[1])
|
||||||
|
from bmspy import ecoflow_proto, ecoflow_river2, ecoflow_river3
|
||||||
|
from bmspy.ecoflow_snapshot import EcoFlowSnapshot
|
||||||
|
|
||||||
|
assert ecoflow_proto.format_payload(2, 0xFE, 0x15, b'\\x08\\x00') == [
|
||||||
|
'[DisplayPropertyUpload]', ' errcode: 0',
|
||||||
|
]
|
||||||
|
assert ecoflow_proto.format_payload(2, 0x20, 2, bytes(23))[0] == '[PD heartbeat]'
|
||||||
|
assert ecoflow_river2.decode_river2_payload(2, 0x20, 2, bytes(23))['soc'] == 0
|
||||||
|
assert ecoflow_river3.decode_river3_payload(2, 0xFE, 0x15, b'\\x08\\x00') == {}
|
||||||
|
assert list(EcoFlowSnapshot().items()) == []
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", script, first],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_and_decoder_imports_remain_compatible():
|
||||||
|
from bmspy import ecoflow_bt
|
||||||
|
|
||||||
|
assert ecoflow_bt.EcoFlowSnapshot is EcoFlowSnapshot
|
||||||
|
assert ecoflow_bt.decode_river2_payload is ecoflow_river2.decode_river2_payload
|
||||||
|
assert ecoflow_bt.decode_river3_payload is ecoflow_river3.decode_river3_payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnostic_formatting_aliases_remain_compatible():
|
||||||
|
assert ecoflow_proto.decode_payload is ecoflow_proto.format_payload
|
||||||
|
assert ecoflow_proto.decode_protobuf is ecoflow_proto.format_protobuf
|
||||||
|
assert ecoflow_proto.decode_schema_protobuf is ecoflow_proto.format_schema_protobuf
|
||||||
|
assert ecoflow_proto.format_protobuf(b"\x08\x00") == ["field 1: 0"]
|
||||||
|
assert ecoflow_proto.format_schema_protobuf(b"\x08\x00", "DisplayPropertyUpload") == [
|
||||||
|
"errcode: 0",
|
||||||
|
]
|
||||||
|
assert ecoflow_proto.format_payload(2, 0xFE, 0x15, b"") is None
|
||||||
|
assert ecoflow_proto.format_payload(0x35, 0x35, 0x20, b"\x00") is None
|
||||||
|
assert ecoflow_proto.describe_message(2, 0x20, 2) == "PD heartbeat"
|
||||||
|
assert ecoflow_proto.describe_message(2, 0xFE, 0x15) == "DisplayPropertyUpload"
|
||||||
|
assert ecoflow_proto.describe_message(0x35, 0x35, 0x20) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model,packets", [
|
||||||
|
(ecoflow_river2, [PD_PACKET, EMS_PACKET, CELL_TEMP_PACKET, (2, 0x20, 2, b"")]),
|
||||||
|
(ecoflow_river3, [CELL_TEMP_PACKET, COMPOSITE_TEMP_PACKET, PD_PACKET, (2, 0xFE, 0x15, b"\x80")]),
|
||||||
|
])
|
||||||
|
def test_legacy_numeric_decoder_entry_points(model, packets):
|
||||||
|
name = "decode_" + model.__name__.rsplit("_", 1)[1] + "_payload"
|
||||||
|
legacy = getattr(ecoflow_proto, name)
|
||||||
|
direct = getattr(model, name)
|
||||||
|
for packet in packets:
|
||||||
|
assert legacy(*packet) == direct(*packet)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("decoder_type,high_packet,low_packet,field,high,low", [
|
||||||
|
(ecoflow_river2.River2Decoder, EMS_PACKET, PD_PACKET, "bms_capacity_charge_ratio", 0.875, 0.47),
|
||||||
|
(ecoflow_river3.River3Decoder, CELL_TEMP_PACKET, COMPOSITE_TEMP_PACKET, "bms_temperature_celcius", 29, 20),
|
||||||
|
], ids=["river2_soc", "river3_temperature"])
|
||||||
|
def test_measurement_priorities_are_per_instance_and_reset_per_connection(
|
||||||
|
decoder_type, high_packet, low_packet, field, high, low,
|
||||||
|
):
|
||||||
|
def value(snapshot):
|
||||||
|
measurement = getattr(snapshot, field)
|
||||||
|
return measurement.raw_values[1] if hasattr(measurement, "raw_values") else measurement.raw_value
|
||||||
|
|
||||||
|
decoder = decoder_type()
|
||||||
|
snapshot = EcoFlowSnapshot()
|
||||||
|
decoder.update(snapshot, *high_packet)
|
||||||
|
decoder.update(snapshot, *low_packet)
|
||||||
|
assert value(snapshot) == high
|
||||||
|
|
||||||
|
other = decoder_type()
|
||||||
|
other_snapshot = EcoFlowSnapshot()
|
||||||
|
other.update(other_snapshot, *low_packet)
|
||||||
|
assert value(other_snapshot) == low
|
||||||
|
assert value(snapshot) == high
|
||||||
|
|
||||||
|
before = asdict(snapshot)
|
||||||
|
decoder.reset()
|
||||||
|
assert asdict(snapshot) == before
|
||||||
|
decoder.update(snapshot, *low_packet)
|
||||||
|
assert value(snapshot) == low
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_keeps_diagnostic_packets_and_json_round_trip():
|
||||||
|
from bmspy.classes import UPS
|
||||||
|
|
||||||
|
snapshot = EcoFlowSnapshot()
|
||||||
|
ecoflow_river2.River2Decoder().update(snapshot, *PD_PACKET)
|
||||||
|
snapshot.record_packet(*PD_PACKET)
|
||||||
|
items = dict(snapshot.items())
|
||||||
|
assert items["bms_capacity_charge_ratio"].raw_value == 0.47
|
||||||
|
assert items["ef_pkt_02_20_02"].info == PD_PACKET[3].hex()
|
||||||
|
assert "ef_remaining_time_minutes" not in items
|
||||||
|
wire_data = {name: asdict(field) for name, field in snapshot.items()}
|
||||||
|
restored = UPS.from_dict(json.loads(json.dumps(wire_data)))
|
||||||
|
assert dict(restored.items()) == items
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Synthetic collection/watch tests. No Bluetooth hardware or private captures."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import struct
|
||||||
|
from argparse import Namespace
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bmspy.ecoflow_bt import EcoFlowBTDevice, EcoFlowPacket, _State
|
||||||
|
from bmspy.ecoflow_proto import parse_protobuf_fields
|
||||||
|
|
||||||
|
ADDRESS = "02:00:00:00:00:03"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def script():
|
||||||
|
path = Path(__file__).resolve().parents[1] / "tools" / "ecoflow-bt.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("ecoflow_live_diagnostics", path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def varint(value):
|
||||||
|
result = bytearray()
|
||||||
|
while value > 127:
|
||||||
|
result.append((value & 127) | 128)
|
||||||
|
value >>= 7
|
||||||
|
result.append(value)
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
def wire_field(number, wire_type=0, value=0):
|
||||||
|
body = struct.pack("<f", value) if wire_type == 5 else varint(value)
|
||||||
|
return varint(number << 3 | wire_type) + body
|
||||||
|
|
||||||
|
|
||||||
|
def report(payload, src=2, cmd_set=0xFE, cmd_id=0x15):
|
||||||
|
return EcoFlowPacket(src, 0x21, cmd_set, cmd_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def required_payload(script, group=None, omit=()):
|
||||||
|
return b"".join(
|
||||||
|
wire_field(number, wire_type)
|
||||||
|
for name, fields in script.RIVER3_REQUIRED_GROUPS.items()
|
||||||
|
if group is None or group == name
|
||||||
|
for number, wire_type in fields.items() if number not in omit
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def device(prefix="R651"):
|
||||||
|
return EcoFlowBTDevice(ADDRESS, prefix + "TEST00000001", "test-user")
|
||||||
|
|
||||||
|
|
||||||
|
def attach(script, dev, watch=False):
|
||||||
|
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES[dev.serial_number[:4]], watch)
|
||||||
|
dev.on_packet(collector.receive)
|
||||||
|
return collector
|
||||||
|
|
||||||
|
|
||||||
|
def river2_bypass_packets():
|
||||||
|
"""Synthetic first burst: three core heartbeats, MPPT, and an empty reply."""
|
||||||
|
pd = bytearray(121)
|
||||||
|
struct.pack_into("<BHHi", pd, 14, 88, 35, 35, 5218)
|
||||||
|
ems = bytearray(46)
|
||||||
|
struct.pack_into("<II", ems, 17, 5999, 5999)
|
||||||
|
struct.pack_into("<f", ems, 26, 87.5)
|
||||||
|
bms = bytearray(69)
|
||||||
|
bms[11], bms[20] = 88, 28
|
||||||
|
struct.pack_into("<I", bms, 12, 13)
|
||||||
|
struct.pack_into("<fIII", bms, 53, 87.75, 0, 0, 0)
|
||||||
|
return [
|
||||||
|
EcoFlowPacket(2, 0x21, 0x20, 2, bytes(pd), version=2),
|
||||||
|
EcoFlowPacket(0x35, 0x21, 1, 0x52, b"", version=2),
|
||||||
|
EcoFlowPacket(3, 0x21, 0x20, 2, bytes(ems), version=2),
|
||||||
|
EcoFlowPacket(3, 0x21, 0x20, 0x32, bytes(bms), version=2),
|
||||||
|
EcoFlowPacket(5, 0x21, 0x20, 2, bytes(94), version=2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("order", [("measurements", "configuration"), ("configuration", "measurements")])
|
||||||
|
def test_checklist_combines_reports_in_either_order_and_counts_zero(script, order, capsys):
|
||||||
|
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||||
|
collector.receive(report(required_payload(script, order[0])))
|
||||||
|
assert collector.missing()
|
||||||
|
first_output = capsys.readouterr().out
|
||||||
|
assert "DisplayPropertyUpload" in first_output # Printed before collection finishes.
|
||||||
|
collector.receive(report(required_payload(script, order[1])))
|
||||||
|
assert collector.missing() == set()
|
||||||
|
assert collector.progress() == "measurements 31/31, configuration 22/22"
|
||||||
|
assert "DisplayPropertyUpload" in capsys.readouterr().out
|
||||||
|
lines = "\n".join(collector.decoded_packets()["ef_pkt_02_fe_15"])
|
||||||
|
assert "pow_in_sum_w: 0.000 W" in lines
|
||||||
|
assert "plug_in_info_ac_in_chg_pow_max: 0 W" in lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_new_or_changed_fields_print_in_normal_mode(script, capsys):
|
||||||
|
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||||
|
packet = report(wire_field(262, 5, 89))
|
||||||
|
collector.receive(packet)
|
||||||
|
assert "cms_batt_soc: 89.000 %" in capsys.readouterr().out
|
||||||
|
collector.receive(packet)
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
collector.receive(report(wire_field(262, 5, 88)))
|
||||||
|
assert "cms_batt_soc: 88.000 %" in capsys.readouterr().out
|
||||||
|
assert len(collector.fields) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_time_counts_as_received_not_missing(script):
|
||||||
|
dev = device()
|
||||||
|
collector = attach(script, dev)
|
||||||
|
dev._record(report(required_payload(script, omit=(268,))))
|
||||||
|
assert "measurements:cms_dsg_rem_time" in collector.missing()
|
||||||
|
dev._record(report(wire_field(268, value=0xFFFFFFFF)))
|
||||||
|
assert collector.missing() == set()
|
||||||
|
assert dev.snapshot.ef_discharge_remaining_time_minutes is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("route", [(3, 0xFE, 0x15), (2, 0xFE, 0x16), (2, 0x20, 2)])
|
||||||
|
def test_unrelated_routes_cannot_satisfy_river3_checklist(script, route):
|
||||||
|
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||||
|
original = collector.missing()
|
||||||
|
collector.receive(report(required_payload(script), *route))
|
||||||
|
assert collector.missing() == original
|
||||||
|
assert collector.fields == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_packet_is_atomic_and_wrong_wire_type_does_not_count(script):
|
||||||
|
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||||
|
collector.receive(report(required_payload(script) + b"\x80"))
|
||||||
|
assert not collector.fields
|
||||||
|
assert not collector.seen
|
||||||
|
assert collector.error is None
|
||||||
|
collector.receive(report(required_payload(script, omit=(3,)) + wire_field(3)))
|
||||||
|
assert collector.missing() == {"measurements:pow_in_sum_w"}
|
||||||
|
assert 3 not in collector.fields
|
||||||
|
collector.receive(report(wire_field(3, 5)))
|
||||||
|
assert not collector.missing()
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_repeated_unknown_fields_remain_grouped_and_optional(script):
|
||||||
|
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R651"])
|
||||||
|
child = wire_field(1, value=7)
|
||||||
|
item = varint(463 << 3 | 2) + varint(len(child)) + child
|
||||||
|
collector.receive(report(item + item))
|
||||||
|
assert collector.fields[463] == item + item
|
||||||
|
assert len(parse_protobuf_fields(collector.fields[463])) == 2
|
||||||
|
assert len(collector.missing()) == 53
|
||||||
|
collector.receive(report(required_payload(script)))
|
||||||
|
assert not collector.missing()
|
||||||
|
assert collector.fields[463] == item + item
|
||||||
|
collector.receive(report(item))
|
||||||
|
assert collector.fields[463] == item # Replace the repeated group, do not append forever.
|
||||||
|
assert not collector.missing()
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_packet_callback_runs_after_snapshot_updates_even_for_duplicates():
|
||||||
|
dev = device()
|
||||||
|
packets, updates = [], []
|
||||||
|
dev.on_packet(Mock(side_effect=ValueError("bad listener")))
|
||||||
|
dev.on_packet(lambda pkt: packets.append((pkt, dev.snapshot.ef_input_power_watts.raw_value)))
|
||||||
|
dev.on_update(lambda: updates.append(True))
|
||||||
|
pkt = report(wire_field(3, 5, 12))
|
||||||
|
dev._record(pkt)
|
||||||
|
dev._record(pkt)
|
||||||
|
assert packets == [(pkt, 12), (pkt, 12)]
|
||||||
|
assert updates == [True, True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_reports_finish_without_waiting_for_a_deadline(script, monkeypatch, capsys):
|
||||||
|
dev = device()
|
||||||
|
collector = attach(script, dev)
|
||||||
|
dev._record(report(required_payload(script)))
|
||||||
|
sleep = AsyncMock(side_effect=AssertionError("must not wait"))
|
||||||
|
monkeypatch.setattr(script.asyncio, "sleep", sleep)
|
||||||
|
asyncio.run(script.collect([(ADDRESS, dev, set())], max_wait=0, collectors={ADDRESS: collector}))
|
||||||
|
sleep.assert_not_awaited()
|
||||||
|
assert "all required information received" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_reports_named_missing_fields_without_claiming_completion(script, capsys):
|
||||||
|
dev = device()
|
||||||
|
collector = attach(script, dev)
|
||||||
|
dev._record(report(required_payload(script, omit=(209,))))
|
||||||
|
asyncio.run(script.collect([(ADDRESS, dev, set())], max_wait=0, collectors={ADDRESS: collector}))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "timed out, incomplete" in out
|
||||||
|
assert "configuration:plug_in_info_ac_in_chg_pow_max" in out
|
||||||
|
assert "all required information received" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prefix", ["R601", "R651"])
|
||||||
|
def test_watch_continues_after_completion_and_timeout_and_includes_late_unknown_packets(
|
||||||
|
script, monkeypatch, capsys, prefix,
|
||||||
|
):
|
||||||
|
dev = device(prefix)
|
||||||
|
dev._state = _State.AUTHENTICATED
|
||||||
|
collector = attach(script, dev, watch=True)
|
||||||
|
if prefix == "R651":
|
||||||
|
dev._record(report(required_payload(script)))
|
||||||
|
else:
|
||||||
|
for src, cmd_id in [(2, 2), (3, 2), (3, 0x32)]:
|
||||||
|
dev._record(EcoFlowPacket(src, 0x21, 0x20, cmd_id, bytes(128), version=2))
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def receive_later(_):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
dev._record(report(b"test-only-unknown", src=0x35, cmd_set=0x35, cmd_id=0x20))
|
||||||
|
dev._record(report(wire_field(999, value=7)))
|
||||||
|
else:
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
|
monkeypatch.setattr(script.asyncio, "sleep", receive_later)
|
||||||
|
expected = script.MODEL_PROFILES[prefix]["expected_fields"]
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=0,
|
||||||
|
collectors={ADDRESS: collector}, watch=True))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert calls == 2
|
||||||
|
assert "continuing watch for optional or unknown information" in out
|
||||||
|
assert "ef_pkt_35_35_20" in out
|
||||||
|
assert "raw_payload: " + b"test-only-unknown".hex() in out
|
||||||
|
assert "No decoded fields; retain raw payload for review." in out
|
||||||
|
assert "field 999: 7" in out
|
||||||
|
assert "timed out" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_watch_detects_disconnection_even_after_completion(script):
|
||||||
|
dev = device()
|
||||||
|
collector = attach(script, dev, watch=True)
|
||||||
|
dev._record(report(required_payload(script)))
|
||||||
|
with pytest.raises(ConnectionError, match="not authenticated"):
|
||||||
|
asyncio.run(script.collect([(ADDRESS, dev, set())], max_wait=0,
|
||||||
|
collectors={ADDRESS: collector}, watch=True))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prefix", ["R601", "R651"])
|
||||||
|
@pytest.mark.parametrize("watch,failure", [
|
||||||
|
(False, None), (False, "output"), (True, "cancel"), (True, "output"), (True, "disconnect"),
|
||||||
|
])
|
||||||
|
def test_run_registers_before_first_burst_and_always_disconnects(script, monkeypatch, capsys, watch, failure, prefix):
|
||||||
|
dev = device(prefix)
|
||||||
|
|
||||||
|
async def connect(**_):
|
||||||
|
dev._state = _State.AUTHENTICATED
|
||||||
|
if prefix == "R601":
|
||||||
|
for packet in river2_bypass_packets():
|
||||||
|
dev._record(packet)
|
||||||
|
else:
|
||||||
|
dev._record(report(required_payload(script, "measurements")))
|
||||||
|
dev._record(report(required_payload(script, "configuration")))
|
||||||
|
if failure == "disconnect":
|
||||||
|
dev._state = _State.DISCONNECTED
|
||||||
|
|
||||||
|
manager = Mock(add_device=Mock(return_value=dev), connect_all=AsyncMock(side_effect=connect),
|
||||||
|
disconnect_all=AsyncMock())
|
||||||
|
monkeypatch.setattr(script, "EcoFlowBTManager", Mock(return_value=manager))
|
||||||
|
if failure == "cancel":
|
||||||
|
monkeypatch.setattr(script.asyncio, "sleep", AsyncMock(side_effect=asyncio.CancelledError()))
|
||||||
|
if failure == "output":
|
||||||
|
monkeypatch.setattr(script.PacketCollector, "_receive", Mock(side_effect=OSError("output failed")))
|
||||||
|
args = Namespace(scan=False, device=ADDRESS, serial_number=dev.serial_number,
|
||||||
|
debug=0, timeout=0, watch=watch)
|
||||||
|
if failure:
|
||||||
|
error = {"cancel": asyncio.CancelledError, "output": OSError, "disconnect": ConnectionError}[failure]
|
||||||
|
with pytest.raises(error):
|
||||||
|
asyncio.run(script.run("test-user", args))
|
||||||
|
else:
|
||||||
|
asyncio.run(script.run("test-user", args))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
final = out.split("Snapshot:")[1]
|
||||||
|
if prefix == "R601":
|
||||||
|
assert "3/3 required heartbeats received (PD, EMS, BMS)" in out
|
||||||
|
assert "Other packet types seen: ef_pkt_35_01_52 (empty payload)" in out
|
||||||
|
assert "[PD heartbeat]" in final
|
||||||
|
assert "latest fields collected this run" not in final
|
||||||
|
else:
|
||||||
|
assert "all required information received" in out
|
||||||
|
assert "latest fields collected this run" in final
|
||||||
|
assert "cms_batt_soc: 0.000 %" in final
|
||||||
|
assert "plug_in_info_ac_in_chg_pow_max: 0 W" in final
|
||||||
|
assert "timed out" not in out
|
||||||
|
manager.disconnect_all.assert_awaited_once()
|
||||||
@@ -1,15 +1,51 @@
|
|||||||
"""Client-visible EcoFlow protocol fields, using synthetic reports only."""
|
"""Client-visible EcoFlow protocol fields, using synthetic reports only."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import struct
|
import struct
|
||||||
|
from dataclasses import asdict
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from bmspy.classes import BMSInfoField, BMSScalarField
|
from bmspy.classes import BMSInfoField, BMSScalarField
|
||||||
from bmspy.client import socket_comms
|
from bmspy.client import read_data, socket_comms
|
||||||
from bmspy.ecoflow_bt import EcoFlowBTDevice, EcoFlowPacket
|
from bmspy.ecoflow_bt import EcoFlowBTDevice, EcoFlowPacket
|
||||||
|
from bmspy.ecoflow_proto import decode_schema_values
|
||||||
from bmspy.ecoflow_river2 import MESSAGE_DEFINITIONS
|
from bmspy.ecoflow_river2 import MESSAGE_DEFINITIONS
|
||||||
|
from bmspy.ecoflow_river3_schema import ENUMS, MESSAGES
|
||||||
|
from bmspy.server import send_response
|
||||||
|
|
||||||
|
|
||||||
|
def varint(value):
|
||||||
|
value &= (1 << 64) - 1
|
||||||
|
result = bytearray()
|
||||||
|
while value > 127:
|
||||||
|
result.append((value & 127) | 128)
|
||||||
|
value >>= 7
|
||||||
|
result.append(value)
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
def integer(number, value):
|
||||||
|
return varint(number << 3) + varint(value)
|
||||||
|
|
||||||
|
|
||||||
|
def floating(number, value):
|
||||||
|
return varint(number << 3 | 5) + struct.pack("<f", value)
|
||||||
|
|
||||||
|
|
||||||
|
def blob(number, value):
|
||||||
|
return varint(number << 3 | 2) + varint(len(value)) + value
|
||||||
|
|
||||||
|
|
||||||
|
def sample(number, kind):
|
||||||
|
if kind in MESSAGES or kind == "string":
|
||||||
|
return blob(number, b"")
|
||||||
|
if kind == "float":
|
||||||
|
return floating(number, 1.5)
|
||||||
|
assert kind in ("bool", "uint32", "int32") or kind in ENUMS
|
||||||
|
return integer(number, 0)
|
||||||
|
|
||||||
|
|
||||||
def device(prefix):
|
def device(prefix):
|
||||||
@@ -82,6 +118,143 @@ def test_river2_malformed_report_preserves_previous_protocol_fields():
|
|||||||
assert "ef_river2_ems_decode_error" not in fields
|
assert "ef_river2_ems_decode_error" not in fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_river3_display_field_is_client_visible_without_defaults():
|
||||||
|
for number, (name, kind, repeated) in MESSAGES["DisplayPropertyUpload"].items():
|
||||||
|
fields = record(device("R651"), sample(number, kind))
|
||||||
|
protocol = {k: v for k, v in fields.items() if k.startswith("ef_river3_display_")}
|
||||||
|
key = "ef_river3_display_" + name
|
||||||
|
assert set(protocol) == {key}
|
||||||
|
field = protocol[key]
|
||||||
|
if repeated or kind in MESSAGES:
|
||||||
|
assert isinstance(field, BMSInfoField)
|
||||||
|
assert json.loads(field.info) == ([{}] if repeated else {})
|
||||||
|
elif kind == "string":
|
||||||
|
assert field.info == ""
|
||||||
|
else:
|
||||||
|
assert isinstance(field, BMSScalarField)
|
||||||
|
assert field.raw_value == (1.5 if kind == "float" else 0)
|
||||||
|
if kind == "bool":
|
||||||
|
assert field.raw_value is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_river3_partial_reports_preserve_values_and_do_not_invent_units():
|
||||||
|
dev = device("R651")
|
||||||
|
record(dev, floating(11, -14.322) + floating(262, 89))
|
||||||
|
fields = record(dev, integer(25, 0) + integer(258, -7))
|
||||||
|
assert fields["ef_river3_display_pow_get_typec1"].raw_value == pytest.approx(-14.322)
|
||||||
|
assert fields["ef_river3_display_pow_get_typec1"].units == "W"
|
||||||
|
assert fields["ef_river3_display_cms_batt_soc"].raw_value == 89
|
||||||
|
assert fields["ef_river3_display_cms_batt_soc"].units == "%"
|
||||||
|
assert fields["bms_capacity_charge_ratio"].raw_value == .89
|
||||||
|
assert fields["ef_river3_display_xboost_en"].raw_value is False
|
||||||
|
assert fields["ef_river3_display_bms_min_cell_temp"].raw_value == -7
|
||||||
|
assert fields["ef_river3_display_bms_min_cell_temp"].units == "°C"
|
||||||
|
fields = record(dev, integer(269, 0xFFFFFFFF))
|
||||||
|
assert fields["ef_river3_display_cms_chg_rem_time"].raw_value == 0xFFFFFFFF
|
||||||
|
assert "ef_charge_remaining_time_minutes" not in fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_repeated_enum_strings_and_empty_values_keep_their_structure():
|
||||||
|
dev = device("R651")
|
||||||
|
stats = blob(1, integer(1, 2) + integer(2, 17)) + blob(1, integer(1, 999)) + blob(1, b"")
|
||||||
|
fields = record(dev, blob(463, stats) + blob(433, b"") + blob(91, b""))
|
||||||
|
value = json.loads(fields["ef_river3_display_display_statistics_sum"].info)
|
||||||
|
assert value == {"list_info": [
|
||||||
|
{"statistics_object": {"code": 2, "name": "STATISTICS_OBJECT_AC_OUT_ENERGY"}, "statistics_content": 17},
|
||||||
|
{"statistics_object": {"code": 999, "name": None}}, {},
|
||||||
|
]}
|
||||||
|
assert fields["ef_river3_display_plug_in_info_dcp_sn"].info == ""
|
||||||
|
assert fields["ef_river3_display_plug_in_info_dcp2_resv"].info == "{}"
|
||||||
|
# A later report replaces a reported group, rather than keeping stale children.
|
||||||
|
fields = record(dev, blob(463, b""))
|
||||||
|
assert fields["ef_river3_display_display_statistics_sum"].info == "{}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_packed_unpacked_and_duplicate_message_fields_merge_within_report():
|
||||||
|
packed = blob(1, varint(0) + varint(128))
|
||||||
|
payload = blob(432, packed) + blob(432, integer(1, 7))
|
||||||
|
values = decode_schema_values(payload, "DisplayPropertyUpload")
|
||||||
|
assert values["plug_in_info_dcp_resv"] == {"resv_info": [0, 128, 7]}
|
||||||
|
empty = decode_schema_values(blob(432, blob(1, b"")), "DisplayPropertyUpload")
|
||||||
|
assert empty["plug_in_info_dcp_resv"] == {"resv_info": []}
|
||||||
|
assert decode_schema_values(floating(3, 1) + floating(3, 2), "DisplayPropertyUpload")["pow_in_sum_w"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_fields_keep_all_wire_encodings_and_repetitions():
|
||||||
|
unknown = integer(999, 7) + integer(999, 0)
|
||||||
|
unknown += blob(1000, b"opaque") + floating(1001, 1.5)
|
||||||
|
unknown += varint(1002 << 3 | 1) + bytes(8)
|
||||||
|
fields = record(device("R651"), unknown)
|
||||||
|
for number, wire in ((999, 0), (1000, 2), (1001, 5), (1002, 1)):
|
||||||
|
items = json.loads(fields[f"ef_river3_display_field_{number}"].info)
|
||||||
|
assert all(item["wire_type"] == wire for item in items)
|
||||||
|
assert all(bytes.fromhex(item["raw"]) in unknown for item in items)
|
||||||
|
assert [item["value"] for item in json.loads(fields["ef_river3_display_field_999"].info)] == [7, 0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [
|
||||||
|
b"\x00", b"\x80", integer(4, 1), floating(4, float("inf")),
|
||||||
|
integer(269, 1 << 32), blob(433, b"\xff"), blob(141, b"\x00"),
|
||||||
|
blob(432, blob(1, b"\x80")),
|
||||||
|
])
|
||||||
|
def test_invalid_river3_protocol_report_is_atomic_and_recoverable(bad):
|
||||||
|
dev = device("R651")
|
||||||
|
record(dev, floating(3, 10) + floating(4, 20))
|
||||||
|
payload = floating(3, 99) + bad
|
||||||
|
fields = record(dev, payload)
|
||||||
|
assert fields["ef_river3_display_pow_in_sum_w"].raw_value == 10
|
||||||
|
assert fields["ef_river3_display_pow_out_sum_w"].raw_value == 20
|
||||||
|
assert "ef_river3_display_decode_error" in fields
|
||||||
|
assert fields["ef_pkt_02_fe_15"].info == payload.hex()
|
||||||
|
fields = record(dev, floating(3, 0))
|
||||||
|
assert fields["ef_river3_display_pow_in_sum_w"].raw_value == 0
|
||||||
|
assert fields["ef_river3_display_pow_out_sum_w"].raw_value == 20
|
||||||
|
assert "ef_river3_display_decode_error" not in fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_value_decoding_has_a_nesting_limit(monkeypatch):
|
||||||
|
monkeypatch.setitem(MESSAGES, "Recursive", {1: ("child", "Recursive", False)})
|
||||||
|
payload = b""
|
||||||
|
for _ in range(20):
|
||||||
|
payload = blob(1, payload)
|
||||||
|
with pytest.raises(ValueError, match="maximum nesting depth"):
|
||||||
|
decode_schema_values(payload, "Recursive")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfirmed_runtime_and_unknown_routes_remain_raw_only():
|
||||||
|
dev = device("R651")
|
||||||
|
for route in ((2, 0xFE, 0x16), (3, 0xFE, 0x15), (0x35, 0x35, 0x20)):
|
||||||
|
fields = record(dev, floating(244, 19.2), route)
|
||||||
|
assert not any(key.startswith("ef_river3_") for key in fields)
|
||||||
|
assert fields[f"ef_pkt_{route[0]:02x}_{route[1]:02x}_{route[2]:02x}"].info
|
||||||
|
|
||||||
|
|
||||||
|
def full_snapshots():
|
||||||
|
river2, river3 = device("R601"), device("R651")
|
||||||
|
for route, (_, definitions, _) in MESSAGE_DEFINITIONS.items():
|
||||||
|
record(river2, heartbeat(definitions)[0], route)
|
||||||
|
record(river3, b"".join(sample(number, kind) for number, (_, kind, _) in MESSAGES["DisplayPropertyUpload"].items()))
|
||||||
|
return {"desktop": river2.snapshot, "nas": river3.snapshot}
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_fields_survive_large_fragmented_server_response_to_client():
|
||||||
|
snapshots = full_snapshots()
|
||||||
|
server_socket = MagicMock()
|
||||||
|
send_response(server_socket, snapshots, "test")
|
||||||
|
frame = server_socket.sendall.call_args.args[0]
|
||||||
|
assert len(frame) > 65536
|
||||||
|
stream = io.BytesIO(frame)
|
||||||
|
sock = MagicMock()
|
||||||
|
sock.recv.side_effect = lambda n: stream.read(min(n, 2 if stream.tell() < 4 else 127))
|
||||||
|
with patch("bmspy.client.socket.socket", return_value=sock):
|
||||||
|
result = read_data("/fake/socket", "test")
|
||||||
|
expected = json.loads(frame[4:])
|
||||||
|
actual = {device: {key: asdict(value) for key, value in snap.items()} for device, snap in result.items()}
|
||||||
|
assert actual == expected
|
||||||
|
assert stream.read() == b""
|
||||||
|
sock.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("frame", [b"", b"\x00\x00", struct.pack("!I", 100) + b"{}"])
|
@pytest.mark.parametrize("frame", [b"", b"\x00\x00", struct.pack("!I", 100) + b"{}"])
|
||||||
def test_incomplete_client_response_fails_without_looping(frame):
|
def test_incomplete_client_response_fails_without_looping(frame):
|
||||||
sock = MagicMock()
|
sock = MagicMock()
|
||||||
@@ -99,3 +272,67 @@ def test_connection_failure_does_not_read_from_unconnected_socket():
|
|||||||
sock.recv.assert_not_called()
|
sock.recv.assert_not_called()
|
||||||
sock.sendall.assert_not_called()
|
sock.sendall.assert_not_called()
|
||||||
sock.close.assert_called_once()
|
sock.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_fields_reach_influxdb():
|
||||||
|
pytest.importorskip("influxdb_client_3")
|
||||||
|
from bmspy.influxdb import influxdb_create_snapshot
|
||||||
|
|
||||||
|
snapshots = full_snapshots()
|
||||||
|
points = influxdb_create_snapshot(snapshots)
|
||||||
|
expected = {(ups, key) for ups, snap in snapshots.items() for key, _ in snap.items()}
|
||||||
|
assert {(point._tags["ups"], point._name) for point in points} == expected
|
||||||
|
for point in points:
|
||||||
|
field = dict(snapshots[point._tags["ups"]].items())[point._name]
|
||||||
|
value = (field.info if isinstance(field, BMSInfoField) else field.raw_value
|
||||||
|
if isinstance(field, BMSScalarField) else field.raw_values[1])
|
||||||
|
assert point._fields["value"] == value
|
||||||
|
assert point.to_line_protocol()
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_fields_reach_prometheus():
|
||||||
|
prometheus = pytest.importorskip("prometheus_client")
|
||||||
|
from bmspy.prometheus import prometheus_create_metric, prometheus_populate_metric
|
||||||
|
|
||||||
|
snapshots = full_snapshots()
|
||||||
|
registry = prometheus.CollectorRegistry()
|
||||||
|
metrics = prometheus_create_metric(registry, snapshots)
|
||||||
|
prometheus_populate_metric(metrics, snapshots)
|
||||||
|
assert set(metrics) == {key for snap in snapshots.values() for key, _ in snap.items()}
|
||||||
|
for ups, snap in snapshots.items():
|
||||||
|
for key, field in snap.items():
|
||||||
|
labels = {"ups": ups}
|
||||||
|
if isinstance(field, BMSInfoField):
|
||||||
|
labels[key] = field.info
|
||||||
|
assert registry.get_sample_value(key + "_info", labels) == 1
|
||||||
|
elif isinstance(field, BMSScalarField):
|
||||||
|
assert registry.get_sample_value(key, labels) == field.raw_value
|
||||||
|
else:
|
||||||
|
for index, value in field.raw_values.items():
|
||||||
|
assert registry.get_sample_value(key, {**labels, field.label: str(index)}) == value
|
||||||
|
|
||||||
|
|
||||||
|
def test_prometheus_daemon_adds_fields_from_later_reports():
|
||||||
|
prometheus = pytest.importorskip("prometheus_client")
|
||||||
|
from bmspy.prometheus import prometheus_export
|
||||||
|
|
||||||
|
first, later = device("R651"), device("R651")
|
||||||
|
record(first, floating(3, 10))
|
||||||
|
record(later, floating(3, 20) + floating(11, -14))
|
||||||
|
snapshots = [{"nas": first.snapshot}, {"nas": later.snapshot}]
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def capture(port, registry):
|
||||||
|
captured.append(registry)
|
||||||
|
|
||||||
|
class Finished(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("bmspy.prometheus.client.read_data", side_effect=snapshots), \
|
||||||
|
patch.object(prometheus, "start_http_server", side_effect=capture), \
|
||||||
|
patch("bmspy.prometheus.time.sleep", side_effect=[None, Finished]):
|
||||||
|
with pytest.raises(Finished):
|
||||||
|
prometheus_export()
|
||||||
|
registry = captured[0]
|
||||||
|
assert registry.get_sample_value("ef_river3_display_pow_in_sum_w", {"ups": "nas"}) == 20
|
||||||
|
assert registry.get_sample_value("ef_river3_display_pow_get_typec1", {"ups": "nas"}) == -14
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""River 3 tests using synthetic protobuf messages, not device captures.
|
||||||
|
|
||||||
|
Field numbers/types follow ha-ef-ble's proto/pr705.proto DisplayPropertyUpload.
|
||||||
|
The packet fixtures model the XOR framing used by upstream River 3 tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import struct
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bmspy.classes import UPS
|
||||||
|
from bmspy.ecoflow_bt import (
|
||||||
|
EcoFlowBTDevice, EcoFlowPacket, _EncFrameHelper, _State, _Type7Encryption,
|
||||||
|
)
|
||||||
|
from bmspy.ecoflow_river3 import decode_river3_payload
|
||||||
|
|
||||||
|
ADDRESS = "02:00:00:00:00:03"
|
||||||
|
SERIAL = "R651TEST00000001"
|
||||||
|
|
||||||
|
|
||||||
|
def varint(value):
|
||||||
|
value &= (1 << 64) - 1
|
||||||
|
data = bytearray()
|
||||||
|
while value > 127:
|
||||||
|
data.append((value & 127) | 128)
|
||||||
|
value >>= 7
|
||||||
|
data.append(value)
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def integer(field, value):
|
||||||
|
return varint(field << 3) + varint(value)
|
||||||
|
|
||||||
|
|
||||||
|
def floating(field, value):
|
||||||
|
return varint((field << 3) | 5) + struct.pack("<f", value)
|
||||||
|
|
||||||
|
|
||||||
|
def report(payload):
|
||||||
|
return EcoFlowPacket(2, 0x21, 0xFE, 0x15, payload, version=3)
|
||||||
|
|
||||||
|
|
||||||
|
def device(serial=SERIAL):
|
||||||
|
return EcoFlowBTDevice(ADDRESS, serial, "test-user")
|
||||||
|
|
||||||
|
|
||||||
|
def display_payload(power=166):
|
||||||
|
return b"".join([
|
||||||
|
floating(262, 88.918), floating(3, 244), floating(4, 39),
|
||||||
|
floating(158, power), integer(259, 29), integer(269, 10),
|
||||||
|
integer(268, 184), integer(282, 1),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_numeric_display_fields():
|
||||||
|
values = decode_river3_payload(2, 0xFE, 0x15, display_payload())
|
||||||
|
assert values["cms_batt_soc"] == pytest.approx(88.918)
|
||||||
|
assert values["pow_in_sum_w"] == 244
|
||||||
|
assert values["pow_out_sum_w"] == 39
|
||||||
|
assert values["pow_get_bms"] == 166
|
||||||
|
assert values["bms_max_cell_temp"] == 29
|
||||||
|
assert values["cms_chg_rem_time"] == 10
|
||||||
|
assert values["cms_dsg_rem_time"] == 184
|
||||||
|
assert values["cms_chg_dsg_state"] == 1
|
||||||
|
assert decode_river3_payload(2, 0xFE, 0x15, b"") == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("src,cmd_set,cmd_id", [(3, 0xFE, 0x15), (2, 0x20, 2), (2, 0xFE, 0x16)])
|
||||||
|
def test_unconfirmed_and_unrelated_messages_are_not_mapped(src, cmd_set, cmd_id):
|
||||||
|
assert decode_river3_payload(src, cmd_set, cmd_id, display_payload()) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", [
|
||||||
|
b"\x00", b"\x80", b"\x80" * 11, b"\x08" + b"\xff" * 10,
|
||||||
|
varint(3 << 3 | 5) + b"\x00" * 3,
|
||||||
|
varint(99 << 3 | 1) + b"\x00" * 7,
|
||||||
|
varint(99 << 3 | 2) + varint(4) + b"abc",
|
||||||
|
varint(99 << 3 | 3), varint(1 << 32),
|
||||||
|
])
|
||||||
|
def test_malformed_protobuf_is_rejected_atomically(payload):
|
||||||
|
assert decode_river3_payload(2, 0xFE, 0x15, floating(3, 10) + payload) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_fields_and_wrong_wire_types_do_not_become_measurements():
|
||||||
|
payload = (
|
||||||
|
integer(3, 99) # Total input must be a float, not a varint.
|
||||||
|
+ integer(999, 123)
|
||||||
|
+ varint(998 << 3 | 1) + bytes(8)
|
||||||
|
+ varint(997 << 3 | 2) + varint(3) + b"abc"
|
||||||
|
+ floating(4, 10) + floating(4, 20)
|
||||||
|
)
|
||||||
|
assert decode_river3_payload(2, 0xFE, 0x15, payload) == {"pow_out_sum_w": 20}
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_temperatures_and_unsigned_time_validation():
|
||||||
|
payload = integer(102, -5) + integer(259, -3) + integer(269, 1 << 32)
|
||||||
|
assert decode_river3_payload(2, 0xFE, 0x15, payload) == {
|
||||||
|
"cms_batt_temp": -5, "bms_max_cell_temp": -3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("prefix", ["R651", "R653", "R654", "R655"])
|
||||||
|
def test_river3_snapshot_and_serialization(prefix):
|
||||||
|
dev = device(prefix + "TEST00000001")
|
||||||
|
dev._record(report(display_payload()))
|
||||||
|
snap = dev.snapshot
|
||||||
|
assert dev._pkt_version == 3
|
||||||
|
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.88918)
|
||||||
|
assert snap.bms_temperature_celcius.raw_values == {1: 29}
|
||||||
|
assert snap.ef_input_power_watts.raw_value == 244
|
||||||
|
assert snap.ef_output_power_watts.raw_value == 39
|
||||||
|
assert snap.bms_charge_is_charging.raw_value is True
|
||||||
|
assert snap.bms_charge_is_discharging.raw_value is False
|
||||||
|
assert snap.ef_remaining_time_minutes.raw_value == 10
|
||||||
|
assert snap.ef_charge_remaining_time_minutes.units == "min"
|
||||||
|
assert snap.ef_charge_state.raw_value == 1
|
||||||
|
serialized = {name: asdict(value) for name, value in snap.items()}
|
||||||
|
assert dict(UPS.from_dict(serialized).items()) == dict(snap.items())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("serial", ["R601TEST00000001", "R631TEST00000001", "unknown"])
|
||||||
|
def test_other_models_do_not_use_river3_decoder(serial):
|
||||||
|
dev = device(serial)
|
||||||
|
dev._record(report(display_payload()))
|
||||||
|
assert dev.snapshot.bms_capacity_charge_ratio is None
|
||||||
|
assert dev.snapshot.ef_input_power_watts is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_updates_keep_missing_values_and_preserve_explicit_zero():
|
||||||
|
dev = device()
|
||||||
|
dev._record(report(floating(262, 0)))
|
||||||
|
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0
|
||||||
|
assert dev.snapshot.ef_input_power_watts is None
|
||||||
|
dev._record(report(floating(3, 44) + floating(4, 44)))
|
||||||
|
assert dev.snapshot.bms_charge_is_charging is None # AC bypass is not charging.
|
||||||
|
dev._record(report(floating(158, 0) + integer(102, -5)))
|
||||||
|
assert dev.snapshot.bms_charge_is_charging.raw_value is False
|
||||||
|
assert dev.snapshot.bms_charge_is_discharging.raw_value is False
|
||||||
|
assert dev.snapshot.ef_remaining_time_minutes is None
|
||||||
|
assert dev.snapshot.bms_temperature_celcius.raw_values == {1: -5}
|
||||||
|
assert dev.snapshot.ef_output_power_watts.raw_value == 44
|
||||||
|
|
||||||
|
|
||||||
|
def test_cell_temperature_remains_preferred_across_partial_updates():
|
||||||
|
dev = device()
|
||||||
|
dev._record(report(integer(259, 29)))
|
||||||
|
dev._record(report(integer(102, 26)))
|
||||||
|
assert dev.snapshot.bms_temperature_celcius.raw_values == {1: 29}
|
||||||
|
dev._record(report(integer(259, 30)))
|
||||||
|
assert dev.snapshot.bms_temperature_celcius.raw_values == {1: 30}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remaining_time_tracks_battery_direction_and_missing_estimates():
|
||||||
|
dev = device()
|
||||||
|
dev._record(report(display_payload()))
|
||||||
|
dev._record(report(floating(158, -64)))
|
||||||
|
assert dev.snapshot.bms_charge_is_charging.raw_value is False
|
||||||
|
assert dev.snapshot.bms_charge_is_discharging.raw_value is True
|
||||||
|
assert dev.snapshot.ef_remaining_time_minutes.raw_value == 184
|
||||||
|
dev._record(report(integer(268, 0xFFFFFFFF)))
|
||||||
|
assert dev.snapshot.ef_discharge_remaining_time_minutes is None
|
||||||
|
assert dev.snapshot.ef_remaining_time_minutes is None
|
||||||
|
dev._record(report(integer(268, 0)))
|
||||||
|
assert dev.snapshot.ef_remaining_time_minutes.raw_value == 0
|
||||||
|
dev._record(report(floating(158, 0)))
|
||||||
|
assert dev.snapshot.ef_remaining_time_minutes is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_load_sample_matches_display_hours_without_changing_minute_units():
|
||||||
|
# Synthetic message using observed measurements, without device identifiers.
|
||||||
|
# The display showed 89% and 75–76 hours, not 75–76 minutes.
|
||||||
|
dev = device()
|
||||||
|
dev._record(report(b"".join([
|
||||||
|
floating(262, 89), floating(3, 0), floating(4, 0),
|
||||||
|
floating(158, -1), integer(259, 29), integer(268, 4556),
|
||||||
|
integer(269, 4559), integer(282, 0),
|
||||||
|
])))
|
||||||
|
# A later configuration-only update must not erase these measurements.
|
||||||
|
dev._record(report(integer(363, 1)))
|
||||||
|
snap = dev.snapshot
|
||||||
|
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.89)
|
||||||
|
assert snap.bms_temperature_celcius.raw_values == {1: 29}
|
||||||
|
assert snap.ef_input_power_watts.raw_value == 0
|
||||||
|
assert snap.ef_output_power_watts.raw_value == 0
|
||||||
|
assert snap.bms_charge_is_charging.raw_value is False
|
||||||
|
assert snap.bms_charge_is_discharging.raw_value is True
|
||||||
|
assert snap.ef_charge_state.raw_value == 0
|
||||||
|
assert snap.ef_charge_remaining_time_minutes.raw_value == 4559
|
||||||
|
assert snap.ef_discharge_remaining_time_minutes.raw_value == 4556
|
||||||
|
assert snap.ef_remaining_time_minutes.raw_value == 4556
|
||||||
|
assert snap.ef_remaining_time_minutes.units == "min"
|
||||||
|
assert divmod(snap.ef_remaining_time_minutes.raw_value, 60) == (75, 56)
|
||||||
|
|
||||||
|
|
||||||
|
def test_usb_load_sample_uses_battery_power_not_river2_state_codes():
|
||||||
|
# Observed values encoded into a synthetic report. River 3 reported state 1
|
||||||
|
# while discharging; River 2 uses state 1 for charging.
|
||||||
|
dev = device()
|
||||||
|
dev._record(report(b"".join([
|
||||||
|
floating(262, 89), floating(3, 0), floating(4, 14),
|
||||||
|
floating(11, -14.322), floating(158, -16), integer(259, 29),
|
||||||
|
integer(268, 754), integer(269, 802), integer(281, 1), integer(282, 1),
|
||||||
|
])))
|
||||||
|
snap = dev.snapshot
|
||||||
|
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.89)
|
||||||
|
assert snap.ef_input_power_watts.raw_value == 0
|
||||||
|
assert snap.ef_output_power_watts.raw_value == 14
|
||||||
|
assert snap.bms_charge_is_charging.raw_value is False
|
||||||
|
assert snap.bms_charge_is_discharging.raw_value is True
|
||||||
|
assert snap.ef_charge_state.raw_value == 1
|
||||||
|
assert snap.ef_remaining_time_minutes.raw_value == 754
|
||||||
|
assert snap.ef_charge_remaining_time_minutes.raw_value == 802
|
||||||
|
assert divmod(snap.ef_remaining_time_minutes.raw_value, 60) == (12, 34)
|
||||||
|
|
||||||
|
|
||||||
|
def test_river2_time_convention_is_not_assumed_for_river3():
|
||||||
|
dev = device()
|
||||||
|
dev._record(report(floating(158, -1) + integer(268, 5999)))
|
||||||
|
assert dev.snapshot.ef_discharge_remaining_time_minutes.raw_value == 5999
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid", [float("nan"), float("inf"), float("-inf")])
|
||||||
|
def test_nonfinite_measurements_are_not_exported(invalid):
|
||||||
|
dev = device()
|
||||||
|
dev._record(report(b"".join(floating(field, invalid) for field in (262, 3, 4, 158))))
|
||||||
|
assert dev.snapshot.bms_capacity_charge_ratio is None
|
||||||
|
assert dev.snapshot.ef_input_power_watts is None
|
||||||
|
assert dev.snapshot.ef_output_power_watts is None
|
||||||
|
assert dev.snapshot.bms_charge_is_charging is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("soc", [-1, 101])
|
||||||
|
def test_invalid_soc_and_negative_total_power_are_not_exported(soc):
|
||||||
|
dev = device()
|
||||||
|
dev._record(report(floating(262, soc) + floating(3, -1) + floating(4, -2)))
|
||||||
|
assert dev.snapshot.bms_capacity_charge_ratio is None
|
||||||
|
assert dev.snapshot.ef_input_power_watts is None
|
||||||
|
assert dev.snapshot.ef_output_power_watts is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version", [3, 0x13])
|
||||||
|
def test_encrypted_xor_reports_survive_notification_fragmentation(version):
|
||||||
|
pytest.importorskip("Crypto")
|
||||||
|
enc = _Type7Encryption(bytes(range(16)), bytes(range(16, 32)))
|
||||||
|
payload = display_payload(power=-64)
|
||||||
|
key = 0x75
|
||||||
|
pkt = EcoFlowPacket(
|
||||||
|
2, 0x21, 0xFE, 0x15, bytes(b ^ key for b in payload),
|
||||||
|
version=version, seq=bytes([key, 0, 0, 0]),
|
||||||
|
)
|
||||||
|
wire = _EncFrameHelper(enc).encode(pkt)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
for split in range(1, len(wire)):
|
||||||
|
dev = device()
|
||||||
|
dev._state = _State.AUTHENTICATED
|
||||||
|
dev._assembler = _EncFrameHelper(enc, xor_payload=dev._is_river3)
|
||||||
|
await dev._on_data(bytearray(wire[:split]))
|
||||||
|
assert dev.snapshot.ef_input_power_watts is None
|
||||||
|
await dev._on_data(bytearray(wire[split:]))
|
||||||
|
assert dev.snapshot.ef_input_power_watts.raw_value == 244
|
||||||
|
assert dev.snapshot.bms_charge_is_discharging.raw_value is True
|
||||||
|
assert dev.snapshot._packets["ef_pkt_02_fe_15"].info == payload.hex()
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_river2_payload_is_not_xored():
|
||||||
|
pytest.importorskip("Crypto")
|
||||||
|
enc = _Type7Encryption(bytes(16), bytes(16))
|
||||||
|
dev = device("R601TEST00000001")
|
||||||
|
helper = _EncFrameHelper(enc, xor_payload=dev._is_river3)
|
||||||
|
pkt = EcoFlowPacket(2, 0x21, 0x20, 2, b"plain", seq=b"\x75\x00\x00\x00", version=2)
|
||||||
|
assert helper.decode(helper.encode(pkt))[0].payload == b"plain"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_waits_for_partial_measurements_not_unconfirmed_runtime(monkeypatch, capsys):
|
||||||
|
path = Path(__file__).resolve().parents[1] / "tools" / "ecoflow-bt.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("ecoflow_diagnostic_river3", path)
|
||||||
|
script = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(script)
|
||||||
|
dev = device()
|
||||||
|
fragments = iter([floating(262, 50), display_payload()])
|
||||||
|
|
||||||
|
async def receive(_):
|
||||||
|
dev._record(report(next(fragments)))
|
||||||
|
|
||||||
|
sleep = AsyncMock(side_effect=receive)
|
||||||
|
monkeypatch.setattr(script.asyncio, "sleep", sleep)
|
||||||
|
expected = script.MODEL_PROFILES["R651"]["expected_fields"]
|
||||||
|
assert "ef_pkt_02_fe_16" not in expected
|
||||||
|
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=1))
|
||||||
|
assert sleep.await_count == 2
|
||||||
|
assert "complete (1 packet type(s))" in capsys.readouterr().out
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Schema-based diagnostic rendering with synthetic data only."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bmspy.ecoflow_proto import (
|
||||||
|
decode_payload, decode_schema_protobuf, describe_protobuf_field,
|
||||||
|
)
|
||||||
|
from bmspy.ecoflow_river3 import decode_river3_payload
|
||||||
|
from bmspy.ecoflow_river3_schema import ENUMS, MESSAGES, SCHEMA_COMMIT
|
||||||
|
|
||||||
|
|
||||||
|
def varint(value):
|
||||||
|
value &= (1 << 64) - 1
|
||||||
|
result = bytearray()
|
||||||
|
while value > 127:
|
||||||
|
result.append((value & 127) | 128)
|
||||||
|
value >>= 7
|
||||||
|
result.append(value)
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
def integer(number, value):
|
||||||
|
return varint(number << 3) + varint(value)
|
||||||
|
|
||||||
|
|
||||||
|
def blob(number, data):
|
||||||
|
return varint(number << 3 | 2) + varint(len(data)) + data
|
||||||
|
|
||||||
|
|
||||||
|
def floating(number, value):
|
||||||
|
return varint(number << 3 | 5) + struct.pack('<f', value)
|
||||||
|
|
||||||
|
|
||||||
|
def display(data):
|
||||||
|
return '\n'.join(decode_payload(2, 0xFE, 0x15, data))
|
||||||
|
|
||||||
|
|
||||||
|
def sample(number, kind):
|
||||||
|
if kind in MESSAGES or kind == 'string':
|
||||||
|
return blob(number, b'')
|
||||||
|
if kind == 'float':
|
||||||
|
return floating(number, 1.5)
|
||||||
|
assert kind in ('bool', 'uint32', 'int32') or kind in ENUMS
|
||||||
|
return integer(number, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('message,cmd_id,count', [
|
||||||
|
('DisplayPropertyUpload', 0x15, 296), ('RuntimePropertyUpload', 0x16, 98),
|
||||||
|
])
|
||||||
|
def test_every_pinned_top_level_field_has_a_label(message, cmd_id, count):
|
||||||
|
assert len(MESSAGES[message]) == count
|
||||||
|
for number, (name, kind, repeated) in MESSAGES[message].items():
|
||||||
|
text = '\n'.join(decode_payload(2, 0xFE, cmd_id, sample(number, kind)))
|
||||||
|
assert name + ':' in text
|
||||||
|
assert describe_protobuf_field(2, 0xFE, cmd_id, number) == name
|
||||||
|
assert not re.search(r'\bfield\s+\d+:', text)
|
||||||
|
assert not any(word in text for word in ('<invalid', '<malformed', '<wire type'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_records_have_named_children_and_preserve_empty_records():
|
||||||
|
item = integer(1, 42) + integer(2, 1234)
|
||||||
|
text = display(blob(141, blob(1, item) + blob(1, b'')))
|
||||||
|
assert 'err_code_record_list: {' in text
|
||||||
|
assert 'list_info: {' in text
|
||||||
|
assert 'errcode: 42' in text
|
||||||
|
assert 'errcode_timestamp: 1234' in text
|
||||||
|
assert 'list_info: {}' in text
|
||||||
|
assert text.count('errcode:') == 1 # Empty records do not become invented errors.
|
||||||
|
assert not re.search(r'\bfield\s+\d+:', text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_statistics_have_named_categories_without_invented_energy_units():
|
||||||
|
items = blob(1, integer(1, 2) + integer(2, 17))
|
||||||
|
items += blob(1, integer(1, 4)) # Content absent, not an explicit zero.
|
||||||
|
items += blob(1, b'')
|
||||||
|
text = display(blob(463, items))
|
||||||
|
assert 'display_statistics_sum:' in text
|
||||||
|
assert 'statistics_object: STATISTICS_OBJECT_AC_OUT_ENERGY (2)' in text
|
||||||
|
assert 'statistics_object: STATISTICS_OBJECT_TYPEC_OUT_ENERGY (4)' in text
|
||||||
|
assert 'statistics_content: 17' in text
|
||||||
|
assert text.count('statistics_content:') == 1
|
||||||
|
assert 'list_info: {}' in text
|
||||||
|
assert ' Wh' not in text
|
||||||
|
assert not re.search(r'\bfield\s+\d+:', text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_strings_are_not_guessed_as_nested_messages():
|
||||||
|
text = display(blob(88, b'') + blob(433, b'TEST-ACCESSORY') + blob(91, b''))
|
||||||
|
assert "plug_in_info_dcp2_sn: ''" in text
|
||||||
|
assert "plug_in_info_dcp_sn: 'TEST-ACCESSORY'" in text
|
||||||
|
assert 'plug_in_info_dcp2_resv: {}' in text
|
||||||
|
invalid = display(blob(433, b'\xff'))
|
||||||
|
assert 'plug_in_info_dcp_sn: <invalid string:' in invalid
|
||||||
|
assert 'raw:' in invalid
|
||||||
|
|
||||||
|
|
||||||
|
def test_packed_and_unpacked_repeated_integers():
|
||||||
|
values = [0, 127, 128, 0xFFFFFFFF]
|
||||||
|
packed = display(blob(432, blob(1, b''.join(varint(n) for n in values))))
|
||||||
|
assert 'resv_info: [0, 127, 128, 4294967295]' in packed
|
||||||
|
unpacked = display(blob(432, b''.join(integer(1, n) for n in values)))
|
||||||
|
for value in values:
|
||||||
|
assert f'resv_info: {value}' in unpacked
|
||||||
|
assert 'resv_info: []' in display(blob(432, blob(1, b'')))
|
||||||
|
assert 'resv_info: <invalid uint32:' in display(blob(432, blob(1, b'\x80')))
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_boolean_enum_and_unknown_enum_values():
|
||||||
|
assert 'bms_min_cell_temp: -7 °C' in display(integer(258, -7))
|
||||||
|
assert 'xboost_en: true' in display(integer(25, 1))
|
||||||
|
assert 'xboost_en: false' in display(integer(25, 0))
|
||||||
|
assert 'statistics_object: 999 (unknown STATISTICS_OBJECT)' in display(blob(463, blob(1, integer(1, 999))))
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_field_with_wrong_encoding_retains_label_and_raw_bytes():
|
||||||
|
text = display(integer(3, 10))
|
||||||
|
assert 'pow_in_sum_w: <wire type 0, expected 5, raw:' in text
|
||||||
|
assert '10 W' not in text
|
||||||
|
assert 'pow_get_bms: <wire type 2, expected 5' in display(blob(158, b'bad'))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('bad', [b'\x00', b'\x80', b'\x0a\x05\x01', b'\x0d\x00', b'\x09\x00'])
|
||||||
|
def test_malformed_reports_are_not_normal_measurements(bad):
|
||||||
|
text = display(floating(262, 89) + bad)
|
||||||
|
assert '<malformed DisplayPropertyUpload:' in text
|
||||||
|
assert 'raw:' in text
|
||||||
|
assert 'cms_batt_soc: 89' not in text
|
||||||
|
assert decode_river3_payload(2, 0xFE, 0x15, floating(262, 89) + bad) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_future_fields_remain_numeric_and_raw():
|
||||||
|
text = display(integer(999, 7) + blob(1000, b'opaque'))
|
||||||
|
assert 'field 999: 7' in text
|
||||||
|
assert 'field 1000:' in text
|
||||||
|
assert b'opaque'.hex() in text
|
||||||
|
assert 'unknown' in text
|
||||||
|
assert decode_payload(0x35, 0x35, 0x20, bytes(8)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_routing_stays_explicitly_unconfirmed():
|
||||||
|
text = '\n'.join(decode_payload(2, 0xFE, 0x16, floating(244, 19.2)))
|
||||||
|
assert 'routing unconfirmed' in text
|
||||||
|
assert 'bms_batt_vol: 19.200 V' in text
|
||||||
|
assert decode_river3_payload(2, 0xFE, 0x16, floating(244, 19.2)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_nesting_has_a_depth_limit(monkeypatch):
|
||||||
|
monkeypatch.setitem(MESSAGES, 'SyntheticRecursive', {1: ('child', 'SyntheticRecursive', False)})
|
||||||
|
payload = b''
|
||||||
|
for _ in range(30):
|
||||||
|
payload = blob(1, payload)
|
||||||
|
text = '\n'.join(decode_schema_protobuf(payload, 'SyntheticRecursive'))
|
||||||
|
assert 'maximum nesting depth reached' in text
|
||||||
|
assert 'raw:' in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def generator():
|
||||||
|
path = Path(__file__).resolve().parents[1] / 'tools/generate_ecoflow_river3_schema.py'
|
||||||
|
spec = importlib.util.spec_from_file_location('schema_generator', path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_generator_is_deterministic_and_pinned(generator):
|
||||||
|
path = Path(__file__).resolve().parents[1] / 'bmspy/ecoflow_river3_schema.py'
|
||||||
|
assert re.fullmatch('[a-f0-9]{40}', SCHEMA_COMMIT)
|
||||||
|
assert generator.generate(MESSAGES, ENUMS, SCHEMA_COMMIT) == path.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generator_keeps_reachable_types_and_ignores_unrelated_nested_types(generator):
|
||||||
|
schema = '''
|
||||||
|
message DisplayPropertyUpload { optional Item values = 1; }
|
||||||
|
message RuntimePropertyUpload { repeated uint32 raw = 2; }
|
||||||
|
message Item { optional Kind kind = 1; }
|
||||||
|
enum Kind { NONE = 0; ACTIVE = 1; }
|
||||||
|
message Unrelated { message Item { optional uint32 unrelated = 1; } }
|
||||||
|
'''
|
||||||
|
messages, enums = generator.parse_schema(schema)
|
||||||
|
assert messages['Item'] == {1: ('kind', 'Kind', False)}
|
||||||
|
assert messages['RuntimePropertyUpload'][2] == ('raw', 'uint32', True)
|
||||||
|
assert enums['Kind'][1] == 'ACTIVE'
|
||||||
|
assert 'Unrelated' not in messages
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('body', [
|
||||||
|
'optional Missing value = 1;',
|
||||||
|
'optional uint32 a = 1; optional uint32 b = 1;',
|
||||||
|
'map<string, int32> unsupported = 1;',
|
||||||
|
'message Nested {} optional Nested value = 1;',
|
||||||
|
])
|
||||||
|
def test_generator_rejects_unsupported_or_incomplete_reachable_types(generator, body):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
generator.parse_schema('message DisplayPropertyUpload {' + body + '} message RuntimePropertyUpload {}')
|
||||||
+134
-25
@@ -4,11 +4,12 @@ Connect to EcoFlow River 2 and River 3 via Bluetooth LE and print a telemetry
|
|||||||
snapshot.
|
snapshot.
|
||||||
|
|
||||||
Supply your device details on the command line, or use --scan to discover them.
|
Supply your device details on the command line, or use --scan to discover them.
|
||||||
After authentication, devices push telemetry packets. The script waits for the
|
After authentication, devices push telemetry packets. River 2 collection waits
|
||||||
expected packet types or the --timeout limit, then prints the snapshot and exits.
|
for its expected heartbeat types. River 3 prints updates as they arrive and tracks
|
||||||
River 2 requires valid PD, EMS, and BMS core payloads. Optional heartbeats and
|
required measurement and configuration fields across partial reports. Normal
|
||||||
other packets are listed separately. Use --watch to print every decoded/raw
|
collection finishes when the required information arrives; --timeout is only a
|
||||||
packet until Ctrl+C. Watch output can contain private identifiers.
|
failure limit. Use --watch to print every packet from either model until Ctrl+C,
|
||||||
|
including raw payloads for investigation. Watch output can contain private data.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run --extra ecoflow-bt python tools/ecoflow-bt.py --user-id <id> --scan
|
uv run --extra ecoflow-bt python tools/ecoflow-bt.py --user-id <id> --scan
|
||||||
@@ -24,13 +25,33 @@ import sys
|
|||||||
try:
|
try:
|
||||||
from bmspy.ecoflow_bt import EcoFlowBTManager, EcoFlowBTDevice, EcoFlowPacket, scan_for_devices
|
from bmspy.ecoflow_bt import EcoFlowBTManager, EcoFlowBTDevice, EcoFlowPacket, scan_for_devices
|
||||||
from bmspy.ecoflow_login import get_user_id
|
from bmspy.ecoflow_login import get_user_id
|
||||||
from bmspy.ecoflow_proto import decode_payload, decode_protobuf, decode_river2_payload
|
from bmspy.ecoflow_proto import (
|
||||||
|
format_payload, format_protobuf, describe_protobuf_field, parse_protobuf_fields,
|
||||||
|
)
|
||||||
|
from bmspy.ecoflow_river2 import decode_river2_payload
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
sys.exit(f"Missing dependency: {exc}\n"
|
sys.exit(f"Missing dependency: {exc}\n"
|
||||||
"Run: uv sync --extra ecoflow-bt")
|
"Run: uv sync --extra ecoflow-bt")
|
||||||
|
|
||||||
# ── Model profiles (no device-specific identifiers) ──────────────────────────
|
# ── Model profiles (no device-specific identifiers) ──────────────────────────
|
||||||
|
|
||||||
|
# Baseline from the named fields in observed R651 measurement/configuration
|
||||||
|
# reports, not every optional field in the protocol schema. Other firmware can
|
||||||
|
# omit fields: report those as missing rather than claiming a complete report.
|
||||||
|
# Values are protobuf wire types (0: varint, 5: float32).
|
||||||
|
RIVER3_REQUIRED_GROUPS = {
|
||||||
|
"measurements": {
|
||||||
|
**dict.fromkeys((1, 148, 212, 248, 254, 255, 258, 259, 260, 261,
|
||||||
|
268, 269, 275, 281, 282, 355), 0),
|
||||||
|
**dict.fromkeys((3, 4, 9, 10, 11, 12, 37, 54, 158, 242, 243,
|
||||||
|
262, 263, 361, 368), 5),
|
||||||
|
},
|
||||||
|
"configuration": dict.fromkeys(
|
||||||
|
(7, 8, 17, 18, 19, 25, 33, 74, 195, 202, 209, 211, 270, 271,
|
||||||
|
356, 358, 360, 364, 365, 366, 367, 458), 0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
RIVER2_REQUIRED_HEARTBEATS = {
|
RIVER2_REQUIRED_HEARTBEATS = {
|
||||||
"ef_pkt_02_20_02": "PD",
|
"ef_pkt_02_20_02": "PD",
|
||||||
"ef_pkt_03_20_02": "EMS",
|
"ef_pkt_03_20_02": "EMS",
|
||||||
@@ -46,8 +67,14 @@ MODEL_PROFILES = {
|
|||||||
"label": "River 3",
|
"label": "River 3",
|
||||||
"enc_type": 7,
|
"enc_type": 7,
|
||||||
"src_candidates": [0x02],
|
"src_candidates": [0x02],
|
||||||
# cmd_set=0xFE: DisplayPropertyUpload (0x15) + RuntimePropertyUpload (0x16)
|
"required_groups": RIVER3_REQUIRED_GROUPS,
|
||||||
"expected_fields": {"ef_pkt_02_fe_15", "ef_pkt_02_fe_16"},
|
# DisplayPropertyUpload can arrive as several partial updates.
|
||||||
|
# RuntimePropertyUpload (0x16) is not confirmed for River 3.
|
||||||
|
"expected_fields": {
|
||||||
|
"ef_pkt_02_fe_15", "bms_capacity_charge_ratio",
|
||||||
|
"bms_temperature_celcius", "ef_input_power_watts",
|
||||||
|
"ef_output_power_watts", "bms_charge_is_charging",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"R601": {
|
"R601": {
|
||||||
"label": "River 2",
|
"label": "River 2",
|
||||||
@@ -60,6 +87,8 @@ MODEL_PROFILES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MODEL_PROFILES["R603"] = {**MODEL_PROFILES["R601"], "label": "River 2 Max"}
|
MODEL_PROFILES["R603"] = {**MODEL_PROFILES["R601"], "label": "River 2 Max"}
|
||||||
|
for prefix in ("R653", "R654", "R655"):
|
||||||
|
MODEL_PROFILES[prefix] = {**MODEL_PROFILES["R651"]}
|
||||||
|
|
||||||
# ── Packet decoder ─────────────────────────────────────────────────────────────
|
# ── Packet decoder ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -92,12 +121,12 @@ def _decode_snapshot_packet(
|
|||||||
return [f" {header}: (empty payload)"]
|
return [f" {header}: (empty payload)"]
|
||||||
|
|
||||||
for src in src_candidates:
|
for src in src_candidates:
|
||||||
lines = decode_payload(src, cmd_set, cmd_id, payload)
|
lines = format_payload(src, cmd_set, cmd_id, payload)
|
||||||
if lines is not None:
|
if lines is not None:
|
||||||
return [" " + ln for ln in lines]
|
return [" " + ln for ln in lines]
|
||||||
|
|
||||||
# Fallback: generic protobuf decode
|
# Fallback: generic protobuf formatting
|
||||||
pb = decode_protobuf(payload)
|
pb = format_protobuf(payload)
|
||||||
if pb:
|
if pb:
|
||||||
return [f" {header}"] + [" " + ln for ln in pb]
|
return [f" {header}"] + [" " + ln for ln in pb]
|
||||||
return [f" {header}: {payload_hex[:120]}{'...' if len(payload_hex) > 120 else ''}"]
|
return [f" {header}: {payload_hex[:120]}{'...' if len(payload_hex) > 120 else ''}"]
|
||||||
@@ -106,7 +135,10 @@ def _decode_snapshot_packet(
|
|||||||
# ── Snapshot printer ───────────────────────────────────────────────────────────
|
# ── Snapshot printer ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def print_snapshot(label: str, address: str, snap, src_candidates: list[int]) -> None:
|
def print_snapshot(
|
||||||
|
label: str, address: str, snap, src_candidates: list[int],
|
||||||
|
decoded_packets: dict[str, list[str]] | None = None,
|
||||||
|
) -> None:
|
||||||
print(f"\n{'━' * 60}")
|
print(f"\n{'━' * 60}")
|
||||||
print(f" {label} ({address})")
|
print(f" {label} ({address})")
|
||||||
print(f"{'━' * 60}")
|
print(f"{'━' * 60}")
|
||||||
@@ -125,28 +157,42 @@ def print_snapshot(label: str, address: str, snap, src_candidates: list[int]) ->
|
|||||||
|
|
||||||
for key, payload_hex in pkt_keys:
|
for key, payload_hex in pkt_keys:
|
||||||
print(f"\n {key}:")
|
print(f"\n {key}:")
|
||||||
for line in _decode_snapshot_packet(key, payload_hex, src_candidates):
|
lines = (decoded_packets or {}).get(key)
|
||||||
|
if lines is None:
|
||||||
|
lines = _decode_snapshot_packet(key, payload_hex, src_candidates)
|
||||||
|
for line in lines:
|
||||||
print(line)
|
print(line)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
# ── Collection logic ───────────────────────────────────────────────────────────
|
# ── Live diagnostics and field-level collection ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class PacketCollector:
|
class PacketCollector:
|
||||||
"""Track valid River 2 heartbeats and optionally print each raw packet."""
|
"""Track valid River 2 heartbeats or accumulated River 3 fields."""
|
||||||
|
|
||||||
def __init__(self, address: str, profile: dict, watch: bool = False) -> None:
|
def __init__(self, address: str, profile: dict, watch: bool = False) -> None:
|
||||||
self.address = address
|
self.address = address
|
||||||
self.label = profile["label"]
|
self.label = profile["label"]
|
||||||
|
self.required_groups = profile.get("required_groups", {})
|
||||||
self.required_heartbeats = profile.get("required_heartbeats", {})
|
self.required_heartbeats = profile.get("required_heartbeats", {})
|
||||||
self.valid_heartbeats: set[str] = set()
|
self.valid_heartbeats: set[str] = set()
|
||||||
|
self.required_types = {
|
||||||
|
number: wire_type for fields in self.required_groups.values()
|
||||||
|
for number, wire_type in fields.items()
|
||||||
|
}
|
||||||
self.watch = watch
|
self.watch = watch
|
||||||
|
self.seen: set[tuple[int, int]] = set()
|
||||||
|
self.fields: dict[int, bytes] = {}
|
||||||
self.error: Exception | None = None
|
self.error: Exception | None = None
|
||||||
|
|
||||||
def observe_snapshot(self, snap) -> None:
|
def observe_snapshot(self, snap) -> None:
|
||||||
"""Seed receipt tracking for callers that start after reception."""
|
"""Seed River 2 receipt tracking for callers that start after reception.
|
||||||
|
|
||||||
|
Normal CLI runs register receive() before connecting. Snapshot seeding
|
||||||
|
also supports direct collect() callers without packet listeners.
|
||||||
|
"""
|
||||||
for key, field in snap.items():
|
for key, field in snap.items():
|
||||||
if key not in self.required_heartbeats or key in self.valid_heartbeats:
|
if key not in self.required_heartbeats or key in self.valid_heartbeats:
|
||||||
continue
|
continue
|
||||||
@@ -164,17 +210,30 @@ class PacketCollector:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def missing(self) -> set[str]:
|
def missing(self) -> set[str]:
|
||||||
|
if self.required_heartbeats:
|
||||||
return {
|
return {
|
||||||
f"{label} ({key})" for key, label in self.required_heartbeats.items()
|
f"{label} ({key})" for key, label in self.required_heartbeats.items()
|
||||||
if key not in self.valid_heartbeats
|
if key not in self.valid_heartbeats
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
f"{group}:{describe_protobuf_field(2, 0xFE, 0x15, number) or number}"
|
||||||
|
for group, fields in self.required_groups.items()
|
||||||
|
for number, wire_type in fields.items()
|
||||||
|
if (number, wire_type) not in self.seen
|
||||||
|
}
|
||||||
|
|
||||||
def progress(self) -> str:
|
def progress(self) -> str:
|
||||||
|
if self.required_heartbeats:
|
||||||
return (f"{len(self.valid_heartbeats)}/{len(self.required_heartbeats)} "
|
return (f"{len(self.valid_heartbeats)}/{len(self.required_heartbeats)} "
|
||||||
"required heartbeats received")
|
"required heartbeats received")
|
||||||
|
return ", ".join(
|
||||||
|
f"{group} {sum((n, w) in self.seen for n, w in fields.items())}/{len(fields)}"
|
||||||
|
for group, fields in self.required_groups.items()
|
||||||
|
)
|
||||||
|
|
||||||
def receive(self, packet: EcoFlowPacket) -> None:
|
def receive(self, packet: EcoFlowPacket) -> None:
|
||||||
# Re-raise callback/output failures from collect(), which owns cleanup.
|
# Device callbacks cannot propagate failures through the BLE dispatcher.
|
||||||
|
# The collection loop re-raises output errors and closes the connections.
|
||||||
if self.error is not None:
|
if self.error is not None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -187,7 +246,7 @@ class PacketCollector:
|
|||||||
key = f"ef_pkt_{packet.src:02x}_{packet.cmd_set:02x}_{packet.cmd_id:02x}"
|
key = f"ef_pkt_{packet.src:02x}_{packet.cmd_set:02x}_{packet.cmd_id:02x}"
|
||||||
header = f"\n{self.label} ({self.address}) {key}:"
|
header = f"\n{self.label} ({self.address}) {key}:"
|
||||||
if self.watch:
|
if self.watch:
|
||||||
lines = decode_payload(*route, packet.payload)
|
lines = format_payload(*route, packet.payload)
|
||||||
print("\n".join([
|
print("\n".join([
|
||||||
header,
|
header,
|
||||||
f" version=0x{packet.version:02x} dst=0x{packet.dst:02x} "
|
f" version=0x{packet.version:02x} dst=0x{packet.dst:02x} "
|
||||||
@@ -195,9 +254,43 @@ class PacketCollector:
|
|||||||
f" raw_payload: {packet.payload.hex() or '(empty)'}",
|
f" raw_payload: {packet.payload.hex() or '(empty)'}",
|
||||||
*(" " + line for line in (lines or ["No decoded fields; retain raw payload for review."])),
|
*(" " + line for line in (lines or ["No decoded fields; retain raw payload for review."])),
|
||||||
]), flush=True)
|
]), flush=True)
|
||||||
|
|
||||||
if key in self.required_heartbeats and not self._accept_heartbeat(key, packet.payload):
|
if key in self.required_heartbeats and not self._accept_heartbeat(key, packet.payload):
|
||||||
print(f"{header}\n {self.required_heartbeats[key]} heartbeat rejected by decoder. "
|
print(f"{header}\n {self.required_heartbeats[key]} heartbeat rejected by decoder. "
|
||||||
"Not counted toward completion", flush=True)
|
"Not counted toward completion", flush=True)
|
||||||
|
if not self.required_groups or route != (0x02, 0xFE, 0x15):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
parsed = parse_protobuf_fields(packet.payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"{header}\n Malformed report, not counted: {exc}", flush=True)
|
||||||
|
return
|
||||||
|
# Keep repeated/nested values together. A subsequent report replaces a
|
||||||
|
# whole top-level field, rather than flattening repeated child fields.
|
||||||
|
grouped: dict[int, bytes] = {}
|
||||||
|
for field in parsed:
|
||||||
|
expected_type = self.required_types.get(field.number)
|
||||||
|
if expected_type is not None and field.wire_type != expected_type:
|
||||||
|
print(f"{header}\n Field {field.number}: unexpected wire type "
|
||||||
|
f"{field.wire_type}, not counted", flush=True)
|
||||||
|
continue
|
||||||
|
self.seen.add((field.number, field.wire_type))
|
||||||
|
grouped[field.number] = grouped.get(field.number, b"") + field.encoded
|
||||||
|
changed = {n: value for n, value in grouped.items() if self.fields.get(n) != value}
|
||||||
|
self.fields.update(grouped)
|
||||||
|
if changed and not self.watch:
|
||||||
|
lines = format_payload(2, 0xFE, 0x15, b"".join(changed[n] for n in sorted(changed)))
|
||||||
|
print("\n".join([header, *(" " + line for line in lines)]), flush=True)
|
||||||
|
|
||||||
|
def decoded_packets(self) -> dict[str, list[str]]:
|
||||||
|
if not self.fields:
|
||||||
|
return {}
|
||||||
|
payload = b"".join(self.fields[n] for n in sorted(self.fields))
|
||||||
|
lines = format_payload(2, 0xFE, 0x15, payload)
|
||||||
|
return {"ef_pkt_02_fe_15": [
|
||||||
|
" [DisplayPropertyUpload: latest fields collected this run]",
|
||||||
|
*(" " + line for line in lines[1:]),
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
def print_river2_packet_summary(snap) -> None:
|
def print_river2_packet_summary(snap) -> None:
|
||||||
@@ -216,13 +309,19 @@ def print_river2_packet_summary(snap) -> None:
|
|||||||
print(f" Other packet types seen: {', '.join(descriptions)}", flush=True)
|
print(f" Other packet types seen: {', '.join(descriptions)}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Collection logic ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def collect(
|
async def collect(
|
||||||
devices: list[tuple[str, EcoFlowBTDevice, set[str]]],
|
devices: list[tuple[str, EcoFlowBTDevice, set[str]]],
|
||||||
max_wait: float,
|
max_wait: float,
|
||||||
collectors: dict[str, PacketCollector] | None = None,
|
collectors: dict[str, PacketCollector] | None = None,
|
||||||
watch: bool = False,
|
watch: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Finish on required information, or keep watching until interrupted."""
|
"""Finish on required information, or keep watching until interrupted.
|
||||||
|
|
||||||
|
The deadline is a failure limit, not evidence that a report is complete.
|
||||||
|
"""
|
||||||
collectors = dict(collectors or {})
|
collectors = dict(collectors or {})
|
||||||
for addr, dev, expected in devices:
|
for addr, dev, expected in devices:
|
||||||
if addr not in collectors and expected and expected <= RIVER2_REQUIRED_HEARTBEATS.keys():
|
if addr not in collectors and expected and expected <= RIVER2_REQUIRED_HEARTBEATS.keys():
|
||||||
@@ -238,7 +337,8 @@ async def collect(
|
|||||||
|
|
||||||
def missing_fields(addr, dev, expected):
|
def missing_fields(addr, dev, expected):
|
||||||
collector = collectors.get(addr)
|
collector = collectors.get(addr)
|
||||||
if collector is not None and collector.required_heartbeats:
|
if collector is not None and (collector.required_groups or collector.required_heartbeats):
|
||||||
|
if collector.required_heartbeats:
|
||||||
collector.observe_snapshot(dev.snapshot)
|
collector.observe_snapshot(dev.snapshot)
|
||||||
return collector.missing()
|
return collector.missing()
|
||||||
return expected - {name for name, _ in dev.snapshot.items()}
|
return expected - {name for name, _ in dev.snapshot.items()}
|
||||||
@@ -256,11 +356,15 @@ async def collect(
|
|||||||
missing = missing_fields(addr, dev, expected)
|
missing = missing_fields(addr, dev, expected)
|
||||||
if missing:
|
if missing:
|
||||||
if last_missing.get(addr) != missing:
|
if last_missing.get(addr) != missing:
|
||||||
if not watch:
|
if collector is not None and collector.required_groups:
|
||||||
|
print(f" {addr}: {collector.progress()}; missing {sorted(missing)}", flush=True)
|
||||||
|
elif not watch:
|
||||||
print(f" {addr}: waiting for {missing}", flush=True)
|
print(f" {addr}: waiting for {missing}", flush=True)
|
||||||
last_missing[addr] = missing
|
last_missing[addr] = missing
|
||||||
else:
|
else:
|
||||||
if collector is not None and collector.required_heartbeats:
|
if collector is not None and collector.required_groups:
|
||||||
|
print(f" {addr}: all required information received ({collector.progress()})", flush=True)
|
||||||
|
elif collector is not None and collector.required_heartbeats:
|
||||||
labels = ", ".join(collector.required_heartbeats.values())
|
labels = ", ".join(collector.required_heartbeats.values())
|
||||||
print(f" {addr}: {collector.progress()} ({labels})", flush=True)
|
print(f" {addr}: {collector.progress()} ({labels})", flush=True)
|
||||||
print_river2_packet_summary(dev.snapshot)
|
print_river2_packet_summary(dev.snapshot)
|
||||||
@@ -343,15 +447,16 @@ async def run(user_id: str, args: argparse.Namespace) -> None:
|
|||||||
encrypt_type=d["enc_type"],
|
encrypt_type=d["enc_type"],
|
||||||
)
|
)
|
||||||
devices.append((addr, dev, d["expected_fields"]))
|
devices.append((addr, dev, d["expected_fields"]))
|
||||||
if watch or d.get("required_heartbeats"):
|
if watch or d.get("required_groups") or d.get("required_heartbeats"):
|
||||||
collector = PacketCollector(addr, d, watch=watch)
|
collector = PacketCollector(addr, d, watch=watch)
|
||||||
collectors[addr] = collector
|
collectors[addr] = collector
|
||||||
|
# Register before connecting so the first notification is not lost.
|
||||||
dev.on_packet(collector.receive)
|
dev.on_packet(collector.receive)
|
||||||
print(f"Registered {d['label']} {address} serial={d['serial_number']}")
|
print(f"Registered {d['label']} {address} serial={d['serial_number']}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if watch:
|
if watch:
|
||||||
print("\nWatching until Ctrl+C; telemetry timeout is ignored.\n"
|
print("\nWatching both models until Ctrl+C; telemetry timeout is ignored.\n"
|
||||||
"Watch output can contain private identifiers. Redact it before sharing.", flush=True)
|
"Watch output can contain private identifiers. Redact it before sharing.", flush=True)
|
||||||
print("\nConnecting…", flush=True)
|
print("\nConnecting…", flush=True)
|
||||||
await manager.connect_all(auth_timeout=30.0)
|
await manager.connect_all(auth_timeout=30.0)
|
||||||
@@ -362,6 +467,10 @@ async def run(user_id: str, args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
print("\nSnapshot:")
|
print("\nSnapshot:")
|
||||||
for addr, dev, _ in devices:
|
for addr, dev, _ in devices:
|
||||||
|
if addr in collectors and collectors[addr].required_groups:
|
||||||
|
print_snapshot(labels[addr], addr, dev.snapshot, src_by_addr[addr],
|
||||||
|
decoded_packets=collectors[addr].decoded_packets())
|
||||||
|
else:
|
||||||
print_snapshot(labels[addr], addr, dev.snapshot, src_by_addr[addr])
|
print_snapshot(labels[addr], addr, dev.snapshot, src_by_addr[addr])
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -393,7 +502,7 @@ def main() -> None:
|
|||||||
parser.add_argument("--timeout", type=float, default=120.0, metavar="SEC",
|
parser.add_argument("--timeout", type=float, default=120.0, metavar="SEC",
|
||||||
help="Failure limit for required information (default: 120 s; ignored by --watch)")
|
help="Failure limit for required information (default: 120 s; ignored by --watch)")
|
||||||
parser.add_argument("--watch", action="store_true",
|
parser.add_argument("--watch", action="store_true",
|
||||||
help="Print decoded and raw packets until Ctrl+C, including unknown information")
|
help="Print decoded and raw packets from either model until Ctrl+C, including unknown information")
|
||||||
parser.add_argument("--debug", type=int, default=0, metavar="N")
|
parser.add_argument("--debug", type=int, default=0, metavar="N")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate diagnostic descriptors from the Apache-2.0 ha-ef-ble pr705 schema.
|
||||||
|
|
||||||
|
Run manually from a checkout. No network access or generator dependency is needed
|
||||||
|
at runtime. Only message/enum declarations reachable from the two upload messages
|
||||||
|
are retained. The generated file contains data, not upstream decoder code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
REPOSITORY = "rabits/ha-ef-ble"
|
||||||
|
SCALARS = {
|
||||||
|
"bool", "bytes", "string", "float", "double", "int32", "int64", "uint32",
|
||||||
|
"uint64", "sint32", "sint64", "fixed32", "fixed64", "sfixed32", "sfixed64",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def api(path):
|
||||||
|
request = Request(f"https://api.github.com/repos/{REPOSITORY}/{path}",
|
||||||
|
headers={"User-Agent": "bmspy-schema-generator"})
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def source_file(path, ref):
|
||||||
|
data = api(f"contents/{path}?ref={ref}")
|
||||||
|
if data["encoding"] != "base64":
|
||||||
|
raise ValueError("Expected a base64-encoded source file")
|
||||||
|
return base64.b64decode(data["content"]).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def declarations(text):
|
||||||
|
"""Yield top-level declarations without treating nested names as global."""
|
||||||
|
position = 0
|
||||||
|
while match := re.search(r"\b(message|enum)\s+(\w+)\s*\{", text[position:]):
|
||||||
|
start = position + match.end()
|
||||||
|
end, depth = start, 1
|
||||||
|
while depth and end < len(text):
|
||||||
|
depth += (text[end] == "{") - (text[end] == "}")
|
||||||
|
end += 1
|
||||||
|
if depth:
|
||||||
|
raise ValueError("Unterminated schema declaration")
|
||||||
|
yield *match.groups(), text[start:end - 1]
|
||||||
|
position = end
|
||||||
|
|
||||||
|
|
||||||
|
def parse_schema(text):
|
||||||
|
"""Parse reachable flat declarations. Reject unsupported reachable types."""
|
||||||
|
text = re.sub(r"/\*.*?\*/|//[^\n]*", "", text, flags=re.S)
|
||||||
|
messages, enums = {}, {}
|
||||||
|
for kind, name, body in declarations(text):
|
||||||
|
if "{" in body:
|
||||||
|
# EventPush has nested definitions but neither upload depends on it.
|
||||||
|
# If a reachable type needs nesting, resolution below must fail.
|
||||||
|
continue
|
||||||
|
fields = {}
|
||||||
|
for statement in body.split(";"):
|
||||||
|
statement = statement.strip()
|
||||||
|
if not statement:
|
||||||
|
continue
|
||||||
|
if kind == "message":
|
||||||
|
match = re.fullmatch(r"(optional|repeated|required)\s+(\w+)\s+(\w+)\s*=\s*(\d+)", statement)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Unsupported field declaration in {name}: {statement}")
|
||||||
|
cardinality, data_type, field_name, number = match.groups()
|
||||||
|
value = (field_name, data_type, cardinality == "repeated")
|
||||||
|
else:
|
||||||
|
match = re.fullmatch(r"(\w+)\s*=\s*(-?\d+)", statement)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Unsupported enum declaration in {name}: {statement}")
|
||||||
|
value, number = match.groups()
|
||||||
|
number = int(number)
|
||||||
|
if number in fields:
|
||||||
|
raise ValueError(f"Duplicate field/enum value in {name}: {number}")
|
||||||
|
fields[number] = value
|
||||||
|
target = messages if kind == "message" else enums
|
||||||
|
if name in target:
|
||||||
|
raise ValueError(f"Duplicate declaration: {name}")
|
||||||
|
target[name] = fields
|
||||||
|
wanted_messages, wanted_enums = {}, {}
|
||||||
|
pending = ["DisplayPropertyUpload", "RuntimePropertyUpload"]
|
||||||
|
while pending:
|
||||||
|
name = pending.pop()
|
||||||
|
if name in SCALARS or name in wanted_messages or name in wanted_enums:
|
||||||
|
continue
|
||||||
|
if name in enums:
|
||||||
|
wanted_enums[name] = enums[name]
|
||||||
|
elif name in messages:
|
||||||
|
wanted_messages[name] = messages[name]
|
||||||
|
pending.extend(data_type for _, data_type, _ in messages[name].values())
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unresolved schema type: {name}")
|
||||||
|
return wanted_messages, wanted_enums
|
||||||
|
|
||||||
|
|
||||||
|
def generate(messages, enums, ref):
|
||||||
|
lines = [
|
||||||
|
'# SPDX-License-Identifier: Apache-2.0',
|
||||||
|
f'# Source: https://github.com/{REPOSITORY}/blob/{ref}/proto/pr705.proto',
|
||||||
|
'# Generated by tools/generate_ecoflow_river3_schema.py. Do not edit by hand.',
|
||||||
|
'', f'SCHEMA_COMMIT = {ref!r}', '',
|
||||||
|
'# Field number: (name, protobuf type, repeated). Units are curated separately.',
|
||||||
|
'MESSAGES = {',
|
||||||
|
]
|
||||||
|
for name, fields in sorted(messages.items()):
|
||||||
|
lines.append(f' {name!r}: {{')
|
||||||
|
lines.extend(f' {number}: {field!r},' for number, field in sorted(fields.items()))
|
||||||
|
lines.append(' },')
|
||||||
|
lines.extend(['}', '', 'ENUMS = {'])
|
||||||
|
for name, values in sorted(enums.items()):
|
||||||
|
lines.append(f' {name!r}: {{')
|
||||||
|
lines.extend(f' {number}: {value!r},' for number, value in sorted(values.items()))
|
||||||
|
lines.append(' },')
|
||||||
|
lines.extend(['}', ''])
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--ref', help='Pinned upstream commit (defaults to current main)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
ref = args.ref or api('commits/main')['sha']
|
||||||
|
if not re.fullmatch(r'[a-f0-9]{40}', ref):
|
||||||
|
parser.error('--ref must be a full commit SHA')
|
||||||
|
messages, enums = parse_schema(source_file('proto/pr705.proto', ref))
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
(root / 'bmspy/ecoflow_river3_schema.py').write_text(generate(messages, enums, ref), encoding='utf-8')
|
||||||
|
print(f'Generated {len(messages)} messages and {len(enums)} enums from {ref}')
|
||||||
|
for name in ('DisplayPropertyUpload', 'RuntimePropertyUpload'):
|
||||||
|
print(f'{name}: {len(messages[name])} fields')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user