Home Assistant Git Exporter

This commit is contained in:
root
2024-05-31 13:07:35 +02:00
parent 64a0536537
commit 60abdd866c
275 changed files with 71113 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
{
"files.associations": {
"*.yaml": "home-assistant"
}
}

View File

@@ -0,0 +1,13 @@
- id: '1584370618810'
alias: learn IR code
description: ''
trigger:
- entity_id: input_boolean.learn_ir_code
from: 'off'
platform: state
to: 'on'
condition: []
action:
- data:
host: 10.0.0.106
service: broadlink.learn

View File

View File

View File

@@ -0,0 +1 @@

View File

View File

View File

View File

View File

View File

View File

File diff suppressed because it is too large Load Diff

View File

@@ -96,3 +96,41 @@ alarm_control_panel:
api:
wake_on_lan:
command_line:
- sensor:
name: Météo France alertes 43
unique_id: meteo_france_alertes_43
scan_interval: 10800
command: >
curl -X GET "https://public-api.meteofrance.fr/public/DPVigilance/v1/cartevigilance/encours' \ -H 'accept: */*' \ -H 'apikey: eyJ4NXQiOiJZV0kxTTJZNE1qWTNOemsyTkRZeU5XTTRPV014TXpjek1UVmhNbU14T1RSa09ETXlOVEE0Tnc9PSIsImtpZCI6ImdhdGV3YXlfY2VydGlmaWNhdGVfYWxpYXMiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJiaWxvYmEyMDA1QGNhcmJvbi5zdXBlciIsImFwcGxpY2F0aW9uIjp7Im93bmVyIjoiYmlsb2JhMjAwNSIsInRpZXJRdW90YVR5cGUiOm51bGwsInRpZXIiOiJVbmxpbWl0ZWQiLCJuYW1lIjoiRGVmYXVsdEFwcGxpY2F0aW9uIiwiaWQiOjEyODM3LCJ1dWlkIjoiZTczNDYwODctMjAzYS00NzFlLTg2MDMtOWVhZjkyYzQ5YTJiIn0sImlzcyI6Imh0dHBzOlwvXC9wb3J0YWlsLWFwaS5tZXRlb2ZyYW5jZS5mcjo0NDNcL29hdXRoMlwvdG9rZW4iLCJ0aWVySW5mbyI6eyI2MFJlcVBhck1pbiI6eyJ0aWVyUXVvdGFUeXBlIjoicmVxdWVzdENvdW50IiwiZ3JhcGhRTE1heENvbXBsZXhpdHkiOjAsImdyYXBoUUxNYXhEZXB0aCI6MCwic3RvcE9uUXVvdGFSZWFjaCI6dHJ1ZSwic3Bpa2VBcnJlc3RMaW1pdCI6MCwic3Bpa2VBcnJlc3RVbml0Ijoic2VjIn19LCJrZXl0eXBlIjoiUFJPRFVDVElPTiIsInN1YnNjcmliZWRBUElzIjpbeyJzdWJzY3JpYmVyVGVuYW50RG9tYWluIjoiY2FyYm9uLnN1cGVyIiwibmFtZSI6IkRvbm5lZXNQdWJsaXF1ZXNWaWdpbGFuY2UiLCJjb250ZXh0IjoiXC9wdWJsaWNcL0RQVmlnaWxhbmNlXC92MSIsInB1Ymxpc2hlciI6ImFkbWluIiwidmVyc2lvbiI6InYxIiwic3Vic2NyaXB0aW9uVGllciI6IjYwUmVxUGFyTWluIn1dLCJleHAiOjE3NDY1MjcyOTcsInRva2VuX3R5cGUiOiJhcGlLZXkiLCJpYXQiOjE3MTQ5OTEyOTcsImp0aSI6ImQ3OTQwZmMxLTYzZTgtNGQ1ZC1iZTBiLWZkZjdlN2RlNjkyMSJ9.JUskxCK07-Acn_bn_SRXjRFqTK7Azj-MGLVa7BP4xLeHbhXS5o2SX0Wn85XH55Il8JPnDZ47Pye4Zp_U5sqqFWWUkqN_B23ztc7YHJ5nXwDIg1sxNBT8fJLhIWD9s0NtqdmdDqU5OlqXkgSsh0nN9o0zLT9auj1-eSuZje8Ua84q1sDBrdc6wChbMRFPAX8OqDbQTErqpLhA5VtQNWPpORlArvTqj2t0XPX_Bi8YtaV0HNom57C3LDY1kzDLClejjfSDf80F7vByRpLHGl8qnokyw_aciLJaFubsRobuTcx_IcWJa3YXy7LVyLax_MZnFXr6jabR9t4XU0_28ax48w==" | jq '{details: {"domain_max_color_id_today": .product.periods[0].timelaps.domain_ids[78].max_color_id,"domain_max_color_id_tomorrow": .product.periods[1].timelaps.domain_ids[78].max_color_id, "update_time": .product.update_time}, "today": .product.periods[0].timelaps.domain_ids[78].phenomenon_items | sort_by(.phenomenon_id), "tomorrow": .product.periods[1].timelaps.domain_ids[78].phenomenon_items | sort_by(.phenomenon_id)}'
value_template: " {{ value_json.details.domain_max_color_id_today }} "
json_attributes:
- details
- today
- tomorrow
- sensor:
name: Météo France alertes image today
unique_id: meteo_france_alertes_image_today
scan_interval: 14400
command: >
curl -X GET "https://public-api.meteofrance.fr/public/DPVigilance/v1/vignettenationale-J/encours" -H "accept: */*" -H "apikey: eyJ4NXQiOiJZV0kxTTJZNE1qWTNOemsyTkRZeU5XTTRPV014TXpjek1UVmhNbU14T1RSa09ETXlOVEE0Tnc9PSIsImtpZCI6ImdhdGV3YXlfY2VydGlmaWNhdGVfYWxpYXMiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJiaWxvYmEyMDA1QGNhcmJvbi5zdXBlciIsImFwcGxpY2F0aW9uIjp7Im93bmVyIjoiYmlsb2JhMjAwNSIsInRpZXJRdW90YVR5cGUiOm51bGwsInRpZXIiOiJVbmxpbWl0ZWQiLCJuYW1lIjoiRGVmYXVsdEFwcGxpY2F0aW9uIiwiaWQiOjEyODM3LCJ1dWlkIjoiZTczNDYwODctMjAzYS00NzFlLTg2MDMtOWVhZjkyYzQ5YTJiIn0sImlzcyI6Imh0dHBzOlwvXC9wb3J0YWlsLWFwaS5tZXRlb2ZyYW5jZS5mcjo0NDNcL29hdXRoMlwvdG9rZW4iLCJ0aWVySW5mbyI6eyI2MFJlcVBhck1pbiI6eyJ0aWVyUXVvdGFUeXBlIjoicmVxdWVzdENvdW50IiwiZ3JhcGhRTE1heENvbXBsZXhpdHkiOjAsImdyYXBoUUxNYXhEZXB0aCI6MCwic3RvcE9uUXVvdGFSZWFjaCI6dHJ1ZSwic3Bpa2VBcnJlc3RMaW1pdCI6MCwic3Bpa2VBcnJlc3RVbml0Ijoic2VjIn19LCJrZXl0eXBlIjoiUFJPRFVDVElPTiIsInN1YnNjcmliZWRBUElzIjpbeyJzdWJzY3JpYmVyVGVuYW50RG9tYWluIjoiY2FyYm9uLnN1cGVyIiwibmFtZSI6IkRvbm5lZXNQdWJsaXF1ZXNWaWdpbGFuY2UiLCJjb250ZXh0IjoiXC9wdWJsaWNcL0RQVmlnaWxhbmNlXC92MSIsInB1Ymxpc2hlciI6ImFkbWluIiwidmVyc2lvbiI6InYxIiwic3Vic2NyaXB0aW9uVGllciI6IjYwUmVxUGFyTWluIn1dLCJleHAiOjE3NDY1MjcyOTcsInRva2VuX3R5cGUiOiJhcGlLZXkiLCJpYXQiOjE3MTQ5OTEyOTcsImp0aSI6ImQ3OTQwZmMxLTYzZTgtNGQ1ZC1iZTBiLWZkZjdlN2RlNjkyMSJ9.JUskxCK07-Acn_bn_SRXjRFqTK7Azj-MGLVa7BP4xLeHbhXS5o2SX0Wn85XH55Il8JPnDZ47Pye4Zp_U5sqqFWWUkqN_B23ztc7YHJ5nXwDIg1sxNBT8fJLhIWD9s0NtqdmdDqU5OlqXkgSsh0nN9o0zLT9auj1-eSuZje8Ua84q1sDBrdc6wChbMRFPAX8OqDbQTErqpLhA5VtQNWPpORlArvTqj2t0XPX_Bi8YtaV0HNom57C3LDY1kzDLClejjfSDf80F7vByRpLHGl8qnokyw_aciLJaFubsRobuTcx_IcWJa3YXy7LVyLax_MZnFXr6jabR9t4XU0_28ax48w==" > ./www/weather/meteo_france_alerte_today.jpg
value_template: "mf_alerte_today"
- sensor:
name: Météo France alertes image tomorrow
unique_id: meteo_france_alertes_image_tomorrow
scan_interval: 14400
command: >
curl -X GET "https://public-api.meteofrance.fr/public/DPVigilance/v1/vignettenationale-J1/encours" -H "accept: */*" -H "apikey: eyJ4NXQiOiJZV0kxTTJZNE1qWTNOemsyTkRZeU5XTTRPV014TXpjek1UVmhNbU14T1RSa09ETXlOVEE0Tnc9PSIsImtpZCI6ImdhdGV3YXlfY2VydGlmaWNhdGVfYWxpYXMiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJiaWxvYmEyMDA1QGNhcmJvbi5zdXBlciIsImFwcGxpY2F0aW9uIjp7Im93bmVyIjoiYmlsb2JhMjAwNSIsInRpZXJRdW90YVR5cGUiOm51bGwsInRpZXIiOiJVbmxpbWl0ZWQiLCJuYW1lIjoiRGVmYXVsdEFwcGxpY2F0aW9uIiwiaWQiOjEyODM3LCJ1dWlkIjoiZTczNDYwODctMjAzYS00NzFlLTg2MDMtOWVhZjkyYzQ5YTJiIn0sImlzcyI6Imh0dHBzOlwvXC9wb3J0YWlsLWFwaS5tZXRlb2ZyYW5jZS5mcjo0NDNcL29hdXRoMlwvdG9rZW4iLCJ0aWVySW5mbyI6eyI2MFJlcVBhck1pbiI6eyJ0aWVyUXVvdGFUeXBlIjoicmVxdWVzdENvdW50IiwiZ3JhcGhRTE1heENvbXBsZXhpdHkiOjAsImdyYXBoUUxNYXhEZXB0aCI6MCwic3RvcE9uUXVvdGFSZWFjaCI6dHJ1ZSwic3Bpa2VBcnJlc3RMaW1pdCI6MCwic3Bpa2VBcnJlc3RVbml0Ijoic2VjIn19LCJrZXl0eXBlIjoiUFJPRFVDVElPTiIsInN1YnNjcmliZWRBUElzIjpbeyJzdWJzY3JpYmVyVGVuYW50RG9tYWluIjoiY2FyYm9uLnN1cGVyIiwibmFtZSI6IkRvbm5lZXNQdWJsaXF1ZXNWaWdpbGFuY2UiLCJjb250ZXh0IjoiXC9wdWJsaWNcL0RQVmlnaWxhbmNlXC92MSIsInB1Ymxpc2hlciI6ImFkbWluIiwidmVyc2lvbiI6InYxIiwic3Vic2NyaXB0aW9uVGllciI6IjYwUmVxUGFyTWluIn1dLCJleHAiOjE3NDY1MjcyOTcsInRva2VuX3R5cGUiOiJhcGlLZXkiLCJpYXQiOjE3MTQ5OTEyOTcsImp0aSI6ImQ3OTQwZmMxLTYzZTgtNGQ1ZC1iZTBiLWZkZjdlN2RlNjkyMSJ9.JUskxCK07-Acn_bn_SRXjRFqTK7Azj-MGLVa7BP4xLeHbhXS5o2SX0Wn85XH55Il8JPnDZ47Pye4Zp_U5sqqFWWUkqN_B23ztc7YHJ5nXwDIg1sxNBT8fJLhIWD9s0NtqdmdDqU5OlqXkgSsh0nN9o0zLT9auj1-eSuZje8Ua84q1sDBrdc6wChbMRFPAX8OqDbQTErqpLhA5VtQNWPpORlArvTqj2t0XPX_Bi8YtaV0HNom57C3LDY1kzDLClejjfSDf80F7vByRpLHGl8qnokyw_aciLJaFubsRobuTcx_IcWJa3YXy7LVyLax_MZnFXr6jabR9t4XU0_28ax48w==" > ./www/weather/meteo_france_alerte_tomorrow.jpg
value_template: "mf_alerte_tomorrow"
camera:
- platform: local_file
name: MF_alerte_today
file_path: /config/www/weather/meteo_france_alerte_today.png
- platform: local_file
name: MF_alerte_tomorrow
file_path: /config/www/weather/meteo_france_alerte_tomorrow.png

View File

@@ -0,0 +1,330 @@
"""The blitzortung integration."""
import asyncio
import json
import logging
import math
import time
import voluptuous as vol
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, UnitOfLength
from homeassistant.core import callback, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.unit_system import IMPERIAL_SYSTEM
from homeassistant.util.unit_conversion import DistanceConverter
from . import const
from .const import (
CONF_IDLE_RESET_TIMEOUT,
CONF_MAX_TRACKED_LIGHTNINGS,
CONF_RADIUS,
CONF_TIME_WINDOW,
DEFAULT_IDLE_RESET_TIMEOUT,
DEFAULT_MAX_TRACKED_LIGHTNINGS,
DEFAULT_RADIUS,
DEFAULT_TIME_WINDOW,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
PLATFORMS,
)
from .geohash_utils import geohash_overlap
from .mqtt import MQTT, MQTT_CONNECTED, MQTT_DISCONNECTED
from .version import __version__
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Optional(const.SERVER_STATS, default=False): bool})},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: dict):
"""Initialize basic config of blitzortung component."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN]["config"] = config.get(DOMAIN) or {}
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Set up blitzortung from a config entry."""
hass.data.setdefault(DOMAIN, {})
config = hass.data[DOMAIN].get("config") or {}
latitude = config_entry.options.get(CONF_LATITUDE, hass.config.latitude)
longitude = config_entry.options.get(CONF_LONGITUDE, hass.config.longitude)
radius = config_entry.options.get(CONF_RADIUS, DEFAULT_RADIUS)
max_tracked_lightnings = config_entry.options.get(
CONF_MAX_TRACKED_LIGHTNINGS, DEFAULT_MAX_TRACKED_LIGHTNINGS
)
time_window_seconds = (
config_entry.options.get(CONF_TIME_WINDOW, DEFAULT_TIME_WINDOW) * 60
)
if max_tracked_lightnings >= 500:
_LOGGER.warning(
"Large number of tracked lightnings: %s, it may lead to"
"bigger memory usage / unstable frontend",
max_tracked_lightnings,
)
if hass.config.units == IMPERIAL_SYSTEM:
radius_mi = radius
radius = DistanceConverter.convert(radius, UnitOfLength.MILES, UnitOfLength.KILOMETERS)
_LOGGER.info("imperial system, %s mi -> %s km", radius_mi, radius)
coordinator = BlitzortungCoordinator(
hass,
latitude,
longitude,
radius,
max_tracked_lightnings,
time_window_seconds,
DEFAULT_UPDATE_INTERVAL,
server_stats=config.get(const.SERVER_STATS),
)
hass.data[DOMAIN][config_entry.entry_id] = coordinator
async def start_platforms():
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_setup(config_entry, component)
for component in PLATFORMS
]
)
await coordinator.connect()
hass.async_create_task(start_platforms())
if not config_entry.update_listeners:
config_entry.add_update_listener(async_update_options)
return True
async def async_update_options(hass, config_entry):
"""Update options."""
_LOGGER.info("async_update_options")
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Unload a config entry."""
coordinator = hass.data[DOMAIN].pop(config_entry.entry_id)
await coordinator.disconnect()
_LOGGER.info("disconnected")
# cleanup platforms
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, component)
for component in PLATFORMS
]
)
)
return unload_ok
async def async_migrate_entry(hass, entry):
_LOGGER.debug("Migrating Blitzortung entry from Version %s", entry.version)
if entry.version == 1:
latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE]
radius = entry.data[CONF_RADIUS]
name = entry.data[CONF_NAME]
entry.unique_id = f"{latitude}-{longitude}-{name}-lightning"
entry.data = {CONF_NAME: name}
entry.options = {
CONF_LATITUDE: latitude,
CONF_LONGITUDE: longitude,
CONF_RADIUS: radius,
}
entry.version = 2
if entry.version == 2:
entry.options = dict(entry.options)
entry.options[CONF_IDLE_RESET_TIMEOUT] = DEFAULT_IDLE_RESET_TIMEOUT
entry.version = 3
if entry.version == 3:
entry.options = dict(entry.options)
entry.options[CONF_TIME_WINDOW] = entry.options.pop(
CONF_IDLE_RESET_TIMEOUT, DEFAULT_TIME_WINDOW
)
entry.version = 4
return True
class BlitzortungCoordinator:
def __init__(
self,
hass,
latitude,
longitude,
radius, # unit: km
max_tracked_lightnings,
time_window_seconds,
update_interval,
server_stats=False,
):
"""Initialize."""
self.hass = hass
self.latitude = latitude
self.longitude = longitude
self.radius = radius
self.max_tracked_lightnings = max_tracked_lightnings
self.time_window_seconds = time_window_seconds
self.server_stats = server_stats
self.last_time = 0
self.sensors = []
self.callbacks = []
self.lightning_callbacks = []
self.on_tick_callbacks = []
self.geohash_overlap = geohash_overlap(
self.latitude, self.longitude, self.radius
)
self._disconnect_callbacks = []
self.unloading = False
_LOGGER.info(
"lat: %s, lon: %s, radius: %skm, geohashes: %s",
self.latitude,
self.longitude,
self.radius,
self.geohash_overlap,
)
self.mqtt_client = MQTT(
hass,
"blitzortung.ha.sed.pl",
1883,
)
self._disconnect_callbacks.append(
async_dispatcher_connect(
self.hass, MQTT_CONNECTED, self._on_connection_change
)
)
self._disconnect_callbacks.append(
async_dispatcher_connect(
self.hass, MQTT_DISCONNECTED, self._on_connection_change
)
)
@callback
def _on_connection_change(self, *args, **kwargs):
if self.unloading:
return
for sensor in self.sensors:
sensor.async_write_ha_state()
def compute_polar_coords(self, lightning):
dy = (lightning["lat"] - self.latitude) * math.pi / 180
dx = (
(lightning["lon"] - self.longitude)
* math.pi
/ 180
* math.cos(self.latitude * math.pi / 180)
)
distance = round(math.sqrt(dx * dx + dy * dy) * 6371, 1)
azimuth = round(math.atan2(dx, dy) * 180 / math.pi) % 360
lightning[SensorDeviceClass.DISTANCE] = distance
lightning[const.ATTR_LIGHTNING_AZIMUTH] = azimuth
async def connect(self):
await self.mqtt_client.async_connect()
_LOGGER.info("Connected to Blitzortung proxy mqtt server")
for geohash_code in self.geohash_overlap:
geohash_part = "/".join(geohash_code)
await self.mqtt_client.async_subscribe(
"blitzortung/1.1/{}/#".format(geohash_part), self.on_mqtt_message, qos=0
)
if self.server_stats:
await self.mqtt_client.async_subscribe(
"$SYS/broker/#", self.on_mqtt_message, qos=0
)
await self.mqtt_client.async_subscribe(
"component/hello", self.on_hello_message, qos=0
)
self._disconnect_callbacks.append(
async_track_time_interval(
self.hass, self._tick, const.DEFAULT_UPDATE_INTERVAL
)
)
async def disconnect(self):
self.unloading = True
await self.mqtt_client.async_disconnect()
for cb in self._disconnect_callbacks:
cb()
def on_hello_message(self, message, *args):
def parse_version(version_str):
return tuple(map(int, version_str.split(".")))
data = json.loads(message.payload)
latest_version_str = data.get("latest_version")
if latest_version_str:
default_message = (
f"New version {latest_version_str} is available. "
f"[Check it out](https://github.com/mrk-its/homeassistant-blitzortung)"
)
latest_version_message = data.get("latest_version_message", default_message)
latest_version_title = data.get("latest_version_title", "Blitzortung")
latest_version = parse_version(latest_version_str)
current_version = parse_version(__version__)
if latest_version > current_version:
_LOGGER.info("new version is available: %s", latest_version_str)
self.hass.components.persistent_notification.async_create(
title=latest_version_title,
message=latest_version_message,
notification_id="blitzortung_new_version_available",
)
async def on_mqtt_message(self, message, *args):
for callback in self.callbacks:
callback(message)
if message.topic.startswith("blitzortung/1.1"):
lightning = json.loads(message.payload)
self.compute_polar_coords(lightning)
if lightning[SensorDeviceClass.DISTANCE] < self.radius:
_LOGGER.debug("lightning data: %s", lightning)
self.last_time = time.time()
for callback in self.lightning_callbacks:
await callback(lightning)
for sensor in self.sensors:
sensor.update_lightning(lightning)
def register_sensor(self, sensor):
self.sensors.append(sensor)
self.register_on_tick(sensor.tick)
def register_message_receiver(self, message_cb):
self.callbacks.append(message_cb)
def register_lightning_receiver(self, lightning_cb):
self.lightning_callbacks.append(lightning_cb)
def register_on_tick(self, on_tick_cb):
self.on_tick_callbacks.append(on_tick_cb)
@property
def is_inactive(self):
return bool(
self.time_window_seconds
and (time.time() - self.last_time) >= self.time_window_seconds
)
@property
def is_connected(self):
return self.mqtt_client.connected
async def _tick(self, *args):
for cb in self.on_tick_callbacks:
cb()

View File

@@ -0,0 +1,91 @@
"""Config flow for blitzortung integration."""
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant import config_entries
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from .const import (
CONF_MAX_TRACKED_LIGHTNINGS,
CONF_RADIUS,
CONF_TIME_WINDOW,
DEFAULT_MAX_TRACKED_LIGHTNINGS,
DEFAULT_RADIUS,
DEFAULT_TIME_WINDOW,
DOMAIN,
)
DEFAULT_CONF_NAME = "Blitzortung"
class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for blitzortung."""
VERSION = 4
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_NAME])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_NAME, default=DEFAULT_CONF_NAME): str}
),
)
@staticmethod
def async_get_options_flow(config_entry):
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
def __init__(self, config_entry: config_entries.ConfigEntry):
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_LATITUDE,
default=self.config_entry.options.get(
CONF_LATITUDE, self.hass.config.latitude
),
): cv.latitude,
vol.Required(
CONF_LONGITUDE,
default=self.config_entry.options.get(
CONF_LONGITUDE, self.hass.config.longitude
),
): cv.longitude,
vol.Required(
CONF_RADIUS,
default=self.config_entry.options.get(
CONF_RADIUS, DEFAULT_RADIUS
),
): int,
vol.Optional(
CONF_TIME_WINDOW,
default=self.config_entry.options.get(
CONF_TIME_WINDOW, DEFAULT_TIME_WINDOW,
),
): int,
vol.Optional(
CONF_MAX_TRACKED_LIGHTNINGS,
default=self.config_entry.options.get(
CONF_MAX_TRACKED_LIGHTNINGS, DEFAULT_MAX_TRACKED_LIGHTNINGS,
),
): int,
}
),
)

View File

@@ -0,0 +1,33 @@
import datetime
SW_VERSION = "1.3.1"
PLATFORMS = ["sensor", "geo_location"]
DOMAIN = "blitzortung"
DATA_UNSUBSCRIBE = "unsubscribe"
ATTR_LIGHTNING_AZIMUTH = "azimuth"
ATTR_LIGHTNING_COUNTER = "counter"
SERVER_STATS = "server_stats"
BASE_URL_TEMPLATE = (
"http://data{data_host_nr}.blitzortung.org/Data/Protected/last_strikes.php"
)
CONF_RADIUS = "radius"
CONF_IDLE_RESET_TIMEOUT = "idle_reset_timeout"
CONF_TIME_WINDOW = "time_window"
CONF_MAX_TRACKED_LIGHTNINGS = "max_tracked_lightnings"
DEFAULT_IDLE_RESET_TIMEOUT = 120
DEFAULT_RADIUS = 100
DEFAULT_MAX_TRACKED_LIGHTNINGS = 100
DEFAULT_TIME_WINDOW = 120
DEFAULT_UPDATE_INTERVAL = datetime.timedelta(seconds=60)
ATTR_LAT = "lat"
ATTR_LON = "lon"
ATTRIBUTION = "Data provided by blitzortung.org"
ATTR_EXTERNAL_ID = "external_id"
ATTR_PUBLICATION_DATE = "publication_date"

View File

@@ -0,0 +1,217 @@
"""Support for Blitzortung geo location events."""
import bisect
import logging
import time
import uuid
from homeassistant.components.geo_location import GeolocationEvent
from homeassistant.const import (
ATTR_ATTRIBUTION,
UnitOfLength
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.util.dt import utc_from_timestamp
from homeassistant.util.unit_system import IMPERIAL_SYSTEM
from .const import ATTR_EXTERNAL_ID, ATTR_PUBLICATION_DATE, ATTRIBUTION, DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_EVENT_NAME_TEMPLATE = "Lightning Strike"
DEFAULT_ICON = "mdi:flash"
SIGNAL_DELETE_ENTITY = "blitzortung_delete_entity_{0}"
async def async_setup_entry(hass, config_entry, async_add_entities):
coordinator = hass.data[DOMAIN][config_entry.entry_id]
if not coordinator.max_tracked_lightnings:
return
manager = BlitzortungEventManager(
hass,
async_add_entities,
coordinator.max_tracked_lightnings,
coordinator.time_window_seconds,
)
coordinator.register_lightning_receiver(manager.lightning_cb)
coordinator.register_on_tick(manager.tick)
class Strikes(list):
def __init__(self, capacity):
self._keys = []
self._key_fn = lambda strike: strike._publication_date
self._max_key = 0
self._capacity = capacity
super().__init__()
def insort(self, item):
k = self._key_fn(item)
if k > self._max_key:
self._max_key = k
self._keys.append(k)
self.append(item)
else:
i = bisect.bisect_right(self._keys, k)
self._keys.insert(i, k)
self.insert(i, item)
n = len(self) - self._capacity
if n > 0:
del self._keys[0:n]
to_delete = self[0:n]
self[0:n] = []
return to_delete
return ()
def cleanup(self, k):
if not self._keys or self._keys[0] > k:
return ()
i = bisect.bisect_right(self._keys, k)
if not i:
return ()
del self._keys[0:i]
to_delete = self[0:i]
self[0:i] = []
return to_delete
class BlitzortungEventManager:
"""Define a class to handle Blitzortung events."""
def __init__(
self, hass, async_add_entities, max_tracked_lightnings, window_seconds,
):
"""Initialize."""
self._async_add_entities = async_add_entities
self._hass = hass
self._strikes = Strikes(max_tracked_lightnings)
self._window_seconds = window_seconds
if hass.config.units == IMPERIAL_SYSTEM:
self._unit = UnitOfLength.MILES
else:
self._unit = UnitOfLength.KILOMETERS
async def lightning_cb(self, lightning):
_LOGGER.debug("geo_location lightning: %s", lightning)
event = BlitzortungEvent(
lightning["distance"],
lightning["lat"],
lightning["lon"],
self._unit,
lightning["time"],
lightning["status"],
lightning["region"],
)
to_delete = self._strikes.insort(event)
self._async_add_entities([event])
if to_delete:
self._remove_events(to_delete)
_LOGGER.debug("tracked lightnings: %s", len(self._strikes))
@callback
def _remove_events(self, events):
"""Remove old geo location events."""
_LOGGER.debug("Going to remove %s", events)
for event in events:
async_dispatcher_send(
self._hass, SIGNAL_DELETE_ENTITY.format(event._strike_id)
)
def tick(self):
to_delete = self._strikes.cleanup(time.time() - self._window_seconds)
if to_delete:
self._remove_events(to_delete)
class BlitzortungEvent(GeolocationEvent):
"""Define a lightning strike event."""
def __init__(self, distance, latitude, longitude, unit, time, status, region):
"""Initialize entity with data provided."""
self._distance = distance
self._latitude = latitude
self._longitude = longitude
self._time = time
self._status = status
self._region = region
self._publication_date = time / 1e9
self._remove_signal_delete = None
self._strike_id = str(uuid.uuid4()).replace("-", "")
self._unit_of_measurement = unit
self.entity_id = "geo_location.lightning_strike_{0}".format(self._strike_id)
@property
def extra_state_attributes(self):
"""Return the device state attributes."""
attributes = {}
for key, value in (
(ATTR_EXTERNAL_ID, self._strike_id),
(ATTR_ATTRIBUTION, ATTRIBUTION),
(ATTR_PUBLICATION_DATE, utc_from_timestamp(self._publication_date)),
):
attributes[key] = value
return attributes
@property
def distance(self):
"""Return distance value of this external event."""
return self._distance
@property
def icon(self):
"""Return the icon to use in the front-end."""
return DEFAULT_ICON
@property
def latitude(self):
"""Return latitude value of this external event."""
return self._latitude
@property
def longitude(self):
"""Return longitude value of this external event."""
return self._longitude
@property
def name(self):
"""Return the name of the event."""
return DEFAULT_EVENT_NAME_TEMPLATE.format(self._publication_date)
@property
def source(self) -> str:
"""Return source value of this external event."""
return DOMAIN
@property
def should_poll(self):
"""Disable polling."""
return False
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
return self._unit_of_measurement
@callback
def _delete_callback(self):
"""Remove this entity."""
self._remove_signal_delete()
self.hass.async_create_task(self.async_remove())
async def async_added_to_hass(self):
"""Call when entity is added to hass."""
self._remove_signal_delete = async_dispatcher_connect(
self.hass,
SIGNAL_DELETE_ENTITY.format(self._strike_id),
self._delete_callback,
)

View File

@@ -0,0 +1,466 @@
# coding: UTF-8
# flake8: noqa
"""
Copyright (C) 2009 Hiroaki Kawai <kawai@iij.ad.jp>
"""
try:
import _geohash
except ImportError:
_geohash = None
__version__ = "0.8.5"
__all__ = ['encode','decode','decode_exactly','bbox', 'neighbors', 'expand']
_base32 = '0123456789bcdefghjkmnpqrstuvwxyz'
_base32_map = {}
for i in range(len(_base32)):
_base32_map[_base32[i]] = i
del i
LONG_ZERO = 0
import sys
if sys.version_info[0] < 3:
LONG_ZERO = long(0)
def _float_hex_to_int(f):
if f<-1.0 or f>=1.0:
return None
if f==0.0:
return 1,1
h = f.hex()
x = h.find("0x1.")
assert(x>=0)
p = h.find("p")
assert(p>0)
half_len = len(h[x+4:p])*4-int(h[p+1:])
if x==0:
r = (1<<half_len) + ((1<<(len(h[x+4:p])*4)) + int(h[x+4:p],16))
else:
r = (1<<half_len) - ((1<<(len(h[x+4:p])*4)) + int(h[x+4:p],16))
return r, half_len+1
def _int_to_float_hex(i, l):
if l==0:
return -1.0
half = 1<<(l-1)
s = int((l+3)/4)
if i >= half:
i = i-half
return float.fromhex(("0x0.%0"+str(s)+"xp1") % (i<<(s*4-l),))
else:
i = half-i
return float.fromhex(("-0x0.%0"+str(s)+"xp1") % (i<<(s*4-l),))
def _encode_i2c(lat,lon,lat_length,lon_length):
precision = int((lat_length+lon_length)/5)
if lat_length < lon_length:
a = lon
b = lat
else:
a = lat
b = lon
boost = (0,1,4,5,16,17,20,21)
ret = ''
for i in range(precision):
ret+=_base32[(boost[a&7]+(boost[b&3]<<1))&0x1F]
t = a>>3
a = b>>2
b = t
return ret[::-1]
def encode(latitude, longitude, precision=12):
if latitude >= 90.0 or latitude < -90.0:
raise Exception("invalid latitude.")
while longitude < -180.0:
longitude += 360.0
while longitude >= 180.0:
longitude -= 360.0
if _geohash:
basecode=_geohash.encode(latitude,longitude)
if len(basecode)>precision:
return basecode[0:precision]
return basecode+'0'*(precision-len(basecode))
xprecision=precision+1
lat_length = lon_length = int(xprecision*5/2)
if xprecision%2==1:
lon_length+=1
if hasattr(float, "fromhex"):
a = _float_hex_to_int(latitude/90.0)
o = _float_hex_to_int(longitude/180.0)
if a[1] > lat_length:
ai = a[0]>>(a[1]-lat_length)
else:
ai = a[0]<<(lat_length-a[1])
if o[1] > lon_length:
oi = o[0]>>(o[1]-lon_length)
else:
oi = o[0]<<(lon_length-o[1])
return _encode_i2c(ai, oi, lat_length, lon_length)[:precision]
lat = latitude/180.0
lon = longitude/360.0
if lat>0:
lat = int((1<<lat_length)*lat)+(1<<(lat_length-1))
else:
lat = (1<<lat_length-1)-int((1<<lat_length)*(-lat))
if lon>0:
lon = int((1<<lon_length)*lon)+(1<<(lon_length-1))
else:
lon = (1<<lon_length-1)-int((1<<lon_length)*(-lon))
return _encode_i2c(lat,lon,lat_length,lon_length)[:precision]
def _decode_c2i(hashcode):
lon = 0
lat = 0
bit_length = 0
lat_length = 0
lon_length = 0
for i in hashcode:
t = _base32_map[i]
if bit_length%2==0:
lon = lon<<3
lat = lat<<2
lon += (t>>2)&4
lat += (t>>2)&2
lon += (t>>1)&2
lat += (t>>1)&1
lon += t&1
lon_length+=3
lat_length+=2
else:
lon = lon<<2
lat = lat<<3
lat += (t>>2)&4
lon += (t>>2)&2
lat += (t>>1)&2
lon += (t>>1)&1
lat += t&1
lon_length+=2
lat_length+=3
bit_length+=5
return (lat,lon,lat_length,lon_length)
def decode(hashcode, delta=False):
'''
decode a hashcode and get center coordinate, and distance between center and outer border
'''
if _geohash:
(lat,lon,lat_bits,lon_bits) = _geohash.decode(hashcode)
latitude_delta = 90.0/(1<<lat_bits)
longitude_delta = 180.0/(1<<lon_bits)
latitude = lat + latitude_delta
longitude = lon + longitude_delta
if delta:
return latitude,longitude,latitude_delta,longitude_delta
return latitude,longitude
(lat,lon,lat_length,lon_length) = _decode_c2i(hashcode)
if hasattr(float, "fromhex"):
latitude_delta = 90.0/(1<<lat_length)
longitude_delta = 180.0/(1<<lon_length)
latitude = _int_to_float_hex(lat, lat_length) * 90.0 + latitude_delta
longitude = _int_to_float_hex(lon, lon_length) * 180.0 + longitude_delta
if delta:
return latitude,longitude,latitude_delta,longitude_delta
return latitude,longitude
lat = (lat<<1) + 1
lon = (lon<<1) + 1
lat_length += 1
lon_length += 1
latitude = 180.0*(lat-(1<<(lat_length-1)))/(1<<lat_length)
longitude = 360.0*(lon-(1<<(lon_length-1)))/(1<<lon_length)
if delta:
latitude_delta = 180.0/(1<<lat_length)
longitude_delta = 360.0/(1<<lon_length)
return latitude,longitude,latitude_delta,longitude_delta
return latitude,longitude
def decode_exactly(hashcode):
return decode(hashcode, True)
## hashcode operations below
def bbox(hashcode):
'''
decode a hashcode and get north, south, east and west border.
'''
if _geohash:
(lat,lon,lat_bits,lon_bits) = _geohash.decode(hashcode)
latitude_delta = 180.0/(1<<lat_bits)
longitude_delta = 360.0/(1<<lon_bits)
return {'s':lat,'w':lon,'n':lat+latitude_delta,'e':lon+longitude_delta}
(lat,lon,lat_length,lon_length) = _decode_c2i(hashcode)
if hasattr(float, "fromhex"):
latitude_delta = 180.0/(1<<lat_length)
longitude_delta = 360.0/(1<<lon_length)
latitude = _int_to_float_hex(lat, lat_length) * 90.0
longitude = _int_to_float_hex(lon, lon_length) * 180.0
return {"s":latitude, "w":longitude, "n":latitude+latitude_delta, "e":longitude+longitude_delta}
ret={}
if lat_length:
ret['n'] = 180.0*(lat+1-(1<<(lat_length-1)))/(1<<lat_length)
ret['s'] = 180.0*(lat-(1<<(lat_length-1)))/(1<<lat_length)
else: # can't calculate the half with bit shifts (negative shift)
ret['n'] = 90.0
ret['s'] = -90.0
if lon_length:
ret['e'] = 360.0*(lon+1-(1<<(lon_length-1)))/(1<<lon_length)
ret['w'] = 360.0*(lon-(1<<(lon_length-1)))/(1<<lon_length)
else: # can't calculate the half with bit shifts (negative shift)
ret['e'] = 180.0
ret['w'] = -180.0
return ret
def neighbors(hashcode):
if _geohash and len(hashcode)<25:
return _geohash.neighbors(hashcode)
(lat,lon,lat_length,lon_length) = _decode_c2i(hashcode)
ret = []
tlat = lat
for tlon in (lon-1, lon+1):
code = _encode_i2c(tlat,tlon,lat_length,lon_length)
if code:
ret.append(code)
tlat = lat+1
if not tlat >> lat_length:
for tlon in (lon-1, lon, lon+1):
ret.append(_encode_i2c(tlat,tlon,lat_length,lon_length))
tlat = lat-1
if tlat >= 0:
for tlon in (lon-1, lon, lon+1):
ret.append(_encode_i2c(tlat,tlon,lat_length,lon_length))
return ret
def expand(hashcode):
ret = neighbors(hashcode)
ret.append(hashcode)
return ret
def _uint64_interleave(lat32, lon32):
intr = 0
boost = (0,1,4,5,16,17,20,21,64,65,68,69,80,81,84,85)
for i in range(8):
intr = (intr<<8) + (boost[(lon32>>(28-i*4))%16]<<1) + boost[(lat32>>(28-i*4))%16]
return intr
def _uint64_deinterleave(ui64):
lat = lon = 0
boost = ((0,0),(0,1),(1,0),(1,1),(0,2),(0,3),(1,2),(1,3),
(2,0),(2,1),(3,0),(3,1),(2,2),(2,3),(3,2),(3,3))
for i in range(16):
p = boost[(ui64>>(60-i*4))%16]
lon = (lon<<2) + p[0]
lat = (lat<<2) + p[1]
return (lat, lon)
def encode_uint64(latitude, longitude):
if latitude >= 90.0 or latitude < -90.0:
raise ValueError("Latitude must be in the range of (-90.0, 90.0)")
while longitude < -180.0:
longitude += 360.0
while longitude >= 180.0:
longitude -= 360.0
if _geohash:
ui128 = _geohash.encode_int(latitude,longitude)
if _geohash.intunit == 64:
return ui128[0]
elif _geohash.intunit == 32:
return (ui128[0]<<32) + ui128[1]
elif _geohash.intunit == 16:
return (ui128[0]<<48) + (ui128[1]<<32) + (ui128[2]<<16) + ui128[3]
lat = int(((latitude + 90.0)/180.0)*(1<<32))
lon = int(((longitude+180.0)/360.0)*(1<<32))
return _uint64_interleave(lat, lon)
def decode_uint64(ui64):
if _geohash:
latlon = _geohash.decode_int(ui64 % 0xFFFFFFFFFFFFFFFF, LONG_ZERO)
if latlon:
return latlon
lat,lon = _uint64_deinterleave(ui64)
return (180.0*lat/(1<<32) - 90.0, 360.0*lon/(1<<32) - 180.0)
def expand_uint64(ui64, precision=50):
ui64 = ui64 & (0xFFFFFFFFFFFFFFFF << (64-precision))
lat,lon = _uint64_deinterleave(ui64)
lat_grid = 1<<(32-int(precision/2))
lon_grid = lat_grid>>(precision%2)
if precision<=2: # expand becomes to the whole range
return []
ranges = []
if lat & lat_grid:
if lon & lon_grid:
ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision+2))))
if precision%2==0:
# lat,lon = (1, 1) and even precision
ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision+1))))
if lat + lat_grid < 0xFFFFFFFF:
ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat+lat_grid, lon)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat+lat_grid, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
else:
# lat,lon = (1, 1) and odd precision
if lat + lat_grid < 0xFFFFFFFF:
ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision+1))))
ui64 = _uint64_interleave(lat+lat_grid, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
else:
ui64 = _uint64_interleave(lat-lat_grid, lon)
ranges.append((ui64, ui64 + (1<<(64-precision+2))))
if precision%2==0:
# lat,lon = (1, 0) and odd precision
ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision+1))))
if lat + lat_grid < 0xFFFFFFFF:
ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat+lat_grid, lon)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat+lat_grid, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
else:
# lat,lon = (1, 0) and odd precision
if lat + lat_grid < 0xFFFFFFFF:
ui64 = _uint64_interleave(lat+lat_grid, lon)
ranges.append((ui64, ui64 + (1<<(64-precision+1))))
ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
else:
if lon & lon_grid:
ui64 = _uint64_interleave(lat, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision+2))))
if precision%2==0:
# lat,lon = (0, 1) and even precision
ui64 = _uint64_interleave(lat, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision+1))))
if lat > 0:
ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat-lat_grid, lon)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
else:
# lat,lon = (0, 1) and odd precision
if lat > 0:
ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision+1))))
ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat+lat_grid, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
else:
ui64 = _uint64_interleave(lat, lon)
ranges.append((ui64, ui64 + (1<<(64-precision+2))))
if precision%2==0:
# lat,lon = (0, 0) and even precision
ui64 = _uint64_interleave(lat, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision+1))))
if lat > 0:
ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat-lat_grid, lon)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
else:
# lat,lon = (0, 0) and odd precision
if lat > 0:
ui64 = _uint64_interleave(lat-lat_grid, lon)
ranges.append((ui64, ui64 + (1<<(64-precision+1))))
ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid)
ranges.append((ui64, ui64 + (1<<(64-precision))))
ranges.sort()
# merge the conditions
shrink = []
prev = None
for i in ranges:
if prev:
if prev[1] != i[0]:
shrink.append(prev)
prev = i
else:
prev = (prev[0], i[1])
else:
prev = i
shrink.append(prev)
ranges = []
for i in shrink:
a,b=i
if a == 0:
a = None # we can remove the condition because it is the lowest value
if b == 0x10000000000000000:
b = None # we can remove the condition because it is the highest value
ranges.append((a,b))
return ranges

View File

@@ -0,0 +1,58 @@
import math
from collections import namedtuple
from . import geohash
Box = namedtuple("Box", ["s", "w", "n", "e"])
def geohash_bbox(gh):
ret = geohash.bbox(gh)
return Box(ret["s"], ret["w"], ret["n"], ret["e"])
def bbox(lat, lon, radius):
lat_delta = radius * 360 / 40000
lon_delta = lat_delta / math.cos(lat * math.pi / 180.0)
return Box(lat - lat_delta, lon - lon_delta, lat + lat_delta, lon + lon_delta)
def overlap(a1, a2, b1, b2):
return a1 < b2 and a2 > b1
def box_overlap(box1: Box, box2: Box):
return overlap(box1.s, box1.n, box2.s, box2.n) and overlap(
box1.w, box1.e, box2.w, box2.e
)
def compute_geohash_tiles(lat, lon, radius, precision):
bounds = bbox(lat, lon, radius)
center = geohash.encode(lat, lon, precision)
stack = set()
checked = set()
stack.add(center)
checked.add(center)
while stack:
current = stack.pop()
for neighbor in geohash.neighbors(current):
if neighbor not in checked and box_overlap(geohash_bbox(neighbor), bounds):
stack.add(neighbor)
checked.add(neighbor)
return checked
def geohash_overlap(lat, lon, radius, max_tiles=9):
result = []
for precision in range(1, 13):
tiles = compute_geohash_tiles(lat, lon, radius, precision)
if len(tiles) <= 9:
result = tiles
precision += 1
else:
break
return result

View File

@@ -0,0 +1,17 @@
{
"domain": "blitzortung",
"name": "Blitzortung",
"after_dependencies": [],
"codeowners": [
"@mrk-its"
],
"config_flow": true,
"dependencies": [
"persistent_notification"
],
"documentation": "https://github.com/mrk-its/homeassistant-blitzortung",
"iot_class": "cloud_push",
"issue_tracker": "https://github.com/mrk-its/homeassistant-blitzortung/issues",
"requirements": ["paho-mqtt>=1.5.0"],
"version": "1.0.1"
}

View File

@@ -0,0 +1,305 @@
"""Support for MQTT message handling."""
import asyncio
import datetime as dt
import logging
from itertools import groupby
from operator import attrgetter
from typing import Callable, List, Optional, Union
import attr
from homeassistant.core import callback, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
DEFAULT_PORT = 1883
DEFAULT_KEEPALIVE = 60
PROTOCOL_311 = "3.1.1"
DEFAULT_PROTOCOL = PROTOCOL_311
MQTT_CONNECTED = "blitzortung_mqtt_connected"
MQTT_DISCONNECTED = "blitzortung_mqtt_disconnected"
MAX_RECONNECT_WAIT = 300 # seconds
def _raise_on_error(result_code: int) -> None:
"""Raise error if error result."""
# pylint: disable=import-outside-toplevel
import paho.mqtt.client as mqtt
if result_code != 0:
raise HomeAssistantError(
f"Error talking to MQTT: {mqtt.error_string(result_code)}"
)
def _match_topic(subscription: str, topic: str) -> bool:
"""Test if topic matches subscription."""
# pylint: disable=import-outside-toplevel
from paho.mqtt.matcher import MQTTMatcher
matcher = MQTTMatcher()
matcher[subscription] = True
try:
next(matcher.iter_match(topic))
return True
except StopIteration:
return False
PublishPayloadType = Union[str, bytes, int, float, None]
@attr.s(slots=True, frozen=True)
class Message:
"""MQTT Message."""
topic = attr.ib(type=str)
payload = attr.ib(type=PublishPayloadType)
qos = attr.ib(type=int)
retain = attr.ib(type=bool)
subscribed_topic = attr.ib(type=str, default=None)
timestamp = attr.ib(type=dt.datetime, default=None)
MessageCallbackType = Callable[[Message], None]
@attr.s(slots=True, frozen=True)
class Subscription:
"""Class to hold data about an active subscription."""
topic = attr.ib(type=str)
callback = attr.ib(type=MessageCallbackType)
qos = attr.ib(type=int, default=0)
encoding = attr.ib(type=str, default="utf-8")
SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None
class MQTT:
"""Home Assistant MQTT client."""
def __init__(
self,
hass: HomeAssistant,
host,
port=DEFAULT_PORT,
keepalive=DEFAULT_KEEPALIVE,
) -> None:
"""Initialize Home Assistant MQTT client."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
self.hass = hass
self.host = host
self.port = port
self.keepalive = keepalive
self.subscriptions: List[Subscription] = []
self.connected = False
self._mqttc: mqtt.Client = None
self._paho_lock = asyncio.Lock()
self.init_client()
def init_client(self):
"""Initialize paho client."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
proto = mqtt.MQTTv311
self._mqttc = mqtt.Client(protocol=proto)
self._mqttc.on_connect = self._mqtt_on_connect
self._mqttc.on_disconnect = self._mqtt_on_disconnect
self._mqttc.on_message = self._mqtt_on_message
async def async_publish(
self, topic: str, payload: PublishPayloadType, qos: int, retain: bool
) -> None:
"""Publish a MQTT message."""
async with self._paho_lock:
_LOGGER.debug("Transmitting message on %s: %s", topic, payload)
await self.hass.async_add_executor_job(
self._mqttc.publish, topic, payload, qos, retain
)
async def async_connect(self) -> str:
"""Connect to the host. Does not process messages yet."""
# pylint: disable=import-outside-toplevel
import paho.mqtt.client as mqtt
result: int = None
try:
result = await self.hass.async_add_executor_job(
self._mqttc.connect, self.host, self.port, self.keepalive,
)
except OSError as err:
_LOGGER.error("Failed to connect to MQTT server due to exception: %s", err)
if result is not None and result != 0:
_LOGGER.error(
"Failed to connect to MQTT server: %s", mqtt.error_string(result)
)
self._mqttc.loop_start()
async def async_disconnect(self):
"""Stop the MQTT client."""
def stop():
"""Stop the MQTT client."""
self._mqttc.disconnect()
self._mqttc.loop_stop()
await self.hass.async_add_executor_job(stop)
async def async_subscribe(
self, topic: str, msg_callback, qos: int, encoding: Optional[str] = None,
) -> Callable[[], None]:
"""Set up a subscription to a topic with the provided qos.
This method is a coroutine.
"""
if not isinstance(topic, str):
raise HomeAssistantError("Topic needs to be a string!")
subscription = Subscription(topic, msg_callback, qos, encoding)
self.subscriptions.append(subscription)
# Only subscribe if currently connected.
if self.connected:
await self._async_perform_subscription(topic, qos)
@callback
def async_remove() -> None:
"""Remove subscription."""
if subscription not in self.subscriptions:
raise HomeAssistantError("Can't remove subscription twice")
self.subscriptions.remove(subscription)
if any(other.topic == topic for other in self.subscriptions):
# Other subscriptions on topic remaining - don't unsubscribe.
return
# Only unsubscribe if currently connected.
if self.connected:
self.hass.async_create_task(self._async_unsubscribe(topic))
return async_remove
async def _async_unsubscribe(self, topic: str) -> None:
"""Unsubscribe from a topic.
This method is a coroutine.
"""
_LOGGER.debug("Unsubscribing from %s", topic)
async with self._paho_lock:
result: int = None
result, _ = await self.hass.async_add_executor_job(
self._mqttc.unsubscribe, topic
)
_raise_on_error(result)
async def _async_perform_subscription(self, topic: str, qos: int) -> None:
"""Perform a paho-mqtt subscription."""
_LOGGER.debug("Subscribing to %s", topic)
async with self._paho_lock:
result: int = None
result, _ = await self.hass.async_add_executor_job(
self._mqttc.subscribe, topic, qos
)
_raise_on_error(result)
def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code: int) -> None:
"""On connect callback.
Resubscribe to all topics we were subscribed to and publish birth
message.
"""
# pylint: disable=import-outside-toplevel
import paho.mqtt.client as mqtt
if result_code != mqtt.CONNACK_ACCEPTED:
_LOGGER.error(
"Unable to connect to the MQTT broker: %s",
mqtt.connack_string(result_code),
)
return
self.connected = True
dispatcher_send(self.hass, MQTT_CONNECTED)
_LOGGER.info(
"Connected to MQTT server %s:%s (%s)", self.host, self.port, result_code,
)
# Group subscriptions to only re-subscribe once for each topic.
keyfunc = attrgetter("topic")
for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), keyfunc):
# Re-subscribe with the highest requested qos
max_qos = max(subscription.qos for subscription in subs)
self.hass.add_job(self._async_perform_subscription, topic, max_qos)
def _mqtt_on_message(self, _mqttc, _userdata, msg) -> None:
"""Message received callback."""
self.hass.add_job(self._mqtt_handle_message, msg)
@callback
def _mqtt_handle_message(self, msg) -> None:
_LOGGER.debug(
"Received message on %s%s: %s",
msg.topic,
" (retained)" if msg.retain else "",
msg.payload,
)
timestamp = dt_util.utcnow()
for subscription in self.subscriptions:
if not _match_topic(subscription.topic, msg.topic):
continue
payload: SubscribePayloadType = msg.payload
if subscription.encoding is not None:
try:
payload = msg.payload.decode(subscription.encoding)
except (AttributeError, UnicodeDecodeError):
_LOGGER.warning(
"Can't decode payload %s on %s with encoding %s (for %s)",
msg.payload,
msg.topic,
subscription.encoding,
subscription.callback,
)
continue
self.hass.async_create_task(
subscription.callback(
Message(
msg.topic,
payload,
msg.qos,
msg.retain,
subscription.topic,
timestamp,
)
)
)
def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None:
"""Disconnected callback."""
self.connected = False
dispatcher_send(self.hass, MQTT_DISCONNECTED)
_LOGGER.info(
"Disconnected from MQTT server %s:%s (%s)",
self.host,
self.port,
result_code,
)

View File

@@ -0,0 +1,225 @@
import logging
from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, DEGREE, UnitOfLength
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.helpers.device_registry import DeviceEntryType
from .const import (
ATTR_LAT,
ATTR_LIGHTNING_AZIMUTH,
ATTR_LIGHTNING_COUNTER,
ATTR_LON,
ATTRIBUTION,
DOMAIN,
SERVER_STATS,
SW_VERSION,
)
ATTR_ICON = "icon"
ATTR_LABEL = "label"
ATTR_UNIT = "unit"
ATTR_LIGHTNING_PROPERTY = "lightning_prop"
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
integration_name = config_entry.data[CONF_NAME]
coordinator = hass.data[DOMAIN][config_entry.entry_id]
unique_prefix = config_entry.unique_id
sensors = [
klass(coordinator, integration_name, unique_prefix)
for klass in (DistanceSensor, AzimuthSensor, CounterSensor)
]
async_add_entities(sensors, False)
config = hass.data[DOMAIN].get("config") or {}
if config.get(SERVER_STATS):
server_stat_sensors = {}
def on_message(message):
if not message.topic.startswith("$SYS/broker/"):
return
topic = message.topic.replace("$SYS/broker/", "")
if topic.startswith("load") and not topic.endswith("/1min"):
return
if topic.startswith("clients") and topic != "clients/connected":
return
sensor = server_stat_sensors.get(topic)
if not sensor:
sensor = ServerStatSensor(
topic, coordinator, integration_name, unique_prefix
)
server_stat_sensors[topic] = sensor
async_add_entities([sensor], False)
sensor.on_message(topic, message)
coordinator.register_message_receiver(on_message)
class BlitzortungSensor(SensorEntity):
"""Define a Blitzortung sensor."""
def __init__(self, coordinator, integration_name, unique_prefix):
"""Initialize."""
self.coordinator = coordinator
self._integration_name = integration_name
self.entity_id = f"sensor.{integration_name}-{self.name}"
self._unique_id = f"{unique_prefix}-{self.kind}"
self._device_class = None
self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION}
should_poll = False
icon = "mdi:flash"
device_class = None
@property
def available(self):
return self.coordinator.is_connected
@property
def label(self):
return self.kind.capitalize()
@property
def name(self):
"""Return the name."""
return f"Lightning {self.label}"
@property
def extra_state_attributes(self):
"""Return the state attributes."""
return self._attrs
@property
def unique_id(self):
"""Return a unique_id for this entity."""
return self._unique_id
async def async_added_to_hass(self):
"""Connect to dispatcher listening for entity data notifications."""
# self.async_on_remove(self.coordinator.async_add_listener(self._update_sensor))
self.coordinator.register_sensor(self)
async def async_update(self):
await self.coordinator.async_request_refresh()
@property
def device_info(self):
return {
"name": f"{self._integration_name} Lightning Detector",
"identifiers": {(DOMAIN, self._integration_name)},
"model": "Lightning Detector",
"sw_version": SW_VERSION,
"entry_type": DeviceEntryType.SERVICE,
}
def update_lightning(self, lightning):
pass
def on_message(self, message):
pass
def tick(self):
pass
class LightningSensor(BlitzortungSensor):
INITIAL_STATE = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._attr_native_value = self.INITIAL_STATE
def tick(self):
if self._attr_native_value != self.INITIAL_STATE and self.coordinator.is_inactive:
self._attr_native_value = self.INITIAL_STATE
self.async_write_ha_state()
class DistanceSensor(LightningSensor):
kind = SensorDeviceClass.DISTANCE
device_class = SensorDeviceClass.DISTANCE
state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = UnitOfLength.KILOMETERS
def update_lightning(self, lightning):
self._attr_native_value = lightning["distance"]
self._attrs[ATTR_LAT] = lightning[ATTR_LAT]
self._attrs[ATTR_LON] = lightning[ATTR_LON]
self.async_write_ha_state()
class AzimuthSensor(LightningSensor):
kind = ATTR_LIGHTNING_AZIMUTH
_attr_native_unit_of_measurement = DEGREE
def update_lightning(self, lightning):
self._attr_native_value = lightning["azimuth"]
self._attrs[ATTR_LAT] = lightning[ATTR_LAT]
self._attrs[ATTR_LON] = lightning[ATTR_LON]
self.async_write_ha_state()
class CounterSensor(LightningSensor):
kind = ATTR_LIGHTNING_COUNTER
_attr_native_unit_of_measurement = ""
INITIAL_STATE = 0
def update_lightning(self, lightning):
self._attr_native_value = self._attr_native_value + 1
self.async_write_ha_state()
class ServerStatSensor(BlitzortungSensor):
def __init__(self, topic, coordinator, integration_name, unique_prefix):
self._topic = topic
topic_parts = topic.replace("$SYS/broker/", "").split("/")
self.kind = "_".join(topic_parts)
if self.kind.startswith("load"):
self.data_type = float
elif self.kind in ("uptime", "version"):
self.data_type = str
else:
self.data_type = int
if self.kind == "clients_connected":
self.kind = "server_stats"
self._name = " ".join(part.capitalize() for part in topic_parts)
super().__init__(coordinator, integration_name, unique_prefix)
@property
def unit_of_measurement(self):
if self.data_type in (int, float):
return "." if self.kind == "server_stats" else " "
else:
return None
@classmethod
def for_topic(cls, topic, coordinator, integration_name, unique_prefix):
return cls(topic, coordinator, integration_name, unique_prefix)
def on_message(self, topic, message):
if topic == self._topic:
payload = message.payload.decode("utf-8")
try:
self._attr_native_value = self.data_type(payload)
except ValueError:
self._attr_native_value = str(payload)
if self.hass:
self.async_write_ha_state()
@property
def label(self):
return self._name
@property
def name(self):
return self._name

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "Set up Blitzortung lightning detection",
"data": {
"name": "Name of the integration instance"
}
}
},
"error": {},
"abort": {}
},
"options": {
"step": {
"init": {
"title": "Blitzortung Options",
"description": "Set up Blitzortung lightning detection options",
"data": {
"radius": "Lightning detection radius (km / mi)",
"time_window": "Time window (minutes, 0 - disabled)",
"max_tracked_lightnings": "Max number of tracked lightnings",
"latitude": "Latitude",
"longitude": "Longitude"
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "Set up Blitzortung lightning detection",
"data": {
"name": "Name of the integration instance"
}
}
},
"error": {},
"abort": {}
},
"options": {
"step": {
"init": {
"title": "Blitzortung Options",
"description": "Set up Blitzortung lightning detection options",
"data": {
"radius": "Lightning detection radius (km / mi)",
"time_window": "Time window (minutes, 0 - disabled)",
"max_tracked_lightnings": "Max number of tracked lightnings",
"latitude": "Latitude",
"longitude": "Longitude"
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "Määritä Blitzortung ukkostutka",
"data": {
"name": "Integraation instanssin nimi"
}
}
},
"error": {},
"abort": {}
},
"options": {
"step": {
"init": {
"title": "Blitzortung Asetukset",
"description": "Määritä Blitzortung ukkostutkan asetukset",
"data": {
"radius": "Salamoiden seuranta-alue (km / mi)",
"time_window": "Aikaikkuna (minuuttia, 0 - ei käytössä)",
"max_tracked_lightnings": "Seurattavien salamoiden enimmäismäärä",
"latitude": "Leveysaste",
"longitude": "Pituusaste"
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "Configurer Blitzortung détection de fourdre",
"data": {
"name": "Nom de l'instance de cette intégration"
}
}
},
"error": {},
"abort": {}
},
"options": {
"step": {
"init": {
"title": "Options Blitzortung ",
"description": "Configurer les options de Blitzortung détection de foudre",
"data": {
"radius": "Rayon de detection de la foudre (km / mi)",
"time_window": "Fenêtre de temps (minutes, 0 - désactivé)",
"max_tracked_lightnings": "Nombre maximum d'éclairs suivis",
"latitude": "Latitude",
"longitude": "Longitude"
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "Postavke Blitzortung senzora groma",
"data": {
"name": "Naziv instance integracije"
}
}
},
"error": {},
"abort": {}
},
"options": {
"step": {
"init": {
"title": "Blitzortung opcije",
"description": "Podesite Blitzortung opcije otkrivanja groma",
"data": {
"radius": "Radijus prepoznavanja groma (km / mi)",
"time_window": "Vremenski period (minuta, 0 - onemogućeno)",
"max_tracked_lightnings": "Maksimalan broj praćenih gromova",
"latitude": "Zemljopisna širina",
"longitude": "Zemljopisna dužina"
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "Sett opp lynregistrering av Blitzortung",
"data": {
"name": "Navnet på integrasjonsforekomsten"
}
}
},
"error": {},
"abort": {}
},
"options": {
"step": {
"init": {
"title": "Blitzortung-alternativer",
"description": "Sett opp Blitzortung lyndeteksjonsalternativer",
"data": {
"radius": "Lyndeteksjonsradius (km / mi)",
"time_window": "Tidsvindu (minutter, 0 - deaktivert)",
"max_tracked_lightnings": "Maks antall sporede lyn",
"latitude": "Breddegrad",
"longitude": "Lengdegrad"
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "Blitzortung bliksemdetectie instellen",
"data": {
"name": "Naam van de integratie-instantie"
}
}
},
"error": {},
"abort": {}
},
"options": {
"step": {
"init": {
"title": "Blitzortung Opties",
"description": "Blitzortung bliksemdetectie opties instellen",
"data": {
"radius": "Bliksemdetectie-radius (km / mi)",
"time_window": "Tijdvenster (minuten, 0 - uitgeschakeld)",
"max_tracked_lightnings": "Max aantal gevolgde bliksems",
"latitude": "Breedtegraad",
"longitude": "Lengtegraad"
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "Konfiguracja wykrywania błyskawic Blitzortung",
"data": {
"name": "Nazwa instancji integracji"
}
}
},
"error": {},
"abort": {}
},
"options": {
"step": {
"init": {
"title": "Opcje Blitzortung",
"description": "Konfiguracja wykrywania błyskawic Blitzortung",
"data": {
"radius": "Promień wykrywania błyskawic (km / mi)",
"time_window": "Okno czasowe (minuty, 0 - wyłączony)",
"max_tracked_lightnings": "Maksymalna ilość śledzonych błyskawic",
"latitude": "Szerokość geograficzna",
"longitude": "Długość geograficzna"
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "Postavke zaznavanja strel Blitzortung",
"data": {
"name": "Ime integracije"
}
}
},
"error": {},
"abort": {}
},
"options": {
"step": {
"init": {
"title": "Možnosti Blitzortung",
"description": "Nastavite možnosti zaznavanja strel Blitzortung",
"data": {
"radius": "Doseg zaznavanja strel (km / mi)",
"time_window": "Časovni okvir (v minutah, 0 - disabled)",
"max_tracked_lightnings": "Maksimalno število zaznanih strel",
"latitude": "Zemljepisna širina središča zaznavanja",
"longitude": "Zemljepisna dolžina središča zaznavanja"
}
}
}
}
}

View File

@@ -0,0 +1 @@
__version__ = "1.1.0"

View File

@@ -0,0 +1,102 @@
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .config.const import CONF_DEVICE_TYPE, CONF_USERNAME, CONF_PASSWORD, OPTS_POWER_STEP, OPTS_REFRESH_PERIOD_SEC, \
DEFAULT_REFRESH_PERIOD_SEC, CONF_DEVICE_ID
from .mqtt.ecoflow_mqtt import EcoflowMQTTClient, EcoflowAuthentication
_LOGGER = logging.getLogger(__name__)
DOMAIN = "ecoflow_cloud"
CONFIG_VERSION = 3
_PLATFORMS = {
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.BUTTON,
}
ATTR_STATUS_SN = "SN"
ATTR_STATUS_UPDATES = "status_request_count"
ATTR_STATUS_LAST_UPDATE = "status_last_update"
ATTR_STATUS_DATA_LAST_UPDATE = "data_last_update"
ATTR_STATUS_RECONNECTS = "reconnects"
ATTR_STATUS_PHASE = "status_phase"
async def async_migrate_entry(hass, config_entry: ConfigEntry):
"""Migrate old entry."""
if config_entry.version == 1:
from .devices.registry import devices as device_registry
device = device_registry[config_entry.data[CONF_DEVICE_TYPE]]
new_data = {**config_entry.data}
new_options = {OPTS_POWER_STEP: device.charging_power_step(),
OPTS_REFRESH_PERIOD_SEC: DEFAULT_REFRESH_PERIOD_SEC}
config_entry.version = 2
hass.config_entries.async_update_entry(config_entry, data=new_data, options=new_options)
_LOGGER.info("Migration to version %s successful", config_entry.version)
if config_entry.version < CONFIG_VERSION:
from .devices.registry import devices as device_registry
from .entities import EcoFlowAbstractEntity
from .devices import EntityMigration, MigrationAction
device = device_registry[config_entry.data[CONF_DEVICE_TYPE]]
device_sn = config_entry.data[CONF_DEVICE_ID]
entity_registry = er.async_get(hass)
for v in (config_entry.version, CONFIG_VERSION):
migrations: list[EntityMigration] = device.migrate(v)
for m in migrations:
if m.action == MigrationAction.REMOVE:
entity_id = entity_registry.async_get_entity_id(
domain=m.domain,
platform=DOMAIN,
unique_id=EcoFlowAbstractEntity.gen_unique_id(sn=device_sn, key=m.key))
if entity_id:
_LOGGER.info(".... removing entity_id = %s", entity_id)
entity_registry.async_remove(entity_id)
config_entry.version = CONFIG_VERSION
hass.config_entries.async_update_entry(config_entry)
_LOGGER.info("Migration to version %s successful", config_entry.version)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
auth = EcoflowAuthentication(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD])
await hass.async_add_executor_job(auth.authorize)
client = EcoflowMQTTClient(hass, entry, auth)
hass.data[DOMAIN][entry.entry_id] = client
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
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: EcoflowMQTTClient = hass.data[DOMAIN].pop(entry.entry_id)
client.stop()
return True
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,34 @@
import logging
from typing import Any
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
from .entities import BaseButtonEntity
from .mqtt.ecoflow_mqtt import EcoflowMQTTClient
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id]
from .devices.registry import devices
async_add_entities(devices[client.device_type].buttons(client))
class EnabledButtonEntity(BaseButtonEntity):
def press(self, **kwargs: Any) -> None:
if self._command:
self.send_set_message(0, self.command_dict(0))
class DisabledButtonEntity(BaseButtonEntity):
async def async_press(self, **kwargs: Any) -> None:
if self._command:
self.send_set_message(0, self.command_dict(0))

View File

@@ -0,0 +1,35 @@
from enum import Enum
from typing import Final
from homeassistant import const
CONF_USERNAME: Final = const.CONF_USERNAME
CONF_PASSWORD: Final = const.CONF_PASSWORD
CONF_DEVICE_TYPE: Final = const.CONF_TYPE
CONF_DEVICE_NAME: Final = const.CONF_NAME
CONF_DEVICE_ID: Final = const.CONF_DEVICE_ID
OPTS_POWER_STEP: Final = "power_step"
OPTS_REFRESH_PERIOD_SEC: Final = "refresh_period_sec"
DEFAULT_REFRESH_PERIOD_SEC: Final = 5
class EcoflowModel(Enum):
DELTA_2 = 1,
RIVER_2 = 2,
RIVER_2_MAX = 3,
RIVER_2_PRO = 4,
DELTA_PRO = 5,
RIVER_MAX = 6,
RIVER_PRO = 7,
DELTA_MAX = 8, # productType = 13
DELTA_2_MAX = 9, # productType = 81
DELTA_MINI = 15, # productType = 15
POWERSTREAM = 51,
GLACIER = 46,
WAVE_2 = 45, # productType = 45
DIAGNOSTIC = 99
@classmethod
def list(cls) -> list[str]:
return [e.name for e in EcoflowModel]

View File

@@ -0,0 +1,72 @@
import logging
from typing import Dict, Any
import voluptuous as vol
from homeassistant import const
from homeassistant.config_entries import ConfigFlow, ConfigEntry, OptionsFlow
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from . import DOMAIN, CONFIG_VERSION
from .config.const import EcoflowModel, CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TYPE, CONF_DEVICE_NAME, \
CONF_DEVICE_ID, OPTS_POWER_STEP, OPTS_REFRESH_PERIOD_SEC, DEFAULT_REFRESH_PERIOD_SEC
_LOGGER = logging.getLogger(__name__)
class EcoflowConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = CONFIG_VERSION
async def async_step_user(self, user_input: dict[str, Any] | None = None):
errors: Dict[str, str] = {}
if user_input is not None and not errors:
from .devices.registry import devices
device = devices[user_input[CONF_DEVICE_TYPE]]
options = {OPTS_POWER_STEP: device.charging_power_step(), OPTS_REFRESH_PERIOD_SEC: DEFAULT_REFRESH_PERIOD_SEC}
return self.async_create_entry(title=user_input[const.CONF_NAME], data=user_input, options=options)
return self.async_show_form(
step_id="user",
last_step=True,
data_schema=vol.Schema({
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_DEVICE_TYPE): selector.SelectSelector(
selector.SelectSelectorConfig(options=EcoflowModel.list(),
mode=selector.SelectSelectorMode.DROPDOWN),
),
vol.Required(CONF_DEVICE_NAME): str,
vol.Required(CONF_DEVICE_ID): str,
})
)
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
return EcoflowOptionsFlow(config_entry)
class EcoflowOptionsFlow(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None:
self.config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
last_step=True,
data_schema=vol.Schema({
vol.Optional(OPTS_POWER_STEP,
default=self.config_entry.options[OPTS_POWER_STEP]): int,
vol.Optional(OPTS_REFRESH_PERIOD_SEC,
default=self.config_entry.options[OPTS_REFRESH_PERIOD_SEC]): int,
})
)

View File

@@ -0,0 +1,70 @@
from abc import ABC, abstractmethod
from enum import StrEnum
from homeassistant.components.number import NumberEntity
from homeassistant.components.select import SelectEntity
from homeassistant.components.sensor import SensorEntity
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.button import ButtonEntity
from homeassistant.const import Platform
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
class MigrationAction(StrEnum):
REMOVE = "remove"
class EntityMigration:
def __init__(self, key: str, domain: Platform, action: MigrationAction, **kwargs):
self.key = key
self.domain = domain
self.action = action
self.args = kwargs
class BaseDevice(ABC):
def charging_power_step(self) -> int:
return 100
@abstractmethod
def sensors(self, client: EcoflowMQTTClient) -> list[SensorEntity]:
pass
@abstractmethod
def numbers(self, client: EcoflowMQTTClient) -> list[NumberEntity]:
pass
@abstractmethod
def switches(self, client: EcoflowMQTTClient) -> list[SwitchEntity]:
pass
@abstractmethod
def selects(self, client: EcoflowMQTTClient) -> list[SelectEntity]:
pass
def buttons(self, client: EcoflowMQTTClient) -> list[ButtonEntity]:
return []
def migrate(self, version) -> list[EntityMigration]:
return []
class DiagnosticDevice(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[SensorEntity]:
return []
def numbers(self, client: EcoflowMQTTClient) -> list[NumberEntity]:
return []
def switches(self, client: EcoflowMQTTClient) -> list[SwitchEntity]:
return []
def buttons(self, client: EcoflowMQTTClient) -> list[ButtonEntity]:
return []
def selects(self, client: EcoflowMQTTClient) -> list[SelectEntity]:
return []

View File

@@ -0,0 +1,255 @@
DC_MODE_OPTIONS = {
"Auto": 0,
"Solar Recharging": 1,
"Car Recharging": 2,
}
DC_ICONS = {
"Auto": None,
"MPPT": "mdi:solar-power",
"DC": "mdi:current-dc",
}
SCREEN_TIMEOUT_OPTIONS = {
"Never": 0,
"10 sec": 10,
"30 sec": 30,
"1 min": 60,
"5 min": 300,
"30 min": 1800,
}
UNIT_TIMEOUT_OPTIONS = {
"Never": 0,
"30 min": 30,
"1 hr": 60,
"2 hr": 120,
"4 hr": 240,
"6 hr": 360,
"12 hr": 720,
"24 hr": 1440
}
UNIT_TIMEOUT_OPTIONS_LIMITED = {
"Never": 0,
"30 min": 30,
"1 hr": 60,
"2 hr": 120,
"6 hr": 360,
"12 hr": 720
}
AC_TIMEOUT_OPTIONS = {
"Never": 0,
"30 min": 30,
"1 hr": 60,
"2 hr": 120,
"4 hr": 240,
"6 hr": 360,
"12 hr": 720,
"24 hr": 1440,
}
AC_TIMEOUT_OPTIONS_LIMITED = {
"Never": 0,
"2 hr": 120,
"4 hr": 240,
"6 hr": 360,
"12 hr": 720,
"24 hr": 1440,
}
DC_TIMEOUT_OPTIONS = {
"Never": 0,
"30 min": 30,
"1 hr": 60,
"2 hr": 120,
"4 hr": 240,
"6 hr": 360,
"12 hr": 720,
"24 hr": 1440,
}
DC_TIMEOUT_OPTIONS_LIMITED = {
"Never": 0,
"2 hr": 120,
"4 hr": 240,
"6 hr": 360,
"12 hr": 720,
"24 hr": 1440,
}
DC_CHARGE_CURRENT_OPTIONS = {
"4A": 4000,
"6A": 6000,
"8A": 8000
}
MAIN_MODE_OPTIONS = {
"Cool": 0,
"Heat": 1,
"Fan": 2
}
FAN_MODE_OPTIONS = {
"Low": 0,
"Medium": 1,
"High": 2
}
REMOTE_MODE_OPTIONS = {
"Startup": 1,
"Standby": 2,
"Shutdown": 3
}
POWER_SUB_MODE_OPTIONS = {
"Max": 0,
"Sleep": 1,
"Eco": 2,
"Manual": 3
}
COMBINED_BATTERY_LEVEL = "Battery Level"
COMBINED_BATTERY_LEVEL_F32 = "Battery Level (Precise)"
BATTERY_CHARGING_STATE = "Battery Charging State"
ATTR_DESIGN_CAPACITY = "Design Capacity (mAh)"
ATTR_FULL_CAPACITY = "Full Capacity (mAh)"
ATTR_REMAIN_CAPACITY = "Remain Capacity (mAh)"
MAIN_DESIGN_CAPACITY = "Main Design Capacity"
MAIN_FULL_CAPACITY = "Main Full Capacity"
MAIN_REMAIN_CAPACITY = "Main Remain Capacity"
SLAVE_DESIGN_CAPACITY = "Slave Design Capacity"
SLAVE_FULL_CAPACITY = "Slave Full Capacity"
SLAVE_REMAIN_CAPACITY = "Slave Remain Capacity"
SLAVE_N_DESIGN_CAPACITY = "Slave %i Design Capacity"
SLAVE_N_FULL_CAPACITY = "Slave %i Full Capacity"
SLAVE_N_REMAIN_CAPACITY = "Slave %i Remain Capacity"
MAIN_BATTERY_LEVEL = "Main Battery Level"
MAIN_BATTERY_LEVEL_F32 = "Main Battery Level (Precise)"
MAIN_BATTERY_CURRENT = "Main Battery Current"
TOTAL_IN_POWER = "Total In Power"
SOLAR_IN_POWER = "Solar In Power"
SOLAR_1_IN_POWER = "Solar (1) In Power"
SOLAR_2_IN_POWER = "Solar (2) In Power"
AC_IN_POWER = "AC In Power"
AC_IN_VOLT = "AC In Volts"
AC_OUT_VOLT = "AC Out Volts"
TYPE_C_IN_POWER = "Type-C In Power"
SOLAR_IN_CURRENT = "Solar In Current"
SOLAR_IN_VOLTAGE = "Solar In Voltage"
SOLAR_IN_ENERGY = "Solar In Energy"
CHARGE_AC_ENERGY = "Battery Charge Energy from AC"
CHARGE_DC_ENERGY = "Battery Charge Energy from DC"
DISCHARGE_AC_ENERGY = "Battery Discharge Energy to AC"
DISCHARGE_DC_ENERGY = "Battery Discharge Energy to DC"
TOTAL_OUT_POWER = "Total Out Power"
AC_OUT_POWER = "AC Out Power"
DC_OUT_POWER = "DC Out Power"
DC_OUT_VOLTAGE = "DC Out Voltage"
DC_CAR_OUT_POWER = "DC Car Out Power"
DC_ANDERSON_OUT_POWER = "DC Anderson Out Power"
TYPEC_OUT_POWER = "Type-C Out Power"
TYPEC_1_OUT_POWER = "Type-C (1) Out Power"
TYPEC_2_OUT_POWER = "Type-C (2) Out Power"
USB_OUT_POWER = "USB Out Power"
USB_1_OUT_POWER = "USB (1) Out Power"
USB_2_OUT_POWER = "USB (2) Out Power"
USB_3_OUT_POWER = "USB (3) Out Power"
USB_QC_1_OUT_POWER = "USB QC (1) Out Power"
USB_QC_2_OUT_POWER = "USB QC (2) Out Power"
REMAINING_TIME = "Remaining Time"
CHARGE_REMAINING_TIME = "Charge Remaining Time"
DISCHARGE_REMAINING_TIME = "Discharge Remaining Time"
CYCLES = "Cycles"
SOH = "State of Health"
SLAVE_BATTERY_LEVEL = "Slave Battery Level"
SLAVE_N_BATTERY_LEVEL = "Slave %i Battery Level"
SLAVE_N_BATTERY_LEVEL_F32 = "Slave %i Battery Level (Precise)"
SLAVE_BATTERY_TEMP = "Slave Battery Temperature"
SLAVE_N_BATTERY_TEMP = "Slave %i Battery Temperature"
SLAVE_MIN_CELL_TEMP = "Slave Min Cell Temperature"
SLAVE_MAX_CELL_TEMP = "Slave Max Cell Temperature"
SLAVE_N_MIN_CELL_TEMP = "Slave %i Min Cell Temperature"
SLAVE_N_MAX_CELL_TEMP = "Slave %i Max Cell Temperature"
SLAVE_CYCLES = "Slave Cycles"
SLAVE_N_CYCLES = "Slave %i Cycles"
SLAVE_SOH = "Slave State of Health"
SLAVE_N_SOH = "Slave %i State of Health"
SLAVE_IN_POWER = "Slave In Power"
SLAVE_N_IN_POWER = "Slave %i In Power"
SLAVE_OUT_POWER = "Slave Out Power"
SLAVE_N_OUT_POWER = "Slave %i Out Power"
SLAVE_BATTERY_VOLT = "Slave Battery Volts"
SLAVE_MIN_CELL_VOLT = "Slave Min Cell Volts"
SLAVE_MAX_CELL_VOLT = "Slave Max Cell Volts"
SLAVE_N_BATTERY_VOLT = "Slave %i Battery Volts"
SLAVE_N_MIN_CELL_VOLT = "Slave %i Min Cell Volts"
SLAVE_N_MAX_CELL_VOLT = "Slave %i Max Cell Volts"
SLAVE_N_BATTERY_CURRENT = "Slave %i Battery Current"
MAX_CHARGE_LEVEL = "Max Charge Level"
MIN_DISCHARGE_LEVEL = "Min Discharge Level"
BACKUP_RESERVE_LEVEL = "Backup Reserve Level"
AC_CHARGING_POWER = "AC Charging Power"
SCREEN_TIMEOUT = "Screen Timeout"
UNIT_TIMEOUT = "Unit Timeout"
AC_TIMEOUT = "AC Timeout"
DC_TIMEOUT = "DC (12V) Timeout"
DC_CHARGE_CURRENT = "DC (12V) Charge Current"
GEN_AUTO_START_LEVEL = "Generator Auto Start Level"
GEN_AUTO_STOP_LEVEL = "Generator Auto Stop Level"
BEEPER = "Beeper"
USB_ENABLED = "USB Enabled"
AC_ENABLED = "AC Enabled"
DC_ENABLED = "DC (12V) Enabled"
XBOOST_ENABLED = "X-Boost Enabled"
AC_ALWAYS_ENABLED = "AC Always On"
PV_PRIO = "Prio Solar Charging"
BP_ENABLED = "Backup Reserve Enabled"
AUTO_FAN_SPEED = "Auto Fan Speed"
AC_SLOW_CHARGE = "AC Slow Charging"
DC_MODE = "DC Mode"
BATTERY_TEMP = "Battery Temperature"
MIN_CELL_TEMP = "Min Cell Temperature"
MAX_CELL_TEMP = "Max Cell Temperature"
INV_IN_TEMP = "Inverter Inside Temperature"
INV_OUT_TEMP = "Inverter Outside Temperature"
DC_CAR_OUT_TEMP = "DC Temperature"
USB_C_TEMP = "USB C Temperature"
ATTR_MIN_CELL_TEMP = MIN_CELL_TEMP
ATTR_MAX_CELL_TEMP = MAX_CELL_TEMP
BATTERY_VOLT = "Battery Volts"
MIN_CELL_VOLT = "Min Cell Volts"
MAX_CELL_VOLT = "Max Cell Volts"
ATTR_MIN_CELL_VOLT = MIN_CELL_VOLT
ATTR_MAX_CELL_VOLT = MAX_CELL_VOLT
BATTERY_AMP = "Battery Current"
SLAVE_BATTERY_AMP = "Slave Battery Current"
FAN_MODE = "Wind speed"
MAIN_MODE = "Main mode"
REMOTE_MODE = "Remote startup/shutdown"
POWER_SUB_MODE = "Sub-mode"

View File

@@ -0,0 +1,200 @@
from homeassistant.const import Platform
from . import const, BaseDevice, EntityMigration, MigrationAction
from .. import EcoflowMQTTClient
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..number import ChargingPowerEntity, MinBatteryLevelEntity, MaxBatteryLevelEntity, \
MaxGenStopLevelEntity, MinGenStartLevelEntity, BatteryBackupLevel
from ..select import DictSelectEntity, TimeoutDictSelectEntity
from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, CyclesSensorEntity, \
InWattsSensorEntity, OutWattsSensorEntity, QuotasStatusSensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \
OutMilliVoltSensorEntity, CapacitySensorEntity
from ..switch import BeeperEntity, EnabledEntity
class Delta2(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL)
.attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH),
LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL),
InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER),
# OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER),
# the same value as pd.carWatts
OutWattsSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER),
RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME),
TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"),
CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES),
TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP)
.attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False),
MilliVoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False)
.attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False),
# Optional Slave Battery
LevelSensorEntity(client, "bms_slave.soc", const.SLAVE_BATTERY_LEVEL, False, True)
.attr("bms_slave.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bms_slave.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bms_slave.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms_slave.designCap", const.SLAVE_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bms_slave.fullCap", const.SLAVE_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bms_slave.remainCap", const.SLAVE_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "bms_slave.soh", const.SLAVE_SOH),
TempSensorEntity(client, "bms_slave.temp", const.SLAVE_BATTERY_TEMP, False, True)
.attr("bms_slave.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms_slave.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms_slave.minCellTemp", const.SLAVE_MIN_CELL_TEMP, False),
TempSensorEntity(client, "bms_slave.maxCellTemp", const.SLAVE_MAX_CELL_TEMP, False),
MilliVoltSensorEntity(client, "bms_slave.vol", const.SLAVE_BATTERY_VOLT, False)
.attr("bms_slave.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bms_slave.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bms_slave.minCellVol", const.SLAVE_MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bms_slave.maxCellVol", const.SLAVE_MAX_CELL_VOLT, False),
CyclesSensorEntity(client, "bms_slave.cycles", const.SLAVE_CYCLES, False, True),
InWattsSensorEntity(client, "bms_slave.inputWatts", const.SLAVE_IN_POWER, False, True),
OutWattsSensorEntity(client, "bms_slave.outputWatts", const.SLAVE_OUT_POWER, False, True),
QuotasStatusSensorEntity(client),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100,
lambda value: {"moduleType": 2, "operateType": "upsConfig",
"params": {"maxChgSoc": int(value)}}),
MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30,
lambda value: {"moduleType": 2, "operateType": "dsgCfg",
"params": {"minDsgSoc": int(value)}}),
BatteryBackupLevel(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100,
"bms_emsStatus.minDsgSoc", "bms_emsStatus.maxChargeSoc",
lambda value: {"moduleType": 1, "operateType": "watthConfig",
"params": {"isConfig": 1, "bpPowerSoc": int(value), "minDsgSoc": 0,
"minChgSoc": 0}}),
MinGenStartLevelEntity(client, "bms_emsStatus.minOpenOilEb", const.GEN_AUTO_START_LEVEL, 0, 30,
lambda value: {"moduleType": 2, "operateType": "openOilSoc",
"params": {"openOilSoc": value}}),
MaxGenStopLevelEntity(client, "bms_emsStatus.maxCloseOilEb", const.GEN_AUTO_STOP_LEVEL, 50, 100,
lambda value: {"moduleType": 2, "operateType": "closeOilSoc",
"params": {"closeOilSoc": value}}),
ChargingPowerEntity(client, "mppt.cfgChgWatts", const.AC_CHARGING_POWER, 200, 1200,
lambda value: {"moduleType": 5, "operateType": "acChgCfg",
"params": {"chgWatts": int(value), "chgPauseFlag": 255}})
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
BeeperEntity(client, "mppt.beepState", const.BEEPER,
lambda value: {"moduleType": 5, "operateType": "quietMode", "params": {"enabled": value}}),
EnabledEntity(client, "pd.dcOutState", const.USB_ENABLED,
lambda value: {"moduleType": 1, "operateType": "dcOutCfg", "params": {"enabled": value}}),
EnabledEntity(client, "pd.acAutoOutConfig", const.AC_ALWAYS_ENABLED,
lambda value, params: {"moduleType": 1, "operateType": "acAutoOutConfig",
"params": {"acAutoOutConfig": value,
"minAcOutSoc": int(params.get("bms_emsStatus.minDsgSoc", 0)) + 5}}),
EnabledEntity(client, "pd.pvChgPrioSet", const.PV_PRIO,
lambda value: {"moduleType": 1, "operateType": "pvChangePrio",
"params": {"pvChangeSet": value}}),
EnabledEntity(client, "mppt.cfgAcEnabled", const.AC_ENABLED,
lambda value: {"moduleType": 5, "operateType": "acOutCfg",
"params": {"enabled": value, "out_voltage": -1, "out_freq": 255,
"xboost": 255}}),
EnabledEntity(client, "mppt.cfgAcXboost", const.XBOOST_ENABLED,
lambda value: {"moduleType": 5, "operateType": "acOutCfg",
"params": {"enabled": 255, "out_voltage": -1, "out_freq": 255,
"xboost": value}}),
EnabledEntity(client, "pd.carState", const.DC_ENABLED,
lambda value: {"moduleType": 5, "operateType": "mpptCar", "params": {"enabled": value}}),
EnabledEntity(client, "pd.bpPowerSoc", const.BP_ENABLED,
lambda value: {"moduleType": 1,
"operateType": "watthConfig",
"params": {"bpPowerSoc": value,
"minChgSoc": 0,
"isConfig": value,
"minDsgSoc": 0}}),
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
DictSelectEntity(client, "mppt.dcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "dcChgCfg",
"params": {"dcChgCfg": value}}),
TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 1, "operateType": "lcdCfg",
"params": {"brighLevel": 255, "delayOff": value}}),
TimeoutDictSelectEntity(client, "pd.standbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 1, "operateType": "standbyTime",
"params": {"standbyMin": value}}),
TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "standbyTime",
"params": {"standbyMins": value}}),
TimeoutDictSelectEntity(client, "mppt.carStandbyMin", const.DC_TIMEOUT, const.DC_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "carStandby",
"params": {"standbyMins": value}})
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE)
]
return []

View File

@@ -0,0 +1,229 @@
from homeassistant.const import Platform
from . import const, BaseDevice, EntityMigration, MigrationAction
from .. import EcoflowMQTTClient
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..number import ChargingPowerEntity, MinBatteryLevelEntity, MaxBatteryLevelEntity, \
MaxGenStopLevelEntity, MinGenStartLevelEntity, BatteryBackupLevel
from ..select import TimeoutDictSelectEntity
from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, CyclesSensorEntity, \
InWattsSensorEntity, OutWattsSensorEntity, StatusSensorEntity, MilliVoltSensorEntity, \
InMilliVoltSensorEntity, OutMilliVoltSensorEntity, CapacitySensorEntity
from ..switch import BeeperEntity, EnabledEntity
class Delta2Max(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL)
.attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH),
LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL),
InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_1_IN_POWER),
InWattsSensorEntity(client, "mppt.pv2InWatts", const.SOLAR_2_IN_POWER),
# OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER),
# the same value as pd.carWatts
OutWattsSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER),
RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME),
TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"),
CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES),
TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP)
.attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False),
MilliVoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False)
.attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False),
# Optional Slave 1 Battery
LevelSensorEntity(client, "bms_slave_bmsSlaveStatus_1.soc", const.SLAVE_N_BATTERY_LEVEL % 1, False, True)
.attr("bms_slave_bmsSlaveStatus_1.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bms_slave_bmsSlaveStatus_1.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bms_slave_bmsSlaveStatus_1.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_1.designCap", const.SLAVE_N_DESIGN_CAPACITY % 1, False),
CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_1.fullCap", const.SLAVE_N_FULL_CAPACITY % 1, False),
CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_1.remainCap", const.SLAVE_N_REMAIN_CAPACITY % 1, False),
TempSensorEntity(client, "bms_slave_bmsSlaveStatus_1.temp", const.SLAVE_N_BATTERY_TEMP % 1, False, True)
.attr("bms_slave_bmsSlaveStatus_1.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms_slave_bmsSlaveStatus_1.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms_slave_bmsSlaveStatus_1.minCellTemp", const.SLAVE_N_MIN_CELL_TEMP % 1, False),
TempSensorEntity(client, "bms_slave_bmsSlaveStatus_1.maxCellTemp", const.SLAVE_N_MAX_CELL_TEMP % 1, False),
MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_1.vol", const.SLAVE_N_BATTERY_VOLT % 1, False)
.attr("bms_slave_bmsSlaveStatus_1.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bms_slave_bmsSlaveStatus_1.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_1.minCellVol", const.SLAVE_N_MIN_CELL_VOLT % 1, False),
MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_1.maxCellVol", const.SLAVE_N_MAX_CELL_VOLT % 1, False),
CyclesSensorEntity(client, "bms_slave_bmsSlaveStatus_1.cycles", const.SLAVE_N_CYCLES % 1, False, True),
LevelSensorEntity(client, "bms_slave_bmsSlaveStatus_1.soh", const.SLAVE_N_SOH % 1, False,True),
InWattsSensorEntity(client, "bms_slave_bmsSlaveStatus_1.inputWatts", const.SLAVE_N_IN_POWER % 1, False, True),
OutWattsSensorEntity(client, "bms_slave_bmsSlaveStatus_1.outputWatts", const.SLAVE_N_OUT_POWER % 1, False, True),
# Optional Slave 2 Battery
LevelSensorEntity(client, "bms_slave_bmsSlaveStatus_2.soc", const.SLAVE_N_BATTERY_LEVEL % 2, False, True)
.attr("bms_slave_bmsSlaveStatus_2.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bms_slave_bmsSlaveStatus_2.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bms_slave_bmsSlaveStatus_2.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_2.designCap", const.SLAVE_N_DESIGN_CAPACITY % 2, False),
CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_2.fullCap", const.SLAVE_N_FULL_CAPACITY % 2, False),
CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_2.remainCap", const.SLAVE_N_REMAIN_CAPACITY % 2, False),
TempSensorEntity(client, "bms_slave_bmsSlaveStatus_2.temp", const.SLAVE_N_BATTERY_TEMP % 2, False, True)
.attr("bms_slave_bmsSlaveStatus_2.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms_slave_bmsSlaveStatus_2.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms_slave_bmsSlaveStatus_2.minCellTemp", const.SLAVE_N_MIN_CELL_TEMP % 2, False),
TempSensorEntity(client, "bms_slave_bmsSlaveStatus_2.maxCellTemp", const.SLAVE_N_MAX_CELL_TEMP % 2, False),
MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_2.vol", const.SLAVE_N_BATTERY_VOLT % 2, False)
.attr("bms_slave_bmsSlaveStatus_2.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bms_slave_bmsSlaveStatus_2.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_2.minCellVol", const.SLAVE_N_MIN_CELL_VOLT % 2, False),
MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_2.maxCellVol", const.SLAVE_N_MAX_CELL_VOLT % 2, False),
CyclesSensorEntity(client, "bms_slave_bmsSlaveStatus_2.cycles", const.SLAVE_N_CYCLES % 2, False, True),
LevelSensorEntity(client, "bms_slave_bmsSlaveStatus_2.soh", const.SLAVE_N_SOH % 2, False, True),
InWattsSensorEntity(client, "bms_slave_bmsSlaveStatus_2.inputWatts", const.SLAVE_N_IN_POWER % 2, False, True),
OutWattsSensorEntity(client, "bms_slave_bmsSlaveStatus_2.outputWatts", const.SLAVE_N_OUT_POWER % 2, False, True),
StatusSensorEntity(client),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100,
lambda value: {"moduleType": 2, "operateType": "upsConfig",
"moduleSn": client.device_sn,
"params": {"maxChgSoc": int(value)}}),
MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30,
lambda value: {"moduleType": 2, "operateType": "dsgCfg",
"moduleSn": client.device_sn,
"params": {"minDsgSoc": int(value)}}),
BatteryBackupLevel(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100,
"bms_emsStatus.minDsgSoc", "bms_emsStatus.maxChargeSoc",
lambda value: {"moduleType": 1, "operateType": "watthConfig",
"params": {"isConfig": 1, "bpPowerSoc": int(value), "minDsgSoc": 0,
"minChgSoc": 0}}),
MinGenStartLevelEntity(client, "bms_emsStatus.minOpenOilEbSoc", const.GEN_AUTO_START_LEVEL, 0, 30,
lambda value: {"moduleType": 2, "operateType": "openOilSoc",
"moduleSn": client.device_sn,
"params": {"openOilSoc": value}}),
MaxGenStopLevelEntity(client, "bms_emsStatus.maxCloseOilEbSoc", const.GEN_AUTO_STOP_LEVEL, 50, 100,
lambda value: {"moduleType": 2, "operateType": "closeOilSoc",
"moduleSn": client.device_sn,
"params": {"closeOilSoc": value}}),
ChargingPowerEntity(client, "inv.SlowChgWatts", const.AC_CHARGING_POWER, 200, 2400,
lambda value: {"moduleType": 3, "operateType": "acChgCfg",
"moduleSn": client.device_sn,
"params": {"slowChgWatts": int(value), "fastChgWatts": 255,
"chgPauseFlag": 0}})
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
BeeperEntity(client, "pd.beepMode", const.BEEPER,
lambda value: {"moduleType": 1, "operateType": "quietCfg",
"moduleSn": client.device_sn,
"params": {"enabled": value}}),
EnabledEntity(client, "pd.dcOutState", const.USB_ENABLED,
lambda value: {"moduleType": 1, "operateType": "dcOutCfg",
"moduleSn": client.device_sn,
"params": {"enabled": value}}),
EnabledEntity(client, "pd.newAcAutoOnCfg", const.AC_ALWAYS_ENABLED,
lambda value: {"moduleType": 1, "operateType": "newAcAutoOnCfg",
"moduleSn": client.device_sn,
"params": {"enabled": value, "minAcSoc": 5}}),
EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED,
lambda value: {"moduleType": 3, "operateType": "acOutCfg",
"moduleSn": client.device_sn,
"params": {"enabled": value, "out_voltage": -1,
"out_freq": 255, "xboost": 255}}),
EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED,
lambda value: {"moduleType": 3, "operateType": "acOutCfg",
"moduleSn": client.device_sn,
"params": {"xboost": value}}),
EnabledEntity(client, "pd.carState", const.DC_ENABLED,
lambda value: {"moduleType": 5, "operateType": "mpptCar",
"params": {"enabled": value}}),
EnabledEntity(client, "pd.bpPowerSoc", const.BP_ENABLED,
lambda value: {"moduleType": 1,
"operateType": "watthConfig",
"params": {"bpPowerSoc": value,
"minChgSoc": 0,
"isConfig": value,
"minDsgSoc": 0}}),
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 1, "operateType": "lcdCfg",
"moduleSn": client.device_sn,
"params": {"brighLevel": 255, "delayOff": value}}),
TimeoutDictSelectEntity(client, "inv.standbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 1, "operateType": "standbyTime",
"moduleSn": client.device_sn,
"params": {"standbyMin": value}}),
TimeoutDictSelectEntity(client, "mppt.carStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "standbyTime",
"moduleSn": client.device_sn,
"params": {"standbyMins": value}}),
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE),
EntityMigration("bms_emsStatus.f32LcdShowSoc", Platform.SENSOR, MigrationAction.REMOVE)
]
return []

View File

@@ -0,0 +1,160 @@
from homeassistant.const import Platform
from . import const, BaseDevice, EntityMigration, MigrationAction
from .. import EcoflowMQTTClient
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..number import ChargingPowerEntity, MinBatteryLevelEntity, MaxBatteryLevelEntity, \
MaxGenStopLevelEntity, MinGenStartLevelEntity
from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, CyclesSensorEntity, \
InWattsSensorEntity, OutWattsSensorEntity, StatusSensorEntity, MilliVoltSensorEntity, \
InMilliVoltSensorEntity, OutMilliVoltSensorEntity, CapacitySensorEntity, InWattsSolarSensorEntity, \
OutWattsDcSensorEntity
from ..switch import BeeperEntity, EnabledEntity
class DeltaMax(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL)
.attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "ems.lcdShowSoc", const.COMBINED_BATTERY_LEVEL),
InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
InWattsSolarSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER),
OutWattsDcSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER),
RemainSensorEntity(client, "ems.chgRemainTime", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "ems.dsgRemainTime", const.DISCHARGE_REMAINING_TIME),
TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"),
CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES),
TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP)
.attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bmsMaster.minCellTemp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bmsMaster.maxCellTemp", const.MAX_CELL_TEMP, False),
MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False)
.attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bmsMaster.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bmsMaster.maxCellVol", const.MAX_CELL_VOLT, False),
# Optional Slave Battery
#LevelSensorEntity(client, "bms_slave.soc", const.SLAVE_BATTERY_LEVEL, False, True),
#TempSensorEntity(client, "bms_slave.temp", const.SLAVE_BATTERY_TEMP, False, True),
#TempSensorEntity(client, "bms_slave.minCellTemp", const.SLAVE_MIN_CELL_TEMP, False),
#TempSensorEntity(client, "bms_slave.maxCellTemp", const.SLAVE_MAX_CELL_TEMP, False),
#VoltSensorEntity(client, "bms_slave.vol", const.SLAVE_BATTERY_VOLT, False),
#VoltSensorEntity(client, "bms_slave.minCellVol", const.SLAVE_MIN_CELL_VOLT, False),
#VoltSensorEntity(client, "bms_slave.maxCellVol", const.SLAVE_MAX_CELL_VOLT, False),
#CyclesSensorEntity(client, "bms_slave.cycles", const.SLAVE_CYCLES, False, True),
#InWattsSensorEntity(client, "bms_slave.inputWatts", const.SLAVE_IN_POWER, False, True),
#OutWattsSensorEntity(client, "bms_slave.outputWatts", const.SLAVE_OUT_POWER, False, True)
StatusSensorEntity(client),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "ems.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100,
lambda value: {"moduleType": 2, "operateType": "TCP",
"params": {"id": 49, "maxChgSoc": value}}),
MinBatteryLevelEntity(client, "ems.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30,
lambda value: {"moduleType": 2, "operateType": "TCP",
"params": {"id": 51, "minDsgSoc": value}}),
MinGenStartLevelEntity(client, "ems.minOpenOilEbSoc", const.GEN_AUTO_START_LEVEL, 0, 30,
lambda value: {"moduleType": 2, "operateType": "TCP",
"params": {"id": 52, "openOilSoc": value}}),
MaxGenStopLevelEntity(client, "ems.maxCloseOilEbSoc", const.GEN_AUTO_STOP_LEVEL, 50, 100,
lambda value: {"moduleType": 2, "operateType": "TCP",
"params": {"id": 53, "closeOilSoc": value}}),
ChargingPowerEntity(client, "inv.cfgFastChgWatt", const.AC_CHARGING_POWER, 200, 2000,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"slowChgPower": value, "id": 69}}),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
BeeperEntity(client, "pd.beepState", const.BEEPER,
lambda value: {"moduleType": 5, "operateType": "TCP", "params": {"id": 38, "enabled": value}}),
EnabledEntity(client, "pd.dcOutState", const.USB_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"enabled": value, "id": 34 }}),
EnabledEntity(client, "pd.acAutoOnCfg", const.AC_ALWAYS_ENABLED,
lambda value: {"moduleType": 1, "operateType": "acAutoOn", "params": {"cfg": value}}),
EnabledEntity(client, "pd.pvChgPrioSet", const.PV_PRIO,
lambda value: {"moduleType": 1, "operateType": "pvChangePrio", "params": {"pvChangeSet": value}}),
EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"enabled": value, "id": 66 }}),
EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED,
lambda value: {"moduleType": 5, "operateType": "TCP", "params": {"id": 66, "xboost": value}}),
EnabledEntity(client, "mppt.carState", const.DC_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"enabled": value, "id": 81 }}),
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
#DictSelectEntity(client, "mppt.cfgDcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS,
# lambda value: {"moduleType": 5, "operateType": "dcChgCfg",
# "params": {"dcChgCfg": value}}),
#TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS,
# lambda value: {"moduleType": 1, "operateType": "lcdCfg",
# "params": {"brighLevel": 255, "delayOff": value}}),
#TimeoutDictSelectEntity(client, "inv.cfgStandbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS,
# lambda value: {"moduleType": 1, "operateType": "standbyTime",
# "params": {"standbyMin": value}}),
#TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS,
# lambda value: {"moduleType": 5, "operateType": "standbyTime",
# "params": {"standbyMins": value}}),
#TimeoutDictSelectEntity(client, "mppt.carStandbyMin", const.DC_TIMEOUT, const.DC_TIMEOUT_OPTIONS,
# lambda value: {"moduleType": 5, "operateType": "carStandby",
# "params": {"standbyMins": value}})
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE),
]
return []

View File

@@ -0,0 +1,147 @@
from homeassistant.const import Platform
from . import const, BaseDevice, EntityMigration, MigrationAction
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity
from ..select import DictSelectEntity, TimeoutDictSelectEntity
from ..sensor import LevelSensorEntity, WattsSensorEntity, RemainSensorEntity, TempSensorEntity, \
CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, OutWattsDcSensorEntity, InWattsSolarSensorEntity, \
StatusSensorEntity, InEnergySensorEntity, OutEnergySensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \
OutMilliVoltSensorEntity, CapacitySensorEntity
from ..switch import BeeperEntity, EnabledEntity
class DeltaMini(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL)
.attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "bmsMaster.soh", const.SOH),
LevelSensorEntity(client, "ems.lcdShowSoc", const.COMBINED_BATTERY_LEVEL),
WattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
WattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
InWattsSolarSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER),
OutWattsDcSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER),
OutWattsDcSensorEntity(client, "mppt.carOutWatts", const.DC_CAR_OUT_POWER),
OutWattsSensorEntity(client, "mppt.dcdc12vWatts", const.DC_ANDERSON_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER),
RemainSensorEntity(client, "ems.chgRemainTime", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "ems.dsgRemainTime", const.DISCHARGE_REMAINING_TIME),
CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES),
TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP, False)
.attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False)
.attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
# https://github.com/tolwi/hassio-ecoflow-cloud/discussions/87
InEnergySensorEntity(client, "pd.chgSunPower", const.SOLAR_IN_ENERGY),
InEnergySensorEntity(client, "pd.chgPowerAc", const.CHARGE_AC_ENERGY),
InEnergySensorEntity(client, "pd.chgPowerDc", const.CHARGE_DC_ENERGY),
OutEnergySensorEntity(client, "pd.dsgPowerAc", const.DISCHARGE_AC_ENERGY),
OutEnergySensorEntity(client, "pd.dsgPowerDc", const.DISCHARGE_DC_ENERGY),
StatusSensorEntity(client),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "ems.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 49, "maxChgSoc": value}}),
MinBatteryLevelEntity(client, "ems.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 51, "minDsgSoc": value}}),
# MaxBatteryLevelEntity(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100,
# lambda value: {"moduleType": 0, "operateType": "TCP",
# "params": {"isConfig": 1, "bpPowerSoc": int(value), "minDsgSoc": 0, "maxChgSoc": 0, "id": 94}}),
# MinGenStartLevelEntity(client, "ems.minOpenOilEbSoc", const.GEN_AUTO_START_LEVEL, 0, 30,
# lambda value: {"moduleType": 0, "operateType": "TCP",
# "params": {"openOilSoc": value, "id": 52}}),
#
# MaxGenStopLevelEntity(client, "ems.maxCloseOilEbSoc", const.GEN_AUTO_STOP_LEVEL, 50, 100,
# lambda value: {"moduleType": 0, "operateType": "TCP",
# "params": {"closeOilSoc": value, "id": 53}}),
ChargingPowerEntity(client, "inv.cfgSlowChgWatts", const.AC_CHARGING_POWER, 200, 900,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"slowChgPower": value, "id": 69}}),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
BeeperEntity(client, "mppt.beepState", const.BEEPER,
lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 38, "enabled": value}}),
EnabledEntity(client, "mppt.carState", const.DC_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 81, "enabled": value}}),
EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 66, "enabled": value}}),
EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "xboost": value}}),
# EnabledEntity(client, "inv.acPassByAutoEn", const.AC_ALWAYS_ENABLED,
# lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 84, "enabled": value}}),
# EnabledEntity(client, "pd.bpPowerSoc", const.BP_ENABLED,
# lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"isConfig": value}}),
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
DictSelectEntity(client, "mppt.cfgDcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"currMa": value, "id": 71}}),
TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"lcdTime": value, "id": 39}}),
TimeoutDictSelectEntity(client, "pd.standByMode", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS_LIMITED,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"standByMode": value, "id": 33}}),
TimeoutDictSelectEntity(client, "inv.cfgStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"standByMins": value, "id": 153}}),
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE),
]
return []

View File

@@ -0,0 +1,212 @@
from homeassistant.const import Platform
from . import const, BaseDevice, EntityMigration, MigrationAction
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity, MinGenStartLevelEntity, \
MaxGenStopLevelEntity
from ..select import DictSelectEntity, TimeoutDictSelectEntity
from ..sensor import LevelSensorEntity, WattsSensorEntity, RemainSensorEntity, TempSensorEntity, \
CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, OutWattsDcSensorEntity, VoltSensorEntity, \
InWattsSolarSensorEntity, InVoltSolarSensorEntity, InAmpSolarSensorEntity, OutVoltDcSensorEntity, \
StatusSensorEntity, InEnergySensorEntity, OutEnergySensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \
OutMilliVoltSensorEntity, AmpSensorEntity, CapacitySensorEntity
from ..switch import BeeperEntity, EnabledEntity
class DeltaPro(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL)
.attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
LevelSensorEntity(client, "bmsMaster.f32ShowSoc", const.MAIN_BATTERY_LEVEL_F32, False)
.attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "bmsMaster.soh", const.SOH),
LevelSensorEntity(client, "ems.lcdShowSoc", const.COMBINED_BATTERY_LEVEL),
LevelSensorEntity(client, "ems.f32LcdShowSoc", const.COMBINED_BATTERY_LEVEL_F32, False),
WattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
WattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
AmpSensorEntity(client, "bmsMaster.amp", const.MAIN_BATTERY_CURRENT),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
InWattsSolarSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER),
InVoltSolarSensorEntity(client, "mppt.inVol", const.SOLAR_IN_VOLTAGE),
InAmpSolarSensorEntity(client, "mppt.inAmp", const.SOLAR_IN_CURRENT),
OutWattsDcSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER),
OutVoltDcSensorEntity(client, "mppt.outVol", const.DC_OUT_VOLTAGE),
OutWattsSensorEntity(client, "mppt.carOutWatts", const.DC_CAR_OUT_POWER),
OutWattsSensorEntity(client, "mppt.dcdc12vWatts", const.DC_ANDERSON_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER),
RemainSensorEntity(client, "ems.chgRemainTime", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "ems.dsgRemainTime", const.DISCHARGE_REMAINING_TIME),
CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES),
TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP)
.attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bmsMaster.minCellTemp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bmsMaster.maxCellTemp", const.MAX_CELL_TEMP, False),
MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False)
.attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bmsMaster.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bmsMaster.maxCellVol", const.MAX_CELL_VOLT, False),
# https://github.com/tolwi/hassio-ecoflow-cloud/discussions/87
InEnergySensorEntity(client, "pd.chgSunPower", const.SOLAR_IN_ENERGY),
InEnergySensorEntity(client, "pd.chgPowerAc", const.CHARGE_AC_ENERGY),
InEnergySensorEntity(client, "pd.chgPowerDc", const.CHARGE_DC_ENERGY),
OutEnergySensorEntity(client, "pd.dsgPowerAc", const.DISCHARGE_AC_ENERGY),
OutEnergySensorEntity(client, "pd.dsgPowerDc", const.DISCHARGE_DC_ENERGY),
# Optional Slave Batteries
LevelSensorEntity(client, "bmsSlave1.soc", const.SLAVE_N_BATTERY_LEVEL % 1, False, True)
.attr("bmsSlave1.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsSlave1.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsSlave1.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
LevelSensorEntity(client, "bmsSlave1.f32ShowSoc", const.SLAVE_N_BATTERY_LEVEL_F32 % 1, False, False)
.attr("bmsSlave1.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsSlave1.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsSlave1.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bmsSlave1.designCap", const.SLAVE_N_DESIGN_CAPACITY % 1, False),
CapacitySensorEntity(client, "bmsSlave1.fullCap", const.SLAVE_N_FULL_CAPACITY % 1, False),
CapacitySensorEntity(client, "bmsSlave1.remainCap", const.SLAVE_N_REMAIN_CAPACITY % 1, False),
LevelSensorEntity(client, "bmsSlave1.soh", const.SLAVE_N_SOH % 1),
TempSensorEntity(client, "bmsSlave1.temp", const.SLAVE_N_BATTERY_TEMP % 1, False, True)
.attr("bmsSlave1.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bmsSlave1.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
WattsSensorEntity(client, "bmsSlave1.inputWatts", const.SLAVE_N_IN_POWER % 1, False, True),
WattsSensorEntity(client, "bmsSlave1.outputWatts", const.SLAVE_N_OUT_POWER % 1, False, True),
LevelSensorEntity(client, "bmsSlave2.soc", const.SLAVE_N_BATTERY_LEVEL % 2, False, True)
.attr("bmsSlave2.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsSlave2.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsSlave2.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
LevelSensorEntity(client, "bmsSlave2.f32ShowSoc", const.SLAVE_N_BATTERY_LEVEL_F32 % 2, False, False)
.attr("bmsSlave2.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsSlave2.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsSlave2.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bmsSlave2.designCap", const.SLAVE_N_DESIGN_CAPACITY % 2, False),
CapacitySensorEntity(client, "bmsSlave2.fullCap", const.SLAVE_N_FULL_CAPACITY % 2, False),
CapacitySensorEntity(client, "bmsSlave2.remainCap", const.SLAVE_N_REMAIN_CAPACITY % 2, False),
LevelSensorEntity(client, "bmsSlave2.soh", const.SLAVE_N_SOH % 2),
MilliVoltSensorEntity(client, "bmsSlave1.vol", const.SLAVE_N_BATTERY_VOLT % 1, False),
MilliVoltSensorEntity(client, "bmsSlave1.minCellVol", const.SLAVE_N_MIN_CELL_VOLT % 1, False),
MilliVoltSensorEntity(client, "bmsSlave1.maxCellVol", const.SLAVE_N_MAX_CELL_VOLT % 1, False),
AmpSensorEntity(client, "bmsSlave1.amp", const.SLAVE_N_BATTERY_CURRENT % 1, False),
MilliVoltSensorEntity(client, "bmsSlave2.vol", const.SLAVE_N_BATTERY_VOLT % 2, False),
MilliVoltSensorEntity(client, "bmsSlave2.minCellVol", const.SLAVE_N_MIN_CELL_VOLT % 2, False),
MilliVoltSensorEntity(client, "bmsSlave2.maxCellVol", const.SLAVE_N_MAX_CELL_VOLT % 2, False),
AmpSensorEntity(client, "bmsSlave2.amp", const.SLAVE_N_BATTERY_CURRENT % 2, False),
TempSensorEntity(client, "bmsSlave2.temp", const.SLAVE_N_BATTERY_TEMP % 2, False, True)
.attr("bmsSlave2.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bmsSlave2.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
WattsSensorEntity(client, "bmsSlave2.inputWatts", const.SLAVE_N_IN_POWER % 2, False, True),
WattsSensorEntity(client, "bmsSlave2.outputWatts", const.SLAVE_N_OUT_POWER % 2, False, True),
CyclesSensorEntity(client, "bmsSlave1.cycles", const.SLAVE_N_CYCLES % 1, False),
CyclesSensorEntity(client, "bmsSlave2.cycles", const.SLAVE_N_CYCLES % 2, False),
StatusSensorEntity(client),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "ems.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 49, "maxChgSoc": value}}),
MinBatteryLevelEntity(client, "ems.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 51, "minDsgSoc": value}}),
MaxBatteryLevelEntity(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"isConfig": 1, "bpPowerSoc": int(value), "minDsgSoc": 0,
"maxChgSoc": 0, "id": 94}}),
MinGenStartLevelEntity(client, "ems.minOpenOilEbSoc", const.GEN_AUTO_START_LEVEL, 0, 30,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"openOilSoc": value, "id": 52}}),
MaxGenStopLevelEntity(client, "ems.maxCloseOilEbSoc", const.GEN_AUTO_STOP_LEVEL, 50, 100,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"closeOilSoc": value, "id": 53}}),
ChargingPowerEntity(client, "inv.cfgSlowChgWatts", const.AC_CHARGING_POWER, 200, 2900,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"slowChgPower": value, "id": 69}}),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
BeeperEntity(client, "pd.beepState", const.BEEPER,
lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 38, "enabled": value}}),
EnabledEntity(client, "mppt.carState", const.DC_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 81, "enabled": value}}),
EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 66, "enabled": value}}),
EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "xboost": value}}),
EnabledEntity(client, "pd.acautooutConfig", const.AC_ALWAYS_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 95, "acautooutConfig": value}}),
EnabledEntity(client, "pd.bppowerSoc", const.BP_ENABLED,
lambda value, params: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 94, "isConfig": value,
"bpPowerSoc": int(params.get("pd.bppowerSoc", 0)),
"minDsgSoc": 0,
"maxChgSoc": 0}}),
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
DictSelectEntity(client, "mppt.cfgDcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"currMa": value, "id": 71}}),
TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"lcdTime": value, "id": 39}}),
TimeoutDictSelectEntity(client, "pd.standByMode", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS_LIMITED,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"standByMode": value, "id": 33}}),
TimeoutDictSelectEntity(client, "inv.cfgStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"standByMins": value, "id": 153}}),
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE),
]
return []

View File

@@ -0,0 +1,129 @@
from . import const, BaseDevice
from ..button import EnabledButtonEntity
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity, BaseButtonEntity
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
from ..number import SetTempEntity
from ..sensor import LevelSensorEntity, RemainSensorEntity, SecondsRemainSensorEntity, TempSensorEntity, \
CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, VoltSensorEntity, QuotasStatusSensorEntity, \
MilliVoltSensorEntity, ChargingStateSensorEntity, \
FanSensorEntity, MiscBinarySensorEntity, DecicelsiusSensorEntity, MiscSensorEntity, CapacitySensorEntity
from ..switch import EnabledEntity, InvertedBeeperEntity
class Glacier(BaseDevice):
def charging_power_step(self) -> int:
return 50
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
# Power and Battery Entities
LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL)
.attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "bms_emsStatus.f32LcdSoc", const.COMBINED_BATTERY_LEVEL),
ChargingStateSensorEntity(client, "bms_emsStatus.chgState", const.BATTERY_CHARGING_STATE),
InWattsSensorEntity(client, "bms_bmsStatus.inWatts", const.TOTAL_IN_POWER),
OutWattsSensorEntity(client, "bms_bmsStatus.outWatts", const.TOTAL_OUT_POWER),
OutWattsSensorEntity(client, "pd.motorWat", "Motor Power"),
RemainSensorEntity(client, "bms_emsStatus.chgRemain", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "bms_emsStatus.dsgRemain", const.DISCHARGE_REMAINING_TIME),
CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES),
TempSensorEntity(client, "bms_bmsStatus.tmp", const.BATTERY_TEMP)
.attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms_bmsStatus.minCellTmp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bms_bmsStatus.maxCellTmp", const.MAX_CELL_TEMP, False),
VoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False)
.attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False),
MiscBinarySensorEntity(client,"pd.batFlag", "Battery Present"),
MiscSensorEntity(client, "pd.xt60InState", "XT60 State"),
#Fridge Entities
FanSensorEntity(client, "bms_emsStatus.fanLvl", "Fan Level"),
DecicelsiusSensorEntity(client, "pd.ambientTmp", "Ambient Temperature"),
DecicelsiusSensorEntity(client, "pd.exhaustTmp", "Exhaust Temperature"),
DecicelsiusSensorEntity(client, "pd.tempWater", "Water Temperature"),
DecicelsiusSensorEntity(client, "pd.tmpL", "Left Temperature"),
DecicelsiusSensorEntity(client, "pd.tmpR", "Right Temperature"),
MiscBinarySensorEntity(client,"pd.flagTwoZone","Dual Zone Mode"),
SecondsRemainSensorEntity(client, "pd.iceTm", "Ice Time Remain"),
LevelSensorEntity(client, "pd.icePercent", "Ice Percentage"),
MiscSensorEntity(client, "pd.iceMkMode", "Ice Make Mode"),
MiscBinarySensorEntity(client,"pd.iceAlert","Ice Alert"),
MiscBinarySensorEntity(client,"pd.waterLine","Ice Water Level OK"),
QuotasStatusSensorEntity(client)
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
SetTempEntity(client,"pd.tmpLSet", "Left Set Temperature",-25, 10,
lambda value, params: {"moduleType": 1, "operateType": "temp",
"params": {"tmpM": int(params.get("pd.tmpMSet", 0)),
"tmpL": int(value),
"tmpR": int(params.get("pd.tmpRSet", 0))}}),
SetTempEntity(client,"pd.tmpMSet", "Combined Set Temperature",-25, 10,
lambda value, params: {"moduleType": 1, "operateType": "temp",
"params": {"tmpM": int(value),
"tmpL": int(params.get("pd.tmpLSet", 0)),
"tmpR": int(params.get("pd.tmpRSet", 0))}}),
SetTempEntity(client,"pd.tmpRSet", "Right Set Temperature",-25, 10,
lambda value, params: {"moduleType": 1, "operateType": "temp",
"params": {"tmpM": int(params.get("pd.tmpMSet", 0)),
"tmpL": int(params.get("pd.tmpLSet", 0)),
"tmpR": int(value)}}),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
InvertedBeeperEntity(client, "pd.beepEn", const.BEEPER,
lambda value: {"moduleType": 1, "operateType": "beepEn", "params": {"flag": value}}),
EnabledEntity(client, "pd.coolMode", "Eco Mode",
lambda value: {"moduleType": 1, "operateType": "ecoMode", "params": {"mode": value}}),
#power parameter is inverted for some reason
EnabledEntity(client, "pd.pwrState", "Power",
lambda value: {"moduleType": 1, "operateType": "powerOff", "params": {"enable": value}}),
]
def buttons(self, client: EcoflowMQTTClient) -> list[BaseButtonEntity]:
return [
EnabledButtonEntity(client, "smlice", "Make Small Ice", lambda value: {"moduleType": 1, "operateType": "iceMake", "params": {"enable": 1, "iceShape": 0}}),
EnabledButtonEntity(client, "lrgice", "Make Large Ice", lambda value: {"moduleType": 1, "operateType": "iceMake", "params": {"enable": 1, "iceShape": 1}}),
EnabledButtonEntity(client, "deice", "Detach Ice", lambda value: {"moduleType": 1, "operateType": "deIce", "params": {"enable": 1}})
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
]

View File

@@ -0,0 +1,104 @@
from . import BaseDevice
from .. import EcoflowMQTTClient
from ..entities import (
BaseSensorEntity, BaseNumberEntity, BaseSelectEntity, BaseSwitchEntity
)
from ..sensor import (
AmpSensorEntity, CentivoltSensorEntity, DeciampSensorEntity,
DecicelsiusSensorEntity, DecihertzSensorEntity, DeciwattsSensorEntity,
DecivoltSensorEntity, InWattsSolarSensorEntity, LevelSensorEntity,
MiscSensorEntity, RemainSensorEntity, StatusSensorEntity,
)
# from ..number import MinBatteryLevelEntity, MaxBatteryLevelEntity
# from ..select import DictSelectEntity
class PowerStream(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
InWattsSolarSensorEntity(client, "pv1_input_watts", "Solar 1 Watts"),
DecivoltSensorEntity(client, "pv1_input_volt", "Solar 1 Input Potential"),
CentivoltSensorEntity(client, "pv1_op_volt", "Solar 1 Op Potential"),
DeciampSensorEntity(client, "pv1_input_cur", "Solar 1 Currrent"),
DecicelsiusSensorEntity(client, "pv1_temp", "Solar 1 Temperature"),
MiscSensorEntity(client, "pv1_relay_status", "Solar 1 Relay Status"),
MiscSensorEntity(client, "pv1_error_code", "Solar 1 Error Code", False),
MiscSensorEntity(client, "pv1_warning_code", "Solar 1 Warning Code", False),
MiscSensorEntity(client, "pv1_status", "Solar 1 Status", False),
InWattsSolarSensorEntity(client, "pv2_input_watts", "Solar 2 Watts"),
DecivoltSensorEntity(client, "pv2_input_volt", "Solar 2 Input Potential"),
CentivoltSensorEntity(client, "pv2_op_volt", "Solar 2 Op Potential"),
DeciampSensorEntity(client, "pv2_input_cur", "Solar 2 Current"),
DecicelsiusSensorEntity(client, "pv2_temp", "Solar 2 Temperature"),
MiscSensorEntity(client, "pv2_relay_status", "Solar 2 Relay Status"),
MiscSensorEntity(client, "pv2_error_code", "Solar 2 Error Code", False),
MiscSensorEntity(client, "pv2_warning_code", "Solar 2 Warning Code", False),
MiscSensorEntity(client, "pv2_status", "Solar 2 Status", False),
MiscSensorEntity(client, "bp_type", "Battery Type", False),
LevelSensorEntity(client, "bat_soc", "Battery Charge"),
DeciwattsSensorEntity(client, "bat_input_watts", "Battery Input Watts"),
DecivoltSensorEntity(client, "bat_input_volt", "Battery Input Potential"),
DecivoltSensorEntity(client, "bat_op_volt", "Battery Op Potential"),
AmpSensorEntity(client, "bat_input_cur", "Battery Input Current"),
DecicelsiusSensorEntity(client, "bat_temp", "Battery Temperature"),
RemainSensorEntity(client, "battery_charge_remain", "Charge Time"),
RemainSensorEntity(client, "battery_discharge_remain", "Discharge Time"),
MiscSensorEntity(client, "bat_error_code", "Battery Error Code", False),
MiscSensorEntity(client, "bat_warning_code", "Battery Warning Code", False),
MiscSensorEntity(client, "bat_status", "Battery Status", False),
DecivoltSensorEntity(client, "llc_input_volt", "LLC Input Potential", False),
DecivoltSensorEntity(client, "llc_op_volt", "LLC Op Potential", False),
MiscSensorEntity(client, "llc_error_code", "LLC Error Code", False),
MiscSensorEntity(client, "llc_warning_code", "LLC Warning Code", False),
MiscSensorEntity(client, "llc_status", "LLC Status", False),
MiscSensorEntity(client, "inv_on_off", "Inverter On/Off Status"),
DeciwattsSensorEntity(client, "inv_output_watts", "Inverter Output Watts"),
DecivoltSensorEntity(client, "inv_input_volt", "Inverter Output Potential", False),
DecivoltSensorEntity(client, "inv_op_volt", "Inverter Op Potential"),
AmpSensorEntity(client, "inv_output_cur", "Inverter Output Current"),
AmpSensorEntity(client, "inv_dc_cur", "Inverter DC Current"),
DecihertzSensorEntity(client, "inv_freq", "Inverter Frequency"),
DecicelsiusSensorEntity(client, "inv_temp", "Inverter Temperature"),
MiscSensorEntity(client, "inv_relay_status", "Inverter Relay Status"),
MiscSensorEntity(client, "inv_error_code", "Inverter Error Code", False),
MiscSensorEntity(client, "inv_warning_code", "Inverter Warning Code", False),
MiscSensorEntity(client, "inv_status", "Inverter Status", False),
DeciwattsSensorEntity(client, "permanent_watts", "Other Loads"),
DeciwattsSensorEntity(client, "dynamic_watts", "Smart Plug Loads"),
DeciwattsSensorEntity(client, "rated_power", "Rated Power"),
MiscSensorEntity(client, "lower_limit", "Lower Battery Limit", False),
MiscSensorEntity(client, "upper_limit", "Upper Battery Limit", False),
MiscSensorEntity(client, "wireless_error_code", "Wireless Error Code", False),
MiscSensorEntity(client, "wireless_warning_code", "Wireless Warning Code", False),
MiscSensorEntity(client, "inv_brightness", "LED Brightness", False),
MiscSensorEntity(client, "heartbeat_frequency", "Heartbeat Frequency", False),
StatusSensorEntity(client)
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
# These will likely be some form of serialised data rather than JSON will look into it later
# MinBatteryLevelEntity(client, "lowerLimit", "Min Disharge Level", 50, 100,
# lambda value: {"moduleType": 0, "operateType": "TCP",
# "params": {"id": 00, "lowerLimit": value}}),
# MaxBatteryLevelEntity(client, "upperLimit", "Max Charge Level", 0, 30,
# lambda value: {"moduleType": 0, "operateType": "TCP",
# "params": {"id": 00, "upperLimit": value}}),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return []
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
# DictSelectEntity(client, "supplyPriority", "Power supply mode", {"Prioritize power supply", "Prioritize power storage"},
# lambda value: {"moduleType": 00, "operateType": "supplyPriority",
# "params": {"supplyPriority": value}}),
]

View File

@@ -0,0 +1,32 @@
from custom_components.ecoflow_cloud.config_flow import EcoflowModel
from custom_components.ecoflow_cloud.devices import BaseDevice, DiagnosticDevice
from custom_components.ecoflow_cloud.devices.delta2 import Delta2
from custom_components.ecoflow_cloud.devices.delta_mini import DeltaMini
from custom_components.ecoflow_cloud.devices.delta_pro import DeltaPro
from custom_components.ecoflow_cloud.devices.river2 import River2
from custom_components.ecoflow_cloud.devices.river2_max import River2Max
from custom_components.ecoflow_cloud.devices.river2_pro import River2Pro
from custom_components.ecoflow_cloud.devices.river_max import RiverMax
from custom_components.ecoflow_cloud.devices.river_pro import RiverPro
from custom_components.ecoflow_cloud.devices.delta_max import DeltaMax
from custom_components.ecoflow_cloud.devices.delta2_max import Delta2Max
from custom_components.ecoflow_cloud.devices.powerstream import PowerStream
from custom_components.ecoflow_cloud.devices.glacier import Glacier
from custom_components.ecoflow_cloud.devices.wave2 import Wave2
devices: dict[str, BaseDevice] = {
EcoflowModel.DELTA_2.name: Delta2(),
EcoflowModel.RIVER_2.name: River2(),
EcoflowModel.RIVER_2_MAX.name: River2Max(),
EcoflowModel.RIVER_2_PRO.name: River2Pro(),
EcoflowModel.DELTA_PRO.name: DeltaPro(),
EcoflowModel.RIVER_MAX.name: RiverMax(),
EcoflowModel.RIVER_PRO.name: RiverPro(),
EcoflowModel.DELTA_MINI.name: DeltaMini(),
EcoflowModel.DELTA_MAX.name: DeltaMax(),
EcoflowModel.DELTA_2_MAX.name: Delta2Max(),
EcoflowModel.POWERSTREAM.name: PowerStream(),
EcoflowModel.GLACIER.name: Glacier(),
EcoflowModel.WAVE_2.name: Wave2(),
EcoflowModel.DIAGNOSTIC.name: DiagnosticDevice()
}

View File

@@ -0,0 +1,135 @@
from homeassistant.const import Platform
from . import const, BaseDevice, EntityMigration, MigrationAction
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity
from ..select import DictSelectEntity, TimeoutDictSelectEntity
from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, \
CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, VoltSensorEntity, StatusSensorEntity, \
MilliVoltSensorEntity, InMilliVoltSensorEntity, OutMilliVoltSensorEntity, ChargingStateSensorEntity, \
CapacitySensorEntity
from ..switch import EnabledEntity
class River2(BaseDevice):
def charging_power_step(self) -> int:
return 50
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL)
.attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH),
LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL),
ChargingStateSensorEntity(client, "bms_emsStatus.chgState", const.BATTERY_CHARGING_STATE),
InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
InWattsSensorEntity(client, "pd.typecChaWatts", const.TYPE_C_IN_POWER),
InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER),
OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER),
# both USB-A Ports (the small RIVER 2 has only two) are being summarized under "pd.usb1Watts" - https://github.com/tolwi/hassio-ecoflow-cloud/issues/12#issuecomment-1432837393
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_OUT_POWER),
RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME),
TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"),
CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES),
TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP)
.attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False),
VoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False)
.attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False),
# FanSensorEntity(client, "bms_emsStatus.fanLevel", "Fan Level"),
StatusSensorEntity(client),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100,
lambda value: {"moduleType": 2, "operateType": "upsConfig",
"params": {"maxChgSoc": int(value)}}),
MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30,
lambda value: {"moduleType": 2, "operateType": "dsgCfg",
"params": {"minDsgSoc": int(value)}}),
ChargingPowerEntity(client, "mppt.cfgChgWatts", const.AC_CHARGING_POWER, 100, 360,
lambda value: {"moduleType": 5, "operateType": "acChgCfg",
"params": {"chgWatts": int(value), "chgPauseFlag": 255}}),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
EnabledEntity(client, "mppt.cfgAcEnabled", const.AC_ENABLED,
lambda value: {"moduleType": 5, "operateType": "acOutCfg",
"params": {"enabled": value, "out_voltage": -1, "out_freq": 255,
"xboost": 255}}),
EnabledEntity(client, "mppt.cfgAcXboost", const.XBOOST_ENABLED,
lambda value: {"moduleType": 5, "operateType": "acOutCfg",
"params": {"enabled": 255, "out_voltage": -1, "out_freq": 255,
"xboost": value}}),
EnabledEntity(client, "pd.carState", const.DC_ENABLED,
lambda value: {"moduleType": 5, "operateType": "mpptCar", "params": {"enabled": value}})
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
DictSelectEntity(client, "mppt.dcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "dcChgCfg",
"params": {"dcChgCfg": value}}),
DictSelectEntity(client, "mppt.cfgChgType", const.DC_MODE, const.DC_MODE_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "chaType",
"params": {"chaType": value}}),
TimeoutDictSelectEntity(client, "mppt.scrStandbyMin", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "lcdCfg",
"params": {"brighLevel": 255, "delayOff": value}}),
TimeoutDictSelectEntity(client, "mppt.powStandbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "standby",
"params": {"standbyMins": value}}),
TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "acStandby",
"params": {"standbyMins": value}})
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE),
]
return []

View File

@@ -0,0 +1,163 @@
from homeassistant.const import Platform
from . import const, BaseDevice, EntityMigration, MigrationAction
from .const import ATTR_DESIGN_CAPACITY, ATTR_FULL_CAPACITY, ATTR_REMAIN_CAPACITY, BATTERY_CHARGING_STATE, \
MAIN_DESIGN_CAPACITY, MAIN_FULL_CAPACITY, MAIN_REMAIN_CAPACITY
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity, BatteryBackupLevel
from ..select import DictSelectEntity, TimeoutDictSelectEntity
from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, \
CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, VoltSensorEntity, InAmpSensorEntity, \
InVoltSensorEntity, QuotasStatusSensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \
OutMilliVoltSensorEntity, ChargingStateSensorEntity, CapacitySensorEntity
from ..switch import EnabledEntity
class River2Max(BaseDevice):
def charging_power_step(self) -> int:
return 50
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL)
.attr("bms_bmsStatus.designCap", ATTR_DESIGN_CAPACITY, 0)
.attr("bms_bmsStatus.fullCap", ATTR_FULL_CAPACITY, 0)
.attr("bms_bmsStatus.remainCap", ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms_bmsStatus.designCap", MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.fullCap", MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.remainCap", MAIN_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH),
LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL),
ChargingStateSensorEntity(client, "bms_emsStatus.chgState", BATTERY_CHARGING_STATE),
InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
InAmpSensorEntity(client, "inv.dcInAmp", const.SOLAR_IN_CURRENT),
InVoltSensorEntity(client, "inv.dcInVol", const.SOLAR_IN_VOLTAGE),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
InWattsSensorEntity(client, "pd.typecChaWatts", const.TYPE_C_IN_POWER),
InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER),
OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_OUT_POWER),
# OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER),
RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME),
RemainSensorEntity(client, "pd.remainTime", const.REMAINING_TIME),
TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"),
CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES),
TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP)
.attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False),
VoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False)
.attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False),
QuotasStatusSensorEntity(client),
# FanSensorEntity(client, "bms_emsStatus.fanLevel", "Fan Level"),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100,
lambda value: {"moduleType": 2, "operateType": "upsConfig",
"params": {"maxChgSoc": int(value)}}),
MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30,
lambda value: {"moduleType": 2, "operateType": "dsgCfg",
"params": {"minDsgSoc": int(value)}}),
ChargingPowerEntity(client, "mppt.cfgChgWatts", const.AC_CHARGING_POWER, 50, 660,
lambda value: {"moduleType": 5, "operateType": "acChgCfg",
"params": {"chgWatts": int(value), "chgPauseFlag": 255}}),
BatteryBackupLevel(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100,
"bms_emsStatus.minDsgSoc", "bms_emsStatus.maxChargeSoc",
lambda value: {"moduleType": 1, "operateType": "watthConfig",
"params": {"isConfig": 1,
"bpPowerSoc": int(value),
"minDsgSoc": 0,
"minChgSoc": 0}}),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
EnabledEntity(client, "mppt.cfgAcEnabled", const.AC_ENABLED,
lambda value: {"moduleType": 5, "operateType": "acOutCfg",
"params": {"enabled": value, "out_voltage": -1, "out_freq": 255,
"xboost": 255}}),
EnabledEntity(client, "pd.acAutoOutConfig", const.AC_ALWAYS_ENABLED,
lambda value, params: {"moduleType": 1, "operateType": "acAutoOutConfig",
"params": {"acAutoOutConfig": value,
"minAcOutSoc": int(params.get("bms_emsStatus.minDsgSoc", 0)) + 5}}
),
EnabledEntity(client, "mppt.cfgAcXboost", const.XBOOST_ENABLED,
lambda value: {"moduleType": 5, "operateType": "acOutCfg",
"params": {"enabled": 255, "out_voltage": -1, "out_freq": 255,
"xboost": value}}),
EnabledEntity(client, "pd.carState", const.DC_ENABLED,
lambda value: {"moduleType": 5, "operateType": "mpptCar", "params": {"enabled": value}}),
EnabledEntity(client, "pd.bpPowerSoc", const.BP_ENABLED,
lambda value, params: {"moduleType": 1, "operateType": "watthConfig",
"params": {"isConfig": value,
"bpPowerSoc": value,
"minDsgSoc": 0,
"minChgSoc": 0}})
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
DictSelectEntity(client, "mppt.dcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "dcChgCfg",
"params": {"dcChgCfg": value}}),
DictSelectEntity(client, "mppt.cfgChgType", const.DC_MODE, const.DC_MODE_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "chaType",
"params": {"chaType": value}}),
TimeoutDictSelectEntity(client, "mppt.scrStandbyMin", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "lcdCfg",
"params": {"brighLevel": 255, "delayOff": value}}),
TimeoutDictSelectEntity(client, "mppt.powStandbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "standby",
"params": {"standbyMins": value}}),
TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "acStandby",
"params": {"standbyMins": value}})
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE),
]
return []

View File

@@ -0,0 +1,136 @@
from homeassistant.const import Platform
from . import const, BaseDevice, EntityMigration, MigrationAction
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity
from ..select import DictSelectEntity, TimeoutDictSelectEntity
from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, \
CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, VoltSensorEntity, QuotasStatusSensorEntity, \
MilliVoltSensorEntity, InMilliVoltSensorEntity, OutMilliVoltSensorEntity, ChargingStateSensorEntity, \
CapacitySensorEntity
from ..switch import EnabledEntity
class River2Pro(BaseDevice):
def charging_power_step(self) -> int:
return 50
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL)
.attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False),
LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH),
LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL),
ChargingStateSensorEntity(client, "bms_emsStatus.chgState", const.BATTERY_CHARGING_STATE),
InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
InWattsSensorEntity(client, "pd.typecChaWatts", const.TYPE_C_IN_POWER),
InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER),
OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER),
OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_OUT_POWER),
# OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER),
RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME),
RemainSensorEntity(client, "pd.remainTime", const.REMAINING_TIME),
TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"),
CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES),
TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP)
.attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False),
VoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False)
.attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False),
# FanSensorEntity(client, "bms_emsStatus.fanLevel", "Fan Level"),
QuotasStatusSensorEntity(client),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100,
lambda value: {"moduleType": 2, "operateType": "upsConfig",
"params": {"maxChgSoc": int(value)}}),
MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30,
lambda value: {"moduleType": 2, "operateType": "dsgCfg",
"params": {"minDsgSoc": int(value)}}),
ChargingPowerEntity(client, "mppt.cfgChgWatts", const.AC_CHARGING_POWER, 100, 950,
lambda value: {"moduleType": 5, "operateType": "acChgCfg",
"params": {"chgWatts": int(value), "chgPauseFlag": 255}}),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
EnabledEntity(client, "mppt.cfgAcEnabled", const.AC_ENABLED,
lambda value: {"moduleType": 5, "operateType": "acOutCfg",
"params": {"enabled": value, "out_voltage": -1, "out_freq": 255,
"xboost": 255}}),
EnabledEntity(client, "mppt.cfgAcXboost", const.XBOOST_ENABLED,
lambda value: {"moduleType": 5, "operateType": "acOutCfg",
"params": {"enabled": 255, "out_voltage": -1, "out_freq": 255,
"xboost": value}}),
EnabledEntity(client, "pd.carState", const.DC_ENABLED,
lambda value: {"moduleType": 5, "operateType": "mpptCar", "params": {"enabled": value}})
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
DictSelectEntity(client, "mppt.dcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "dcChgCfg",
"params": {"dcChgCfg": value}}),
DictSelectEntity(client, "mppt.cfgChgType", const.DC_MODE, const.DC_MODE_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "chaType",
"params": {"chaType": value}}),
TimeoutDictSelectEntity(client, "mppt.scrStandbyMin", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "lcdCfg",
"params": {"brighLevel": 255, "delayOff": value}}),
TimeoutDictSelectEntity(client, "mppt.powStandbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "standby",
"params": {"standbyMins": value}}),
TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS,
lambda value: {"moduleType": 5, "operateType": "acStandby",
"params": {"standbyMins": value}})
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE),
]
return []

View File

@@ -0,0 +1,117 @@
from homeassistant.const import Platform
from . import const, BaseDevice, MigrationAction, EntityMigration
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
from ..number import MaxBatteryLevelEntity
from ..select import DictSelectEntity
from ..sensor import LevelSensorEntity, WattsSensorEntity, RemainSensorEntity, TempSensorEntity, \
CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, StatusSensorEntity, \
InEnergySensorEntity, OutEnergySensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \
OutMilliVoltSensorEntity, CapacitySensorEntity
from ..switch import EnabledEntity, BeeperEntity
class RiverMax(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL)
.attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False),
WattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
WattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER),
OutWattsSensorEntity(client, "pd.typecWatts", const.TYPEC_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb3Watts", const.USB_3_OUT_POWER),
RemainSensorEntity(client, "pd.remainTime", const.REMAINING_TIME),
CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES),
TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP)
.attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bmsMaster.minCellTemp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bmsMaster.maxCellTemp", const.MAX_CELL_TEMP, False),
MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False)
.attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bmsMaster.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bmsMaster.maxCellVol", const.MAX_CELL_VOLT, False),
# https://github.com/tolwi/hassio-ecoflow-cloud/discussions/87
InEnergySensorEntity(client, "pd.chgSunPower", const.SOLAR_IN_ENERGY),
InEnergySensorEntity(client, "pd.chgPowerAC", const.CHARGE_AC_ENERGY),
InEnergySensorEntity(client, "pd.chgPowerDC", const.CHARGE_DC_ENERGY),
OutEnergySensorEntity(client, "pd.dsgPowerAC", const.DISCHARGE_AC_ENERGY),
OutEnergySensorEntity(client, "pd.dsgPowerDC", const.DISCHARGE_DC_ENERGY),
LevelSensorEntity(client, "bmsSlave1.soc", const.SLAVE_BATTERY_LEVEL, False, True)
.attr("bmsSlave1.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsSlave1.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsSlave1.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bmsSlave1.designCap", const.SLAVE_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bmsSlave1.fullCap", const.SLAVE_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bmsSlave1.remainCap", const.SLAVE_REMAIN_CAPACITY, False),
TempSensorEntity(client, "bmsSlave1.temp", const.SLAVE_BATTERY_TEMP, False, True)
.attr("bmsSlave1.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bmsSlave1.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bmsSlave1.minCellTemp", const.SLAVE_MIN_CELL_TEMP, False),
TempSensorEntity(client, "bmsSlave1.maxCellTemp", const.SLAVE_MAX_CELL_TEMP, False),
MilliVoltSensorEntity(client, "bmsSlave1.vol", const.BATTERY_VOLT, False)
.attr("bmsSlave1.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bmsSlave1.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bmsSlave1.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bmsSlave1.maxCellVol", const.MAX_CELL_VOLT, False),
CyclesSensorEntity(client, "bmsSlave1.cycles", const.SLAVE_CYCLES, False, True),
StatusSensorEntity(client),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "bmsMaster.maxChargeSoc", const.MAX_CHARGE_LEVEL, 30, 100, None),
# MinBatteryLevelEntity(client, "bmsMaster.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, None),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
BeeperEntity(client, "pd.beepState", const.BEEPER, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 38, "enabled": value}}),
EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "enabled": value}}),
EnabledEntity(client, "pd.carSwitch", const.DC_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 34, "enabled": value}}),
EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "xboost": value}})
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
DictSelectEntity(client, "pd.standByMode", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 33, "standByMode": value}}),
DictSelectEntity(client, "inv.cfgStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 153, "standByMins": value}}),
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE),
]
return []

View File

@@ -0,0 +1,138 @@
from homeassistant.const import Platform
from . import const, BaseDevice, EntityMigration, MigrationAction
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
from ..number import MaxBatteryLevelEntity
from ..select import TimeoutDictSelectEntity
from ..sensor import LevelSensorEntity, WattsSensorEntity, RemainSensorEntity, TempSensorEntity, \
CyclesSensorEntity, InEnergySensorEntity, InWattsSensorEntity, OutEnergySensorEntity, OutWattsSensorEntity, VoltSensorEntity, InVoltSensorEntity, \
InAmpSensorEntity, AmpSensorEntity, StatusSensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \
OutMilliVoltSensorEntity, CapacitySensorEntity
from ..switch import EnabledEntity, BeeperEntity
class RiverPro(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL)
.attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False),
WattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER),
WattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER),
InAmpSensorEntity(client, "inv.dcInAmp", const.SOLAR_IN_CURRENT),
InVoltSensorEntity(client, "inv.dcInVol", const.SOLAR_IN_VOLTAGE),
InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER),
OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER),
InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT),
OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT),
OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER),
OutWattsSensorEntity(client, "pd.typecWatts", const.TYPEC_OUT_POWER),
# disabled by default because they aren't terribly useful
TempSensorEntity(client, "pd.carTemp", const.DC_CAR_OUT_TEMP, False),
TempSensorEntity(client, "pd.typecTemp", const.USB_C_TEMP, False),
OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER),
OutWattsSensorEntity(client, "pd.usb3Watts", const.USB_3_OUT_POWER),
RemainSensorEntity(client, "pd.remainTime", const.REMAINING_TIME),
TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP)
.attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bmsMaster.minCellTemp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bmsMaster.maxCellTemp", const.MAX_CELL_TEMP, False),
TempSensorEntity(client, "inv.inTemp", const.INV_IN_TEMP),
TempSensorEntity(client, "inv.outTemp", const.INV_OUT_TEMP),
# https://github.com/tolwi/hassio-ecoflow-cloud/discussions/87
InEnergySensorEntity(client, "pd.chgSunPower", const.SOLAR_IN_ENERGY),
InEnergySensorEntity(client, "pd.chgPowerAC", const.CHARGE_AC_ENERGY),
InEnergySensorEntity(client, "pd.chgPowerDC", const.CHARGE_DC_ENERGY),
OutEnergySensorEntity(client, "pd.dsgPowerAC", const.DISCHARGE_AC_ENERGY),
OutEnergySensorEntity(client, "pd.dsgPowerDC", const.DISCHARGE_DC_ENERGY),
AmpSensorEntity(client, "bmsMaster.amp", const.BATTERY_AMP, False),
MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False)
.attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bmsMaster.minCellVol", const.MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bmsMaster.maxCellVol", const.MAX_CELL_VOLT, False),
CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES),
# Optional Slave Batteries
LevelSensorEntity(client, "bmsSlave1.soc", const.SLAVE_BATTERY_LEVEL, False, True)
.attr("bmsSlave1.designCap", const.ATTR_DESIGN_CAPACITY, 0)
.attr("bmsSlave1.fullCap", const.ATTR_FULL_CAPACITY, 0)
.attr("bmsSlave1.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bmsSlave1.designCap", const.SLAVE_DESIGN_CAPACITY, False),
CapacitySensorEntity(client, "bmsSlave1.fullCap", const.SLAVE_FULL_CAPACITY, False),
CapacitySensorEntity(client, "bmsSlave1.remainCap", const.SLAVE_REMAIN_CAPACITY, False),
CyclesSensorEntity(client, "bmsSlave1.cycles", const.SLAVE_CYCLES, False, True),
TempSensorEntity(client, "bmsSlave1.temp", const.SLAVE_BATTERY_TEMP, False, True)
.attr("bmsSlave1.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bmsSlave1.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
AmpSensorEntity(client, "bmsSlave1.amp", const.SLAVE_BATTERY_AMP, False),
MilliVoltSensorEntity(client, "bmsSlave1.vol", const.SLAVE_BATTERY_VOLT, False)
.attr("bmsSlave1.minCellVol", const.ATTR_MIN_CELL_VOLT, 0)
.attr("bmsSlave1.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0),
MilliVoltSensorEntity(client, "bmsSlave1.minCellVol", const.SLAVE_MIN_CELL_VOLT, False),
MilliVoltSensorEntity(client, "bmsSlave1.maxCellVol", const.SLAVE_MAX_CELL_VOLT, False),
StatusSensorEntity(client),
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
MaxBatteryLevelEntity(client, "bmsMaster.maxChargeSoc", const.MAX_CHARGE_LEVEL, 30, 100,
lambda value: {"moduleType": 0, "operateType": "TCP",
"params": {"id": 49, "maxChgSoc": value}}),
# MinBatteryLevelEntity(client, "bmsMaster.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, None),
]
def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]:
return [
BeeperEntity(client, "pd.beepState", const.BEEPER, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 38, "enabled": value}}),
EnabledEntity(client, "inv.acAutoOutConfig", const.AC_ALWAYS_ENABLED,
lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 95, "acautooutConfig": value, "minAcoutSoc": 255}}),
EnabledEntity(client, "pd.carSwitch", const.DC_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 34, "enabled": value}}),
EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "enabled": value}}),
EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "xboost": value}}),
EnabledEntity(client, "inv.cfgAcChgModeFlg", const.AC_SLOW_CHARGE, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 65, "workMode": value}}),
EnabledEntity(client, "inv.cfgFanMode", const.AUTO_FAN_SPEED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 73, "fanMode": value}})
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
TimeoutDictSelectEntity(client, "pd.standByMode", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS_LIMITED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 33, "standByMode": value}}),
TimeoutDictSelectEntity(client, "pd.carDelayOffMin", const.DC_TIMEOUT, const.DC_TIMEOUT_OPTIONS_LIMITED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"cmdSet": 32, "id": 84, "carDelayOffMin": value}}),
TimeoutDictSelectEntity(client, "inv.cfgStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS_LIMITED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 153, "standByMins": value}})
# lambda is confirmed correct, but pd.lcdOffSec is missing from status
# TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS,
# lambda value: {"moduleType": 0, "operateType": "TCP",
# "params": {"lcdTime": value, "id": 39}})
]
def migrate(self, version) -> list[EntityMigration]:
if version == 2:
return [
EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE),
]
return []

View File

@@ -0,0 +1,91 @@
from homeassistant.components.switch import SwitchEntity
from . import const, BaseDevice
from .. import EcoflowMQTTClient
from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSelectEntity
from ..number import SetTempEntity
from ..select import DictSelectEntity
from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, \
WattsSensorEntity, QuotasStatusSensorEntity, \
MilliCelsiusSensorEntity, CapacitySensorEntity
class Wave2(BaseDevice):
def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]:
return [
# Power and Battery Entities
LevelSensorEntity(client, "bms.soc", const.MAIN_BATTERY_LEVEL)
.attr("bms.remainCap", const.ATTR_REMAIN_CAPACITY, 0),
CapacitySensorEntity(client, "bms.remainCap", const.MAIN_REMAIN_CAPACITY, False),
TempSensorEntity(client, "bms.tmp", const.BATTERY_TEMP)
.attr("bms.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0)
.attr("bms.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0),
TempSensorEntity(client, "bms.minCellTmp", const.MIN_CELL_TEMP, False),
TempSensorEntity(client, "bms.maxCellTmp", const.MAX_CELL_TEMP, False),
RemainSensorEntity(client, "pd.batChgRemain", const.CHARGE_REMAINING_TIME),
RemainSensorEntity(client, "pd.batDsgRemain", const.DISCHARGE_REMAINING_TIME),
# heat pump
MilliCelsiusSensorEntity(client, "pd.condTemp", "Condensation temperature", False),
MilliCelsiusSensorEntity(client, "pd.heatEnv", "Return air temperature in condensation zone", False),
MilliCelsiusSensorEntity(client, "pd.coolEnv", "Air outlet temperature", False),
MilliCelsiusSensorEntity(client, "pd.evapTemp", "Evaporation temperature", False),
MilliCelsiusSensorEntity(client, "pd.motorOutTemp", "Exhaust temperature", False),
MilliCelsiusSensorEntity(client, "pd.airInTemp", "Evaporation zone return air temperature", False),
TempSensorEntity(client, "pd.coolTemp", "Air outlet temperature", False),
TempSensorEntity(client, "pd.envTemp", "Ambient temperature", False),
# power (pd)
WattsSensorEntity(client, "pd.mpptPwr", "PV input power"),
WattsSensorEntity(client, "pd.batPwrOut", "Battery output power"),
WattsSensorEntity(client, "pd.pvPower", "PV charging power"),
WattsSensorEntity(client, "pd.acPwrIn", "AC input power"),
WattsSensorEntity(client, "pd.psdrPower ", "Power supply power"),
WattsSensorEntity(client, "pd.sysPowerWatts", "System power"),
WattsSensorEntity(client, "pd.batPower ", "Battery power"),
# power (motor)
WattsSensorEntity(client, "motor.power", "Motor operating power"),
# power (power)
WattsSensorEntity(client, "power.batPwrOut", "Battery output power"),
WattsSensorEntity(client, "power.acPwrI", "AC input power"),
WattsSensorEntity(client, "power.mpptPwr ", "PV input power"),
QuotasStatusSensorEntity(client)
]
def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]:
return [
SetTempEntity(client, "pd.setTemp", "Set Temperature", 0, 40,
lambda value: {"moduleType": 1, "operateType": "setTemp",
"sn": client.device_sn,
"params": {"setTemp": int(value)}}),
]
def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]:
return [
DictSelectEntity(client, "pd.fanValue", const.FAN_MODE, const.FAN_MODE_OPTIONS,
lambda value: {"moduleType": 1, "operateType": "fanValue",
"sn": client.device_sn,
"params": {"fanValue": value}}),
DictSelectEntity(client, "pd.mainMode", const.MAIN_MODE, const.MAIN_MODE_OPTIONS,
lambda value: {"moduleType": 1, "operateType": "mainMode",
"sn": client.device_sn,
"params": {"mainMode": value}}),
DictSelectEntity(client, "pd.powerMode", const.REMOTE_MODE, const.REMOTE_MODE_OPTIONS,
lambda value: {"moduleType": 1, "operateType": "powerMode",
"sn": client.device_sn,
"params": {"powerMode": value}}),
DictSelectEntity(client, "pd.subMode", const.POWER_SUB_MODE, const.POWER_SUB_MODE_OPTIONS,
lambda value: {"moduleType": 1, "operateType": "subMode",
"sn": client.device_sn,
"params": {"subMode": value}}),
]
def switches(self, client: EcoflowMQTTClient) -> list[SwitchEntity]:
return []

View File

@@ -0,0 +1,30 @@
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from . import DOMAIN
from .mqtt.ecoflow_mqtt import EcoflowMQTTClient
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: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id]
values = {
'device': client.device_type,
'params': dict(sorted(client.data.params.items())),
'set': [dict(sorted(k.items())) for k in client.data.set],
'set_reply': [dict(sorted(k.items())) for k in client.data.set_reply],
'get': [dict(sorted(k.items())) for k in client.data.get],
'get_reply': [dict(sorted(k.items())) for k in client.data.get_reply],
'raw_data': client.data.raw_data,
}
return values

View File

@@ -0,0 +1,151 @@
from __future__ import annotations
import inspect
from typing import Any, Callable, Optional, OrderedDict, Mapping
from homeassistant.components.number import NumberEntity
from homeassistant.components.select import SelectEntity
from homeassistant.components.sensor import SensorEntity
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.button import ButtonEntity
from homeassistant.helpers.entity import Entity, EntityCategory
from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient
class EcoFlowAbstractEntity(Entity):
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, client: EcoflowMQTTClient, title: str, key: str):
self._client = client
self._attr_name = title
self._attr_device_info = client.device_info_main
self._attr_unique_id = self.gen_unique_id(client.device_sn, key)
def send_get_message(self, command: dict):
self._client.send_get_message(command)
def send_set_message(self, target_dict: dict[str, Any] | None, command: dict):
self._client.send_set_message(target_dict, command)
@staticmethod
def gen_unique_id(sn: str, key: str):
return 'ecoflow-' + sn + '-' + key.replace('.', '-').replace('_', '-')
class EcoFlowDictEntity(EcoFlowAbstractEntity):
def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, enabled: bool = True,
auto_enable: bool = False) -> object:
super().__init__(client, title, mqtt_key)
self._mqtt_key = mqtt_key
self._auto_enable = auto_enable
self._attr_entity_registry_enabled_default = enabled
self.__attributes_mapping: dict[str, str] = {}
self.__attrs = OrderedDict[str, Any]()
def attr(self, mqtt_key: str, title: str, default: Any) -> EcoFlowDictEntity:
self.__attributes_mapping[mqtt_key] = title
self.__attrs[title] = default
return self
@property
def mqtt_key(self):
return self._mqtt_key
@property
def auto_enable(self):
return self._auto_enable
def send_set_message(self, target_value: Any, command: dict):
super().send_set_message({self._mqtt_key: target_value}, command)
@property
def enabled_default(self):
return self._attr_entity_registry_enabled_default
async def async_added_to_hass(self):
await super().async_added_to_hass()
d = self._client.data.params_observable().subscribe(self._updated)
self.async_on_remove(d.dispose)
def _updated(self, data: dict[str, Any]):
# update attributes
for key, title in self.__attributes_mapping.items():
if key in data:
self.__attrs[title] = data[key]
# update value
if self._mqtt_key in data:
self._attr_available = True
if self._auto_enable:
self._attr_entity_registry_enabled_default = True
if self._update_value(data[self._mqtt_key]):
self.schedule_update_ha_state()
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
return self.__attrs
def _update_value(self, val: Any) -> bool:
return False
class EcoFlowBaseCommandEntity(EcoFlowDictEntity):
def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str,
command: Callable[[int, Optional[dict[str, Any]]], dict[str, Any]] | None,
enabled: bool = True, auto_enable: bool = False):
super().__init__(client, mqtt_key, title, enabled, auto_enable)
self._command = command
def command_dict(self, value: int) -> dict[str, any] | None:
if self._command:
p_count = len(inspect.signature(self._command).parameters)
if p_count == 1:
return self._command(value)
elif p_count == 2:
return self._command(value, self._client.data.params)
else:
return None
class BaseNumberEntity(NumberEntity, EcoFlowBaseCommandEntity):
_attr_entity_category = EntityCategory.CONFIG
def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, min_value: int, max_value: int,
command: Callable[[int], dict[str, any]] | None, enabled: bool = True,
auto_enable: bool = False):
super().__init__(client, mqtt_key, title, command, enabled, auto_enable)
self._attr_native_max_value = max_value
self._attr_native_min_value = min_value
def _update_value(self, val: Any) -> bool:
if self._attr_native_value != val:
self._attr_native_value = val
return True
else:
return False
class BaseSensorEntity(SensorEntity, EcoFlowDictEntity):
def _update_value(self, val: Any) -> bool:
if self._attr_native_value != val:
self._attr_native_value = val
return True
else:
return False
class BaseSwitchEntity(SwitchEntity, EcoFlowBaseCommandEntity):
pass
class BaseSelectEntity(SelectEntity, EcoFlowBaseCommandEntity):
pass
class BaseButtonEntity(ButtonEntity, EcoFlowBaseCommandEntity):
pass

View File

@@ -0,0 +1,17 @@
{
"domain": "ecoflow_cloud",
"name": "Ecoflow-Cloud",
"codeowners": [
"@tolwi"
],
"config_flow": true,
"documentation": "https://github.com/tolwi/hassio-ecoflow-cloud",
"iot_class": "cloud_push",
"issue_tracker": "https://github.com/tolwi/hassio-ecoflow-cloud/issues",
"requirements": [
"paho-mqtt==1.6.1",
"reactivex==4.0.4",
"protobuf>=4.23.0"
],
"version": "0.13.3"
}

View File

@@ -0,0 +1,340 @@
import base64
import json
import logging
import random
import ssl
import time
import uuid
from datetime import datetime
from typing import Any
import paho.mqtt.client as mqtt_client
import requests
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, DOMAIN
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.util import utcnow
from reactivex import Subject, Observable
from .proto import powerstream_pb2 as powerstream, ecopacket_pb2 as ecopacket
from .utils import BoundFifoList
from ..config.const import CONF_DEVICE_TYPE, CONF_DEVICE_ID, OPTS_REFRESH_PERIOD_SEC, EcoflowModel
_LOGGER = logging.getLogger(__name__)
class EcoflowException(Exception):
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
class EcoflowAuthentication:
def __init__(self, ecoflow_username, ecoflow_password):
self.ecoflow_username = ecoflow_username
self.ecoflow_password = ecoflow_password
self.user_id = None
self.token = None
self.mqtt_url = "mqtt.mqtt.com"
self.mqtt_port = 8883
self.mqtt_username = None
self.mqtt_password = None
def authorize(self):
url = "https://api.ecoflow.com/auth/login"
headers = {"lang": "en_US", "content-type": "application/json"}
data = {"email": self.ecoflow_username,
"password": base64.b64encode(self.ecoflow_password.encode()).decode(),
"scene": "IOT_APP",
"userType": "ECOFLOW"}
_LOGGER.info(f"Login to EcoFlow API {url}")
request = requests.post(url, json=data, headers=headers)
response = self.get_json_response(request)
try:
self.token = response["data"]["token"]
self.user_id = response["data"]["user"]["userId"]
user_name = response["data"]["user"].get("name", "<no user name>")
except KeyError as key:
raise EcoflowException(f"Failed to extract key {key} from response: {response}")
_LOGGER.info(f"Successfully logged in: {user_name}")
url = "https://api.ecoflow.com/iot-auth/app/certification"
headers = {"lang": "en_US", "authorization": f"Bearer {self.token}"}
data = {"userId": self.user_id}
_LOGGER.info(f"Requesting IoT MQTT credentials {url}")
request = requests.get(url, data=data, headers=headers)
response = self.get_json_response(request)
try:
self.mqtt_url = response["data"]["url"]
self.mqtt_port = int(response["data"]["port"])
self.mqtt_username = response["data"]["certificateAccount"]
self.mqtt_password = response["data"]["certificatePassword"]
except KeyError as key:
raise EcoflowException(f"Failed to extract key {key} from {response}")
_LOGGER.info(f"Successfully extracted account: {self.mqtt_username}")
def get_json_response(self, request):
if request.status_code != 200:
raise EcoflowException(f"Got HTTP status code {request.status_code}: {request.text}")
try:
response = json.loads(request.text)
response_message = response["message"]
except KeyError as key:
raise EcoflowException(f"Failed to extract key {key} from {response}")
except Exception as error:
raise EcoflowException(f"Failed to parse response: {request.text} Error: {error}")
if response_message.lower() != "success":
raise EcoflowException(f"{response_message}")
return response
class EcoflowDataHolder:
def __init__(self, update_period_sec: int, collect_raw: bool = False):
self.__update_period_sec = update_period_sec
self.__collect_raw = collect_raw
self.set = BoundFifoList[dict[str, Any]]()
self.set_reply = BoundFifoList[dict[str, Any]]()
self.get = BoundFifoList[dict[str, Any]]()
self.get_reply = BoundFifoList[dict[str, Any]]()
self.params = dict[str, Any]()
self.raw_data = BoundFifoList[dict[str, Any]]()
self.__params_time = utcnow().replace(year=2000, month=1, day=1, hour=0, minute=0, second=0)
self.__params_broadcast_time = utcnow().replace(year=2000, month=1, day=1, hour=0, minute=0, second=0)
self.__params_observable = Subject[dict[str, Any]]()
self.__set_reply_observable = Subject[list[dict[str, Any]]]()
self.__get_reply_observable = Subject[list[dict[str, Any]]]()
def params_observable(self) -> Observable[dict[str, Any]]:
return self.__params_observable
def get_reply_observable(self) -> Observable[list[dict[str, Any]]]:
return self.__get_reply_observable
def set_reply_observable(self) -> Observable[list[dict[str, Any]]]:
return self.__set_reply_observable
def add_set_message(self, msg: dict[str, Any]):
self.set.append(msg)
def add_set_reply_message(self, msg: dict[str, Any]):
self.set_reply.append(msg)
self.__set_reply_observable.on_next(self.set_reply)
def add_get_message(self, msg: dict[str, Any]):
self.get.append(msg)
def add_get_reply_message(self, msg: dict[str, Any]):
self.get_reply.append(msg)
self.__get_reply_observable.on_next(self.get_reply)
def update_to_target_state(self, target_state: dict[str, Any]):
self.params.update(target_state)
self.__broadcast()
def update_data(self, raw: dict[str, Any]):
self.__add_raw_data(raw)
# self.__params_time = datetime.fromtimestamp(raw['timestamp'], UTC)
self.__params_time = utcnow()
self.params['timestamp'] = raw['timestamp']
self.params.update(raw['params'])
if (utcnow() - self.__params_broadcast_time).total_seconds() > self.__update_period_sec:
self.__broadcast()
def __broadcast(self):
self.__params_broadcast_time = utcnow()
self.__params_observable.on_next(self.params)
def __add_raw_data(self, raw: dict[str, Any]):
if self.__collect_raw:
self.raw_data.append(raw)
def params_time(self) -> datetime:
return self.__params_time
class EcoflowMQTTClient:
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, auth: EcoflowAuthentication):
self.auth = auth
self.config_entry = entry
self.device_type = entry.data[CONF_DEVICE_TYPE]
self.device_sn = entry.data[CONF_DEVICE_ID]
self._data_topic = f"/app/device/property/{self.device_sn}"
self._set_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/set"
self._set_reply_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/set_reply"
self._get_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/get"
self._get_reply_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/get_reply"
self.data = EcoflowDataHolder(entry.options.get(OPTS_REFRESH_PERIOD_SEC), self.device_type == "DIAGNOSTIC")
self.device_info_main = DeviceInfo(
identifiers={(DOMAIN, self.device_sn)},
manufacturer="EcoFlow",
name=entry.title,
model=self.device_type,
)
self.client = mqtt_client.Client(client_id=f'ANDROID_-{str(uuid.uuid4()).upper()}_{auth.user_id}',
clean_session=True, reconnect_on_failure=True)
self.client.username_pw_set(self.auth.mqtt_username, self.auth.mqtt_password)
self.client.tls_set(certfile=None, keyfile=None, cert_reqs=ssl.CERT_REQUIRED)
self.client.tls_insecure_set(False)
self.client.on_connect = self.on_connect
self.client.on_disconnect = self.on_disconnect
if self.device_type == EcoflowModel.POWERSTREAM.name:
self.client.on_message = self.on_bytes_message
else:
self.client.on_message = self.on_json_message
_LOGGER.info(f"Connecting to MQTT Broker {self.auth.mqtt_url}:{self.auth.mqtt_port}")
self.client.connect(self.auth.mqtt_url, self.auth.mqtt_port, 30)
self.client.loop_start()
def is_connected(self):
return self.client.is_connected()
def reconnect(self) -> bool:
try:
_LOGGER.info(f"Re-connecting to MQTT Broker {self.auth.mqtt_url}:{self.auth.mqtt_port}")
self.client.loop_stop(True)
self.client.reconnect()
self.client.loop_start()
return True
except Exception as e:
_LOGGER.error(e)
return False
def on_connect(self, client, userdata, flags, rc):
match rc:
case 0:
self.client.subscribe([(self._data_topic, 1),
(self._set_topic, 1), (self._set_reply_topic, 1),
(self._get_topic, 1), (self._get_reply_topic, 1)])
_LOGGER.info(f"Subscribed to MQTT topic {self._data_topic}")
case -1:
_LOGGER.error("Failed to connect to MQTT: connection timed out")
case 1:
_LOGGER.error("Failed to connect to MQTT: incorrect protocol version")
case 2:
_LOGGER.error("Failed to connect to MQTT: invalid client identifier")
case 3:
_LOGGER.error("Failed to connect to MQTT: server unavailable")
case 4:
_LOGGER.error("Failed to connect to MQTT: bad username or password")
case 5:
_LOGGER.error("Failed to connect to MQTT: not authorised")
case _:
_LOGGER.error(f"Failed to connect to MQTT: another error occured: {rc}")
return client
def on_disconnect(self, client, userdata, rc):
if rc != 0:
_LOGGER.error(f"Unexpected MQTT disconnection: {rc}. Will auto-reconnect")
time.sleep(5)
# self.client.reconnect() ??
def on_json_message(self, client, userdata, message):
try:
payload = message.payload.decode("utf-8", errors='ignore')
raw = json.loads(payload)
if message.topic == self._data_topic:
self.data.update_data(raw)
elif message.topic == self._set_topic:
self.data.add_set_message(raw)
elif message.topic == self._set_reply_topic:
self.data.add_set_reply_message(raw)
elif message.topic == self._get_topic:
self.data.add_get_message(raw)
elif message.topic == self._get_reply_topic:
self.data.add_get_reply_message(raw)
except UnicodeDecodeError as error:
_LOGGER.error(f"UnicodeDecodeError: {error}. Ignoring message and waiting for the next one.")
def on_bytes_message(self, client, userdata, message):
try:
payload = message.payload
while True:
packet = ecopacket.SendHeaderMsg()
packet.ParseFromString(payload)
_LOGGER.debug("cmd id %u payload \"%s\"", packet.msg.cmd_id, payload.hex())
if packet.msg.cmd_id != 1:
_LOGGER.info("Unsupported EcoPacket cmd id %u", packet.msg.cmd_id)
else:
heartbeat = powerstream.InverterHeartbeat()
heartbeat.ParseFromString(packet.msg.pdata)
raw = {"params": {}}
for descriptor in heartbeat.DESCRIPTOR.fields:
if not heartbeat.HasField(descriptor.name):
continue
raw["params"][descriptor.name] = getattr(heartbeat, descriptor.name)
_LOGGER.info("Found %u fields", len(raw["params"]))
raw["timestamp"] = utcnow()
self.data.update_data(raw)
if packet.ByteSize() >= len(payload):
break
_LOGGER.info("Found another frame in payload")
packetLength = len(payload) - packet.ByteSize()
payload = payload[:packetLength]
except Exception as error:
_LOGGER.error(error)
_LOGGER.info(message.payload.hex())
message_id = 999900000 + random.randint(10000, 99999)
def __prepare_payload(self, command: dict):
self.message_id += 1
payload = {"from": "HomeAssistant",
"id": f"{self.message_id}",
"version": "1.0"}
payload.update(command)
return payload
def __send(self, topic: str, message: str):
try:
info = self.client.publish(topic, message, 1)
_LOGGER.debug("Sending " + message + " :" + str(info) + "(" + str(info.is_published()) + ")")
except RuntimeError as error:
_LOGGER.error(error)
def send_get_message(self, command: dict):
payload = self.__prepare_payload(command)
self.__send(self._get_topic, json.dumps(payload))
def send_set_message(self, mqtt_state: dict[str, Any], command: dict):
self.data.update_to_target_state(mqtt_state)
payload = self.__prepare_payload(command)
self.__send(self._set_topic, json.dumps(payload))
def stop(self):
self.client.loop_stop()
self.client.disconnect()

View File

@@ -0,0 +1,57 @@
syntax = "proto3";
message Header
{
optional bytes pdata = 1;
optional int32 src = 2;
optional int32 dest = 3;
optional int32 d_src= 4;
optional int32 d_dest = 5;
optional int32 enc_type = 6;
optional int32 check_type = 7;
optional int32 cmd_func = 8;
optional int32 cmd_id = 9;
optional int32 data_len = 10;
optional int32 need_ack = 11;
optional int32 is_ack = 12;
optional int32 seq = 14;
optional int32 product_id = 15;
optional int32 version = 16;
optional int32 payload_ver = 17;
optional int32 time_snap = 18;
optional int32 is_rw_cmd = 19;
optional int32 is_queue = 20;
optional int32 ack_type= 21;
optional string code = 22;
optional string from = 23;
optional string module_sn = 24;
optional string device_sn = 25;
}
message SendHeaderMsg
{
optional Header msg = 1;
}
message SendMsgHart
{
optional int32 link_id = 1;
optional int32 src = 2;
optional int32 dest = 3;
optional int32 d_src = 4;
optional int32 d_dest = 5;
optional int32 enc_type = 6;
optional int32 check_type = 7;
optional int32 cmd_func = 8;
optional int32 cmd_id = 9;
optional int32 data_len = 10;
optional int32 need_ack = 11;
optional int32 is_ack = 12;
optional int32 ack_type = 13;
optional int32 seq = 14;
optional int32 time_snap = 15;
optional int32 is_rw_cmd = 16;
optional int32 is_queue = 17;
optional int32 product_id = 18;
optional int32 version = 19;
}

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: ecopacket.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x65\x63opacket.proto\"\xb8\x06\n\x06Header\x12\x12\n\x05pdata\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x10\n\x03src\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\x04\x64\x65st\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x12\n\x05\x64_src\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12\x13\n\x06\x64_dest\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x15\n\x08\x65nc_type\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x17\n\ncheck_type\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12\x15\n\x08\x63md_func\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x13\n\x06\x63md_id\x18\t \x01(\x05H\x08\x88\x01\x01\x12\x15\n\x08\x64\x61ta_len\x18\n \x01(\x05H\t\x88\x01\x01\x12\x15\n\x08need_ack\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x13\n\x06is_ack\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x10\n\x03seq\x18\x0e \x01(\x05H\x0c\x88\x01\x01\x12\x17\n\nproduct_id\x18\x0f \x01(\x05H\r\x88\x01\x01\x12\x14\n\x07version\x18\x10 \x01(\x05H\x0e\x88\x01\x01\x12\x18\n\x0bpayload_ver\x18\x11 \x01(\x05H\x0f\x88\x01\x01\x12\x16\n\ttime_snap\x18\x12 \x01(\x05H\x10\x88\x01\x01\x12\x16\n\tis_rw_cmd\x18\x13 \x01(\x05H\x11\x88\x01\x01\x12\x15\n\x08is_queue\x18\x14 \x01(\x05H\x12\x88\x01\x01\x12\x15\n\x08\x61\x63k_type\x18\x15 \x01(\x05H\x13\x88\x01\x01\x12\x11\n\x04\x63ode\x18\x16 \x01(\tH\x14\x88\x01\x01\x12\x11\n\x04\x66rom\x18\x17 \x01(\tH\x15\x88\x01\x01\x12\x16\n\tmodule_sn\x18\x18 \x01(\tH\x16\x88\x01\x01\x12\x16\n\tdevice_sn\x18\x19 \x01(\tH\x17\x88\x01\x01\x42\x08\n\x06_pdataB\x06\n\x04_srcB\x07\n\x05_destB\x08\n\x06_d_srcB\t\n\x07_d_destB\x0b\n\t_enc_typeB\r\n\x0b_check_typeB\x0b\n\t_cmd_funcB\t\n\x07_cmd_idB\x0b\n\t_data_lenB\x0b\n\t_need_ackB\t\n\x07_is_ackB\x06\n\x04_seqB\r\n\x0b_product_idB\n\n\x08_versionB\x0e\n\x0c_payload_verB\x0c\n\n_time_snapB\x0c\n\n_is_rw_cmdB\x0b\n\t_is_queueB\x0b\n\t_ack_typeB\x07\n\x05_codeB\x07\n\x05_fromB\x0c\n\n_module_snB\x0c\n\n_device_sn\"2\n\rSendHeaderMsg\x12\x19\n\x03msg\x18\x01 \x01(\x0b\x32\x07.HeaderH\x00\x88\x01\x01\x42\x06\n\x04_msg\"\x93\x05\n\x0bSendMsgHart\x12\x14\n\x07link_id\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x10\n\x03src\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\x04\x64\x65st\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x12\n\x05\x64_src\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12\x13\n\x06\x64_dest\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x15\n\x08\x65nc_type\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x17\n\ncheck_type\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12\x15\n\x08\x63md_func\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x13\n\x06\x63md_id\x18\t \x01(\x05H\x08\x88\x01\x01\x12\x15\n\x08\x64\x61ta_len\x18\n \x01(\x05H\t\x88\x01\x01\x12\x15\n\x08need_ack\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x13\n\x06is_ack\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x15\n\x08\x61\x63k_type\x18\r \x01(\x05H\x0c\x88\x01\x01\x12\x10\n\x03seq\x18\x0e \x01(\x05H\r\x88\x01\x01\x12\x16\n\ttime_snap\x18\x0f \x01(\x05H\x0e\x88\x01\x01\x12\x16\n\tis_rw_cmd\x18\x10 \x01(\x05H\x0f\x88\x01\x01\x12\x15\n\x08is_queue\x18\x11 \x01(\x05H\x10\x88\x01\x01\x12\x17\n\nproduct_id\x18\x12 \x01(\x05H\x11\x88\x01\x01\x12\x14\n\x07version\x18\x13 \x01(\x05H\x12\x88\x01\x01\x42\n\n\x08_link_idB\x06\n\x04_srcB\x07\n\x05_destB\x08\n\x06_d_srcB\t\n\x07_d_destB\x0b\n\t_enc_typeB\r\n\x0b_check_typeB\x0b\n\t_cmd_funcB\t\n\x07_cmd_idB\x0b\n\t_data_lenB\x0b\n\t_need_ackB\t\n\x07_is_ackB\x0b\n\t_ack_typeB\x06\n\x04_seqB\x0c\n\n_time_snapB\x0c\n\n_is_rw_cmdB\x0b\n\t_is_queueB\r\n\x0b_product_idB\n\n\x08_versionb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ecopacket_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_globals['_HEADER']._serialized_start=20
_globals['_HEADER']._serialized_end=844
_globals['_SENDHEADERMSG']._serialized_start=846
_globals['_SENDHEADERMSG']._serialized_end=896
_globals['_SENDMSGHART']._serialized_start=899
_globals['_SENDMSGHART']._serialized_end=1558
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,105 @@
syntax = "proto3";
message EnergyItem
{
optional uint32 timestamp = 1;
optional uint32 watth_type = 2;
repeated uint32 watth = 3;
}
message EnergyTotalReport
{
optional uint32 watth_seq = 1;
optional EnergyItem watth_item = 2;
}
message BatchEnergyTotalReport
{
optional uint32 watth_seq = 1;
repeated EnergyItem watth_item = 2;
}
message EnergyTotalReportAck
{
optional uint32 result = 1;
optional uint32 watth_seq = 2;
optional uint32 watth_type = 3;
}
message EventRecordItem
{
optional uint32 timestamp = 1;
optional uint32 sys_ms = 2;
optional uint32 event_no = 3;
repeated float event_detail = 4;
}
message EventRecordReport
{
optional uint32 event_ver = 1;
optional uint32 event_seq = 2;
repeated EventRecordItem event_item = 3;
}
message EventInfoReportAck
{
optional uint32 result = 1;
optional uint32 event_seq = 2;
optional uint32 event_item_num =3;
}
message ProductNameSet
{
optional string name = 1;
}
message ProductNameSetAck
{
optional uint32 result = 1;
}
message ProductNameGet { }
message ProductNameGetAck
{
optional string name = 3;
}
message RTCTimeGet { }
message RTCTimeGetAck
{
optional uint32 timestamp = 1;
optional int32 timezone = 2;
}
message RTCTimeSet
{
optional uint32 timestamp = 1;
optional int32 timezone = 2;
}
message RTCTimeSetAck
{
optional uint32 result = 1;
}
message country_town_message
{
optional uint32 country = 1;
optional uint32 town = 2;
}
enum PlCmdSets
{
PL_NONE_CMD_SETS = 0;
PL_BASIC_CMD_SETS = 1;
PL_EXT_CMD_SETS = 254;
}
enum PlCmdId
{
PL_CMD_ID_NONE = 0;
PL_CMD_ID_XLOG = 16;
PL_CMD_ID_WATTH = 32;
}

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: platform.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eplatform.proto\"i\n\nEnergyItem\x12\x16\n\ttimestamp\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x17\n\nwatth_type\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05watth\x18\x03 \x03(\rB\x0c\n\n_timestampB\r\n\x0b_watth_type\"n\n\x11\x45nergyTotalReport\x12\x16\n\twatth_seq\x18\x01 \x01(\rH\x00\x88\x01\x01\x12$\n\nwatth_item\x18\x02 \x01(\x0b\x32\x0b.EnergyItemH\x01\x88\x01\x01\x42\x0c\n\n_watth_seqB\r\n\x0b_watth_item\"_\n\x16\x42\x61tchEnergyTotalReport\x12\x16\n\twatth_seq\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x1f\n\nwatth_item\x18\x02 \x03(\x0b\x32\x0b.EnergyItemB\x0c\n\n_watth_seq\"\x84\x01\n\x14\x45nergyTotalReportAck\x12\x13\n\x06result\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x16\n\twatth_seq\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\x17\n\nwatth_type\x18\x03 \x01(\rH\x02\x88\x01\x01\x42\t\n\x07_resultB\x0c\n\n_watth_seqB\r\n\x0b_watth_type\"\x91\x01\n\x0f\x45ventRecordItem\x12\x16\n\ttimestamp\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x13\n\x06sys_ms\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x08\x65vent_no\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x14\n\x0c\x65vent_detail\x18\x04 \x03(\x02\x42\x0c\n\n_timestampB\t\n\x07_sys_msB\x0b\n\t_event_no\"\x85\x01\n\x11\x45ventRecordReport\x12\x16\n\tevent_ver\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x16\n\tevent_seq\x18\x02 \x01(\rH\x01\x88\x01\x01\x12$\n\nevent_item\x18\x03 \x03(\x0b\x32\x10.EventRecordItemB\x0c\n\n_event_verB\x0c\n\n_event_seq\"\x8a\x01\n\x12\x45ventInfoReportAck\x12\x13\n\x06result\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x16\n\tevent_seq\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\x1b\n\x0e\x65vent_item_num\x18\x03 \x01(\rH\x02\x88\x01\x01\x42\t\n\x07_resultB\x0c\n\n_event_seqB\x11\n\x0f_event_item_num\",\n\x0eProductNameSet\x12\x11\n\x04name\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\x07\n\x05_name\"3\n\x11ProductNameSetAck\x12\x13\n\x06result\x18\x01 \x01(\rH\x00\x88\x01\x01\x42\t\n\x07_result\"\x10\n\x0eProductNameGet\"/\n\x11ProductNameGetAck\x12\x11\n\x04name\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x07\n\x05_name\"\x0c\n\nRTCTimeGet\"Y\n\rRTCTimeGetAck\x12\x16\n\ttimestamp\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08timezone\x18\x02 \x01(\x05H\x01\x88\x01\x01\x42\x0c\n\n_timestampB\x0b\n\t_timezone\"V\n\nRTCTimeSet\x12\x16\n\ttimestamp\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08timezone\x18\x02 \x01(\x05H\x01\x88\x01\x01\x42\x0c\n\n_timestampB\x0b\n\t_timezone\"/\n\rRTCTimeSetAck\x12\x13\n\x06result\x18\x01 \x01(\rH\x00\x88\x01\x01\x42\t\n\x07_result\"T\n\x14\x63ountry_town_message\x12\x14\n\x07\x63ountry\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x11\n\x04town\x18\x02 \x01(\rH\x01\x88\x01\x01\x42\n\n\x08_countryB\x07\n\x05_town*N\n\tPlCmdSets\x12\x14\n\x10PL_NONE_CMD_SETS\x10\x00\x12\x15\n\x11PL_BASIC_CMD_SETS\x10\x01\x12\x14\n\x0fPL_EXT_CMD_SETS\x10\xfe\x01*F\n\x07PlCmdId\x12\x12\n\x0ePL_CMD_ID_NONE\x10\x00\x12\x12\n\x0ePL_CMD_ID_XLOG\x10\x10\x12\x13\n\x0fPL_CMD_ID_WATTH\x10 b\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'platform_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
_globals['_PLCMDSETS']._serialized_start=1388
_globals['_PLCMDSETS']._serialized_end=1466
_globals['_PLCMDID']._serialized_start=1468
_globals['_PLCMDID']._serialized_end=1538
_globals['_ENERGYITEM']._serialized_start=18
_globals['_ENERGYITEM']._serialized_end=123
_globals['_ENERGYTOTALREPORT']._serialized_start=125
_globals['_ENERGYTOTALREPORT']._serialized_end=235
_globals['_BATCHENERGYTOTALREPORT']._serialized_start=237
_globals['_BATCHENERGYTOTALREPORT']._serialized_end=332
_globals['_ENERGYTOTALREPORTACK']._serialized_start=335
_globals['_ENERGYTOTALREPORTACK']._serialized_end=467
_globals['_EVENTRECORDITEM']._serialized_start=470
_globals['_EVENTRECORDITEM']._serialized_end=615
_globals['_EVENTRECORDREPORT']._serialized_start=618
_globals['_EVENTRECORDREPORT']._serialized_end=751
_globals['_EVENTINFOREPORTACK']._serialized_start=754
_globals['_EVENTINFOREPORTACK']._serialized_end=892
_globals['_PRODUCTNAMESET']._serialized_start=894
_globals['_PRODUCTNAMESET']._serialized_end=938
_globals['_PRODUCTNAMESETACK']._serialized_start=940
_globals['_PRODUCTNAMESETACK']._serialized_end=991
_globals['_PRODUCTNAMEGET']._serialized_start=993
_globals['_PRODUCTNAMEGET']._serialized_end=1009
_globals['_PRODUCTNAMEGETACK']._serialized_start=1011
_globals['_PRODUCTNAMEGETACK']._serialized_end=1058
_globals['_RTCTIMEGET']._serialized_start=1060
_globals['_RTCTIMEGET']._serialized_end=1072
_globals['_RTCTIMEGETACK']._serialized_start=1074
_globals['_RTCTIMEGETACK']._serialized_end=1163
_globals['_RTCTIMESET']._serialized_start=1165
_globals['_RTCTIMESET']._serialized_end=1251
_globals['_RTCTIMESETACK']._serialized_start=1253
_globals['_RTCTIMESETACK']._serialized_end=1300
_globals['_COUNTRY_TOWN_MESSAGE']._serialized_start=1302
_globals['_COUNTRY_TOWN_MESSAGE']._serialized_end=1386
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,127 @@
syntax = "proto3";
message InverterHeartbeat {
optional uint32 inv_error_code = 1;
optional uint32 inv_warning_code = 3;
optional uint32 pv1_error_code = 2;
optional uint32 pv1_warning_code = 4;
optional uint32 pv2_error_code = 5;
optional uint32 pv2_warning_code = 6;
optional uint32 bat_error_code = 7;
optional uint32 bat_warning_code = 8;
optional uint32 llc_error_code = 9;
optional uint32 llc_warning_code = 10;
optional uint32 pv1_status = 11;
optional uint32 pv2_status = 12;
optional uint32 bat_status = 13;
optional uint32 llc_status = 14;
optional uint32 inv_status = 15;
optional int32 pv1_input_volt = 16;
optional int32 pv1_op_volt = 17;
optional int32 pv1_input_cur = 18;
optional int32 pv1_input_watts = 19;
optional int32 pv1_temp = 20;
optional int32 pv2_input_volt = 21;
optional int32 pv2_op_volt = 22;
optional int32 pv2_input_cur = 23;
optional int32 pv2_input_watts = 24;
optional int32 pv2_temp = 25;
optional int32 bat_input_volt = 26;
optional int32 bat_op_volt = 27;
optional int32 bat_input_cur = 28;
optional int32 bat_input_watts = 29;
optional int32 bat_temp = 30;
optional uint32 bat_soc = 31;
optional int32 llc_input_volt = 32;
optional int32 llc_op_volt = 33;
optional int32 llc_temp = 34;
optional int32 inv_input_volt = 35;
optional int32 inv_op_volt = 36;
optional int32 inv_output_cur = 37;
optional int32 inv_output_watts = 38;
optional int32 inv_temp = 39;
optional int32 inv_freq = 40;
optional int32 inv_dc_cur = 41;
optional int32 bp_type = 42;
optional int32 inv_relay_status = 43;
optional int32 pv1_relay_status = 44;
optional int32 pv2_relay_status = 45;
optional uint32 install_country = 46;
optional uint32 install_town = 47;
optional uint32 permanent_watts = 48;
optional uint32 dynamic_watts = 49;
optional uint32 supply_priority = 50;
optional uint32 lower_limit = 51;
optional uint32 upper_limit = 52;
optional uint32 inv_on_off = 53;
optional uint32 wireless_error_code = 54;
optional uint32 wireless_warning_code = 55;
optional uint32 inv_brightness = 56;
optional uint32 heartbeat_frequency = 57;
optional uint32 rated_power = 58;
optional uint32 battery_charge_remain = 59;
optional uint32 battery_discharge_remain = 60;
}
message PermanentWattsPack
{
optional uint32 permanent_watts = 1;
}
message SupplyPriorityPack
{
optional uint32 supply_priority = 1;
}
message BatLowerPack
{
optional int32 lower_limit = 1;
}
message BatUpperPack
{
optional int32 upper_limit = 1;
}
message BrightnessPack
{
optional int32 brightness = 1;
}
message PowerItem
{
optional uint32 timestamp = 1;
optional sint32 timezone = 2;
optional uint32 inv_to_grid_power = 3;
optional uint32 inv_to_plug_power = 4;
optional int32 battery_power = 5;
optional uint32 pv1_output_power = 6;
optional uint32 pv2_output_power = 7;
}
message PowerPack
{
optional uint32 sys_seq = 1;
repeated PowerItem sys_power_stream = 2;
}
message PowerAckPack
{
optional uint32 sys_seq = 1;
}
message NodeMassage
{
optional string sn = 1;
optional bytes mac = 2;
}
message MeshChildNodeInfo
{
optional uint32 topology_type = 1;
optional uint32 mesh_protocol = 2;
optional uint32 max_sub_device_num = 3;
optional bytes parent_mac_id = 4;
optional bytes mesh_id = 5;
repeated NodeMassage sub_device_list = 6;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,34 @@
from collections import OrderedDict
from typing import Callable, List, TypeVar
class LimitedSizeOrderedDict(OrderedDict):
def __init__(self, maxlen=20):
"""Initialize a new DedupStore."""
super().__init__()
self.maxlen = maxlen
def append(self, key, val, on_delete: Callable = None):
self[key] = val
self.move_to_end(key)
if len(self) > self.maxlen:
# Removes the first record which should also be the oldest
itm = self.popitem(last=False)
if on_delete:
on_delete(itm)
_T = TypeVar("_T")
class BoundFifoList(List):
def __init__(self, maxlen=20) -> None:
super().__init__()
self.maxlen = maxlen
def append(self, __object: _T) -> None:
super().insert(0, __object)
while len(self) >= self.maxlen:
self.pop()

View File

@@ -0,0 +1,86 @@
from typing import Callable, Any
from homeassistant.components.number import NumberMode
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfPower, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import DOMAIN, OPTS_POWER_STEP
from .entities import BaseNumberEntity
from .mqtt.ecoflow_mqtt import EcoflowMQTTClient
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id]
from .devices.registry import devices
async_add_entities(devices[client.device_type].numbers(client))
class ValueUpdateEntity(BaseNumberEntity):
_attr_native_step = 1
_attr_mode = NumberMode.SLIDER
async def async_set_native_value(self, value: float):
if self._command:
ival = int(value)
self.send_set_message(ival, self.command_dict(ival))
class ChargingPowerEntity(ValueUpdateEntity):
_attr_icon = "mdi:transmission-tower-import"
_attr_native_unit_of_measurement = UnitOfPower.WATT
_attr_device_class = SensorDeviceClass.POWER
def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, min_value: int, max_value: int,
command: Callable[[int], dict[str, any]] | None, enabled: bool = True, auto_enable: bool = False):
super().__init__(client, mqtt_key, title, min_value, max_value, command, enabled, auto_enable)
self._attr_native_step = client.config_entry.options[OPTS_POWER_STEP]
class BatteryBackupLevel(ValueUpdateEntity):
_attr_icon = "mdi:battery-charging-90"
_attr_native_unit_of_measurement = PERCENTAGE
def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str,
min_value: int, max_value: int,
min_key: str, max_key: str,
command: Callable[[int], dict[str, any]] | None):
super().__init__(client, mqtt_key, title, min_value, max_value, command, True, False)
self.__min_key = min_key
self.__max_key = max_key
def _updated(self, data: dict[str, Any]):
if self.__min_key in data:
self._attr_native_min_value = int(data[self.__min_key]) + 5 # min + 5%
if self.__max_key in data:
self._attr_native_max_value = int(data[self.__max_key])
super()._updated(data)
class LevelEntity(ValueUpdateEntity):
_attr_native_unit_of_measurement = PERCENTAGE
class MinBatteryLevelEntity(LevelEntity):
_attr_icon = "mdi:battery-charging-10"
class MaxBatteryLevelEntity(LevelEntity):
_attr_icon = "mdi:battery-charging-90"
class MinGenStartLevelEntity(LevelEntity):
_attr_icon = "mdi:engine"
class MaxGenStopLevelEntity(LevelEntity):
_attr_icon = "mdi:engine-off"
class SetTempEntity(ValueUpdateEntity):
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS

View File

@@ -0,0 +1,9 @@
from homeassistant.core import callback, HomeAssistant
from custom_components.ecoflow_cloud import ATTR_STATUS_UPDATES, ATTR_STATUS_DATA_LAST_UPDATE, \
ATTR_STATUS_LAST_UPDATE, ATTR_STATUS_PHASE
@callback
def exclude_attributes(hass: HomeAssistant) -> set[str]:
return {ATTR_STATUS_UPDATES, ATTR_STATUS_DATA_LAST_UPDATE, ATTR_STATUS_LAST_UPDATE, ATTR_STATUS_PHASE}

View File

@@ -0,0 +1,48 @@
from typing import Callable, Any
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 custom_components.ecoflow_cloud import EcoflowMQTTClient, DOMAIN
from custom_components.ecoflow_cloud.entities import BaseSelectEntity
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id]
from .devices.registry import devices
async_add_entities(devices[client.device_type].selects(client))
class DictSelectEntity(BaseSelectEntity):
_attr_entity_category = EntityCategory.CONFIG
_attr_available = False
def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, options: dict[str, int],
command: Callable[[int], dict[str, any]] | None, enabled: bool = True, auto_enable: bool = False):
super().__init__(client, mqtt_key, title, command, enabled, auto_enable)
self.__options_dict = options
self._attr_options = list(options.keys())
def options_dict(self) -> dict[str, int]:
return self.__options_dict
def _update_value(self, val: Any) -> bool:
ival = int(val)
lval = [k for k, v in self.__options_dict.items() if v == ival]
if len(lval) == 1:
self._attr_current_option = lval[0]
return True
else:
return False
async def async_select_option(self, option: str):
if self._command:
val = self.__options_dict[option]
self.send_set_message(val, self.command_dict(val))
class TimeoutDictSelectEntity(DictSelectEntity):
_attr_icon = "mdi:timer-outline"

View File

@@ -0,0 +1,393 @@
import math
import logging
from datetime import timedelta, datetime
from typing import Any, Mapping, OrderedDict
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass
from homeassistant.components.sensor import (SensorDeviceClass, SensorStateClass, SensorEntity)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (PERCENTAGE,
UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency,
UnitOfPower, UnitOfTemperature, UnitOfTime)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util import utcnow
from homeassistant.util.dt import UTC
from . import DOMAIN, ATTR_STATUS_SN, ATTR_STATUS_DATA_LAST_UPDATE, ATTR_STATUS_UPDATES, \
ATTR_STATUS_LAST_UPDATE, ATTR_STATUS_RECONNECTS, ATTR_STATUS_PHASE
from .entities import BaseSensorEntity, EcoFlowAbstractEntity, EcoFlowDictEntity
from .mqtt.ecoflow_mqtt import EcoflowMQTTClient
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id]
from .devices.registry import devices
async_add_entities(devices[client.device_type].sensors(client))
class MiscBinarySensorEntity(BinarySensorEntity, EcoFlowDictEntity):
def _update_value(self, val: Any) -> bool:
self._attr_is_on = bool(val)
return True
class ChargingStateSensorEntity(BaseSensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_icon = "mdi:battery-charging"
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
def _update_value(self, val: Any) -> bool:
if val == 0:
return super()._update_value("unused")
elif val == 1:
return super()._update_value("charging")
elif val == 2:
return super()._update_value("discharging")
else:
return False
class CyclesSensorEntity(BaseSensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_icon = "mdi:battery-heart-variant"
_attr_state_class = SensorStateClass.TOTAL_INCREASING
class FanSensorEntity(BaseSensorEntity):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:fan"
class MiscSensorEntity(BaseSensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
class LevelSensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
class RemainSensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.DURATION
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_value = 0
def _update_value(self, val: Any) -> Any:
ival = int(val)
if ival < 0 or ival > 5000:
ival = 0
return super()._update_value(ival)
class SecondsRemainSensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.DURATION
_attr_native_unit_of_measurement = UnitOfTime.SECONDS
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_value = 0
def _update_value(self, val: Any) -> Any:
ival = int(val)
if ival < 0 or ival > 5000:
ival = 0
return super()._update_value(ival)
class TempSensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_value = -1
class DecicelsiusSensorEntity(TempSensorEntity):
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class MilliCelsiusSensorEntity(TempSensorEntity):
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 100)
class VoltSensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.VOLTAGE
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_value = 0
class MilliVoltSensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.VOLTAGE
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = UnitOfElectricPotential.MILLIVOLT
_attr_suggested_unit_of_measurement = UnitOfElectricPotential.VOLT
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_value = 3
class InMilliVoltSensorEntity(MilliVoltSensorEntity):
_attr_icon = "mdi:transmission-tower-import"
_attr_suggested_display_precision = 0
class OutMilliVoltSensorEntity(MilliVoltSensorEntity):
_attr_icon = "mdi:transmission-tower-export"
_attr_suggested_display_precision = 0
class DecivoltSensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.VOLTAGE
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_value = 0
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class CentivoltSensorEntity(DecivoltSensorEntity):
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class AmpSensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.CURRENT
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = UnitOfElectricCurrent.MILLIAMPERE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_value = 0
class DeciampSensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.CURRENT
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_value = 0
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class WattsSensorEntity(BaseSensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_device_class = SensorDeviceClass.POWER
_attr_native_unit_of_measurement = UnitOfPower.WATT
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_value = 0
class EnergySensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.ENERGY
_attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR
_attr_state_class = SensorStateClass.TOTAL_INCREASING
def _update_value(self, val: Any) -> bool:
ival = int(val)
if ival > 0:
return super()._update_value(ival)
else:
return False
class CapacitySensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.CURRENT
_attr_native_unit_of_measurement = "mAh"
_attr_state_class = SensorStateClass.MEASUREMENT
class DeciwattsSensorEntity(WattsSensorEntity):
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class InWattsSensorEntity(WattsSensorEntity):
_attr_icon = "mdi:transmission-tower-import"
class InWattsSolarSensorEntity(InWattsSensorEntity):
_attr_icon = "mdi:solar-power"
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class OutWattsSensorEntity(WattsSensorEntity):
_attr_icon = "mdi:transmission-tower-export"
class OutWattsDcSensorEntity(WattsSensorEntity):
_attr_icon = "mdi:transmission-tower-export"
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class InVoltSensorEntity(VoltSensorEntity):
_attr_icon = "mdi:transmission-tower-import"
class InVoltSolarSensorEntity(VoltSensorEntity):
_attr_icon = "mdi:solar-power"
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class OutVoltDcSensorEntity(VoltSensorEntity):
_attr_icon = "mdi:transmission-tower-export"
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class InAmpSensorEntity(AmpSensorEntity):
_attr_icon = "mdi:transmission-tower-import"
class InAmpSolarSensorEntity(AmpSensorEntity):
_attr_icon = "mdi:solar-power"
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) * 10)
class InEnergySensorEntity(EnergySensorEntity):
_attr_icon = "mdi:transmission-tower-import"
class OutEnergySensorEntity(EnergySensorEntity):
_attr_icon = "mdi:transmission-tower-export"
class FrequencySensorEntity(BaseSensorEntity):
_attr_device_class = SensorDeviceClass.FREQUENCY
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_native_unit_of_measurement = UnitOfFrequency.HERTZ
_attr_state_class = SensorStateClass.MEASUREMENT
class DecihertzSensorEntity(FrequencySensorEntity):
def _update_value(self, val: Any) -> bool:
return super()._update_value(int(val) / 10)
class StatusSensorEntity(SensorEntity, EcoFlowAbstractEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
DEADLINE_PHASE = 10
CHECK_PHASES = [2, 4, 6]
CONNECT_PHASES = [3, 5, 7]
def __init__(self, client: EcoflowMQTTClient, check_interval_sec=30):
super().__init__(client, "Status", "status")
self._online = 0
self.__check_interval_sec = check_interval_sec
self._attrs = OrderedDict[str, Any]()
self._attrs[ATTR_STATUS_SN] = client.device_sn
self._attrs[ATTR_STATUS_DATA_LAST_UPDATE] = self._client.data.params_time()
self._attrs[ATTR_STATUS_UPDATES] = 0
self._attrs[ATTR_STATUS_LAST_UPDATE] = None
self._attrs[ATTR_STATUS_RECONNECTS] = 0
self._attrs[ATTR_STATUS_PHASE] = 0
async def async_added_to_hass(self):
await super().async_added_to_hass()
params_d = self._client.data.params_observable().subscribe(self.__params_update)
self.async_on_remove(params_d.dispose)
self.async_on_remove(
async_track_time_interval(self.hass, self.__check_status, timedelta(seconds=self.__check_interval_sec)))
self._update_status((utcnow() - self._client.data.params_time()).total_seconds())
def __check_status(self, now: datetime):
data_outdated_sec = (now - self._client.data.params_time()).total_seconds()
phase = math.ceil(data_outdated_sec / self.__check_interval_sec)
self._attrs[ATTR_STATUS_PHASE] = phase
time_to_reconnect = phase in self.CONNECT_PHASES
time_to_check_status = phase in self.CHECK_PHASES
if self._online == 1:
if time_to_check_status or phase >= self.DEADLINE_PHASE:
# online and outdated - refresh status to detect if device went offline
self._update_status(data_outdated_sec)
elif time_to_reconnect:
# online, updated and outdated - reconnect
self._attrs[ATTR_STATUS_RECONNECTS] = self._attrs[ATTR_STATUS_RECONNECTS] + 1
self._client.reconnect()
self.schedule_update_ha_state()
elif not self._client.is_connected(): # validate connection even for offline device
self._attrs[ATTR_STATUS_RECONNECTS] = self._attrs[ATTR_STATUS_RECONNECTS] + 1
self._client.reconnect()
self.schedule_update_ha_state()
def __params_update(self, data: dict[str, Any]):
self._attrs[ATTR_STATUS_DATA_LAST_UPDATE] = self._client.data.params_time()
if self._online == 0:
self._update_status(0)
self.schedule_update_ha_state()
def _update_status(self, data_outdated_sec):
if data_outdated_sec > self.__check_interval_sec * self.DEADLINE_PHASE:
self._online = 0
self._attr_native_value = "assume_offline"
else:
self._online = 1
self._attr_native_value = "assume_online"
self._attrs[ATTR_STATUS_LAST_UPDATE] = utcnow()
self._attrs[ATTR_STATUS_UPDATES] = self._attrs[ATTR_STATUS_UPDATES] + 1
self.schedule_update_ha_state()
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
return self._attrs
class QuotasStatusSensorEntity(StatusSensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, client: EcoflowMQTTClient):
super().__init__(client)
async def async_added_to_hass(self):
get_reply_d = self._client.data.get_reply_observable().subscribe(self.__get_reply_update)
self.async_on_remove(get_reply_d.dispose)
await super().async_added_to_hass()
def _update_status(self, update_delta_sec):
if self._client.is_connected():
self._attrs[ATTR_STATUS_UPDATES] = self._attrs[ATTR_STATUS_UPDATES] + 1
self.send_get_message({"version": "1.1", "moduleType": 0, "operateType": "latestQuotas", "params": {}})
else:
super()._update_status(update_delta_sec)
def __get_reply_update(self, data: list[dict[str, Any]]):
d = data[0]
if d["operateType"] == "latestQuotas":
self._online = d["data"]["online"]
self._attrs[ATTR_STATUS_LAST_UPDATE] = utcnow()
if self._online == 1:
self._attrs[ATTR_STATUS_SN] = d["data"]["sn"]
self._attr_native_value = "online"
# ?? self._client.data.update_data(d["data"]["quotaMap"])
else:
self._attr_native_value = "offline"
self.schedule_update_ha_state()

View File

@@ -0,0 +1,73 @@
import logging
from typing import Any
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
from .entities import BaseSwitchEntity
from .mqtt.ecoflow_mqtt import EcoflowMQTTClient
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback):
client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id]
from .devices.registry import devices
async_add_entities(devices[client.device_type].switches(client))
class EnabledEntity(BaseSwitchEntity):
def _update_value(self, val: Any) -> bool:
_LOGGER.debug("Updating switch " + self._attr_unique_id + " to " + str(val))
self._attr_is_on = bool(val)
return True
def turn_on(self, **kwargs: Any) -> None:
if self._command:
self.send_set_message(1, self.command_dict(1))
def turn_off(self, **kwargs: Any) -> None:
if self._command:
self.send_set_message(0, self.command_dict(0))
class DisabledEntity(BaseSwitchEntity):
def _update_value(self, val: Any) -> bool:
_LOGGER.debug("Updating switch " + self._attr_unique_id + " to " + str(val))
self._attr_is_on = not bool(val)
return True
async def async_turn_on(self, **kwargs: Any) -> None:
if self._command:
self.send_set_message(0, self.command_dict(0))
async def async_turn_off(self, **kwargs: Any) -> None:
if self._command:
self.send_set_message(1, self.command_dict(1))
class BeeperEntity(DisabledEntity):
_attr_entity_category = EntityCategory.CONFIG
@property
def icon(self) -> str | None:
if self.is_on:
return "mdi:volume-high"
else:
return "mdi:volume-mute"
class InvertedBeeperEntity(EnabledEntity):
_attr_entity_category = EntityCategory.CONFIG
@property
def icon(self) -> str | None:
if self.is_on:
return "mdi:volume-high"
else:
return "mdi:volume-mute"

View File

@@ -0,0 +1,26 @@
{
"title": "EcoFlow-Cloud",
"config": {
"step": {
"user": {
"data": {
"username": "Benutzer-Email",
"password": "Benutzer-Passwort",
"type": "Gerätetyp",
"name": "Gerätename",
"device_id": "Seriennummer des Gerät"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"power_step": "Ladeleistung (Schritte für Schieberegler)",
"refresh_period_sec": "Datenaktualisierung in Sekunden"
}
}
}
}
}

View File

@@ -0,0 +1,26 @@
{
"title": "EcoFlow-Cloud",
"config": {
"step": {
"user": {
"data": {
"username": "User email",
"password": "User password",
"type": "Device type",
"name": "Device name",
"device_id": "Device SN"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"power_step": "Charging power slider step",
"refresh_period_sec": "Data refresh period (sec)"
}
}
}
}
}

View File

@@ -0,0 +1,27 @@
{
"title": "EcoFlow-Cloud",
"config": {
"step": {
"user": {
"data": {
"username": "Adresse e-mail",
"password": "Mot de passe",
"type": "Type d'appareil",
"name": "Nom de l'appareil",
"device_id": "Numéro de série"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"power_step": "Pas du curseur de puissance de charge",
"refresh_period_sec": "Durée actualisation des données (secondes)"
}
}
}
}
}

View File

@@ -0,0 +1,483 @@
"""
Custom integration to integrate frigate with Home Assistant.
For more details about this integration, please refer to
https://github.com/blakeblackshear/frigate-hass-integration
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import timedelta
import logging
import re
from typing import Any, Final
from awesomeversion import AwesomeVersion
from custom_components.frigate.config_flow import get_config_entry_title
from homeassistant.components.mqtt.models import ReceiveMessage
from homeassistant.components.mqtt.subscription import (
async_prepare_subscribe_topics,
async_subscribe_topics,
async_unsubscribe_topics,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MODEL, CONF_HOST, CONF_URL
from homeassistant.core import Config, HomeAssistant, callback, valid_entity_id
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.loader import async_get_integration
from homeassistant.util import slugify
from .api import FrigateApiClient, FrigateApiClientError
from .const import (
ATTR_CLIENT,
ATTR_CONFIG,
ATTR_COORDINATOR,
ATTRIBUTE_LABELS,
CONF_CAMERA_STATIC_IMAGE_HEIGHT,
DOMAIN,
FRIGATE_RELEASES_URL,
FRIGATE_VERSION_ERROR_CUTOFF,
NAME,
PLATFORMS,
STARTUP_MESSAGE,
STATUS_ERROR,
STATUS_RUNNING,
STATUS_STARTING,
)
from .views import async_setup as views_async_setup
from .ws_api import async_setup as ws_api_async_setup
SCAN_INTERVAL = timedelta(seconds=5)
_LOGGER: logging.Logger = logging.getLogger(__name__)
# Typing notes:
# - The HomeAssistant library does not provide usable type hints for custom
# components. Certain type checks (e.g. decorators and class inheritance) need
# to be marked as ignored or casted, when using the default Home Assistant
# mypy settings. Using the same settings is preferable, to smoothen a future
# migration to Home Assistant Core.
def get_frigate_device_identifier(
entry: ConfigEntry, camera_name: str | None = None
) -> tuple[str, str]:
"""Get a device identifier."""
if camera_name:
return (DOMAIN, f"{entry.entry_id}:{slugify(camera_name)}")
return (DOMAIN, entry.entry_id)
def get_frigate_entity_unique_id(
config_entry_id: str, type_name: str, name: str
) -> str:
"""Get the unique_id for a Frigate entity."""
return f"{config_entry_id}:{type_name}:{name}"
def get_friendly_name(name: str) -> str:
"""Get a friendly version of a name."""
return name.replace("_", " ").title()
def get_cameras(config: dict[str, Any]) -> set[str]:
"""Get cameras."""
cameras = set()
for cam_name, _ in config["cameras"].items():
cameras.add(cam_name)
return cameras
def get_cameras_and_objects(
config: dict[str, Any], include_all: bool = True
) -> set[tuple[str, str]]:
"""Get cameras and tracking object tuples."""
camera_objects = set()
for cam_name, cam_config in config["cameras"].items():
for obj in cam_config["objects"]["track"]:
if obj not in ATTRIBUTE_LABELS:
camera_objects.add((cam_name, obj))
# add an artificial all label to track
# all objects for this camera
if include_all:
camera_objects.add((cam_name, "all"))
return camera_objects
def get_cameras_and_audio(config: dict[str, Any]) -> set[tuple[str, str]]:
"""Get cameras and audio tuples."""
camera_audio = set()
for cam_name, cam_config in config["cameras"].items():
if cam_config.get("audio", {}).get("enabled_in_config", False):
for audio in cam_config.get("audio", {}).get("listen", []):
camera_audio.add((cam_name, audio))
return camera_audio
def get_cameras_zones_and_objects(config: dict[str, Any]) -> set[tuple[str, str]]:
"""Get cameras/zones and tracking object tuples."""
camera_objects = get_cameras_and_objects(config)
zone_objects = set()
for cam_name, obj in camera_objects:
for zone_name in config["cameras"][cam_name]["zones"]:
zone_name_objects = config["cameras"][cam_name]["zones"][zone_name].get(
"objects"
)
if not zone_name_objects or obj in zone_name_objects:
zone_objects.add((zone_name, obj))
# add an artificial all label to track
# all objects for this zone
zone_objects.add((zone_name, "all"))
return camera_objects.union(zone_objects)
def get_cameras_and_zones(config: dict[str, Any]) -> set[str]:
"""Get cameras and zones."""
cameras_zones = set()
for camera in config.get("cameras", {}).keys():
cameras_zones.add(camera)
for zone in config["cameras"][camera].get("zones", {}).keys():
cameras_zones.add(zone)
return cameras_zones
def get_zones(config: dict[str, Any]) -> set[str]:
"""Get zones."""
cameras_zones = set()
for camera in config.get("cameras", {}).keys():
for zone in config["cameras"][camera].get("zones", {}).keys():
cameras_zones.add(zone)
return cameras_zones
def decode_if_necessary(data: str | bytes) -> str:
"""Decode a string if necessary."""
return data.decode("utf-8") if isinstance(data, bytes) else data
async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up this integration using YAML is not supported."""
integration = await async_get_integration(hass, DOMAIN)
_LOGGER.info(
STARTUP_MESSAGE,
NAME,
integration.version,
)
hass.data.setdefault(DOMAIN, {})
ws_api_async_setup(hass)
views_async_setup(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up this integration using UI."""
client = FrigateApiClient(
entry.data.get(CONF_URL),
async_get_clientsession(hass),
)
coordinator = FrigateDataUpdateCoordinator(hass, client=client)
await coordinator.async_config_entry_first_refresh()
try:
server_version = await client.async_get_version()
config = await client.async_get_config()
except FrigateApiClientError as exc:
raise ConfigEntryNotReady from exc
if AwesomeVersion(server_version.split("-")[0]) <= AwesomeVersion(
FRIGATE_VERSION_ERROR_CUTOFF
):
_LOGGER.error(
"Using a Frigate server (%s) with version %s <= %s which is not "
"compatible -- you must upgrade: %s",
entry.data[CONF_URL],
server_version,
FRIGATE_VERSION_ERROR_CUTOFF,
FRIGATE_RELEASES_URL,
)
return False
model = f"{(await async_get_integration(hass, DOMAIN)).version}/{server_version}"
hass.data[DOMAIN][entry.entry_id] = {
ATTR_COORDINATOR: coordinator,
ATTR_CLIENT: client,
ATTR_CONFIG: config,
ATTR_MODEL: model,
}
# Remove old devices associated with cameras that have since been removed
# from the Frigate server, keeping the 'master' device for this config
# entry.
current_devices: set[tuple[str, str]] = set({get_frigate_device_identifier(entry)})
for item in get_cameras_and_zones(config):
current_devices.add(get_frigate_device_identifier(entry, item))
if config.get("birdseye", {}).get("restream", False):
current_devices.add(get_frigate_device_identifier(entry, "birdseye"))
device_registry = dr.async_get(hass)
for device_entry in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
for identifier in device_entry.identifiers:
if identifier in current_devices:
break
else:
device_registry.async_remove_device(device_entry.id)
# Cleanup old clips switch (<v0.9.0) if it exists.
entity_registry = er.async_get(hass)
for camera in config["cameras"].keys():
unique_id = get_frigate_entity_unique_id(
entry.entry_id, SWITCH_DOMAIN, f"{camera}_clips"
)
entity_id = entity_registry.async_get_entity_id(
SWITCH_DOMAIN, DOMAIN, unique_id
)
if entity_id:
entity_registry.async_remove(entity_id)
# Remove old `camera_image_height` option.
if CONF_CAMERA_STATIC_IMAGE_HEIGHT in entry.options:
new_options = entry.options.copy()
new_options.pop(CONF_CAMERA_STATIC_IMAGE_HEIGHT)
hass.config_entries.async_update_entry(entry, options=new_options)
# Cleanup object_motion sensors (replaced with occupancy sensors).
for cam_name, obj_name in get_cameras_zones_and_objects(config):
unique_id = get_frigate_entity_unique_id(
entry.entry_id,
"motion_sensor",
f"{cam_name}_{obj_name}",
)
entity_id = entity_registry.async_get_entity_id(
"binary_sensor", DOMAIN, unique_id
)
if entity_id:
entity_registry.async_remove(entity_id)
# Cleanup camera snapshot entities (replaced with image entities).
for cam_name, obj_name in get_cameras_and_objects(config, False):
unique_id = get_frigate_entity_unique_id(
entry.entry_id,
"camera_snapshots",
f"{cam_name}_{obj_name}",
)
entity_id = entity_registry.async_get_entity_id("camera", DOMAIN, unique_id)
if entity_id:
entity_registry.async_remove(entity_id)
# Rename / change ID of object count sensors.
for cam_name, obj_name in get_cameras_zones_and_objects(config):
unique_id = get_frigate_entity_unique_id(
entry.entry_id,
"sensor_object_count",
f"{cam_name}_{obj_name}",
)
entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id)
new_id = f"sensor.{slugify(cam_name)}_{slugify(obj_name)}_count"
if (
entity_id
and entity_id != new_id
and valid_entity_id(new_id)
and not entity_registry.async_get(new_id)
):
new_name = f"{get_friendly_name(cam_name)} {obj_name} Count".title()
entity_registry.async_update_entity(
entity_id=entity_id,
new_entity_id=new_id,
name=new_name,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
return True
class FrigateDataUpdateCoordinator(DataUpdateCoordinator): # type: ignore[misc]
"""Class to manage fetching data from the API."""
def __init__(self, hass: HomeAssistant, client: FrigateApiClient):
"""Initialize."""
self._api = client
self.server_status: str = STATUS_STARTING
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
try:
stats = await self._api.async_get_stats()
self.server_status = STATUS_RUNNING
return stats
except FrigateApiClientError as exc:
self.server_status = STATUS_ERROR
raise UpdateFailed from exc
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
unload_ok = bool(
await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
)
if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Handle entry updates."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate from v1 entry."""
if config_entry.version == 1:
_LOGGER.debug("Migrating config entry from version '%s'", config_entry.version)
data = {**config_entry.data}
data[CONF_URL] = data.pop(CONF_HOST)
hass.config_entries.async_update_entry(
config_entry, data=data, title=get_config_entry_title(data[CONF_URL])
)
config_entry.version = 2
@callback # type: ignore[misc]
def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
"""Update unique ID of entity entry."""
converters: Final[dict[re.Pattern, Callable[[re.Match], list[str]]]] = {
re.compile(rf"^{DOMAIN}_(?P<cam_obj>\S+)_binary_sensor$"): lambda m: [
"occupancy_sensor",
m.group("cam_obj"),
],
re.compile(rf"^{DOMAIN}_(?P<cam>\S+)_camera$"): lambda m: [
"camera",
m.group("cam"),
],
re.compile(rf"^{DOMAIN}_(?P<cam_obj>\S+)_snapshot$"): lambda m: [
"camera_snapshots",
m.group("cam_obj"),
],
re.compile(rf"^{DOMAIN}_detection_fps$"): lambda m: [
"sensor_fps",
"detection",
],
re.compile(
rf"^{DOMAIN}_(?P<detector>\S+)_inference_speed$"
): lambda m: ["sensor_detector_speed", m.group("detector")],
re.compile(rf"^{DOMAIN}_(?P<cam_fps>\S+)_fps$"): lambda m: [
"sensor_fps",
m.group("cam_fps"),
],
re.compile(rf"^{DOMAIN}_(?P<cam_switch>\S+)_switch$"): lambda m: [
"switch",
m.group("cam_switch"),
],
# Caution: This is a broad but necessary match (keep until last).
re.compile(rf"^{DOMAIN}_(?P<cam_obj>\S+)$"): lambda m: [
"sensor_object_count",
m.group("cam_obj"),
],
}
for regexp, func in converters.items():
match = regexp.match(entity_entry.unique_id)
if match:
args = [config_entry.entry_id] + func(match)
return {"new_unique_id": get_frigate_entity_unique_id(*args)}
return None
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
_LOGGER.debug(
"Migrating config entry to version '%s' successful", config_entry.version
)
return True
class FrigateEntity(Entity): # type: ignore[misc]
"""Base class for Frigate entities."""
_attr_has_entity_name = True
def __init__(self, config_entry: ConfigEntry):
"""Construct a FrigateEntity."""
Entity.__init__(self)
self._config_entry = config_entry
self._available = True
@property
def available(self) -> bool:
"""Return the availability of the entity."""
return self._available and super().available
def _get_model(self) -> str:
"""Get the Frigate device model string."""
return str(self.hass.data[DOMAIN][self._config_entry.entry_id][ATTR_MODEL])
class FrigateMQTTEntity(FrigateEntity):
"""Base class for MQTT-based Frigate entities."""
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
topic_map: dict[str, Any],
) -> None:
"""Construct a FrigateMQTTEntity."""
super().__init__(config_entry)
self._frigate_config = frigate_config
self._sub_state = None
self._available = False
self._topic_map = topic_map
async def async_added_to_hass(self) -> None:
"""Subscribe mqtt events."""
self._topic_map["availability_topic"] = {
"topic": f"{self._frigate_config['mqtt']['topic_prefix']}/available",
"msg_callback": self._availability_message_received,
"qos": 0,
}
state = async_prepare_subscribe_topics(
self.hass,
self._sub_state,
self._topic_map,
)
self._sub_state = await async_subscribe_topics(self.hass, state)
await super().async_added_to_hass()
async def async_will_remove_from_hass(self) -> None:
"""Cleanup prior to hass removal."""
async_unsubscribe_topics(self.hass, self._sub_state)
self._sub_state = None
await super().async_will_remove_from_hass()
@callback # type: ignore[misc]
def _availability_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT availability message."""
self._available = decode_if_necessary(msg.payload) == "online"
self.async_write_ha_state()

View File

@@ -0,0 +1,264 @@
"""Frigate API client."""
from __future__ import annotations
import asyncio
import logging
import socket
from typing import Any, cast
import aiohttp
import async_timeout
from yarl import URL
TIMEOUT = 10
_LOGGER: logging.Logger = logging.getLogger(__name__)
HEADERS = {"Content-type": "application/json; charset=UTF-8"}
# ==============================================================================
# Please do not add HomeAssistant specific imports/functionality to this module,
# so that this library can be optionally moved to a different repo at a later
# date.
# ==============================================================================
class FrigateApiClientError(Exception):
"""General FrigateApiClient error."""
class FrigateApiClient:
"""Frigate API client."""
def __init__(self, host: str, session: aiohttp.ClientSession) -> None:
"""Construct API Client."""
self._host = host
self._session = session
async def async_get_version(self) -> str:
"""Get data from the API."""
return cast(
str,
await self.api_wrapper(
"get", str(URL(self._host) / "api/version"), decode_json=False
),
)
async def async_get_stats(self) -> dict[str, Any]:
"""Get data from the API."""
return cast(
dict[str, Any],
await self.api_wrapper("get", str(URL(self._host) / "api/stats")),
)
async def async_get_events(
self,
cameras: list[str] | None = None,
labels: list[str] | None = None,
sub_labels: list[str] | None = None,
zones: list[str] | None = None,
after: int | None = None,
before: int | None = None,
limit: int | None = None,
has_clip: bool | None = None,
has_snapshot: bool | None = None,
favorites: bool | None = None,
decode_json: bool = True,
) -> list[dict[str, Any]]:
"""Get data from the API."""
params = {
"cameras": ",".join(cameras) if cameras else None,
"labels": ",".join(labels) if labels else None,
"sub_labels": ",".join(sub_labels) if sub_labels else None,
"zones": ",".join(zones) if zones else None,
"after": after,
"before": before,
"limit": limit,
"has_clip": int(has_clip) if has_clip is not None else None,
"has_snapshot": int(has_snapshot) if has_snapshot is not None else None,
"include_thumbnails": 0,
"favorites": int(favorites) if favorites is not None else None,
}
return cast(
list[dict[str, Any]],
await self.api_wrapper(
"get",
str(
URL(self._host)
/ "api/events"
% {k: v for k, v in params.items() if v is not None}
),
decode_json=decode_json,
),
)
async def async_get_event_summary(
self,
has_clip: bool | None = None,
has_snapshot: bool | None = None,
timezone: str | None = None,
decode_json: bool = True,
) -> list[dict[str, Any]]:
"""Get data from the API."""
params = {
"has_clip": int(has_clip) if has_clip is not None else None,
"has_snapshot": int(has_snapshot) if has_snapshot is not None else None,
"timezone": str(timezone) if timezone is not None else None,
}
return cast(
list[dict[str, Any]],
await self.api_wrapper(
"get",
str(
URL(self._host)
/ "api/events/summary"
% {k: v for k, v in params.items() if v is not None}
),
decode_json=decode_json,
),
)
async def async_get_config(self) -> dict[str, Any]:
"""Get data from the API."""
return cast(
dict[str, Any],
await self.api_wrapper("get", str(URL(self._host) / "api/config")),
)
async def async_get_ptz_info(
self,
camera: str,
decode_json: bool = True,
) -> Any:
"""Get PTZ info."""
return await self.api_wrapper(
"get",
str(URL(self._host) / "api" / camera / "ptz/info"),
decode_json=decode_json,
)
async def async_get_path(self, path: str) -> Any:
"""Get data from the API."""
return await self.api_wrapper("get", str(URL(self._host) / f"{path}/"))
async def async_retain(
self, event_id: str, retain: bool, decode_json: bool = True
) -> dict[str, Any] | str:
"""Un/Retain an event."""
result = await self.api_wrapper(
"post" if retain else "delete",
str(URL(self._host) / f"api/events/{event_id}/retain"),
decode_json=decode_json,
)
return cast(dict[str, Any], result) if decode_json else result
async def async_export_recording(
self,
camera: str,
playback_factor: str,
start_time: float,
end_time: float,
decode_json: bool = True,
) -> dict[str, Any] | str:
"""Export recording."""
result = await self.api_wrapper(
"post",
str(
URL(self._host)
/ f"api/export/{camera}/start/{start_time}/end/{end_time}"
),
data={"playback": playback_factor},
decode_json=decode_json,
)
return cast(dict[str, Any], result) if decode_json else result
async def async_get_recordings_summary(
self, camera: str, timezone: str, decode_json: bool = True
) -> list[dict[str, Any]] | str:
"""Get recordings summary."""
params = {"timezone": timezone}
result = await self.api_wrapper(
"get",
str(
URL(self._host)
/ f"api/{camera}/recordings/summary"
% {k: v for k, v in params.items() if v is not None}
),
decode_json=decode_json,
)
return cast(list[dict[str, Any]], result) if decode_json else result
async def async_get_recordings(
self,
camera: str,
after: int | None = None,
before: int | None = None,
decode_json: bool = True,
) -> dict[str, Any] | str:
"""Get recordings."""
params = {
"after": after,
"before": before,
}
result = await self.api_wrapper(
"get",
str(
URL(self._host)
/ f"api/{camera}/recordings"
% {k: v for k, v in params.items() if v is not None}
),
decode_json=decode_json,
)
return cast(dict[str, Any], result) if decode_json else result
async def api_wrapper(
self,
method: str,
url: str,
data: dict | None = None,
headers: dict | None = None,
decode_json: bool = True,
) -> Any:
"""Get information from the API."""
if data is None:
data = {}
if headers is None:
headers = {}
try:
async with async_timeout.timeout(TIMEOUT):
func = getattr(self._session, method)
if func:
response = await func(
url, headers=headers, raise_for_status=True, json=data
)
if decode_json:
return await response.json()
return await response.text()
except asyncio.TimeoutError as exc:
_LOGGER.error(
"Timeout error fetching information from %s: %s",
url,
exc,
)
raise FrigateApiClientError from exc
except (KeyError, TypeError) as exc:
_LOGGER.error(
"Error parsing information from %s: %s",
url,
exc,
)
raise FrigateApiClientError from exc
except (aiohttp.ClientError, socket.gaierror) as exc:
_LOGGER.error(
"Error fetching information from %s: %s",
url,
exc,
)
raise FrigateApiClientError from exc

View File

@@ -0,0 +1,303 @@
"""Binary sensor platform for Frigate."""
from __future__ import annotations
import logging
from typing import Any, cast
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
FrigateMQTTEntity,
ReceiveMessage,
decode_if_necessary,
get_cameras,
get_cameras_and_audio,
get_cameras_zones_and_objects,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
get_zones,
)
from .const import ATTR_CONFIG, DOMAIN, NAME
from .icons import get_dynamic_icon_from_type
_LOGGER: logging.Logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Binary sensor entry setup."""
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
entities = []
# add object sensors for cameras and zones
entities.extend(
[
FrigateObjectOccupancySensor(entry, frigate_config, cam_name, obj)
for cam_name, obj in get_cameras_zones_and_objects(frigate_config)
]
)
# add audio sensors for cameras
entities.extend(
[
FrigateAudioSensor(entry, frigate_config, cam_name, audio)
for cam_name, audio in get_cameras_and_audio(frigate_config)
]
)
# add generic motion sensors for cameras
entities.extend(
[
FrigateMotionSensor(entry, frigate_config, cam_name)
for cam_name in get_cameras(frigate_config)
]
)
async_add_entities(entities)
class FrigateObjectOccupancySensor(FrigateMQTTEntity, BinarySensorEntity): # type: ignore[misc]
"""Frigate Occupancy Sensor class."""
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
obj_name: str,
) -> None:
"""Construct a new FrigateObjectOccupancySensor."""
self._cam_name = cam_name
self._obj_name = obj_name
self._is_on = False
self._frigate_config = frigate_config
super().__init__(
config_entry,
frigate_config,
{
"state_topic": {
"msg_callback": self._state_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/{self._obj_name}"
),
"encoding": None,
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
try:
self._is_on = int(msg.payload) > 0
except ValueError:
self._is_on = False
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"occupancy_sensor",
f"{self._cam_name}_{self._obj_name}",
)
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name if self._cam_name not in get_zones(self._frigate_config) else ''}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._obj_name} occupancy"
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._is_on
@property
def device_class(self) -> str:
"""Return the device class."""
return cast(str, BinarySensorDeviceClass.OCCUPANCY)
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return get_dynamic_icon_from_type(self._obj_name, self._is_on)
class FrigateAudioSensor(FrigateMQTTEntity, BinarySensorEntity): # type: ignore[misc]
"""Frigate Audio Sensor class."""
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
audio_name: str,
) -> None:
"""Construct a new FrigateAudioSensor."""
self._cam_name = cam_name
self._audio_name = audio_name
self._is_on = False
self._frigate_config = frigate_config
super().__init__(
config_entry,
frigate_config,
{
"state_topic": {
"msg_callback": self._state_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/audio/{self._audio_name}"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
self._is_on = decode_if_necessary(msg.payload) == "ON"
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"audio_sensor",
f"{self._cam_name}_{self._audio_name}",
)
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._audio_name} sound"
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._is_on
@property
def device_class(self) -> str:
"""Return the device class."""
return cast(str, BinarySensorDeviceClass.SOUND)
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return get_dynamic_icon_from_type("sound", self._is_on)
class FrigateMotionSensor(FrigateMQTTEntity, BinarySensorEntity): # type: ignore[misc]
"""Frigate Motion Sensor class."""
_attr_name = "Motion"
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
) -> None:
"""Construct a new FrigateMotionSensor."""
self._cam_name = cam_name
self._is_on = False
self._frigate_config = frigate_config
super().__init__(
config_entry,
frigate_config,
{
"state_topic": {
"msg_callback": self._state_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/motion"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
self._is_on = decode_if_necessary(msg.payload) == "ON"
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"motion_sensor",
f"{self._cam_name}",
)
@property
def device_info(self) -> dict[str, Any]:
"""Return device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name if self._cam_name not in get_zones(self._frigate_config) else ''}",
"manufacturer": NAME,
}
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._is_on
@property
def device_class(self) -> str:
"""Return the device class."""
return cast(str, BinarySensorDeviceClass.MOTION)

View File

@@ -0,0 +1,465 @@
"""Support for Frigate cameras."""
from __future__ import annotations
import datetime
import logging
from typing import Any, cast
import aiohttp
import async_timeout
from jinja2 import Template
import voluptuous as vol
from yarl import URL
from custom_components.frigate.api import FrigateApiClient
from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType
from homeassistant.components.mqtt import async_publish
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import (
FrigateDataUpdateCoordinator,
FrigateEntity,
FrigateMQTTEntity,
ReceiveMessage,
decode_if_necessary,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
)
from .const import (
ATTR_CLIENT,
ATTR_CONFIG,
ATTR_COORDINATOR,
ATTR_END_TIME,
ATTR_EVENT_ID,
ATTR_FAVORITE,
ATTR_PLAYBACK_FACTOR,
ATTR_PTZ_ACTION,
ATTR_PTZ_ARGUMENT,
ATTR_START_TIME,
CONF_ENABLE_WEBRTC,
CONF_RTMP_URL_TEMPLATE,
CONF_RTSP_URL_TEMPLATE,
DEVICE_CLASS_CAMERA,
DOMAIN,
NAME,
SERVICE_EXPORT_RECORDING,
SERVICE_FAVORITE_EVENT,
SERVICE_PTZ,
)
from .views import get_frigate_instance_id_for_config_entry
_LOGGER: logging.Logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Camera entry setup."""
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
frigate_client = hass.data[DOMAIN][entry.entry_id][ATTR_CLIENT]
client_id = get_frigate_instance_id_for_config_entry(hass, entry)
coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR]
async_add_entities(
[
FrigateCamera(
entry,
cam_name,
frigate_client,
client_id,
coordinator,
frigate_config,
camera_config,
)
for cam_name, camera_config in frigate_config["cameras"].items()
]
+ (
[BirdseyeCamera(entry, frigate_client)]
if frigate_config.get("birdseye", {}).get("restream", False)
else []
)
)
# setup services
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_EXPORT_RECORDING,
{
vol.Required(ATTR_PLAYBACK_FACTOR, default="realtime"): str,
vol.Required(ATTR_START_TIME): str,
vol.Required(ATTR_END_TIME): str,
},
SERVICE_EXPORT_RECORDING,
)
platform.async_register_entity_service(
SERVICE_FAVORITE_EVENT,
{
vol.Required(ATTR_EVENT_ID): str,
vol.Optional(ATTR_FAVORITE, default=True): bool,
},
SERVICE_FAVORITE_EVENT,
)
platform.async_register_entity_service(
SERVICE_PTZ,
{
vol.Required(ATTR_PTZ_ACTION): str,
vol.Optional(ATTR_PTZ_ARGUMENT, default=""): str,
},
SERVICE_PTZ,
)
class FrigateCamera(FrigateMQTTEntity, CoordinatorEntity, Camera): # type: ignore[misc]
"""Representation of a Frigate camera."""
# sets the entity name to same as device name ex: camera.front_doorbell
_attr_name = None
def __init__(
self,
config_entry: ConfigEntry,
cam_name: str,
frigate_client: FrigateApiClient,
frigate_client_id: Any | None,
coordinator: FrigateDataUpdateCoordinator,
frigate_config: dict[str, Any],
camera_config: dict[str, Any],
) -> None:
"""Initialize a Frigate camera."""
self._client = frigate_client
self._client_id = frigate_client_id
self._frigate_config = frigate_config
self._camera_config = camera_config
self._cam_name = cam_name
super().__init__(
config_entry,
frigate_config,
{
"state_topic": {
"msg_callback": self._state_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/recordings/state"
),
"encoding": None,
},
"motion_topic": {
"msg_callback": self._motion_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/motion/state"
),
"encoding": None,
},
},
)
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
Camera.__init__(self)
self._url = config_entry.data[CONF_URL]
self._attr_is_on = True
# The device_class is used to filter out regular camera entities
# from motion camera entities on selectors
self._attr_device_class = DEVICE_CLASS_CAMERA
self._stream_source = None
self._attr_is_streaming = (
self._camera_config.get("rtmp", {}).get("enabled")
or self._cam_name
in self._frigate_config.get("go2rtc", {}).get("streams", {}).keys()
)
self._attr_is_recording = self._camera_config.get("record", {}).get("enabled")
self._attr_motion_detection_enabled = self._camera_config.get("motion", {}).get(
"enabled"
)
self._ptz_topic = (
f"{frigate_config['mqtt']['topic_prefix']}" f"/{self._cam_name}/ptz"
)
self._set_motion_topic = (
f"{frigate_config['mqtt']['topic_prefix']}" f"/{self._cam_name}/motion/set"
)
if (
self._cam_name
in self._frigate_config.get("go2rtc", {}).get("streams", {}).keys()
):
if config_entry.options.get(CONF_ENABLE_WEBRTC, False):
self._restream_type = "webrtc"
self._attr_frontend_stream_type = StreamType.WEB_RTC
else:
self._restream_type = "rtsp"
self._attr_frontend_stream_type = StreamType.HLS
streaming_template = config_entry.options.get(
CONF_RTSP_URL_TEMPLATE, ""
).strip()
if streaming_template:
# Can't use homeassistant.helpers.template as it requires hass which
# is not available in the constructor, so use direct jinja2
# template instead. This means templates cannot access HomeAssistant
# state, but rather only the camera config.
self._stream_source = Template(streaming_template).render(
**self._camera_config
)
else:
self._stream_source = (
f"rtsp://{URL(self._url).host}:8554/{self._cam_name}"
)
elif self._camera_config.get("rtmp", {}).get("enabled"):
self._restream_type = "rtmp"
streaming_template = config_entry.options.get(
CONF_RTMP_URL_TEMPLATE, ""
).strip()
if streaming_template:
# Can't use homeassistant.helpers.template as it requires hass which
# is not available in the constructor, so use direct jinja2
# template instead. This means templates cannot access HomeAssistant
# state, but rather only the camera config.
self._stream_source = Template(streaming_template).render(
**self._camera_config
)
else:
self._stream_source = (
f"rtmp://{URL(self._url).host}/live/{self._cam_name}"
)
else:
self._restream_type = "none"
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
self._attr_is_recording = decode_if_necessary(msg.payload) == "ON"
self.async_write_ha_state()
@callback # type: ignore[misc]
def _motion_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT extra message."""
self._attr_motion_detection_enabled = decode_if_necessary(msg.payload) == "ON"
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Signal when frigate loses connection to camera."""
if self.coordinator.data:
if (
self.coordinator.data.get("cameras", {})
.get(self._cam_name, {})
.get("camera_fps", 0)
== 0
):
return False
return super().available
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"camera",
self._cam_name,
)
@property
def device_info(self) -> dict[str, Any]:
"""Return the device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._url}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return entity specific state attributes."""
return {
"client_id": str(self._client_id),
"camera_name": self._cam_name,
"restream_type": self._restream_type,
}
@property
def supported_features(self) -> CameraEntityFeature:
"""Return supported features of this camera."""
if not self._attr_is_streaming:
return CameraEntityFeature(0)
return CameraEntityFeature.STREAM
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return bytes of camera image."""
websession = cast(aiohttp.ClientSession, async_get_clientsession(self.hass))
image_url = str(
URL(self._url)
/ f"api/{self._cam_name}/latest.jpg"
% ({"h": height} if height is not None and height > 0 else {})
)
async with async_timeout.timeout(10):
response = await websession.get(image_url)
return await response.read()
async def stream_source(self) -> str | None:
"""Return the source of the stream."""
if not self._attr_is_streaming:
return None
return self._stream_source
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
"""Handle the WebRTC offer and return an answer."""
websession = cast(aiohttp.ClientSession, async_get_clientsession(self.hass))
url = f"{self._url}/api/go2rtc/webrtc?src={self._cam_name}"
payload = {"type": "offer", "sdp": offer_sdp}
async with websession.post(url, json=payload) as resp:
answer = await resp.json()
return cast(str, answer["sdp"])
async def async_enable_motion_detection(self) -> None:
"""Enable motion detection for this camera."""
await async_publish(
self.hass,
self._set_motion_topic,
"ON",
0,
False,
)
async def async_disable_motion_detection(self) -> None:
"""Disable motion detection for this camera."""
await async_publish(
self.hass,
self._set_motion_topic,
"OFF",
0,
False,
)
async def export_recording(
self, playback_factor: str, start_time: str, end_time: str
) -> None:
"""Export recording."""
await self._client.async_export_recording(
self._cam_name,
playback_factor,
datetime.datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S").timestamp(),
datetime.datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S").timestamp(),
)
async def favorite_event(self, event_id: str, favorite: bool) -> None:
"""Favorite an event."""
await self._client.async_retain(event_id, favorite)
async def ptz(self, action: str, argument: str) -> None:
"""Run PTZ command."""
await async_publish(
self.hass,
self._ptz_topic,
f"{action}{f'_{argument}' if argument else ''}",
0,
False,
)
class BirdseyeCamera(FrigateEntity, Camera): # type: ignore[misc]
"""Representation of the Frigate birdseye camera."""
# sets the entity name to same as device name ex: camera.front_doorbell
_attr_name = None
def __init__(
self,
config_entry: ConfigEntry,
frigate_client: FrigateApiClient,
) -> None:
"""Initialize the birdseye camera."""
self._client = frigate_client
FrigateEntity.__init__(self, config_entry)
Camera.__init__(self)
self._url = config_entry.data[CONF_URL]
self._attr_is_on = True
# The device_class is used to filter out regular camera entities
# from motion camera entities on selectors
self._attr_device_class = DEVICE_CLASS_CAMERA
self._attr_is_streaming = True
self._attr_is_recording = False
streaming_template = config_entry.options.get(
CONF_RTSP_URL_TEMPLATE, ""
).strip()
if streaming_template:
# Can't use homeassistant.helpers.template as it requires hass which
# is not available in the constructor, so use direct jinja2
# template instead. This means templates cannot access HomeAssistant
# state, but rather only the camera config.
self._stream_source = Template(streaming_template).render(
{"name": "birdseye"}
)
else:
self._stream_source = f"rtsp://{URL(self._url).host}:8554/birdseye"
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"camera",
"birdseye",
)
@property
def device_info(self) -> dict[str, Any]:
"""Return the device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, "birdseye")
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": "Birdseye",
"model": self._get_model(),
"configuration_url": f"{self._url}/cameras/birdseye",
"manufacturer": NAME,
}
@property
def supported_features(self) -> CameraEntityFeature:
"""Return supported features of this camera."""
return CameraEntityFeature.STREAM
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return bytes of camera image."""
websession = cast(aiohttp.ClientSession, async_get_clientsession(self.hass))
image_url = str(
URL(self._url)
/ "api/birdseye/latest.jpg"
% ({"h": height} if height is not None and height > 0 else {})
)
async with async_timeout.timeout(10):
response = await websession.get(image_url)
return await response.read()
async def stream_source(self) -> str | None:
"""Return the source of the stream."""
return self._stream_source

View File

@@ -0,0 +1,192 @@
"""Adds config flow for Frigate."""
from __future__ import annotations
import logging
from typing import Any, Dict, cast
import voluptuous as vol
from voluptuous.validators import All, Range
from yarl import URL
from homeassistant import config_entries
from homeassistant.const import CONF_URL
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .api import FrigateApiClient, FrigateApiClientError
from .const import (
CONF_ENABLE_WEBRTC,
CONF_MEDIA_BROWSER_ENABLE,
CONF_NOTIFICATION_PROXY_ENABLE,
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS,
CONF_RTMP_URL_TEMPLATE,
CONF_RTSP_URL_TEMPLATE,
DEFAULT_HOST,
DOMAIN,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
def get_config_entry_title(url_str: str) -> str:
"""Get the title of a config entry from the URL."""
# Strip the scheme from the URL as it's not that interesting in the title
# and space is limited on the integrations page.
url = URL(url_str)
return str(url)[len(url.scheme + "://") :]
class FrigateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg,misc]
"""Config flow for Frigate."""
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Handle a flow initialized by the user."""
if user_input is None:
return self._show_config_form()
try:
# Cannot use cv.url validation in the schema itself, so
# apply extra validation here.
cv.url(user_input[CONF_URL])
except vol.Invalid:
return self._show_config_form(user_input, errors={"base": "invalid_url"})
try:
session = async_create_clientsession(self.hass)
client = FrigateApiClient(user_input[CONF_URL], session)
await client.async_get_stats()
except FrigateApiClientError:
return self._show_config_form(user_input, errors={"base": "cannot_connect"})
# Search for duplicates with the same Frigate CONF_HOST value.
for existing_entry in self._async_current_entries(include_ignore=False):
if existing_entry.data.get(CONF_URL) == user_input[CONF_URL]:
return cast(
Dict[str, Any], self.async_abort(reason="already_configured")
)
return cast(
Dict[str, Any],
self.async_create_entry(
title=get_config_entry_title(user_input[CONF_URL]), data=user_input
),
)
def _show_config_form(
self,
user_input: dict[str, Any] | None = None,
errors: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Show the configuration form."""
if user_input is None:
user_input = {}
return cast(
Dict[str, Any],
self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_URL, default=user_input.get(CONF_URL, DEFAULT_HOST)
): str
}
),
errors=errors,
),
)
@staticmethod
@callback # type: ignore[misc]
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> FrigateOptionsFlowHandler:
"""Get the Frigate Options flow."""
return FrigateOptionsFlowHandler(config_entry)
class FrigateOptionsFlowHandler(config_entries.OptionsFlow): # type: ignore[misc]
"""Frigate options flow."""
def __init__(self, config_entry: config_entries.ConfigEntry):
"""Initialize a Frigate options flow."""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Manage the options."""
if user_input is not None:
return cast(
Dict[str, Any], self.async_create_entry(title="", data=user_input)
)
if not self.show_advanced_options:
return cast(
Dict[str, Any], self.async_abort(reason="only_advanced_options")
)
schema: dict[Any, Any] = {
# Whether to enable webrtc as the medium for camera streaming
vol.Optional(
CONF_ENABLE_WEBRTC,
default=self._config_entry.options.get(
CONF_ENABLE_WEBRTC,
False,
),
): bool,
# The input URL is not validated as being a URL to allow for the
# possibility the template input won't be a valid URL until after
# it's rendered.
vol.Optional(
CONF_RTMP_URL_TEMPLATE,
default=self._config_entry.options.get(
CONF_RTMP_URL_TEMPLATE,
"",
),
): str,
# The input URL is not validated as being a URL to allow for the
# possibility the template input won't be a valid URL until after
# it's rendered.
vol.Optional(
CONF_RTSP_URL_TEMPLATE,
default=self._config_entry.options.get(
CONF_RTSP_URL_TEMPLATE,
"",
),
): str,
vol.Optional(
CONF_NOTIFICATION_PROXY_ENABLE,
default=self._config_entry.options.get(
CONF_NOTIFICATION_PROXY_ENABLE,
True,
),
): bool,
vol.Optional(
CONF_MEDIA_BROWSER_ENABLE,
default=self._config_entry.options.get(
CONF_MEDIA_BROWSER_ENABLE,
True,
),
): bool,
vol.Optional(
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS,
default=self._config_entry.options.get(
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS,
0,
),
): All(int, Range(min=0)),
}
return cast(
Dict[str, Any],
self.async_show_form(step_id="init", data_schema=vol.Schema(schema)),
)

View File

@@ -0,0 +1,93 @@
"""Constants for frigate."""
# Base component constants
NAME = "Frigate"
DOMAIN = "frigate"
FRIGATE_VERSION_ERROR_CUTOFF = "0.12.1"
FRIGATE_RELEASES_URL = "https://github.com/blakeblackshear/frigate/releases"
FRIGATE_RELEASE_TAG_URL = f"{FRIGATE_RELEASES_URL}/tag"
# Platforms
BINARY_SENSOR = "binary_sensor"
NUMBER = "number"
SENSOR = "sensor"
SWITCH = "switch"
CAMERA = "camera"
IMAGE = "image"
UPDATE = "update"
PLATFORMS = [SENSOR, CAMERA, IMAGE, NUMBER, SWITCH, BINARY_SENSOR, UPDATE]
# Device Classes
# This device class does not exist in HA, but we use it to be able
# to filter cameras in selectors
DEVICE_CLASS_CAMERA = "camera"
# Unit of measurement
FPS = "fps"
MS = "ms"
# Attributes
ATTR_CLIENT = "client"
ATTR_CLIENT_ID = "client_id"
ATTR_CONFIG = "config"
ATTR_COORDINATOR = "coordinator"
ATTR_END_TIME = "end_time"
ATTR_EVENT_ID = "event_id"
ATTR_FAVORITE = "favorite"
ATTR_MQTT = "mqtt"
ATTR_PLAYBACK_FACTOR = "playback_factor"
ATTR_PTZ_ACTION = "action"
ATTR_PTZ_ARGUMENT = "argument"
ATTR_START_TIME = "start_time"
# Frigate Attribute Labels
# These are labels that are not individually tracked as they are
# attributes of another label. ex: face is an attribute of person
ATTRIBUTE_LABELS = ["amazon", "face", "fedex", "license_plate", "ups"]
# Configuration and options
CONF_CAMERA_STATIC_IMAGE_HEIGHT = "camera_image_height"
CONF_MEDIA_BROWSER_ENABLE = "media_browser_enable"
CONF_NOTIFICATION_PROXY_ENABLE = "notification_proxy_enable"
CONF_PASSWORD = "password"
CONF_PATH = "path"
CONF_RTMP_URL_TEMPLATE = "rtmp_url_template"
CONF_RTSP_URL_TEMPLATE = "rtsp_url_template"
CONF_ENABLE_WEBRTC = "enable_webrtc"
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS = "notification_proxy_expire_after_seconds"
# Defaults
DEFAULT_NAME = DOMAIN
DEFAULT_HOST = "http://ccab4aaf-frigate:5000"
STARTUP_MESSAGE = """
-------------------------------------------------------------------
%s
Integration Version: %s
This is a custom integration!
If you have any issues with this you need to open an issue here:
https://github.com/blakeblackshear/frigate-hass-integration/issues
-------------------------------------------------------------------
"""
# Min Values
MAX_CONTOUR_AREA = 50
MAX_THRESHOLD = 255
# Min Values
MIN_CONTOUR_AREA = 1
MIN_THRESHOLD = 1
# States
STATE_DETECTED = "active"
STATE_IDLE = "idle"
# Statuses
STATUS_ERROR = "error"
STATUS_RUNNING = "running"
STATUS_STARTING = "starting"
# Frigate Services
SERVICE_EXPORT_RECORDING = "export_recording"
SERVICE_FAVORITE_EVENT = "favorite_event"
SERVICE_PTZ = "ptz"

View File

@@ -0,0 +1,37 @@
"""Diagnostics support for Frigate."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import ATTR_CLIENT, ATTR_CONFIG, CONF_PASSWORD, CONF_PATH, DOMAIN
REDACT_CONFIG = {CONF_PASSWORD, CONF_PATH}
def get_redacted_data(data: dict[str, Any]) -> Any:
"""Redact sensitive vales from data."""
return async_redact_data(data, REDACT_CONFIG)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
redacted_config = get_redacted_data(config)
stats = await hass.data[DOMAIN][entry.entry_id][ATTR_CLIENT].async_get_stats()
redacted_stats = get_redacted_data(stats)
data = {
"frigate_config": redacted_config,
"frigate_stats": redacted_stats,
}
return data

View File

@@ -0,0 +1,80 @@
"""Handles icons for different entity types."""
ICON_AUDIO = "mdi:ear-hearing"
ICON_AUDIO_OFF = "mdi:ear-hearing-off"
ICON_PTZ_AUTOTRACKER = "mdi:cctv"
ICON_BICYCLE = "mdi:bicycle"
ICON_CAR = "mdi:car"
ICON_CAT = "mdi:cat"
ICON_CONTRAST = "mdi:contrast-circle"
ICON_CORAL = "mdi:scoreboard-outline"
ICON_COW = "mdi:cow"
ICON_DOG = "mdi:dog-side"
ICON_FILM_MULTIPLE = "mdi:filmstrip-box-multiple"
ICON_HORSE = "mdi:horse"
ICON_IMAGE_MULTIPLE = "mdi:image-multiple"
ICON_MOTION_SENSOR = "mdi:motion-sensor"
ICON_MOTORCYCLE = "mdi:motorbike"
ICON_OTHER = "mdi:shield-alert"
ICON_PERSON = "mdi:human"
ICON_SERVER = "mdi:server"
ICON_SPEEDOMETER = "mdi:speedometer"
ICON_WAVEFORM = "mdi:waveform"
ICON_DEFAULT_ON = "mdi:home"
ICON_CAR_OFF = "mdi:car-off"
ICON_DEFAULT_OFF = "mdi:home-outline"
ICON_DOG_OFF = "mdi:dog-side-off"
def get_dynamic_icon_from_type(obj_type: str, is_on: bool) -> str:
"""Get icon for a specific object type and current state."""
if obj_type == "car":
return ICON_CAR if is_on else ICON_CAR_OFF
if obj_type == "dog":
return ICON_DOG if is_on else ICON_DOG_OFF
if obj_type == "sound":
return ICON_AUDIO if is_on else ICON_AUDIO_OFF
return ICON_DEFAULT_ON if is_on else ICON_DEFAULT_OFF
def get_icon_from_switch(switch_type: str) -> str:
"""Get icon for a specific switch type."""
if switch_type == "snapshots":
return ICON_IMAGE_MULTIPLE
if switch_type == "recordings":
return ICON_FILM_MULTIPLE
if switch_type == "improve_contrast":
return ICON_CONTRAST
if switch_type == "audio":
return ICON_AUDIO
if switch_type == "ptz_autotracker":
return ICON_PTZ_AUTOTRACKER
return ICON_MOTION_SENSOR
def get_icon_from_type(obj_type: str) -> str:
"""Get icon for a specific object type."""
if obj_type == "person":
return ICON_PERSON
if obj_type == "car":
return ICON_CAR
if obj_type == "dog":
return ICON_DOG
if obj_type == "cat":
return ICON_CAT
if obj_type == "motorcycle":
return ICON_MOTORCYCLE
if obj_type == "bicycle":
return ICON_BICYCLE
if obj_type == "cow":
return ICON_COW
if obj_type == "horse":
return ICON_HORSE
return ICON_OTHER

View File

@@ -0,0 +1,123 @@
"""Support for Frigate images."""
from __future__ import annotations
import datetime
import logging
from typing import Any
from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
FrigateMQTTEntity,
ReceiveMessage,
get_cameras_and_objects,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
)
from .const import ATTR_CONFIG, DOMAIN, NAME
_LOGGER: logging.Logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Image entry setup."""
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
async_add_entities(
[
FrigateMqttSnapshots(hass, entry, frigate_config, cam_name, obj_name)
for cam_name, obj_name in get_cameras_and_objects(frigate_config, False)
]
)
class FrigateMqttSnapshots(FrigateMQTTEntity, ImageEntity): # type: ignore[misc]
"""Frigate best image class."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
obj_name: str,
) -> None:
"""Construct a FrigateMqttSnapshots image."""
self._frigate_config = frigate_config
self._cam_name = cam_name
self._obj_name = obj_name
self._last_image_timestamp: datetime.datetime | None = None
self._last_image: bytes | None = None
FrigateMQTTEntity.__init__(
self,
config_entry,
frigate_config,
{
"state_topic": {
"msg_callback": self._state_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/{self._obj_name}/snapshot"
),
"encoding": None,
},
},
)
ImageEntity.__init__(self, hass)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
self._last_image_timestamp = datetime.datetime.now()
self._last_image = msg.payload
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"image_best_snapshot",
f"{self._cam_name}_{self._obj_name}",
)
@property
def device_info(self) -> DeviceInfo:
"""Get the device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return self._obj_name.title()
@property
def image_last_updated(self) -> datetime.datetime | None:
"""Return timestamp of last image update."""
return self._last_image_timestamp
def image(
self,
) -> bytes | None: # pragma: no cover (HA currently does not support a direct way to test this)
"""Return bytes of image."""
return self._last_image

View File

@@ -0,0 +1,18 @@
{
"domain": "frigate",
"name": "Frigate",
"codeowners": [
"@blakeblackshear"
],
"config_flow": true,
"dependencies": [
"http",
"media_source",
"mqtt"
],
"documentation": "https://github.com/blakeblackshear/frigate",
"iot_class": "local_push",
"issue_tracker": "https://github.com/blakeblackshear/frigate-hass-integration/issues",
"requirements": ["pytz"],
"version": "5.2.0"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
"""Number platform for frigate."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.mqtt import async_publish
from homeassistant.components.number import NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
FrigateMQTTEntity,
ReceiveMessage,
get_cameras,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
)
from .const import (
ATTR_CONFIG,
DOMAIN,
MAX_CONTOUR_AREA,
MAX_THRESHOLD,
MIN_CONTOUR_AREA,
MIN_THRESHOLD,
NAME,
)
from .icons import ICON_SPEEDOMETER
_LOGGER: logging.Logger = logging.getLogger(__name__)
CAMERA_FPS_TYPES = ["camera", "detection", "process", "skipped"]
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Sensor entry setup."""
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
entities = []
# add motion configurations for cameras
for cam_name in get_cameras(frigate_config):
entities.extend(
[FrigateMotionContourArea(entry, frigate_config, cam_name, False)]
)
entities.extend(
[FrigateMotionThreshold(entry, frigate_config, cam_name, False)]
)
async_add_entities(entities)
class FrigateMotionContourArea(FrigateMQTTEntity, NumberEntity): # type: ignore[misc]
"""FrigateMotionContourArea class."""
_attr_entity_category = EntityCategory.CONFIG
_attr_name = "Contour area"
_attr_native_min_value = MIN_CONTOUR_AREA
_attr_native_max_value = MAX_CONTOUR_AREA
_attr_native_step = 1
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
default_enabled: bool,
) -> None:
"""Construct a FrigateNumber."""
self._frigate_config = frigate_config
self._cam_name = cam_name
self._attr_native_value = float(
self._frigate_config["cameras"][self._cam_name]["motion"]["contour_area"]
)
self._command_topic = (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/motion_contour_area/set"
)
self._attr_entity_registry_enabled_default = default_enabled
super().__init__(
config_entry,
frigate_config,
{
"state_topic": {
"msg_callback": self._state_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/motion_contour_area/state"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
try:
self._attr_native_value = float(msg.payload)
except (TypeError, ValueError):
pass
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"number",
f"{self._cam_name}_contour_area",
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
async def async_set_native_value(self, value: float) -> None:
"""Update motion contour area."""
await async_publish(
self.hass,
self._command_topic,
int(value),
0,
False,
)
@property
def icon(self) -> str:
"""Return the icon of the number."""
return ICON_SPEEDOMETER
class FrigateMotionThreshold(FrigateMQTTEntity, NumberEntity): # type: ignore[misc]
"""FrigateMotionThreshold class."""
_attr_entity_category = EntityCategory.CONFIG
_attr_name = "Threshold"
_attr_native_min_value = MIN_THRESHOLD
_attr_native_max_value = MAX_THRESHOLD
_attr_native_step = 1
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
default_enabled: bool,
) -> None:
"""Construct a FrigateMotionThreshold."""
self._frigate_config = frigate_config
self._cam_name = cam_name
self._attr_native_value = float(
self._frigate_config["cameras"][self._cam_name]["motion"]["threshold"]
)
self._command_topic = (
f"{frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/motion_threshold/set"
)
self._attr_entity_registry_enabled_default = default_enabled
super().__init__(
config_entry,
frigate_config,
{
"state_topic": {
"msg_callback": self._state_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/motion_threshold/state"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
try:
self._attr_native_value = float(msg.payload)
except (TypeError, ValueError):
pass
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"number",
f"{self._cam_name}_threshold",
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
async def async_set_native_value(self, value: float) -> None:
"""Update motion threshold."""
await async_publish(
self.hass,
self._command_topic,
int(value),
0,
False,
)
@property
def icon(self) -> str:
"""Return the icon of the number."""
return ICON_SPEEDOMETER

View File

@@ -0,0 +1,712 @@
"""Sensor platform for frigate."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_URL,
PERCENTAGE,
UnitOfSoundPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import (
FrigateDataUpdateCoordinator,
FrigateEntity,
FrigateMQTTEntity,
ReceiveMessage,
get_cameras,
get_cameras_zones_and_objects,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
get_zones,
)
from .const import ATTR_CONFIG, ATTR_COORDINATOR, DOMAIN, FPS, MS, NAME
from .icons import (
ICON_CORAL,
ICON_SERVER,
ICON_SPEEDOMETER,
ICON_WAVEFORM,
get_icon_from_type,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
CAMERA_FPS_TYPES = ["camera", "detection", "process", "skipped"]
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Sensor entry setup."""
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR]
entities = []
for key, value in coordinator.data.items():
if key == "detection_fps":
entities.append(FrigateFpsSensor(coordinator, entry))
elif key == "detectors":
for name in value.keys():
entities.append(DetectorSpeedSensor(coordinator, entry, name))
elif key == "gpu_usages":
for name in value.keys():
entities.append(GpuLoadSensor(coordinator, entry, name))
elif key == "processes":
# don't create sensor for other processes
continue
elif key == "service":
# Temperature is only supported on PCIe Coral.
for name in value.get("temperatures", {}):
entities.append(DeviceTempSensor(coordinator, entry, name))
elif key == "cpu_usages":
for camera in get_cameras(frigate_config):
entities.append(
CameraProcessCpuSensor(coordinator, entry, camera, "capture")
)
entities.append(
CameraProcessCpuSensor(coordinator, entry, camera, "detect")
)
entities.append(
CameraProcessCpuSensor(coordinator, entry, camera, "ffmpeg")
)
elif key == "cameras":
for name in value.keys():
entities.extend(
[
CameraFpsSensor(coordinator, entry, name, t)
for t in CAMERA_FPS_TYPES
]
)
if frigate_config["cameras"][name]["audio"]["enabled_in_config"]:
entities.append(CameraSoundSensor(coordinator, entry, name))
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
entities.extend(
[
FrigateObjectCountSensor(entry, frigate_config, cam_name, obj)
for cam_name, obj in get_cameras_zones_and_objects(frigate_config)
]
)
entities.append(FrigateStatusSensor(coordinator, entry))
async_add_entities(entities)
class FrigateFpsSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Sensor class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_name = "Detection fps"
def __init__(
self, coordinator: FrigateDataUpdateCoordinator, config_entry: ConfigEntry
) -> None:
"""Construct a FrigateFpsSensor."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._attr_entity_registry_enabled_default = False
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id, "sensor_fps", "detection"
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {get_frigate_device_identifier(self._config_entry)},
"name": NAME,
"model": self._get_model(),
"configuration_url": self._config_entry.data.get(CONF_URL),
"manufacturer": NAME,
}
@property
def state(self) -> int | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = self.coordinator.data.get("detection_fps")
if data is not None:
try:
return round(float(data))
except ValueError:
pass
return None
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
return FPS
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_SPEEDOMETER
class FrigateStatusSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Status Sensor class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_name = "Status"
def __init__(
self, coordinator: FrigateDataUpdateCoordinator, config_entry: ConfigEntry
) -> None:
"""Construct a FrigateStatusSensor."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._attr_entity_registry_enabled_default = False
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id, "sensor_status", "frigate"
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {get_frigate_device_identifier(self._config_entry)},
"name": NAME,
"model": self._get_model(),
"configuration_url": self._config_entry.data.get(CONF_URL),
"manufacturer": NAME,
}
@property
def state(self) -> str:
"""Return the state of the sensor."""
return str(self.coordinator.server_status)
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_SERVER
class DetectorSpeedSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Detector Speed class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
detector_name: str,
) -> None:
"""Construct a DetectorSpeedSensor."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._detector_name = detector_name
self._attr_entity_registry_enabled_default = False
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id, "sensor_detector_speed", self._detector_name
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {get_frigate_device_identifier(self._config_entry)},
"name": NAME,
"model": self._get_model(),
"configuration_url": self._config_entry.data.get(CONF_URL),
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{get_friendly_name(self._detector_name)} inference speed"
@property
def state(self) -> int | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = (
self.coordinator.data.get("detectors", {})
.get(self._detector_name, {})
.get("inference_speed")
)
if data is not None:
try:
return round(float(data))
except ValueError:
pass
return None
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
return MS
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_SPEEDOMETER
class GpuLoadSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate GPU Load class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
gpu_name: str,
) -> None:
"""Construct a GpuLoadSensor."""
self._gpu_name = gpu_name
self._attr_name = f"{get_friendly_name(self._gpu_name)} gpu load"
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._attr_entity_registry_enabled_default = False
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id, "gpu_load", self._gpu_name
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {get_frigate_device_identifier(self._config_entry)},
"name": NAME,
"model": self._get_model(),
"configuration_url": self._config_entry.data.get(CONF_URL),
"manufacturer": NAME,
}
@property
def state(self) -> float | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = (
self.coordinator.data.get("gpu_usages", {})
.get(self._gpu_name, {})
.get("gpu")
)
if data is None or not isinstance(data, str):
return None
try:
return float(data.replace("%", "").strip())
except ValueError:
pass
return None
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
return "%"
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_SPEEDOMETER
class CameraFpsSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Camera Fps class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
cam_name: str,
fps_type: str,
) -> None:
"""Construct a CameraFpsSensor."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._cam_name = cam_name
self._fps_type = fps_type
self._attr_entity_registry_enabled_default = False
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"sensor_fps",
f"{self._cam_name}_{self._fps_type}",
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._fps_type} fps"
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
return FPS
@property
def state(self) -> int | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = (
self.coordinator.data.get("cameras", {})
.get(self._cam_name, {})
.get(f"{self._fps_type}_fps")
)
if data is not None:
try:
return round(float(data))
except ValueError:
pass
return None
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_SPEEDOMETER
class CameraSoundSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Camera Sound Level class."""
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
cam_name: str,
) -> None:
"""Construct a CameraSoundSensor."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._cam_name = cam_name
self._attr_entity_registry_enabled_default = True
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"sensor_sound_level",
f"{self._cam_name}_dB",
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return "sound level"
@property
def unit_of_measurement(self) -> Any:
"""Return the unit of measurement of the sensor."""
return UnitOfSoundPressure.DECIBEL
@property
def state(self) -> int | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = (
self.coordinator.data.get("cameras", {})
.get(self._cam_name, {})
.get("audio_dBFS")
)
if data is not None:
try:
return round(float(data))
except ValueError:
pass
return None
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_WAVEFORM
class FrigateObjectCountSensor(FrigateMQTTEntity):
"""Frigate Motion Sensor class."""
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
obj_name: str,
) -> None:
"""Construct a FrigateObjectCountSensor."""
self._cam_name = cam_name
self._obj_name = obj_name
self._state = 0
self._frigate_config = frigate_config
self._icon = get_icon_from_type(self._obj_name)
super().__init__(
config_entry,
frigate_config,
{
"state_topic": {
"msg_callback": self._state_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/{self._obj_name}"
),
"encoding": None,
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
try:
self._state = int(msg.payload)
self.async_write_ha_state()
except ValueError:
pass
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"sensor_object_count",
f"{self._cam_name}_{self._obj_name}",
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name if self._cam_name not in get_zones(self._frigate_config) else ''}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._obj_name} count"
@property
def state(self) -> int:
"""Return true if the binary sensor is on."""
return self._state
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of the sensor."""
return "objects"
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return self._icon
class DeviceTempSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate Coral Temperature Sensor class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
name: str,
) -> None:
"""Construct a CoralTempSensor."""
self._name = name
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._attr_entity_registry_enabled_default = False
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id, "sensor_temp", self._name
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {get_frigate_device_identifier(self._config_entry)},
"name": NAME,
"model": self._get_model(),
"configuration_url": self._config_entry.data.get(CONF_URL),
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{get_friendly_name(self._name)} temperature"
@property
def state(self) -> float | None:
"""Return the state of the sensor."""
if self.coordinator.data:
data = (
self.coordinator.data.get("service", {})
.get("temperatures", {})
.get(self._name, 0.0)
)
try:
return float(data)
except (TypeError, ValueError):
pass
return None
@property
def unit_of_measurement(self) -> Any:
"""Return the unit of measurement of the sensor."""
return UnitOfTemperature.CELSIUS
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_CORAL
class CameraProcessCpuSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc]
"""Cpu usage for camera processes class."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
cam_name: str,
process_type: str,
) -> None:
"""Construct a CoralTempSensor."""
self._cam_name = cam_name
self._process_type = process_type
self._attr_name = f"{self._process_type} cpu usage"
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
self._attr_entity_registry_enabled_default = False
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
f"{self._process_type}_cpu_usage",
self._cam_name,
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
@property
def state(self) -> float | None:
"""Return the state of the sensor."""
if self.coordinator.data:
pid_key = (
"pid" if self._process_type == "detect" else f"{self._process_type}_pid"
)
pid = str(
self.coordinator.data.get("cameras", {})
.get(self._cam_name, {})
.get(pid_key, "-1")
)
data = (
self.coordinator.data.get("cpu_usages", {})
.get(pid, {})
.get("cpu", None)
)
try:
return float(data)
except (TypeError, ValueError):
pass
return None
@property
def unit_of_measurement(self) -> Any:
"""Return the unit of measurement of the sensor."""
return PERCENTAGE
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return ICON_CORAL

View File

@@ -0,0 +1,103 @@
---
export_recording:
name: Export recording
description: Export a custom recording or timelapse.
target:
entity:
integration: frigate
domain: camera
device_class: camera
fields:
playback_factor:
name: Playback Factor
description: Playback factor for recordings
required: true
advanced: false
example: realtime
default: realtime
selector:
select:
options:
- "realtime"
- "timelapse_25x"
start_time:
name: Export Start Time
description: Start time of exported recording
required: true
advanced: false
selector:
datetime:
end_time:
name: Export End Time
description: End time of exported recording
required: true
advanced: false
selector:
datetime:
favorite_event:
name: Favorite or unfavorite Event
description: >
Favorites or unfavorites an event. Favorited events are retained
indefinitely.
target:
entity:
integration: frigate
domain: camera
device_class: camera
fields:
event_id:
name: Event ID
description: ID of the event to favorite or unfavorite.
required: true
advanced: false
example: "1656510950.19548-ihtjj7"
default: ""
selector:
text:
favorite:
name: Favorite
description: >
If the event should be favorited or unfavorited. Enable to favorite,
disable to unfavorite.
required: false
advanced: false
example: true
default: true
selector:
boolean:
ptz:
name: Control camera via PTZ
description: >
Pan / Tilt, Zoom, or move a camera to a preset
target:
entity:
integration: frigate
domain: camera
device_class: camera
fields:
action:
name: PTZ Service
description: Type of PTZ action
required: true
advanced: false
example: move
default: move
selector:
select:
options:
- "move"
- "preset"
- "stop"
- "zoom"
argument:
name: PTZ Action
description: >
left, right, up, down for move; in, out for zoom; name of preset
required: false
advanced: false
example: down
default: ""
selector:
text:

View File

@@ -0,0 +1,182 @@
"""Sensor platform for frigate."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.mqtt import async_publish
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import (
FrigateMQTTEntity,
ReceiveMessage,
decode_if_necessary,
get_friendly_name,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
)
from .const import ATTR_CONFIG, DOMAIN, NAME
from .icons import get_icon_from_switch
_LOGGER: logging.Logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Switch entry setup."""
frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG]
entities = []
for camera in frigate_config["cameras"].keys():
entities.extend(
[
FrigateSwitch(entry, frigate_config, camera, "detect", True),
FrigateSwitch(entry, frigate_config, camera, "motion", True),
FrigateSwitch(entry, frigate_config, camera, "recordings", True),
FrigateSwitch(entry, frigate_config, camera, "snapshots", True),
FrigateSwitch(entry, frigate_config, camera, "improve_contrast", False),
]
)
if (
frigate_config["cameras"][camera]
.get("audio", {})
.get("enabled_in_config", False)
):
entities.append(
FrigateSwitch(
entry, frigate_config, camera, "audio", True, "audio_detection"
),
)
if (
frigate_config["cameras"][camera]
.get("onvif", {})
.get("autotracking", {})
.get("enabled_in_config", False)
):
entities.append(
FrigateSwitch(
entry,
frigate_config,
camera,
"ptz_autotracker",
True,
"ptz_autotracker",
),
)
async_add_entities(entities)
class FrigateSwitch(FrigateMQTTEntity, SwitchEntity): # type: ignore[misc]
"""Frigate Switch class."""
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
config_entry: ConfigEntry,
frigate_config: dict[str, Any],
cam_name: str,
switch_name: str,
default_enabled: bool,
descriptive_name: str = "",
) -> None:
"""Construct a FrigateSwitch."""
self._frigate_config = frigate_config
self._cam_name = cam_name
self._switch_name = switch_name
self._is_on = False
self._command_topic = (
f"{frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/{self._switch_name}/set"
)
self._descriptive_name = descriptive_name if descriptive_name else switch_name
self._attr_entity_registry_enabled_default = default_enabled
self._icon = get_icon_from_switch(self._switch_name)
super().__init__(
config_entry,
frigate_config,
{
"state_topic": {
"msg_callback": self._state_message_received,
"qos": 0,
"topic": (
f"{self._frigate_config['mqtt']['topic_prefix']}"
f"/{self._cam_name}/{self._switch_name}/state"
),
},
},
)
@callback # type: ignore[misc]
def _state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
self._is_on = decode_if_necessary(msg.payload) == "ON"
self.async_write_ha_state()
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id,
"switch",
f"{self._cam_name}_{self._switch_name}",
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {
get_frigate_device_identifier(self._config_entry, self._cam_name)
},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": get_friendly_name(self._cam_name),
"model": self._get_model(),
"configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}",
"manufacturer": NAME,
}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{get_friendly_name(self._descriptive_name)}".title()
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._is_on
@property
def icon(self) -> str:
"""Return the icon of the sensor."""
return self._icon
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
await async_publish(
self.hass,
self._command_topic,
"ON",
0,
False,
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
await async_publish(
self.hass,
self._command_topic,
"OFF",
0,
False,
)

View File

@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"description": "L'URL que utilitzeu per accedir a Frigate (p. ex. 'http://frigate:5000/')\\n\\nSi feu servir HassOS amb el complement, l'URL hauria de ser 'http://ccab4aaf-frigate:5000/' \\n\\nHome Assistant necessita accedir al port 5000 (api) i 1935 (rtmp) per a totes les funcions.\\n\\nLa integració configurarà sensors, càmeres i la funcionalitat del navegador multimèdia.\\n\\nSensors:\\n- Estadístiques per supervisar el rendiment de Frigate\\n- Recompte d'objectes per a totes les zones i càmeres\\n\\nCàmeres:\\n- Càmeres per a la imatge de l'últim objecte detectat per a cada càmera\\n- Entitats de càmera amb suport de transmissió (requereix RTMP)\\n\\nNavegador multimèdia:\\n - Interfície d'usuari enriquida amb miniatures per explorar clips d'esdeveniments\\n- Interfície d'usuari enriquida per navegar per enregistraments les 24 hores al dia, els set dies a la setmana, per mes, dia, càmera, hora\\n\\nAPI:\\n- API de notificació amb punts de connexió públics per a imatges a les notificacions.",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "No s'ha pogut connectar",
"invalid_url": "URL no vàlid"
},
"abort": {
"already_configured": "El dispositiu ja està configurat"
}
},
"options": {
"step": {
"init": {
"data": {
"enable_webrtc": "Activa WebRTC per als fluxos de la càmera",
"rtmp_url_template": "Plantilla de l'URL del RTMP (vegeu la documentació)",
"rtsp_url_template": "Plantilla de l'URL del RTSP (vegeu la documentació)",
"media_browser_enable": "Habiliteu el navegador multimèdia",
"notification_proxy_enable": "Habiliteu el servidor intermediari no autenticat d'esdeveniments de notificacions",
"notification_proxy_expire_after_seconds": "No permetre l'accés a notificacions no autenticades després dels segons especificats (0=mai)"
}
}
},
"abort": {
"only_advanced_options": "El mode avançat està desactivat i només hi ha opcions avançades"
}
}
}

View File

@@ -0,0 +1,35 @@
{
"config": {
"step": {
"user": {
"description": "URL, die Sie für den Zugriff auf Frigate verwenden (z. B. \"http://frigate:5000/\")\n\nWenn Sie HassOS mit dem Addon verwenden, sollte die URL „http://ccab4aaf-frigate:5000/“ lauten\n\nHome Assistant benötigt für alle Funktionen Zugriff auf Port 5000 (api) und 1935 (rtmp).\n\nDie Integration richtet Sensoren, Kameras und Medienbrowser-Funktionen ein.\n\nSensoren:\n- Statistiken zur Überwachung der Frigate-Leistung\n- Objektzählungen für alle Zonen und Kameras\n\nKameras:\n- Kameras für Bild des zuletzt erkannten Objekts für jede Kamera\n- Kameraeinheiten mit Stream-Unterstützung (erfordert RTMP)\n\nMedienbrowser:\n- Umfangreiche Benutzeroberfläche mit Vorschaubildern zum Durchsuchen von Event-Clips\n- Umfangreiche Benutzeroberfläche zum Durchsuchen von 24/7-Aufzeichnungen nach Monat, Tag, Kamera und Uhrzeit\n\nAPI:\n- Benachrichtigungs-API mit öffentlich zugänglichen Endpunkten für Bilder in Benachrichtigungen",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen",
"invalid_url": "Ungültige URL"
},
"abort": {
"already_configured": "Gerät ist bereits konfiguriert"
}
},
"options": {
"step": {
"init": {
"data": {
"rtmp_url_template": "RTMP-URL-Vorlage (siehe Dokumentation)",
"rtsp_url_template": "RTSP-URL-Vorlage (siehe Dokumentation)",
"media_browser_enable": "Aktivieren Sie den Medienbrowser",
"notification_proxy_enable": "Aktivieren Sie den Proxy für nicht authentifizierte Benachrichtigungsereignisse",
"notification_proxy_expire_after_seconds": "Zugriff auf nicht authentifizierte Benachrichtigungen nach Sekunden verbieten (0=nie)"
}
}
},
"abort": {
"only_advanced_options": "Der erweiterte Modus ist deaktiviert und es stehen nur erweiterte Optionen zur Verfügung"
}
}
}

View File

@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"description": "URL you use to access Frigate (ie. `http://frigate:5000/`)\n\nIf you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000/`\n\nHome Assistant needs access to port 5000 (api) and 1935 (rtmp) for all features.\n\nThe integration will setup sensors, cameras, and media browser functionality.\n\nSensors:\n- Stats to monitor frigate performance\n- Object counts for all zones and cameras\n\nCameras:\n- Cameras for image of the last detected object for each camera\n- Camera entities with stream support (requires RTMP)\n\nMedia Browser:\n- Rich UI with thumbnails for browsing event clips\n- Rich UI for browsing 24/7 recordings by month, day, camera, time\n\nAPI:\n- Notification API with public facing endpoints for images in notifications",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_url": "Invalid URL"
},
"abort": {
"already_configured": "Device is already configured"
}
},
"options": {
"step": {
"init": {
"data": {
"enable_webrtc": "Enable WebRTC for camera streams",
"rtmp_url_template": "RTMP URL template (see documentation)",
"rtsp_url_template": "RTSP URL template (see documentation)",
"media_browser_enable": "Enable the media browser",
"notification_proxy_enable": "Enable the unauthenticated notification event proxy",
"notification_proxy_expire_after_seconds": "Disallow unauthenticated notification access after seconds (0=never)"
}
}
},
"abort": {
"only_advanced_options": "Advanced mode is disabled and there are only advanced options"
}
}
}

View File

@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"description": "URL que vous utilisez pour accéder à Frigate (par exemple, `http://frigate:5000/`)\n\nSi vous utilisez HassOS avec l'addon, l'URL devrait être `http://ccab4aaf-frigate:5000/`\n\nHome Assistant a besoin d'accès au port 5000 (api) et 1935 (rtmp) pour toutes les fonctionnalités.\n\nL'intégration configurera des capteurs, des caméras et la fonctionnalité de navigateur multimédia.\n\nCapteurs :\n- Statistiques pour surveiller la performance de Frigate\n- Comptes d'objets pour toutes les zones et caméras\n\nCaméras :\n- Caméras pour l'image du dernier objet détecté pour chaque caméra\n- Entités de caméra avec support de flux (nécessite RTMP)\n\nNavigateur multimédia :\n- Interface riche avec miniatures pour parcourir les clips d'événements\n- Interface riche pour parcourir les enregistrements 24/7 par mois, jour, caméra, heure\n\nAPI :\n- API de notification avec des points de terminaison publics pour les images dans les notifications",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Échec de la connexion",
"invalid_url": "URL invalide"
},
"abort": {
"already_configured": "L'appareil est déjà configuré"
}
},
"options": {
"step": {
"init": {
"data": {
"enable_webrtc": "Activer WebRTC pour les flux de caméra",
"rtmp_url_template": "Modèle d'URL RTMP (voir la documentation)",
"rtsp_url_template": "Modèle d'URL RTSP (voir la documentation)",
"media_browser_enable": "Activer le navigateur multimédia",
"notification_proxy_enable": "Activer le proxy d'événement de notification non authentifié",
"notification_proxy_expire_after_seconds": "Interdire l'accès à la notification non authentifiée après secondes (0=jamais)"
}
}
},
"abort": {
"only_advanced_options": "Le mode avancé est désactivé et il n'y a que des options avancées"
}
}
}

View File

@@ -0,0 +1,35 @@
{
"config": {
"step": {
"user": {
"description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 1935 (rtmp) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream (requer RTMP)\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Falhou ao conectar",
"invalid_url": "URL inválida"
},
"abort": {
"already_configured": "O dispositivo já está configurado"
}
},
"options": {
"step": {
"init": {
"data": {
"rtmp_url_template": "Modelo de URL RTMP (consulte a documentação)",
"rtsp_url_template": "Modelo de URL RTSP (consulte a documentação)",
"notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado",
"notification_proxy_expire_after_seconds": "Proibir acesso de notificação não autenticado após segundos (0=nunca)",
"media_browser_enable": "Ative o navegador de mídia"
}
}
},
"abort": {
"only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas"
}
}
}

View File

@@ -0,0 +1,35 @@
{
"config": {
"step": {
"user": {
"description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 1935 (rtmp) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream (requer RTMP)\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Falhou ao conectar",
"invalid_url": "URL inválida"
},
"abort": {
"already_configured": "O dispositivo já está configurado"
}
},
"options": {
"step": {
"init": {
"data": {
"rtmp_url_template": "Modelo de URL RTMP (consulte a documentação)",
"rtsp_url_template": "Modelo de URL RTSP (consulte a documentação)",
"notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado",
"notification_proxy_expire_after_seconds": "Proibir acesso de notificação não autenticado após segundos (0=nunca)",
"media_browser_enable": "Ative o navegador de mídia"
}
}
},
"abort": {
"only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas"
}
}
}

View File

@@ -0,0 +1,36 @@
{
"config": {
"step": {
"user": {
"description": "URL, который вы используете для доступа к Frigate (например, http://frigate:5000/)\n\nЕсли вы используете HassOS с дополнением, URL должен быть http://ccab4aaf-frigate:5000/\n\nHome Assistant требуется доступ к порту 5000 (API) и 1935 (RTMP) для всех функций.\n\n Интеграция настроит сенсоры, камеры и функциональность медиа-браузера.\n\nСенсоры:\n- Статистика для отслеживания производительности Frigate\n- Количество объектов для всех зон и камер\n\nКамеры:\n- Камеры для снимка последнего обнаруженного объекта с каждой камеры\n- Камеры с поддержкой потока (требуется RTMP)\n\nМедиа-браузер:\n- Пользовательский интерфейс с миниатюрами для просмотра записей 24/7 по времени и камерам\n\nAPI:\n- API для отправки событий во внешние системы",
"data": {
"url": "URL"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться",
"invalid_url": "Неверный URL"
},
"abort": {
"already_configured": "Устройство уже настроено"
}
},
"options": {
"step": {
"init": {
"data": {
"enable_webrtc": "Использовать WebRTC для трансляции камер",
"rtmp_url_template": "Шаблон URL для RTMP (см. документацию)",
"rtsp_url_template": "Шаблон URL для RTSP (см. документацию)",
"media_browser_enable": "Включить медиа-браузер",
"notification_proxy_enable": "Включить незащищённый прокси-сервер уведомлений",
"notification_proxy_expire_after_seconds": "Запретить неаутентифицированный доступ к уведомлениям после N секунд (0=никогда)"
}
}
},
"abort": {
"only_advanced_options": "Режим расширенных настроек отключен; доступны только основные параметры"
}
}
}

View File

@@ -0,0 +1,100 @@
"""Update platform for frigate."""
from __future__ import annotations
import logging
from homeassistant.components.update import UpdateEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import (
FrigateDataUpdateCoordinator,
FrigateEntity,
get_frigate_device_identifier,
get_frigate_entity_unique_id,
)
from .const import ATTR_COORDINATOR, DOMAIN, FRIGATE_RELEASE_TAG_URL, NAME
_LOGGER: logging.Logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Sensor entry setup."""
coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR]
entities = []
entities.append(FrigateContainerUpdate(coordinator, entry))
async_add_entities(entities)
class FrigateContainerUpdate(FrigateEntity, UpdateEntity, CoordinatorEntity): # type: ignore[misc]
"""Frigate container update."""
_attr_name = "Server"
def __init__(
self,
coordinator: FrigateDataUpdateCoordinator,
config_entry: ConfigEntry,
) -> None:
"""Construct a FrigateContainerUpdate."""
FrigateEntity.__init__(self, config_entry)
CoordinatorEntity.__init__(self, coordinator)
@property
def unique_id(self) -> str:
"""Return a unique ID to use for this entity."""
return get_frigate_entity_unique_id(
self._config_entry.entry_id, "update", "frigate_server"
)
@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return {
"identifiers": {get_frigate_device_identifier(self._config_entry)},
"via_device": get_frigate_device_identifier(self._config_entry),
"name": NAME,
"model": self._get_model(),
"configuration_url": self._config_entry.data.get(CONF_URL),
"manufacturer": NAME,
}
@property
def installed_version(self) -> str | None:
"""Version currently in use."""
version_hash = self.coordinator.data.get("service", {}).get("version")
if not version_hash:
return None
version = str(version_hash).split("-", maxsplit=1)[0]
return version
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
version = self.coordinator.data.get("service", {}).get("latest_version")
if not version or version == "unknown" or version == "disabled":
return None
return str(version)
@property
def release_url(self) -> str | None:
"""URL to the full release notes of the latest version available."""
if (version := self.latest_version) is None:
return None
return f"{FRIGATE_RELEASE_TAG_URL}/v{version}"

View File

@@ -0,0 +1,579 @@
"""Frigate HTTP views."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
import datetime
from http import HTTPStatus
from ipaddress import ip_address
import logging
from typing import Any, Optional, cast
import aiohttp
from aiohttp import hdrs, web
from aiohttp.web_exceptions import HTTPBadGateway, HTTPUnauthorized
import jwt
from multidict import CIMultiDict
from yarl import URL
from custom_components.frigate.api import FrigateApiClient
from custom_components.frigate.const import (
ATTR_CLIENT,
ATTR_CLIENT_ID,
ATTR_CONFIG,
ATTR_MQTT,
CONF_NOTIFICATION_PROXY_ENABLE,
CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS,
DOMAIN,
)
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http.auth import DATA_SIGN_SECRET, SIGN_QUERY_PARAM
from homeassistant.components.http.const import KEY_HASS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER: logging.Logger = logging.getLogger(__name__)
def get_default_config_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""Get the default Frigate config entry.
This is for backwards compatibility for when only a single instance was
supported. If there's more than one instance configured, then there is no
default and the user must specify explicitly which instance they want.
"""
frigate_entries = hass.config_entries.async_entries(DOMAIN)
if len(frigate_entries) == 1:
return frigate_entries[0]
return None
def get_frigate_instance_id(config: dict[str, Any]) -> str | None:
"""Get the Frigate instance id from a Frigate configuration."""
# Use the MQTT client_id as a way to separate the frigate instances, rather
# than just using the config_entry_id, in order to make URLs maximally
# relatable/findable by the user. The MQTT client_id value is configured by
# the user in their Frigate configuration and will be unique per Frigate
# instance (enforced in practice on the Frigate/MQTT side).
return cast(Optional[str], config.get(ATTR_MQTT, {}).get(ATTR_CLIENT_ID))
def get_config_entry_for_frigate_instance_id(
hass: HomeAssistant, frigate_instance_id: str
) -> ConfigEntry | None:
"""Get a ConfigEntry for a given frigate_instance_id."""
for config_entry in hass.config_entries.async_entries(DOMAIN):
config = hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CONFIG, {})
if config and get_frigate_instance_id(config) == frigate_instance_id:
return config_entry
return None
def get_client_for_frigate_instance_id(
hass: HomeAssistant, frigate_instance_id: str
) -> FrigateApiClient | None:
"""Get a client for a given frigate_instance_id."""
config_entry = get_config_entry_for_frigate_instance_id(hass, frigate_instance_id)
if config_entry:
return cast(
FrigateApiClient,
hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CLIENT),
)
return None
def get_frigate_instance_id_for_config_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> ConfigEntry | None:
"""Get a frigate_instance_id for a ConfigEntry."""
config = hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CONFIG, {})
return get_frigate_instance_id(config) if config else None
def async_setup(hass: HomeAssistant) -> None:
"""Set up the views."""
session = async_get_clientsession(hass)
hass.http.register_view(JSMPEGProxyView(session))
hass.http.register_view(MSEProxyView(session))
hass.http.register_view(WebRTCProxyView(session))
hass.http.register_view(NotificationsProxyView(session))
hass.http.register_view(SnapshotsProxyView(session))
hass.http.register_view(RecordingProxyView(session))
hass.http.register_view(ThumbnailsProxyView(session))
hass.http.register_view(VodProxyView(session))
hass.http.register_view(VodSegmentProxyView(session))
# These proxies are inspired by:
# - https://github.com/home-assistant/supervisor/blob/main/supervisor/api/ingress.py
class ProxyView(HomeAssistantView): # type: ignore[misc]
"""HomeAssistant view."""
requires_auth = True
def __init__(self, websession: aiohttp.ClientSession):
"""Initialize the frigate clips proxy view."""
self._websession = websession
def _get_config_entry_for_request(
self, request: web.Request, frigate_instance_id: str | None
) -> ConfigEntry | None:
"""Get a ConfigEntry for a given request."""
hass = request.app[KEY_HASS]
if frigate_instance_id:
return get_config_entry_for_frigate_instance_id(hass, frigate_instance_id)
return get_default_config_entry(hass)
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
raise NotImplementedError # pragma: no cover
def _permit_request(
self, request: web.Request, config_entry: ConfigEntry, **kwargs: Any
) -> bool:
"""Determine whether to permit a request."""
return True
async def get(
self,
request: web.Request,
**kwargs: Any,
) -> web.Response | web.StreamResponse | web.WebSocketResponse:
"""Route data to service."""
try:
return await self._handle_request(request, **kwargs)
except aiohttp.ClientError as err:
_LOGGER.debug("Reverse proxy error for %s: %s", request.rel_url, err)
raise HTTPBadGateway() from None
@staticmethod
def _get_query_params(request: web.Request) -> Mapping[str, str]:
"""Get the query params to send upstream."""
return {k: v for k, v in request.query.items() if k != "authSig"}
async def _handle_request(
self,
request: web.Request,
frigate_instance_id: str | None = None,
**kwargs: Any,
) -> web.Response | web.StreamResponse:
"""Handle route for request."""
config_entry = self._get_config_entry_for_request(request, frigate_instance_id)
if not config_entry:
return web.Response(status=HTTPStatus.BAD_REQUEST)
if not self._permit_request(request, config_entry, **kwargs):
return web.Response(status=HTTPStatus.FORBIDDEN)
full_path = self._create_path(**kwargs)
if not full_path:
return web.Response(status=HTTPStatus.NOT_FOUND)
url = str(URL(config_entry.data[CONF_URL]) / full_path)
data = await request.read()
source_header = _init_header(request)
async with self._websession.request(
request.method,
url,
headers=source_header,
params=self._get_query_params(request),
allow_redirects=False,
data=data,
) as result:
headers = _response_header(result)
# Stream response
response = web.StreamResponse(status=result.status, headers=headers)
response.content_type = result.content_type
try:
await response.prepare(request)
async for data in result.content.iter_any():
await response.write(data)
except (aiohttp.ClientError, aiohttp.ClientPayloadError) as err:
_LOGGER.debug("Stream error for %s: %s", request.rel_url, err)
except ConnectionResetError:
# Connection is reset/closed by peer.
pass
return response
class SnapshotsProxyView(ProxyView):
"""A proxy for snapshots."""
url = "/api/frigate/{frigate_instance_id:.+}/snapshot/{eventid:.*}"
extra_urls = ["/api/frigate/snapshot/{eventid:.*}"]
name = "api:frigate:snapshots"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"api/events/{kwargs['eventid']}/snapshot.jpg"
class RecordingProxyView(ProxyView):
"""A proxy for recordings."""
url = "/api/frigate/{frigate_instance_id:.+}/recording/{camera:.+}/start/{start:[.0-9]+}/end/{end:[.0-9]*}"
extra_urls = [
"/api/frigate/recording/{camera:.+}/start/{start:[.0-9]+}/end/{end:[.0-9]*}"
]
name = "api:frigate:recording"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return (
f"api/{kwargs['camera']}/start/{kwargs['start']}"
+ f"/end/{kwargs['end']}/clip.mp4"
)
class ThumbnailsProxyView(ProxyView):
"""A proxy for snapshots."""
url = "/api/frigate/{frigate_instance_id:.+}/thumbnail/{eventid:.*}"
name = "api:frigate:thumbnails"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"api/events/{kwargs['eventid']}/thumbnail.jpg"
class NotificationsProxyView(ProxyView):
"""A proxy for notifications."""
url = "/api/frigate/{frigate_instance_id:.+}/notifications/{event_id}/{path:.*}"
extra_urls = ["/api/frigate/notifications/{event_id}/{path:.*}"]
name = "api:frigate:notification"
requires_auth = False
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
path, event_id = kwargs["path"], kwargs["event_id"]
if path == "thumbnail.jpg":
return f"api/events/{event_id}/thumbnail.jpg"
if path == "snapshot.jpg":
return f"api/events/{event_id}/snapshot.jpg"
if path.endswith("clip.mp4"):
return f"api/events/{event_id}/clip.mp4"
return None
def _permit_request(
self, request: web.Request, config_entry: ConfigEntry, **kwargs: Any
) -> bool:
"""Determine whether to permit a request."""
is_notification_proxy_enabled = bool(
config_entry.options.get(CONF_NOTIFICATION_PROXY_ENABLE, True)
)
# If proxy is disabled, immediately reject
if not is_notification_proxy_enabled:
return False
# Authenticated requests are always allowed.
if request[KEY_AUTHENTICATED]:
return True
# If request is not authenticated, check whether it is expired.
notification_expiration_seconds = int(
config_entry.options.get(CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, 0)
)
# If notification events never expire, immediately permit.
if notification_expiration_seconds == 0:
return True
try:
event_id_timestamp = int(kwargs["event_id"].partition(".")[0])
event_datetime = datetime.datetime.fromtimestamp(
event_id_timestamp, tz=datetime.timezone.utc
)
now_datetime = datetime.datetime.now(tz=datetime.timezone.utc)
expiration_datetime = event_datetime + datetime.timedelta(
seconds=notification_expiration_seconds
)
# Otherwise, permit only if notification event is not expired
return now_datetime.timestamp() <= expiration_datetime.timestamp()
except ValueError:
_LOGGER.warning(
"The event id %s does not have a valid format.", kwargs["event_id"]
)
return False
class VodProxyView(ProxyView):
"""A proxy for vod playlists."""
url = "/api/frigate/{frigate_instance_id:.+}/vod/{path:.+}/{manifest:.+}.m3u8"
extra_urls = ["/api/frigate/vod/{path:.+}/{manifest:.+}.m3u8"]
name = "api:frigate:vod:manifest"
@staticmethod
def _get_query_params(request: web.Request) -> Mapping[str, str]:
"""Get the query params to send upstream."""
return request.query
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"vod/{kwargs['path']}/{kwargs['manifest']}.m3u8"
class VodSegmentProxyView(ProxyView):
"""A proxy for vod segments."""
url = "/api/frigate/{frigate_instance_id:.+}/vod/{path:.+}/{segment:.+}.{extension:(ts|m4s|mp4)}"
extra_urls = ["/api/frigate/vod/{path:.+}/{segment:.+}.{extension:(ts|m4s|mp4)}"]
name = "api:frigate:vod:segment"
requires_auth = False
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"vod/{kwargs['path']}/{kwargs['segment']}.{kwargs['extension']}"
async def _async_validate_signed_manifest(self, request: web.Request) -> bool:
"""Validate the signature for the manifest of this segment."""
hass = request.app[KEY_HASS]
secret = hass.data.get(DATA_SIGN_SECRET)
signature = request.query.get(SIGN_QUERY_PARAM)
if signature is None:
_LOGGER.warning("Missing authSig query parameter on VOD segment request.")
return False
try:
claims = jwt.decode(
signature, secret, algorithms=["HS256"], options={"verify_iss": False}
)
except jwt.InvalidTokenError:
_LOGGER.warning("Invalid JWT token for VOD segment request.")
return False
# Check that the base path is the same as what was signed
check_path = request.path.rsplit("/", maxsplit=1)[0]
if not claims["path"].startswith(check_path):
_LOGGER.warning("%s does not start with %s", claims["path"], check_path)
return False
return True
async def get(
self,
request: web.Request,
**kwargs: Any,
) -> web.Response | web.StreamResponse | web.WebSocketResponse:
"""Route data to service."""
if not await self._async_validate_signed_manifest(request):
raise HTTPUnauthorized()
return await super().get(request, **kwargs)
class WebsocketProxyView(ProxyView):
"""A simple proxy for websockets."""
async def _proxy_msgs(
self,
ws_in: aiohttp.ClientWebSocketResponse | web.WebSocketResponse,
ws_out: aiohttp.ClientWebSocketResponse | web.WebSocketResponse,
) -> None:
async for msg in ws_in:
try:
if msg.type == aiohttp.WSMsgType.TEXT:
await ws_out.send_str(msg.data)
elif msg.type == aiohttp.WSMsgType.BINARY:
await ws_out.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.PING:
await ws_out.ping()
elif msg.type == aiohttp.WSMsgType.PONG:
await ws_out.pong()
except ConnectionResetError:
return
async def _handle_request(
self,
request: web.Request,
frigate_instance_id: str | None = None,
**kwargs: Any,
) -> web.Response | web.StreamResponse:
"""Handle route for request."""
config_entry = self._get_config_entry_for_request(request, frigate_instance_id)
if not config_entry:
return web.Response(status=HTTPStatus.BAD_REQUEST)
if not self._permit_request(request, config_entry, **kwargs):
return web.Response(status=HTTPStatus.FORBIDDEN)
full_path = self._create_path(**kwargs)
if not full_path:
return web.Response(status=HTTPStatus.NOT_FOUND)
req_protocols = []
if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers:
req_protocols = [
str(proto.strip())
for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",")
]
ws_to_user = web.WebSocketResponse(
protocols=req_protocols, autoclose=False, autoping=False
)
await ws_to_user.prepare(request)
# Preparing
url = str(URL(config_entry.data[CONF_URL]) / full_path)
source_header = _init_header(request)
# Support GET query
if request.query_string:
url = f"{url}?{request.query_string}"
async with self._websession.ws_connect(
url,
headers=source_header,
protocols=req_protocols,
autoclose=False,
autoping=False,
) as ws_to_frigate:
await asyncio.wait(
[
asyncio.create_task(self._proxy_msgs(ws_to_frigate, ws_to_user)),
asyncio.create_task(self._proxy_msgs(ws_to_user, ws_to_frigate)),
],
return_when=asyncio.tasks.FIRST_COMPLETED,
)
return ws_to_user
class JSMPEGProxyView(WebsocketProxyView):
"""A proxy for JSMPEG websocket."""
url = "/api/frigate/{frigate_instance_id:.+}/jsmpeg/{path:.+}"
extra_urls = ["/api/frigate/jsmpeg/{path:.+}"]
name = "api:frigate:jsmpeg"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"live/jsmpeg/{kwargs['path']}"
class MSEProxyView(WebsocketProxyView):
"""A proxy for MSE websocket."""
url = "/api/frigate/{frigate_instance_id:.+}/mse/{path:.+}"
extra_urls = ["/api/frigate/mse/{path:.+}"]
name = "api:frigate:mse"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"live/mse/{kwargs['path']}"
class WebRTCProxyView(WebsocketProxyView):
"""A proxy for WebRTC websocket."""
url = "/api/frigate/{frigate_instance_id:.+}/webrtc/{path:.+}"
extra_urls = ["/api/frigate/webrtc/{path:.+}"]
name = "api:frigate:webrtc"
def _create_path(self, **kwargs: Any) -> str | None:
"""Create path."""
return f"live/webrtc/{kwargs['path']}"
def _init_header(request: web.Request) -> CIMultiDict | dict[str, str]:
"""Create initial header."""
headers = {}
# filter flags
for name, value in request.headers.items():
if name in (
hdrs.CONTENT_LENGTH,
hdrs.CONTENT_ENCODING,
hdrs.SEC_WEBSOCKET_EXTENSIONS,
hdrs.SEC_WEBSOCKET_PROTOCOL,
hdrs.SEC_WEBSOCKET_VERSION,
hdrs.SEC_WEBSOCKET_KEY,
hdrs.HOST,
hdrs.AUTHORIZATION,
):
continue
headers[name] = value
# Set X-Forwarded-For
forward_for = request.headers.get(hdrs.X_FORWARDED_FOR)
assert request.transport
connected_ip = ip_address(request.transport.get_extra_info("peername")[0])
if forward_for:
forward_for = f"{forward_for}, {connected_ip!s}"
else:
forward_for = f"{connected_ip!s}"
headers[hdrs.X_FORWARDED_FOR] = forward_for
# Set X-Forwarded-Host
forward_host = request.headers.get(hdrs.X_FORWARDED_HOST)
if not forward_host:
forward_host = request.host
headers[hdrs.X_FORWARDED_HOST] = forward_host
# Set X-Forwarded-Proto
forward_proto = request.headers.get(hdrs.X_FORWARDED_PROTO)
if not forward_proto:
forward_proto = request.url.scheme
headers[hdrs.X_FORWARDED_PROTO] = forward_proto
return headers
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
"""Create response header."""
headers = {}
for name, value in response.headers.items():
if name in (
hdrs.TRANSFER_ENCODING,
# Removing Content-Length header for streaming responses
# prevents seeking from working for mp4 files
# hdrs.CONTENT_LENGTH,
hdrs.CONTENT_TYPE,
hdrs.CONTENT_ENCODING,
# Strips inbound CORS response headers since the aiohttp_cors
# library will assert that they are not already present for CORS
# requests.
hdrs.ACCESS_CONTROL_ALLOW_ORIGIN,
hdrs.ACCESS_CONTROL_ALLOW_CREDENTIALS,
hdrs.ACCESS_CONTROL_EXPOSE_HEADERS,
):
continue
headers[name] = value
return headers

View File

@@ -0,0 +1,272 @@
"""Frigate HTTP views."""
from __future__ import annotations
import logging
import voluptuous as vol
from custom_components.frigate.api import FrigateApiClient, FrigateApiClientError
from custom_components.frigate.views import get_client_for_frigate_instance_id
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
_LOGGER: logging.Logger = logging.getLogger(__name__)
def async_setup(hass: HomeAssistant) -> None:
"""Set up the recorder websocket API."""
websocket_api.async_register_command(hass, ws_retain_event)
websocket_api.async_register_command(hass, ws_get_recordings)
websocket_api.async_register_command(hass, ws_get_recordings_summary)
websocket_api.async_register_command(hass, ws_get_events)
websocket_api.async_register_command(hass, ws_get_events_summary)
websocket_api.async_register_command(hass, ws_get_ptz_info)
def _get_client_or_send_error(
hass: HomeAssistant,
instance_id: str,
msg_id: int,
connection: websocket_api.ActiveConnection,
) -> FrigateApiClient | None:
"""Get the API client or send an error that it cannot be found."""
client = get_client_for_frigate_instance_id(hass, instance_id)
if client is None:
connection.send_error(
msg_id,
websocket_api.const.ERR_NOT_FOUND,
f"Unable to find Frigate instance with ID: {instance_id}",
)
return None
return client
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/event/retain",
vol.Required("instance_id"): str,
vol.Required("event_id"): str,
vol.Required("retain"): bool,
}
) # type: ignore[misc]
@websocket_api.async_response # type: ignore[misc]
async def ws_retain_event(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Un/Retain an event."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_retain(
msg["event_id"], msg["retain"], decode_json=False
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst un/retaining event {msg['event_id']} "
f"for Frigate instance {msg['instance_id']}",
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/recordings/get",
vol.Required("instance_id"): str,
vol.Required("camera"): str,
vol.Optional("after"): int,
vol.Optional("before"): int,
}
) # type: ignore[misc]
@websocket_api.async_response # type: ignore[misc]
async def ws_get_recordings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get recordings for a camera."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_recordings(
msg["camera"], msg.get("after"), msg.get("before"), decode_json=False
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving recordings for camera {msg['camera']} "
f"for Frigate instance {msg['instance_id']}",
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/recordings/summary",
vol.Required("instance_id"): str,
vol.Required("camera"): str,
vol.Optional("timezone"): str,
}
) # type: ignore[misc]
@websocket_api.async_response # type: ignore[misc]
async def ws_get_recordings_summary(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get recordings summary for a camera."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_recordings_summary(
msg["camera"], msg.get("timezone", "utc"), decode_json=False
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving recordings summary for camera "
f"{msg['camera']} for Frigate instance {msg['instance_id']}",
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/events/get",
vol.Required("instance_id"): str,
vol.Optional("cameras"): [str],
vol.Optional("labels"): [str],
vol.Optional("sub_labels"): [str],
vol.Optional("zones"): [str],
vol.Optional("after"): int,
vol.Optional("before"): int,
vol.Optional("limit"): int,
vol.Optional("has_clip"): bool,
vol.Optional("has_snapshot"): bool,
vol.Optional("has_snapshot"): bool,
vol.Optional("favorites"): bool,
}
) # type: ignore[misc]
@websocket_api.async_response # type: ignore[misc]
async def ws_get_events(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get events."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_events(
msg.get("cameras"),
msg.get("labels"),
msg.get("sub_labels"),
msg.get("zones"),
msg.get("after"),
msg.get("before"),
msg.get("limit"),
msg.get("has_clip"),
msg.get("has_snapshot"),
msg.get("favorites"),
decode_json=False,
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving events for cameras "
f"{msg['cameras']} for Frigate instance {msg['instance_id']}",
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/events/summary",
vol.Required("instance_id"): str,
vol.Optional("has_clip"): bool,
vol.Optional("has_snapshot"): bool,
vol.Optional("timezone"): str,
}
) # type: ignore[misc]
@websocket_api.async_response # type: ignore[misc]
async def ws_get_events_summary(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get events."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_event_summary(
msg.get("has_clip"),
msg.get("has_snapshot"),
msg.get("timezone", "utc"),
decode_json=False,
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving events summary for Frigate instance "
f"{msg['instance_id']}",
)
@websocket_api.websocket_command(
{
vol.Required("type"): "frigate/ptz/info",
vol.Required("instance_id"): str,
vol.Required("camera"): str,
}
) # type: ignore[misc]
@websocket_api.async_response # type: ignore[misc]
async def ws_get_ptz_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Get PTZ info."""
client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection)
if not client:
return
try:
connection.send_result(
msg["id"],
await client.async_get_ptz_info(
msg["camera"],
decode_json=False,
),
)
except FrigateApiClientError:
connection.send_error(
msg["id"],
"frigate_error",
f"API error whilst retrieving PTZ info for camera "
f"{msg['camera']} for Frigate instance {msg['instance_id']}",
)

Some files were not shown because too many files have changed in this diff Show More