Home Assistant Git Exporter

This commit is contained in:
root
2024-08-26 13:38:09 +02:00
parent 80fc630f5e
commit fc0376e38e
2010 changed files with 11414 additions and 16153 deletions

View 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

View 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

View 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,
)

View 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

View 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

View 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

View 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()

View 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"))

View 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))

View 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"
}

View 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)))

View 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)

View 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)

View 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))

View 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"
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
{
"title": "EcoFlow",
"config": {
"abort": {
"product_unsupported": "現時点では、この製品はサポートされていません。"
},
"error": {
"timeout": "接続がタイムアウトしました"
},
"step": {
"user": {
"data": {
"host": "ホスト名またはIPアドレス"
}
}
}
}
}