Files
homeassistant_config/config/custom_components/ecoflow_cloud/mqtt/ecoflow_mqtt.py
2024-05-31 13:07:35 +02:00

341 lines
13 KiB
Python

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", "<no user 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()