chore: initial baseline before refactoring
Initial snapshot of Home Assistant config (HAOS 2026.4.4)
prior to dashboard restructuring and helper YAML migration.
Pre-commit cleanup applied:
- Removed blueprints/switch_manager/ (9.4 MB stale, component already gone)
- Removed .storage/switch_manager
- Removed home-assistant.log.{1,old,fault}
- Removed zigbee2mqtt/configuration_backup_v{1..4}.yaml
- Removed zigbee2mqtt/database.db.tmp.2024-09-27 (1.5y old)
- Created empty themes/ to satisfy configuration.yaml include
.gitignore uses allowlist strategy for .storage/ to keep
all tokens, keys, and PINs out of version control.
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
"""ecoflow.py: API for PowerOcean integration."""
|
||||
|
||||
import base64
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from homeassistant.exceptions import IntegrationError
|
||||
from homeassistant.util.json import json_loads
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from .const import _LOGGER, ISSUE_URL_ERROR_MESSAGE, DOMAIN
|
||||
|
||||
# Mock path to response.json file
|
||||
mocked_response = Path("documentation/response.json")
|
||||
# Dict with entity names to use
|
||||
SENSSELECT = Path(f"custom_components/{DOMAIN}/datapoints.json")
|
||||
|
||||
# Better storage of PowerOcean endpoint
|
||||
PowerOceanEndPoint = namedtuple(
|
||||
"PowerOceanEndPoint",
|
||||
"internal_unique_id, serial, name, friendly_name, value, unit, description, icon",
|
||||
)
|
||||
|
||||
|
||||
# ecoflow_api to detect device and get device info, fetch the actual data from the PowerOcean device, and parse it
|
||||
# Rename, there is an official API since june
|
||||
class Ecoflow:
|
||||
"""Class representing Ecoflow"""
|
||||
|
||||
def __init__(self, serialnumber: str, username: str, password: str):
|
||||
self.sn = serialnumber
|
||||
self.unique_id = serialnumber
|
||||
self.ecoflow_username = username
|
||||
self.ecoflow_password = password
|
||||
self.token = None
|
||||
self.device = None
|
||||
self.session = requests.Session()
|
||||
self.url_iot_app = "https://api.ecoflow.com/auth/login"
|
||||
self.url_user_fetch = f"https://api-e.ecoflow.com/provider-service/user/device/detail?sn={self.sn}"
|
||||
|
||||
def get_device(self):
|
||||
"""Get device info."""
|
||||
self.device = {
|
||||
"product": "PowerOcean",
|
||||
"vendor": "Ecoflow",
|
||||
"serial": self.sn,
|
||||
"version": "5.1.25", # TODO: woher bekommt man diese Info?
|
||||
"build": "8", # TODO: wo finde ich das?
|
||||
"name": "PowerOcean",
|
||||
"features": "Photovoltaik",
|
||||
}
|
||||
|
||||
return self.device
|
||||
|
||||
def authorize(self):
|
||||
"""Function authorize"""
|
||||
auth_ok = False # default
|
||||
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",
|
||||
}
|
||||
|
||||
try:
|
||||
url = self.url_iot_app
|
||||
_LOGGER.info("Login to EcoFlow API %s", {url})
|
||||
request = requests.post(url, json=data, headers=headers, timeout=10)
|
||||
response = self.get_json_response(request)
|
||||
|
||||
except ConnectionError:
|
||||
error = f"Unable to connect to {self.url_iot_app}. Device might be offline."
|
||||
_LOGGER.warning(error + ISSUE_URL_ERROR_MESSAGE)
|
||||
raise IntegrationError(error)
|
||||
|
||||
try:
|
||||
self.token = response["data"]["token"]
|
||||
# self.user_id = response["data"]["user"]["userId"]
|
||||
# user_name = response["data"]["user"].get("name", "<no user name>")
|
||||
auth_ok = True
|
||||
except KeyError as key:
|
||||
raise Exception(f"Failed to extract key {key} from response: {response}")
|
||||
|
||||
_LOGGER.info("Successfully logged in.")
|
||||
|
||||
self.get_device() # collect device info
|
||||
|
||||
return auth_ok
|
||||
|
||||
def get_json_response(self, request):
|
||||
"""Function get json response"""
|
||||
if request.status_code != 200:
|
||||
raise Exception(
|
||||
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 Exception(
|
||||
f"Failed to extract key {key} from {json_loads(request.text)}"
|
||||
)
|
||||
except Exception as error:
|
||||
raise Exception(f"Failed to parse response: {request.text} Error: {error}")
|
||||
|
||||
if response_message.lower() != "success":
|
||||
raise Exception(f"{response_message}")
|
||||
|
||||
return response
|
||||
|
||||
# Fetch the data from the PowerOcean device, which then constitues the Sensors
|
||||
def fetch_data(self):
|
||||
"""Fetch data from Url."""
|
||||
url = self.url_user_fetch
|
||||
try:
|
||||
headers = {
|
||||
"authorization": f"Bearer {self.token}",
|
||||
# "accept": "application/json, text/plain, */*",
|
||||
"user-agent": "Firefox/133.0",
|
||||
# "platform": "web",
|
||||
"product-type": "83",
|
||||
}
|
||||
request = requests.get(self.url_user_fetch, headers=headers, timeout=30)
|
||||
response = self.get_json_response(request)
|
||||
|
||||
try:
|
||||
# TESTING!!! create response file and use it as data
|
||||
with Path.open(mocked_response, "r", encoding="utf-8") as datei:
|
||||
response = json_loads(datei.read())
|
||||
except FileExistsError:
|
||||
error = f"Data from: {mocked_response}"
|
||||
_LOGGER.warning(error)
|
||||
except FileNotFoundError:
|
||||
error = f"Responsefile not present: {mocked_response}"
|
||||
_LOGGER.debug(error)
|
||||
|
||||
_LOGGER.debug(f"{response}")
|
||||
return self._get_sensors(response)
|
||||
|
||||
except ConnectionError:
|
||||
error = f"ConnectionError in fetch_data: Unable to connect to {url}. Device might be offline."
|
||||
_LOGGER.warning(error + ISSUE_URL_ERROR_MESSAGE)
|
||||
raise IntegrationError(error)
|
||||
|
||||
except RequestException as e:
|
||||
error = f"RequestException in fetch_data: Error while fetching data from {url}: {e}"
|
||||
_LOGGER.warning(error + ISSUE_URL_ERROR_MESSAGE)
|
||||
raise IntegrationError(error)
|
||||
|
||||
def __get_unit(self, key):
|
||||
"""Get unit from key name."""
|
||||
if key.endswith(("pwr", "Pwr", "Power")):
|
||||
unit = "W"
|
||||
elif key.endswith(("amp", "Amp")):
|
||||
unit = "A"
|
||||
elif key.endswith(("soc", "Soc", "soh", "Soh")):
|
||||
unit = "%"
|
||||
elif key.endswith(("vol", "Vol")):
|
||||
unit = "V"
|
||||
elif key.endswith(("Watth", "Energy")):
|
||||
unit = "Wh"
|
||||
elif "Generation" in key:
|
||||
unit = "kWh"
|
||||
elif key.startswith("bpTemp"):
|
||||
unit = "°C"
|
||||
else:
|
||||
unit = None
|
||||
|
||||
return unit
|
||||
|
||||
def __get_description(self, key):
|
||||
# TODO: hier könnte man noch mehr definieren bzw ein translation dict erstellen +1
|
||||
# Comment: Ich glaube hier brauchen wir n
|
||||
description = key # default description
|
||||
if key == "sysLoadPwr":
|
||||
description = "Hausnetz"
|
||||
if key == "sysGridPwr":
|
||||
description = "Stromnetz"
|
||||
if key == "mpptPwr":
|
||||
description = "Solarertrag"
|
||||
if key == "bpPwr":
|
||||
description = "Batterieleistung"
|
||||
if key == "bpSoc":
|
||||
description = "Ladezustand der Batterie"
|
||||
if key == "online":
|
||||
description = "Online"
|
||||
if key == "systemName":
|
||||
description = "System Name"
|
||||
if key == "createTime":
|
||||
description = "Installations Datum"
|
||||
# Battery descriptions
|
||||
if key == "bpVol":
|
||||
description = "Batteriespannung"
|
||||
if key == "bpAmp":
|
||||
description = "Batteriestrom"
|
||||
if key == "bpCycles":
|
||||
description = "Ladezyklen"
|
||||
if key == "bpTemp":
|
||||
description = "Temperatur der Batteriezellen"
|
||||
|
||||
return description
|
||||
|
||||
def __get_sens_select(self, report):
|
||||
# open File an read
|
||||
with Path.open(SENSSELECT) as file:
|
||||
config = json_loads(file.read())
|
||||
|
||||
return config[report]
|
||||
|
||||
def _get_sensors(self, response):
|
||||
# get sensors from response['data']
|
||||
sensors = self.__get_sensors_data(response)
|
||||
|
||||
# get sensors from 'JTS1_ENERGY_STREAM_REPORT'
|
||||
sensors = self.__get_sensors_energy_stream(response, sensors)
|
||||
|
||||
# get sensors from 'JTS1_EMS_CHANGE_REPORT'
|
||||
sensors = self.__get_sensors_ems_change(response, sensors)
|
||||
|
||||
# get info from batteries => JTS1_BP_STA_REPORT
|
||||
sensors = self.__get_sensors_battery(response, sensors)
|
||||
|
||||
# get info from PV strings => JTS1_EMS_HEARTBEAT
|
||||
sensors = self.__get_sensors_ems_heartbeat(response, sensors)
|
||||
|
||||
# get info from "JTS1_EVCHARGING_REPORT"
|
||||
sensors = self.__get_sensors_evcharging_report(response, sensors)
|
||||
|
||||
return sensors
|
||||
|
||||
def __get_sensors_data(self, response):
|
||||
d = response["data"].copy()
|
||||
|
||||
sens_select = self.__get_sens_select("data")
|
||||
|
||||
sensors = dict() # start with empty dict
|
||||
for key, value in d.items():
|
||||
if key in sens_select: # use only sensors in sens_select
|
||||
if not isinstance(value, dict):
|
||||
# default uid, unit and descript
|
||||
unique_id = f"{self.sn}_{key}"
|
||||
special_icon = None
|
||||
if key == "mpptPwr":
|
||||
special_icon = "mdi:solar-power"
|
||||
if key == "online":
|
||||
special_icon = "mdi:cloud-check"
|
||||
if key == "sysGridPwr":
|
||||
special_icon = "mdi:transmission-tower-import"
|
||||
if key == "sysLoadPwr":
|
||||
special_icon = "mdi:home-import-outline"
|
||||
|
||||
sensors[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{key}",
|
||||
friendly_name=key,
|
||||
value=value,
|
||||
unit=self.__get_unit(key),
|
||||
description=self.__get_description(key),
|
||||
icon=special_icon,
|
||||
)
|
||||
|
||||
return sensors
|
||||
|
||||
def __get_sensors_evcharging_report(self, response, sensors):
|
||||
report = "JTS1_EVCHARGING_REPORT"
|
||||
#_LOGGER.warning(f"{report=}")
|
||||
d = response["data"]["quota"][report]
|
||||
sens_select = self.__get_sens_select(report)
|
||||
|
||||
data = {}
|
||||
for key, value in d.items():
|
||||
if key in sens_select:
|
||||
# default uid, unit and descript
|
||||
unique_id = f"{self.sn}_{report}_{key}"
|
||||
description_tmp = self.__get_description(key)
|
||||
data[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{key}",
|
||||
friendly_name=key,
|
||||
value=value,
|
||||
unit=self.__get_unit(key),
|
||||
description=description_tmp,
|
||||
icon=None,
|
||||
)
|
||||
|
||||
dict.update(sensors, data)
|
||||
|
||||
return sensors
|
||||
|
||||
def __get_sensors_energy_stream(self, response, sensors):
|
||||
report = "JTS1_ENERGY_STREAM_REPORT"
|
||||
d = response["data"]["quota"][report]
|
||||
sens_select = self.__get_sens_select(report)
|
||||
|
||||
data = {}
|
||||
for key, value in d.items():
|
||||
if key in sens_select:
|
||||
# default uid, unit and descript
|
||||
unique_id = f"{self.sn}_{report}_{key}"
|
||||
special_icon = None
|
||||
if key.startswith(("pv1", "pv2")):
|
||||
special_icon = "mdi:solar-power"
|
||||
description_tmp = self.__get_description(key)
|
||||
data[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{key}",
|
||||
friendly_name=key,
|
||||
value=value,
|
||||
unit=self.__get_unit(key),
|
||||
description=description_tmp,
|
||||
icon=special_icon,
|
||||
)
|
||||
|
||||
dict.update(sensors, data)
|
||||
|
||||
return sensors
|
||||
|
||||
def __get_sensors_ems_change(self, response, sensors):
|
||||
report = "JTS1_EMS_CHANGE_REPORT"
|
||||
d = response["data"]["quota"][report]
|
||||
|
||||
sens_select = self.__get_sens_select(report)
|
||||
|
||||
# add mppt Warning/Fault Codes
|
||||
keys = d.keys()
|
||||
r = re.compile("mppt.*Code")
|
||||
wfc = list(filter(r.match, keys)) # warning/fault code keys
|
||||
sens_select += wfc
|
||||
|
||||
data = {}
|
||||
for key, value in d.items():
|
||||
if key in sens_select: # use only sensors in sens_select
|
||||
# default uid, unit and descript
|
||||
unique_id = f"{self.sn}_{report}_{key}"
|
||||
|
||||
data[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{key}",
|
||||
friendly_name=key,
|
||||
value=value,
|
||||
unit=self.__get_unit(key),
|
||||
description=self.__get_description(key),
|
||||
icon=None,
|
||||
)
|
||||
dict.update(sensors, data)
|
||||
|
||||
return sensors
|
||||
|
||||
def __get_sensors_battery(self, response, sensors):
|
||||
report = "JTS1_BP_STA_REPORT"
|
||||
d = response["data"]["quota"][report]
|
||||
keys = list(d.keys())
|
||||
|
||||
# loop over N batteries:
|
||||
batts = [s for s in keys if len(s) > 12]
|
||||
bat_sens_select = self.__get_sens_select(report)
|
||||
|
||||
data = {}
|
||||
prefix = "bpack"
|
||||
for ibat, bat in enumerate(batts):
|
||||
name = prefix + "%i_" % (ibat + 1)
|
||||
d_bat = json_loads(d[bat])
|
||||
for key, value in d_bat.items():
|
||||
if key in bat_sens_select:
|
||||
# default uid, unit and descript
|
||||
unique_id = f"{self.sn}_{report}_{bat}_{key}"
|
||||
description_tmp = f"{name}" + self.__get_description(key)
|
||||
special_icon = None
|
||||
if key == "bpAmp":
|
||||
special_icon = "mdi:current-dc"
|
||||
data[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{name + key}",
|
||||
friendly_name=name + key,
|
||||
value=value,
|
||||
unit=self.__get_unit(key),
|
||||
description=description_tmp,
|
||||
icon=special_icon,
|
||||
)
|
||||
# compute mean temperature of cells
|
||||
key = "bpTemp"
|
||||
temp = d_bat[key]
|
||||
value = sum(temp) / len(temp)
|
||||
unique_id = f"{self.sn}_{report}_{bat}_{key}"
|
||||
description_tmp = f"{name}" + self.__get_description(key)
|
||||
data[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{name + key}",
|
||||
friendly_name=name + key,
|
||||
value=value,
|
||||
unit=self.__get_unit(key),
|
||||
description=description_tmp,
|
||||
icon=None,
|
||||
)
|
||||
|
||||
dict.update(sensors, data)
|
||||
|
||||
return sensors
|
||||
|
||||
def __get_sensors_ems_heartbeat(self, response, sensors):
|
||||
report = "JTS1_EMS_HEARTBEAT"
|
||||
d = response["data"]["quota"][report]
|
||||
sens_select = self.__get_sens_select(report)
|
||||
|
||||
data = {}
|
||||
for key, value in d.items():
|
||||
if key in sens_select:
|
||||
# default uid, unit and descript
|
||||
unique_id = f"{self.sn}_{report}_{key}"
|
||||
description_tmp = self.__get_description(key)
|
||||
data[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{key}",
|
||||
friendly_name=key,
|
||||
value=value,
|
||||
unit=self.__get_unit(key),
|
||||
description=description_tmp,
|
||||
icon=None,
|
||||
)
|
||||
|
||||
# special for phases
|
||||
phases = ["pcsAPhase", "pcsBPhase", "pcsCPhase"]
|
||||
if phases[1] in d:
|
||||
for i, phase in enumerate(phases):
|
||||
for key, value in d[phase].items():
|
||||
name = phase + "_" + key
|
||||
unique_id = f"{self.sn}_{report}_{name}"
|
||||
|
||||
data[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{name}",
|
||||
friendly_name=f"{name}",
|
||||
value=value,
|
||||
unit=self.__get_unit(key),
|
||||
description=self.__get_description(key),
|
||||
icon=None,
|
||||
)
|
||||
|
||||
# special for mpptPv
|
||||
if "mpptHeartBeat" in d:
|
||||
n_strings = len(d["mpptHeartBeat"][0]["mpptPv"]) # TODO: auch als Sensor?
|
||||
mpptpvs = []
|
||||
for i in range(1, n_strings + 1):
|
||||
mpptpvs.append(f"mpptPv{i}")
|
||||
mpptPv_sum = 0.0
|
||||
for i, mpptpv in enumerate(mpptpvs):
|
||||
for key, value in d["mpptHeartBeat"][0]["mpptPv"][i].items():
|
||||
unique_id = f"{self.sn}_{report}_mpptHeartBeat_{mpptpv}_{key}"
|
||||
special_icon = None
|
||||
if key.endswith("amp"):
|
||||
special_icon = "mdi:current-dc"
|
||||
if key.endswith("pwr"):
|
||||
special_icon = "mdi:solar-power"
|
||||
|
||||
data[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{mpptpv}_{key}",
|
||||
friendly_name=f"{mpptpv}_{key}",
|
||||
value=value,
|
||||
unit=self.__get_unit(key),
|
||||
description=self.__get_description(key),
|
||||
icon=special_icon,
|
||||
)
|
||||
# sum power of all strings
|
||||
if key == "pwr":
|
||||
mpptPv_sum += value
|
||||
|
||||
# create total power sensor of all strings
|
||||
name = "mpptPv_pwrTotal"
|
||||
unique_id = f"{self.sn}_{report}_mpptHeartBeat_{name}"
|
||||
|
||||
data[unique_id] = PowerOceanEndPoint(
|
||||
internal_unique_id=unique_id,
|
||||
serial=self.sn,
|
||||
name=f"{self.sn}_{name}",
|
||||
friendly_name=f"{name}",
|
||||
value=mpptPv_sum,
|
||||
unit=self.__get_unit(key),
|
||||
description="Solarertrag aller Strings",
|
||||
icon="mdi:solar-power",
|
||||
)
|
||||
|
||||
dict.update(sensors, data)
|
||||
|
||||
return sensors
|
||||
|
||||
|
||||
class AuthenticationFailed(Exception):
|
||||
"""Exception to indicate authentication failure."""
|
||||
Reference in New Issue
Block a user