Home Assistant Git Exporter
This commit is contained in:
5
config/automation/.vscode/settings.json
vendored
Normal file
5
config/automation/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.yaml": "home-assistant"
|
||||
}
|
||||
}
|
||||
13
config/automation/broadlink.yaml.old
Normal file
13
config/automation/broadlink.yaml.old
Normal 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
|
||||
0
config/automation/chauff_sdb.yaml
Normal file
0
config/automation/chauff_sdb.yaml
Normal file
0
config/automation/climate.yaml
Normal file
0
config/automation/climate.yaml
Normal file
1
config/automation/telegram.yaml
Normal file
1
config/automation/telegram.yaml
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
0
config/automation/telegram_camera.yaml
Normal file
0
config/automation/telegram_camera.yaml
Normal file
0
config/automation/telegram_chauff.yaml
Normal file
0
config/automation/telegram_chauff.yaml
Normal file
0
config/automation/telegram_volet.yaml
Normal file
0
config/automation/telegram_volet.yaml
Normal file
0
config/automation/test.yaml
Normal file
0
config/automation/test.yaml
Normal file
0
config/automation/time.yaml
Normal file
0
config/automation/time.yaml
Normal file
0
config/automation/vmc.yaml
Normal file
0
config/automation/vmc.yaml
Normal file
0
config/automation/volet.yaml
Normal file
0
config/automation/volet.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
330
config/custom_components/blitzortung/__init__.py
Normal file
330
config/custom_components/blitzortung/__init__.py
Normal 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()
|
||||
91
config/custom_components/blitzortung/config_flow.py
Normal file
91
config/custom_components/blitzortung/config_flow.py
Normal 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,
|
||||
}
|
||||
),
|
||||
)
|
||||
33
config/custom_components/blitzortung/const.py
Normal file
33
config/custom_components/blitzortung/const.py
Normal 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"
|
||||
217
config/custom_components/blitzortung/geo_location.py
Normal file
217
config/custom_components/blitzortung/geo_location.py
Normal 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,
|
||||
)
|
||||
466
config/custom_components/blitzortung/geohash.py
Normal file
466
config/custom_components/blitzortung/geohash.py
Normal 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
|
||||
58
config/custom_components/blitzortung/geohash_utils.py
Normal file
58
config/custom_components/blitzortung/geohash_utils.py
Normal 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
|
||||
17
config/custom_components/blitzortung/manifest.json
Normal file
17
config/custom_components/blitzortung/manifest.json
Normal 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"
|
||||
}
|
||||
305
config/custom_components/blitzortung/mqtt.py
Normal file
305
config/custom_components/blitzortung/mqtt.py
Normal 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,
|
||||
)
|
||||
225
config/custom_components/blitzortung/sensor.py
Normal file
225
config/custom_components/blitzortung/sensor.py
Normal 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
|
||||
29
config/custom_components/blitzortung/strings.json
Normal file
29
config/custom_components/blitzortung/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
config/custom_components/blitzortung/translations/en.json
Normal file
29
config/custom_components/blitzortung/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
config/custom_components/blitzortung/translations/fi.json
Normal file
29
config/custom_components/blitzortung/translations/fi.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
config/custom_components/blitzortung/translations/fr.json
Normal file
29
config/custom_components/blitzortung/translations/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
config/custom_components/blitzortung/translations/hr.json
Normal file
29
config/custom_components/blitzortung/translations/hr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
config/custom_components/blitzortung/translations/nb.json
Normal file
29
config/custom_components/blitzortung/translations/nb.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
config/custom_components/blitzortung/translations/nl.json
Normal file
29
config/custom_components/blitzortung/translations/nl.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
config/custom_components/blitzortung/translations/pl.json
Normal file
29
config/custom_components/blitzortung/translations/pl.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
config/custom_components/blitzortung/translations/sl.json
Normal file
29
config/custom_components/blitzortung/translations/sl.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
config/custom_components/blitzortung/version.py
Normal file
1
config/custom_components/blitzortung/version.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "1.1.0"
|
||||
102
config/custom_components/ecoflow_cloud/__init__.py
Normal file
102
config/custom_components/ecoflow_cloud/__init__.py
Normal 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)
|
||||
34
config/custom_components/ecoflow_cloud/button.py
Normal file
34
config/custom_components/ecoflow_cloud/button.py
Normal 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))
|
||||
|
||||
35
config/custom_components/ecoflow_cloud/config/const.py
Normal file
35
config/custom_components/ecoflow_cloud/config/const.py
Normal 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]
|
||||
72
config/custom_components/ecoflow_cloud/config_flow.py
Normal file
72
config/custom_components/ecoflow_cloud/config_flow.py
Normal 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,
|
||||
})
|
||||
)
|
||||
70
config/custom_components/ecoflow_cloud/devices/__init__.py
Normal file
70
config/custom_components/ecoflow_cloud/devices/__init__.py
Normal 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 []
|
||||
255
config/custom_components/ecoflow_cloud/devices/const.py
Normal file
255
config/custom_components/ecoflow_cloud/devices/const.py
Normal 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"
|
||||
200
config/custom_components/ecoflow_cloud/devices/delta2.py
Normal file
200
config/custom_components/ecoflow_cloud/devices/delta2.py
Normal 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 []
|
||||
229
config/custom_components/ecoflow_cloud/devices/delta2_max.py
Normal file
229
config/custom_components/ecoflow_cloud/devices/delta2_max.py
Normal 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 []
|
||||
160
config/custom_components/ecoflow_cloud/devices/delta_max.py
Normal file
160
config/custom_components/ecoflow_cloud/devices/delta_max.py
Normal 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 []
|
||||
147
config/custom_components/ecoflow_cloud/devices/delta_mini.py
Normal file
147
config/custom_components/ecoflow_cloud/devices/delta_mini.py
Normal 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 []
|
||||
212
config/custom_components/ecoflow_cloud/devices/delta_pro.py
Normal file
212
config/custom_components/ecoflow_cloud/devices/delta_pro.py
Normal 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 []
|
||||
129
config/custom_components/ecoflow_cloud/devices/glacier.py
Normal file
129
config/custom_components/ecoflow_cloud/devices/glacier.py
Normal 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 [
|
||||
|
||||
]
|
||||
104
config/custom_components/ecoflow_cloud/devices/powerstream.py
Normal file
104
config/custom_components/ecoflow_cloud/devices/powerstream.py
Normal 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}}),
|
||||
]
|
||||
32
config/custom_components/ecoflow_cloud/devices/registry.py
Normal file
32
config/custom_components/ecoflow_cloud/devices/registry.py
Normal 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()
|
||||
}
|
||||
135
config/custom_components/ecoflow_cloud/devices/river2.py
Normal file
135
config/custom_components/ecoflow_cloud/devices/river2.py
Normal 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 []
|
||||
163
config/custom_components/ecoflow_cloud/devices/river2_max.py
Normal file
163
config/custom_components/ecoflow_cloud/devices/river2_max.py
Normal 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 []
|
||||
136
config/custom_components/ecoflow_cloud/devices/river2_pro.py
Normal file
136
config/custom_components/ecoflow_cloud/devices/river2_pro.py
Normal 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 []
|
||||
117
config/custom_components/ecoflow_cloud/devices/river_max.py
Normal file
117
config/custom_components/ecoflow_cloud/devices/river_max.py
Normal 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 []
|
||||
138
config/custom_components/ecoflow_cloud/devices/river_pro.py
Normal file
138
config/custom_components/ecoflow_cloud/devices/river_pro.py
Normal 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 []
|
||||
91
config/custom_components/ecoflow_cloud/devices/wave2.py
Normal file
91
config/custom_components/ecoflow_cloud/devices/wave2.py
Normal 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 []
|
||||
30
config/custom_components/ecoflow_cloud/diagnostics.py
Normal file
30
config/custom_components/ecoflow_cloud/diagnostics.py
Normal 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
|
||||
151
config/custom_components/ecoflow_cloud/entities/__init__.py
Normal file
151
config/custom_components/ecoflow_cloud/entities/__init__.py
Normal 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
|
||||
|
||||
17
config/custom_components/ecoflow_cloud/manifest.json
Normal file
17
config/custom_components/ecoflow_cloud/manifest.json
Normal 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"
|
||||
}
|
||||
340
config/custom_components/ecoflow_cloud/mqtt/ecoflow_mqtt.py
Normal file
340
config/custom_components/ecoflow_cloud/mqtt/ecoflow_mqtt.py
Normal 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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
105
config/custom_components/ecoflow_cloud/mqtt/proto/platform.proto
Normal file
105
config/custom_components/ecoflow_cloud/mqtt/proto/platform.proto
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
34
config/custom_components/ecoflow_cloud/mqtt/utils.py
Normal file
34
config/custom_components/ecoflow_cloud/mqtt/utils.py
Normal 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()
|
||||
|
||||
86
config/custom_components/ecoflow_cloud/number.py
Normal file
86
config/custom_components/ecoflow_cloud/number.py
Normal 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
|
||||
|
||||
9
config/custom_components/ecoflow_cloud/recorder.py
Normal file
9
config/custom_components/ecoflow_cloud/recorder.py
Normal 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}
|
||||
48
config/custom_components/ecoflow_cloud/select.py
Normal file
48
config/custom_components/ecoflow_cloud/select.py
Normal 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"
|
||||
393
config/custom_components/ecoflow_cloud/sensor.py
Normal file
393
config/custom_components/ecoflow_cloud/sensor.py
Normal 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()
|
||||
73
config/custom_components/ecoflow_cloud/switch.py
Normal file
73
config/custom_components/ecoflow_cloud/switch.py
Normal 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"
|
||||
26
config/custom_components/ecoflow_cloud/translations/de.json
Normal file
26
config/custom_components/ecoflow_cloud/translations/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
config/custom_components/ecoflow_cloud/translations/en.json
Normal file
26
config/custom_components/ecoflow_cloud/translations/en.json
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
config/custom_components/ecoflow_cloud/translations/fr.json
Normal file
27
config/custom_components/ecoflow_cloud/translations/fr.json
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
483
config/custom_components/frigate/__init__.py
Normal file
483
config/custom_components/frigate/__init__.py
Normal 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()
|
||||
264
config/custom_components/frigate/api.py
Normal file
264
config/custom_components/frigate/api.py
Normal 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
|
||||
303
config/custom_components/frigate/binary_sensor.py
Normal file
303
config/custom_components/frigate/binary_sensor.py
Normal 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)
|
||||
465
config/custom_components/frigate/camera.py
Normal file
465
config/custom_components/frigate/camera.py
Normal 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
|
||||
192
config/custom_components/frigate/config_flow.py
Normal file
192
config/custom_components/frigate/config_flow.py
Normal 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)),
|
||||
)
|
||||
93
config/custom_components/frigate/const.py
Normal file
93
config/custom_components/frigate/const.py
Normal 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"
|
||||
37
config/custom_components/frigate/diagnostics.py
Normal file
37
config/custom_components/frigate/diagnostics.py
Normal 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
|
||||
80
config/custom_components/frigate/icons.py
Normal file
80
config/custom_components/frigate/icons.py
Normal 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
|
||||
123
config/custom_components/frigate/image.py
Normal file
123
config/custom_components/frigate/image.py
Normal 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
|
||||
18
config/custom_components/frigate/manifest.json
Normal file
18
config/custom_components/frigate/manifest.json
Normal 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"
|
||||
}
|
||||
1346
config/custom_components/frigate/media_source.py
Normal file
1346
config/custom_components/frigate/media_source.py
Normal file
File diff suppressed because it is too large
Load Diff
242
config/custom_components/frigate/number.py
Normal file
242
config/custom_components/frigate/number.py
Normal 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
|
||||
712
config/custom_components/frigate/sensor.py
Normal file
712
config/custom_components/frigate/sensor.py
Normal 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
|
||||
103
config/custom_components/frigate/services.yaml
Normal file
103
config/custom_components/frigate/services.yaml
Normal 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:
|
||||
182
config/custom_components/frigate/switch.py
Normal file
182
config/custom_components/frigate/switch.py
Normal 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,
|
||||
)
|
||||
36
config/custom_components/frigate/translations/ca.json
Normal file
36
config/custom_components/frigate/translations/ca.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
config/custom_components/frigate/translations/de.json
Normal file
35
config/custom_components/frigate/translations/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
config/custom_components/frigate/translations/en.json
Normal file
36
config/custom_components/frigate/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
config/custom_components/frigate/translations/fr.json
Normal file
36
config/custom_components/frigate/translations/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
config/custom_components/frigate/translations/pt-BR.json
Normal file
35
config/custom_components/frigate/translations/pt-BR.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
config/custom_components/frigate/translations/pt_br.json
Normal file
35
config/custom_components/frigate/translations/pt_br.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
config/custom_components/frigate/translations/ru.json
Normal file
36
config/custom_components/frigate/translations/ru.json
Normal 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": "Режим расширенных настроек отключен; доступны только основные параметры"
|
||||
}
|
||||
}
|
||||
}
|
||||
100
config/custom_components/frigate/update.py
Normal file
100
config/custom_components/frigate/update.py
Normal 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}"
|
||||
579
config/custom_components/frigate/views.py
Normal file
579
config/custom_components/frigate/views.py
Normal 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
|
||||
272
config/custom_components/frigate/ws_api.py
Normal file
272
config/custom_components/frigate/ws_api.py
Normal 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
Reference in New Issue
Block a user