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:
|
api:
|
||||||
|
|
||||||
wake_on_lan:
|
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