import base64 import json import logging import random import ssl import time import uuid from datetime import datetime from typing import Any import paho.mqtt.client as mqtt_client import requests from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, DOMAIN from homeassistant.helpers.entity import DeviceInfo from homeassistant.util import utcnow from reactivex import Subject, Observable from .proto import powerstream_pb2 as powerstream, ecopacket_pb2 as ecopacket from .utils import BoundFifoList from ..config.const import CONF_DEVICE_TYPE, CONF_DEVICE_ID, OPTS_REFRESH_PERIOD_SEC, EcoflowModel _LOGGER = logging.getLogger(__name__) class EcoflowException(Exception): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) class EcoflowAuthentication: def __init__(self, ecoflow_username, ecoflow_password): self.ecoflow_username = ecoflow_username self.ecoflow_password = ecoflow_password self.user_id = None self.token = None self.mqtt_url = "mqtt.mqtt.com" self.mqtt_port = 8883 self.mqtt_username = None self.mqtt_password = None def authorize(self): url = "https://api.ecoflow.com/auth/login" headers = {"lang": "en_US", "content-type": "application/json"} data = {"email": self.ecoflow_username, "password": base64.b64encode(self.ecoflow_password.encode()).decode(), "scene": "IOT_APP", "userType": "ECOFLOW"} _LOGGER.info(f"Login to EcoFlow API {url}") request = requests.post(url, json=data, headers=headers) response = self.get_json_response(request) try: self.token = response["data"]["token"] self.user_id = response["data"]["user"]["userId"] user_name = response["data"]["user"].get("name", "") except KeyError as key: raise EcoflowException(f"Failed to extract key {key} from response: {response}") _LOGGER.info(f"Successfully logged in: {user_name}") url = "https://api.ecoflow.com/iot-auth/app/certification" headers = {"lang": "en_US", "authorization": f"Bearer {self.token}"} data = {"userId": self.user_id} _LOGGER.info(f"Requesting IoT MQTT credentials {url}") request = requests.get(url, data=data, headers=headers) response = self.get_json_response(request) try: self.mqtt_url = response["data"]["url"] self.mqtt_port = int(response["data"]["port"]) self.mqtt_username = response["data"]["certificateAccount"] self.mqtt_password = response["data"]["certificatePassword"] except KeyError as key: raise EcoflowException(f"Failed to extract key {key} from {response}") _LOGGER.info(f"Successfully extracted account: {self.mqtt_username}") def get_json_response(self, request): if request.status_code != 200: raise EcoflowException(f"Got HTTP status code {request.status_code}: {request.text}") try: response = json.loads(request.text) response_message = response["message"] except KeyError as key: raise EcoflowException(f"Failed to extract key {key} from {response}") except Exception as error: raise EcoflowException(f"Failed to parse response: {request.text} Error: {error}") if response_message.lower() != "success": raise EcoflowException(f"{response_message}") return response class EcoflowDataHolder: def __init__(self, update_period_sec: int, collect_raw: bool = False): self.__update_period_sec = update_period_sec self.__collect_raw = collect_raw self.set = BoundFifoList[dict[str, Any]]() self.set_reply = BoundFifoList[dict[str, Any]]() self.get = BoundFifoList[dict[str, Any]]() self.get_reply = BoundFifoList[dict[str, Any]]() self.params = dict[str, Any]() self.raw_data = BoundFifoList[dict[str, Any]]() self.__params_time = utcnow().replace(year=2000, month=1, day=1, hour=0, minute=0, second=0) self.__params_broadcast_time = utcnow().replace(year=2000, month=1, day=1, hour=0, minute=0, second=0) self.__params_observable = Subject[dict[str, Any]]() self.__set_reply_observable = Subject[list[dict[str, Any]]]() self.__get_reply_observable = Subject[list[dict[str, Any]]]() def params_observable(self) -> Observable[dict[str, Any]]: return self.__params_observable def get_reply_observable(self) -> Observable[list[dict[str, Any]]]: return self.__get_reply_observable def set_reply_observable(self) -> Observable[list[dict[str, Any]]]: return self.__set_reply_observable def add_set_message(self, msg: dict[str, Any]): self.set.append(msg) def add_set_reply_message(self, msg: dict[str, Any]): self.set_reply.append(msg) self.__set_reply_observable.on_next(self.set_reply) def add_get_message(self, msg: dict[str, Any]): self.get.append(msg) def add_get_reply_message(self, msg: dict[str, Any]): self.get_reply.append(msg) self.__get_reply_observable.on_next(self.get_reply) def update_to_target_state(self, target_state: dict[str, Any]): self.params.update(target_state) self.__broadcast() def update_data(self, raw: dict[str, Any]): self.__add_raw_data(raw) # self.__params_time = datetime.fromtimestamp(raw['timestamp'], UTC) self.__params_time = utcnow() self.params['timestamp'] = raw['timestamp'] self.params.update(raw['params']) if (utcnow() - self.__params_broadcast_time).total_seconds() > self.__update_period_sec: self.__broadcast() def __broadcast(self): self.__params_broadcast_time = utcnow() self.__params_observable.on_next(self.params) def __add_raw_data(self, raw: dict[str, Any]): if self.__collect_raw: self.raw_data.append(raw) def params_time(self) -> datetime: return self.__params_time class EcoflowMQTTClient: def __init__(self, hass: HomeAssistant, entry: ConfigEntry, auth: EcoflowAuthentication): self.auth = auth self.config_entry = entry self.device_type = entry.data[CONF_DEVICE_TYPE] self.device_sn = entry.data[CONF_DEVICE_ID] self._data_topic = f"/app/device/property/{self.device_sn}" self._set_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/set" self._set_reply_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/set_reply" self._get_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/get" self._get_reply_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/get_reply" self.data = EcoflowDataHolder(entry.options.get(OPTS_REFRESH_PERIOD_SEC), self.device_type == "DIAGNOSTIC") self.device_info_main = DeviceInfo( identifiers={(DOMAIN, self.device_sn)}, manufacturer="EcoFlow", name=entry.title, model=self.device_type, ) self.client = mqtt_client.Client(client_id=f'ANDROID_-{str(uuid.uuid4()).upper()}_{auth.user_id}', clean_session=True, reconnect_on_failure=True) self.client.username_pw_set(self.auth.mqtt_username, self.auth.mqtt_password) self.client.tls_set(certfile=None, keyfile=None, cert_reqs=ssl.CERT_REQUIRED) self.client.tls_insecure_set(False) self.client.on_connect = self.on_connect self.client.on_disconnect = self.on_disconnect if self.device_type == EcoflowModel.POWERSTREAM.name: self.client.on_message = self.on_bytes_message else: self.client.on_message = self.on_json_message _LOGGER.info(f"Connecting to MQTT Broker {self.auth.mqtt_url}:{self.auth.mqtt_port}") self.client.connect(self.auth.mqtt_url, self.auth.mqtt_port, 30) self.client.loop_start() def is_connected(self): return self.client.is_connected() def reconnect(self) -> bool: try: _LOGGER.info(f"Re-connecting to MQTT Broker {self.auth.mqtt_url}:{self.auth.mqtt_port}") self.client.loop_stop(True) self.client.reconnect() self.client.loop_start() return True except Exception as e: _LOGGER.error(e) return False def on_connect(self, client, userdata, flags, rc): match rc: case 0: self.client.subscribe([(self._data_topic, 1), (self._set_topic, 1), (self._set_reply_topic, 1), (self._get_topic, 1), (self._get_reply_topic, 1)]) _LOGGER.info(f"Subscribed to MQTT topic {self._data_topic}") case -1: _LOGGER.error("Failed to connect to MQTT: connection timed out") case 1: _LOGGER.error("Failed to connect to MQTT: incorrect protocol version") case 2: _LOGGER.error("Failed to connect to MQTT: invalid client identifier") case 3: _LOGGER.error("Failed to connect to MQTT: server unavailable") case 4: _LOGGER.error("Failed to connect to MQTT: bad username or password") case 5: _LOGGER.error("Failed to connect to MQTT: not authorised") case _: _LOGGER.error(f"Failed to connect to MQTT: another error occured: {rc}") return client def on_disconnect(self, client, userdata, rc): if rc != 0: _LOGGER.error(f"Unexpected MQTT disconnection: {rc}. Will auto-reconnect") time.sleep(5) # self.client.reconnect() ?? def on_json_message(self, client, userdata, message): try: payload = message.payload.decode("utf-8", errors='ignore') raw = json.loads(payload) if message.topic == self._data_topic: self.data.update_data(raw) elif message.topic == self._set_topic: self.data.add_set_message(raw) elif message.topic == self._set_reply_topic: self.data.add_set_reply_message(raw) elif message.topic == self._get_topic: self.data.add_get_message(raw) elif message.topic == self._get_reply_topic: self.data.add_get_reply_message(raw) except UnicodeDecodeError as error: _LOGGER.error(f"UnicodeDecodeError: {error}. Ignoring message and waiting for the next one.") def on_bytes_message(self, client, userdata, message): try: payload = message.payload while True: packet = ecopacket.SendHeaderMsg() packet.ParseFromString(payload) _LOGGER.debug("cmd id %u payload \"%s\"", packet.msg.cmd_id, payload.hex()) if packet.msg.cmd_id != 1: _LOGGER.info("Unsupported EcoPacket cmd id %u", packet.msg.cmd_id) else: heartbeat = powerstream.InverterHeartbeat() heartbeat.ParseFromString(packet.msg.pdata) raw = {"params": {}} for descriptor in heartbeat.DESCRIPTOR.fields: if not heartbeat.HasField(descriptor.name): continue raw["params"][descriptor.name] = getattr(heartbeat, descriptor.name) _LOGGER.info("Found %u fields", len(raw["params"])) raw["timestamp"] = utcnow() self.data.update_data(raw) if packet.ByteSize() >= len(payload): break _LOGGER.info("Found another frame in payload") packetLength = len(payload) - packet.ByteSize() payload = payload[:packetLength] except Exception as error: _LOGGER.error(error) _LOGGER.info(message.payload.hex()) message_id = 999900000 + random.randint(10000, 99999) def __prepare_payload(self, command: dict): self.message_id += 1 payload = {"from": "HomeAssistant", "id": f"{self.message_id}", "version": "1.0"} payload.update(command) return payload def __send(self, topic: str, message: str): try: info = self.client.publish(topic, message, 1) _LOGGER.debug("Sending " + message + " :" + str(info) + "(" + str(info.is_published()) + ")") except RuntimeError as error: _LOGGER.error(error) def send_get_message(self, command: dict): payload = self.__prepare_payload(command) self.__send(self._get_topic, json.dumps(payload)) def send_set_message(self, mqtt_state: dict[str, Any], command: dict): self.data.update_to_target_state(mqtt_state) payload = self.__prepare_payload(command) self.__send(self._set_topic, json.dumps(payload)) def stop(self): self.client.loop_stop() self.client.disconnect()