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:
@@ -1,2 +1,8 @@
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
|
||||
# Optional third-party Type 7 lookup table. Obtain it separately (see README).
|
||||
/bmspy/ecoflow_keydata.py
|
||||
|
||||
@@ -53,3 +53,65 @@ To push data for a single UPS only:
|
||||
poetry run bmspy-influxdb --ups network --url ...
|
||||
|
||||
InfluxDB connection details can also be supplied via environment variables (`INFLUXDB_V2_URL`, `INFLUXDB_V2_ORG`, `INFLUXDB_V2_TOKEN`) instead of command-line flags.
|
||||
|
||||
## EcoFlow Bluetooth LE devices
|
||||
|
||||
Install the Bluetooth dependencies:
|
||||
|
||||
```bash
|
||||
poetry install -E ecoflow-bt
|
||||
```
|
||||
|
||||
Close the EcoFlow app and stop other Bluetooth collectors before connecting.
|
||||
|
||||
### Setup
|
||||
|
||||
Get your EcoFlow account user ID:
|
||||
|
||||
```bash
|
||||
poetry run bmspy-ecoflow-login you@example.com
|
||||
```
|
||||
|
||||
The command prompts for your password. Add `--region EU` or `--region US` if needed.
|
||||
Use the diagnostic scan below to find device addresses and serial numbers.
|
||||
|
||||
Type 7 authentication also needs an external lookup table from
|
||||
[ha-ef-ble](https://github.com/rabits/ha-ef-ble). From the bmspy repository root, download it:
|
||||
|
||||
```bash
|
||||
curl --fail --location --output bmspy/ecoflow_keydata.py \
|
||||
https://raw.githubusercontent.com/rabits/ha-ef-ble/main/custom_components/ef_ble/eflib/keydata.py
|
||||
```
|
||||
|
||||
This file is ignored by Git and excluded from packages. Type 1 devices do not need it.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Scan and collect reports from nearby supported devices:
|
||||
|
||||
```bash
|
||||
poetry run python -u tools/ecoflow-bt.py --user-id YOUR_USER_ID --scan --timeout 30
|
||||
```
|
||||
|
||||
Add `--watch` to print every packet until Ctrl+C. Watch mode ignores the telemetry
|
||||
timeout, but stops on a lost connection. Its payloads are already decrypted.
|
||||
Keep diagnostic output private because it can contain device and account identifiers.
|
||||
|
||||
The scan detects encryption automatically. To select one discovered device,
|
||||
add `--device ADDRESS`. Without scanning, replace `--scan` with
|
||||
`--device ADDRESS --serial-number SERIAL`. Direct connections default to Type 7.
|
||||
Session keys stay internal. The script has no encryption override or key-export option.
|
||||
|
||||
River 2 collection waits for three valid core reports, not optional inverter or
|
||||
solar reports. A timeout reports incomplete collection. Use `--watch` to receive
|
||||
later reports.
|
||||
|
||||
River 2 reports charge, temperature, power, and time estimates. Missing measurements
|
||||
stay absent. See the [River 2 measurement reference](docs/ecoflow-river2-fields.md)
|
||||
for units, decoding rules, and known limits.
|
||||
|
||||
Client snapshots include all complete River 2 heartbeat fields under
|
||||
`ef_river2_*`, alongside the shared measurements and raw packets.
|
||||
Partial reports retain earlier field values. See the
|
||||
[River 2 field reference](docs/ecoflow-river2-fields.md#reported-fields-available-to-clients)
|
||||
for names, units, and limits.
|
||||
|
||||
+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:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# River 2 measurements
|
||||
|
||||
River 2 models with serial prefixes `R601` and `R603` use packet version 2.
|
||||
Encryption is separate from the packet version. The tested River 2 uses Type 7
|
||||
encryption, not Type 1. River 3 uses a separate decoder.
|
||||
|
||||
`bmspy/ecoflow_river2.py` contains the binary layouts, numeric decoder, and
|
||||
measurement rules. `bmspy/ecoflow_bt.py` handles Bluetooth transport and
|
||||
resets the decoder's source priorities before each connection.
|
||||
`bmspy/ecoflow_snapshot.py` defines the shared EcoFlow snapshot.
|
||||
|
||||
## Normal snapshot
|
||||
|
||||
A snapshot contains the latest received measurements. Missing measurements stay
|
||||
absent. These field names are also used by River 3, but each model has its own
|
||||
rules for interpreting the device reports.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `ef_input_power_watts` | Total device input power, W. |
|
||||
| `ef_output_power_watts` | Total device output power, W. |
|
||||
| `bms_temperature_celcius` | Battery temperature at sensor 1, °C. The spelling preserves the existing field name. |
|
||||
| `bms_capacity_charge_ratio` | Battery charge as a ratio from 0 to 1. |
|
||||
| `ef_remaining_time_minutes` | General remaining-time estimate, minutes. |
|
||||
| `ef_charge_remaining_time_minutes` | Charging-time estimate from the energy management module, minutes. |
|
||||
| `ef_discharge_remaining_time_minutes` | Discharging-time estimate from the energy management module, minutes. |
|
||||
| `bms_charge_is_charging` | Whether battery-side power flows into the battery. |
|
||||
| `bms_charge_is_discharging` | Whether battery-side power flows out of the battery. |
|
||||
| `ef_charge_state` | Numeric device state code. Observed R601 values are 0 for idle, 1 for charging, and 2 for discharging. |
|
||||
|
||||
Input power alone does not indicate battery charging. The device can supply an
|
||||
AC load without charging its battery. Do not apply River 2 state-code meanings
|
||||
to River 3.
|
||||
|
||||
## Reported fields available to clients
|
||||
|
||||
Clients also receive every complete field from the five known heartbeat layouts.
|
||||
A heartbeat is a periodic report from a device module.
|
||||
The field names use these prefixes:
|
||||
|
||||
| Report | Prefix | Example |
|
||||
|---|---|---|
|
||||
| Device (PD) | `ef_river2_pd_` | `ef_river2_pd_usb1_watts` |
|
||||
| Energy management (EMS) | `ef_river2_ems_` | `ef_river2_ems_max_charge_soc` |
|
||||
| Battery management (BMS) | `ef_river2_bms_` | `ef_river2_bms_cycles` |
|
||||
| Inverter (INV) | `ef_river2_inv_` | `ef_river2_inv_inv_out_vol` |
|
||||
| Solar controller (MPPT) | `ef_river2_mppt_` | `ef_river2_mppt_in_watts` |
|
||||
|
||||
These fields 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.
|
||||
|
||||
Numeric fields use `BMSScalarField.raw_value` and the units from the layout.
|
||||
They retain reported state codes, zero values, and unavailable-time codes such as
|
||||
`5999`. They do not replace the normal measurements or their interpretation rules.
|
||||
For example, `ef_river2_pd_soc` uses percent, while `bms_capacity_charge_ratio`
|
||||
uses a ratio from 0 to 1.
|
||||
|
||||
Byte fields use `BMSInfoField.info` with a `0x` hexadecimal string.
|
||||
This includes version, reserved, and other byte fields omitted by the text decoder.
|
||||
Bytes beyond the last complete field use the same format under
|
||||
`<prefix>trailing_bytes`. Their meanings remain unknown.
|
||||
|
||||
Each field retains its latest received value for the snapshot.
|
||||
Shorter firmware reports do not erase earlier trailing fields or create zero values
|
||||
for missing fields. A snapshot can therefore combine values from different reports.
|
||||
A rejected report leaves the earlier decoded fields unchanged and adds
|
||||
`<prefix>decode_error`. The next accepted report removes that error.
|
||||
The `ef_pkt_<src>_<cmdset>_<cmdid>` field always retains the latest raw payload.
|
||||
|
||||
## Source priority and units
|
||||
|
||||
Within each connection, charge percentage prefers the precise battery-management
|
||||
reading, then the precise energy-management reading, then an integer reading.
|
||||
Later integer packets cannot overwrite a precise reading. New readings from the
|
||||
preferred source still update the snapshot.
|
||||
|
||||
R601 pack voltage and current use volts and signed amps. Cell voltages and current
|
||||
limits use millivolts and milliamps. Unverified inverter configuration fields have
|
||||
no unit labels in diagnostics.
|
||||
|
||||
The normal snapshot omits negative, all-ones, and `5999` time estimates. R601
|
||||
samples report `5999` for inactive directions, but it can also mean an unknown
|
||||
or capped estimate. Diagnostics retain these raw values. Other estimates remain
|
||||
unchanged. This rule does not apply to River 3.
|
||||
|
||||
## Diagnostic collection
|
||||
|
||||
The diagnostic script waits for valid PD, EMS, and BMS reports. These are periodic
|
||||
reports from the device, energy management, and battery management modules.
|
||||
Empty or truncated reports do not count. Inverter (INV) and solar-controller
|
||||
(MPPT) reports are optional and do not delay completion.
|
||||
|
||||
Raw packet keys include the source module: `ef_pkt_<src>_<cmdset>_<cmdid>`.
|
||||
This keeps reports from different modules separate, even when they share a
|
||||
command number. Watch mode also shows optional reports and unclassified packets.
|
||||
|
||||
Live R601 checks cover idle, charging, and discharging operation. Automated tests
|
||||
use synthetic packets. These results do not validate River 3 operation.
|
||||
|
||||
See the [README](../README.md#ecoflow-bluetooth-le-devices) for setup and commands.
|
||||
Generated
+563
-3
@@ -1,5 +1,36 @@
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "bleak"
|
||||
version = "3.0.2"
|
||||
description = "Bluetooth Low Energy platform Agnostic Klient"
|
||||
optional = true
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\""
|
||||
files = [
|
||||
{file = "bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d"},
|
||||
{file = "bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
dbus-fast = {version = ">=1.83.0", markers = "sys_platform == \"linux\""}
|
||||
pyobjc-core = {version = ">=10.3", markers = "sys_platform == \"darwin\""}
|
||||
pyobjc-framework-corebluetooth = {version = ">=10.3", markers = "sys_platform == \"darwin\""}
|
||||
pyobjc-framework-libdispatch = {version = ">=10.3", markers = "sys_platform == \"darwin\""}
|
||||
winrt-runtime = {version = ">=3.1", markers = "sys_platform == \"win32\""}
|
||||
winrt-windows-devices-bluetooth = {version = ">=3.1", markers = "sys_platform == \"win32\""}
|
||||
winrt-windows-devices-bluetooth-advertisement = {version = ">=3.1", markers = "sys_platform == \"win32\""}
|
||||
winrt-windows-devices-bluetooth-genericattributeprofile = {version = ">=3.1", markers = "sys_platform == \"win32\""}
|
||||
winrt-windows-devices-enumeration = {version = ">=3.1", markers = "sys_platform == \"win32\""}
|
||||
winrt-windows-devices-radios = {version = ">=3.1", markers = "sys_platform == \"win32\""}
|
||||
winrt-windows-foundation = {version = ">=3.1", markers = "sys_platform == \"win32\""}
|
||||
winrt-windows-foundation-collections = {version = ">=3.1", markers = "sys_platform == \"win32\""}
|
||||
winrt-windows-storage-streams = {version = ">=3.1", markers = "sys_platform == \"win32\""}
|
||||
|
||||
[package.extras]
|
||||
pythonista = ["bleak-pythonista (>=0.1.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
@@ -145,6 +176,74 @@ files = [
|
||||
[package.extras]
|
||||
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-fast"
|
||||
version = "4.0.4"
|
||||
description = "A faster version of dbus-next"
|
||||
optional = true
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"linux\""
|
||||
files = [
|
||||
{file = "dbus_fast-4.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc3955d9b86717a2b126700842012e30df3c3b263707154047e9b06e91d7b48d"},
|
||||
{file = "dbus_fast-4.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1dc97206c3f7fbf45e2fc12a2a3fdf0f080325a342a140edece4bf33f0bfd23"},
|
||||
{file = "dbus_fast-4.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:864e032aabfae051db27e1b63055a0f6460d0121d36ddcbe97b903149fa932f6"},
|
||||
{file = "dbus_fast-4.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6738fa2a98652caf29e3368e6532f3faead007739f464d047c2cc46ec5b56f6c"},
|
||||
{file = "dbus_fast-4.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed82af97d3ceee7ba31a169b5b8b9341e05ebf45fdf93f02d2fee0b74ef56190"},
|
||||
{file = "dbus_fast-4.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54c67a84a9f633a745c5bf6a37fece3c13f4057b4ecb23a2e83600423cd78057"},
|
||||
{file = "dbus_fast-4.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf5109bbc98c8427f3cc22030de55e57a7288550bc0701a493905bbad3b3dc93"},
|
||||
{file = "dbus_fast-4.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:baca8ffed5a5b18cce9c1f17820cc699512bc97ac0743b8b88c61159aa0c961a"},
|
||||
{file = "dbus_fast-4.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cb570c6c8dac1ee2ca02621c0d410ec78d1a96535deee8a34e3028f475be7675"},
|
||||
{file = "dbus_fast-4.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a2ee216e0ddd7b4db397bb31ff71741f6c7fc9618d9ca201e0096d9523b4f33"},
|
||||
{file = "dbus_fast-4.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:139b462190f7421e55fec421108f824425a559b3d9a5e0b15421e68275735bad"},
|
||||
{file = "dbus_fast-4.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00a3db08cf1d98bbedfb73467b774fd49adcf83935b8a92f6c552ce78d93491f"},
|
||||
{file = "dbus_fast-4.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74183b4ef4cc79a9cd1a46e006c19d00ce9abc7d99d36e6adcd094f414446d38"},
|
||||
{file = "dbus_fast-4.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:003f042b1299ebac900d6ee30e8ab2a29988937f56792894da7b5d39a26b1f5f"},
|
||||
{file = "dbus_fast-4.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87be8a41330481c3d3a5e30af10ec713e4f08fe0d6c36f5a401eaef7b5526417"},
|
||||
{file = "dbus_fast-4.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:728bc8a02302c6677a42da2b037580755b29c8fccd5c0866acc529053b2d374d"},
|
||||
{file = "dbus_fast-4.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fe60ce3a97e265a3ad117b7f40fc8c08357781b1a2ed3e853f29f9e35d24af7"},
|
||||
{file = "dbus_fast-4.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982235dabb7e7187df4c2c299d95a6232aebc95c8c906496f630265a10bfdf95"},
|
||||
{file = "dbus_fast-4.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33c0168e0e65ed2d0fa91682773cc893472e3d71a5e085ee082e6484c2d29f4a"},
|
||||
{file = "dbus_fast-4.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:069df5d46390299d7fccfc0e704af504a8b75794c2c3bc0246b6db0db1f245b0"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3564c8a756903a6edf01ceb4dd4927354f03b40a28f000e2b896f02a784e9301"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3a8c07d672b1656a9c7cbdc9a85f493602f72fce8e7f9d97c5e972fa22a684"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5512b9901ba600f1efdae2555ea18da2b67a46e5c24e397edb38ecbbde4aa43f"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:36f86ba826f05c4d238c9198ae962f90a9cd27ffacd3a4a988166b3d06c699ca"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:df0f88a9c1c9f9365627fbfb3dcae3589a12fdc82594945832cb24d9a62674d1"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4c6c8722e829b8e750fd6db2906b298d3bcfc32d8cdf766e50ba474ef48bf2af"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8a515e8de0e5971e64a3f49fad2e536b6d2167a9f489e597baf7c802cfb7eef3"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad5240c29ec631f9610e03a6bbf4f4c22274e04ea8791e8401542cd6cdbd7b28"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5843c3e543ac1d48cafff67394ada9e568f20d122b44408e378ae43402b785ca"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7ebb845f2e15ef19f4a99cb879dfbcb1c7028a97d5aaba93b36f65cfd938a022"},
|
||||
{file = "dbus_fast-4.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:698fe83128150dd49aaa7deb5493523309f0a9749c3c075730a6981851607455"},
|
||||
{file = "dbus_fast-4.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f815c979322e0f2e0ad0b1bd3adbf7d2840d69ca7f75be1215d8211e8a667fe5"},
|
||||
{file = "dbus_fast-4.0.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0827134ae05d0f23349c0505ecc3b54ae8359be7f634b9eb16c8ad2dff9d51bb"},
|
||||
{file = "dbus_fast-4.0.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44e605de4e18d0e94690d0ef7270e5f1c396ec30e0aac0ae3cac8886b458965c"},
|
||||
{file = "dbus_fast-4.0.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0c869d1949065d7b3c75d926cd461faa434691b4cf57a10559617821afe30737"},
|
||||
{file = "dbus_fast-4.0.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:92b3aaea0e6df4cf83208ae994b08554335166eff726947733b93da748eab641"},
|
||||
{file = "dbus_fast-4.0.4.tar.gz", hash = "sha256:43137f0b73a7adbf7d5c0e9eb9d8d34df9e6e0aeafade2166e641c52dfe0a853"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.2"
|
||||
description = "ECDSA cryptographic signature library (pure python)"
|
||||
optional = true
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\""
|
||||
files = [
|
||||
{file = "ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399"},
|
||||
{file = "ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
six = ">=1.9.0"
|
||||
|
||||
[package.extras]
|
||||
gmpy = ["gmpy"]
|
||||
gmpy2 = ["gmpy2"]
|
||||
|
||||
[[package]]
|
||||
name = "influxdb3-python"
|
||||
version = "0.19.0"
|
||||
@@ -290,6 +389,58 @@ files = [
|
||||
{file = "pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
description = "Cryptographic library for Python"
|
||||
optional = true
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\""
|
||||
files = [
|
||||
{file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"},
|
||||
{file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"},
|
||||
{file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"},
|
||||
{file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"},
|
||||
{file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"},
|
||||
{file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"},
|
||||
{file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"},
|
||||
{file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"},
|
||||
{file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"},
|
||||
{file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"},
|
||||
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"},
|
||||
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"},
|
||||
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"},
|
||||
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"},
|
||||
{file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"},
|
||||
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"},
|
||||
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"},
|
||||
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"},
|
||||
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"},
|
||||
{file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"},
|
||||
{file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@@ -305,6 +456,93 @@ files = [
|
||||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-core"
|
||||
version = "12.1"
|
||||
description = "Python<->ObjC Interoperability Module"
|
||||
optional = true
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"darwin\""
|
||||
files = [
|
||||
{file = "pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7"},
|
||||
{file = "pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6"},
|
||||
{file = "pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962"},
|
||||
{file = "pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a"},
|
||||
{file = "pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc"},
|
||||
{file = "pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43"},
|
||||
{file = "pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882"},
|
||||
{file = "pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-cocoa"
|
||||
version = "12.1"
|
||||
description = "Wrappers for the Cocoa frameworks on macOS"
|
||||
optional = true
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"darwin\""
|
||||
files = [
|
||||
{file = "pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48"},
|
||||
{file = "pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6"},
|
||||
{file = "pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858"},
|
||||
{file = "pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118"},
|
||||
{file = "pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44"},
|
||||
{file = "pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a"},
|
||||
{file = "pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc"},
|
||||
{file = "pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pyobjc-core = ">=12.1"
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-corebluetooth"
|
||||
version = "12.1"
|
||||
description = "Wrappers for the framework CoreBluetooth on macOS"
|
||||
optional = true
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"darwin\""
|
||||
files = [
|
||||
{file = "pyobjc_framework_corebluetooth-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:937849f4d40a33afbcc56cbe90c8d1fbf30fb27a962575b9fb7e8e2c61d3c551"},
|
||||
{file = "pyobjc_framework_corebluetooth-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:37e6456c8a076bd5a2bdd781d0324edd5e7397ef9ac9234a97433b522efb13cf"},
|
||||
{file = "pyobjc_framework_corebluetooth-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe72c9732ee6c5c793b9543f08c1f5bdd98cd95dfc9d96efd5708ec9d6eeb213"},
|
||||
{file = "pyobjc_framework_corebluetooth-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a894f695e6c672f0260327103a31ad8b98f8d4fb9516a0383db79a82a7e58dc"},
|
||||
{file = "pyobjc_framework_corebluetooth-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1daf07a0047c3ed89fab84ad5f6769537306733b6a6e92e631581a0f419e3f32"},
|
||||
{file = "pyobjc_framework_corebluetooth-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:15ba5207ca626dffe57ccb7c1beaf01f93930159564211cb97d744eaf0d812aa"},
|
||||
{file = "pyobjc_framework_corebluetooth-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e5385195bd365a49ce70e2fb29953681eefbe68a7b15ecc2493981d2fb4a02b1"},
|
||||
{file = "pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pyobjc-core = ">=12.1"
|
||||
pyobjc-framework-Cocoa = ">=12.1"
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-libdispatch"
|
||||
version = "12.1"
|
||||
description = "Wrappers for libdispatch on macOS"
|
||||
optional = true
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"darwin\""
|
||||
files = [
|
||||
{file = "pyobjc_framework_libdispatch-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:50a81a29506f0e35b4dc313f97a9d469f7b668dae3ba597bb67bbab94de446bd"},
|
||||
{file = "pyobjc_framework_libdispatch-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:954cc2d817b71383bd267cc5cd27d83536c5f879539122353ca59f1c945ac706"},
|
||||
{file = "pyobjc_framework_libdispatch-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0e9570d7a9a3136f54b0b834683bf3f206acd5df0e421c30f8fd4f8b9b556789"},
|
||||
{file = "pyobjc_framework_libdispatch-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:58ffce5e6bcd7456b4311009480b195b9f22107b7682fb0835d4908af5a68ad0"},
|
||||
{file = "pyobjc_framework_libdispatch-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e9f49517e253716e40a0009412151f527005eec0b9a2311ac63ecac1bdf02332"},
|
||||
{file = "pyobjc_framework_libdispatch-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0ebfd9e4446ab6528126bff25cfb09e4213ddf992b3208978911cfd3152e45f5"},
|
||||
{file = "pyobjc_framework_libdispatch-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:23fc9915cba328216b6a736c7a48438a16213f16dfb467f69506300b95938cc7"},
|
||||
{file = "pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pyobjc-core = ">=12.1"
|
||||
pyobjc-framework-Cocoa = ">=12.1"
|
||||
|
||||
[[package]]
|
||||
name = "pyserial"
|
||||
version = "3.5"
|
||||
@@ -401,7 +639,7 @@ description = "Python 2 and 3 compatibility utilities"
|
||||
optional = true
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"influxdb\""
|
||||
markers = "extra == \"influxdb\" or extra == \"ecoflow-bt\""
|
||||
files = [
|
||||
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
|
||||
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
|
||||
@@ -414,7 +652,7 @@ description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"influxdb\""
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\" or extra == \"influxdb\""
|
||||
files = [
|
||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
@@ -439,11 +677,333 @@ h2 = ["h2 (>=4,<5)"]
|
||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-runtime"
|
||||
version = "3.2.1"
|
||||
description = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3"},
|
||||
{file = "winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257"},
|
||||
{file = "winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae"},
|
||||
{file = "winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022"},
|
||||
{file = "winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c"},
|
||||
{file = "winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130"},
|
||||
{file = "winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1"},
|
||||
{file = "winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576"},
|
||||
{file = "winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195"},
|
||||
{file = "winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1"},
|
||||
{file = "winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d"},
|
||||
{file = "winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159"},
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"},
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"},
|
||||
{file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"},
|
||||
{file = "winrt_runtime-3.2.1-cp39-cp39-win32.whl", hash = "sha256:07c0cb4a53a4448c2cb7597b62ae8c94343c289eeebd8f83f946eb2c817bde01"},
|
||||
{file = "winrt_runtime-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:1856325ca3354b45e0789cf279be9a882134085d34214946db76110d98391efa"},
|
||||
{file = "winrt_runtime-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:cf237858de1d62e4c9b132c66b52028a7a3e8534e8ab90b0e29a68f24f7be39d"},
|
||||
{file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing_extensions = ">=4.12.2"
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-devices-bluetooth"
|
||||
version = "3.2.1"
|
||||
description = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win32.whl", hash = "sha256:32fc355bfdc5d6b3b1875df16eaf12f9b9fc0445e01177833c27d9a4fc0d50b6"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b886ef1fc0ed49163ae6c2422dd5cb8dd4709da7972af26c8627e211872818d0"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8643afa53f9fb8fe3b05967227f86f0c8e1d7b822289e60a848c6368acc977d2"},
|
||||
{file = "winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Radios[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Networking[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-devices-bluetooth-advertisement"
|
||||
version = "3.2.1"
|
||||
description = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win32.whl", hash = "sha256:6c4747d2e5b0e2ef24e9b84a848cf8fc50fb5b268a2086b5ee8680206d1e0197"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:18d4c5d8b80ee2d29cc13c2fc1353fdb3c0f620c8083701c9b9ecf5e6c503c8d"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:75dd856611d847299078d56aee60e319df52975b931c992cd1d32ad5143fe772"},
|
||||
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-devices-bluetooth-genericattributeprofile"
|
||||
version = "3.2.1"
|
||||
description = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win32.whl", hash = "sha256:963339a0161f9970b577a6193924be783978d11693da48b41a025f61b3c5562a"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d43615c5dfa939dd30fe80dc0649434a13cc7cf0294ad0d7283d5a9f48c6ce86"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8e70fa970997e2e67a8a4172bc00b0b2a79b5ff5bb2668f79cf10b3fd63d3974"},
|
||||
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-devices-enumeration"
|
||||
version = "3.2.1"
|
||||
description = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win32.whl", hash = "sha256:986e8d651b769a0e60d2834834bdd3f6959f6a88caa0c9acb917797e6b43a588"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:10da7d403ac4afd385fe13bd5808c9a5dd616a8ef31ca5c64cea3f87673661c1"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:679e471d21ac22cb50de1bf4dfc4c0c3f5da9f3e3fbc7f08dcacfe9de9d6dd58"},
|
||||
{file = "winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["winrt-Windows.ApplicationModel.Background[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Security.Credentials[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI.Popups[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI[all] (>=3.2.1.0,<3.3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-devices-radios"
|
||||
version = "3.2.1"
|
||||
description = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win32.whl", hash = "sha256:f97766fd551d06c102155d51b2922f96663dee045e1f8d57177def0a2149cb78"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:104b737fa1279a3b6a88ba3c6236157afc1de03c472657c45e5176ad7a209e23"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:55b02877d2de06ca6f0f6140611a9af9d0c65710e28f1afdeaac1040433b1837"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win32.whl", hash = "sha256:c3e683ce682338a5a5ed465f735e223ba7a22f16d0bbea2d070962bc7657edbb"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a116e552a3f38607b9be558fb2e7de9b4450d1f9080069944d74d80cdda1873e"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4c28822f9251c9d547324f596b5c2581f050254ded05e5b786c650a3502744c1"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win32.whl", hash = "sha256:ae4a0065927fcd2d10215223f8a46be6fb89bad71cb4edd25dae3d01c137b3a8"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:bf1a975f46a2aa271ffea1340be0c7e64985050d07433e701343dddc22a72290"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:10b298ed154c5824cea2de174afce1694ed2aabfb58826de814074027ffef96f"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win32.whl", hash = "sha256:59b868d45ff22afad21b0b0d1466ec43e54543c4e4c6f1efcc2d4adc77053bd5"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:dbfcbb977f60f19c852204987ace0cd6f7a432d735882a45b3074fdbfd3fdb5a"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:659e07e6aa5542587ccfc4d4e2cc6e1ef0869606c867a3e95fc82cc8aeaf1f81"},
|
||||
{file = "winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-foundation"
|
||||
version = "3.2.1"
|
||||
description = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp39-cp39-win32.whl", hash = "sha256:14d5191725301498e4feb744d91f5b46ce317bf3d28370efda407d5c87f4423b"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:de5e4f61d253a91ba05019dbf4338c43f962bdad935721ced5e7997933994af5"},
|
||||
{file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:ebbf6e8168398c9ed0c72c8bdde95a406b9fbb9a23e3705d4f0fe28e5a209705"},
|
||||
{file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-foundation-collections"
|
||||
version = "3.2.1"
|
||||
description = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win32.whl", hash = "sha256:20610f098b84c87765018cbc71471092197881f3b92e5d06158fad3bfcea2563"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9739775320ac4c0238e1775d94a54e886d621f9995977e65d4feb8b3778c111"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:e4c6bddb1359d5014ceb45fe2ecd838d4afeb1184f2ea202c2d21037af0d08a3"},
|
||||
{file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "winrt-windows-storage-streams"
|
||||
version = "3.2.1"
|
||||
description = "Python projection of Windows Runtime (WinRT) APIs"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"ecoflow-bt\" and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win32.whl", hash = "sha256:1c630cfdece58fcf82e4ed86c826326123529836d6d4d855ae8e9ceeff67b627"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7ff22434a4829d616a04b068a191ac79e008f6c27541bb178c1f6f1fe7a1657"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:fa90244191108f85f6f7afb43a11d365aca4e0722fe8adc62fb4d2c678d0993d"},
|
||||
{file = "winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.System[all] (>=3.2.1.0,<3.3.0.0)"]
|
||||
|
||||
[extras]
|
||||
ecoflow-bt = ["bleak", "ecdsa", "pycryptodome"]
|
||||
influxdb = ["influxdb3-python", "typing-extensions"]
|
||||
prometheus = ["prometheus-client"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.12,<4.0"
|
||||
content-hash = "365e8ad492e514bbf9e48ed443fa5bd0dcd86f8970cbe1c7b654631f88866c3d"
|
||||
content-hash = "c12d2be1c47839b71272f5857ee1ace55e9c388cbe82e9b60b45f257acd5bb84"
|
||||
|
||||
@@ -5,6 +5,10 @@ description = "bmspy is a tool to get information from a xiaoxiang-type BMS syst
|
||||
authors = ["Timothy Allen <tim@treehouse.org.za>"]
|
||||
license = "CC BY-NC-SA 4.0"
|
||||
readme = "README.md"
|
||||
include = [
|
||||
{path = "docs/ecoflow-river2-fields.md", format = "sdist"},
|
||||
{path = "tools/ecoflow-bt.py", format = "sdist"},
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.12,<4.0"
|
||||
@@ -12,10 +16,14 @@ pyserial = ">=3.5"
|
||||
influxdb3-python = {version = ">=0.19.0", optional = true, extras = ["influxdb"]}
|
||||
typing-extensions = {version = ">=4.13", optional = true, extras = ["influxdb"]}
|
||||
prometheus-client = {version = ">=0.11.0", optional = true, extras = ["prometheus"]}
|
||||
bleak = {version = ">=0.21.0", optional = true, extras = ["ecoflow-bt"]}
|
||||
ecdsa = {version = ">=0.19.0", optional = true, extras = ["ecoflow-bt"]}
|
||||
pycryptodome = {version = ">=3.20.0", optional = true, extras = ["ecoflow-bt"]}
|
||||
|
||||
[tool.poetry.extras]
|
||||
influxdb = ["influxdb3-python", "typing-extensions"]
|
||||
prometheus = ["prometheus-client"]
|
||||
ecoflow-bt = ["bleak", "ecdsa", "pycryptodome"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = ">=8.0"
|
||||
@@ -38,3 +46,4 @@ bmspy-influxdb = "bmspy.influxdb:main"
|
||||
bmspy-prometheus = "bmspy.prometheus:main"
|
||||
bmspy-ups = "bmspy.ups:main"
|
||||
bmspy-usbd = "bmspy.usbhid:main"
|
||||
bmspy-ecoflow-login = "bmspy.ecoflow_login:main"
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
"""River 2 protocol tests. Payload fixtures are synthetic, not hardware captures.
|
||||
|
||||
Offsets follow ha-ef-ble's DirectPdHeartbeatPack, DirectEmsDeltaHeartbeatPack,
|
||||
and DirectBmsMDeltaHeartbeatPack. Fixtures do not use bmspy's field descriptors
|
||||
so an accidental layout change cannot silently change the test data too.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import struct
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from dataclasses import asdict
|
||||
from itertools import permutations
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from bmspy.classes import UPS
|
||||
from bmspy.ecoflow_bt import (
|
||||
EcoFlowBTDevice,
|
||||
EcoFlowBTManager,
|
||||
EcoFlowPacket,
|
||||
_EncFrameHelper,
|
||||
_RawHeaderHelper,
|
||||
_State,
|
||||
_Type1Encryption,
|
||||
_Type7Encryption,
|
||||
_crc8,
|
||||
_crc16,
|
||||
_enc_packet_to_bytes,
|
||||
_parse_enc_frame,
|
||||
collect_data,
|
||||
initialise_device,
|
||||
)
|
||||
from bmspy.ecoflow_proto import decode_payload
|
||||
from bmspy.ecoflow_river2 import decode_river2_payload
|
||||
|
||||
# Synthetic device identifiers, not values from a real device.
|
||||
ADDRESS = "02:00:00:00:00:01"
|
||||
SERIAL = "R601TEST00000001"
|
||||
|
||||
|
||||
def device(serial=SERIAL, **kwargs):
|
||||
return EcoFlowBTDevice(ADDRESS, serial, "test-user", **kwargs)
|
||||
|
||||
|
||||
def pd_payload(soc=73, watts_out=42, watts_in=160, remaining=87):
|
||||
data = bytearray(23)
|
||||
struct.pack_into("<BHHi", data, 14, soc, watts_out, watts_in, remaining)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def ems_payload(charging=87, discharging=240, soc=None):
|
||||
data = bytearray(25 if soc is None else 30)
|
||||
data[0] = 1
|
||||
struct.pack_into("<II", data, 17, charging, discharging)
|
||||
if soc is not None:
|
||||
struct.pack_into("<f", data, 26, soc)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def bms_payload(soc=73.25, temperature=29, watts_in=110, watts_out=0):
|
||||
data = bytearray(69)
|
||||
data[11] = 73
|
||||
data[20] = temperature
|
||||
struct.pack_into("<fIII", data, 53, soc, watts_in, watts_out, 87)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def heartbeat(src, payload, cmd_id=0x02):
|
||||
return EcoFlowPacket(src, 0x21, 0x20, cmd_id, payload, version=2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("serial,version", [
|
||||
(SERIAL, 2), ("R603XXXXXXXXXXXX", 2), ("R651TEST00000001", 3),
|
||||
("unknown", 3),
|
||||
])
|
||||
def test_model_selects_packet_version_not_encryption(serial, version):
|
||||
dev = device(serial)
|
||||
assert dev._pkt_version == version
|
||||
assert dev._enc_type == 7
|
||||
|
||||
|
||||
def test_explicit_packet_version_and_manager_defaults():
|
||||
assert device(packet_version=3)._pkt_version == 3
|
||||
manager = EcoFlowBTManager("test-user")
|
||||
assert manager.add_device(ADDRESS, SERIAL)._pkt_version == 2
|
||||
cfg = initialise_device(ADDRESS, SERIAL, "test-user")
|
||||
assert EcoFlowBTDevice(cfg.address, cfg.serial_number, cfg.user_id,
|
||||
packet_version=cfg.packet_version)._pkt_version == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version,overhead", [(2, 18), (3, 20)])
|
||||
def test_packet_header_layout(version, overhead):
|
||||
pkt = EcoFlowPacket(0x21, 0x35, 0x35, 0x86, b"test", version=version)
|
||||
raw = pkt.to_bytes()
|
||||
assert len(raw) == overhead + 4
|
||||
assert raw[:4] == bytes([0xAA, version, 4, 0])
|
||||
assert raw[12:14] == b"\x21\x35"
|
||||
assert raw[overhead - 4:overhead - 2] == b"\x35\x86"
|
||||
if version == 3:
|
||||
assert raw[14:16] == b"\x01\x01"
|
||||
decoded = EcoFlowPacket.from_bytes(raw)
|
||||
assert decoded is not None
|
||||
assert decoded.payload == b"test"
|
||||
assert decoded.version == version
|
||||
assert decoded.cmd_set == 0x35
|
||||
assert decoded.cmd_id == 0x86
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [b"", b"\xaa", b"\xaa\x02", b"\xaa\x02\x00\x00"])
|
||||
def test_short_packet_is_rejected(raw):
|
||||
assert EcoFlowPacket.from_bytes(raw) is None
|
||||
|
||||
|
||||
def test_bad_crc_and_inconsistent_length_are_rejected():
|
||||
raw = bytearray(heartbeat(2, pd_payload()).to_bytes())
|
||||
raw[-1] ^= 1
|
||||
assert EcoFlowPacket.from_bytes(raw) is None
|
||||
raw[2] += 1
|
||||
raw[4] = _crc8(raw[:4])
|
||||
raw[-2:] = struct.pack("<H", _crc16(raw[:-2]))
|
||||
assert EcoFlowPacket.from_bytes(raw) is None
|
||||
|
||||
|
||||
def test_outer_frames_fragmentation_coalescing_and_embedded_prefix():
|
||||
# A marker inside an incomplete frame must not replace the real header.
|
||||
payload = b"abcd\x5a\x5a" + bytes(range(50))
|
||||
wire = _enc_packet_to_bytes(1, payload)
|
||||
second = _enc_packet_to_bytes(1, b"next")
|
||||
for split in range(len(wire) + 1):
|
||||
buf = bytearray()
|
||||
first, buf = _parse_enc_frame(wire[:split], buf)
|
||||
rest, buf = _parse_enc_frame(wire[split:] + second, buf)
|
||||
assert first + rest == [payload, b"next"]
|
||||
assert not buf
|
||||
|
||||
|
||||
def test_outer_frame_discards_noise_and_bad_lengths():
|
||||
buf = bytearray()
|
||||
assert _parse_enc_frame(b"noise" * 100, buf)[0] == []
|
||||
assert not buf
|
||||
wire = _enc_packet_to_bytes(1, b"ok")
|
||||
for bad_length in [0, 1, 10001]:
|
||||
bad = b"\x5a\x5a\x10\x01" + struct.pack("<H", bad_length) + b"00"
|
||||
assert _parse_enc_frame(bad + wire, bytearray())[0] == [b"ok"]
|
||||
packets, buf = _parse_enc_frame(b"noise" + wire[:1], bytearray())
|
||||
assert packets == []
|
||||
assert _parse_enc_frame(wire[1:], buf)[0] == [b"ok"]
|
||||
corrupt = wire[:-1] + bytes([wire[-1] ^ 1])
|
||||
assert _parse_enc_frame(corrupt + wire, bytearray())[0] == [b"ok"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enc_helper():
|
||||
pytest.importorskip("Crypto")
|
||||
return _EncFrameHelper(_Type7Encryption(bytes(range(16)), bytes(range(16, 32))))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [2, 3])
|
||||
def test_encrypted_frames_round_trip(enc_helper, version):
|
||||
pkt = EcoFlowPacket(2, 0x21, 0x20, 2, pd_payload(), version=version)
|
||||
wire = enc_helper.encode(pkt)
|
||||
for split in range(1, len(wire)):
|
||||
assert enc_helper.decode(wire[:split]) == []
|
||||
decoded = enc_helper.decode(wire[split:])
|
||||
assert len(decoded) == 1
|
||||
assert decoded[0].payload == pkt.payload
|
||||
assert decoded[0].version == version
|
||||
|
||||
|
||||
def test_type1_version2_round_trip():
|
||||
pytest.importorskip("Crypto")
|
||||
helper = _RawHeaderHelper(_Type1Encryption(bytes(16), bytes(16)))
|
||||
pkt = heartbeat(2, pd_payload())
|
||||
wire = helper.encode(pkt)
|
||||
assert helper.decode(wire[:7]) == []
|
||||
assert helper.decode(wire[7:])[0].payload == pkt.payload
|
||||
|
||||
|
||||
def test_numeric_decoder_uses_source_and_handles_short_firmware_payloads():
|
||||
pd = decode_river2_payload(2, 0x20, 2, pd_payload())
|
||||
ems = decode_river2_payload(3, 0x20, 2, ems_payload())
|
||||
assert pd["watts_in_sum"] == 160
|
||||
assert pd["watts_out_sum"] == 42
|
||||
assert pd["remain_time"] == 87
|
||||
assert ems["chg_remain_time"] == 87
|
||||
assert ems["dsg_remain_time"] == 240
|
||||
assert decode_river2_payload(2, 0x20, 2, pd_payload() + b"\x00") == {
|
||||
**pd, "beep_mode": 0,
|
||||
}
|
||||
assert decode_river2_payload(2, 0xFE, 0x15, pd_payload()) is None
|
||||
assert decode_river2_payload(9, 0x20, 2, pd_payload()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("src,cmd_id,payload", [
|
||||
(2, 2, pd_payload()), (3, 2, ems_payload()), (3, 0x32, bms_payload()[:21]),
|
||||
])
|
||||
def test_truncated_core_heartbeat_is_rejected(src, cmd_id, payload):
|
||||
for length in range(len(payload)):
|
||||
assert decode_river2_payload(src, 0x20, cmd_id, payload[:length]) is None
|
||||
|
||||
|
||||
def test_snapshot_contains_power_temperature_and_times():
|
||||
dev = device()
|
||||
updates = []
|
||||
dev.on_update(lambda: updates.append(dev.snapshot.ef_input_power_watts.raw_value))
|
||||
dev._record(heartbeat(2, pd_payload()))
|
||||
dev._record(heartbeat(3, ems_payload()))
|
||||
dev._record(heartbeat(3, bms_payload(), 0x32))
|
||||
snap = dev.snapshot
|
||||
assert updates == [160, 160, 160]
|
||||
assert snap.ef_input_power_watts.raw_value == 160
|
||||
assert snap.ef_input_power_watts.units == "W"
|
||||
assert snap.ef_output_power_watts.raw_value == 42
|
||||
assert snap.bms_capacity_charge_ratio.raw_value == 0.7325
|
||||
assert snap.bms_temperature_celcius.raw_values == {1: 29}
|
||||
assert snap.bms_temperature_celcius.units == "°C"
|
||||
assert snap.ef_remaining_time_minutes.raw_value == 87
|
||||
assert snap.ef_charge_remaining_time_minutes.raw_value == 87
|
||||
assert snap.ef_discharge_remaining_time_minutes.raw_value == 240
|
||||
assert snap.ef_charge_remaining_time_minutes.units == "min"
|
||||
assert snap.ef_charge_state.raw_value == 1
|
||||
assert snap.bms_charge_is_charging.raw_value is True
|
||||
assert snap.bms_charge_is_discharging.raw_value is False
|
||||
assert snap.ef_packets_received.raw_value == 3
|
||||
assert set(snap._packets) == {
|
||||
"ef_pkt_02_20_02", "ef_pkt_03_20_02", "ef_pkt_03_20_32",
|
||||
}
|
||||
# All metrics and source-specific packets survive the server/client format.
|
||||
serialized = {name: asdict(value) for name, value in snap.items()}
|
||||
assert dict(UPS.from_dict(serialized).items()) == dict(snap.items())
|
||||
|
||||
|
||||
def test_zero_power_bypass_and_discharge():
|
||||
dev = device()
|
||||
dev._record(heartbeat(2, pd_payload(watts_in=42, watts_out=42)))
|
||||
assert dev.snapshot.bms_charge_is_charging is None
|
||||
dev._record(heartbeat(3, bms_payload(watts_in=0), 0x32))
|
||||
assert dev.snapshot.bms_charge_is_charging.raw_value is False
|
||||
dev._record(heartbeat(2, pd_payload(watts_in=0, watts_out=42)))
|
||||
dev._record(heartbeat(3, bms_payload(watts_in=0, watts_out=50), 0x32))
|
||||
assert dev.snapshot.ef_input_power_watts.raw_value == 0
|
||||
assert dev.snapshot.bms_charge_is_discharging.raw_value is True
|
||||
|
||||
|
||||
def test_unavailable_times_clear_previous_values_and_zero_is_valid():
|
||||
dev = device()
|
||||
dev._record(heartbeat(2, pd_payload()))
|
||||
dev._record(heartbeat(3, ems_payload()))
|
||||
dev._record(heartbeat(2, pd_payload(remaining=-1)))
|
||||
dev._record(heartbeat(3, ems_payload(charging=0xFFFFFFFF, discharging=0)))
|
||||
assert dev.snapshot.ef_remaining_time_minutes is None
|
||||
assert dev.snapshot.ef_charge_remaining_time_minutes is None
|
||||
assert dev.snapshot.ef_discharge_remaining_time_minutes.raw_value == 0
|
||||
|
||||
|
||||
def test_short_bms_has_temperature_without_inventing_charge_status():
|
||||
dev = device()
|
||||
dev._record(heartbeat(3, bms_payload()[:21], 0x32))
|
||||
assert dev.snapshot.bms_temperature_celcius.raw_values == {1: 29}
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.73
|
||||
assert dev.snapshot.bms_charge_is_charging is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("soc", [float("nan"), float("inf"), -1, 101])
|
||||
def test_invalid_soc_is_not_exported(soc):
|
||||
dev = device()
|
||||
dev._record(heartbeat(3, bms_payload(soc=soc), 0x32))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", list(permutations(["pd", "ems", "bms"])))
|
||||
def test_precise_soc_is_independent_of_packet_order(order):
|
||||
dev = device()
|
||||
packets = {
|
||||
"pd": heartbeat(2, pd_payload(soc=89)),
|
||||
"ems": heartbeat(3, ems_payload(soc=88.91)),
|
||||
"bms": heartbeat(3, bms_payload(soc=88.918), 0x32),
|
||||
}
|
||||
for source in order:
|
||||
dev._record(packets[source])
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == pytest.approx(0.88918)
|
||||
# Source preference must not freeze the first precise reading.
|
||||
dev._record(heartbeat(3, bms_payload(soc=89.25), 0x32))
|
||||
dev._record(heartbeat(2, pd_payload(soc=89)))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.8925
|
||||
|
||||
|
||||
def test_ems_float_is_a_fallback_when_bms_float_is_missing():
|
||||
dev = device()
|
||||
dev._record(heartbeat(3, ems_payload(soc=88.5)))
|
||||
dev._record(heartbeat(3, bms_payload()[:21], 0x32))
|
||||
dev._record(heartbeat(2, pd_payload(soc=89)))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.885
|
||||
dev._record(heartbeat(3, ems_payload(soc=89.5)))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.895
|
||||
|
||||
|
||||
@pytest.mark.parametrize("soc", [0.0, 100.0])
|
||||
def test_precise_soc_accepts_zero_and_full_charge(soc):
|
||||
dev = device()
|
||||
dev._record(heartbeat(3, bms_payload(soc=soc), 0x32))
|
||||
dev._record(heartbeat(2, pd_payload(soc=73)))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == soc / 100
|
||||
|
||||
|
||||
def test_invalid_float_does_not_block_valid_soc_fallback():
|
||||
dev = device()
|
||||
dev._record(heartbeat(3, bms_payload(soc=float("nan")), 0x32))
|
||||
dev._record(heartbeat(3, ems_payload(soc=float("inf"))))
|
||||
dev._record(heartbeat(2, pd_payload(soc=89)))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.89
|
||||
dev._record(heartbeat(3, bms_payload(soc=88.5), 0x32))
|
||||
dev._record(heartbeat(3, bms_payload(soc=float("nan")), 0x32))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.885
|
||||
|
||||
|
||||
def test_soc_source_preference_resets_on_new_connection(monkeypatch):
|
||||
client = Mock(
|
||||
connect=AsyncMock(), disconnect=AsyncMock(), is_connected=True,
|
||||
start_notify=AsyncMock(), stop_notify=AsyncMock(),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "bleak", SimpleNamespace(
|
||||
BleakClient=Mock(return_value=client),
|
||||
))
|
||||
|
||||
async def run():
|
||||
dev = device()
|
||||
dev._record(heartbeat(3, bms_payload(soc=88.5), 0x32))
|
||||
dev._auth_type7_init = AsyncMock()
|
||||
try:
|
||||
assert await dev.connect()
|
||||
dev._record(heartbeat(2, pd_payload(soc=90)))
|
||||
assert dev.snapshot.bms_capacity_charge_ratio.raw_value == 0.9
|
||||
finally:
|
||||
await dev.disconnect()
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("current", [-5, 0, 12])
|
||||
def test_battery_pack_voltage_and_signed_current_units(current):
|
||||
bms = bytearray(bms_payload())
|
||||
ems = bytearray(ems_payload())
|
||||
struct.pack_into("<II", bms, 12, 13, current & 0xFFFFFFFF)
|
||||
struct.pack_into("<II", ems, 3, 13, current & 0xFFFFFFFF)
|
||||
struct.pack_into("<H", bms, 39, 3460) # Cell voltage remains mV.
|
||||
struct.pack_into("<I", bms, 49, 20000) # Charge-current limit remains mA.
|
||||
for cmd_id, payload, voltage_name, current_name in (
|
||||
(0x32, bytes(bms), "vol", "amp"),
|
||||
(0x02, bytes(ems), "chg_vol", "chg_amp"),
|
||||
):
|
||||
values = decode_river2_payload(3, 0x20, cmd_id, payload)
|
||||
assert values[voltage_name] == 13
|
||||
assert values[current_name] == current
|
||||
lines = decode_payload(3, 0x20, cmd_id, payload)
|
||||
assert f" {voltage_name}: 13 V" in lines
|
||||
assert f" {current_name}: {current} A" in lines
|
||||
lines = decode_payload(3, 0x20, 0x32, bytes(bms))
|
||||
assert " max_cell_vol: 3460 mV" in lines
|
||||
assert " tag_chg_amp: 20000 mA" in lines
|
||||
|
||||
|
||||
def test_mppt_voltage_setting_is_volts_not_millivolts():
|
||||
data = bytearray(75)
|
||||
struct.pack_into("<I", data, 8, 1122)
|
||||
struct.pack_into("<I", data, 61, 8000)
|
||||
struct.pack_into("<IB", data, 68, 230, 50)
|
||||
lines = decode_payload(5, 0x20, 2, bytes(data))
|
||||
assert " cfg_ac_out_voltage: 230 V" in lines
|
||||
assert " cfg_ac_out_freq: 50 Hz" in lines
|
||||
assert " in_vol: 1122 mV" in lines
|
||||
assert " cfg_dc_chg_current: 8000 mA" in lines
|
||||
|
||||
|
||||
def test_unverified_inv_configuration_is_not_labeled_as_measured_frequency():
|
||||
data = bytearray(52)
|
||||
data[22] = 50
|
||||
data[51] = 2
|
||||
lines = decode_payload(4, 0x20, 2, bytes(data))
|
||||
assert " inv_out_freq: 50 Hz" in lines
|
||||
assert " cfg_ac_out_freq: 2" in lines
|
||||
|
||||
|
||||
def test_capped_times_are_omitted_but_retained_in_diagnostics():
|
||||
dev = device()
|
||||
dev._record(heartbeat(2, pd_payload()))
|
||||
dev._record(heartbeat(3, ems_payload()))
|
||||
dev._record(heartbeat(2, pd_payload(remaining=5999)))
|
||||
dev._record(heartbeat(3, ems_payload(charging=5999, discharging=5999)))
|
||||
names = dict(dev.snapshot.items())
|
||||
assert "ef_remaining_time_minutes" not in names
|
||||
assert "ef_charge_remaining_time_minutes" not in names
|
||||
assert "ef_discharge_remaining_time_minutes" not in names
|
||||
raw = bytes.fromhex(names["ef_pkt_03_20_02"].info)
|
||||
assert " chg_remain_time: 5999 min" in decode_payload(3, 0x20, 2, raw)
|
||||
|
||||
|
||||
def test_switching_direction_clears_inactive_time_estimate():
|
||||
dev = device()
|
||||
dev._record(heartbeat(3, ems_payload(charging=69, discharging=5999)))
|
||||
assert dev.snapshot.ef_charge_remaining_time_minutes.raw_value == 69
|
||||
assert dev.snapshot.ef_discharge_remaining_time_minutes is None
|
||||
dev._record(heartbeat(3, ems_payload(charging=5999, discharging=184)))
|
||||
assert dev.snapshot.ef_charge_remaining_time_minutes is None
|
||||
assert dev.snapshot.ef_discharge_remaining_time_minutes.raw_value == 184
|
||||
|
||||
|
||||
@pytest.mark.parametrize("minutes", [0, 10, 192, 5998, 6000])
|
||||
def test_non_sentinel_times_are_not_adjusted(minutes):
|
||||
dev = device()
|
||||
dev._record(heartbeat(2, pd_payload(remaining=minutes)))
|
||||
dev._record(heartbeat(3, ems_payload(charging=minutes, discharging=minutes)))
|
||||
assert dev.snapshot.ef_remaining_time_minutes.raw_value == minutes
|
||||
assert dev.snapshot.ef_charge_remaining_time_minutes.raw_value == minutes
|
||||
assert dev.snapshot.ef_discharge_remaining_time_minutes.raw_value == minutes
|
||||
|
||||
|
||||
def test_river3_does_not_use_river2_struct_decoder():
|
||||
dev = device("R651TEST00000001")
|
||||
dev._record(heartbeat(2, pd_payload()))
|
||||
assert dev.snapshot.ef_input_power_watts is None
|
||||
assert "ef_pkt_02_20_02" in dev.snapshot._packets
|
||||
|
||||
|
||||
def test_fragmented_auth_probe_then_auth_and_telemetry(enc_helper):
|
||||
async def run():
|
||||
dev = device()
|
||||
dev._assembler = enc_helper
|
||||
dev._state = _State.AUTH_STATUS
|
||||
dev._write_packet = AsyncMock()
|
||||
probe = EcoFlowPacket(0x35, 0x21, 0x35, 0x89, b"\x00", version=2)
|
||||
wire = enc_helper.encode(probe)
|
||||
await dev._notify_handler(None, bytearray(wire[:9]))
|
||||
dev._write_packet.assert_not_awaited()
|
||||
assert dev._state == _State.AUTH_STATUS
|
||||
await dev._notify_handler(None, bytearray(wire[9:]))
|
||||
dev._write_packet.assert_awaited_once()
|
||||
auth = dev._write_packet.call_args.args[0]
|
||||
assert auth.version == 2
|
||||
assert auth.cmd_id == 0x86
|
||||
assert len(auth.payload) == 32
|
||||
assert dev._state == _State.AUTHENTICATING
|
||||
|
||||
success = EcoFlowPacket(0x35, 0x21, 0x35, 0x86, b"\x00", version=2)
|
||||
wire = enc_helper.encode(success) + enc_helper.encode(heartbeat(2, pd_payload()))
|
||||
await dev._notify_handler(None, bytearray(wire))
|
||||
assert await dev.wait_authenticated(timeout=0.1)
|
||||
assert dev.is_authenticated
|
||||
assert dev.snapshot.ef_input_power_watts.raw_value == 160
|
||||
# Read-only collection: no echo of telemetry as device commands.
|
||||
dev._write_packet.assert_awaited_once()
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_unrecognized_f001_is_not_auth_success(enc_helper):
|
||||
async def run():
|
||||
dev = device()
|
||||
dev._state = _State.AUTHENTICATING
|
||||
dev._assembler = enc_helper
|
||||
enc = enc_helper._enc
|
||||
wire = _enc_packet_to_bytes(1, b"\xf0\x01", enc.session_key, enc.iv)
|
||||
await dev._on_data(bytearray(wire))
|
||||
assert not dev.is_authenticated
|
||||
assert dev._pkt_count == 0
|
||||
rejected = EcoFlowPacket(0x35, 0x21, 0x35, 0x86, b"\x01", version=2)
|
||||
await dev._on_data(bytearray(enc_helper.encode(rejected)))
|
||||
assert dev._state == _State.ERROR
|
||||
assert not dev._authenticated.is_set()
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_disconnect_cancels_pending_auth_fallback():
|
||||
async def run():
|
||||
dev = device()
|
||||
dev._state = _State.AUTH_STATUS
|
||||
dev._do_authenticate = AsyncMock()
|
||||
task = asyncio.create_task(dev._auth_type7_fallback())
|
||||
dev._auth_fallback_task = task
|
||||
await dev.disconnect()
|
||||
assert task.cancelled()
|
||||
assert dev._auth_fallback_task is None
|
||||
dev._do_authenticate.assert_not_awaited()
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("connect_ok,authenticated,packets", [
|
||||
(False, False, 0), (True, False, 0), (True, True, 0), (True, True, 1),
|
||||
])
|
||||
def test_collection_always_disconnects(monkeypatch, connect_ok, authenticated, packets):
|
||||
fake = Mock(
|
||||
connect=AsyncMock(return_value=connect_ok),
|
||||
wait_authenticated=AsyncMock(return_value=authenticated),
|
||||
disconnect=AsyncMock(), _pkt_count=packets,
|
||||
)
|
||||
monkeypatch.setattr("bmspy.ecoflow_bt.EcoFlowBTDevice", Mock(return_value=fake))
|
||||
result = collect_data(initialise_device(ADDRESS, SERIAL, "test-user", listen_duration=0))
|
||||
assert result is (fake.snapshot if packets else False)
|
||||
fake.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def diagnostic_script():
|
||||
path = Path(__file__).resolve().parents[1] / "tools" / "ecoflow-bt.py"
|
||||
spec = importlib.util.spec_from_file_location("ecoflow_diagnostic", path)
|
||||
script = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(script)
|
||||
return script
|
||||
|
||||
|
||||
def test_diagnostic_script_decodes_exact_source(diagnostic_script):
|
||||
lines = diagnostic_script._decode_snapshot_packet("ef_pkt_03_20_02", ems_payload().hex(), [2, 3])
|
||||
assert "EMS heartbeat" in lines[0]
|
||||
assert any("dsg_remain_time: 240 min" in line for line in lines)
|
||||
|
||||
|
||||
def test_diagnostic_printer_preserves_empty_packet(diagnostic_script, capsys):
|
||||
dev = device()
|
||||
dev._record(EcoFlowPacket(0x35, 0x21, 0x01, 0x52, b"", version=2))
|
||||
dev._record(heartbeat(2, pd_payload()))
|
||||
diagnostic_script.print_snapshot("River 2", ADDRESS, dev.snapshot, [2, 3])
|
||||
output = capsys.readouterr().out
|
||||
assert "ef_pkt_35_01_52:" in output
|
||||
assert "(empty payload)" in output
|
||||
assert "BMSInfoField(" not in output
|
||||
assert "PD heartbeat" in output
|
||||
assert "watts_in_sum: 160 W" in output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", ["BMSInfoField(info='')", "zz", "a", None])
|
||||
def test_diagnostic_invalid_hex_is_reported(diagnostic_script, payload):
|
||||
lines = diagnostic_script._decode_snapshot_packet("ef_pkt_35_01_52", payload, [2, 3])
|
||||
assert "invalid hex payload" in lines[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_stage", [None, "connect", "collect", "print", "cancel"])
|
||||
def test_diagnostic_run_always_disconnects(diagnostic_script, monkeypatch, failure_stage):
|
||||
manager = Mock(
|
||||
connect_all=AsyncMock(), disconnect_all=AsyncMock(),
|
||||
add_device=Mock(return_value=device()),
|
||||
)
|
||||
collect = AsyncMock()
|
||||
printer = Mock()
|
||||
monkeypatch.setattr(diagnostic_script, "EcoFlowBTManager", Mock(return_value=manager))
|
||||
monkeypatch.setattr(diagnostic_script, "collect", collect)
|
||||
monkeypatch.setattr(diagnostic_script, "print_snapshot", printer)
|
||||
args = Namespace(scan=False, device=ADDRESS, serial_number=SERIAL,
|
||||
debug=0, timeout=0)
|
||||
if failure_stage is not None:
|
||||
target = {
|
||||
"connect": manager.connect_all, "collect": collect,
|
||||
"print": printer, "cancel": collect,
|
||||
}[failure_stage]
|
||||
error_type = asyncio.CancelledError if failure_stage == "cancel" else RuntimeError
|
||||
target.side_effect = error_type("test failure")
|
||||
with pytest.raises(error_type):
|
||||
asyncio.run(diagnostic_script.run("test-user", args))
|
||||
else:
|
||||
asyncio.run(diagnostic_script.run("test-user", args))
|
||||
printer.assert_called_once()
|
||||
manager.disconnect_all.assert_awaited_once()
|
||||
manager.add_device.assert_called_once_with(
|
||||
address=ADDRESS, serial_number=SERIAL, encrypt_type=7,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("select_device,serial_override", [
|
||||
(False, None), (True, None), (True, SERIAL),
|
||||
])
|
||||
@pytest.mark.parametrize("expected_type", [1, 7])
|
||||
def test_diagnostic_uses_discovery_instead_of_saved_identifiers(
|
||||
diagnostic_script, monkeypatch, select_device, serial_override, expected_type,
|
||||
):
|
||||
detected = SimpleNamespace(address=ADDRESS, serial_number=SERIAL, encrypt_type=expected_type, rssi=-50)
|
||||
unsupported = SimpleNamespace(address="02:00:00:00:00:02", serial_number="UNKNOWN", encrypt_type=7, rssi=-50)
|
||||
scanner = AsyncMock(return_value=[detected, unsupported])
|
||||
manager = Mock(
|
||||
connect_all=AsyncMock(), disconnect_all=AsyncMock(),
|
||||
add_device=Mock(return_value=device()),
|
||||
)
|
||||
monkeypatch.setattr(diagnostic_script, "scan_for_devices", scanner)
|
||||
monkeypatch.setattr(diagnostic_script, "EcoFlowBTManager", Mock(return_value=manager))
|
||||
monkeypatch.setattr(diagnostic_script, "collect", AsyncMock())
|
||||
monkeypatch.setattr(diagnostic_script, "print_snapshot", Mock())
|
||||
args = Namespace(scan=True, device=ADDRESS if select_device else None,
|
||||
serial_number=serial_override, debug=0, timeout=0)
|
||||
asyncio.run(diagnostic_script.run("test-user", args))
|
||||
manager.add_device.assert_called_once_with(
|
||||
address=ADDRESS, serial_number=SERIAL, encrypt_type=expected_type,
|
||||
)
|
||||
manager.disconnect_all.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("address,serial,scan,found,error", [
|
||||
(None, None, False, [], "Supply --device"),
|
||||
(None, SERIAL, False, [], "--serial-number requires --device"),
|
||||
(ADDRESS, None, False, [], "Supply --serial-number"),
|
||||
(ADDRESS, None, True, [], "Supply --serial-number"),
|
||||
(ADDRESS, "UNKNOWN", False, [], "Unsupported serial prefix"),
|
||||
(None, None, True, [], "No EcoFlow devices found"),
|
||||
])
|
||||
def test_diagnostic_missing_device_details_fail_before_connecting(
|
||||
diagnostic_script, monkeypatch, address, serial, scan, found, error,
|
||||
):
|
||||
factory = Mock()
|
||||
monkeypatch.setattr(diagnostic_script, "EcoFlowBTManager", factory)
|
||||
monkeypatch.setattr(diagnostic_script, "scan_for_devices", AsyncMock(return_value=found))
|
||||
args = Namespace(scan=scan, device=address, serial_number=serial,
|
||||
debug=0, timeout=0)
|
||||
with pytest.raises(SystemExit, match=error):
|
||||
asyncio.run(diagnostic_script.run("test-user", args))
|
||||
factory.assert_not_called()
|
||||
|
||||
|
||||
def test_diagnostic_help_has_no_capture_key_or_encryption_options(diagnostic_script, monkeypatch, capsys):
|
||||
monkeypatch.setattr(sys, "argv", ["ecoflow-bt.py", "--help"])
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
diagnostic_script.main()
|
||||
assert exc.value.code == 0
|
||||
text = capsys.readouterr().out
|
||||
assert "--keys-out" not in text
|
||||
assert "--encrypt-type" not in text
|
||||
assert "--scan" in text and "--watch" in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("obsolete", [["--keys-out", "unused.json"], ["--encrypt-type", "7"]])
|
||||
def test_diagnostic_rejects_obsolete_flags_before_connecting(diagnostic_script, monkeypatch, capsys, obsolete):
|
||||
run = Mock()
|
||||
monkeypatch.setattr(diagnostic_script, "run", run)
|
||||
monkeypatch.setattr(sys, "argv", ["ecoflow-bt.py", "--user-id", "test-user", "--scan", *obsolete])
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
diagnostic_script.main()
|
||||
assert exc.value.code == 2
|
||||
assert "unrecognized arguments" in capsys.readouterr().err
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("serial", [SERIAL, "R651TEST00000001"])
|
||||
def test_type7_live_decryption_needs_no_public_key_export(serial, monkeypatch):
|
||||
async def scenario():
|
||||
dev = device(serial)
|
||||
initial = _Type7Encryption(bytes(range(16)), bytes(range(16, 32)))
|
||||
dev._enc = initial
|
||||
session_key = bytes(range(32, 48))
|
||||
seed_data = bytes(range(18))
|
||||
derive = Mock(return_value=session_key)
|
||||
monkeypatch.setattr(dev, "_derive_session_key", derive)
|
||||
monkeypatch.setattr(dev, "_do_auth_status", AsyncMock())
|
||||
try:
|
||||
await dev._process_session_key(initial.encrypt(seed_data))
|
||||
derive.assert_called_once_with(seed_data[16:18], seed_data[:16])
|
||||
assert not hasattr(dev, "session_key")
|
||||
assert not hasattr(dev, "session_iv")
|
||||
# The active transport still owns and uses the negotiated key.
|
||||
peer = _EncFrameHelper(_Type7Encryption(session_key, initial.iv))
|
||||
packet = EcoFlowPacket(2, 0x21, 0x20, 2, b"synthetic-telemetry", version=dev._pkt_version)
|
||||
decoded = dev._assembler.decode(peer.encode(packet))
|
||||
assert len(decoded) == 1
|
||||
assert decoded[0].payload == packet.payload
|
||||
finally:
|
||||
await dev.disconnect()
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Synthetic River 2 diagnostics. No device captures or private identifiers."""
|
||||
|
||||
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
|
||||
|
||||
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 device(prefix="R601"):
|
||||
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),
|
||||
]
|
||||
|
||||
|
||||
def test_river2_finishes_on_first_heartbeat_set_with_same_snapshot_format(script, monkeypatch, capsys):
|
||||
dev = device("R601")
|
||||
# Include optional INV/MPPT packets in the initial burst as well as the three
|
||||
# core heartbeats. Normal River 2 output and required packet types stay intact.
|
||||
for src, cmd_id in [(2, 2), (3, 2), (3, 0x32), (4, 2), (5, 2)]:
|
||||
dev._record(EcoFlowPacket(src, 0x21, 0x20, cmd_id, bytes(128), version=2))
|
||||
expected = script.MODEL_PROFILES["R601"]["expected_fields"]
|
||||
sleep = AsyncMock(side_effect=AssertionError("must not wait"))
|
||||
monkeypatch.setattr(script.asyncio, "sleep", sleep)
|
||||
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=120))
|
||||
script.print_snapshot("River 2", ADDRESS, dev.snapshot, [2, 3, 4, 5])
|
||||
sleep.assert_not_awaited()
|
||||
out = capsys.readouterr().out
|
||||
assert "3/3 required heartbeats received (PD, EMS, BMS)" in out
|
||||
assert "Optional heartbeat types seen: INV, MPPT. Not seen: none (not required)" in out
|
||||
for message in ("PD heartbeat", "EMS heartbeat", "BMS heartbeat", "INV heartbeat", "MPPT heartbeat"):
|
||||
assert message in out
|
||||
assert "latest fields collected" not in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["R601", "R603"])
|
||||
def test_river2_bypass_burst_without_inverter_finishes_immediately(script, monkeypatch, capsys, prefix):
|
||||
dev = device(prefix)
|
||||
collector = attach(script, dev)
|
||||
for packet in river2_bypass_packets():
|
||||
dev._record(packet)
|
||||
expected = script.MODEL_PROFILES[prefix]["expected_fields"]
|
||||
sleep = AsyncMock(side_effect=AssertionError("must not wait for optional INV"))
|
||||
monkeypatch.setattr(script.asyncio, "sleep", sleep)
|
||||
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=120, collectors={ADDRESS: collector}))
|
||||
script.print_snapshot("River 2", ADDRESS, dev.snapshot, [2, 3, 4, 5])
|
||||
sleep.assert_not_awaited()
|
||||
out = capsys.readouterr().out
|
||||
assert "3/3 required heartbeats received (PD, EMS, BMS)" in out
|
||||
assert "Optional heartbeat types seen: MPPT. Not seen: INV (not required)" in out
|
||||
assert "Other packet types seen: ef_pkt_35_01_52 (empty payload)" in out
|
||||
assert "5 packet type(s)" not in out
|
||||
assert "[INV heartbeat]" not in out
|
||||
for message in ("PD heartbeat", "EMS heartbeat", "BMS heartbeat", "MPPT heartbeat"):
|
||||
assert message in out
|
||||
snap = dev.snapshot
|
||||
assert snap.ef_packets_received.raw_value == 5
|
||||
assert snap.bms_capacity_charge_ratio.raw_value == pytest.approx(0.8775)
|
||||
assert snap.bms_temperature_celcius.raw_values == {1: 28}
|
||||
assert snap.bms_charge_is_charging.raw_value is False
|
||||
assert snap.bms_charge_is_discharging.raw_value is False
|
||||
assert snap.ef_input_power_watts.raw_value == snap.ef_output_power_watts.raw_value == 35
|
||||
assert snap.ef_remaining_time_minutes.raw_value == 5218
|
||||
assert snap.ef_charge_remaining_time_minutes is None
|
||||
assert snap.ef_discharge_remaining_time_minutes is None
|
||||
assert "chg_remain_time: 5999 min" in out
|
||||
assert "dsg_remain_time: 5999 min" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index,minimum,label", [(0, 23, "PD"), (2, 25, "EMS"), (3, 21, "BMS")])
|
||||
@pytest.mark.parametrize("empty", [False, True])
|
||||
@pytest.mark.parametrize("listen", [False, True])
|
||||
def test_invalid_river2_heartbeat_cannot_complete_collection(
|
||||
script, capsys, index, minimum, label, empty, listen,
|
||||
):
|
||||
dev = device("R601")
|
||||
collectors = {ADDRESS: attach(script, dev)} if listen else {}
|
||||
packets = river2_bypass_packets()
|
||||
valid = packets[index]
|
||||
packets[index] = EcoFlowPacket(valid.src, valid.dst, valid.cmd_set, valid.cmd_id,
|
||||
b"" if empty else valid.payload[:minimum - 1], version=2)
|
||||
for packet in packets:
|
||||
dev._record(packet)
|
||||
expected = script.MODEL_PROFILES["R601"]["expected_fields"]
|
||||
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=0, collectors=collectors))
|
||||
out = capsys.readouterr().out
|
||||
assert "timed out, incomplete" in out
|
||||
assert f"{label} (ef_pkt_" in out
|
||||
assert "3/3 required heartbeats received" not in out
|
||||
dev._record(valid)
|
||||
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=0, collectors=collectors))
|
||||
assert "3/3 required heartbeats received" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_river2_valid_heartbeat_stays_received_after_a_later_bad_packet(script, capsys):
|
||||
dev = device("R601")
|
||||
collector = attach(script, dev)
|
||||
packets = river2_bypass_packets()
|
||||
dev._record(packets[0])
|
||||
dev._record(EcoFlowPacket(2, 0x21, 0x20, 2, b"", version=2))
|
||||
for packet in packets[1:]:
|
||||
dev._record(packet)
|
||||
expected = script.MODEL_PROFILES["R601"]["expected_fields"]
|
||||
asyncio.run(script.collect([(ADDRESS, dev, expected)], max_wait=0, collectors={ADDRESS: collector}))
|
||||
assert "3/3 required heartbeats received" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_river2_accepts_zero_values_and_minimum_valid_core_lengths(script, capsys):
|
||||
dev = device("R601")
|
||||
for src, cmd_id, size in [(2, 2, 23), (3, 2, 25), (3, 0x32, 21)]:
|
||||
dev._record(EcoFlowPacket(src, 0x21, 0x20, cmd_id, bytes(size), version=2))
|
||||
asyncio.run(script.collect([(ADDRESS, dev, script.MODEL_PROFILES["R601"]["expected_fields"])], max_wait=0))
|
||||
out = capsys.readouterr().out
|
||||
assert "3/3 required heartbeats received" in out
|
||||
assert "Not seen: INV, MPPT (not required)" in out
|
||||
|
||||
|
||||
def test_packet_listeners_receive_duplicates_after_snapshot_updates():
|
||||
dev = device()
|
||||
observed = []
|
||||
dev.on_packet(Mock(side_effect=ValueError("bad listener")))
|
||||
dev.on_packet(lambda pkt: observed.append((pkt, dev.snapshot.ef_input_power_watts.raw_value)))
|
||||
updated = Mock()
|
||||
dev.on_update(updated)
|
||||
pkt = river2_bypass_packets()[0]
|
||||
dev._record(pkt)
|
||||
dev._record(pkt)
|
||||
assert observed == [(pkt, 35), (pkt, 35)]
|
||||
assert updated.call_count == 2
|
||||
|
||||
|
||||
def test_river2_watch_keeps_late_unknown_packets_after_completion(script, monkeypatch, capsys):
|
||||
dev = device()
|
||||
dev._state = _State.AUTHENTICATED
|
||||
collector = attach(script, dev, watch=True)
|
||||
for packet in river2_bypass_packets():
|
||||
dev._record(packet)
|
||||
calls = 0
|
||||
|
||||
async def receive_later(_):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
dev._record(EcoFlowPacket(0x35, 0x21, 0x35, 0x20, b"synthetic-unknown"))
|
||||
else:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
monkeypatch.setattr(script.asyncio, "sleep", receive_later)
|
||||
expected = script.MODEL_PROFILES["R601"]["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 "3/3 required heartbeats received" in out
|
||||
assert "continuing watch for optional or unknown information" in out
|
||||
assert "raw_payload: " + b"synthetic-unknown".hex() in out
|
||||
assert "timed out" not in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [None, "cancel", "output", "disconnect"])
|
||||
def test_river2_run_listens_before_connect_and_always_disconnects(script, monkeypatch, capsys, failure):
|
||||
dev = device()
|
||||
|
||||
async def connect(**_):
|
||||
dev._state = _State.AUTHENTICATED
|
||||
packets = river2_bypass_packets()
|
||||
dev._record(packets[0])
|
||||
# Valid receipt must survive a later bad packet even before collect starts.
|
||||
dev._record(EcoFlowPacket(2, 0x21, 0x20, 2, b"", version=2))
|
||||
for packet in packets[1:]:
|
||||
dev._record(packet)
|
||||
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=bool(failure))
|
||||
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))
|
||||
assert "3/3 required heartbeats received" in capsys.readouterr().out
|
||||
manager.disconnect_all.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "not-hex", None, 123])
|
||||
def test_river2_snapshot_validation_rejects_invalid_hex(script, value):
|
||||
collector = script.PacketCollector(ADDRESS, script.MODEL_PROFILES["R601"])
|
||||
snapshot = Mock()
|
||||
snapshot.items.return_value = [(key, Mock(info=value)) for key in script.RIVER2_REQUIRED_HEARTBEATS]
|
||||
collector.observe_snapshot(snapshot)
|
||||
assert len(collector.missing()) == 3
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Client-visible EcoFlow protocol fields, using synthetic reports only."""
|
||||
|
||||
import io
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bmspy.classes import BMSInfoField, BMSScalarField
|
||||
from bmspy.client import socket_comms
|
||||
from bmspy.ecoflow_bt import EcoFlowBTDevice, EcoFlowPacket
|
||||
from bmspy.ecoflow_river2 import MESSAGE_DEFINITIONS
|
||||
|
||||
|
||||
def device(prefix):
|
||||
return EcoFlowBTDevice("02:00:00:00:00:01", prefix + "TEST00000001", "test-user")
|
||||
|
||||
|
||||
def record(dev, payload, route=(2, 0xFE, 0x15)):
|
||||
src, cmd_set, cmd_id = route
|
||||
dev._record(EcoFlowPacket(src, 0x21, cmd_set, cmd_id, payload))
|
||||
return dict(dev.snapshot.items())
|
||||
|
||||
|
||||
def heartbeat(definitions):
|
||||
# Include skipped bytes, zeroes, and signed values, not just summary fields.
|
||||
values = [bytes([0xAB]) * struct.calcsize("<" + f.fmt) if f.fmt.endswith("s")
|
||||
else -3 if f.fmt in ("i", "h") else 1.5 if f.fmt == "f" else 0
|
||||
for f in definitions]
|
||||
return struct.pack("<" + "".join(f.fmt for f in definitions), *values), values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route,namespace", [
|
||||
((2, 0x20, 2), "pd"), ((3, 0x20, 2), "ems"),
|
||||
((3, 0x20, 0x32), "bms"), ((4, 0x20, 2), "inv"), ((5, 0x20, 2), "mppt"),
|
||||
])
|
||||
def test_every_river2_layout_field_is_client_visible(route, namespace):
|
||||
definitions = MESSAGE_DEFINITIONS[route][1]
|
||||
payload, values = heartbeat(definitions)
|
||||
fields = record(device("R601"), payload, route)
|
||||
prefix = f"ef_river2_{namespace}_"
|
||||
assert {key for key in fields if key.startswith(prefix)} == {
|
||||
prefix + f.name for f in definitions
|
||||
}
|
||||
for definition, value in zip(definitions, values):
|
||||
field = fields[prefix + definition.name]
|
||||
if isinstance(value, bytes):
|
||||
assert isinstance(field, BMSInfoField)
|
||||
assert field.info == "0x" + value.hex()
|
||||
else:
|
||||
assert isinstance(field, BMSScalarField)
|
||||
assert field.raw_value == value * definition.scale
|
||||
assert field.units == (definition.unit or None)
|
||||
|
||||
|
||||
def test_river2_short_firmware_reports_keep_previous_tail_and_explicit_zero():
|
||||
dev = device("R603")
|
||||
definitions = MESSAGE_DEFINITIONS[(2, 0x20, 2)][1]
|
||||
payload, _ = heartbeat(definitions)
|
||||
record(dev, payload + b"\x12\x34", (2, 0x20, 2))
|
||||
fields = record(dev, payload[:23], (2, 0x20, 2))
|
||||
assert fields["ef_river2_pd_watts_in_sum"].raw_value == 0
|
||||
assert fields["ef_river2_pd_relay_switch_count"].raw_value == 0
|
||||
assert fields["ef_river2_pd_trailing_bytes"].info == "0x1234"
|
||||
assert fields["ef_river2_pd_remain_time"].raw_value == -3
|
||||
assert "ef_remaining_time_minutes" not in fields
|
||||
fresh = record(device("R601"), payload[:23], (2, 0x20, 2))
|
||||
assert "ef_river2_pd_relay_switch_count" not in fresh
|
||||
|
||||
|
||||
def test_river2_malformed_report_preserves_previous_protocol_fields():
|
||||
dev = device("R601")
|
||||
definitions = MESSAGE_DEFINITIONS[(3, 0x20, 2)][1]
|
||||
payload, _ = heartbeat(definitions)
|
||||
record(dev, payload, (3, 0x20, 2))
|
||||
for bad in (payload[:24], payload[:26] + struct.pack("<f", float("nan"))):
|
||||
fields = record(dev, bad, (3, 0x20, 2))
|
||||
assert "ef_river2_ems_decode_error" in fields
|
||||
assert fields["ef_river2_ems_f32_lcd_show_soc"].raw_value == 1.5
|
||||
assert fields["ef_pkt_03_20_02"].info == bad.hex()
|
||||
fields = record(dev, payload, (3, 0x20, 2))
|
||||
assert "ef_river2_ems_decode_error" not in fields
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", [b"", b"\x00\x00", struct.pack("!I", 100) + b"{}"])
|
||||
def test_incomplete_client_response_fails_without_looping(frame):
|
||||
sock = MagicMock()
|
||||
sock.recv.side_effect = io.BytesIO(frame).read
|
||||
with patch("bmspy.client.socket.socket", return_value=sock), pytest.raises(SystemExit):
|
||||
socket_comms("/fake/socket", {"command": "GET"})
|
||||
sock.close.assert_called_once()
|
||||
|
||||
|
||||
def test_connection_failure_does_not_read_from_unconnected_socket():
|
||||
sock = MagicMock()
|
||||
sock.connect.side_effect = ConnectionRefusedError("offline")
|
||||
with patch("bmspy.client.socket.socket", return_value=sock), pytest.raises(ConnectionRefusedError):
|
||||
socket_comms("/fake/socket", {"command": "GET"})
|
||||
sock.recv.assert_not_called()
|
||||
sock.sendall.assert_not_called()
|
||||
sock.close.assert_called_once()
|
||||
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Connect to EcoFlow River 2 and River 3 via Bluetooth LE and print a telemetry
|
||||
snapshot.
|
||||
|
||||
Supply your device details on the command line, or use --scan to discover them.
|
||||
After authentication, devices push telemetry packets. The script waits for the
|
||||
expected packet types or the --timeout limit, then prints the snapshot and exits.
|
||||
River 2 requires valid PD, EMS, and BMS core payloads. Optional heartbeats and
|
||||
other packets are listed separately. Use --watch to print every decoded/raw
|
||||
packet until Ctrl+C. Watch output can contain private identifiers.
|
||||
|
||||
Usage:
|
||||
poetry run python tools/ecoflow-bt.py --user-id <id> --scan
|
||||
poetry run python tools/ecoflow-bt.py --email you@example.com --scan
|
||||
poetry run python tools/ecoflow-bt.py --user-id <id> --device <address> --serial-number <serial>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
try:
|
||||
from bmspy.ecoflow_bt import EcoFlowBTManager, EcoFlowBTDevice, EcoFlowPacket, scan_for_devices
|
||||
from bmspy.ecoflow_login import get_user_id
|
||||
from bmspy.ecoflow_proto import decode_payload, decode_protobuf, decode_river2_payload
|
||||
except ImportError as exc:
|
||||
sys.exit(f"Missing dependency: {exc}\n"
|
||||
"Run: poetry install -E ecoflow-bt")
|
||||
|
||||
# ── Model profiles (no device-specific identifiers) ──────────────────────────
|
||||
|
||||
RIVER2_REQUIRED_HEARTBEATS = {
|
||||
"ef_pkt_02_20_02": "PD",
|
||||
"ef_pkt_03_20_02": "EMS",
|
||||
"ef_pkt_03_20_32": "BMS",
|
||||
}
|
||||
RIVER2_OPTIONAL_HEARTBEATS = {
|
||||
"ef_pkt_04_20_02": "INV",
|
||||
"ef_pkt_05_20_02": "MPPT",
|
||||
}
|
||||
|
||||
MODEL_PROFILES = {
|
||||
"R651": {
|
||||
"label": "River 3",
|
||||
"enc_type": 7,
|
||||
"src_candidates": [0x02],
|
||||
# cmd_set=0xFE: DisplayPropertyUpload (0x15) + RuntimePropertyUpload (0x16)
|
||||
"expected_fields": {"ef_pkt_02_fe_15", "ef_pkt_02_fe_16"},
|
||||
},
|
||||
"R601": {
|
||||
"label": "River 2",
|
||||
"enc_type": 7,
|
||||
"src_candidates": [0x02, 0x03, 0x04, 0x05],
|
||||
# PD: total power; EMS: time estimates; BMS: battery temperature.
|
||||
"required_heartbeats": RIVER2_REQUIRED_HEARTBEATS,
|
||||
"expected_fields": set(RIVER2_REQUIRED_HEARTBEATS),
|
||||
},
|
||||
}
|
||||
|
||||
MODEL_PROFILES["R603"] = {**MODEL_PROFILES["R601"], "label": "River 2 Max"}
|
||||
|
||||
# ── Packet decoder ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _decode_snapshot_packet(
|
||||
key: str,
|
||||
payload_hex: str,
|
||||
src_candidates: list[int],
|
||||
) -> list[str]:
|
||||
parts = key.split("_")
|
||||
if len(parts) < 4:
|
||||
return [f" {payload_hex}"]
|
||||
try:
|
||||
if len(parts) == 5:
|
||||
src_candidates = [int(parts[2], 16)]
|
||||
cmd_set = int(parts[3], 16)
|
||||
cmd_id = int(parts[4], 16)
|
||||
else: # Snapshots from older bmspy versions lack the source address.
|
||||
cmd_set = int(parts[2], 16)
|
||||
cmd_id = int(parts[3], 16)
|
||||
except ValueError:
|
||||
return [f" {payload_hex}"]
|
||||
|
||||
header = f"[cmd_set=0x{cmd_set:02X} cmd_id=0x{cmd_id:02X}]"
|
||||
try:
|
||||
payload = bytes.fromhex(payload_hex)
|
||||
except (ValueError, TypeError):
|
||||
return [f" {header}: invalid hex payload {payload_hex!r}"]
|
||||
if not payload:
|
||||
return [f" {header}: (empty payload)"]
|
||||
|
||||
for src in src_candidates:
|
||||
lines = decode_payload(src, cmd_set, cmd_id, payload)
|
||||
if lines is not None:
|
||||
return [" " + ln for ln in lines]
|
||||
|
||||
# Fallback: generic protobuf decode
|
||||
pb = decode_protobuf(payload)
|
||||
if pb:
|
||||
return [f" {header}"] + [" " + ln for ln in pb]
|
||||
return [f" {header}: {payload_hex[:120]}{'...' if len(payload_hex) > 120 else ''}"]
|
||||
|
||||
|
||||
# ── Snapshot printer ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def print_snapshot(label: str, address: str, snap, src_candidates: list[int]) -> None:
|
||||
print(f"\n{'━' * 60}")
|
||||
print(f" {label} ({address})")
|
||||
print(f"{'━' * 60}")
|
||||
|
||||
pkt_keys = []
|
||||
for name, field in snap.items():
|
||||
if name.startswith("ef_pkt_"):
|
||||
payload_hex = getattr(field, "info", None)
|
||||
pkt_keys.append((name, payload_hex if payload_hex is not None else str(field)))
|
||||
else:
|
||||
val = getattr(field, "value", None)
|
||||
if val is None:
|
||||
val = getattr(field, "values", None) or getattr(field, "info", str(field))
|
||||
unit = getattr(field, "units", "") or ""
|
||||
print(f" {name}: {val}{' ' + unit if unit else ''}")
|
||||
|
||||
for key, payload_hex in pkt_keys:
|
||||
print(f"\n {key}:")
|
||||
for line in _decode_snapshot_packet(key, payload_hex, src_candidates):
|
||||
print(line)
|
||||
|
||||
print()
|
||||
|
||||
|
||||
# ── Collection logic ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PacketCollector:
|
||||
"""Track valid River 2 heartbeats and optionally print each raw packet."""
|
||||
|
||||
def __init__(self, address: str, profile: dict, watch: bool = False) -> None:
|
||||
self.address = address
|
||||
self.label = profile["label"]
|
||||
self.required_heartbeats = profile.get("required_heartbeats", {})
|
||||
self.valid_heartbeats: set[str] = set()
|
||||
self.watch = watch
|
||||
self.error: Exception | None = None
|
||||
|
||||
def observe_snapshot(self, snap) -> None:
|
||||
"""Seed receipt tracking for callers that start after reception."""
|
||||
for key, field in snap.items():
|
||||
if key not in self.required_heartbeats or key in self.valid_heartbeats:
|
||||
continue
|
||||
try:
|
||||
payload = bytes.fromhex(field.info)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
continue
|
||||
self._accept_heartbeat(key, payload)
|
||||
|
||||
def _accept_heartbeat(self, key: str, payload: bytes) -> bool:
|
||||
route = tuple(int(part, 16) for part in key.split("_")[2:])
|
||||
if decode_river2_payload(*route, payload) is None:
|
||||
return False
|
||||
self.valid_heartbeats.add(key)
|
||||
return True
|
||||
|
||||
def missing(self) -> set[str]:
|
||||
return {
|
||||
f"{label} ({key})" for key, label in self.required_heartbeats.items()
|
||||
if key not in self.valid_heartbeats
|
||||
}
|
||||
|
||||
def progress(self) -> str:
|
||||
return (f"{len(self.valid_heartbeats)}/{len(self.required_heartbeats)} "
|
||||
"required heartbeats received")
|
||||
|
||||
def receive(self, packet: EcoFlowPacket) -> None:
|
||||
# Re-raise callback/output failures from collect(), which owns cleanup.
|
||||
if self.error is not None:
|
||||
return
|
||||
try:
|
||||
self._receive(packet)
|
||||
except Exception as exc:
|
||||
self.error = exc
|
||||
|
||||
def _receive(self, packet: EcoFlowPacket) -> None:
|
||||
route = (packet.src, packet.cmd_set, packet.cmd_id)
|
||||
key = f"ef_pkt_{packet.src:02x}_{packet.cmd_set:02x}_{packet.cmd_id:02x}"
|
||||
header = f"\n{self.label} ({self.address}) {key}:"
|
||||
if self.watch:
|
||||
lines = decode_payload(*route, packet.payload)
|
||||
print("\n".join([
|
||||
header,
|
||||
f" version=0x{packet.version:02x} dst=0x{packet.dst:02x} "
|
||||
f"dsrc=0x{packet.dsrc:02x} ddst=0x{packet.ddst:02x} seq={packet.seq.hex()}",
|
||||
f" raw_payload: {packet.payload.hex() or '(empty)'}",
|
||||
*(" " + line for line in (lines or ["No decoded fields; retain raw payload for review."])),
|
||||
]), flush=True)
|
||||
if key in self.required_heartbeats and not self._accept_heartbeat(key, packet.payload):
|
||||
print(f"{header}\n {self.required_heartbeats[key]} heartbeat rejected by decoder. "
|
||||
"Not counted toward completion", flush=True)
|
||||
|
||||
|
||||
def print_river2_packet_summary(snap) -> None:
|
||||
"""Separate optional heartbeats and other packets from required heartbeats."""
|
||||
packets = {key: field for key, field in snap.items() if key.startswith("ef_pkt_")}
|
||||
seen = [label for key, label in RIVER2_OPTIONAL_HEARTBEATS.items() if key in packets]
|
||||
absent = [label for key, label in RIVER2_OPTIONAL_HEARTBEATS.items() if key not in packets]
|
||||
print(f" Optional heartbeat types seen: {', '.join(seen) or 'none'}. "
|
||||
f"Not seen: {', '.join(absent) or 'none'} (not required)", flush=True)
|
||||
other = packets.keys() - RIVER2_REQUIRED_HEARTBEATS.keys() - RIVER2_OPTIONAL_HEARTBEATS.keys()
|
||||
if other:
|
||||
descriptions = [
|
||||
key + (" (empty payload)" if getattr(packets[key], "info", None) == "" else "")
|
||||
for key in sorted(other)
|
||||
]
|
||||
print(f" Other packet types seen: {', '.join(descriptions)}", flush=True)
|
||||
|
||||
|
||||
async def collect(
|
||||
devices: list[tuple[str, EcoFlowBTDevice, set[str]]],
|
||||
max_wait: float,
|
||||
collectors: dict[str, PacketCollector] | None = None,
|
||||
watch: bool = False,
|
||||
) -> None:
|
||||
"""Finish on required information, or keep watching until interrupted."""
|
||||
collectors = dict(collectors or {})
|
||||
for addr, dev, expected in devices:
|
||||
if addr not in collectors and expected and expected <= RIVER2_REQUIRED_HEARTBEATS.keys():
|
||||
collectors[addr] = PacketCollector(addr, {
|
||||
"label": "River 2",
|
||||
"required_heartbeats": {
|
||||
key: label for key, label in RIVER2_REQUIRED_HEARTBEATS.items() if key in expected
|
||||
},
|
||||
})
|
||||
done: set[str] = set()
|
||||
last_missing: dict[str, set[str]] = {}
|
||||
deadline = asyncio.get_event_loop().time() + max_wait
|
||||
|
||||
def missing_fields(addr, dev, expected):
|
||||
collector = collectors.get(addr)
|
||||
if collector is not None and collector.required_heartbeats:
|
||||
collector.observe_snapshot(dev.snapshot)
|
||||
return collector.missing()
|
||||
return expected - {name for name, _ in dev.snapshot.items()}
|
||||
|
||||
while True:
|
||||
for addr, dev, expected in devices:
|
||||
collector = collectors.get(addr)
|
||||
if collector is not None and collector.error is not None:
|
||||
raise collector.error
|
||||
if watch and not dev.is_authenticated:
|
||||
raise ConnectionError(f"{addr}: watch stopped because the device is not authenticated")
|
||||
if addr in done:
|
||||
continue
|
||||
received = {name for name, _ in dev.snapshot.items()}
|
||||
missing = missing_fields(addr, dev, expected)
|
||||
if missing:
|
||||
if last_missing.get(addr) != missing:
|
||||
if not watch:
|
||||
print(f" {addr}: waiting for {missing}", flush=True)
|
||||
last_missing[addr] = missing
|
||||
else:
|
||||
if collector is not None and collector.required_heartbeats:
|
||||
labels = ", ".join(collector.required_heartbeats.values())
|
||||
print(f" {addr}: {collector.progress()} ({labels})", flush=True)
|
||||
print_river2_packet_summary(dev.snapshot)
|
||||
else:
|
||||
packet_count = sum(name.startswith("ef_pkt_") for name in received)
|
||||
print(f" {addr}: complete ({packet_count} packet type(s))", flush=True)
|
||||
if watch:
|
||||
print(f" {addr}: continuing watch for optional or unknown information", flush=True)
|
||||
done.add(addr)
|
||||
|
||||
if len(done) == len(devices) and not watch:
|
||||
return
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if not watch and remaining <= 0:
|
||||
for addr, dev, expected in devices:
|
||||
if addr not in done:
|
||||
missing = missing_fields(addr, dev, expected)
|
||||
print(f" {addr}: timed out, incomplete; missing {sorted(missing)}", flush=True)
|
||||
return
|
||||
await asyncio.sleep(0.5 if watch else min(0.5, remaining))
|
||||
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def run(user_id: str, args: argparse.Namespace) -> None:
|
||||
found = []
|
||||
if args.serial_number and not args.device:
|
||||
sys.exit("--serial-number requires --device.")
|
||||
if not args.device and not args.scan:
|
||||
sys.exit("Supply --device and --serial-number, or use --scan.")
|
||||
if args.scan:
|
||||
print("Scanning for EcoFlow devices (10 s)…", flush=True)
|
||||
found = await scan_for_devices(timeout=10.0, debug=args.debug)
|
||||
if found:
|
||||
for d in found:
|
||||
print(f" {d.address} serial={d.serial_number} "
|
||||
f"enc_type={d.encrypt_type} rssi={d.rssi}")
|
||||
else:
|
||||
print(" (none found)")
|
||||
print()
|
||||
|
||||
discovered = {d.address.upper(): d for d in found}
|
||||
addresses = [args.device.upper()] if args.device else list(discovered)
|
||||
if not addresses:
|
||||
sys.exit("No EcoFlow devices found.")
|
||||
selected = {}
|
||||
for address in addresses:
|
||||
detected = discovered.get(address)
|
||||
serial = args.serial_number or (detected.serial_number if detected else None)
|
||||
if not serial:
|
||||
sys.exit("Supply --serial-number, or use --scan to discover the selected device.")
|
||||
profile = MODEL_PROFILES.get(serial[:4])
|
||||
if profile is None:
|
||||
if args.device:
|
||||
sys.exit("Unsupported serial prefix. Supported prefixes: " + ", ".join(MODEL_PROFILES))
|
||||
print(f"Skipping unsupported model prefix: {serial[:4]}")
|
||||
continue
|
||||
encrypt_type = detected.encrypt_type if detected else profile["enc_type"]
|
||||
selected[address] = {
|
||||
**profile, "serial_number": serial, "enc_type": encrypt_type,
|
||||
}
|
||||
if not selected:
|
||||
sys.exit("No supported EcoFlow devices found.")
|
||||
|
||||
manager = EcoFlowBTManager(user_id=user_id, debug=args.debug)
|
||||
devices = []
|
||||
labels = {}
|
||||
src_by_addr = {}
|
||||
collectors = {}
|
||||
watch = getattr(args, "watch", False)
|
||||
|
||||
for address, d in selected.items():
|
||||
addr = address.upper()
|
||||
labels[addr] = d["label"]
|
||||
src_by_addr[addr] = d["src_candidates"]
|
||||
dev = manager.add_device(
|
||||
address=address,
|
||||
serial_number=d["serial_number"],
|
||||
encrypt_type=d["enc_type"],
|
||||
)
|
||||
devices.append((addr, dev, d["expected_fields"]))
|
||||
if watch or d.get("required_heartbeats"):
|
||||
collector = PacketCollector(addr, d, watch=watch)
|
||||
collectors[addr] = collector
|
||||
dev.on_packet(collector.receive)
|
||||
print(f"Registered {d['label']} {address} serial={d['serial_number']}")
|
||||
|
||||
try:
|
||||
if watch:
|
||||
print("\nWatching until Ctrl+C; telemetry timeout is ignored.\n"
|
||||
"Watch output can contain private identifiers. Redact it before sharing.", flush=True)
|
||||
print("\nConnecting…", flush=True)
|
||||
await manager.connect_all(auth_timeout=30.0)
|
||||
|
||||
if not watch:
|
||||
print(f"\nCollecting required information (failure limit={args.timeout}s)…", flush=True)
|
||||
await collect(devices, max_wait=args.timeout, collectors=collectors, watch=watch)
|
||||
|
||||
print("\nSnapshot:")
|
||||
for addr, dev, _ in devices:
|
||||
print_snapshot(labels[addr], addr, dev.snapshot, src_by_addr[addr])
|
||||
finally:
|
||||
try:
|
||||
print("Disconnecting…", flush=True)
|
||||
finally:
|
||||
await manager.disconnect_all()
|
||||
print("Done.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Retrieve a telemetry snapshot from EcoFlow River 2 and River 3",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
creds = parser.add_mutually_exclusive_group(required=True)
|
||||
creds.add_argument("--user-id", metavar="ID",
|
||||
help="EcoFlow user ID (skip cloud login)")
|
||||
creds.add_argument("--email", metavar="EMAIL",
|
||||
help="EcoFlow account email (fetches user ID via API)")
|
||||
parser.add_argument("--password", metavar="PASS")
|
||||
parser.add_argument("--region", default="default", choices=["default", "EU", "US"])
|
||||
parser.add_argument("--device", default=None, metavar="BT_ADDRESS",
|
||||
help="BT address to connect to (default: all discovered devices)")
|
||||
parser.add_argument("--serial-number", metavar="SERIAL",
|
||||
help="Device serial number (required without --scan)")
|
||||
parser.add_argument("--scan", action="store_true",
|
||||
help="Scan for nearby EcoFlow devices before connecting")
|
||||
parser.add_argument("--timeout", type=float, default=120.0, metavar="SEC",
|
||||
help="Failure limit for required information (default: 120 s; ignored by --watch)")
|
||||
parser.add_argument("--watch", action="store_true",
|
||||
help="Print decoded and raw packets until Ctrl+C, including unknown information")
|
||||
parser.add_argument("--debug", type=int, default=0, metavar="N")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.user_id:
|
||||
user_id = args.user_id
|
||||
else:
|
||||
password = args.password or getpass.getpass(f"Password for {args.email}: ")
|
||||
print("Fetching user ID…", flush=True)
|
||||
user_id = get_user_id(args.email, password, args.region)
|
||||
|
||||
try:
|
||||
asyncio.run(run(user_id, args))
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user