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