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:
tim
2026-10-08 14:02:45 +02:00
parent 90388d2800
commit 886991caf3
16 changed files with 4093 additions and 11 deletions
+6
View File
@@ -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
+62
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -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()
+358
View File
@@ -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
+367
View File
@@ -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)
+71
View File
@@ -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
View File
@@ -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:
+101
View File
@@ -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
View File
@@ -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"
+9
View File
@@ -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"
+665
View File
@@ -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())
+241
View File
@@ -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
+101
View File
@@ -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()
+414
View File
@@ -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()