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:
2026-05-02 14:24:53 +02:00
commit 5f01411780
149 changed files with 47886 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
"""__init__.py: The PowerOcean integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, PLATFORMS, _LOGGER, DOMAIN, ISSUE_URL_ERROR_MESSAGE, STARTUP_MESSAGE
from .ecoflow import Ecoflow
_LOGGER.info(STARTUP_MESSAGE)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up PowerOcean from a config entry."""
# Setup DOMAIN as default
hass.data.setdefault(DOMAIN, {})
# Setup device specific sensor list (used in updates) on HASS so it is available within integration (reuqired for unload)
hass.data[DOMAIN]["device_specific_sensors"] = {}
# Store an instance of the API instance in hass.data[domain]
user_input = entry.data["user_input"] # This user_input object was stored after the device
# was setup and has the user/pass/serial info
device_info = entry.data.get("device_info") # This device_info object was stored after the device
# was setup and has the name and serial needed etc.
options = entry.data["options"] # These are the options during setup, including custom device name
ecoflow = Ecoflow(user_input["serialnumber"], user_input["username"], user_input["password"])
if device_info:
ecoflow.device = device_info # Store the device information
ecoflow.options = options # Store the options
hass.data[DOMAIN][entry.entry_id] = ecoflow
# Forward to sensor platform
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Get the device registry
device_registry = dr.async_get(hass)
# Fetching device info from device registry
# During the config flow, the device info is saved in the entry's data under 'device_info' key.
device_info = entry.data.get("device_info")
# If the device_info was provided, register the device
if device_info:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, device_info["serial"])},
manufacturer=device_info.get("vendor", "ECOFLOW"),
serial_number=device_info.get("serial"),
name=options.get("custom_device_name"), # Custom device name from user step 2 (options)
model=device_info.get("product"),
sw_version=device_info.get("version"),
configuration_url="https://api-e.ecoflow.com",
suggested_area="Boiler Room",
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# Unload all platforms associated with this entry
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
# Clean up hass.data if any reference exists
hass.data[DOMAIN].pop(entry.entry_id, None)
# Additionally, clear the device-specific sensors list if it exists
device_id = entry.data.get("device_info").get("serial")
device_name = entry.data.get("options").get("custom_device_name")
if device_id in hass.data.get(DOMAIN, {}).get("device_specific_sensors", {}):
hass.data[DOMAIN]["device_specific_sensors"].pop(device_id, None)
_LOGGER.debug(
f"{device_id}: Cleared sensor update list for device with custom name '{device_name}'"
)
return unload_ok
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)
@@ -0,0 +1,25 @@
,
"evSn",
"workMode",
"useGridFirst",
"evOnoffSet",
"errorCode",
"evPhaseSet",
"orderState",
"orderTime",
"orderEndTimestamp",
"defaultVehicleId",
"expectChargingEnergy",
"switchBits",
"evDetectPhase",
"orderStartTimestamp",
"onlineBits",
"updateTime",
"evUserManual",
"evChargingEnergy",
"evCurrSet",
"chargeVehicleId",
"evPlugAndPlay",
"evExeMode",
"stopChargingSoc"
+189
View File
@@ -0,0 +1,189 @@
"""config_flow.py: Config flow for PowerOcean integration."""
from __future__ import annotations
import re
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import IntegrationError
from .const import _LOGGER, DOMAIN, ISSUE_URL_ERROR_MESSAGE
from .ecoflow import Ecoflow, AuthenticationFailed
# This is the first step's schema when setting up the integration, or its devices
# The second schema is defined inside the ConfigFlow class as it has dynamic default values set via API call
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("serialnumber", default=""): str,
vol.Required("username", default=""): str,
vol.Required("password", default=""): str,
}
)
async def validate_input_for_device(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
ecoflow = Ecoflow(data["serialnumber"], data["username"], data["password"])
try:
# Check for authentication
# auth_check = await hass.async_add_executor_job(ecoflow.fetch_data) # TODO what else is needed from fetch_data?
auth_check = await hass.async_add_executor_job(ecoflow.authorize)
if not auth_check:
# If authentication check returns False, raise an authentication failure exception
raise AuthenticationFailed("Invalid authentication!")
# Get device info
device = await hass.async_add_executor_job(ecoflow.get_device)
# Return the device object with the device information
return device
# Exception if device cannot be found
except IntegrationError as e:
_LOGGER.error(f"Failed to connect to PowerOcean device: {e}" + ISSUE_URL_ERROR_MESSAGE)
raise CannotConnect from e
# Exception if authentication fails
except AuthenticationFailed as e:
_LOGGER.error(f"Authentication failed: {e}" + ISSUE_URL_ERROR_MESSAGE)
raise InvalidAuth from e
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for PowerOcean."""
VERSION = 1.3
# Make sure user input data is passed from one step to the next using user_input_from_step_user
def __init__(self):
self.user_input_from_step_user = None
# This is step 1 for the host/port/user/pass function.
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
# Valide the user input whilst setting up integration or adding new devices.
# validate_input_for_devices will try to detect the device and get more info from it,
# and authenticate and deal with exceptions
device = await validate_input_for_device(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors)
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Create a unique device id as a combination of the device's product and its serial number (which should be unique on its own)
# The host can be udpated if necessary, as the IP address of the device may have changed
unique_id = f"{device['product']}_{device['serial']}"
# Checks that the device is actually unique, otherwise abort
await self.async_set_unique_id(unique_id)
# self._abort_if_unique_id_configured(
# updates={"host": user_input["host"]}
# )
# Before creating the entry in the config_entry registry, go to step 2 for the options
# However, make sure the steps from the user input are passed on to the next step
self.user_input_from_step_user = user_input
self.device_info = device
# Now call the second step but set user_input to None for the first time to force data entry in step 2
return await self.async_step_device_options(user_input=None)
# Show the form for step 1 with the user/host/pass as defined in STEP_USER_DATA_SCHEMA
return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors)
# This is step 2 for the options such as custom name, group and disable sensors
async def async_step_device_options(self, user_input: dict[str, Any] | None = None,) -> FlowResult:
"""Handle the device options step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
# Sanitize the user provided custom device name, which is used for entry and device registry name
user_input["custom_device_name"] = sanitize_device_name(
user_input["custom_device_name"], self.device_info["name"]
)
# Since we have already set the unique ID and updated host if necessary create
# the entry with the additional options.
# The title of the integration is the custom friendly device name given by the user in step 2
title = user_input["custom_device_name"]
return self.async_create_entry(title=title,
data={
"user_input": self.user_input_from_step_user, # from previous step
"device_info": self.device_info, # from device detection
"options": user_input, # new options from this step
},
)
except Exception as e:
_LOGGER.error(f"Failed to handle device options: {e}" + ISSUE_URL_ERROR_MESSAGE)
errors["base"] = "option_error"
# Prepare the second form's schema as it has dynamic values based on the API call
# Use the name from the detected device as default device name
default_device_name = (
self.device_info["name"]
if self.device_info and "name" in self.device_info
else "New Device"
)
step_device_options_schema = vol.Schema(
{
vol.Required("custom_device_name", default=default_device_name): str,
vol.Required("polling_time", default=10): vol.All(
vol.Coerce(int), vol.Clamp(min=5)
),
vol.Required("group_sensors", default=True): bool,
vol.Required("disable_sensors", default=False): bool,
}
)
# Show the form for step 2 with the device name and other options as defined in STEP_DEVICE_OPTIONS_SCHEMA
return self.async_show_form(
step_id="device_options",
data_schema=step_device_options_schema,
errors={},
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
# Helper function to sanitize
def sanitize_device_name(device_name: str, fall_back: str, max_length=255) -> str:
# Trim whitespace
name = device_name.strip()
# Remove special characters but keep spaces
name = re.sub(r"[^\w\s-]", "", name)
# Replace multiple spaces with a single space
name = re.sub(r"\s+", " ", name)
# Length check
if len(name) > max_length:
# Split at the last space to avoid cutting off in the middle of a word
name = name[:max_length].rsplit(" ", 1)[0]
# Fallback name
if not name:
name = fall_back
return name
+38
View File
@@ -0,0 +1,38 @@
"""Constants for the PowerOcean integration."""
import logging
from homeassistant.const import Platform
DOMAIN = "powerocean"
NAME = "Ecoflow PowerOcean"
VERSION = "2024.08.27"
ISSUE_URL = "https://github.com/niltrip/powerocean/issues"
ISSUE_URL_ERROR_MESSAGE = " Please log any issues here: " + ISSUE_URL
PLATFORMS: list[Platform] = [Platform.SENSOR]
_LOGGER = logging.getLogger(f"custom_components.{DOMAIN}")
ATTR_PRODUCT_DESCRIPTION = "Product Description"
ATTR_DESTINATION_NAME = "Destination Name"
ATTR_SOURCE_NAME = "Source Name"
ATTR_UNIQUE_ID = "Internal Unique ID"
ATTR_PRODUCT_VENDOR = "Vendor"
ATTR_PRODUCT_SERIAL = "Vendor Product Serial"
ATTR_PRODUCT_NAME = "Device Name"
ATTR_PRODUCT_VERSION = "Vendor Firmware Version"
ATTR_PRODUCT_BUILD = "Vendor Product Build"
ATTR_PRODUCT_FEATURES = "Vendor Product Features"
STARTUP_MESSAGE = f"""
----------------------------------------------------------------------------
{NAME}
Version: {VERSION}
Domain: {DOMAIN}
If you have any issues with this custom component please open an issue here:
{ISSUE_URL}
----------------------------------------------------------------------------
"""
+72
View File
@@ -0,0 +1,72 @@
{
"data": [
"sysLoadPwr",
"sysGridPwr",
"mpptPwr",
"bpPwr",
"online",
"dcdcPwr",
"todayElectricityGeneration",
"monthElectricityGeneration",
"yearElectricityGeneration",
"totalElectricityGeneration",
"systemName",
"createTime"
],
"JTS1_ENERGY_STREAM_REPORT": [
"pv1Pwr",
"pvInvPwr",
"pv2Pwr"
],
"JTS1_EMS_CHANGE_REPORT": [
"bpTotalChgEnergy",
"bpTotalDsgEnergy",
"bpSoc",
"bpOnlineSum",
"emsCtrlLedBright"
],
"JTS1_BP_STA_REPORT": [
"bpPwr",
"bpSoc",
"bpSoh",
"bpVol",
"bpAmp",
"bpCycles",
"bpSysState",
"bpRemainWatth"
],
"JTS1_EMS_HEARTBEAT": [
"bpRemainWatth",
"emsBpAliveNum",
"emsBpPower",
"pcsActPwr",
"pcsMeterPower"
],
"JTS1_EVCHARGING_REPORT": [
"chargingStatus",
"evPwr",
"evSn",
"workMode",
"useGridFirst",
"evOnoffSet",
"errorCode",
"evPhaseSet",
"orderState",
"orderTime",
"orderEndTimestamp",
"defaultVehicleId",
"expectChargingEnergy",
"switchBits",
"evDetectPhase",
"orderStartTimestamp",
"onlineBits",
"updateTime",
"evUserManual",
"evChargingEnergy",
"evCurrSet",
"chargeVehicleId",
"evPlugAndPlay",
"evExeMode",
"stopChargingSoc"
]
}
+501
View File
@@ -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."""
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"domain": "powerocean",
"name": "Ecoflow PowerOcean",
"codeowners": [
"@niltrip"
],
"config_flow": true,
"documentation": "https://github.com/niltrip/powerocean",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/niltrip/powerocean/issues",
"version": "2025.01.07"
}
+635
View File
@@ -0,0 +1,635 @@
{
"code": "0",
"message": "Success",
"data": {
"sysLoadPwr": 823,
"sysGridPwr": -13,
"mpptPwr": 0,
"bpPwr": -836,
"bpSoc": 88,
"sysBatChgUpLimit": 0,
"sysBatDsgDownLimit": 0,
"sysGridSta": 0,
"sysOnOffMachineStat": 0,
"online": 1,
"todayElectricityGeneration": "95.30",
"monthElectricityGeneration": "192.67",
"yearElectricityGeneration": "5298.98",
"totalElectricityGeneration": "7243.73",
"systemName": "Florian Krebs",
"createTime": "2024-09-21 20:36:04",
"location": "Germany-Bayern-Augsburg-Klostergasse 20a",
"timezone": "Europe/Berlin",
"quota": {
"JTS1_ENERGY_STREAM_REPORT": {
"bpSoc": 88,
"mpptPwr": 0,
"bpPwr": -836,
"updateTime": "2025-05-03 03:15:22",
"sysLoadPwr": 823,
"sysGridPwr": -13,
"pv1Pwr": 0,
"pvInvPwr": 0,
"pv2Pwr": 0
},
"JTS1_EMS_CHANGE_REPORT": {
"sys14aEnable": false,
"afciFaultMaxValueCh1": 0,
"pcsHighVolOnGrid": 253,
"bpChgDsgSta": 0,
"pcsLowVolTime2": 300,
"duration": 0,
"pcsOngridReconnectFlag": 0,
"emsSgReadyEn": false,
"pcsLowFreqTime1": 100,
"wireless4gIccid": "",
"pcsSendEnd": 0,
"sysBatDsgDownLimit": 15,
"bpOnlineSum": 2,
"emsWorkState": 0,
"pcsOverVolDeratingSwitch": 0,
"emsSgRunStat": 0,
"mppt1WarningCode": 0,
"pcsAntiBackFlowImportCurrentLimit": 0,
"pcsAntiBackFlowSwitch": 1,
"pcsOverFreqDeratingStartDelay": 0,
"pcsActivePowerDeratingPercent": 1,
"mppt1FaultCode": 2,
"pcs10minOverVolTime": 100,
"pcsOverVolTime1": 100,
"pcsAvgOvpProtectValue": 0,
"pcsLowVolOnGrid": 195.5,
"afciFaultValueCh1": 0,
"ethWanStat": 0,
"pcsCospPf4": 0,
"pcsAcWarningCode": 0,
"pcs10minOverVol": 253,
"bpLineOffFlag": 0,
"pcsUnderFreqIncrementRecoverSlope": 0.09,
"pcsReactPwrModeSelect": 0,
"pcsPowerDeratingSet": 200,
"pcsLowVolTime3": 240,
"afciProtectValueCh2": 0,
"devMaxPower": 0,
"sysRateCtrlTime": 60,
"pcsUnderFreqIncrementStart": 49.8,
"afciFaultValueCh2": 0,
"pcsLowVolRideThroughStart1": 184,
"chgDsgMode": 0,
"iot4gSta": 2,
"pcsOfpProtectCnt": 0,
"pcsCospP1": 0.1,
"pcsFaultRecoverOnGridWaitTime": 60000,
"batRelayCloseFailFlag": 0,
"mppt2FaultCode": 2,
"pcsLowVol2": 103.5,
"endTimestamp": 0,
"afciFaultFlagCh2": 0,
"pcsFunctionEnable": 0,
"pcsOverVolDeratingTimeConst": 10,
"pcsLowVolRideThroughRecover": 195.5,
"pcsLowFreqTime2": 100,
"pcsLowVolRideThroughProtectTime3": 1000,
"pcsLowVolRideThroughProtectTime2": 3000,
"sysCalStat": 0,
"pcsReactPwrPercent": 0,
"pcsLowVolRideThroughProtectTime1": 5200,
"pcsOverVolRideThroughStart1": 265.65,
"pcsAutoTestPercent": 0,
"pcsRelayStateShow": 13732943,
"pcsOverVolDeratingStart": 253,
"pcsQuQ3": 0,
"pcsUvp2ProtectValue": 0,
"pcsFaultRecoverHighFreqOnGrid": 50.1,
"pcsPowerDeratingFlag": 5,
"pcsOverFreqDeratingCutoffPower": 0,
"pcsActivePowerSoftstartSwitch": 1,
"pcsCospPf2": -1,
"wifiStaStat": 0,
"pcsFastCheck": 0,
"pcsQuLockinPower": 0,
"sysMulPeakSwitch": false,
"pcsUvp1ProtectCnt": 0,
"pcsOverFreqDeratingRecoverSlopeSwitch": 1,
"pcsFaultRecoverLowFreqOnGrid": 47.53,
"pcsCospPf1": -1,
"iot4gErr": 7,
"pcsCospP3": 1,
"iot4gOn": 1,
"pcsOverVolTime2": 100,
"emsFeedPwr": 0,
"afciEnSet": 0,
"pcsOvpProtectCnt": 0,
"pcsCospP4": 0,
"bpReverseFlag": 0,
"pcsActivePowerSoftstartTime": 666,
"afciSelfTestCmdState": 0,
"afciProtectValueCh1": 0,
"parallelTypeCur": 0,
"emsFeedRatio": 100,
"emsCtrlLedBright": 10,
"userRole": 0,
"pcsOverFreqTime2": 100,
"pcsOverVolDeratingStartingPower": 1,
"afciFaultClearState": 0,
"pcsFaultRecoverHighVolOnGrid": 253,
"pcsUfpProtectCnt": 0,
"sysStateBit": 0,
"pcsOverVolTime3": 0,
"pcsRunSta": "RUNSTA_RUN",
"virtualHardEdition": 1,
"afciSwitchFreqCh1": 0,
"emsFeedMode": 1,
"batRealyStatus": 2,
"pcsQuQ1": 0.6,
"pcsReconnectGridDetectSwitch": 1,
"pcsUnderFreqIncrementRecoverSlopeSwitch": 1,
"pcsRunFsmState": 8669282,
"pcsHvrtLvrtSwitch": 1,
"emsBackupEvent": 0,
"pcsCospP2": 0.5,
"pcsOfpProtectValue": 0,
"sysOnOffMachineStat": 0,
"pcsFaultRecoverLowVolOnGrid": 195.5,
"pcsOverFreqDeratingRecoverSlope": 0.09,
"parallelTypeSet": 0,
"afciEnableCmdState": 0,
"pcsLowVolRideThroughStart2": 103.5,
"afciIsExist": 0,
"afciEn": 0,
"pcsUnderFreqIncrementEnd": 49.8,
"pcsOverFreqDeratingFrozeSwitch": 0,
"bpTotalDsgEnergy": 1368561,
"bpTotalChgEnergy": 1419246,
"sys14aType": 1,
"rateCtrlSwtich": false,
"pcsActivePowerNormalRampUpRate": 60,
"pcsOverVolDeratingEnd": 257.6,
"emsStopAll": 0,
"updateTime": "2025-05-03 03:17:49",
"bpSoc": 87,
"pcsOnGridWaitTime": 60000,
"pcsActivePowerDeratingSwitch": 0,
"pcsReactPwrCompensation": 0.0062,
"pcsOverVolRideThroughProtectTime2": 1000,
"pcsQuMinimumCosphi": 0.4,
"pcsPcsAntiBackFlowProtectSwitch": 0,
"pcsVolRecoverTime": 1000,
"pcsAutoTestState": 0,
"pcsOverFreq1": 51.5,
"parallelType": 0,
"pcsActivePowerSoftStartRate": 0.1,
"pcsLowVolTime1": 3000,
"pcsOverVolDeratingEndPower": 0,
"parallelAllowState": false,
"sysHeatStat": 0,
"bpRestartFlag": 1,
"pcsOverVol2": 287.5,
"pcsUnderFreqIncrementSlope": 0.4,
"pcsAcErrCode": 0,
"pcsOverFreqDeratingEndDelay": 0,
"pcsLowVolRecover": 195.5,
"pcsFreqRecoverTime": 1000,
"pcs10minOverVolSwitch": 1,
"sysMulPeakTime": 1200,
"pcsOverVolDeratingDaleyTime": 0,
"pcsOverFreqTime1": 100,
"afciFaultCntCh1": 0,
"pcsUvp1ProtectValue": 0,
"pcsOverVolRideThroughProtectTime1": 5500,
"pcsHighVolRideThroughRecover": 253,
"pcsOverFreq2": 51.5,
"pcsQuV2": 223.1,
"pcsUnderFreqIncrementFrozeSwitch": 0,
"pcsFreqLocalCommand": 1,
"pcsHighFreqOnGrid": 50.1,
"pcsUvp2ProtectCnt": 0,
"pcsDcErrCode": 0,
"pcsAntiBackFlowExportCurrentLimit": 0,
"pcsOverVol1": 287.5,
"sysTypeCfg": 0,
"pcsOverVol3": 0,
"pcsPfValue": 1,
"sysMeterCfg": 0,
"emsWordMode": "WORKMODE_SELFUSE",
"pcsOverFreqDeratingPowerBased": 2,
"relay14a": 0,
"pcsLowVol1": 184,
"emsCtrlLedType": 1,
"afciFaultCntCh2": 0,
"pcsOverVolRecover": 253,
"afciFaultFlagCh1": 0,
"pcsIslandDetectSwitch": 1,
"pcsActivePowerGradient": 0.0033,
"pcsOverVolRideThroughStart2": 287.5,
"pcsOverFreqRecover": 50.1,
"pcsQuQ2": 0,
"afciSwitchFreqCh2": 0,
"pcsCospPf3": -0.9,
"pcsQuTimeConst": 10,
"pcsQuV4": 246.1,
"pcsLowFreq2": 47.5,
"pcsLowVol3": 57.5,
"pcsLowFreqRecover": 47.53,
"pcsOvpProtectValue": 0,
"pcsLowFreq1": 47.5,
"pcsQuV1": 213.90001,
"pcsAutoTestFlag": 0,
"pcsOverFreqDeratingEnd": 50.2,
"pcsQuQ4": -0.6,
"pcsOverFreqDeratingSlope": 0.4,
"pcsFreqExternalSignal": 0,
"chgDsgPwr": 700,
"pcsOverFreqDeratingSwitch": 0,
"evBindList": {
"evSn": [
"AC31ZEH4AG130052"
]
},
"pcsUfpProtectValue": 0,
"pcsLowFreqOnGrid": 47.53,
"mppt2WarningCode": 0,
"pcsUnderFreqIncrementEndDelay": 0,
"afciFaultMaxValueCh2": 0,
"pcsUnderFreqIncrementSwitch": 0,
"pcsQuV3": 236.9,
"pcsQuLockoutPower": 0,
"afciSellfTestResult": 0,
"iot4gPdp": -1,
"pcsLowVolRideThroughStart3": 34.5,
"pcsSafetyCountryCodeSelection": 4,
"pcsRelaySelfCheckSta": 0,
"pcsAvgOvpProtectCnt": 0,
"sysGridSta": 0,
"batSoftRelayStatus": 0,
"pcsUnderFreqIncrementStartDelay": 0,
"pcsOverFreqDeratingStart": 50.2
},
"JTS1_BP_STA_REPORT": {
"HJ3AZDH5ZG3G0490": "{\n \"bpPwr\": -0.8074197,\n \"bpSoc\": 88,\n \"bpSoh\": 100,\n \"bpTemp\": [29.0, 30.0, 29.0, 30.0, 30.0, 29.0, 29.0, 29.0, 29.0],\n \"bpCellMaxVol\": 3319.0,\n \"bpCellMinVol\": 3318.0,\n \"bpRunSta\": \"RUNSTA_RUN\",\n \"bpVol\": 53.066,\n \"bpAmp\": -0.015215387,\n \"bpBusVol\": 799.216,\n \"bpErrCode\": 0,\n \"bpCellVol\": [3319.0, 3319.0, 3318.0, 3318.0, 3318.0, 3319.0, 3318.0, 3318.0, 3318.0, 3318.0, 3318.0, 3318.0, 3318.0, 3319.0, 3318.0, 3318.0],\n \"bpDsrc\": 1,\n \"bpSn\": \"SEozQVpESDVaRzNHMDQ5MA==\",\n \"bpCycles\": 155,\n \"bpBalanceState\": 0,\n \"bpHvMosTemp\": 40.0,\n \"bpLvMosTemp\": 38.0,\n \"bpPtcTemp\": 29.0,\n \"bpHtsTemp\": 34.0,\n \"bpBusNegTemp\": 35.0,\n \"bpBusPosTemp\": 36.0,\n \"bpEnvTemp\": 35.0,\n \"bpAccuChgCap\": 13263555,\n \"bpAccuDsgCap\": 13246290,\n \"bpDesignCap\": 100000,\n \"bpFullCap\": 100000,\n \"bpMaxCellTemp\": 30.0,\n \"bpMinCellTemp\": 29.0,\n \"bpMaxMosTemp\": 40.0,\n \"bpMinMosTemp\": 38.0,\n \"bpBmsFault\": 0,\n \"bpEcloundSoc\": 65535,\n \"bpHeartbeatVer\": 33,\n \"bpTimestamp\": 1746213449,\n \"bpRealSoc\": ...",
"updateTime": "2025-05-03 03:17:39",
"HJ3AZDH5ZG3G0384": "{\n \"bpPwr\": -841.428,\n \"bpSoc\": 88,\n \"bpSoh\": 100,\n \"bpTemp\": [30.0, 31.0, 31.0, 31.0, 31.0, 30.0, 30.0, 31.0, 31.0],\n \"bpCellMaxVol\": 3315.0,\n \"bpCellMinVol\": 3313.0,\n \"bpRunSta\": \"RUNSTA_RUN\",\n \"bpVol\": 53.005,\n \"bpAmp\": -15.874502,\n \"bpBusVol\": 798.7922,\n \"bpErrCode\": 0,\n \"bpCellVol\": [3315.0, 3314.0, 3314.0, 3314.0, 3314.0, 3313.0, 3313.0, 3314.0, 3313.0, 3314.0, 3314.0, 3314.0, 3314.0, 3314.0, 3314.0, 3314.0],\n \"bpDsrc\": 2,\n \"bpSn\": \"SEozQVpESDVaRzNHMDM4NA==\",\n \"bpCycles\": 160,\n \"bpBalanceState\": 0,\n \"bpHvMosTemp\": 38.0,\n \"bpLvMosTemp\": 37.0,\n \"bpPtcTemp\": 31.0,\n \"bpHtsTemp\": 35.0,\n \"bpBusNegTemp\": 35.0,\n \"bpBusPosTemp\": 36.0,\n \"bpEnvTemp\": 35.0,\n \"bpAccuChgCap\": 13259968,\n \"bpAccuDsgCap\": 13221698,\n \"bpDesignCap\": 100000,\n \"bpFullCap\": 100000,\n \"bpMaxCellTemp\": 31.0,\n \"bpMinCellTemp\": 30.0,\n \"bpMaxMosTemp\": 38.0,\n \"bpMinMosTemp\": 37.0,\n \"bpBmsFault\": 0,\n \"bpEcloundSoc\": 65535,\n \"bpHeartbeatVer\": 33,\n \"bpTimestamp\": 1746213455,\n \"bpRealSoc\": 88....",
"": "{\n \"bpTemp\": [],\n \"bpCellVol\": []\n}"
},
"JTS1_LOGY_DEV_REPORT": {
"HPReport": {
"devSn": "",
"online": 0,
"errorCode": ""
},
"updateTime": "2025-05-03 03:12:27"
},
"JTS1_EMS_PARAM_CHANGE_REPORT": {
"smartCtrl": false,
"updateTime": "2025-05-03 03:12:27",
"breakerCapacityMax": 32,
"energyEfficientEnable": false,
"breakerEnableState": true,
"bpBurst": false,
"lowerPowerStat": false,
"sysZone": 0,
"sysTimeTab": 0,
"devSoc": 80
},
"JTS1_ERROR_CODE_MASK_REPORT": {
"errorCode": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"updateTime": "2025-05-03 02:01:35"
},
"JTS1_EDEV_BIND_LIST_REPORT": {
"213": "{\n \"devAddr\": 213,\n \"devItem\": []\n}",
"214": "{\n \"devAddr\": 214,\n \"devItem\": []\n}",
"updateTime": "2025-05-03 02:01:37"
},
"JTS1_EMS_ALL_TIMER_TASK_REPORT": {
"timeTaskCfg": [],
"updateTime": "2025-05-02 15:34:18"
},
"JT303_EDEV_SYS_REPORT": {
"devLastInfo": 31,
"devInfoFix": {
"devSubInfoFix": [
{
"devAttrCur": 197424,
"bindType": 1,
"devInfo": {
"devAddr": 209,
"devSn": "AC31ZEH4AG130052",
"subAddr": 1
},
"devFlag": 131175,
"errorCode": 0,
"onlineBits": 2,
"modbusAddr": 52,
"warnCode": 0,
"memDevIdx": 0,
"targetMax": 11000,
"faultCode": 0,
"sceneType": 3,
"currentPrio": 1,
"devStateInfo": 5
}
]
},
"freeNum": 35,
"devInfoVar": {
"devSubInfoVar": [
{
"allocatedPower": 0,
"realPowerLock": 0,
"refPower": 0,
"devInfo": {
"devAddr": 209,
"devSn": "AC31ZEH4AG130052",
"subAddr": 1
}
}
]
},
"updateTime": "2025-05-03 03:11:48",
"usedNum": 1,
"sysFlag": 35,
"devLastMinInfo": 31,
"socDev": 80,
"dispatchType": 2,
"socCur": 88.93504,
"stratType": 1,
"solarFlag": 985,
"devFirstInfo": 32,
"startState": 9,
"pclPwrBase": -851.92706,
"feedPwrCap": 12000
},
"JTS1_EMS_HEARTBEAT": {
"emsBusVoltRipple": 1.241394,
"pcsInterruptOccupancyRate": 88,
"bpRemainWatth": 9355,
"emsStartFsmState": 1078644260,
"emsAcMakeupMinSoc": 0,
"pcsActivePowerLimitDn": -12000,
"emsMpptStartupState": 1,
"pcsAverageVoltage": 1.0132734,
"updateTime": "2025-05-03 03:18:33",
"pcsActivePowerRef": 663.75104,
"pcsAPhase": {
"vol": 232.77829,
"amp": 1.2337433,
"actPwr": -220.07922,
"reactPwr": 184.506,
"apparentPwr": 287.18866
},
"pcsRelayStateShow": 13732943,
"emsMpptRunState": 0,
"pcsBpPower": -816.97534,
"pcsVbusRef": 770,
"pcsVgridThd": 0.0016662398,
"pcsLoadInfo": [
{
"vol": 232.77829,
"amp": 0,
"freq": 49.990864,
"pwr": 0
},
{
"vol": 232.9028,
"amp": 0,
"freq": 49.99184,
"pwr": 0
},
{
"vol": 232.37206,
"amp": 0,
"freq": 49.99131,
"pwr": 0
}
],
"emsBpSelfcheckState": 1,
"meterHeartBeat": [
{
"meterAddr": 106,
"meterType": 15,
"meterData": [
-135.6,
234.4,
-110.5,
-11.700012
]
}
],
"pcsActPwr": -656.1483,
"emsBpStartupState": 1,
"pcsDci": 0.0012805257,
"emsBpPower": -813.38104,
"emsBusVoltErrSlidFilter": 0.66655725,
"emsLpStateFlag": 0,
"pcsBpPowerChgLimit": 4999.1465,
"emsBpChgRequest": 0,
"pcsAcFreq": 49.99,
"pcsGridSafetyFuncRecord": 234905714,
"emsMpptSelfcheckState": 1,
"pcsBusVolt": 797.13464,
"emsPvInvPwr": 0,
"pcsActivePowerLimitUp": 663.75104,
"mpptHeartBeat": [
{
"mpptPv": [
{
"vol": 42.0932,
"amp": 0.05277126,
"lightSta": true,
"pwr": 2.221311
},
{
"vol": 41.54573,
"amp": 0.08966458,
"lightSta": true,
"pwr": 3.7251804
}
],
"mpptTempVal": [
41.2,
43.5,
43.1
],
"mpptInsResist": 0
}
],
"emsBpChg": 5000.0005,
"emsMpptHbState": 17,
"emsLpMpptCnt": 0,
"pcsMeterPower": -13.199997,
"emsAcMakeupCnt": 0,
"emsBpDsg": -6600,
"emsLpState": 0,
"emsLpType": 0,
"emsNtcTempMax": 43.5,
"emsAcMakeupTriggleSoc": 17,
"emsLpBpCnt": 0,
"pcsBPhase": {
"vol": 232.9028,
"amp": 1.2401944,
"actPwr": -218.97543,
"reactPwr": 188.36415,
"apparentPwr": 288.84476
},
"pcsGridSafetyStateRecord": 9486428,
"emsMpptModStat": 1,
"pcsReactivePowerRef": 0,
"pcsDcv": 0,
"emsBpAliveNum": 2,
"emsAcMakeupExitSoc": 19,
"emsBusVolt": 800.071,
"emsActiveOffGridCmd": 0,
"emsSocCalibState": 0,
"pcsGridInvErrorRms": 6.710472,
"emsSysCfg": 7,
"bpDsgTime": 672,
"pcsLeakAmp": -6.4453125,
"pcsCommInterfaceState": 17120,
"emsSocCalibRequest": 0,
"emsPcsSelfcheckState": 1,
"pcsCPhase": {
"vol": 232.37206,
"amp": 1.2358344,
"actPwr": -217.09364,
"reactPwr": 187.98643,
"apparentPwr": 287.17337
},
"emsPcsStartupState": 1
},
"JTS1_EMS_PV_INV_ENERGY_STREAM_REPORT": {
"pvInvPwr": 0,
"updateTime": "2025-05-03 03:15:22"
},
"JT303_EDEV_PRIORITY_LIST_REPORT": {
"devPrioReport": [
{
"devPrio": 1,
"data": "Aw==",
"enable": true,
"devInfo": {
"devAddr": 209,
"devSn": "AC31ZEH4AG130052",
"subAddr": 1
}
}
],
"updateTime": "2025-05-03 02:11:24"
},
"JTS1_EVCHARGING_REPORT": {
"evSn": "AC31ZEH4AG130052",
"workMode": 2,
"useGridFirst": 1,
"evOnoffSet": 0,
"errorCode": "AAAAAAAAAAA=",
"evPhaseSet": 1,
"orderState": 2,
"orderTime": 29670,
"orderEndTimestamp": 1746213532,
"defaultVehicleId": "534",
"chargingStatus": "EV_CHG_STS_SUSPENDED_EVSE",
"expectChargingEnergy": 0,
"evPwr": 0,
"switchBits": 0,
"evDetectPhase": 3,
"orderStartTimestamp": 1746183862,
"onlineBits": 2,
"updateTime": "2025-05-03 03:18:53",
"evUserManual": 0,
"evChargingEnergy": 17256,
"evCurrSet": 0,
"chargeVehicleId": "534",
"evPlugAndPlay": 1,
"evExeMode": 2,
"stopChargingSoc": 255
},
"JTS1_ERROR_CHANGE_REPORT": {
"bpErrCode": [
{
"errCode": [],
"moduleSn": "SEozQVpESDVaRzNHMDQ5MA=="
},
{
"errCode": [],
"moduleSn": "SEozQVpESDVaRzNHMDM4NA=="
}
],
"emsErrCode": {
"errCode": [],
"moduleSn": "SEozN1pESDVaRzVXMDEwOQ=="
},
"pcsErrCode": {
"errCode": [],
"moduleSn": "SEozMTIxMDJCRzRKMTMzMQ=="
},
"updateTime": "2025-05-03 03:12:28"
},
"JTS1_ECOLOGY_DEV_BIND_LIST_REPORT": {
"devItem": [],
"updateTime": "2025-05-03 02:01:36"
},
"JTS1_EV_CHARGING_ENERGY_STREAM_REPORT": {
"updateTime": "2025-05-03 03:15:24",
"evStreamShow": [
{
"evSn": "AC31ZEH4AG130052",
"evPwr": 0
}
]
},
"JTS1_EV_CHARGING_TIMER_TASK_REPORT": {
"timeTaskCfg": [
{
"taskIndex": 1,
"timeParam": 0,
"param": 16,
"isCfg": 0,
"isEffect": false,
"timeMode": 65,
"timeTable": [
19660800
],
"type": 1,
"isEnable": false
}
],
"evSn": "AC31ZEH4AG130052",
"updateTime": "2025-05-03 02:01:36"
}
}
},
"eagleEyeTraceId": "ea1a2a582917462135513308071d0007",
"tid": ""
}
+375
View File
@@ -0,0 +1,375 @@
from datetime import timedelta
from collections import defaultdict
from homeassistant.components.sensor import SensorEntity
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.sensor import SensorStateClass
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers import entity_registry
from homeassistant.exceptions import IntegrationError
from .const import (
DOMAIN,
_LOGGER,
ATTR_PRODUCT_DESCRIPTION,
ATTR_DESTINATION_NAME,
ATTR_SOURCE_NAME,
ATTR_UNIQUE_ID,
ATTR_PRODUCT_SERIAL,
ATTR_PRODUCT_NAME,
ATTR_PRODUCT_VENDOR,
ATTR_PRODUCT_BUILD,
ATTR_PRODUCT_VERSION,
ATTR_PRODUCT_FEATURES,
ISSUE_URL_ERROR_MESSAGE,
)
from .ecoflow import Ecoflow, AuthenticationFailed
# Setting up the adding and updating of sensor entities
async def async_setup_entry(hass, config_entry, async_add_entities):
# Retrieve the API instance from the config_entry data
ecoflow = hass.data[DOMAIN][config_entry.entry_id]
device_id = ecoflow.device["serial"]
# Call EcoFlow to get access to the API data
try:
auth_check = await hass.async_add_executor_job(ecoflow.authorize)
if not auth_check:
# If device returns False or is empty, log an error and return
_LOGGER.warning(
f"{device_id}: It appears the PowerOcean device is offline or has changed host."
+ ISSUE_URL_ERROR_MESSAGE
)
except AuthenticationFailed as error:
_LOGGER.warning(f"{device_id}: Authentication failed: {error}")
return
try:
# Fetch the sensor data from the device
data = await hass.async_add_executor_job(ecoflow.fetch_data)
if not data:
# If data returns False or is empty, log an error and return
_LOGGER.warning(
f"{device_id}: Failed to fetch sensor data => authentication failed or no data."
+ ISSUE_URL_ERROR_MESSAGE
)
return
# Exception if data cannot be fetched
except IntegrationError as error:
_LOGGER.warning(
f"{device_id}: Failed to fetch sensor data: {error}"
+ ISSUE_URL_ERROR_MESSAGE
)
return
# Get device id and then reset the device specific list of sensors for updates
# to ensure it's empty before adding new entries
# Initialize or clear the sensor list for this device
hass.data[DOMAIN]["device_specific_sensors"][device_id] = []
# Register entities and add them to the list for schedule updates on each device
# which is stored within hass.data
for unique_id, endpoint in data.items():
# Get individual sensor entry from API
sensor = PowerOceanSensor(ecoflow, endpoint)
# Add sensors to the device specific list of sensors to be updated, via hass.data as also used in unload
hass.data[DOMAIN]["device_specific_sensors"][device_id].append(sensor)
# Register sensor
async_add_entities([sensor], False)
device_specific_sensors = hass.data[DOMAIN]["device_specific_sensors"]
_LOGGER.debug(
f"{device_id}: List of device_specific_sensors[device_id]: "
f"{device_specific_sensors[device_id]}"
)
# Log the number of sensors registered (and added to the update list)
_LOGGER.debug(
f"{device_id}: All '{len(device_specific_sensors[device_id])}' sensors have registered."
)
# Schedule updates
async def async_update_data(now):
# If device deleted but HASS not restarted, then don't bother continuing
if device_id not in hass.data.get(DOMAIN, {}).get(
"device_specific_sensors", {}
):
return False
_LOGGER.debug(f"{device_id}: Preparing to update sensors at {now}")
# Fetch the full dataset once from the API
try:
full_data = await hass.async_add_executor_job(ecoflow.fetch_data)
except Exception as e:
_LOGGER.error(
f"{device_id}: Error fetching data from the device: {e}"
+ ISSUE_URL_ERROR_MESSAGE
)
return
# Fetch the registry and check if sensors are enabled
registry = entity_registry.async_get(hass)
# Set counters to zero
counter_updated = 0 # Successfully updated sensors
counter_disabled = 0 # Disabled sensors, not to be updated
counter_unchanged = 0 # Skipped sensors since value has not changed
counter_error = 0 # Skipped sensors due to some error, such as registry not found or no data from API
# Get the list of device specific sensors from hass.data
if device_id in hass.data.get(DOMAIN, {}).get("device_specific_sensors", {}):
device_specific_sensors = hass.data[DOMAIN]["device_specific_sensors"]
# ----------------------------------------------
# Now loop through the sensors to be updated
# ----------------------------------------------
for sensor in device_specific_sensors[device_id]:
entity_id = registry.async_get_entity_id(
"sensor", DOMAIN, sensor.unique_id
)
if entity_id:
entity = registry.entities.get(entity_id) # get entity
# entity is enabled
if entity and not entity.disabled_by:
sensor_data = full_data.get(sensor.unique_id)
# _LOGGER.debug(f"{device_id}: Sensor {sensor.name} enabled.")
if sensor_data:
# _LOGGER.debug(
# f"{device_id}: Sensor {sensor.name} has API data to update {sensor_data}"
# )
# Check if current state value differs from new API value,
# or current state has not initialized
if (
str(sensor._state).strip()
!= str(sensor_data.value).strip()
):
# _LOGGER.debug(
# f"{device_id}: Sensor {sensor.name} marked for update: current state = "
# f"{sensor._state} with new value = {sensor_data.value}"
# )
# Now update the sensor with new values
# update_status returns 1 for upated, 0 for skipped or error
update_status = await sensor.async_update(sensor_data)
counter_updated = counter_updated + update_status
else:
# _LOGGER.debug(
# f"{device_id}: Sensor {sensor.name} skipped update! Current value = "
# f"{sensor._state}, new value = {sensor_data.value}"
# )
counter_unchanged = counter_unchanged + 1
else:
_LOGGER.warning(
f"{device_id}: Sensor {sensor.name}: found no data for update!"
+ ISSUE_URL_ERROR_MESSAGE
)
counter_error = counter_error + 1
else:
# _LOGGER.debug(
# f"{device_id}: Sensor {sensor.name} is disabled, skipping update"
# )
counter_disabled = counter_disabled + 1
else:
_LOGGER.warning(
f"{device_id}: Sensor {sensor.name} not found in the registry, skipping update"
+ ISSUE_URL_ERROR_MESSAGE
)
counter_error = counter_error + 1
# Log summary of updates
_LOGGER.debug(
f"{device_id}: A total of {counter_updated} sensors have been updated. "
f"Number of disabled sensors or skipped updates = {counter_disabled} "
f"Number of sensors with constant values = {counter_unchanged} "
f"Number of sensors with errors = {counter_error}"
)
# Device not in list: must have been deleted, will resolve post re-start
else:
_LOGGER.warning(
f"{device_id}: Sensor must have been deleted, re-start of HA recommended."
)
# Get the polling interval from the options, defaulting to 5 seconds if not set
polling_interval = timedelta(
seconds=config_entry.options.get("polling_interval", 5)
)
async_track_time_interval(hass, async_update_data, polling_interval)
# This is the actual instance of SensorEntity class
class PowerOceanSensor(SensorEntity):
"""Representation of a PowerOcean Sensor."""
def __init__(self, ecoflow: Ecoflow, endpoint):
"""Initialize the sensor."""
# Make Ecoflow and the endpoint parameters from the Sensor API available
self.ecoflow = ecoflow
self.endpoint = endpoint
# Set Friendly name when sensor is first created
self._attr_unique_id = endpoint.name
self._attr_has_entity_name = True
self._attr_name = endpoint.friendly_name
self._name = endpoint.friendly_name
# The unique identifier for this sensor within Home Assistant
# has nothing to do with the entity_id, it is the internal unique_id of the sensor entity registry
self._unique_id = endpoint.internal_unique_id
# Set the icon for the sensor based on its unit, ensure the icon_mapper is defined
# Default handled in function
# self._icon = PowerOceanSensor.icon_mapper.get(endpoint.unit)
self._icon = endpoint.icon
# The initial state/value of the sensor
self._state = endpoint.value
# The unit of measurement for the sensor
self._unit = endpoint.unit
# Set entity category to diagnostic for sensors with no unit
if ecoflow.options.get("group_sensors") and not endpoint.unit:
self._attr_entity_category = EntityCategory.DIAGNOSTIC
# If diagnostics entity then disable sensor by default
if ecoflow.options.get("disable_sensors") and not endpoint.unit:
self._attr_entity_registry_enabled_default = False
@property
def should_poll(self):
"""async_track_time_intervals handles updates."""
return False
@property
def unique_id(self):
"""Return the unique ID of the sensor."""
return self._unique_id
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return self._unit
@property
def device_class(self):
"""Return the device class of this entity, if any."""
if self._unit == "°C":
return SensorDeviceClass.TEMPERATURE
elif self._unit == "%":
return SensorDeviceClass.BATTERY
elif self._unit in {"Wh", "kWh"}:
return SensorDeviceClass.ENERGY
elif self._unit == "W":
return SensorDeviceClass.POWER
elif self._unit == "V":
return SensorDeviceClass.VOLTAGE
elif self._unit == "A":
return SensorDeviceClass.CURRENT
else:
return None
@property
def state_class(self):
"""Return the state class of this entity, if any."""
if self._unit in {"°C", "h", "W", "V", "A"}:
return SensorStateClass.MEASUREMENT
elif self._unit in {"Wh", "kWh"}:
return SensorStateClass.TOTAL_INCREASING
else:
return None
@property
def extra_state_attributes(self):
"""Return the state attributes of this device."""
attr = {}
attr[ATTR_PRODUCT_DESCRIPTION] = self.endpoint.description
attr[ATTR_UNIQUE_ID] = self.endpoint.internal_unique_id
attr[ATTR_PRODUCT_VENDOR] = self.ecoflow.device["vendor"]
attr[ATTR_PRODUCT_NAME] = self.ecoflow.device["name"]
attr[ATTR_PRODUCT_SERIAL] = self.endpoint.serial
attr[ATTR_PRODUCT_VERSION] = self.ecoflow.device["version"]
attr[ATTR_PRODUCT_BUILD] = self.ecoflow.device["build"]
attr[ATTR_PRODUCT_FEATURES] = self.ecoflow.device["features"]
return attr
@property
def device_info(self):
"""Return device specific attributes."""
# The unique identifier of the device is the serial number
return {
"identifiers": {(DOMAIN, self.ecoflow.device["serial"])},
"name": self.ecoflow.device["name"],
"manufacturer": "ECOFLOW",
}
@property
def icon(self):
"""Return the icon of the sensor."""
return self._icon
# icon_mapper = defaultdict(
# lambda: "mdi:alert-circle",
# {
# "°C": "mdi:thermometer",
# "%": "mdi:flash",
# "s": "mdi:timer",
# "Wh": "mdi:solar-power-variant-outline",
# "h": "mdi:timer-sand",
# },
# )
# This is to register the icon settings
async def async_added_to_hass(self):
"""Call when the sensor is added to Home Assistant."""
self.async_write_ha_state()
# Update of Sensor values
async def async_update(self, sensor_data=None):
"""Update the sensor with the provided data."""
if sensor_data is None:
_LOGGER.warning(
f"{self.ecoflow.device['serial']}: No new data provided for sensor '{self.name}' update"
+ ISSUE_URL_ERROR_MESSAGE
)
update_status = 0
return
try:
self._state = sensor_data.value
update_status = 1
self.async_write_ha_state()
except Exception as error:
_LOGGER.error(
f"{self.ecoflow.device['serial']}: Error updating sensor {self.name}: {error}"
+ ISSUE_URL_ERROR_MESSAGE
)
update_status = 0
return update_status
+31
View File
@@ -0,0 +1,31 @@
{
"config": {
"step": {
"user": {
"description": "[%key:common::config_flow::data::description%]",
"data": {
"serialnumber": "[%key:common::config_flow::data::serialnumber%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"device_options": {
"description": "[%key:common::config_flow::data::description%]",
"data": {
"custom_device_name": "[%key:common::config_flow::data::custom_device_name%]",
"polling_time": "[%key:common::config_flow::data::polling_time%]",
"group_sensors": "[%key:common::config_flow::data::group_sensors%]",
"disable_sensors": "[%key:common::config_flow::data::disable_sensors%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}
@@ -0,0 +1,31 @@
{
"config": {
"abort": {
"already_configured": "Das Gerät ist bereits konfiguriert"
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen",
"invalid_auth": "Ungültige Authentifizierung",
"unknown": "Unerwarteter Fehler"
},
"step": {
"user": {
"description": "Bitte geben Sie unten Ihre PowerOcean Geräteinformationen ein:",
"data": {
"serialnumber": "Seriennummer des Wechselrichters",
"password": "Passwort",
"username": "Benutzername"
}
},
"device_options": {
"description": "Bitte wählen Sie die folgenden Optionen aus:",
"data": {
"custom_device_name": "Benutzerfreundlicher Gerätename",
"polling_time": "Abfragezeit (in Sekunden), um Sensoren vom Gerät zu aktualisieren",
"group_sensors": "Gruppieren Sie Sensoren auf der Geräteseite",
"disable_sensors": "Diagnosesensoren deaktivieren"
}
}
}
}
}
@@ -0,0 +1,31 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"user": {
"description": "Please enter your PowerOcean device information below:",
"data": {
"serialnumber": "Inverter serial number",
"password": "Password",
"username": "Username"
}
},
"device_options": {
"description": "Please select the following options:",
"data": {
"custom_device_name": "Friendly device name",
"polling_time": "Polling time (in seconds) to update sensors from device",
"group_sensors": "Group sensors on device page",
"disable_sensors": "Disable diagnostics sensors"
}
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
{
"data": [
"sysLoadPwr",
"sysGridPwr",
"mpptPwr",
"bpPwr",
"bpSoc",
"online",
"systemName",
"createTime",
"location",
"timezone"
],
"JTS1_ENERGY_STREAM_REPORT": [],
"JTS1_EMS_CHANGE_REPORT": [],
"JTS1_BP_STA_REPORT": [],
"JTS1_ENERGY_STREAM_REPORT": [
"bpSoc",
"heatingPower",
"dcdcPwr",
"bpPwr",
"pvPwr",
"gridPwr",
"loadPwr",
"timezone",
"updateTime",
"timestamp"
],
"JTS1_EMS_HEARTBEAT": [
"emsSystemState",
"mpptRatePower",
"gridOutDayEnergy",
"meterACurrent",
"meterBCurrent",
"bpInDayEnergy",
"meterPowerFactor",
"isPvToInvDirectly",
"bpRemainWatth",
"bp1SocCoefficient",
"sysRunMeterFeedPower",
"inverterBand",
"loadDayEnergy",
"startDischargePower",
"bpDichargeAbilityAdjustValueLv2",
"bpDichargeAbilityAdjustValueLv1",
"meterAddress",
"gridFeedRate",
"currentNetif",
"batterySocLowerLimit",
"bpDsgTime",
"bpChgDsgSta",
"autoDetectStartPowerEn",
"meterPhasePower",
"sysErrCode",
"meterTotalPower",
"bpBusVoltageBaseCoefficient",
"bpSaveSocStopDelay",
"bpOutDayEnergy",
"bpBusVoltCoefficient",
"pvInvWiringMode",
"underVoltageProtectPoint",
"batterySocUpperLimit",
"bpRunDelay",
"meterBVoltage",
"bpStopDelay",
"errCode",
"gridInDayEnergy",
"sysRunMeterTakePower",
"bpSoc",
"bp3SocCoefficient",
"bpErrorCodeMask",
"workingMode",
"appCtrlState",
"bpSocBaseCoefficient",
"emsErrorCodeMask",
"meterRatio",
"sysWorkSta",
"pv2ErrorCodeMask",
"pv1ErrorCodeMask",
"manualSetStartChargePower",
"pvWiringType",
"emsErrCode",
"bp2SocCoefficient",
"mpptWithstandVoltage",
"meterCVoltage",
"updateTime",
"pvaErrorCodeMask",
"meterAVoltage",
"invWiringType",
"disOrChargeAbilityStepValue",
"reportCycleTime",
"pvInDayEnergy",
"meterType",
"meterCCurrent",
"mpptVoltageMinimum",
"mpptVoltageMaximum",
"gridFeedPowerMinimum",
"epoSwitchState"
]
}
@@ -0,0 +1,45 @@
{
"data": [
"sysLoadPwr",
"sysGridPwr",
"mpptPwr",
"bpPwr",
"online",
"dcdcPwr",
"todayElectricityGeneration",
"monthElectricityGeneration",
"yearElectricityGeneration",
"totalElectricityGeneration",
"systemName",
"createTime"
],
"JTS1_ENERGY_STREAM_REPORT": [
"pv1Pwr",
"pvInvPwr",
"pv2Pwr"
],
"JTS1_EMS_CHANGE_REPORT": [
"bpTotalChgEnergy",
"bpTotalDsgEnergy",
"bpSoc",
"bpOnlineSum",
"emsCtrlLedBright"
],
"JTS1_BP_STA_REPORT": [
"bpPwr",
"bpSoc",
"bpSoh",
"bpVol",
"bpAmp",
"bpCycles",
"bpSysState",
"bpRemainWatth"
],
"JTS1_EMS_HEARTBEAT": [
"bpRemainWatth",
"emsBpAliveNum",
"emsBpPower",
"pcsActPwr",
"pcsMeterPower"
]
}