Home Assistant Git Exporter
This commit is contained in:
285
config/custom_components/ecoflow/__init__.py
Normal file
285
config/custom_components/ecoflow/__init__.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any, Callable, Optional, TypeVar, cast
|
||||
|
||||
import reactivex.operators as ops
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import event
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.device_registry import async_get as async_get_dr
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory
|
||||
from homeassistant.util.dt import utcnow
|
||||
from reactivex import Observable, Subject, compose, throw
|
||||
from reactivex.subject.replaysubject import ReplaySubject
|
||||
|
||||
from . import ecoflow as ef
|
||||
from .ecoflow import receive
|
||||
from .ecoflow.rxtcp import RxTcpAutoConnection
|
||||
|
||||
CONF_PRODUCT = "product"
|
||||
DISCONNECT_TIME = timedelta(seconds=15)
|
||||
DOMAIN = "ecoflow"
|
||||
|
||||
_PLATFORMS = {
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
}
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
async def to_task(src: Observable[_T]):
|
||||
return await src
|
||||
|
||||
|
||||
async def request(tcp: RxTcpAutoConnection, req: bytes, res: Observable[_T]) -> _T:
|
||||
t = to_task(res.pipe(
|
||||
ops.timeout(5, throw(TimeoutError())),
|
||||
ops.first(),
|
||||
))
|
||||
try:
|
||||
tcp.write(req)
|
||||
except BaseException as ex:
|
||||
t.close()
|
||||
raise ex
|
||||
return await t
|
||||
|
||||
|
||||
def select_bms(idx: int):
|
||||
return compose(
|
||||
ops.filter(lambda x: x[0] == idx),
|
||||
ops.map(lambda x: cast(dict[str, Any], x[1])),
|
||||
)
|
||||
|
||||
|
||||
class HassioEcoFlowClient:
|
||||
__disconnected = None
|
||||
__extra_connected = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
|
||||
self.tcp = RxTcpAutoConnection(entry.data[CONF_HOST], ef.PORT)
|
||||
self.product: int = entry.data[CONF_PRODUCT]
|
||||
self.serial = entry.unique_id
|
||||
self.diagnostics = dict[str, dict[str, Any]]()
|
||||
dr = async_get_dr(hass)
|
||||
|
||||
self.device_info_main = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.serial)},
|
||||
manufacturer="EcoFlow",
|
||||
name=entry.title,
|
||||
)
|
||||
if mac := entry.data.get(CONF_MAC, None):
|
||||
self.device_info_main["connections"] = {
|
||||
(CONNECTION_NETWORK_MAC, mac),
|
||||
}
|
||||
|
||||
self.received = self.tcp.received.pipe(
|
||||
receive.merge_packet(),
|
||||
ops.map(receive.decode_packet),
|
||||
ops.share(),
|
||||
)
|
||||
self.pd = self.received.pipe(
|
||||
ops.filter(receive.is_pd),
|
||||
ops.map(lambda x: receive.parse_pd(x[3], self.product)),
|
||||
ops.multicast(subject=ReplaySubject(1, DISCONNECT_TIME)),
|
||||
ops.ref_count(),
|
||||
)
|
||||
self.ems = self.received.pipe(
|
||||
ops.filter(receive.is_ems),
|
||||
ops.map(lambda x: receive.parse_ems(x[3], self.product)),
|
||||
ops.multicast(subject=ReplaySubject(1, DISCONNECT_TIME)),
|
||||
ops.ref_count(),
|
||||
)
|
||||
self.inverter = self.received.pipe(
|
||||
ops.filter(receive.is_inverter),
|
||||
ops.map(lambda x: receive.parse_inverter(x[3], self.product)),
|
||||
ops.multicast(subject=ReplaySubject(1, DISCONNECT_TIME)),
|
||||
ops.ref_count(),
|
||||
)
|
||||
self.mppt = self.received.pipe(
|
||||
ops.filter(receive.is_mppt),
|
||||
ops.map(lambda x: receive.parse_mppt(x[3], self.product)),
|
||||
ops.multicast(subject=ReplaySubject(1, DISCONNECT_TIME)),
|
||||
ops.ref_count(),
|
||||
)
|
||||
self.bms = self.received.pipe(
|
||||
ops.filter(receive.is_bms),
|
||||
ops.map(lambda x: receive.parse_bms(x[3], self.product)),
|
||||
ops.multicast(subject=ReplaySubject(1, DISCONNECT_TIME)),
|
||||
ops.ref_count(),
|
||||
)
|
||||
|
||||
self.dc_in_current_config = self.received.pipe(
|
||||
ops.filter(receive.is_dc_in_current_config),
|
||||
ops.map(lambda x: receive.parse_dc_in_current_config(x[3])),
|
||||
)
|
||||
self.dc_in_type = self.received.pipe(
|
||||
ops.filter(receive.is_dc_in_type),
|
||||
ops.map(lambda x: receive.parse_dc_in_type(x[3])),
|
||||
)
|
||||
self.fan_auto = self.received.pipe(
|
||||
ops.filter(receive.is_fan_auto),
|
||||
ops.map(lambda x: receive.parse_fan_auto(x[3])),
|
||||
)
|
||||
self.lcd_timeout = self.received.pipe(
|
||||
ops.filter(receive.is_lcd_timeout),
|
||||
ops.map(lambda x: receive.parse_lcd_timeout(x[3])),
|
||||
)
|
||||
|
||||
self.disconnected = Subject[Optional[int]]()
|
||||
|
||||
def _disconnected(*args):
|
||||
self.__disconnected = None
|
||||
self.tcp.reconnect()
|
||||
self.diagnostics.clear()
|
||||
self.disconnected.on_next(None)
|
||||
if self.__extra_connected:
|
||||
self.__extra_connected = False
|
||||
|
||||
def reset_timer(*args):
|
||||
if self.__disconnected:
|
||||
self.__disconnected()
|
||||
self.__disconnected = event.async_track_point_in_utc_time(
|
||||
hass,
|
||||
_disconnected,
|
||||
utcnow().replace(microsecond=0) + (DISCONNECT_TIME + timedelta(seconds=1)),
|
||||
)
|
||||
|
||||
def end_timer(ex=None):
|
||||
self.disconnected.on_next(None)
|
||||
if ex:
|
||||
self.disconnected.on_error(ex)
|
||||
else:
|
||||
self.disconnected.on_completed()
|
||||
self.received.subscribe(reset_timer, end_timer, end_timer)
|
||||
|
||||
def pd_updated(data: dict[str, Any]):
|
||||
self.diagnostics["pd"] = data
|
||||
self.device_info_main["model"] = ef.get_model_name(
|
||||
self.product, data["model"])
|
||||
dr.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
**self.device_info_main,
|
||||
)
|
||||
if self.__extra_connected != ef.has_extra(self.product, data.get("model", None)):
|
||||
self.__extra_connected = not self.__extra_connected
|
||||
if not self.__extra_connected:
|
||||
self.disconnected.on_next(1)
|
||||
self.pd.subscribe(pd_updated)
|
||||
|
||||
def bms_updated(data: tuple[int, dict[str, Any]]):
|
||||
if "bms" not in self.diagnostics:
|
||||
self.diagnostics["bms"] = dict[str, Any]()
|
||||
self.diagnostics["bms"][data[0]] = data[1]
|
||||
self.bms.subscribe(bms_updated)
|
||||
|
||||
def ems_updated(data: dict[str, Any]):
|
||||
self.diagnostics["ems"] = data
|
||||
self.ems.subscribe(ems_updated)
|
||||
|
||||
def inverter_updated(data: dict[str, Any]):
|
||||
self.diagnostics["inverter"] = data
|
||||
self.inverter.subscribe(inverter_updated)
|
||||
|
||||
def mppt_updated(data: dict[str, Any]):
|
||||
self.diagnostics["mppt"] = data
|
||||
self.mppt.subscribe(mppt_updated)
|
||||
|
||||
async def close(self):
|
||||
self.tcp.close()
|
||||
await self.tcp.wait_closed()
|
||||
|
||||
|
||||
class EcoFlowBaseEntity(Entity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_connected = False
|
||||
|
||||
def __init__(self, client: HassioEcoFlowClient, bms_id: Optional[int] = None):
|
||||
self._attr_available = False
|
||||
self._client = client
|
||||
self._bms_id = bms_id or 0
|
||||
self._attr_device_info = client.device_info_main
|
||||
self._attr_unique_id = client.serial
|
||||
if bms_id:
|
||||
self._attr_unique_id += f"-{bms_id}"
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
await super().async_added_to_hass()
|
||||
self._subscribe(self._client.disconnected, self.__on_disconnected)
|
||||
|
||||
def _subscribe(self, src: Observable, func: Callable):
|
||||
self.async_on_remove(src.subscribe(func).dispose)
|
||||
|
||||
def __on_disconnected(self, bms_id: Optional[int]):
|
||||
if bms_id is not None and self._bms_id != bms_id:
|
||||
return
|
||||
self._connected = False
|
||||
if self._attr_available:
|
||||
self._attr_available = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class EcoFlowEntity(EcoFlowBaseEntity):
|
||||
def __init__(self, client: HassioEcoFlowClient, src: Observable[dict[str, Any]], key: str, name: str, bms_id: Optional[int] = None):
|
||||
super().__init__(client, bms_id)
|
||||
self._key = key
|
||||
self._src = src
|
||||
self._attr_name = name
|
||||
self._attr_unique_id += f"-{key.replace('_', '-')}"
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
await super().async_added_to_hass()
|
||||
self._subscribe(self._src, self.__updated)
|
||||
|
||||
def __updated(self, data: dict[str, Any]):
|
||||
self._attr_available = True
|
||||
self._on_updated(data)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
pass
|
||||
|
||||
|
||||
class EcoFlowConfigEntity(EcoFlowBaseEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_should_poll = True
|
||||
|
||||
def __init__(self, client: HassioEcoFlowClient, key: str, name: str):
|
||||
super().__init__(client)
|
||||
self._attr_name = name
|
||||
self._attr_unique_id += f"-{key.replace('_', '-')}"
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
await super().async_added_to_hass()
|
||||
self._subscribe(self._client.received, self.__updated)
|
||||
|
||||
def __updated(self, data):
|
||||
if not self._connected:
|
||||
self._connected = True
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
client = HassioEcoFlowClient(hass, entry)
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = client
|
||||
hass.config_entries.async_setup_platforms(entry, _PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
if not await hass.config_entries.async_unload_platforms(entry, _PLATFORMS):
|
||||
return False
|
||||
|
||||
client: HassioEcoFlowClient = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
await client.close()
|
||||
return True
|
||||
142
config/custom_components/ecoflow/binary_sensor.py
Normal file
142
config/custom_components/ecoflow/binary_sensor.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from typing import Any
|
||||
|
||||
import reactivex.operators as ops
|
||||
from homeassistant.components.binary_sensor import (BinarySensorDeviceClass,
|
||||
BinarySensorEntity)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import (DOMAIN, EcoFlowBaseEntity, EcoFlowEntity, HassioEcoFlowClient,
|
||||
select_bms)
|
||||
from .ecoflow import is_delta, is_power_station, is_river
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||
client: HassioEcoFlowClient = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = []
|
||||
|
||||
if is_power_station(client.product):
|
||||
entities.extend([
|
||||
ChargingEntity(client),
|
||||
MainErrorEntity(client),
|
||||
])
|
||||
if is_delta(client.product):
|
||||
entities.extend([
|
||||
ExtraErrorEntity(client, client.bms.pipe(select_bms(
|
||||
1), ops.share()), "battery_error", "Extra1 status", 1),
|
||||
ExtraErrorEntity(client, client.bms.pipe(select_bms(
|
||||
2), ops.share()), "battery_error", "Extra2 status", 2),
|
||||
InputEntity(client, client.inverter, "ac_in_type", "AC input"),
|
||||
InputEntity(client, client.mppt, "dc_in_state", "DC input"),
|
||||
CustomChargeEntity(client, client.inverter,
|
||||
"ac_in_limit_switch", "AC custom charge speed"),
|
||||
])
|
||||
if is_river(client.product):
|
||||
entities.extend([
|
||||
ExtraErrorEntity(client, client.bms.pipe(select_bms(
|
||||
1), ops.share()), "battery_error", "Extra status", 1),
|
||||
InputEntity(client, client.inverter, "in_type", "Input"),
|
||||
])
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BaseEntity(BinarySensorEntity, EcoFlowEntity):
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_is_on = bool(data[self._key])
|
||||
|
||||
|
||||
class ChargingEntity(BinarySensorEntity, EcoFlowBaseEntity):
|
||||
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
|
||||
_battery_level = None
|
||||
_battery_level_max = None
|
||||
_in_power = None
|
||||
_out_power = None
|
||||
|
||||
def __init__(self, client: HassioEcoFlowClient):
|
||||
super().__init__(client)
|
||||
self._attr_name = "Charging"
|
||||
self._attr_unique_id += "-in-charging"
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
await super().async_added_to_hass()
|
||||
self._subscribe(self._client.pd, self.__updated)
|
||||
self._subscribe(self._client.ems, self.__updated)
|
||||
|
||||
def __updated(self, data: dict[str, Any]):
|
||||
self._attr_available = True
|
||||
self._on_updated(data)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
if "in_power" in data:
|
||||
self._in_power = data["in_power"]
|
||||
if "out_power" in data:
|
||||
self._out_power = data["out_power"]
|
||||
if "battery_level" in data:
|
||||
self._battery_level = data["battery_level"]
|
||||
if "battery_level_max" in data:
|
||||
self._battery_level_max = data["battery_level_max"]
|
||||
|
||||
if not self._in_power:
|
||||
self._attr_is_on = False
|
||||
elif (self._battery_level is not None) and (self._battery_level_max is not None) and (self._battery_level_max < self._battery_level):
|
||||
self._attr_is_on = False
|
||||
elif (self._in_power is not None) and (self._out_power is not None) and (self._in_power <= self._out_power):
|
||||
self._attr_is_on = False
|
||||
else:
|
||||
self._attr_is_on = True
|
||||
|
||||
|
||||
class CustomChargeEntity(BaseEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_is_on = data[self._key] == 2
|
||||
|
||||
|
||||
class ExtraErrorEntity(BaseEntity):
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_is_on = data[self._key] not in [0, 6]
|
||||
self._attr_extra_state_attributes = {"code": data[self._key]}
|
||||
|
||||
|
||||
class MainErrorEntity(BinarySensorEntity, EcoFlowBaseEntity):
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
def __init__(self, client: HassioEcoFlowClient):
|
||||
super().__init__(client)
|
||||
self._attr_name = "Main status"
|
||||
self._attr_unique_id += "-error"
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
return next((True for x in self._attr_extra_state_attributes.values() if x not in [0, 6]), False)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
await super().async_added_to_hass()
|
||||
self._subscribe(self._client.pd, self.__updated)
|
||||
self._subscribe(self._client.ems, self.__updated)
|
||||
self._subscribe(self._client.inverter, self.__updated)
|
||||
self._subscribe(self._client.mppt, self.__updated)
|
||||
|
||||
def __updated(self, data: dict[str, Any]):
|
||||
self._attr_available = True
|
||||
if "ac_error" in data:
|
||||
self._attr_extra_state_attributes["ac"] = data["ac_error"]
|
||||
if "battery_main_error" in data:
|
||||
self._attr_extra_state_attributes["battery"] = data["battery_main_error"]
|
||||
if "dc_in_error" in data:
|
||||
self._attr_extra_state_attributes["dc"] = data["dc_in_error"]
|
||||
if "pd_error" in data:
|
||||
self._attr_extra_state_attributes["system"] = data["pd_error"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class InputEntity(BaseEntity):
|
||||
_attr_device_class = BinarySensorDeviceClass.POWER
|
||||
75
config/custom_components/ecoflow/config_flow.py
Normal file
75
config/custom_components/ecoflow/config_flow.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import reactivex.operators as ops
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.dhcp import DhcpServiceInfo
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC
|
||||
|
||||
from . import CONF_PRODUCT, DOMAIN, request
|
||||
from .ecoflow import PORT, PRODUCTS, receive, send
|
||||
from .ecoflow.rxtcp import RxTcpAutoConnection
|
||||
|
||||
|
||||
class EcoflowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
host = None
|
||||
mac = None
|
||||
|
||||
async def _get_serial_main(self):
|
||||
tcp = RxTcpAutoConnection(self.host, PORT)
|
||||
received = tcp.received.pipe(
|
||||
receive.merge_packet(),
|
||||
ops.map(receive.decode_packet),
|
||||
ops.filter(receive.is_serial_main),
|
||||
ops.map(lambda x: receive.parse_serial(x[3])),
|
||||
)
|
||||
try:
|
||||
await tcp.wait_opened()
|
||||
info = await request(tcp, send.get_serial_main(), received)
|
||||
finally:
|
||||
tcp.close()
|
||||
if info["product"] not in PRODUCTS:
|
||||
return self.async_abort(reason="product_unsupported")
|
||||
await self.async_set_unique_id(info["serial"])
|
||||
self._abort_if_unique_id_configured(updates={
|
||||
CONF_HOST: self.host,
|
||||
CONF_MAC: self.mac,
|
||||
})
|
||||
return info
|
||||
|
||||
async def async_step_dhcp(self, discovery_info: DhcpServiceInfo):
|
||||
self.host = discovery_info.ip
|
||||
self.mac = discovery_info.macaddress
|
||||
await self._get_serial_main()
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
async def async_step_user(self, user_input: dict = None):
|
||||
if user_input:
|
||||
self.host = user_input.get(CONF_HOST)
|
||||
|
||||
errors = {}
|
||||
if self.host and user_input is not None:
|
||||
try:
|
||||
info = await self._get_serial_main()
|
||||
except TimeoutError:
|
||||
errors["base"] = "timeout"
|
||||
else:
|
||||
pn = PRODUCTS.get(info["product"], "")
|
||||
if pn != "":
|
||||
pn += " "
|
||||
return self.async_create_entry(
|
||||
title=f'{pn}{info["serial"][-6:]}',
|
||||
data={
|
||||
CONF_HOST: self.host,
|
||||
CONF_MAC: self.mac,
|
||||
CONF_PRODUCT: info["product"],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
errors=errors,
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_HOST, default=self.host): str,
|
||||
}),
|
||||
last_step=True,
|
||||
)
|
||||
24
config/custom_components/ecoflow/diagnostics.py
Normal file
24
config/custom_components/ecoflow/diagnostics.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import DOMAIN, HassioEcoFlowClient
|
||||
|
||||
|
||||
def _to_serializable(x):
|
||||
t = type(x)
|
||||
if t is dict:
|
||||
x = {y: _to_serializable(x[y]) for y in x}
|
||||
if t is timedelta:
|
||||
x = x.__str__()
|
||||
return x
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: ConfigEntry):
|
||||
client: HassioEcoFlowClient = hass.data[DOMAIN][entry.entry_id]
|
||||
values = {}
|
||||
for i in client.diagnostics:
|
||||
d = client.diagnostics[i]
|
||||
values[i] = _to_serializable(d)
|
||||
return values
|
||||
78
config/custom_components/ecoflow/ecoflow/__init__.py
Normal file
78
config/custom_components/ecoflow/ecoflow/__init__.py
Normal file
@@ -0,0 +1,78 @@
|
||||
PORT = 8055
|
||||
PRODUCTS = {
|
||||
5: "RIVER",
|
||||
7: "RIVER 600 Pro",
|
||||
12: "RIVER Pro",
|
||||
13: "DELTA Max",
|
||||
14: "DELTA Pro",
|
||||
15: "DELTA Mini",
|
||||
17: "RIVER Mini",
|
||||
18: "RIVER Plus",
|
||||
20: "Smart Generator",
|
||||
}
|
||||
|
||||
_crc8_tab = [0, 7, 14, 9, 28, 27, 18, 21, 56, 63, 54, 49, 36, 35, 42, 45, 112, 119, 126, 121, 108, 107, 98, 101, 72, 79, 70, 65, 84, 83, 90, 93, 224, 231, 238, 233, 252, 251, 242, 245, 216, 223, 214, 209, 196, 195, 202, 205, 144, 151, 158, 153, 140, 139, 130, 133, 168, 175, 166, 161, 180, 179, 186, 189, 199, 192, 201, 206, 219, 220, 213, 210, 255, 248, 241, 246, 227, 228, 237, 234, 183, 176, 185, 190, 171, 172, 165, 162, 143, 136, 129, 134, 147, 148, 157, 154, 39, 32, 41, 46, 59, 60, 53, 50, 31, 24, 17, 22, 3, 4, 13, 10, 87, 80, 89, 94, 75, 76, 69, 66, 111, 104, 97, 102, 115, 116, 125,
|
||||
122, 137, 142, 135, 128, 149, 146, 155, 156, 177, 182, 191, 184, 173, 170, 163, 164, 249, 254, 247, 240, 229, 226, 235, 236, 193, 198, 207, 200, 221, 218, 211, 212, 105, 110, 103, 96, 117, 114, 123, 124, 81, 86, 95, 88, 77, 74, 67, 68, 25, 30, 23, 16, 5, 2, 11, 12, 33, 38, 47, 40, 61, 58, 51, 52, 78, 73, 64, 71, 82, 85, 92, 91, 118, 113, 120, 127, 106, 109, 100, 99, 62, 57, 48, 55, 34, 37, 44, 43, 6, 1, 8, 15, 26, 29, 20, 19, 174, 169, 160, 167, 178, 181, 188, 187, 150, 145, 152, 159, 138, 141, 132, 131, 222, 217, 208, 215, 194, 197, 204, 203, 230, 225, 232, 239, 250, 253, 244, 243]
|
||||
_crc16_tab = [0, 49345, 49537, 320, 49921, 960, 640, 49729, 50689, 1728, 1920, 51009, 1280, 50625, 50305, 1088, 52225, 3264, 3456, 52545, 3840, 53185, 52865, 3648, 2560, 51905, 52097, 2880, 51457, 2496, 2176, 51265, 55297, 6336, 6528, 55617, 6912, 56257, 55937, 6720, 7680, 57025, 57217, 8000, 56577, 7616, 7296, 56385, 5120, 54465, 54657, 5440, 55041, 6080, 5760, 54849, 53761, 4800, 4992, 54081, 4352, 53697, 53377, 4160, 61441, 12480, 12672, 61761, 13056, 62401, 62081, 12864, 13824, 63169, 63361, 14144, 62721, 13760, 13440, 62529, 15360, 64705, 64897, 15680, 65281, 16320, 16000, 65089, 64001, 15040, 15232, 64321, 14592, 63937, 63617, 14400, 10240, 59585, 59777, 10560, 60161, 11200, 10880, 59969, 60929, 11968, 12160, 61249, 11520, 60865, 60545, 11328, 58369, 9408, 9600, 58689, 9984, 59329, 59009, 9792, 8704, 58049, 58241, 9024, 57601, 8640, 8320, 57409, 40961, 24768,
|
||||
24960, 41281, 25344, 41921, 41601, 25152, 26112, 42689, 42881, 26432, 42241, 26048, 25728, 42049, 27648, 44225, 44417, 27968, 44801, 28608, 28288, 44609, 43521, 27328, 27520, 43841, 26880, 43457, 43137, 26688, 30720, 47297, 47489, 31040, 47873, 31680, 31360, 47681, 48641, 32448, 32640, 48961, 32000, 48577, 48257, 31808, 46081, 29888, 30080, 46401, 30464, 47041, 46721, 30272, 29184, 45761, 45953, 29504, 45313, 29120, 28800, 45121, 20480, 37057, 37249, 20800, 37633, 21440, 21120, 37441, 38401, 22208, 22400, 38721, 21760, 38337, 38017, 21568, 39937, 23744, 23936, 40257, 24320, 40897, 40577, 24128, 23040, 39617, 39809, 23360, 39169, 22976, 22656, 38977, 34817, 18624, 18816, 35137, 19200, 35777, 35457, 19008, 19968, 36545, 36737, 20288, 36097, 19904, 19584, 35905, 17408, 33985, 34177, 17728, 34561, 18368, 18048, 34369, 33281, 17088, 17280, 33601, 16640, 33217, 32897, 16448]
|
||||
|
||||
|
||||
def calcCrc8(data: bytes):
|
||||
crc = 0
|
||||
for i3 in range(len(data)):
|
||||
crc = _crc8_tab[(crc ^ data[i3]) & 255]
|
||||
return crc.to_bytes(1, "little")
|
||||
|
||||
|
||||
def calcCrc16(data: bytes):
|
||||
crc = 0
|
||||
for i3 in range(len(data)):
|
||||
crc = _crc16_tab[(crc ^ data[i3]) & 255] ^ (crc >> 8)
|
||||
return crc.to_bytes(2, "little")
|
||||
|
||||
|
||||
def get_model_name(product: int, model: int):
|
||||
if product == 5 and model == 2:
|
||||
return "RIVER Max"
|
||||
elif product == 18 and model == 2:
|
||||
return "RIVER Max Plus"
|
||||
else:
|
||||
return PRODUCTS.get(product, None)
|
||||
|
||||
|
||||
def has_extra(product: int, model: int):
|
||||
if product in [5, 12]:
|
||||
return model == 2
|
||||
return False
|
||||
|
||||
|
||||
def has_light(product: int):
|
||||
return product in [5, 7, 12, 18]
|
||||
|
||||
|
||||
def is_delta(product: int):
|
||||
return 12 < product < 16
|
||||
|
||||
|
||||
def is_delta_max(product: int):
|
||||
return product == 13
|
||||
|
||||
|
||||
def is_delta_mini(product: int):
|
||||
return product == 15
|
||||
|
||||
|
||||
def is_delta_pro(product: int):
|
||||
return product == 14
|
||||
|
||||
|
||||
def is_power_station(product: int):
|
||||
return is_delta(product) or is_river(product) or is_river_mini(product)
|
||||
|
||||
|
||||
def is_river(product: int):
|
||||
return product in [5, 7, 12, 18]
|
||||
|
||||
|
||||
def is_river_mini(product: int):
|
||||
return product == 17
|
||||
558
config/custom_components/ecoflow/ecoflow/receive.py
Normal file
558
config/custom_components/ecoflow/ecoflow/receive.py
Normal file
@@ -0,0 +1,558 @@
|
||||
import struct
|
||||
from datetime import timedelta
|
||||
from typing import Any, Callable, Iterable, Optional, TypedDict, cast
|
||||
|
||||
from reactivex import Observable, Observer
|
||||
|
||||
from . import calcCrc8, calcCrc16, is_delta, is_river
|
||||
|
||||
|
||||
class Serial(TypedDict):
|
||||
chk_val: int
|
||||
product: int
|
||||
product_detail: int
|
||||
model: int
|
||||
serial: str
|
||||
cpu_id: str
|
||||
|
||||
|
||||
def _merge_packet(obs: Observable[Optional[bytes]]):
|
||||
def func(sub: Observer[bytes], sched=None):
|
||||
x = b''
|
||||
|
||||
def next(rcv: Optional[bytes]):
|
||||
nonlocal x
|
||||
if rcv is None:
|
||||
x = b''
|
||||
return
|
||||
x += rcv
|
||||
while len(x) >= 18:
|
||||
if x[:2] != b'\xaa\x02':
|
||||
x = x[1:]
|
||||
continue
|
||||
size = int.from_bytes(x[2:4], 'little')
|
||||
if 18 + size > len(x):
|
||||
return
|
||||
if calcCrc8(x[:4]) != x[4:5]:
|
||||
x = x[2:]
|
||||
continue
|
||||
if calcCrc16(x[:16 + size]) != x[16 + size:18 + size]:
|
||||
x = x[2:]
|
||||
continue
|
||||
sub.on_next(x[:18 + size])
|
||||
x = x[18 + size:]
|
||||
|
||||
return obs.subscribe(next, sub.on_error, sub.on_completed, scheduler=sched)
|
||||
|
||||
return Observable[bytes](func)
|
||||
|
||||
|
||||
def _parse_dict(d: bytes, types: Iterable[tuple[str, int, Callable[[bytes], Any]]]):
|
||||
res = dict[str, Any]()
|
||||
idx = 0
|
||||
_len = len(d)
|
||||
for (name, size, fn) in types:
|
||||
if name is not None:
|
||||
res[name] = fn(d[idx:idx + size])
|
||||
idx += size
|
||||
if idx >= _len:
|
||||
break
|
||||
return res
|
||||
|
||||
|
||||
def _to_float(d: bytes) -> float:
|
||||
return struct.unpack("<f", d)[0]
|
||||
|
||||
|
||||
def _to_int(d: bytes):
|
||||
return int.from_bytes(d, "little")
|
||||
|
||||
|
||||
def _to_int_ex(div: int = 1):
|
||||
def f(d: bytes):
|
||||
v = _to_int(d)
|
||||
if v is None:
|
||||
return None
|
||||
v /= div
|
||||
return v
|
||||
return f
|
||||
|
||||
|
||||
def _to_timedelta_min(d: bytes):
|
||||
return timedelta(minutes=int.from_bytes(d, "little"))
|
||||
|
||||
|
||||
def _to_timedelta_sec(d: bytes):
|
||||
return timedelta(seconds=int.from_bytes(d, "little"))
|
||||
|
||||
|
||||
def _to_utf8(d: bytes):
|
||||
try:
|
||||
return d.decode("utf-8")
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def _to_ver(data: Iterable[int]):
|
||||
return ".".join(str(i) for i in data)
|
||||
|
||||
|
||||
def _to_ver_reversed(data: Iterable[int]):
|
||||
return _to_ver(reversed(data))
|
||||
|
||||
|
||||
def decode_packet(x: bytes):
|
||||
size = int.from_bytes(x[2:4], 'little')
|
||||
args = x[16:16 + size]
|
||||
if ((x[5] >> 5) & 3) == 1:
|
||||
# Deobfuscation
|
||||
args = bytes(v ^ x[6] for v in args)
|
||||
return (x[12], x[14], x[15], args)
|
||||
|
||||
|
||||
def is_bms(x: tuple[int, int, int]):
|
||||
return x[0:3] == (3, 32, 50) or x[0:3] == (6, 32, 2) or x[0:3] == (6, 32, 50)
|
||||
|
||||
|
||||
def is_dc_in_current_config(x: tuple[int, int, int]):
|
||||
return x[0:3] == (4, 32, 72) or x[0:3] == (5, 32, 72)
|
||||
|
||||
|
||||
def is_dc_in_type(x: tuple[int, int, int]):
|
||||
return x[0:3] == (4, 32, 68) or x[0:3] == (5, 32, 82)
|
||||
|
||||
|
||||
def is_ems(x: tuple[int, int, int]):
|
||||
return x[0:3] == (3, 32, 2)
|
||||
|
||||
|
||||
def is_fan_auto(x: tuple[int, int, int]):
|
||||
return x[0:3] == (4, 32, 74)
|
||||
|
||||
|
||||
def is_inverter(x: tuple[int, int, int]):
|
||||
return x[0:3] == (4, 32, 2)
|
||||
|
||||
|
||||
def is_lcd_timeout(x: tuple[int, int, int]):
|
||||
return x[0:3] == (2, 32, 40)
|
||||
|
||||
|
||||
def is_mppt(x: tuple[int, int, int]):
|
||||
return x[0:3] == (5, 32, 2)
|
||||
|
||||
|
||||
def is_pd(x: tuple[int, int, int]):
|
||||
return x[0:3] == (2, 32, 2)
|
||||
|
||||
|
||||
def is_serial_main(x: tuple[int, int, int]):
|
||||
return x[0] in [2, 11] and x[1:3] == (1, 65)
|
||||
|
||||
|
||||
def is_serial_extra(x: tuple[int, int, int]):
|
||||
return x[0:3] == (6, 1, 65)
|
||||
|
||||
|
||||
def parse_bms(d: bytes, product: int):
|
||||
if is_delta(product):
|
||||
return parse_bms_delta(d)
|
||||
if is_river(product):
|
||||
return parse_bms_river(d)
|
||||
return (0, {})
|
||||
|
||||
|
||||
def parse_bms_delta(d: bytes):
|
||||
val = _parse_dict(d, [
|
||||
("num", 1, _to_int),
|
||||
("battery_type", 1, _to_int),
|
||||
("battery_cell_id", 1, _to_int),
|
||||
("battery_error", 4, _to_int),
|
||||
("battery_version", 4, _to_ver_reversed),
|
||||
("battery_level", 1, _to_int),
|
||||
("battery_voltage", 4, _to_int_ex(div=1000)),
|
||||
("battery_current", 4, _to_int),
|
||||
("battery_temp", 1, _to_int),
|
||||
("_open_bms_idx", 1, _to_int),
|
||||
("battery_capacity_design", 4, _to_int),
|
||||
("battery_capacity_remain", 4, _to_int),
|
||||
("battery_capacity_full", 4, _to_int),
|
||||
("battery_cycles", 4, _to_int),
|
||||
("_soh", 1, _to_int),
|
||||
("battery_voltage_max", 2, _to_int_ex(div=1000)),
|
||||
("battery_voltage_min", 2, _to_int_ex(div=1000)),
|
||||
("battery_temp_max", 1, _to_int),
|
||||
("battery_temp_min", 1, _to_int),
|
||||
("battery_mos_temp_max", 1, _to_int),
|
||||
("battery_mos_temp_min", 1, _to_int),
|
||||
("battery_fault", 1, _to_int),
|
||||
("_sys_stat_reg", 1, _to_int),
|
||||
("_tag_chg_current", 4, _to_int),
|
||||
("battery_level_f32", 4, _to_float),
|
||||
("battery_in_power", 4, _to_int),
|
||||
("battery_out_power", 4, _to_int),
|
||||
("battery_remain", 4, _to_timedelta_min),
|
||||
])
|
||||
return (cast(int, val.pop("num")), val)
|
||||
|
||||
|
||||
def parse_bms_river(d: bytes):
|
||||
return (1, _parse_dict(d, [
|
||||
("battery_error", 4, _to_int),
|
||||
("battery_version", 4, _to_ver_reversed),
|
||||
("battery_level", 1, _to_int),
|
||||
("battery_voltage", 4, _to_int_ex(div=1000)),
|
||||
("battery_current", 4, _to_int),
|
||||
("battery_temp", 1, _to_int),
|
||||
("battery_capacity_remain", 4, _to_int),
|
||||
("battery_capacity_full", 4, _to_int),
|
||||
("battery_cycles", 4, _to_int),
|
||||
("ambient_mode", 1, _to_int),
|
||||
("ambient_animate", 1, _to_int),
|
||||
("ambient_color", 4, list),
|
||||
("ambient_brightness", 1, _to_int),
|
||||
]))
|
||||
|
||||
|
||||
def parse_dc_in_current_config(d: bytes):
|
||||
return int.from_bytes(d[:4], "little")
|
||||
|
||||
|
||||
def parse_dc_in_type(d: bytes):
|
||||
return d[1]
|
||||
|
||||
|
||||
def parse_ems(d: bytes, product: int):
|
||||
if is_delta(product):
|
||||
return parse_ems_delta(d)
|
||||
if is_river(product):
|
||||
return parse_ems_river(d)
|
||||
# if is_river_mini(product):
|
||||
# return parse_ems_river_mini(d)
|
||||
return {}
|
||||
|
||||
|
||||
def parse_ems_delta(d: bytes):
|
||||
return _parse_dict(d, [
|
||||
("_state_charge", 1, _to_int),
|
||||
("_chg_cmd", 1, _to_int),
|
||||
("_dsg_cmd", 1, _to_int),
|
||||
("battery_main_voltage", 4, _to_int_ex(div=1000)),
|
||||
("battery_main_current", 4, _to_int_ex(div=1000)),
|
||||
("_fan_level", 1, _to_int),
|
||||
("battery_level_max", 1, _to_int),
|
||||
("model", 1, _to_int),
|
||||
("battery_main_level", 1, _to_int),
|
||||
("_flag_open_ups", 1, _to_int),
|
||||
("battery_main_warning", 1, _to_int),
|
||||
("battery_remain_charge", 4, _to_timedelta_min),
|
||||
("battery_remain_discharge", 4, _to_timedelta_min),
|
||||
("battery_main_normal", 1, _to_int),
|
||||
("battery_main_level_f32", 4, _to_float),
|
||||
("_is_connect", 3, _to_int),
|
||||
("_max_available_num", 1, _to_int),
|
||||
("_open_bms_idx", 1, _to_int),
|
||||
("battery_main_voltage_min", 4, _to_int_ex(div=1000)),
|
||||
("battery_main_voltage_max", 4, _to_int_ex(div=1000)),
|
||||
("battery_level_min", 1, _to_int),
|
||||
("generator_level_start", 1, _to_int),
|
||||
("generator_level_stop", 1, _to_int),
|
||||
])
|
||||
|
||||
|
||||
def parse_ems_river(d: bytes):
|
||||
return _parse_dict(d, [
|
||||
("battery_main_error", 4, _to_int),
|
||||
("battery_main_version", 4, _to_ver_reversed),
|
||||
("battery_main_level", 1, _to_int),
|
||||
("battery_main_voltage", 4, _to_int_ex(div=1000)),
|
||||
("battery_main_current", 4, _to_int),
|
||||
("battery_main_temp", 1, _to_int),
|
||||
("_open_bms_idx", 1, _to_int),
|
||||
("battery_capacity_remain", 4, _to_int),
|
||||
("battery_capacity_full", 4, _to_int),
|
||||
("battery_cycles", 4, _to_int),
|
||||
("battery_level_max", 1, _to_int),
|
||||
("battery_main_voltage_max", 2, _to_int_ex(div=1000)),
|
||||
("battery_main_voltage_min", 2, _to_int_ex(div=1000)),
|
||||
("battery_main_temp_max", 1, _to_int),
|
||||
("battery_main_temp_min", 1, _to_int),
|
||||
("mos_temp_max", 1, _to_int),
|
||||
("mos_temp_min", 1, _to_int),
|
||||
("battery_main_fault", 1, _to_int),
|
||||
("_bq_sys_stat_reg", 1, _to_int),
|
||||
("_tag_chg_amp", 4, _to_int),
|
||||
])
|
||||
|
||||
|
||||
# def parse_ems_river_mini(d: bytes):
|
||||
# pass
|
||||
|
||||
def parse_fan_auto(d: bytes):
|
||||
return d[0] == 1
|
||||
|
||||
|
||||
def parse_inverter(d: bytes, product: int):
|
||||
if is_delta(product):
|
||||
return parse_inverter_delta(d)
|
||||
if is_river(product):
|
||||
return parse_inverter_river(d)
|
||||
# if is_river_mini(product):
|
||||
# return parse_pd_river_mini(d)
|
||||
return {}
|
||||
|
||||
|
||||
def parse_inverter_delta(d: bytes):
|
||||
return _parse_dict(d, [
|
||||
("ac_error", 4, _to_int),
|
||||
("ac_version", 4, _to_ver_reversed),
|
||||
("ac_in_type", 1, _to_int),
|
||||
("ac_in_power", 2, _to_int),
|
||||
("ac_out_power", 2, _to_int),
|
||||
("ac_type", 1, _to_int),
|
||||
("ac_out_voltage", 4, _to_int_ex(div=1000)),
|
||||
("ac_out_current", 4, _to_int_ex(div=1000)),
|
||||
("ac_out_freq", 1, _to_int),
|
||||
("ac_in_voltage", 4, _to_int_ex(div=1000)),
|
||||
("ac_in_current", 4, _to_int_ex(div=1000)),
|
||||
("ac_in_freq", 1, _to_int),
|
||||
("ac_out_temp", 2, _to_int),
|
||||
("dc_in_voltage", 4, _to_int),
|
||||
("dc_in_current", 4, _to_int),
|
||||
("ac_in_temp", 2, _to_int),
|
||||
("fan_state", 1, _to_int),
|
||||
("ac_out_state", 1, _to_int),
|
||||
("ac_out_xboost", 1, _to_int),
|
||||
("ac_out_voltage_config", 4, _to_int_ex(div=1000)),
|
||||
("ac_out_freq_config", 1, _to_int),
|
||||
("fan_config", 1, _to_int),
|
||||
("ac_in_pause", 1, _to_int),
|
||||
("ac_in_limit_switch", 1, _to_int),
|
||||
("ac_in_limit_max", 2, _to_int),
|
||||
("ac_in_limit_custom", 2, _to_int),
|
||||
("ac_out_timeout", 2, _to_int),
|
||||
])
|
||||
|
||||
|
||||
def parse_inverter_river(d: bytes):
|
||||
return _parse_dict(d, [
|
||||
("ac_error", 4, _to_int),
|
||||
("ac_version", 4, _to_ver_reversed),
|
||||
("in_type", 1, _to_int),
|
||||
("in_power", 2, _to_int),
|
||||
("ac_out_power", 2, _to_int),
|
||||
("ac_type", 1, _to_int),
|
||||
("ac_out_voltage", 4, _to_int_ex(div=1000)),
|
||||
("ac_out_current", 4, _to_int_ex(div=1000)),
|
||||
("ac_out_freq", 1, _to_int),
|
||||
("ac_in_voltage", 4, _to_int_ex(div=1000)),
|
||||
("ac_in_current", 4, _to_int_ex(div=1000)),
|
||||
("ac_in_freq", 1, _to_int),
|
||||
("ac_out_temp", 1, _to_int),
|
||||
("dc_in_voltage", 4, _to_int_ex(div=1000)),
|
||||
("dc_in_current", 4, _to_int_ex(div=1000)),
|
||||
("ac_in_temp", 1, _to_int),
|
||||
("fan_state", 1, _to_int),
|
||||
("ac_out_state", 1, _to_int),
|
||||
("ac_out_xboost", 1, _to_int),
|
||||
("ac_out_voltage_config", 4, _to_int_ex(div=1000)),
|
||||
("ac_out_freq_config", 1, _to_int),
|
||||
("ac_in_slow", 1, _to_int),
|
||||
("ac_out_timeout", 2, _to_int),
|
||||
("fan_config", 1, _to_int),
|
||||
])
|
||||
|
||||
|
||||
def parse_lcd_timeout(d: bytes):
|
||||
return int.from_bytes(d[1:3], "little")
|
||||
|
||||
|
||||
def parse_mppt(d: bytes, product: int):
|
||||
if is_delta(product):
|
||||
return parse_mppt_delta(d)
|
||||
return {}
|
||||
|
||||
|
||||
def parse_mppt_delta(d: bytes):
|
||||
return _parse_dict(d, [
|
||||
("dc_in_error", 4, _to_int),
|
||||
("dc_in_version", 4, _to_ver_reversed),
|
||||
("dc_in_voltage", 4, _to_int_ex(div=10)),
|
||||
("dc_in_current", 4, _to_int_ex(div=100)),
|
||||
("dc_in_power", 2, _to_int_ex(div=10)),
|
||||
("_volt_?_out", 4, _to_int),
|
||||
("_curr_?_out", 4, _to_int),
|
||||
("_watts_?_out", 2, _to_int),
|
||||
("dc_in_temp", 2, _to_int),
|
||||
("dc_in_type", 1, _to_int),
|
||||
("dc_in_type_config", 1, _to_int),
|
||||
("_dc_in_type", 1, _to_int),
|
||||
("dc_in_state", 1, _to_int),
|
||||
("anderson_out_voltage", 4, _to_int),
|
||||
("anderson_out_current", 4, _to_int),
|
||||
("anderson_out_power", 2, _to_int),
|
||||
("car_out_voltage", 4, _to_int_ex(div=10)),
|
||||
("car_out_current", 4, _to_int_ex(div=100)),
|
||||
("car_out_power", 2, _to_int_ex(div=10)),
|
||||
("car_out_temp", 2, _to_int),
|
||||
("car_out_state", 1, _to_int),
|
||||
("dc24_temp", 2, _to_int),
|
||||
("dc24_state", 1, _to_int),
|
||||
("dc_in_pause", 1, _to_int),
|
||||
("_dc_in_switch", 1, _to_int),
|
||||
("_dc_in_limit_max", 2, _to_int),
|
||||
("_dc_in_limit_custom", 2, _to_int),
|
||||
])
|
||||
|
||||
|
||||
def parse_pd(d: bytes, product: int):
|
||||
if is_delta(product):
|
||||
return parse_pd_delta(d)
|
||||
if is_river(product):
|
||||
return parse_pd_river(d)
|
||||
# if is_river_mini(product):
|
||||
# return parse_pd_river_mini(d)
|
||||
return {}
|
||||
|
||||
|
||||
def parse_pd_delta(d: bytes):
|
||||
return _parse_dict(d, [
|
||||
("model", 1, _to_int),
|
||||
("pd_error", 4, _to_int),
|
||||
("pd_version", 4, _to_ver_reversed),
|
||||
("wifi_version", 4, _to_ver_reversed),
|
||||
("wifi_autorecovery", 1, _to_int),
|
||||
("battery_level", 1, _to_int),
|
||||
("out_power", 2, _to_int),
|
||||
("in_power", 2, _to_int),
|
||||
("remain_display", 4, _to_timedelta_min),
|
||||
("beep", 1, _to_int),
|
||||
("_watts_anderson_out", 1, _to_int),
|
||||
("usb_out1_power", 1, _to_int),
|
||||
("usb_out2_power", 1, _to_int),
|
||||
("usbqc_out1_power", 1, _to_int),
|
||||
("usbqc_out2_power", 1, _to_int),
|
||||
("typec_out1_power", 1, _to_int),
|
||||
("typec_out2_power", 1, _to_int),
|
||||
("typec_out1_temp", 1, _to_int),
|
||||
("typec_out2_temp", 1, _to_int),
|
||||
("car_out_state", 1, _to_int),
|
||||
("car_out_power", 1, _to_int),
|
||||
("car_out_temp", 1, _to_int),
|
||||
("standby_timeout", 2, _to_int),
|
||||
("lcd_timeout", 2, _to_int),
|
||||
("lcd_brightness", 1, _to_int),
|
||||
("car_in_energy", 4, _to_int),
|
||||
("mppt_in_energy", 4, _to_int),
|
||||
("ac_in_energy", 4, _to_int),
|
||||
("car_out_energy", 4, _to_int),
|
||||
("ac_out_energy", 4, _to_int),
|
||||
("usb_time", 4, _to_timedelta_sec),
|
||||
("typec_time", 4, _to_timedelta_sec),
|
||||
("car_out_time", 4, _to_timedelta_sec),
|
||||
("ac_out_time", 4, _to_timedelta_sec),
|
||||
("ac_in_time", 4, _to_timedelta_sec),
|
||||
("car_in_time", 4, _to_timedelta_sec),
|
||||
("mppt_time", 4, _to_timedelta_sec),
|
||||
(None, 2, None),
|
||||
("_ext_rj45", 1, _to_int),
|
||||
("_ext_infinity", 1, _to_int),
|
||||
])
|
||||
|
||||
|
||||
def parse_pd_river(d: bytes):
|
||||
return _parse_dict(d, [
|
||||
("model", 1, _to_int),
|
||||
("pd_error", 4, _to_int),
|
||||
("pd_version", 4, _to_ver_reversed),
|
||||
("battery_level", 1, _to_int),
|
||||
("out_power", 2, _to_int),
|
||||
("in_power", 2, _to_int),
|
||||
("remain_display", 4, _to_timedelta_min),
|
||||
("car_out_state", 1, _to_int),
|
||||
("light_state", 1, _to_int),
|
||||
("beep", 1, _to_int),
|
||||
("typec_out1_power", 1, _to_int),
|
||||
("usb_out1_power", 1, _to_int),
|
||||
("usb_out2_power", 1, _to_int),
|
||||
("usbqc_out1_power", 1, _to_int),
|
||||
("car_out_power", 1, _to_int),
|
||||
("light_power", 1, _to_int),
|
||||
("typec_out1_temp", 1, _to_int),
|
||||
("car_out_temp", 1, _to_int),
|
||||
("standby_timeout", 2, _to_int),
|
||||
("car_in_energy", 4, _to_int),
|
||||
("mppt_in_energy", 4, _to_int),
|
||||
("ac_in_energy", 4, _to_int),
|
||||
("car_out_energy", 4, _to_int),
|
||||
("ac_out_energy", 4, _to_int),
|
||||
("usb_time", 4, _to_timedelta_sec),
|
||||
("usbqc_time", 4, _to_timedelta_sec),
|
||||
("typec_time", 4, _to_timedelta_sec),
|
||||
("car_out_time", 4, _to_timedelta_sec),
|
||||
("ac_out_time", 4, _to_timedelta_sec),
|
||||
("car_in_time", 4, _to_timedelta_sec),
|
||||
("mppt_time", 4, _to_timedelta_sec),
|
||||
])
|
||||
|
||||
|
||||
# def parse_pd_river_mini(d: bytes):
|
||||
# return _parse_dict(d, [
|
||||
# ("model", 1, _to_int),
|
||||
# ("pd_error", 4, _to_int),
|
||||
# ("pd_version", 4, _to_ver_reversed),
|
||||
# ("wifi_version", 4, _to_ver_reversed),
|
||||
# ("wifi_autorecovery", 1,),
|
||||
# ("soc_sum", 1, _to_int),
|
||||
# ("watts_out_sum", 2, _to_int),
|
||||
# ("watts_in_sum", 2, _to_int),
|
||||
# ("remain_time", 4, _to_int),
|
||||
# ("beep", 1, _to_int),
|
||||
# ("dc_out", 1, _to_int),
|
||||
# ("usb1_watts", 1, _to_int),
|
||||
# ("usb2_watts", 1, _to_int),
|
||||
# ("usbqc1_watts", 1, _to_int),
|
||||
# ("usbqc2_watts", 1, _to_int),
|
||||
# ("typec1_watts", 1, _to_int),
|
||||
# ("typec2_watts", 1, _to_int),
|
||||
# ("typec1_temp", 1, _to_int),
|
||||
# ("typec2_temp", 1, _to_int),
|
||||
# ("dc_out_watts", 1, _to_int),
|
||||
# ("car_out_temp", 1, _to_int),
|
||||
# ("standby_timeout", 2, _to_int),
|
||||
# ("lcd_sec", 2, _to_int),
|
||||
# ("lcd_brightness", 1, _to_int),
|
||||
# ("chg_power_dc", 4, _to_int),
|
||||
# ("chg_power_mppt", 4, _to_int),
|
||||
# ("chg_power_ac", 4, _to_int),
|
||||
# ("dsg_power_dc", 4, _to_int),
|
||||
# ("dsg_power_ac", 4, _to_int),
|
||||
# ("usb_used_time", 4, _to_int),
|
||||
# ("usbqc_used_time", 4, _to_int),
|
||||
# ("typec_used_time", 4, _to_int),
|
||||
# ("dc_out_used_time", 4, _to_int),
|
||||
# ("ac_out_used_time", 4, _to_int),
|
||||
# ("dc_in_used_time", 4, _to_int),
|
||||
# ("mppt_used_time", 4, _to_int),
|
||||
# (None, 5, None),
|
||||
# ("sys_chg_flag", 1, _to_int),
|
||||
# ("wifi_rssi", 1, _to_int),
|
||||
# ("wifi_watts", 1, _to_int),
|
||||
# ])
|
||||
|
||||
|
||||
def parse_serial(d: bytes) -> Serial:
|
||||
return _parse_dict(d, [
|
||||
("chk_val", 4, _to_int),
|
||||
("product", 1, _to_int),
|
||||
(None, 1, None),
|
||||
("product_detail", 1, _to_int),
|
||||
("model", 1, _to_int),
|
||||
("serial", 15, _to_utf8),
|
||||
(None, 1, None),
|
||||
("cpu_id", 12, _to_utf8),
|
||||
])
|
||||
|
||||
|
||||
def merge_packet():
|
||||
return _merge_packet
|
||||
84
config/custom_components/ecoflow/ecoflow/rxtcp.py
Normal file
84
config/custom_components/ecoflow/ecoflow/rxtcp.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from asyncio import Future, create_task, open_connection, sleep
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from reactivex import Subject
|
||||
|
||||
_LOGGER = getLogger(__name__)
|
||||
|
||||
|
||||
class RxTcpAutoConnection:
|
||||
__rx = None
|
||||
__tx = None
|
||||
|
||||
def __init__(self, host: str, port: int):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.received = Subject[Optional[bytes]]()
|
||||
self.__is_open = True
|
||||
self.__task = create_task(self.__loop())
|
||||
self.__opened = Future()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
self.close()
|
||||
await self.wait_closed()
|
||||
|
||||
def close(self):
|
||||
self.__is_open = False
|
||||
if self.__rx:
|
||||
self.__rx.feed_eof()
|
||||
|
||||
async def drain(self):
|
||||
await self.__tx.drain()
|
||||
|
||||
def reconnect(self):
|
||||
if self.__rx:
|
||||
self.__rx.feed_eof()
|
||||
|
||||
async def wait_closed(self):
|
||||
try:
|
||||
await self.__task
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
await self.__tx.wait_closed()
|
||||
except:
|
||||
pass
|
||||
|
||||
async def wait_opened(self):
|
||||
await self.__opened
|
||||
|
||||
def write(self, data: bytes):
|
||||
self.__tx.write(data)
|
||||
|
||||
async def __loop(self):
|
||||
while self.__is_open:
|
||||
_LOGGER.debug(f"connecting {self.host}")
|
||||
try:
|
||||
(self.__rx, self.__tx) = await open_connection(self.host, self.port)
|
||||
except Exception as ex:
|
||||
_LOGGER.debug(ex)
|
||||
await sleep(1)
|
||||
continue
|
||||
_LOGGER.debug(f"connected {self.host}")
|
||||
if not self.__opened.done():
|
||||
self.__opened.set_result(None)
|
||||
try:
|
||||
while not self.__rx.at_eof():
|
||||
data = await self.__rx.read(1024)
|
||||
if data:
|
||||
self.received.on_next(data)
|
||||
except Exception as ex:
|
||||
if type(ex) is not TimeoutError:
|
||||
_LOGGER.exception(ex)
|
||||
except BaseException as ex:
|
||||
self.received.on_error(ex)
|
||||
return
|
||||
finally:
|
||||
self.__rx.feed_eof()
|
||||
self.__tx.close()
|
||||
self.received.on_next(None)
|
||||
self.received.on_completed()
|
||||
193
config/custom_components/ecoflow/ecoflow/send.py
Normal file
193
config/custom_components/ecoflow/ecoflow/send.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from typing import Optional
|
||||
|
||||
from . import calcCrc8, calcCrc16, is_delta, is_river_mini
|
||||
|
||||
NO_USB_SWITCH = {5, 7, 12, 14, 15, 18}
|
||||
|
||||
|
||||
def _btoi(b: Optional[bool]):
|
||||
if b is None:
|
||||
return 255
|
||||
return 1 if b else 0
|
||||
|
||||
|
||||
def build2(dst: int, cmd_set: int, cmd_id: int, data: bytes = b''):
|
||||
b = bytes([170, 2])
|
||||
b += len(data).to_bytes(2, "little")
|
||||
b += calcCrc8(b)
|
||||
b += bytes([13, 0, 0, 0, 0, 0, 0, 32, dst, cmd_set, cmd_id])
|
||||
b += data
|
||||
b += calcCrc16(b)
|
||||
return b
|
||||
|
||||
|
||||
def get_product_info(dst: int):
|
||||
return build2(dst, 1, 5)
|
||||
|
||||
|
||||
def get_cpu_id():
|
||||
return build2(2, 1, 64)
|
||||
|
||||
|
||||
def get_serial_main():
|
||||
return build2(2, 1, 65)
|
||||
|
||||
|
||||
def get_pd():
|
||||
return build2(2, 32, 2, b'\0')
|
||||
|
||||
|
||||
def reset():
|
||||
return build2(2, 32, 3)
|
||||
|
||||
|
||||
def set_standby_timeout(value: int):
|
||||
return build2(2, 32, 33, value.to_bytes(2, "little"))
|
||||
|
||||
|
||||
def set_usb(enable: bool):
|
||||
return build2(2, 32, 34, bytes([1 if enable else 0]))
|
||||
|
||||
|
||||
def set_light(product: int, value: int):
|
||||
return build2(2, 32, 35, bytes([value]))
|
||||
|
||||
|
||||
def set_dc_out(product: int, enable: bool):
|
||||
if is_delta(product):
|
||||
cmd = (5, 32, 81)
|
||||
elif product == 20:
|
||||
cmd = (8, 8, 3)
|
||||
elif product in [5, 7, 12, 18]:
|
||||
cmd = (2, 32, 34)
|
||||
else:
|
||||
cmd = (2, 32, 37)
|
||||
return build2(*cmd, bytes([1 if enable else 0]))
|
||||
|
||||
|
||||
def set_beep(enable: bool):
|
||||
return build2(2, 32, 38, bytes([0 if enable else 1]))
|
||||
|
||||
|
||||
def set_lcd(product: int, time: int = 0xFFFF, light: int = 255):
|
||||
arg = time.to_bytes(2, "little")
|
||||
if is_delta(product) or is_river_mini(product):
|
||||
arg += bytes([light])
|
||||
return build2(2, 32, 39, arg)
|
||||
|
||||
|
||||
def get_lcd():
|
||||
return build2(2, 32, 40)
|
||||
|
||||
|
||||
def close(value: int):
|
||||
return build2(2, 32, 41, value.to_bytes(2, "little"))
|
||||
|
||||
|
||||
def get_ems_main():
|
||||
return build2(3, 32, 2)
|
||||
|
||||
|
||||
def set_level_max(product: int, value: int):
|
||||
dst = 4 if product == 17 else 3
|
||||
return build2(dst, 32, 49, bytes([value]))
|
||||
|
||||
|
||||
def set_level_min(value: int):
|
||||
return build2(3, 32, 51, bytes([value]))
|
||||
|
||||
|
||||
def set_generate_start(value: int):
|
||||
return build2(3, 32, 52, bytes([value]))
|
||||
|
||||
|
||||
def set_generate_stop(value: int):
|
||||
return build2(3, 32, 53, bytes([value]))
|
||||
|
||||
|
||||
def get_inverter():
|
||||
return build2(4, 32, 2)
|
||||
|
||||
|
||||
def set_ac_in_slow(value: bool):
|
||||
return build2(4, 32, 65, bytes([_btoi(value)]))
|
||||
|
||||
|
||||
def set_ac_out(product: int, enable: bool = None, xboost: bool = None, freq: int = 255):
|
||||
if product == 20:
|
||||
cmd = (8, 8, 2)
|
||||
arg = [_btoi(enable)]
|
||||
else:
|
||||
cmd = (4, 32, 66)
|
||||
arg = [_btoi(enable), _btoi(xboost), 255, 255, 255, 255, freq]
|
||||
return build2(*cmd, bytes(arg))
|
||||
|
||||
|
||||
def set_dc_in_type(product: int, value: int):
|
||||
if is_delta(product):
|
||||
cmd = (5, 32, 82)
|
||||
else:
|
||||
cmd = (4, 32, 67)
|
||||
return build2(*cmd, bytes([value]))
|
||||
|
||||
|
||||
def get_dc_in_type(product: int):
|
||||
if is_delta(product):
|
||||
cmd = (5, 32, 82)
|
||||
else:
|
||||
cmd = (4, 32, 68)
|
||||
return build2(*cmd, bytes([0]))
|
||||
|
||||
|
||||
def set_ac_in_limit(watts: int = 0xFFFF, pause: bool = None):
|
||||
arg = bytes([255, 255])
|
||||
arg += watts.to_bytes(2, "little")
|
||||
arg += bytes([_btoi(pause)])
|
||||
return build2(4, 32, 69, arg)
|
||||
|
||||
|
||||
def set_dc_in_current(product: int, value: int):
|
||||
dst = 5 if is_delta(product) else 4
|
||||
return build2(dst, 32, 71, value.to_bytes(4, "little"))
|
||||
|
||||
|
||||
def get_dc_in_current(product: int):
|
||||
dst = 5 if is_delta(product) else 4
|
||||
return build2(dst, 32, 72)
|
||||
|
||||
|
||||
def set_fan_auto(product: int, value: bool):
|
||||
return build2(4, 32, 73, bytes([1 if value else 3]))
|
||||
|
||||
|
||||
def get_fan_auto():
|
||||
return build2(4, 32, 74)
|
||||
|
||||
|
||||
def get_lab():
|
||||
return build2(4, 32, 84)
|
||||
|
||||
|
||||
def set_lab(value: int):
|
||||
return build2(4, 32, 84, bytes([value]))
|
||||
|
||||
|
||||
def set_ac_timeout(value: int):
|
||||
return build2(4, 32, 153, value.to_bytes(2, "little"))
|
||||
|
||||
|
||||
def get_serial_extra():
|
||||
return build2(6, 1, 65)
|
||||
|
||||
|
||||
def get_ems_extra():
|
||||
return build2(6, 32, 2)
|
||||
|
||||
|
||||
def set_ambient(mode: int = 255, animate: int = 255, color=(255, 255, 255, 255), brightness=255):
|
||||
arg = [mode, animate, *color, brightness]
|
||||
return build2(6, 32, 97, bytes(arg))
|
||||
|
||||
|
||||
def _set_watt(value: int):
|
||||
return build2(8, 8, 7, value.to_bytes(2, "little"))
|
||||
100
config/custom_components/ecoflow/light.py
Normal file
100
config/custom_components/ecoflow/light.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import (ColorMode, LightEntity,
|
||||
LightEntityFeature)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import DOMAIN, EcoFlowEntity, HassioEcoFlowClient, select_bms
|
||||
from .ecoflow import is_river, send
|
||||
|
||||
_EFFECTS = ["Low", "High", "SOS"]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||
client: HassioEcoFlowClient = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = []
|
||||
|
||||
if is_river(client.product):
|
||||
entities.extend([
|
||||
LedEntity(client, client.pd, "light_state", "Light"),
|
||||
])
|
||||
if client.product == 5: # RIVER Max
|
||||
entities.extend([
|
||||
AmbientEntity(client, client.bms.pipe(
|
||||
select_bms(1)), "ambient", "Ambient light", 1),
|
||||
])
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AmbientEntity(LightEntity, EcoFlowEntity):
|
||||
_attr_effect_list = ["Default", "Breathe", "Flow", "Dynamic", "Rainbow"]
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:led-strip"
|
||||
_attr_supported_color_modes = {ColorMode.RGB, ColorMode.BRIGHTNESS}
|
||||
_attr_supported_features = LightEntityFeature.EFFECT
|
||||
_last_mode = 1
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
self._client.tcp.write(send.set_ambient(0))
|
||||
|
||||
async def async_turn_on(self, brightness=None, rgb_color=None, effect=None, **kwargs):
|
||||
if brightness is None:
|
||||
brightness = 255
|
||||
else:
|
||||
brightness = int(brightness * 100 / 255)
|
||||
|
||||
if rgb_color is None:
|
||||
rgb_color = (255, 255, 255, 255)
|
||||
else:
|
||||
rgb_color = list[int](rgb_color)
|
||||
rgb_color.append(0)
|
||||
|
||||
if effect is None:
|
||||
effect = 255
|
||||
else:
|
||||
effect = self._attr_effect_list.index(effect)
|
||||
|
||||
self._client.tcp.write(send.set_ambient(
|
||||
self._last_mode, effect, rgb_color, brightness))
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_is_on = data["ambient_mode"] != 0
|
||||
self._attr_brightness = int(data["ambient_brightness"] * 255 / 100)
|
||||
if self._attr_is_on:
|
||||
self._last_mode = data["ambient_mode"]
|
||||
self._attr_effect = self._attr_effect_list[data["ambient_animate"]]
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS if data[
|
||||
"ambient_animate"] > 1 else ColorMode.RGB
|
||||
else:
|
||||
self._attr_effect = None
|
||||
self._attr_color_mode = None
|
||||
self._attr_rgb_color = data["ambient_color"][0:3]
|
||||
|
||||
|
||||
class LedEntity(LightEntity, EcoFlowEntity):
|
||||
_attr_effect = _EFFECTS[0]
|
||||
_attr_effect_list = _EFFECTS
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
_attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
value = data[self._key]
|
||||
if value != 0:
|
||||
self._attr_is_on = True
|
||||
self._attr_effect = _EFFECTS[value - 1]
|
||||
else:
|
||||
self._attr_is_on = False
|
||||
self._attr_effect = None
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
self._client.tcp.write(send.set_light(self._client.product, 0))
|
||||
|
||||
async def async_turn_on(self, effect: str = None, **kwargs):
|
||||
if not effect:
|
||||
effect = self.effect or _EFFECTS[0]
|
||||
self._client.tcp.write(send.set_light(
|
||||
self._client.product, _EFFECTS.index(effect) + 1))
|
||||
21
config/custom_components/ecoflow/manifest.json
Normal file
21
config/custom_components/ecoflow/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"domain": "ecoflow",
|
||||
"name": "Ecoflow",
|
||||
"version": "2.1",
|
||||
"documentation": "https://github.com/vwt12eh8/hassio-ecoflow",
|
||||
"issue_tracker": "https://github.com/vwt12eh8/hassio-ecoflow/issues",
|
||||
"requirements": [
|
||||
"reactivex"
|
||||
],
|
||||
"config_flow": true,
|
||||
"codeowners": [
|
||||
"@vwt12eh8"
|
||||
],
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "ecoflow_*",
|
||||
"macaddress": "*"
|
||||
}
|
||||
],
|
||||
"iot_class": "local_push"
|
||||
}
|
||||
175
config/custom_components/ecoflow/number.py
Normal file
175
config/custom_components/ecoflow/number.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.number import NumberEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ELECTRIC_CURRENT_AMPERE, PERCENTAGE, POWER_WATT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import (DOMAIN, EcoFlowConfigEntity, EcoFlowEntity, HassioEcoFlowClient,
|
||||
request)
|
||||
from .ecoflow import (is_delta, is_delta_max, is_delta_mini, is_delta_pro,
|
||||
is_power_station, send)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||
client: HassioEcoFlowClient = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = []
|
||||
|
||||
if is_power_station(client.product):
|
||||
entities.extend([
|
||||
DcInCurrentEntity(client, "dc_in_current_config",
|
||||
"Car input"),
|
||||
MaxLevelEntity(client, client.ems,
|
||||
"battery_level_max", "Charge level"),
|
||||
])
|
||||
if is_delta(client.product):
|
||||
entities.extend([
|
||||
ChargeWattsEntity(client, client.inverter,
|
||||
"ac_in_limit_custom", "AC charge speed"),
|
||||
LcdBrightnessEntity(client, client.pd,
|
||||
"lcd_brightness", "Screen brightness"),
|
||||
MinLevelEntity(client, client.ems,
|
||||
"battery_level_min", "Discharge level"),
|
||||
])
|
||||
if is_delta_pro(client.product):
|
||||
entities.extend([
|
||||
GenerateStartEntity(
|
||||
client, client.ems, "generator_level_start", "Smart generator auto on"),
|
||||
GenerateStopEntity(
|
||||
client, client.ems, "generator_level_stop", "Smart generator auto off"),
|
||||
])
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BaseEntity(NumberEntity, EcoFlowEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_native_value = data[self._key]
|
||||
|
||||
|
||||
class ChargeWattsEntity(BaseEntity):
|
||||
_attr_icon = "mdi:car-speed-limiter"
|
||||
_attr_native_max_value = 1500
|
||||
_attr_native_min_value = 200
|
||||
_attr_native_step = 100
|
||||
_attr_native_unit_of_measurement = POWER_WATT
|
||||
|
||||
async def async_set_native_value(self, value: float):
|
||||
self._client.tcp.write(send.set_ac_in_limit(int(value)))
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
super()._on_updated(data)
|
||||
voltage: float = data["ac_out_voltage_config"]
|
||||
if is_delta_max(self._client.product):
|
||||
if self._client.serial.startswith("DD"):
|
||||
self._attr_native_max_value = 1600
|
||||
elif voltage >= 220:
|
||||
self._attr_native_max_value = 2000
|
||||
elif voltage >= 120:
|
||||
self._attr_native_max_value = 1800
|
||||
elif voltage >= 110:
|
||||
self._attr_native_max_value = 1650
|
||||
else:
|
||||
self._attr_native_max_value = 1500
|
||||
elif is_delta_pro(self._client.product):
|
||||
if voltage >= 240:
|
||||
self._attr_native_max_value = 3000
|
||||
elif voltage >= 230:
|
||||
self._attr_native_max_value = 2900
|
||||
elif voltage >= 220:
|
||||
self._attr_native_max_value = 2200
|
||||
elif voltage >= 120:
|
||||
self._attr_native_max_value = 1800
|
||||
elif voltage >= 110:
|
||||
self._attr_native_max_value = 1650
|
||||
else:
|
||||
self._attr_native_max_value = 1500
|
||||
elif is_delta_mini(self._client.product):
|
||||
self._attr_native_max_value = 900
|
||||
else:
|
||||
self._attr_native_max_value = 1500
|
||||
|
||||
|
||||
class DcInCurrentEntity(NumberEntity, EcoFlowConfigEntity):
|
||||
_attr_icon = "mdi:car-speed-limiter"
|
||||
_attr_native_max_value = 8
|
||||
_attr_native_min_value = 4
|
||||
_attr_native_step = 2
|
||||
_attr_native_unit_of_measurement = ELECTRIC_CURRENT_AMPERE
|
||||
|
||||
async def async_set_native_value(self, value: float):
|
||||
self._client.tcp.write(send.set_dc_in_current(
|
||||
self._client.product, int(value * 1000)))
|
||||
|
||||
async def async_update(self):
|
||||
try:
|
||||
value = await request(self._client.tcp, send.get_dc_in_current(self._client.product), self._client.dc_in_current_config)
|
||||
except:
|
||||
return
|
||||
self._client.diagnostics["dc_in_current_config"] = value
|
||||
self._attr_native_value = int(value / 1000)
|
||||
self._attr_available = True
|
||||
|
||||
|
||||
class GenerateStartEntity(BaseEntity):
|
||||
_attr_icon = "mdi:engine-outline"
|
||||
_attr_native_max_value = 30
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
|
||||
async def async_set_native_value(self, value: float):
|
||||
self._client.tcp.write(send.set_generate_start(int(value)))
|
||||
|
||||
|
||||
class GenerateStopEntity(BaseEntity):
|
||||
_attr_icon = "mdi:engine-off-outline"
|
||||
_attr_native_max_value = 100
|
||||
_attr_native_min_value = 50
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
|
||||
async def async_set_native_value(self, value: float):
|
||||
self._client.tcp.write(send.set_generate_stop(int(value)))
|
||||
|
||||
|
||||
class LcdBrightnessEntity(BaseEntity):
|
||||
_attr_icon = "mdi:brightness-6"
|
||||
_attr_native_max_value = 100
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_native_value = data[self._key] & 0x7F
|
||||
|
||||
async def async_set_native_value(self, value: float):
|
||||
self._client.tcp.write(send.set_lcd(
|
||||
self._client.product, light=int(value)))
|
||||
|
||||
|
||||
class MaxLevelEntity(BaseEntity):
|
||||
_attr_icon = "mdi:battery-arrow-up"
|
||||
_attr_native_max_value = 100
|
||||
_attr_native_min_value = 30
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
|
||||
async def async_set_native_value(self, value: float):
|
||||
self._client.tcp.write(send.set_level_max(
|
||||
self._client.product, int(value)))
|
||||
|
||||
|
||||
class MinLevelEntity(BaseEntity):
|
||||
_attr_icon = "mdi:battery-arrow-down-outline"
|
||||
_attr_native_max_value = 30
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_step = 1
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
|
||||
async def async_set_native_value(self, value: float):
|
||||
self._client.tcp.write(send.set_level_min(int(value)))
|
||||
196
config/custom_components/ecoflow/select.py
Normal file
196
config/custom_components/ecoflow/select.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import FREQUENCY_HERTZ
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import (DOMAIN, EcoFlowConfigEntity, EcoFlowEntity, HassioEcoFlowClient,
|
||||
request)
|
||||
from .ecoflow import is_delta, is_power_station, is_river, send
|
||||
|
||||
_AC_OPTIONS = {
|
||||
"Never": 0,
|
||||
"2hour": 120,
|
||||
"4hour": 240,
|
||||
"6hour": 360,
|
||||
"12hour": 720,
|
||||
"24hour": 1440,
|
||||
}
|
||||
|
||||
_FREQS = {
|
||||
"50Hz": 1,
|
||||
"60Hz": 2,
|
||||
}
|
||||
|
||||
_DC_IMPUTS = {
|
||||
"Auto": 0,
|
||||
"Solar": 1,
|
||||
"Car": 2,
|
||||
}
|
||||
|
||||
_DC_ICONS = {
|
||||
"Auto": None,
|
||||
"MPPT": "mdi:solar-power",
|
||||
"DC": "mdi:current-dc",
|
||||
}
|
||||
|
||||
_LCD_OPTIONS = {
|
||||
"Never": 0,
|
||||
"10sec": 10,
|
||||
"30sec": 30,
|
||||
"1min": 60,
|
||||
"5min": 300,
|
||||
"30min": 1800,
|
||||
}
|
||||
|
||||
_STANDBY_OPTIONS = {
|
||||
"Never": 0,
|
||||
"30min": 30,
|
||||
"1hour": 60,
|
||||
"2hour": 120,
|
||||
"6hour": 360,
|
||||
"12hour": 720,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||
client: HassioEcoFlowClient = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = []
|
||||
|
||||
if is_power_station(client.product):
|
||||
entities.extend([
|
||||
AcTimeoutEntity(client, client.inverter,
|
||||
"ac_out_timeout", "AC timeout"),
|
||||
FreqEntity(client, client.inverter,
|
||||
"ac_out_freq_config", "AC frequency"),
|
||||
StandbyTimeoutEntity(
|
||||
client, client.pd, "standby_timeout", "Unit timeout"),
|
||||
])
|
||||
if is_delta(client.product):
|
||||
entities.extend([
|
||||
LcdTimeoutPushEntity(client, client.pd,
|
||||
"lcd_timeout", "Screen timeout"),
|
||||
])
|
||||
if is_river(client.product):
|
||||
entities.extend([
|
||||
DcInTypeEntity(client),
|
||||
LcdTimeoutPollEntity(client, "lcd_timeout", "Screen timeout"),
|
||||
])
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AcTimeoutEntity(SelectEntity, EcoFlowEntity):
|
||||
_attr_current_option = None
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:timer-settings"
|
||||
_attr_options = list(_AC_OPTIONS.keys())
|
||||
|
||||
async def async_select_option(self, option: str):
|
||||
self._client.tcp.write(send.set_ac_timeout(_AC_OPTIONS[option]))
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
value = data[self._key]
|
||||
self._attr_current_option = next(
|
||||
(i for i in _AC_OPTIONS if _AC_OPTIONS[i] == value), None)
|
||||
|
||||
|
||||
class DcInTypeEntity(SelectEntity, EcoFlowConfigEntity):
|
||||
_attr_current_option = None
|
||||
_attr_options = list(_DC_IMPUTS.keys())
|
||||
|
||||
def __init__(self, client: HassioEcoFlowClient):
|
||||
super().__init__(client, "dc_in_type_config", "DC mode")
|
||||
self._req = send.get_dc_in_type(client.product)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return _DC_ICONS.get(self.current_option, None)
|
||||
|
||||
async def async_select_option(self, option: str):
|
||||
self._client.tcp.write(send.set_dc_in_type(
|
||||
self._client.product, _DC_IMPUTS[option]))
|
||||
|
||||
async def async_update(self):
|
||||
try:
|
||||
value = await request(self._client.tcp, self._req, self._client.dc_in_type)
|
||||
except:
|
||||
return
|
||||
self._client.diagnostics["dc_in_type"] = value
|
||||
self._attr_current_option = next(
|
||||
(i for i in _DC_IMPUTS if _DC_IMPUTS[i] == value), None)
|
||||
self._attr_available = True
|
||||
|
||||
|
||||
class FreqEntity(SelectEntity, EcoFlowEntity):
|
||||
_attr_current_option = None
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:sine-wave"
|
||||
_attr_options = list(_FREQS.keys())
|
||||
_attr_unit_of_measurement = FREQUENCY_HERTZ
|
||||
|
||||
async def async_select_option(self, option: str):
|
||||
self._client.tcp.write(send.set_ac_out(
|
||||
self._client.product, freq=_FREQS[option]))
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
value = data[self._key]
|
||||
self._attr_current_option = next(
|
||||
(i for i in _FREQS if _FREQS[i] == value), None)
|
||||
|
||||
|
||||
class LcdTimeoutPollEntity(SelectEntity, EcoFlowConfigEntity):
|
||||
_attr_current_option = None
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:timer-settings"
|
||||
_attr_options = list(_LCD_OPTIONS.keys())
|
||||
_req = send.get_lcd()
|
||||
|
||||
async def async_select_option(self, option: str):
|
||||
self._client.tcp.write(send.set_lcd(
|
||||
self._client.product, time=_LCD_OPTIONS[option]))
|
||||
|
||||
async def async_update(self):
|
||||
try:
|
||||
value = await request(self._client.tcp, self._req, self._client.lcd_timeout)
|
||||
except:
|
||||
return
|
||||
self._client.diagnostics["lcd_timeout"] = value
|
||||
self._attr_current_option = next(
|
||||
(i for i in _LCD_OPTIONS if _LCD_OPTIONS[i] == value), None)
|
||||
self._attr_available = True
|
||||
|
||||
|
||||
class LcdTimeoutPushEntity(SelectEntity, EcoFlowEntity):
|
||||
_attr_current_option = None
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:timer-settings"
|
||||
_attr_options = list(_LCD_OPTIONS.keys())
|
||||
|
||||
async def async_select_option(self, option: str):
|
||||
self._client.tcp.write(send.set_lcd(
|
||||
self._client.product, time=_LCD_OPTIONS[option]))
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
value = data[self._key]
|
||||
self._attr_current_option = next(
|
||||
(i for i in _LCD_OPTIONS if _LCD_OPTIONS[i] == value), None)
|
||||
|
||||
|
||||
class StandbyTimeoutEntity(SelectEntity, EcoFlowEntity):
|
||||
_attr_current_option = None
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:timer-settings"
|
||||
_attr_options = list(_STANDBY_OPTIONS.keys())
|
||||
|
||||
async def async_select_option(self, option: str):
|
||||
self._client.tcp.write(
|
||||
send.set_standby_timeout(_STANDBY_OPTIONS[option]))
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
value = data[self._key]
|
||||
self._attr_current_option = next(
|
||||
(i for i in _STANDBY_OPTIONS if _STANDBY_OPTIONS[i] == value), None)
|
||||
301
config/custom_components/ecoflow/sensor.py
Normal file
301
config/custom_components/ecoflow/sensor.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import reactivex.operators as ops
|
||||
from homeassistant.components.sensor import (SensorDeviceClass, SensorEntity,
|
||||
SensorStateClass)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (ELECTRIC_CURRENT_AMPERE,
|
||||
ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR,
|
||||
FREQUENCY_HERTZ, PERCENTAGE, POWER_WATT,
|
||||
TEMP_CELSIUS)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
from reactivex import Observable
|
||||
|
||||
from . import DOMAIN, EcoFlowEntity, HassioEcoFlowClient, select_bms
|
||||
from .ecoflow import (is_delta, is_delta_mini, is_delta_pro, is_power_station,
|
||||
is_river)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||
client: HassioEcoFlowClient = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = []
|
||||
|
||||
if is_power_station(client.product):
|
||||
entities.extend([
|
||||
CurrentEntity(client, client.inverter,
|
||||
"ac_in_current", "AC input current"),
|
||||
CurrentEntity(client, client.inverter,
|
||||
"ac_out_current", "AC output current"),
|
||||
EnergyEntity(client, client.pd, "mppt_in_energy",
|
||||
"MPPT input energy"),
|
||||
EnergySumEntity(client, "in_energy", [
|
||||
"ac", "car", "mppt"], "Total input energy"),
|
||||
EnergySumEntity(client, "out_energy", [
|
||||
"ac", "car"], "Total output energy"),
|
||||
FanEntity(client, client.inverter, "fan_state", "Fan"),
|
||||
FrequencyEntity(client, client.inverter,
|
||||
"ac_in_freq", "AC input frequency"),
|
||||
FrequencyEntity(client, client.inverter,
|
||||
"ac_out_freq", "AC output frequency"),
|
||||
RemainEntity(client, client.pd, "remain_display", "Remain"),
|
||||
LevelEntity(client, client.pd, "battery_level",
|
||||
"Battery"),
|
||||
VoltageEntity(client, client.inverter,
|
||||
"ac_in_voltage", "AC input voltage"),
|
||||
VoltageEntity(client, client.inverter,
|
||||
"ac_out_voltage", "AC output voltage"),
|
||||
WattsEntity(client, client.pd, "in_power", "Total input"),
|
||||
WattsEntity(client, client.pd, "out_power", "Total output"),
|
||||
WattsEntity(client, client.inverter,
|
||||
"ac_consumption", "AC output + loss", real=True),
|
||||
WattsEntity(client, client.inverter, "ac_out_power",
|
||||
"AC output", real=False),
|
||||
WattsEntity(client, client.pd, "usb_out1_power",
|
||||
"USB-A left output"),
|
||||
WattsEntity(client, client.pd, "usb_out2_power",
|
||||
"USB-A right output"),
|
||||
])
|
||||
if is_delta(client.product):
|
||||
bms = (
|
||||
client.bms.pipe(select_bms(0), ops.share()),
|
||||
client.bms.pipe(select_bms(1), ops.share()),
|
||||
client.bms.pipe(select_bms(2), ops.share()),
|
||||
)
|
||||
entities.extend([
|
||||
CurrentEntity(client, client.mppt, "dc_in_current",
|
||||
"DC input current"),
|
||||
CyclesEntity(
|
||||
client, bms[0], "battery_cycles", "Main battery cycles", 0),
|
||||
RemainEntity(client, client.ems,
|
||||
"battery_remain_charge", "Remain charge"),
|
||||
RemainEntity(client, client.ems,
|
||||
"battery_remain_discharge", "Remain discharge"),
|
||||
SingleLevelEntity(
|
||||
client, bms[0], "battery_level_f32", "Main battery", 0),
|
||||
TempEntity(client, client.inverter, "ac_out_temp",
|
||||
"AC temperature"),
|
||||
TempEntity(client, bms[0], "battery_temp",
|
||||
"Main battery temperature", 0),
|
||||
TempEntity(client, client.mppt, "dc_in_temp",
|
||||
"DC input temperature"),
|
||||
TempEntity(client, client.mppt, "dc24_temp",
|
||||
"DC output temperature"),
|
||||
TempEntity(client, client.pd, "typec_out1_temp",
|
||||
"USB-C left temperature"),
|
||||
TempEntity(client, client.pd, "typec_out2_temp",
|
||||
"USB-C right temperature"),
|
||||
VoltageEntity(client, client.mppt, "dc_in_voltage",
|
||||
"DC input voltage"),
|
||||
WattsEntity(client, client.inverter,
|
||||
"ac_in_power", "AC input"),
|
||||
WattsEntity(client, client.mppt, "dc_in_power",
|
||||
"DC input", real=True),
|
||||
WattsEntity(client, client.mppt,
|
||||
"car_consumption", "Car output + loss", real=True),
|
||||
WattsEntity(client, client.mppt,
|
||||
"car_out_power", "Car output"),
|
||||
])
|
||||
if is_delta_mini(client.product):
|
||||
entities.extend([
|
||||
WattsEntity(client, client.pd,
|
||||
"usbqc_out1_power", "USB-Fast output"),
|
||||
WattsEntity(client, client.pd,
|
||||
"typec_out1_power", "USB-C output"),
|
||||
])
|
||||
else:
|
||||
entities.extend([
|
||||
CyclesEntity(
|
||||
client, bms[1], "battery_cycles", "Extra1 battery cycles", 1),
|
||||
CyclesEntity(
|
||||
client, bms[2], "battery_cycles", "Extra2 battery cycles", 2),
|
||||
SingleLevelEntity(
|
||||
client, bms[1], "battery_level_f32", "Extra1 battery", 1),
|
||||
SingleLevelEntity(
|
||||
client, bms[2], "battery_level_f32", "Extra2 battery", 2),
|
||||
TempEntity(client, bms[1], "battery_temp",
|
||||
"Extra1 battery temperature", 1),
|
||||
TempEntity(client, bms[2], "battery_temp",
|
||||
"Extra2 battery temperature", 2),
|
||||
WattsEntity(client, client.pd, "usbqc_out1_power",
|
||||
"USB-Fast left output"),
|
||||
WattsEntity(client, client.pd, "usbqc_out2_power",
|
||||
"USB-Fast right output"),
|
||||
WattsEntity(client, client.pd, "typec_out1_power",
|
||||
"USB-C left output"),
|
||||
WattsEntity(client, client.pd, "typec_out2_power",
|
||||
"USB-C right output"),
|
||||
])
|
||||
if is_delta_pro(client.product):
|
||||
entities.extend([
|
||||
WattsEntity(client, client.mppt,
|
||||
"anderson_out_power", "Anderson output"),
|
||||
])
|
||||
if is_river(client.product):
|
||||
extra = client.bms.pipe(select_bms(1), ops.share())
|
||||
entities.extend([
|
||||
CurrentEntity(client, client.inverter, "dc_in_current",
|
||||
"DC input current"),
|
||||
CyclesEntity(client, client.ems, "battery_cycles",
|
||||
"Main battery cycles"),
|
||||
CyclesEntity(client, extra, "battery_cycles",
|
||||
"Extra battery cycles", 1),
|
||||
SingleLevelEntity(client, client.ems, "battery_main_level",
|
||||
"Main battery"),
|
||||
SingleLevelEntity(
|
||||
client, extra, "battery_level", "Extra battery", 1),
|
||||
TempEntity(client, client.inverter, "ac_in_temp",
|
||||
"AC input temperature"),
|
||||
TempEntity(client, client.inverter, "ac_out_temp",
|
||||
"AC output temperature"),
|
||||
TempEntity(client, client.ems, "battery_main_temp",
|
||||
"Main battery temperature"),
|
||||
TempEntity(client, extra, "battery_temp",
|
||||
"Extra battery temperature", 1),
|
||||
TempEntity(client, client.pd, "car_out_temp",
|
||||
"DC output temperature"),
|
||||
TempEntity(client, client.pd, "typec_out1_temp",
|
||||
"USB-C temperature"),
|
||||
VoltageEntity(client, client.inverter, "dc_in_voltage",
|
||||
"DC input voltage"),
|
||||
WattsEntity(client, client.pd, "car_out_power", "Car output"),
|
||||
WattsEntity(client, client.pd, "light_power", "Light output"),
|
||||
WattsEntity(client, client.pd, "usbqc_out1_power",
|
||||
"USB-Fast output"),
|
||||
WattsEntity(client, client.pd, "typec_out1_power",
|
||||
"USB-C output"),
|
||||
])
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BaseEntity(SensorEntity, EcoFlowEntity):
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_native_value = data[self._key]
|
||||
|
||||
|
||||
class CurrentEntity(BaseEntity):
|
||||
_attr_device_class = SensorDeviceClass.CURRENT
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = ELECTRIC_CURRENT_AMPERE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
|
||||
class CyclesEntity(BaseEntity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_icon = "mdi:battery-heart-variant"
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
|
||||
|
||||
class EnergyEntity(BaseEntity):
|
||||
_attr_device_class = SensorDeviceClass.ENERGY
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = ENERGY_WATT_HOUR
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
|
||||
|
||||
class EnergySumEntity(EnergyEntity):
|
||||
def __init__(self, client: HassioEcoFlowClient, key: str, keys: list[str], name: str):
|
||||
super().__init__(client, client.pd, key, name)
|
||||
self._suffix_len = len(key) + 1
|
||||
self._keys = [f"{x}_{key}" for x in keys]
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
values = {key[:-self._suffix_len]: data[key]
|
||||
for key in data if key in self._keys}
|
||||
self._attr_extra_state_attributes = values
|
||||
self._attr_native_value = sum(values.values())
|
||||
|
||||
|
||||
class FanEntity(BaseEntity):
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
value = self.native_value
|
||||
if value is None or self.native_value <= 0:
|
||||
return "mdi:fan-off"
|
||||
return "mdi:fan"
|
||||
|
||||
|
||||
class FrequencyEntity(BaseEntity):
|
||||
_attr_device_class = SensorDeviceClass.FREQUENCY
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = FREQUENCY_HERTZ
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
|
||||
class LevelEntity(BaseEntity):
|
||||
_attr_device_class = SensorDeviceClass.BATTERY
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, client: HassioEcoFlowClient, src: Observable[dict[str, Any]], key: str, name: str, bms_id: Optional[int] = None):
|
||||
super().__init__(client, src, key, name, bms_id)
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
|
||||
class RemainEntity(BaseEntity):
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
value: timedelta = data[self._key]
|
||||
if value.total_seconds() == 8639940:
|
||||
self._attr_native_value = None
|
||||
else:
|
||||
self._attr_native_value = utcnow() + value
|
||||
|
||||
|
||||
class SingleLevelEntity(LevelEntity):
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
super()._on_updated(data)
|
||||
if "battery_capacity_remain" in data:
|
||||
self._attr_extra_state_attributes["capacity_remain"] = data["battery_capacity_remain"]
|
||||
if "battery_capacity_full" in data:
|
||||
self._attr_extra_state_attributes["capacity_full"] = data["battery_capacity_full"]
|
||||
if "battery_capacity_design" in data:
|
||||
self._attr_extra_state_attributes["capacity_design"] = data["battery_capacity_design"]
|
||||
|
||||
|
||||
class TempEntity(BaseEntity):
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = TEMP_CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
|
||||
class VoltageEntity(BaseEntity):
|
||||
_attr_device_class = SensorDeviceClass.VOLTAGE
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
|
||||
class WattsEntity(BaseEntity):
|
||||
_attr_device_class = SensorDeviceClass.POWER
|
||||
_attr_native_unit_of_measurement = POWER_WATT
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, client: HassioEcoFlowClient, src: Observable[dict[str, Any]], key: str, name: str, real: Union[bool, int] = False):
|
||||
super().__init__(client, src, key, name)
|
||||
if key.endswith("_consumption"):
|
||||
self._key = key[:-11] + "out_power"
|
||||
self._attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
self._real = real
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
key = self._key[:-5]
|
||||
if self._real is not False and f"{key}current" in data and f"{key}voltage" in data:
|
||||
self._attr_native_value = (
|
||||
data[f"{key}current"] * data[f"{key}voltage"])
|
||||
if self._real is not True:
|
||||
self._attr_native_value = round(
|
||||
self._attr_native_value, self._real)
|
||||
if self._real == 0:
|
||||
self._attr_native_value = int(self._attr_native_value)
|
||||
else:
|
||||
super()._on_updated(data)
|
||||
184
config/custom_components/ecoflow/switch.py
Normal file
184
config/custom_components/ecoflow/switch.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import DOMAIN, EcoFlowEntity, HassioEcoFlowClient, select_bms
|
||||
from .ecoflow import is_delta, is_power_station, is_river, is_river_mini, send
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
|
||||
client: HassioEcoFlowClient = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = []
|
||||
|
||||
if is_power_station(client.product):
|
||||
entities.extend([
|
||||
AcEntity(client, client.inverter, "ac_out_state", "AC output"),
|
||||
BeepEntity(client, client.pd, "beep", "Beep"),
|
||||
])
|
||||
if is_delta(client.product):
|
||||
entities.extend([
|
||||
AcPauseEntity(client, client.inverter,
|
||||
"ac_in_pause", "AC charge"),
|
||||
DcEntity(client, client.mppt, "car_out_state", "DC output"),
|
||||
LcdAutoEntity(client, client.pd, "lcd_brightness",
|
||||
"Screen brightness auto"),
|
||||
])
|
||||
if is_river(client.product):
|
||||
entities.extend([
|
||||
AcSlowChargeEntity(client, client.inverter,
|
||||
"ac_in_slow", "AC slow charging"),
|
||||
DcEntity(client, client.pd, "car_out_state", "DC output"),
|
||||
FanAutoEntity(client, client.inverter,
|
||||
"fan_config", "Auto fan speed"),
|
||||
])
|
||||
if client.product == 5: # RIVER Max
|
||||
entities.extend([
|
||||
AmbientSyncEntity(client, client.bms.pipe(
|
||||
select_bms(1)), "ambient_mode", "Ambient light sync screen", 1)
|
||||
])
|
||||
if not is_river_mini(client.product):
|
||||
entities.extend([
|
||||
XBoostEntity(client, client.inverter,
|
||||
"ac_out_xboost", "AC X-Boost"),
|
||||
])
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class SimpleEntity(SwitchEntity, EcoFlowEntity):
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_is_on = bool(data[self._key])
|
||||
|
||||
|
||||
class AcEntity(SimpleEntity):
|
||||
_attr_device_class = SwitchDeviceClass.OUTLET
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ac_out(self._client.product, False))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ac_out(self._client.product, True))
|
||||
|
||||
|
||||
class AcPauseEntity(SimpleEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_is_on = not bool(data[self._key])
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ac_in_limit(pause=True))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ac_in_limit(pause=False))
|
||||
|
||||
|
||||
class AcSlowChargeEntity(SimpleEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:car-speed-limiter"
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ac_in_slow(False))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ac_in_slow(True))
|
||||
|
||||
|
||||
class AmbientSyncEntity(SimpleEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return "mdi:sync-off" if self.is_on is False else "mdi:sync"
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ambient(2))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ambient(1))
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
if data[self._key] == 1:
|
||||
self._attr_is_on = True
|
||||
elif data[self._key] == 2:
|
||||
self._attr_is_on = False
|
||||
else:
|
||||
self._attr_is_on = None
|
||||
|
||||
|
||||
class BeepEntity(SimpleEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return "mdi:volume-source" if self.is_on else "mdi:volume-mute"
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_is_on = not bool(data[self._key])
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_beep(False))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_beep(True))
|
||||
|
||||
|
||||
class DcEntity(SimpleEntity):
|
||||
_attr_device_class = SwitchDeviceClass.OUTLET
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_dc_out(self._client.product, False))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_dc_out(self._client.product, True))
|
||||
|
||||
|
||||
class FanAutoEntity(SimpleEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return "mdi:fan-auto" if self.is_on else "mdi:fan-chevron-up"
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_fan_auto(self._client.product, False))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_fan_auto(self._client.product, True))
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_is_on = data[self._key] == 1
|
||||
|
||||
|
||||
class LcdAutoEntity(SimpleEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:brightness-auto"
|
||||
_brightness = 0
|
||||
|
||||
def _on_updated(self, data: dict[str, Any]):
|
||||
self._attr_is_on = bool(data[self._key] & 0x80)
|
||||
self._brightness = data[self._key] & 0x7F
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any):
|
||||
value = self._brightness
|
||||
self._client.tcp.write(send.set_lcd(self._client.product, light=value))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any):
|
||||
value = self._brightness | 0x80
|
||||
self._client.tcp.write(send.set_lcd(self._client.product, light=value))
|
||||
|
||||
|
||||
class XBoostEntity(SimpleEntity):
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ac_out(
|
||||
self._client.product, xboost=False))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any):
|
||||
self._client.tcp.write(send.set_ac_out(
|
||||
self._client.product, xboost=True))
|
||||
18
config/custom_components/ecoflow/translations/en.json
Normal file
18
config/custom_components/ecoflow/translations/en.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "EcoFlow",
|
||||
"config": {
|
||||
"abort": {
|
||||
"product_unsupported": "Sorry, This product is not supported now."
|
||||
},
|
||||
"error": {
|
||||
"timeout": "Connection timeouted"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Hostname or IP-address"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
config/custom_components/ecoflow/translations/ja.json
Normal file
18
config/custom_components/ecoflow/translations/ja.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "EcoFlow",
|
||||
"config": {
|
||||
"abort": {
|
||||
"product_unsupported": "現時点では、この製品はサポートされていません。"
|
||||
},
|
||||
"error": {
|
||||
"timeout": "接続がタイムアウトしました"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "ホスト名またはIPアドレス"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user