"""Abode cloud push events.""" import collections import http.cookiejar import logging import jaraco from jaraco.itertools import always_iterable from . import socketio as sio from ._itertools import opt_single, single from .devices.alarm import Alarm from .devices.base import Device from .helpers import errors as ERROR from .helpers import timeline as TIMELINE from .helpers import urls log = logging.getLogger(__name__) SOCKETIO_URL = 'wss://my.goabode.com/socket.io/' def _cookie_string(cookies: http.cookiejar.CookieJar): """ >>> jar = http.cookiejar.CookieJar() >>> from test.test_http_cookiejar import interact_netscape >>> _ = interact_netscape(jar, 'http://any/', 'foo=bar', 'bing=baz') >>> _cookie_string(jar) in ['foo=bar; bing=baz', 'bing=baz; foo=bar'] True """ return "; ".join(f"{cookie.name}={cookie.value}" for cookie in cookies) class EventController: """Subscribes to events.""" def __init__(self, client, url=SOCKETIO_URL): self._client = client self._thread = None self._running = False self._connected = False # Setup callback dicts self._connection_status_callbacks = collections.defaultdict(list) self._device_callbacks = collections.defaultdict(list) self._event_callbacks = collections.defaultdict(list) self._timeline_callbacks = collections.defaultdict(list) # Setup SocketIO self._socketio = sio.SocketIO(url=url, origin=urls.BASE) # Setup SocketIO Callbacks self._socketio.on('started', self._on_socket_started) self._socketio.on('connected', self._on_socket_connected) self._socketio.on('disconnected', self._on_socket_disconnected) self._socketio.on('com.goabode.device.update', self._on_device_update) self._socketio.on('com.goabode.gateway.mode', self._on_mode_change) self._socketio.on('com.goabode.gateway.timeline', self._on_timeline_update) self._socketio.on('com.goabode.automation', self._on_automation_update) def start(self): """Start a thread to handle Abode SocketIO notifications.""" self._socketio.start() def stop(self): """Tell the subscription thread to terminate - will block.""" self._socketio.stop() def add_connection_status_callback(self, unique_id, callback): """Register callback for Abode server connection status.""" if not unique_id: return False log.debug("Subscribing to Abode connection updates for: %s", unique_id) self._connection_status_callbacks[unique_id].append(callback) return True def remove_connection_status_callback(self, unique_id): """Unregister connection status callbacks.""" if not unique_id: return False log.debug("Unsubscribing from Abode connection updates for : %s", unique_id) self._connection_status_callbacks[unique_id].clear() return True def add_device_callback(self, devices, callback): """Register a device callback.""" if not devices: return False for device in always_iterable(devices): # Device may be a device_id device_id = device # If they gave us an actual device, get that devices ID if isinstance(device, Device): device_id = device.id # Validate the device is valid if not self._client.get_device(device_id): raise jaraco.abode.Exception(ERROR.EVENT_DEVICE_INVALID) log.debug("Subscribing to updates for device_id: %s", device_id) self._device_callbacks[device_id].append(callback) return True def remove_all_device_callbacks(self, devices): """Unregister all callbacks for a device.""" if not devices: return False for device in always_iterable(devices): device_id = device if isinstance(device, Device): device_id = device.id if not self._client.get_device(device_id): raise jaraco.abode.Exception(ERROR.EVENT_DEVICE_INVALID) if device_id not in self._device_callbacks: return False log.debug("Unsubscribing from all updates for device_id: %s", device_id) self._device_callbacks[device_id].clear() return True def add_event_callback(self, event_groups, callback): """Register callback for a group of timeline events.""" if not event_groups: return False for event_group in always_iterable(event_groups): if event_group not in TIMELINE.Groups.ALL: raise jaraco.abode.Exception(ERROR.EVENT_GROUP_INVALID) log.debug("Subscribing to event group: %s", event_group) self._event_callbacks[event_group].append(callback) return True def add_timeline_callback(self, timeline_events, callback): """Register a callback for a specific timeline event.""" if not timeline_events: return False for timeline_event in always_iterable(timeline_events): if not isinstance(timeline_event, dict): raise jaraco.abode.Exception(ERROR.EVENT_CODE_MISSING) event_code = timeline_event.get('event_code') if not event_code: raise jaraco.abode.Exception(ERROR.EVENT_CODE_MISSING) log.debug("Subscribing to timeline event: %s", timeline_event) self._timeline_callbacks[event_code].append(callback) return True @property def connected(self): """Get the Abode connection status.""" return self._connected @property def socketio(self): """Get the SocketIO instance.""" return self._socketio def _on_socket_started(self): """Socket IO startup callback.""" self._socketio.set_cookie(_cookie_string(self._client._get_session().cookies)) def _on_socket_connected(self): """Socket IO connected callback.""" self._connected = True try: self._client.refresh() except Exception as exc: log.warning("Captured exception during Abode refresh: %s", exc) finally: # Callbacks should still execute even if refresh fails (Abode # server issues) so that the entity availability in Home Assistant # is updated since we are in fact connected to the web socket. for callbacks in self._connection_status_callbacks.values(): for callback in callbacks: _execute_callback(callback) def _on_socket_disconnected(self): """Socket IO disconnected callback.""" self._connected = False for callbacks in self._connection_status_callbacks.values(): for callback in callbacks: _execute_callback(callback) def _on_device_update(self, devid): """Device callback from Abode SocketIO server.""" devid = opt_single(devid) if devid is None: log.warning("Device update with no device id.") return log.debug("Device update event for device ID: %s", devid) device = self._client.get_device(devid, True) if not device: log.debug("Got device update for unknown device: %s", devid) return for callback in self._device_callbacks[device.id]: _execute_callback(callback, device) def _on_mode_change(self, mode): """Mode change broadcast from Abode SocketIO server.""" mode = opt_single(mode) if mode is None: log.warning("Mode change event with no mode.") return if not mode or mode.lower() not in Alarm.all_modes: log.warning("Mode change event with unknown mode: %s", mode) return log.debug("Alarm mode change event to: %s", mode) # We're just going to convert it to an Alarm device alarm_device = self._client.get_alarm(refresh=True) # At the time of development, refreshing after mode change notification # didn't seem to get the latest update immediately. As such, we will # force the mode status now to match the notification. alarm_device._state['mode']['area_1'] = mode for callback in self._device_callbacks[alarm_device.id]: _execute_callback(callback, alarm_device) def _on_timeline_update(self, event): """Timeline update broadcast from Abode SocketIO server.""" event = single(event) event_type = event.get('event_type') event_code = event.get('event_code') if not event_type or not event_code: log.warning("Invalid timeline update event: %s", event) return log.debug( "Timeline event received: %s - %s (%s)", event.get('event_name'), event_type, event_code, ) # Compress our callbacks into those that match this event_code # or ones registered to get callbacks for all events codes = (event_code, TIMELINE.ALL['event_code']) all_callbacks = [self._timeline_callbacks[code] for code in codes] for callbacks in all_callbacks: for callback in callbacks: _execute_callback(callback, event) # Attempt to map the event code to a group and callback event_group = TIMELINE.map_event_code(event_code) for callback in self._event_callbacks[event_group]: _execute_callback(callback, event) def _on_automation_update(self, event): """Automation update broadcast from Abode SocketIO server.""" event_group = TIMELINE.Groups.AUTOMATION_EDIT event = single(event) for callback in self._event_callbacks[event_group]: _execute_callback(callback, event) def _execute_callback(callback, *args, **kwargs): # Callback with some data, capturing any exceptions to prevent chaos try: callback(*args, **kwargs) except Exception as exc: log.warning("Captured exception during callback: %s", exc)