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()