Add River 2 Bluetooth telemetry and diagnostics
Decode River 2 charge, temperature, power flow, and time estimates. Preserve precise charge readings across packet reordering and omit unavailable time estimates. Require valid PD, EMS, and BMS core heartbeats for diagnostic completion. Report optional packets separately and support raw watch output with reliable disconnect cleanup. Use synthetic regression tests and keep captures, credentials, and external keydata private. Device details come from command-line arguments or discovery. This commit retains Poetry; the separate packaging commit migrates to uv.
This commit is contained in:
+19
-5
@@ -45,6 +45,17 @@ def handle_registration(socket_path: str, client_name: str, debug: int = 0) -> d
|
||||
return data
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, length: int) -> bytes:
|
||||
"""Read a complete frame, even when the stream splits it across receives."""
|
||||
data = bytearray()
|
||||
while len(data) < length:
|
||||
chunk = sock.recv(length - len(data))
|
||||
if not chunk:
|
||||
raise OSError("Daemon closed the socket before the response was complete")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0) -> dict[str, Any]:
|
||||
"""Send a JSON request to the daemon socket and return the decoded JSON response."""
|
||||
response_data: dict[str, Any] = dict()
|
||||
@@ -62,6 +73,8 @@ def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0)
|
||||
debugger("Failed to connect to bmspy daemon")
|
||||
else:
|
||||
debugger("socket client: {}".format(msg))
|
||||
sock.close()
|
||||
raise
|
||||
|
||||
# Send request
|
||||
if debug > 2:
|
||||
@@ -81,13 +94,14 @@ def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0)
|
||||
if debug > 4:
|
||||
debugger("socket client: outgoing request: {}".format(request))
|
||||
except Exception:
|
||||
sock.close()
|
||||
debugger("socket client ERROR: unable to encode request")
|
||||
sys.exit(1)
|
||||
sock.sendall(request)
|
||||
|
||||
# get length of expected json string
|
||||
response = sock.recv(struct.calcsize("!I"))
|
||||
try:
|
||||
sock.sendall(request)
|
||||
response = _recv_exact(sock, struct.calcsize("!I"))
|
||||
length = struct.unpack("!I", response)[0]
|
||||
if debug > 4:
|
||||
debugger(
|
||||
@@ -96,18 +110,18 @@ def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0)
|
||||
)
|
||||
)
|
||||
# read length bytes
|
||||
response = sock.recv(length)
|
||||
response = _recv_exact(sock, length)
|
||||
if debug > 3:
|
||||
debugger("socket client: incoming response: {}".format(response))
|
||||
response_data = json.loads(response)
|
||||
except Exception:
|
||||
debugger("socket client ERROR: unable to decode response")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
sock.close()
|
||||
if debug > 2:
|
||||
debugger("socket client: received {!r}".format(response_data))
|
||||
|
||||
sock.close()
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
|
||||
+1033
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# Retrieve EcoFlow user ID by logging in with email and password.
|
||||
#
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
|
||||
REGIONS = {
|
||||
"EU": "api-e.ecoflow.com",
|
||||
"US": "api-a.ecoflow.com",
|
||||
"default": "api.ecoflow.com",
|
||||
}
|
||||
|
||||
|
||||
def get_user_id(email: str, password: str, region: str = "default") -> str:
|
||||
base_url = REGIONS.get(region.upper(), REGIONS["default"])
|
||||
url = f"https://{base_url}/auth/login"
|
||||
payload = json.dumps({
|
||||
"scene": "IOT_APP",
|
||||
"appVersion": "1.0.0",
|
||||
"password": base64.b64encode(password.encode()).decode(),
|
||||
"oauth": {"bundleId": "com.ef.EcoFlow"},
|
||||
"email": email,
|
||||
"userType": "ECOFLOW",
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise SystemExit(f"Login failed: HTTP {e.code} {e.reason}")
|
||||
except urllib.error.URLError as e:
|
||||
raise SystemExit(f"Connection error: {e.reason}")
|
||||
|
||||
if data.get("code") != "0":
|
||||
msg = data.get("message", "unknown error")
|
||||
raise SystemExit(f"Login failed: {msg}")
|
||||
|
||||
try:
|
||||
return str(data["data"]["user"]["userId"])
|
||||
except KeyError:
|
||||
raise SystemExit(f"Unexpected response structure: {data}")
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
import getpass
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Retrieve EcoFlow user ID via the EcoFlow API",
|
||||
)
|
||||
parser.add_argument("email", help="EcoFlow account email")
|
||||
parser.add_argument(
|
||||
"--region",
|
||||
"-r",
|
||||
default="default",
|
||||
choices=["default", "EU", "US"],
|
||||
help="API region (default: global)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
password = getpass.getpass(f"Password for {args.email}: ")
|
||||
user_id = get_user_id(args.email, password, args.region)
|
||||
print(user_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
EcoFlow BLE protocol decoder — River 2 (struct-packed) and River 3 (protobuf).
|
||||
|
||||
River 2 (serial R601/R603/R611/R613):
|
||||
Little-endian packed C structs; cmd_set=0x20.
|
||||
|
||||
River 3 (serial R651/R653/R654/R655):
|
||||
Protobuf-encoded (standard_protocol_mr521.proto, package mr521).
|
||||
cmd_set=0xFE, cmd_id=0x15, src=0x02 → DisplayPropertyUpload.
|
||||
cmd_set=0xFE, cmd_id=0x16, src=0x02 → RuntimePropertyUpload (cmdId inferred).
|
||||
|
||||
Sources:
|
||||
https://github.com/rabits/ef-ble-reverse
|
||||
https://github.com/rabits/ha-ef-ble
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
# ── Protobuf wire-format decoder ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class ProtoField(NamedTuple):
|
||||
name: str
|
||||
unit: str = ""
|
||||
signed: bool = False # int32 fields that may be negative
|
||||
|
||||
|
||||
def _pb_read_varint(data: bytes, pos: int) -> tuple[int, int]:
|
||||
result, shift = 0, 0
|
||||
while pos < len(data):
|
||||
b = data[pos]; pos += 1
|
||||
result |= (b & 0x7F) << shift
|
||||
if not (b & 0x80):
|
||||
return result, pos
|
||||
shift += 7
|
||||
raise ValueError("truncated varint")
|
||||
|
||||
|
||||
def decode_protobuf(
|
||||
data: bytes,
|
||||
field_names: dict[int, ProtoField] | None = None,
|
||||
indent: int = 0,
|
||||
) -> list[str]:
|
||||
"""Decode protobuf wire format into human-readable lines.
|
||||
|
||||
With *field_names* dict the output uses field names and units instead of
|
||||
generic "field N: value" labels. Unknown field numbers fall back to the
|
||||
generic form. Works without any external protobuf library.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
pad = " " * indent
|
||||
pos = 0
|
||||
while pos < len(data):
|
||||
try:
|
||||
tag, pos = _pb_read_varint(data, pos)
|
||||
except (ValueError, IndexError):
|
||||
lines.append(f"{pad}[truncated at byte {pos}]")
|
||||
break
|
||||
field_num = tag >> 3
|
||||
wire_type = tag & 0x07
|
||||
pf = field_names.get(field_num) if field_names else None
|
||||
label = pf.name if pf else f"field {field_num:3d}"
|
||||
unit = f" {pf.unit}" if pf and pf.unit else ""
|
||||
try:
|
||||
if wire_type == 0: # varint
|
||||
val, pos = _pb_read_varint(data, pos)
|
||||
if pf and pf.signed and val >= (1 << 31):
|
||||
# proto int32 negative: sign-extended to 64-bit varint
|
||||
val = val - (1 << 64) if val >= (1 << 63) else val - (1 << 32)
|
||||
lines.append(f"{pad}{label}: {val}{unit}")
|
||||
elif wire_type == 1: # 64-bit fixed
|
||||
raw = data[pos:pos + 8]; pos += 8
|
||||
lines.append(f"{pad}{label}: 0x{raw.hex()} (64-bit)")
|
||||
elif wire_type == 2: # length-delimited
|
||||
length, pos = _pb_read_varint(data, pos)
|
||||
chunk = data[pos:pos + length]; pos += length
|
||||
try:
|
||||
nested = decode_protobuf(chunk, indent=indent + 1)
|
||||
if nested:
|
||||
lines.append(f"{pad}{label}: {{")
|
||||
lines.extend(nested)
|
||||
lines.append(f"{pad}}}")
|
||||
else:
|
||||
raise ValueError("empty")
|
||||
except Exception:
|
||||
try:
|
||||
lines.append(f"{pad}{label}: {chunk.decode()!r}")
|
||||
except UnicodeDecodeError:
|
||||
lines.append(f"{pad}{label}: 0x{chunk.hex()}")
|
||||
elif wire_type == 5: # 32-bit fixed (float)
|
||||
raw = data[pos:pos + 4]; pos += 4
|
||||
fval = struct.unpack("<f", raw)[0]
|
||||
lines.append(f"{pad}{label}: {fval:.3f}{unit}")
|
||||
else:
|
||||
lines.append(f"{pad}{label}: unknown wire_type={wire_type}")
|
||||
break
|
||||
except (ValueError, IndexError, struct.error) as exc:
|
||||
lines.append(f"{pad}{label}: parse error ({exc})")
|
||||
break
|
||||
return lines
|
||||
|
||||
|
||||
# ── River 2 — struct field descriptor ─────────────────────────────────────────
|
||||
|
||||
|
||||
class FieldDef(NamedTuple):
|
||||
name: str
|
||||
fmt: str # struct format char(s)
|
||||
unit: str = ""
|
||||
scale: float = 1.0
|
||||
skip: bool = False
|
||||
|
||||
|
||||
# ── River 3 — DisplayPropertyUpload field map ──────────────────────────────────
|
||||
# Source: standard_protocol_mr521.proto (package mr521)
|
||||
# Routing: src=0x02, cmd_set=0xFE, cmd_id=0x15
|
||||
|
||||
_DISPLAY_PROPERTY_UPLOAD: dict[int, ProtoField] = {
|
||||
# ── status / error ──
|
||||
1: ProtoField("errcode"),
|
||||
2: ProtoField("sys_status"),
|
||||
6: ProtoField("energy_backup_state"),
|
||||
130: ProtoField("dev_online_flag"),
|
||||
212: ProtoField("dev_sleep_state"),
|
||||
213: ProtoField("pd_err_code"),
|
||||
214: ProtoField("llc_err_code"),
|
||||
215: ProtoField("mppt_err_code"),
|
||||
232: ProtoField("llc_inv_err_code"),
|
||||
450: ProtoField("inv_err_code"),
|
||||
140: ProtoField("bms_err_code"),
|
||||
# ── total power flows ──
|
||||
3: ProtoField("pow_in_sum_w", "W"),
|
||||
4: ProtoField("pow_out_sum_w", "W"),
|
||||
# ── AC ──
|
||||
54: ProtoField("pow_get_ac_in", "W"),
|
||||
368: ProtoField("pow_get_ac_out", "W"),
|
||||
367: ProtoField("flow_info_ac_out"),
|
||||
298: ProtoField("ac_out_open"),
|
||||
211: ProtoField("ac_out_freq", "Hz"),
|
||||
209: ProtoField("plug_in_info_ac_in_chg_pow_max", "W"),
|
||||
458: ProtoField("plug_in_info_ac_in_chg_hal_pow_max", "W"),
|
||||
202: ProtoField("plug_in_info_ac_charger_flag"),
|
||||
# ── DC / solar input ──
|
||||
361: ProtoField("pow_get_pv", "W"),
|
||||
360: ProtoField("flow_info_pv"),
|
||||
364: ProtoField("plug_in_info_pv_charger_flag"),
|
||||
356: ProtoField("plug_in_info_pv_dc_amp_max", "A"),
|
||||
365: ProtoField("plug_in_info_pv_chg_amp_max", "A"),
|
||||
366: ProtoField("plug_in_info_pv_chg_vol_max", "V"),
|
||||
148: ProtoField("pv_chg_type"),
|
||||
# ── DC 12 V output ──
|
||||
37: ProtoField("pow_get_12v", "W"),
|
||||
33: ProtoField("flow_info_12v"),
|
||||
75: ProtoField("btn_dc_12v_out_switch"),
|
||||
74: ProtoField("dc_out_open"),
|
||||
# ── USB outputs ──
|
||||
9: ProtoField("pow_get_qcusb1", "W"),
|
||||
10: ProtoField("pow_get_qcusb2", "W"),
|
||||
11: ProtoField("pow_get_typec1", "W"),
|
||||
12: ProtoField("pow_get_typec2", "W"),
|
||||
76: ProtoField("btn_usb_switch"),
|
||||
# ── battery (BMS composite / CMS) ──
|
||||
158: ProtoField("pow_get_bms", "W"), # +chg / -dsg
|
||||
262: ProtoField("cms_batt_soc", "%"),
|
||||
263: ProtoField("cms_batt_soh", "%"),
|
||||
102: ProtoField("cms_batt_temp", "°C", signed=True),
|
||||
268: ProtoField("cms_dsg_rem_time", "min"),
|
||||
269: ProtoField("cms_chg_rem_time", "min"),
|
||||
270: ProtoField("cms_max_chg_soc", "%"),
|
||||
271: ProtoField("cms_min_dsg_soc", "%"),
|
||||
275: ProtoField("cms_bms_run_state"),
|
||||
282: ProtoField("cms_chg_dsg_state"),
|
||||
287: ProtoField("cms_batt_full_cap", "mAh"),
|
||||
288: ProtoField("cms_batt_design_cap", "mAh"),
|
||||
289: ProtoField("cms_batt_remain_cap", "mAh"),
|
||||
459: ProtoField("cms_batt_pow_out_max", "W"),
|
||||
460: ProtoField("cms_batt_pow_in_max", "W"),
|
||||
462: ProtoField("cms_batt_full_energy", "Wh"),
|
||||
# ── BMS cell-level ──
|
||||
242: ProtoField("bms_batt_soc", "%"),
|
||||
243: ProtoField("bms_batt_soh", "%"),
|
||||
248: ProtoField("bms_design_cap", "mAh"),
|
||||
254: ProtoField("bms_dsg_rem_time", "min"),
|
||||
255: ProtoField("bms_chg_rem_time", "min"),
|
||||
258: ProtoField("bms_min_cell_temp", "°C", signed=True),
|
||||
259: ProtoField("bms_max_cell_temp", "°C", signed=True),
|
||||
260: ProtoField("bms_min_mos_temp", "°C", signed=True),
|
||||
261: ProtoField("bms_max_mos_temp", "°C", signed=True),
|
||||
281: ProtoField("bms_chg_dsg_state"),
|
||||
# ── energy backup ──
|
||||
7: ProtoField("energy_backup_en"),
|
||||
8: ProtoField("energy_backup_start_soc", "%"),
|
||||
# ── standby / display config ──
|
||||
5: ProtoField("lcd_light"),
|
||||
17: ProtoField("dev_standby_time", "s"),
|
||||
18: ProtoField("screen_off_time", "s"),
|
||||
19: ProtoField("ac_standby_time", "s"),
|
||||
20: ProtoField("dc_standby_time", "s"),
|
||||
290: ProtoField("ble_standby_time", "s"),
|
||||
# ── misc ──
|
||||
25: ProtoField("xboost_en"),
|
||||
30: ProtoField("pcs_fan_level"),
|
||||
195: ProtoField("en_beep"),
|
||||
358: ProtoField("low_power_alarm", "%"),
|
||||
355: ProtoField("ups_alram"),
|
||||
}
|
||||
|
||||
# ── River 3 — RuntimePropertyUpload field map ──────────────────────────────────
|
||||
# Source: standard_protocol_mr521.proto (package mr521)
|
||||
# Note: cmd_id for RuntimePropertyUpload is not confirmed in ha-ef-ble river3.py;
|
||||
# packets will be decoded if the device sends them on any cmd_id with these
|
||||
# field numbers. Add the correct (src, cmd_set, cmd_id) key when known.
|
||||
|
||||
_RUNTIME_PROPERTY_UPLOAD: dict[int, ProtoField] = {
|
||||
# ── temperatures ──
|
||||
26: ProtoField("temp_pcs_dc", "°C"),
|
||||
27: ProtoField("temp_pcs_ac", "°C"),
|
||||
28: ProtoField("temp_pv_h", "°C"),
|
||||
29: ProtoField("temp_pv_l", "°C"),
|
||||
379: ProtoField("temp_pv", "°C"),
|
||||
# ── AC voltages / currents ──
|
||||
67: ProtoField("plug_in_info_ac_out_vol", "V"),
|
||||
68: ProtoField("plug_in_info_ac_in_vol", "V"),
|
||||
223: ProtoField("plug_in_info_ac_in_amp", "A"),
|
||||
224: ProtoField("plug_in_info_ac_out_amp", "A"),
|
||||
# ── solar / DC input ──
|
||||
380: ProtoField("plug_in_info_pv_vol", "V"),
|
||||
381: ProtoField("plug_in_info_pv_amp", "A"),
|
||||
41: ProtoField("plug_in_info_pv_h_vol", "V"),
|
||||
221: ProtoField("plug_in_info_pv_h_amp", "A"),
|
||||
44: ProtoField("plug_in_info_pv_l_vol", "V"),
|
||||
222: ProtoField("plug_in_info_pv_l_amp", "A"),
|
||||
# ── 12 V output ──
|
||||
382: ProtoField("plug_in_info_12v_vol", "V"),
|
||||
383: ProtoField("plug_in_info_12v_amp", "A"),
|
||||
# ── BMS battery voltage / current ──
|
||||
244: ProtoField("bms_batt_vol", "V"),
|
||||
245: ProtoField("bms_batt_amp", "A"),
|
||||
264: ProtoField("cms_batt_vol", "V"),
|
||||
265: ProtoField("cms_batt_amp", "A"),
|
||||
# ── LLC / bus internals ──
|
||||
384: ProtoField("llc_bat_vol", "V"),
|
||||
385: ProtoField("llc_bat_cur", "A"),
|
||||
386: ProtoField("llc_bus_vol", "V"),
|
||||
# ── firmware versions ──
|
||||
176: ProtoField("pd_firm_ver"),
|
||||
177: ProtoField("iot_firm_ver"),
|
||||
178: ProtoField("mppt_firm_ver"),
|
||||
179: ProtoField("llc_firm_ver"),
|
||||
241: ProtoField("bms_firm_ver"),
|
||||
}
|
||||
|
||||
|
||||
# ── Message registry ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# Struct-based (River 2): value is ("label", [FieldDef, ...], "struct")
|
||||
# Protobuf-based (River 3): value is ("label", {field_num: ProtoField}, "proto")
|
||||
|
||||
_REGISTRY: dict[tuple[int, int, int], tuple[str, list | dict, str]] = {
|
||||
# ── River 3: src / cmd_set / cmd_id ────────────────────────────────────────
|
||||
(0x02, 0xFE, 0x15): ("DisplayPropertyUpload", _DISPLAY_PROPERTY_UPLOAD, "proto"),
|
||||
(0x02, 0xFE, 0x16): ("RuntimePropertyUpload", _RUNTIME_PROPERTY_UPLOAD, "proto"),
|
||||
}
|
||||
|
||||
|
||||
# ── Struct decoder ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _decode_struct(label: str, fields: list[FieldDef], data: bytes) -> list[str]:
|
||||
lines: list[str] = [f"[{label}]"]
|
||||
active = list(fields)
|
||||
while active:
|
||||
fmt = "<" + "".join(f.fmt for f in active)
|
||||
if struct.calcsize(fmt) <= len(data):
|
||||
break
|
||||
active.pop()
|
||||
if not active:
|
||||
lines.append(f" <payload too short: {len(data)} bytes>")
|
||||
return lines
|
||||
fmt = "<" + "".join(f.fmt for f in active)
|
||||
try:
|
||||
values = struct.unpack_from(fmt, data)
|
||||
except struct.error as exc:
|
||||
lines.append(f" <struct error: {exc}>")
|
||||
return lines
|
||||
for field, raw_val in zip(active, values):
|
||||
if field.skip:
|
||||
continue
|
||||
if isinstance(raw_val, bytes):
|
||||
display = raw_val.hex()
|
||||
elif isinstance(raw_val, float):
|
||||
display = f"{raw_val * field.scale:.3f}"
|
||||
else:
|
||||
val = int(raw_val) * field.scale
|
||||
display = f"{int(val)}" if val == int(val) else f"{val:.3f}"
|
||||
unit_str = f" {field.unit}" if field.unit else ""
|
||||
lines.append(f" {field.name}: {display}{unit_str}")
|
||||
tail = len(data) - struct.calcsize(fmt)
|
||||
if tail > 0:
|
||||
lines.append(f" <{tail} trailing bytes: {data[-tail:].hex()}>")
|
||||
return lines
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
|
||||
def decode_payload(
|
||||
src: int,
|
||||
cmd_set: int,
|
||||
cmd_id: int,
|
||||
payload: bytes,
|
||||
) -> list[str] | None:
|
||||
"""Decode a payload by (src, cmd_set, cmd_id).
|
||||
|
||||
Returns annotated lines, or None if no definition is registered.
|
||||
Callers should fall back to the generic protobuf/hex decoder on None.
|
||||
"""
|
||||
if not payload:
|
||||
return None
|
||||
entry = _message_definition(src, cmd_set, cmd_id)
|
||||
if entry is None:
|
||||
return None
|
||||
label, fields, kind = entry
|
||||
if kind == "struct":
|
||||
return _decode_struct(label, fields, payload) # type: ignore[arg-type]
|
||||
# proto
|
||||
lines = [f"[{label}]"]
|
||||
lines += [" " + ln for ln in decode_protobuf(payload, field_names=fields)] # type: ignore[arg-type]
|
||||
return lines
|
||||
|
||||
|
||||
def describe_message(src: int, cmd_set: int, cmd_id: int) -> str | None:
|
||||
entry = _message_definition(src, cmd_set, cmd_id)
|
||||
return entry[0] if entry else None
|
||||
|
||||
|
||||
# Load model definitions only when needed to avoid circular imports.
|
||||
def _message_definition(src: int, cmd_set: int, cmd_id: int):
|
||||
from .ecoflow_river2 import MESSAGE_DEFINITIONS
|
||||
|
||||
key = (src, cmd_set, cmd_id)
|
||||
return MESSAGE_DEFINITIONS.get(key) or _REGISTRY.get(key)
|
||||
|
||||
|
||||
def decode_river2_payload(src: int, cmd_set: int, cmd_id: int, payload: bytes):
|
||||
from .ecoflow_river2 import decode_river2_payload as decode
|
||||
|
||||
return decode(src, cmd_set, cmd_id, payload)
|
||||
|
||||
|
||||
format_payload = decode_payload
|
||||
format_protobuf = decode_protobuf
|
||||
@@ -0,0 +1,367 @@
|
||||
"""River 2 binary layouts, numeric decoding, and measurement interpretation.
|
||||
|
||||
Protocol sources: https://github.com/rabits/ef-ble-reverse and
|
||||
https://github.com/rabits/ha-ef-ble. No Bluetooth I/O.
|
||||
"""
|
||||
|
||||
import math
|
||||
import struct
|
||||
|
||||
from bmspy.classes import BMSField, BMSInfoField, BMSMultiField, BMSScalarField
|
||||
from bmspy.ecoflow_proto import FieldDef
|
||||
from bmspy.ecoflow_snapshot import EcoFlowSnapshot
|
||||
|
||||
SERIAL_PREFIXES = ("R601", "R603")
|
||||
|
||||
|
||||
# ── River 2 — struct layouts ───────────────────────────────────────────────────
|
||||
|
||||
_PD_FIELDS: list[FieldDef] = [
|
||||
FieldDef("model", "B"),
|
||||
FieldDef("err_code", "4s", skip=True),
|
||||
FieldDef("sys_ver", "4s", skip=True),
|
||||
FieldDef("wifi_ver", "4s", skip=True),
|
||||
FieldDef("wifi_auto_rcvy", "B"),
|
||||
FieldDef("soc", "B", "%"),
|
||||
FieldDef("watts_out_sum", "H", "W"),
|
||||
FieldDef("watts_in_sum", "H", "W"),
|
||||
FieldDef("remain_time", "i", "min"),
|
||||
FieldDef("beep_mode", "B"),
|
||||
FieldDef("dc_out_state", "B"),
|
||||
FieldDef("usb1_watts", "B", "W"),
|
||||
FieldDef("usb2_watts", "B", "W"),
|
||||
FieldDef("qc_usb1_watts", "B", "W"),
|
||||
FieldDef("qc_usb2_watts", "B", "W"),
|
||||
FieldDef("typec1_watts", "B", "W"),
|
||||
FieldDef("typec2_watts", "B", "W"),
|
||||
FieldDef("typec1_temp", "B", "°C"),
|
||||
FieldDef("typec2_temp", "B", "°C"),
|
||||
FieldDef("car_state", "B"),
|
||||
FieldDef("car_watts", "B", "W"),
|
||||
FieldDef("car_temp", "B", "°C"),
|
||||
FieldDef("standby_min", "H", "min"),
|
||||
FieldDef("lcd_off_sec", "H", "s"),
|
||||
FieldDef("lcd_brightness", "B"),
|
||||
FieldDef("chg_power_dc", "I", "Wh"),
|
||||
FieldDef("chg_sun_power", "I", "Wh"),
|
||||
FieldDef("chg_power_ac", "I", "Wh"),
|
||||
FieldDef("dsg_power_dc", "I", "Wh"),
|
||||
FieldDef("dsg_power_ac", "I", "Wh"),
|
||||
FieldDef("usb_used_time", "I", "s"),
|
||||
FieldDef("usbqc_used_time", "I", "s"),
|
||||
FieldDef("typec_used_time", "I", "s"),
|
||||
FieldDef("car_used_time", "I", "s"),
|
||||
FieldDef("inv_used_time", "I", "s"),
|
||||
FieldDef("dc_in_used_time", "I", "s"),
|
||||
FieldDef("mppt_used_time", "I", "s"),
|
||||
FieldDef("reserved", "2s", skip=True),
|
||||
FieldDef("ext_rj45_port", "B"),
|
||||
FieldDef("ext_3p8_port", "B"),
|
||||
FieldDef("ext_4p8_port", "B"),
|
||||
FieldDef("sys_chg_dsg_state", "B"),
|
||||
FieldDef("wifi_rssi", "B"),
|
||||
FieldDef("wireless_watts", "B", "W"),
|
||||
FieldDef("screen_state", "14s", skip=True),
|
||||
FieldDef("ac_auto_config", "B"),
|
||||
FieldDef("min_ac_out_soc", "B", "%"),
|
||||
FieldDef("ac_out_pause", "B"),
|
||||
FieldDef("watth_is_config", "B"),
|
||||
FieldDef("backup_soc", "B", "%"),
|
||||
FieldDef("hysteresis_add", "B"),
|
||||
FieldDef("relay_switch_count", "I"),
|
||||
]
|
||||
|
||||
# R601 captures report pack voltage/current in whole V/A, unlike the
|
||||
# cell voltages and current limits below, which remain in mV/mA.
|
||||
# Negative pack current is a signed 32-bit value (discharging).
|
||||
_EMS_FIELDS: list[FieldDef] = [
|
||||
FieldDef("chg_state", "B"),
|
||||
FieldDef("chg_cmd", "B"),
|
||||
FieldDef("dsg_cmd", "B"),
|
||||
FieldDef("chg_vol", "I", "V"),
|
||||
FieldDef("chg_amp", "i", "A"),
|
||||
FieldDef("fan_level", "B"),
|
||||
FieldDef("max_charge_soc", "B", "%"),
|
||||
FieldDef("bms_model", "B"),
|
||||
FieldDef("lcd_show_soc", "B", "%"),
|
||||
FieldDef("open_ups_flag", "B"),
|
||||
FieldDef("bms_warning_state", "B"),
|
||||
FieldDef("chg_remain_time", "I", "min"),
|
||||
FieldDef("dsg_remain_time", "I", "min"),
|
||||
FieldDef("ems_is_normal_flag", "B"),
|
||||
FieldDef("f32_lcd_show_soc", "f", "%"),
|
||||
FieldDef("bms_is_connt", "3s", skip=True),
|
||||
FieldDef("max_available_num", "B"),
|
||||
FieldDef("open_bms_idx", "B"),
|
||||
FieldDef("para_vol_min", "I", "mV"),
|
||||
FieldDef("para_vol_max", "I", "mV"),
|
||||
FieldDef("min_dsg_soc", "B", "%"),
|
||||
FieldDef("open_oil_eb_soc", "B", "%"),
|
||||
FieldDef("close_oil_eb_soc", "B", "%"),
|
||||
]
|
||||
|
||||
_BMS_FIELDS: list[FieldDef] = [
|
||||
FieldDef("num", "B"),
|
||||
FieldDef("type", "B"),
|
||||
FieldDef("cell_id", "B"),
|
||||
FieldDef("err_code", "I"),
|
||||
FieldDef("sys_ver", "I"),
|
||||
FieldDef("soc", "B", "%"),
|
||||
FieldDef("vol", "I", "V"),
|
||||
FieldDef("amp", "i", "A"),
|
||||
FieldDef("temp", "B", "°C"),
|
||||
FieldDef("open_bms_idx", "B"),
|
||||
FieldDef("design_cap", "I", "mAh"),
|
||||
FieldDef("remain_cap", "I", "mAh"),
|
||||
FieldDef("full_cap", "I", "mAh"),
|
||||
FieldDef("cycles", "I"),
|
||||
FieldDef("soh", "B", "%"),
|
||||
FieldDef("max_cell_vol", "H", "mV"),
|
||||
FieldDef("min_cell_vol", "H", "mV"),
|
||||
FieldDef("max_cell_temp", "B", "°C"),
|
||||
FieldDef("min_cell_temp", "B", "°C"),
|
||||
FieldDef("max_mos_temp", "B", "°C"),
|
||||
FieldDef("min_mos_temp", "B", "°C"),
|
||||
FieldDef("bms_fault", "B"),
|
||||
FieldDef("bq_sys_stat_reg","B"),
|
||||
FieldDef("tag_chg_amp", "I", "mA"),
|
||||
FieldDef("f32_show_soc", "f", "%"),
|
||||
FieldDef("input_watts", "I", "W"),
|
||||
FieldDef("output_watts", "I", "W"),
|
||||
FieldDef("remain_time", "I", "min"),
|
||||
]
|
||||
|
||||
_INV_RIVER_FIELDS: list[FieldDef] = [
|
||||
FieldDef("err_code", "I"),
|
||||
FieldDef("sys_ver", "I"),
|
||||
FieldDef("charger_type", "B"),
|
||||
FieldDef("input_watts", "H", "W"),
|
||||
FieldDef("output_watts", "H", "W"),
|
||||
FieldDef("inv_type", "B"),
|
||||
FieldDef("inv_out_vol", "I", "mV"),
|
||||
FieldDef("inv_out_amp", "I", "mA"),
|
||||
FieldDef("inv_out_freq", "B", "Hz"),
|
||||
FieldDef("inv_in_vol", "I", "mV"),
|
||||
FieldDef("inv_in_amp", "I", "mA"),
|
||||
FieldDef("inv_in_freq", "B", "Hz"),
|
||||
FieldDef("out_temp", "H", "°C"),
|
||||
FieldDef("dc_in_vol", "I", "mV"),
|
||||
FieldDef("dc_in_amp", "I", "mA"),
|
||||
FieldDef("in_temp", "H", "°C"),
|
||||
FieldDef("fan_state", "B"),
|
||||
FieldDef("cfg_ac_enabled", "B"),
|
||||
FieldDef("cfg_ac_xboost", "B"),
|
||||
# These INV configuration values are not validated physical units.
|
||||
# In particular, R601 reports cfg_ac_out_freq=2 with a measured 50 Hz.
|
||||
FieldDef("cfg_ac_out_voltage", "I"),
|
||||
FieldDef("cfg_ac_out_freq", "B"),
|
||||
FieldDef("cfg_ac_chg_mode_flg","B"),
|
||||
FieldDef("cfg_standby_min", "H", "min"),
|
||||
FieldDef("cfg_fan_mode", "B"),
|
||||
FieldDef("ac_auto_config", "B"),
|
||||
]
|
||||
|
||||
_MPPT_MR330_FIELDS: list[FieldDef] = [
|
||||
FieldDef("fault_code", "I"),
|
||||
FieldDef("sw_ver", "4s", skip=True),
|
||||
FieldDef("in_vol", "I", "mV"),
|
||||
FieldDef("in_amp", "I", "mA"),
|
||||
FieldDef("in_watts", "H", "W"),
|
||||
FieldDef("out_vol", "I", "mV"),
|
||||
FieldDef("out_amp", "I", "mA"),
|
||||
FieldDef("out_watts", "H", "W"),
|
||||
FieldDef("mppt_temp", "h", "°C"),
|
||||
FieldDef("xt60_chg_type", "B"),
|
||||
FieldDef("cfg_chg_type", "B"),
|
||||
FieldDef("chg_type", "B"),
|
||||
FieldDef("chg_state", "B"),
|
||||
FieldDef("dcdc_12v_vol", "I", "mV"),
|
||||
FieldDef("dcdc_12v_amp", "I", "mA"),
|
||||
FieldDef("dcdc_12v_watts", "H", "W"),
|
||||
FieldDef("car_out_vol", "I", "mV"),
|
||||
FieldDef("car_out_amp", "I", "mA"),
|
||||
FieldDef("car_out_watts", "H", "W"),
|
||||
FieldDef("car_temp", "h", "°C"),
|
||||
FieldDef("car_state", "B"),
|
||||
FieldDef("dc24v_temp", "h", "°C"),
|
||||
FieldDef("dc24v_state", "B"),
|
||||
FieldDef("chg_pause_flag", "B"),
|
||||
FieldDef("cfg_dc_chg_current", "I", "mA"),
|
||||
FieldDef("beep_state", "B"),
|
||||
FieldDef("cfg_ac_enabled", "B"),
|
||||
FieldDef("cfg_ac_xboost", "B"),
|
||||
FieldDef("cfg_ac_out_voltage", "I", "V"),
|
||||
FieldDef("cfg_ac_out_freq", "B", "Hz"),
|
||||
FieldDef("cfg_chg_watts", "H", "W"),
|
||||
FieldDef("ac_standby_mins", "H", "min"),
|
||||
FieldDef("discharge_type", "B"),
|
||||
FieldDef("car_standby_mins", "H", "min"),
|
||||
FieldDef("power_standby_mins", "H", "min"),
|
||||
FieldDef("screen_standby_mins", "H", "min"),
|
||||
FieldDef("pay_flag", "H"),
|
||||
FieldDef("reserved", "8s", skip=True),
|
||||
]
|
||||
|
||||
|
||||
MESSAGE_DEFINITIONS = {
|
||||
(0x02, 0x20, 0x02): ("PD heartbeat", _PD_FIELDS, "struct"),
|
||||
(0x03, 0x20, 0x02): ("EMS heartbeat", _EMS_FIELDS, "struct"),
|
||||
(0x03, 0x20, 0x32): ("BMS heartbeat", _BMS_FIELDS, "struct"),
|
||||
(0x04, 0x20, 0x02): ("INV heartbeat", _INV_RIVER_FIELDS, "struct"),
|
||||
(0x05, 0x20, 0x02): ("MPPT heartbeat", _MPPT_MR330_FIELDS, "struct"),
|
||||
}
|
||||
|
||||
|
||||
_CORE_MINIMUM_LENGTHS = {
|
||||
(0x02, 0x20, 0x02): 23, # PD through remain_time
|
||||
(0x03, 0x20, 0x02): 25, # EMS through dsg_remain_time
|
||||
(0x03, 0x20, 0x32): 21, # BMS through temp
|
||||
}
|
||||
_PROTOCOL_NAMESPACES = {
|
||||
(0x02, 0x20, 0x02): "ef_river2_pd",
|
||||
(0x03, 0x20, 0x02): "ef_river2_ems",
|
||||
(0x03, 0x20, 0x32): "ef_river2_bms",
|
||||
(0x04, 0x20, 0x02): "ef_river2_inv",
|
||||
(0x05, 0x20, 0x02): "ef_river2_mppt",
|
||||
}
|
||||
|
||||
|
||||
def _record_heartbeat_fields(
|
||||
snapshot: EcoFlowSnapshot, src: int, cmd_set: int, cmd_id: int, payload: bytes,
|
||||
) -> None:
|
||||
"""Retain complete fields from every known heartbeat, including INV/MPPT."""
|
||||
key = (src, cmd_set, cmd_id)
|
||||
if key not in MESSAGE_DEFINITIONS or not payload:
|
||||
return
|
||||
label, definitions, _ = MESSAGE_DEFINITIONS[key]
|
||||
namespace = _PROTOCOL_NAMESPACES[key]
|
||||
minimum = _CORE_MINIMUM_LENGTHS.get(key, struct.calcsize("<" + definitions[0].fmt))
|
||||
if len(payload) < minimum:
|
||||
snapshot.record_protocol_error(namespace, f"{label}: payload too short ({len(payload)} < {minimum})")
|
||||
return
|
||||
fields: dict[str, BMSField] = {}
|
||||
offset = 0
|
||||
for definition in definitions:
|
||||
size = struct.calcsize("<" + definition.fmt)
|
||||
if offset + size > len(payload):
|
||||
break
|
||||
value = struct.unpack_from("<" + definition.fmt, payload, offset)[0]
|
||||
offset += size
|
||||
help = f"River 2 {label}: {definition.name} (reported protocol value)"
|
||||
if isinstance(value, bytes):
|
||||
fields[definition.name] = BMSInfoField(help=help + "; encoded bytes", info="0x" + value.hex())
|
||||
else:
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
snapshot.record_protocol_error(namespace, f"{label}.{definition.name}: non-finite value")
|
||||
return
|
||||
value = value * definition.scale if definition.scale != 1 else value
|
||||
fields[definition.name] = BMSScalarField(
|
||||
help=help, raw_value=value, value=str(value), units=definition.unit or None,
|
||||
)
|
||||
if offset < len(payload):
|
||||
fields["trailing_bytes"] = BMSInfoField(
|
||||
help=f"River 2 {label}: undecoded trailing bytes", info="0x" + payload[offset:].hex(),
|
||||
)
|
||||
snapshot.record_protocol_fields(namespace, fields)
|
||||
|
||||
|
||||
def decode_river2_payload(
|
||||
src: int,
|
||||
cmd_set: int,
|
||||
cmd_id: int,
|
||||
payload: bytes,
|
||||
) -> dict[str, int | float] | None:
|
||||
"""Return numeric River 2 fields without parsing the diagnostic text.
|
||||
|
||||
Firmware versions can omit trailing fields. Only complete fields are
|
||||
returned, and unknown trailing bytes are ignored. A short core heartbeat
|
||||
is rejected rather than treated as a partial measurement.
|
||||
"""
|
||||
key = (src, cmd_set, cmd_id)
|
||||
minimum = _CORE_MINIMUM_LENGTHS.get(key)
|
||||
if minimum is None or len(payload) < minimum:
|
||||
return None
|
||||
_, fields, _ = MESSAGE_DEFINITIONS[key]
|
||||
values: dict[str, int | float] = {}
|
||||
offset = 0
|
||||
for field in fields:
|
||||
size = struct.calcsize("<" + field.fmt)
|
||||
if offset + size > len(payload):
|
||||
break
|
||||
value = struct.unpack_from("<" + field.fmt, payload, offset)[0]
|
||||
offset += size
|
||||
if not field.skip and isinstance(value, (int, float)):
|
||||
values[field.name] = value * field.scale if field.scale != 1 else value
|
||||
return values
|
||||
|
||||
|
||||
class River2Decoder:
|
||||
"""Apply model measurements while retaining per-connection source priorities."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset source priorities before a new connection, not the snapshot."""
|
||||
self._soc_priority = 0
|
||||
|
||||
def update(
|
||||
self, snapshot: EcoFlowSnapshot, src: int, cmd_set: int, cmd_id: int, payload: bytes,
|
||||
) -> None:
|
||||
_record_heartbeat_fields(snapshot, src, cmd_set, cmd_id, payload)
|
||||
values = decode_river2_payload(src, cmd_set, cmd_id, payload)
|
||||
if values is None:
|
||||
return
|
||||
|
||||
def scalar(name: str, help: str, value: int | float | bool, units: str | None = None):
|
||||
setattr(snapshot, name, BMSScalarField(
|
||||
help=help, raw_value=value, value=str(value), units=units,
|
||||
))
|
||||
|
||||
def charge_ratio(soc: int | float, priority: int) -> None:
|
||||
# Prefer BMS float > EMS float > integer for this connection.
|
||||
# Lower-precision heartbeats must not overwrite a precise value
|
||||
# just because their packets arrive later. Same-source updates
|
||||
# still replace the previous reading as charge changes.
|
||||
if math.isfinite(soc) and 0 <= soc <= 100 and priority >= self._soc_priority:
|
||||
scalar("bms_capacity_charge_ratio", "Battery Charge", soc / 100)
|
||||
self._soc_priority = priority
|
||||
|
||||
def remaining(name: str, help: str, value: int | float):
|
||||
# 5999 (99h59m) appears for inactive estimates in R601 captures.
|
||||
# Treat it as unavailable/capped, not an exact duration. Raw
|
||||
# packet diagnostics retain the value for further investigation.
|
||||
if value < 0 or value in (5999, 0xFFFFFFFF):
|
||||
setattr(snapshot, name, None)
|
||||
else:
|
||||
scalar(name, help, value, "min")
|
||||
|
||||
if src == 0x02: # Total device power, not just battery power.
|
||||
charge_ratio(values["soc"], priority=1)
|
||||
scalar("ef_input_power_watts", "Total Input Power", values["watts_in_sum"], "W")
|
||||
scalar("ef_output_power_watts", "Total Output Power", values["watts_out_sum"], "W")
|
||||
remaining("ef_remaining_time_minutes", "Remaining Time", values["remain_time"])
|
||||
elif cmd_id == 0x02: # EMS has separate charge/discharge estimates.
|
||||
if "f32_lcd_show_soc" in values:
|
||||
charge_ratio(values["f32_lcd_show_soc"], priority=2)
|
||||
scalar("ef_charge_state", "Charge State (device code)", values["chg_state"])
|
||||
remaining("ef_charge_remaining_time_minutes", "Time to Charge", values["chg_remain_time"])
|
||||
remaining("ef_discharge_remaining_time_minutes", "Time to Discharge", values["dsg_remain_time"])
|
||||
else: # BMS battery temperature and battery-side power flow.
|
||||
if "f32_show_soc" in values:
|
||||
charge_ratio(values["f32_show_soc"], priority=3)
|
||||
else:
|
||||
charge_ratio(values["soc"], priority=1)
|
||||
temp = values["temp"]
|
||||
snapshot.bms_temperature_celcius = BMSMultiField(
|
||||
help="Battery Temperature", label="sensor",
|
||||
raw_values={1: temp}, values={1: str(temp)}, units="°C",
|
||||
)
|
||||
# Do not infer battery charging from AC input: bypass can power
|
||||
# a load without charging the battery.
|
||||
for field_name, power_name, help in (
|
||||
("bms_charge_is_charging", "input_watts", "Battery Charging"),
|
||||
("bms_charge_is_discharging", "output_watts", "Battery Discharging"),
|
||||
):
|
||||
if power_name in values:
|
||||
scalar(field_name, help, values[power_name] > 0)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""EcoFlow measurements and diagnostic packets, independent of Bluetooth I/O."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field, fields as dataclass_fields
|
||||
|
||||
from bmspy.classes import BMSField, BMSInfoField, BMSMultiField, BMSScalarField, UPS
|
||||
|
||||
|
||||
@dataclass
|
||||
class EcoFlowSnapshot(UPS):
|
||||
"""Data container for a single EcoFlow device telemetry snapshot.
|
||||
|
||||
Named scalar/info fields appear as standard BMSFields. Raw packet
|
||||
payloads received from the device are stored in *_packets* and are
|
||||
also exposed via *items()*, keyed as ``ef_pkt_<src>_<cmdset>_<cmdid>``.
|
||||
Decoded protocol fields retain the latest reported value independently,
|
||||
so partial reports do not erase fields received earlier in the collection.
|
||||
"""
|
||||
|
||||
ef_serial_number: BMSInfoField | None = None
|
||||
ef_bt_address: BMSInfoField | None = None
|
||||
ef_connection_state: BMSInfoField | None = None
|
||||
ef_packets_received: BMSScalarField | None = None
|
||||
|
||||
bms_capacity_charge_ratio: BMSScalarField | None = None
|
||||
bms_temperature_celcius: BMSMultiField | None = None
|
||||
bms_charge_is_charging: BMSScalarField | None = None
|
||||
bms_charge_is_discharging: BMSScalarField | None = None
|
||||
ef_input_power_watts: BMSScalarField | None = None
|
||||
ef_output_power_watts: BMSScalarField | None = None
|
||||
ef_remaining_time_minutes: BMSScalarField | None = None
|
||||
ef_charge_remaining_time_minutes: BMSScalarField | None = None
|
||||
ef_discharge_remaining_time_minutes: BMSScalarField | None = None
|
||||
ef_charge_state: BMSScalarField | None = None
|
||||
|
||||
_packets: dict[str, BMSInfoField] = field(
|
||||
default_factory=dict, init=False, repr=False
|
||||
)
|
||||
|
||||
_protocol_fields: dict[str, BMSField] = field(
|
||||
default_factory=dict, init=False, repr=False
|
||||
)
|
||||
|
||||
def record_protocol_fields(self, namespace: str, fields: dict[str, BMSField]) -> None:
|
||||
"""Replace reported fields, not the whole message or snapshot."""
|
||||
self._protocol_fields.update({f"{namespace}_{name}": value for name, value in fields.items()})
|
||||
self._protocol_fields.pop(f"{namespace}_decode_error", None)
|
||||
|
||||
def record_protocol_error(self, namespace: str, error: str) -> None:
|
||||
"""Retain the last valid fields and expose why this report was rejected."""
|
||||
self._protocol_fields[f"{namespace}_decode_error"] = BMSInfoField(
|
||||
help="Protocol report rejected; previous decoded fields retained", info=error,
|
||||
)
|
||||
|
||||
def record_packet(self, src: int, cmd_set: int, cmd_id: int, payload: bytes) -> None:
|
||||
"""Keep heartbeats from different modules separate, even with the same command."""
|
||||
key = f"ef_pkt_{src:02x}_{cmd_set:02x}_{cmd_id:02x}"
|
||||
self._packets[key] = BMSInfoField(
|
||||
help=f"Packet src=0x{src:02X} cmd_set=0x{cmd_set:02X} cmd_id=0x{cmd_id:02X}",
|
||||
info=payload.hex(),
|
||||
)
|
||||
|
||||
def items(self) -> Iterator[tuple[str, BMSField]]:
|
||||
for f in dataclass_fields(self):
|
||||
if f.name.startswith("_"):
|
||||
continue
|
||||
v = getattr(self, f.name)
|
||||
if v is not None:
|
||||
yield f.name, v
|
||||
yield from self._protocol_fields.items()
|
||||
yield from self._packets.items()
|
||||
+6
-3
@@ -13,10 +13,12 @@ DAEMON_UPDATE_PERIOD = 30
|
||||
|
||||
|
||||
def prometheus_create_metric(
|
||||
registry: CollectorRegistry, ups_data: dict[str, UPS]
|
||||
registry: CollectorRegistry, ups_data: dict[str, UPS],
|
||||
metric: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create one Gauge per scalar/multi field and one Info per info field; skip duplicates."""
|
||||
metric: dict[str, Any] = {}
|
||||
"""Add metrics for new fields, preserving metrics from earlier snapshots."""
|
||||
if metric is None:
|
||||
metric = {}
|
||||
for ups_name, ups_obj in ups_data.items():
|
||||
for name, field in ups_obj.items():
|
||||
if name in metric:
|
||||
@@ -80,6 +82,7 @@ def prometheus_export(
|
||||
ups_data = {}
|
||||
while not ups_data:
|
||||
ups_data = client.read_data(socket_path, "prometheus", ups=ups, debug=debug)
|
||||
prometheus_create_metric(registry, ups_data, metric)
|
||||
prometheus_populate_metric(metric, ups_data)
|
||||
prometheus_client.generate_latest(registry)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user