From 886991caf36f62245d93c98df2f1705055f51676 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Wed, 7 Oct 2026 17:16:19 +0200 Subject: [PATCH] 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. --- .gitignore | 6 + README.md | 62 ++ bmspy/client.py | 24 +- bmspy/ecoflow_bt.py | 1033 ++++++++++++++++++++++ bmspy/ecoflow_login.py | 77 ++ bmspy/ecoflow_proto.py | 358 ++++++++ bmspy/ecoflow_river2.py | 367 ++++++++ bmspy/ecoflow_snapshot.py | 71 ++ bmspy/prometheus.py | 9 +- docs/ecoflow-river2-fields.md | 101 +++ poetry.lock | 566 +++++++++++- pyproject.toml | 9 + tests/test_ecoflow_bt.py | 665 ++++++++++++++ tests/test_ecoflow_diagnostics_river2.py | 241 +++++ tests/test_ecoflow_fields.py | 101 +++ tools/ecoflow-bt.py | 414 +++++++++ 16 files changed, 4093 insertions(+), 11 deletions(-) create mode 100644 bmspy/ecoflow_bt.py create mode 100644 bmspy/ecoflow_login.py create mode 100644 bmspy/ecoflow_proto.py create mode 100644 bmspy/ecoflow_river2.py create mode 100644 bmspy/ecoflow_snapshot.py create mode 100644 docs/ecoflow-river2-fields.md create mode 100644 tests/test_ecoflow_bt.py create mode 100644 tests/test_ecoflow_diagnostics_river2.py create mode 100644 tests/test_ecoflow_fields.py create mode 100644 tools/ecoflow-bt.py diff --git a/.gitignore b/.gitignore index de35933..f845632 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index d7ebdf0..436d430 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bmspy/client.py b/bmspy/client.py index 791d749..785ab54 100644 --- a/bmspy/client.py +++ b/bmspy/client.py @@ -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 diff --git a/bmspy/ecoflow_bt.py b/bmspy/ecoflow_bt.py new file mode 100644 index 0000000..76f7994 --- /dev/null +++ b/bmspy/ecoflow_bt.py @@ -0,0 +1,1033 @@ +#!/usr/bin/env python3 +# +# Connect to EcoFlow devices via Bluetooth LE and gather telemetry. +# Protocol based on: https://github.com/rabits/ha-ef-ble +# +# Requirements (add ecoflow-bt optional dependency group): +# pip install bleak ecdsa pycryptodome +# +# Type 7 authentication (ECDH-based, used by most EcoFlow devices) requires +# the keydata lookup table from ha-ef-ble. Copy +# custom_components/ef_ble/eflib/keydata.py +# from that project to bmspy/ecoflow_keydata.py, then re-install the package. +# +import asyncio +import contextlib +import hashlib +import logging +import struct +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum, auto + +from bmspy.classes import BMSInfoField, BMSScalarField +from bmspy.ecoflow_river2 import ( + River2Decoder, SERIAL_PREFIXES as _RIVER2_PREFIXES, decode_river2_payload, +) +from bmspy.ecoflow_snapshot import EcoFlowSnapshot +from bmspy.utilities import debugger + +_LOGGER = logging.getLogger(__name__) + +# ── BLE constants ───────────────────────────────────────────────────────────── + +MANUFACTURER_KEY = 0xB5B5 + +# Packet version and encryption type are independent. R601/R603 use V2 +# packets even when the BLE advertisement selects Type 7 encryption. + + +def _default_packet_version(serial_number: str) -> int: + return 2 if serial_number.startswith(_RIVER2_PREFIXES) else 3 + +_BLE_UUIDS = { + "rfcomm": { + "notify": "00000003-0000-1000-8000-00805f9b34fb", + "write": "00000002-0000-1000-8000-00805f9b34fb", + }, + "nordic_uart": { + "notify": "6e400003-b5a3-f393-e0a9-e50e24dcca9e", + "write": "6e400002-b5a3-f393-e0a9-e50e24dcca9e", + }, +} + +# ── Keydata for Type 7 session-key derivation ───────────────────────────────── + +try: + from bmspy.ecoflow_keydata import get8bytes as _kd_get8bytes # type: ignore[import] + _KEYDATA_AVAILABLE = True +except ImportError: + try: + # ha-ef-ble checked out alongside this project + from custom_components.ef_ble.eflib.keydata import get8bytes as _kd_get8bytes # type: ignore[import] + _KEYDATA_AVAILABLE = True + except ImportError: + _kd_get8bytes = None + _KEYDATA_AVAILABLE = False + +# ── CRC utilities ───────────────────────────────────────────────────────────── + + +def _crc8(data: bytes) -> int: + """CRC-8/SMBUS (poly 0x07, init 0x00, not reflected).""" + crc = 0 + for byte in data: + crc ^= byte + for _ in range(8): + crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF + return crc + + +def _crc16(data: bytes) -> int: + """CRC-16/ARC (poly 0x8005, init 0x0000, reflected).""" + crc = 0 + for byte in data: + crc ^= byte + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return crc + + +# ── Inner packet (EcoFlow BLE protocol payload) ─────────────────────────────── + + +class EcoFlowPacket: + """Encode/decode the inner EcoFlow BLE packet (0xAA prefix).""" + + PREFIX = b"\xaa" + + def __init__( + self, + src: int, + dst: int, + cmd_set: int, + cmd_id: int, + payload: bytes = b"", + dsrc: int = 1, + ddst: int = 1, + version: int = 3, + seq: bytes | None = None, + product_id: int = 0, + ) -> None: + self.src = src + self.dst = dst + self.cmd_set = cmd_set + self.cmd_id = cmd_id + self.payload = payload + self.dsrc = dsrc + self.ddst = ddst + self.version = version + self.seq = seq if seq is not None else b"\x00\x00\x00\x00" + self.product_id = product_id + + def to_bytes(self) -> bytes: + data = self.PREFIX + data += struct.pack("= 0 else b"\x0c" + data += self.seq + data += b"\x00\x00" + data += struct.pack("= 3: + data += struct.pack(" "EcoFlowPacket | None": + if len(data) < 5 or not data.startswith(EcoFlowPacket.PREFIX): + return None + version = data[1] + if version not in (2, 3, 0x13): + return None + min_len = 18 if version == 2 else 20 + payload_length = struct.unpack(" 0: + payload = data[payload_start : payload_start + payload_length] + if xor_payload and seq[0] != 0: + payload = bytes(c ^ seq[0] for c in payload) + if version == 0x13 and payload[-2:] == b"\xbb\xbb": + payload = payload[:-2] + return EcoFlowPacket( + src=src, dst=dst, cmd_set=cmd_set, cmd_id=cmd_id, + payload=payload, dsrc=dsrc, ddst=ddst, + version=version, seq=seq, + ) + + def __repr__(self) -> str: + return ( + f"EcoFlowPacket(src=0x{self.src:02X}, dst=0x{self.dst:02X}, " + f"cmd_set=0x{self.cmd_set:02X}, cmd_id=0x{self.cmd_id:02X}, " + f"payload={self.payload.hex()!r})" + ) + + +# ── Outer BLE wire frame (EncPacket, 0x5A5A prefix) ────────────────────────── + + +def _enc_packet_to_bytes( + frame_type: int, + payload: bytes, + enc_key: bytes | None = None, + iv: bytes | None = None, +) -> bytes: + """Encode payload into an EncPacket frame, optionally AES-CBC encrypted.""" + if enc_key is not None and iv is not None: + from Crypto.Cipher import AES + from Crypto.Util.Padding import pad + payload = AES.new(enc_key, AES.MODE_CBC, iv).encrypt(pad(payload, AES.block_size)) + data = b"\x5a\x5a" + struct.pack(" bytes: + """Wrap raw bytes in an unencrypted EncPacket command frame (used during auth).""" + return _enc_packet_to_bytes(_FRAME_TYPE_COMMAND, payload) + + +def _parse_enc_frame(data: bytes, buf: bytearray) -> tuple[list[bytes], bytearray]: + """Extract raw payloads from a stream of EncPacket frames. + + Handles partial frames across notifications via *buf* (mutated in place). + Returns (list-of-raw-payloads, updated-buf). + """ + if buf: + data = bytes(buf) + data + buf.clear() + payloads: list[bytes] = [] + while data: + start = data.find(_ENC_PREFIX) + if start < 0: + data = b"\x5a" if data.endswith(b"\x5a") else b"" + break + if start > 0: + data = data[start:] + if len(data) < 8: + break + header = data[:6] + payload_len = struct.unpack(" 10_000: + data = data[2:] + continue + frame_end = 6 + payload_len + if frame_end > len(data): + # Ciphertext can contain 5a5a. Do not mistake it for a new frame. + break + raw_payload = data[6 : frame_end - 2] + crc_bytes = data[frame_end - 2 : frame_end] + if _crc16(header + raw_payload) != struct.unpack(" tuple[list[bytes], bytearray]: + """Extract raw payloads from a stream of RawHeader-style frames (Type 1). + + These frames have a 5-byte plain header (0xAA ...) followed by an + AES-CBC encrypted body. The decryption is done by the caller once + the complete frame bytes are extracted. + Returns (list-of-encrypted-frames, updated-buf). + """ + if buf: + data = bytes(buf) + data + buf.clear() + frames: list[bytes] = [] + while data: + start = data.find(EcoFlowPacket.PREFIX) + if start < 0: + break + if start > 0: + data = data[start:] + if len(data) < 5: + break + if _crc8(data[:4]) != data[4]: + data = data[1:] + continue + payload_length = struct.unpack("= 3 else 13 + encrypted_len = ((inner_overhead + payload_length) + 15) // 16 * 16 + frame_len = 5 + encrypted_len + if len(data) < frame_len: + break + frames.append(data[:frame_len]) + data = data[frame_len:] + buf.extend(data) + return frames, buf + + +# ── Encryption strategies ───────────────────────────────────────────────────── + + +class _Type1Encryption: + """AES-CBC with zero-padding; key = MD5(sn), iv = MD5(reversed sn).""" + + def __init__(self, key: bytes, iv: bytes) -> None: + self.session_key = key + self.iv = iv + + def encrypt(self, data: bytes) -> bytes: + from Crypto.Cipher import AES + padded_len = (len(data) + 15) // 16 * 16 + padded = data + b"\x00" * (padded_len - len(data)) + return AES.new(self.session_key, AES.MODE_CBC, self.iv).encrypt(padded) + + def decrypt(self, data: bytes) -> bytes: + from Crypto.Cipher import AES + return AES.new(self.session_key, AES.MODE_CBC, self.iv).decrypt(data) + + +class _Type7Encryption: + """AES-CBC with PKCS7 padding; key derived via ECDH.""" + + def __init__(self, key: bytes, iv: bytes) -> None: + self.session_key = key + self.iv = iv + + def encrypt(self, data: bytes) -> bytes: + from Crypto.Cipher import AES + from Crypto.Util.Padding import pad + return AES.new(self.session_key, AES.MODE_CBC, self.iv).encrypt( + pad(data, AES.block_size) + ) + + def decrypt(self, data: bytes) -> bytes: + from Crypto.Cipher import AES + from Crypto.Util.Padding import unpad + aligned = len(data) - len(data) % AES.block_size + if aligned == 0: + return data + decrypted = AES.new(self.session_key, AES.MODE_CBC, self.iv).decrypt( + data[:aligned] + ) + try: + return unpad(decrypted, AES.block_size) + except ValueError: + return decrypted + + +def _ecdh_type_size(curve_num: int) -> int: + match curve_num: + case 1: return 52 + case 2: return 56 + case 3 | 4: return 64 + case _: return 40 + + +# ── Auth state ──────────────────────────────────────────────────────────────── + + +class _State(StrEnum): + CREATED = auto() + CONNECTING = auto() + CONNECTED = auto() + KEY_EXCHANGE = auto() + SESSION_KEY = auto() + AUTH_STATUS = auto() + AUTHENTICATING = auto() + AUTHENTICATED = auto() + ERROR = auto() + DISCONNECTED = auto() + + +# ── Single-device connection ────────────────────────────────────────────────── + + +class EcoFlowBTDevice: + """BLE connection and auth state machine for one EcoFlow device. + + Data arrives via BLE notifications after authentication. Close the phone + app during collection if the device does not allow simultaneous clients. + """ + + _AUTH_DST = 0x35 + + def __init__( + self, + address: str, + serial_number: str, + user_id: str, + encrypt_type: int = 7, + packet_version: int | None = None, + debug: int = 0, + ) -> None: + self._address = address + self._sn = serial_number + self._user_id = user_id + self._enc_type = encrypt_type + self._pkt_version = ( + _default_packet_version(serial_number) if packet_version is None else packet_version + ) + self._is_river2 = serial_number.startswith(_RIVER2_PREFIXES) + self._measurement_decoder = River2Decoder() if self._is_river2 else None + self._debug = debug + + self._state: _State = _State.CREATED + self._client = None + self._enc: _Type1Encryption | _Type7Encryption | None = None + self._assembler: _EncFrameHelper | _RawHeaderHelper | None = None + self._simple_buf = bytearray() + self._priv_key = None + self._auth_fallback_task: asyncio.Task | None = None + + self._pkt_count = 0 + self._authenticated = asyncio.Event() + self._on_update: list[Callable[[], None]] = [] + self._on_packet: list[Callable[[EcoFlowPacket], None]] = [] + + self._snapshot = EcoFlowSnapshot( + ef_serial_number=BMSInfoField(help="Serial Number", info=serial_number), + ef_bt_address=BMSInfoField(help="BT Address", info=address), + ef_connection_state=BMSInfoField(help="Connection", info=str(_State.CREATED)), + ) + + # ── Public API ──────────────────────────────────────────────────────────── + + @property + def address(self) -> str: + return self._address + + @property + def serial_number(self) -> str: + return self._sn + + @property + def snapshot(self) -> EcoFlowSnapshot: + return self._snapshot + + @property + def is_authenticated(self) -> bool: + return self._state == _State.AUTHENTICATED + + def on_update(self, callback: Callable[[], None]) -> None: + """Register a callback invoked whenever a new data packet arrives.""" + self._on_update.append(callback) + + def on_packet(self, callback: Callable[[EcoFlowPacket], None]) -> None: + """Register a synchronous listener for each packet, after snapshot updates.""" + self._on_packet.append(callback) + + async def connect(self) -> bool: + """Open BLE connection and run the authentication handshake. + + Returns True when the connection is established (auth may still be in + progress; await *wait_authenticated()* for the full handshake). + """ + from bleak import BleakClient + + await self.disconnect() + self._enc = None + self._assembler = None + self._simple_buf.clear() + if self._measurement_decoder is not None: + self._measurement_decoder.reset() + self._priv_key = None + self._set_state(_State.CONNECTING) + self._authenticated.clear() + + try: + self._client = BleakClient( + self._address, + disconnected_callback=self._on_ble_disconnect, + ) + await self._client.connect() + except Exception as exc: + self._log(1, "connect failed: %s", exc) + await self.disconnect() + self._set_state(_State.ERROR) + return False + + self._set_state(_State.CONNECTED) + self._log(1, "BLE connected") + + try: + # Register ONE persistent notify handler before sending anything, so + # no device response can arrive before our handler is active. + await self._start_notify(self._notify_handler) + + if self._enc_type == 1: + await self._auth_type1_init() + else: + await self._auth_type7_init() + except Exception as exc: + self._log(1, "auth error: %s", exc) + await self.disconnect() + self._set_state(_State.ERROR) + return False + + return True + + async def wait_authenticated(self, timeout: float = 30.0) -> bool: + """Block until authentication completes or *timeout* elapses.""" + try: + await asyncio.wait_for(self._authenticated.wait(), timeout=timeout) + return True + except asyncio.TimeoutError: + return False + + async def disconnect(self) -> None: + """Stop notifications and close the BLE connection.""" + if self._auth_fallback_task is not None: + self._auth_fallback_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._auth_fallback_task + self._auth_fallback_task = None + self._authenticated.clear() + if self._client and self._client.is_connected: + with contextlib.suppress(Exception): + notify, _ = self._char_uuids() + await self._client.stop_notify(notify) + with contextlib.suppress(Exception): + await self._client.disconnect() + self._client = None + self._set_state(_State.DISCONNECTED) + + # ── Single persistent notify dispatcher ─────────────────────────────────── + + async def _notify_handler(self, _char, recv: bytearray) -> None: + """Route incoming BLE notifications to the correct auth-phase handler. + + One subscription remains active throughout authentication and data + collection. Frame buffers retain notifications split across BLE reads. + """ + self._log(2, "notify recv state=%s len=%d bytes=%s", + self._state, len(recv), bytes(recv).hex()) + try: + state = self._state + if state == _State.KEY_EXCHANGE: + await self._on_key_exchange(recv) + elif state == _State.SESSION_KEY: + await self._on_session_key(recv) + elif state in (_State.AUTH_STATUS, _State.AUTHENTICATING, _State.AUTHENTICATED): + await self._on_data(recv) + else: + self._log(2, "notify ignored in state %s", state) + except Exception as exc: + self._log(1, "notify handler error: %s", exc) + + # ── Type 1 auth ─────────────────────────────────────────────────────────── + + async def _auth_type1_init(self) -> None: + key = hashlib.md5(self._sn.encode()).digest() + iv = hashlib.md5(self._sn[::-1].encode()).digest() + self._enc = _Type1Encryption(key, iv) + self._assembler = _RawHeaderHelper(self._enc) + # Type 1: send probe and authenticate immediately without waiting for + # the probe response (mirrors ha-ef-ble's _type_1_session). + self._set_state(_State.AUTHENTICATING) + await self._do_auth_status() + await self._do_authenticate() + + # ── Type 7 auth (ECDH + session key) ───────────────────────────────────── + + async def _auth_type7_init(self) -> None: + import ecdsa + self._set_state(_State.KEY_EXCHANGE) + + self._priv_key = ecdsa.SigningKey.generate(curve=ecdsa.SECP160r1) + pub_key: ecdsa.VerifyingKey = self._priv_key.get_verifying_key() + + _, write = self._char_uuids() + to_send = _simple_encode(b"\x01\x00" + pub_key.to_string()) + await self._client.write_gatt_char(write, bytearray(to_send), response=False) + + async def _on_key_exchange(self, recv: bytearray) -> None: + # Re-check state; the check + set_state is synchronous (no await between + # them) so only the first of any concurrent calls proceeds. + if self._state != _State.KEY_EXCHANGE: + return + raw_payloads, self._simple_buf = _parse_enc_frame(bytes(recv), self._simple_buf) + for payload in raw_payloads: + if len(payload) < 3: + continue + self._set_state(_State.SESSION_KEY) # blocks re-entry before first await + await self._process_key_exchange(payload) + return + + async def _process_key_exchange(self, data: bytes) -> None: + import ecdsa + size = _ecdh_type_size(data[2]) + self._log(2, "key_exchange: payload=%s ecdh_type=%d size=%d", data.hex(), data[2], size) + dev_pub = ecdsa.VerifyingKey.from_string(data[3 : size + 3], curve=ecdsa.SECP160r1) + shared = ecdsa.ECDH(ecdsa.SECP160r1, self._priv_key, dev_pub).generate_sharedsecret_bytes() + iv = hashlib.md5(shared).digest() + self._enc = _Type7Encryption(shared[:16], iv) + + _, write = self._char_uuids() + to_send = _simple_encode(b"\x02") + self._log(2, "key_exchange: sending session key request: %s", to_send.hex()) + await self._client.write_gatt_char(write, bytearray(to_send), response=False) + + async def _on_session_key(self, recv: bytearray) -> None: + if self._state != _State.SESSION_KEY: + return + raw_payloads, self._simple_buf = _parse_enc_frame(bytes(recv), self._simple_buf) + for payload in raw_payloads: + if not payload or payload[0] != 0x02: + continue + self._set_state(_State.AUTH_STATUS) # blocks re-entry before first await + await self._process_session_key(payload[1:]) + return + + async def _process_session_key(self, encrypted_data: bytes) -> None: + self._log(2, "session_key: encrypted=%s", encrypted_data.hex()) + data = self._enc.decrypt(encrypted_data) + session_key = self._derive_session_key(data[16:18], data[:16]) + self._enc = _Type7Encryption(session_key, self._enc.iv) + self._assembler = _EncFrameHelper(self._enc) + await self._do_auth_status() + self._auth_fallback_task = asyncio.create_task(self._auth_type7_fallback()) + + async def _auth_type7_fallback(self) -> None: + """Send auth if the device doesn't respond to the probe within 1.5 seconds.""" + await asyncio.sleep(1.5) + if self._state == _State.AUTH_STATUS: + self._log(1, "auth_fallback: no probe response, sending auth directly") + self._set_state(_State.AUTHENTICATING) + await self._do_authenticate() + + def _derive_session_key(self, seed: bytes, srand: bytes) -> bytes: + if not _KEYDATA_AVAILABLE: + raise ImportError( + "keydata lookup table required for Type 7 auth. " + "Copy custom_components/ef_ble/eflib/keydata.py from " + "https://github.com/rabits/ha-ef-ble to bmspy/ecoflow_keydata.py" + ) + pos = seed[0] * 0x10 + ((seed[1] - 1) & 0xFF) * 0x100 + n0 = struct.unpack(" None: + """Send the auth-status probe (0x89).""" + pkt = EcoFlowPacket( + src=0x21, dst=self._AUTH_DST, + cmd_set=0x35, cmd_id=0x89, + payload=b"", version=self._pkt_version, + ) + self._log(2, "auth_status: sending probe (0x89) assembler=%s", + type(self._assembler).__name__ if self._assembler else "None") + await self._write_packet(pkt) + self._log(2, "auth_status: probe sent, wire bytes=%s", self._assembler.encode(pkt).hex() if self._assembler else pkt.to_bytes().hex()) + + async def _do_authenticate(self) -> None: + """Send the authentication packet (MD5 of user_id + serial).""" + md5 = hashlib.md5((self._user_id + self._sn).encode("ASCII")).digest() + payload = "".join(f"{b:02X}" for b in md5).encode("ASCII") + pkt = EcoFlowPacket( + src=0x21, dst=self._AUTH_DST, + cmd_set=0x35, cmd_id=0x86, + payload=payload, version=self._pkt_version, + ) + self._log(2, "authenticate: sending 0x86") + await self._write_packet(pkt) + + # ── Main data handler ───────────────────────────────────────────────────── + + async def _on_data(self, recv: bytearray) -> None: + """Handle all notifications after the session key is established. + + Authentication requires a valid success response, not an arbitrary + notification or the undocumented two-byte f001 response. Telemetry + packets are read only. They are not echoed as device commands. + """ + self._log(2, "on_data: raw=%s", bytes(recv).hex()) + try: + packets = self._decode(bytes(recv)) + except Exception as exc: + self._log(1, "decode error: %s", exc) + return + + self._log(2, "on_data: decoded %d packet(s)", len(packets)) + for pkt in packets: + if pkt.src == self._AUTH_DST and pkt.cmd_set == 0x35 and pkt.cmd_id == 0x89: + if self._state == _State.AUTH_STATUS: + self._set_state(_State.AUTHENTICATING) + await self._do_authenticate() + continue + if pkt.src == self._AUTH_DST and pkt.cmd_set == 0x35 and pkt.cmd_id == 0x86: + if pkt.payload != b"\x00": + self._log(1, "authentication rejected: %s", pkt.payload.hex()) + self._authenticated.clear() + self._set_state(_State.ERROR) + return + self._set_state(_State.AUTHENTICATED) + self._authenticated.set() + self._log(1, "authenticated") + else: + if self.is_authenticated: + self._record(pkt) + + def _decode(self, data: bytes) -> list[EcoFlowPacket]: + if self._assembler is None: + return [] + return self._assembler.decode(data) + + def _record(self, pkt: EcoFlowPacket) -> None: + self._pkt_count += 1 + self._snapshot.ef_packets_received = BMSScalarField( + help="Packets Received", + raw_value=self._pkt_count, + value=str(self._pkt_count), + ) + self._snapshot.record_packet(pkt.src, pkt.cmd_set, pkt.cmd_id, pkt.payload) + if self._measurement_decoder is not None: + self._measurement_decoder.update( + self._snapshot, pkt.src, pkt.cmd_set, pkt.cmd_id, pkt.payload, + ) + self._log(2, "pkt %s", pkt) + for listener in self._on_packet: + try: + listener(pkt) + except Exception: + pass + for cb in self._on_update: + try: + cb() + except Exception: + pass + + # ── Wire-level send ─────────────────────────────────────────────────────── + + async def _write_packet(self, pkt: EcoFlowPacket) -> None: + _, write = self._char_uuids() + data = self._assembler.encode(pkt) if self._assembler else pkt.to_bytes() + # The Android River 2 capture uses ATT Write Commands (no response). + await self._client.write_gatt_char(write, bytearray(data), response=False) + + # ── Helpers ─────────────────────────────────────────────────────────────── + + async def _start_notify(self, callback: Callable) -> None: + """Start BLE notifications, forcing StartNotify on BlueZ to avoid AcquireNotify.""" + notify, _ = self._char_uuids() + try: + await self._client.start_notify(notify, callback, bluez={"use_start_notify": True}) + except TypeError: + # bluez kwarg not supported on non-Linux backends + await self._client.start_notify(notify, callback) + + def _char_uuids(self) -> tuple[str, str]: + assert self._client is not None + for uuids in _BLE_UUIDS.values(): + notify = self._client.services.get_characteristic(uuids["notify"]) + write = self._client.services.get_characteristic(uuids["write"]) + if notify and write: + return uuids["notify"], uuids["write"] + raise RuntimeError(f"No supported BLE protocol on {self._address}") + + def _set_state(self, state: _State) -> None: + self._state = state + self._snapshot.ef_connection_state = BMSInfoField( + help="Connection", info=str(state) + ) + self._log(2, "state → %s", state) + + def _on_ble_disconnect(self, _client) -> None: + self._log(1, "disconnected") + self._set_state(_State.DISCONNECTED) + self._authenticated.clear() + + def _log(self, level: int, msg: str, *args) -> None: + if self._debug >= level: + debugger(f"ecoflow_bt [{self._address}]: " + (msg % args if args else msg)) + + +# ── Frame encode/decode helpers (thin wrappers keeping enc state) ───────────── + + +class _EncFrameHelper: + """Encode/decode EncPacket frames (Type 7, AES-CBC).""" + + def __init__(self, enc: _Type7Encryption) -> None: + self._enc = enc + self._buf = bytearray() + + def encode(self, pkt: EcoFlowPacket) -> bytes: + return _enc_packet_to_bytes( + _FRAME_TYPE_PROTOCOL, pkt.to_bytes(), + enc_key=self._enc.session_key, iv=self._enc.iv, + ) + + def decode(self, data: bytes) -> list[EcoFlowPacket]: + raw_list, self._buf = _parse_enc_frame(data, self._buf) + packets = [] + for raw in raw_list: + decrypted = self._enc.decrypt(raw) + pkt = EcoFlowPacket.from_bytes(decrypted) + if pkt is not None: + packets.append(pkt) + return packets + + +class _RawHeaderHelper: + """Encode/decode RawHeader frames (Type 1, AES-CBC).""" + + def __init__(self, enc: _Type1Encryption) -> None: + self._enc = enc + self._buf = bytearray() + + def encode(self, pkt: EcoFlowPacket) -> bytes: + raw = pkt.to_bytes() + return raw[:5] + self._enc.encrypt(raw[5:]) + + def decode(self, data: bytes) -> list[EcoFlowPacket]: + frames, self._buf = _parse_raw_header_frame(data, self._buf) + packets = [] + for frame in frames: + header = frame[:5] + decrypted = self._enc.decrypt(frame[5:]) + payload_length = struct.unpack("= 3 else 13 + inner_len = inner_overhead + payload_length + pkt = EcoFlowPacket.from_bytes(header + decrypted[:inner_len]) + if pkt is not None: + packets.append(pkt) + return packets + + +# ── Multi-device manager ────────────────────────────────────────────────────── + + +class EcoFlowBTManager: + """Manage BLE connections to one or more EcoFlow devices. + + Each device is connected and authenticated concurrently. Data arrives + via BLE notify callbacks and is stored in each device's snapshot. + """ + + _MAX_RECONNECT = 2 + + def __init__(self, user_id: str, debug: int = 0) -> None: + self._user_id = user_id + self._debug = debug + self._devices: dict[str, EcoFlowBTDevice] = {} + + def add_device( + self, + address: str, + serial_number: str, + encrypt_type: int = 7, + packet_version: int | None = None, + ) -> EcoFlowBTDevice: + """Register a device. Returns the EcoFlowBTDevice instance.""" + dev = EcoFlowBTDevice( + address=address, + serial_number=serial_number, + user_id=self._user_id, + encrypt_type=encrypt_type, + packet_version=packet_version, + debug=self._debug, + ) + self._devices[address] = dev + return dev + + async def connect_all(self, auth_timeout: float = 30.0) -> None: + """Connect and authenticate all registered devices concurrently.""" + await asyncio.gather( + *[ + self._connect_with_retry(dev, auth_timeout) + for dev in self._devices.values() + ], + return_exceptions=True, + ) + + async def _connect_with_retry( + self, dev: EcoFlowBTDevice, timeout: float + ) -> None: + for attempt in range(1, self._MAX_RECONNECT + 2): + ok = await dev.connect() + if ok and await dev.wait_authenticated(timeout): + return + await dev.disconnect() + if self._debug > 0: + debugger( + f"ecoflow_bt [{dev.address}]: " + f"attempt {attempt}/{self._MAX_RECONNECT + 1} failed" + ) + await asyncio.sleep(5 * attempt) + + async def disconnect_all(self) -> None: + """Disconnect from all devices.""" + await asyncio.gather( + *[dev.disconnect() for dev in self._devices.values()], + return_exceptions=True, + ) + + def snapshots(self) -> dict[str, EcoFlowSnapshot]: + """Return the latest snapshot for each device, keyed by BT address.""" + return {addr: dev.snapshot for addr, dev in self._devices.items()} + + +# ── BLE scanning ────────────────────────────────────────────────────────────── + + +@dataclass +class DiscoveredEcoFlowDevice: + address: str + serial_number: str + encrypt_type: int + rssi: int | None = None + + +async def scan_for_devices( + timeout: float = 10.0, + debug: int = 0, +) -> list[DiscoveredEcoFlowDevice]: + """Scan for EcoFlow BLE devices and return their addresses and serial numbers. + + *timeout* controls how long to scan (seconds). Devices broadcast + manufacturer data with key 0xB5B5; the serial number occupies bytes 1-17 + and the encryption type is encoded in byte 22 (capability_flags bits 3-5). + """ + from bleak import BleakScanner + + found: list[DiscoveredEcoFlowDevice] = [] + seen: set[str] = set() + + def _callback(ble_dev, adv_data) -> None: + man = adv_data.manufacturer_data + if MANUFACTURER_KEY not in man: + return + payload = man[MANUFACTURER_KEY] + if len(payload) < 17: + return + if ble_dev.address in seen: + return + seen.add(ble_dev.address) + sn = payload[1:17].strip(b"\x00").decode("ASCII", errors="replace") + cap = payload[22] if len(payload) > 22 else 0b0111000 + enc = (cap & 0b0111000) >> 3 + if debug > 0: + debugger(f"ecoflow_bt: found {ble_dev.address} sn={sn!r} enc_type={enc}") + found.append(DiscoveredEcoFlowDevice( + address=ble_dev.address, + serial_number=sn, + encrypt_type=enc, + rssi=getattr(adv_data, "rssi", None), + )) + + async with BleakScanner(detection_callback=_callback): + await asyncio.sleep(timeout) + + return found + + +# ── Synchronous interface (analogous to jbd_bms) ────────────────────────────── + + +@dataclass +class EcoFlowBTConfig: + """Device configuration passed to :func:`collect_data`. + + Analogous to ``serial.Serial`` in the JBD path — a lightweight descriptor + that :func:`initialise_device` returns and :func:`collect_data` consumes. + """ + + address: str + serial_number: str + user_id: str + encrypt_type: int = 7 + packet_version: int | None = None + listen_duration: float = 10.0 + + +def initialise_device( + address: str, + serial_number: str, + user_id: str, + encrypt_type: int = 7, + packet_version: int | None = None, + listen_duration: float = 10.0, +) -> EcoFlowBTConfig: + """Return a device config for use with :func:`collect_data`. + + Analogous to :func:`bmspy.jbd_bms.initialise_serial`. No connection is + opened here; the connection is made (and closed) inside each + :func:`collect_data` call. + """ + return EcoFlowBTConfig( + address=address, + serial_number=serial_number, + user_id=user_id, + encrypt_type=encrypt_type, + packet_version=packet_version, + listen_duration=listen_duration, + ) + + +def collect_data(cfg: EcoFlowBTConfig, debug: int = 0) -> EcoFlowSnapshot | bool: + """Connect to an EcoFlow device, collect push data, disconnect, return snapshot. + + Analogous to :func:`bmspy.jbd_bms.collect_data`. Each call opens a fresh + BLE connection, authenticates, waits *listen_duration* seconds for the + device to push telemetry via notifications, then disconnects. + + Returns the populated :class:`EcoFlowSnapshot`, or ``False`` on failure. + """ + async def _run() -> EcoFlowSnapshot | bool: + device = EcoFlowBTDevice( + address=cfg.address, + serial_number=cfg.serial_number, + user_id=cfg.user_id, + encrypt_type=cfg.encrypt_type, + packet_version=cfg.packet_version, + debug=debug, + ) + try: + if not await device.connect(): + return False + if not await device.wait_authenticated(timeout=30.0): + return False + await asyncio.sleep(cfg.listen_duration) + # Metadata alone is not a successful telemetry collection. + return device.snapshot if device._pkt_count else False + finally: + await device.disconnect() + + try: + return asyncio.run(_run()) + except Exception as exc: + if debug > 0: + debugger(f"ecoflow_bt [{cfg.address}]: collect_data error: {exc}") + return False diff --git a/bmspy/ecoflow_login.py b/bmspy/ecoflow_login.py new file mode 100644 index 0000000..c356868 --- /dev/null +++ b/bmspy/ecoflow_login.py @@ -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() diff --git a/bmspy/ecoflow_proto.py b/bmspy/ecoflow_proto.py new file mode 100644 index 0000000..7f87113 --- /dev/null +++ b/bmspy/ecoflow_proto.py @@ -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(" 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" ") + 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" ") + 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 diff --git a/bmspy/ecoflow_river2.py b/bmspy/ecoflow_river2.py new file mode 100644 index 0000000..0fa40a5 --- /dev/null +++ b/bmspy/ecoflow_river2.py @@ -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) diff --git a/bmspy/ecoflow_snapshot.py b/bmspy/ecoflow_snapshot.py new file mode 100644 index 0000000..10dbf89 --- /dev/null +++ b/bmspy/ecoflow_snapshot.py @@ -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___``. + 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() diff --git a/bmspy/prometheus.py b/bmspy/prometheus.py index 3c0f3af..4de12d3 100644 --- a/bmspy/prometheus.py +++ b/bmspy/prometheus.py @@ -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: diff --git a/docs/ecoflow-river2-fields.md b/docs/ecoflow-river2-fields.md new file mode 100644 index 0000000..47dd2f0 --- /dev/null +++ b/docs/ecoflow-river2-fields.md @@ -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 +`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 +`decode_error`. The next accepted report removes that error. +The `ef_pkt___` 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___`. +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. diff --git a/poetry.lock b/poetry.lock index 2c8e11c..ec0ce15 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 0dd1da5..3f97c5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,10 @@ description = "bmspy is a tool to get information from a xiaoxiang-type BMS syst authors = ["Timothy Allen "] 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" diff --git a/tests/test_ecoflow_bt.py b/tests/test_ecoflow_bt.py new file mode 100644 index 0000000..683aa10 --- /dev/null +++ b/tests/test_ecoflow_bt.py @@ -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(" --scan + poetry run python tools/ecoflow-bt.py --email you@example.com --scan + poetry run python tools/ecoflow-bt.py --user-id --device
--serial-number +""" + +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()