""" Lutron RadioRA 2 module for interacting with the Main Repeater. Basic operations for enumerating and controlling the loads are supported. """ __author__ = "Dima Zavin" __copyright__ = "Copyright 2016, Dima Zavin" from enum import Enum import logging import socket import telnetlib import threading import time from typing import Any, Callable, Dict, Type _LOGGER = logging.getLogger(__name__) # We brute force exception handling in a number of areas to ensure # connections can be recovered _EXPECTED_NETWORK_EXCEPTIONS = ( BrokenPipeError, # OSError: [Errno 101] Network unreachable OSError, EOFError, TimeoutError, socket.timeout, ) class LutronException(Exception): """Top level module exception.""" pass class IntegrationIdExistsError(LutronException): """Asserted when there's an attempt to register a duplicate integration id.""" pass class ConnectionExistsError(LutronException): """Raised when a connection already exists (e.g. user calls connect() twice).""" pass class InvalidSubscription(LutronException): """Raised when an invalid subscription is requested (e.g. calling Lutron.subscribe on an incompatible object.""" pass class LutronConnection(threading.Thread): """Encapsulates the connection to the Lutron controller.""" USER_PROMPT = b'login: ' PW_PROMPT = b'password: ' PROMPT = b'GNET> ' def __init__(self, host, user, password, recv_callback): """Initializes the lutron connection, doesn't actually connect.""" threading.Thread.__init__(self) self._host = host self._user = user.encode('ascii') self._password = password.encode('ascii') self._telnet = None self._connected = False self._lock = threading.Lock() self._connect_cond = threading.Condition(lock=self._lock) self._recv_cb = recv_callback self._done = False self.setDaemon(True) def connect(self): """Connects to the lutron controller.""" if self._connected or self.is_alive(): raise ConnectionExistsError("Already connected") # After starting the thread we wait for it to post us # an event signifying that connection is established. This # ensures that the caller only resumes when we are fully connected. self.start() with self._lock: self._connect_cond.wait_for(lambda: self._connected) def _send_locked(self, cmd): """Sends the specified command to the lutron controller. Assumes self._lock is held. """ _LOGGER.debug("Sending: %s" % cmd) try: self._telnet.write(cmd.encode('ascii') + b'\r\n') except _EXPECTED_NETWORK_EXCEPTIONS: _LOGGER.exception("Error sending {}".format(cmd)) self._disconnect_locked() def send(self, cmd): """Sends the specified command to the lutron controller. Must not hold self._lock. """ with self._lock: if not self._connected: _LOGGER.debug("Ignoring send of '%s' because we are disconnected." % cmd) return self._send_locked(cmd) def _do_login_locked(self): """Executes the login procedure (telnet) as well as setting up some connection defaults like turning off the prompt, etc.""" self._telnet = telnetlib.Telnet(self._host, timeout=2) # 2 second timeout # Ensure we know that connection goes away somewhat quickly try: sock = self._telnet.get_socket() sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) # Some operating systems may not include TCP_KEEPIDLE (macOS, variants of Windows) if hasattr(socket, 'TCP_KEEPIDLE'): # Send keepalive probes after 60 seconds of inactivity sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) # Wait 10 seconds for an ACK sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) # Send 3 probes before we give up sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) except OSError: _LOGGER.exception('error configuring socket') self._telnet.read_until(LutronConnection.USER_PROMPT, timeout=3) self._telnet.write(self._user + b'\r\n') self._telnet.read_until(LutronConnection.PW_PROMPT, timeout=3) self._telnet.write(self._password + b'\r\n') self._telnet.read_until(LutronConnection.PROMPT, timeout=3) self._send_locked("#MONITORING,12,2") self._send_locked("#MONITORING,255,2") self._send_locked("#MONITORING,3,1") self._send_locked("#MONITORING,4,1") self._send_locked("#MONITORING,5,1") self._send_locked("#MONITORING,6,1") self._send_locked("#MONITORING,8,1") def _disconnect_locked(self): """Closes the current connection. Assume self._lock is held.""" was_connected = self._connected self._connected = False self._connect_cond.notify_all() self._telnet = None if was_connected: _LOGGER.warning("Disconnected") def _maybe_reconnect(self): """Reconnects to the controller if we have been previously disconnected.""" with self._lock: if not self._connected: _LOGGER.info("Connecting") # This can throw an exception, but we'll catch it in run() self._do_login_locked() self._connected = True self._connect_cond.notify_all() _LOGGER.info("Connected") def _main_loop(self): """Main body of the the thread function. This will maintain connection and receive remote status updates. """ while True: line = b'' try: self._maybe_reconnect() # If someone is sending a command, we can lose our connection so grab a # copy beforehand. We don't need the lock because if the connection is # open, we are the only ones that will read from telnet (the reconnect # code runs synchronously in this loop). t = self._telnet if t is not None: line = t.read_until(b"\n", timeout=3) else: raise EOFError('Telnet object already torn down') except _EXPECTED_NETWORK_EXCEPTIONS: _LOGGER.exception("Uncaught exception") try: self._lock.acquire() self._disconnect_locked() # don't spam reconnect time.sleep(1) continue finally: self._lock.release() self._recv_cb(line.decode('ascii').rstrip()) def run(self): """Main entry point into our receive thread. It just wraps _main_loop() so we can catch exceptions. """ _LOGGER.info("Started") try: self._main_loop() except Exception: _LOGGER.exception("Uncaught exception") raise class LutronXmlDbParser(object): """The parser for Lutron XML database. The database describes all the rooms (Area), keypads (Device), and switches (Output). We handle the most relevant features, but some things like LEDs, etc. are not implemented.""" def __init__(self, lutron, xml_db_str): """Initializes the XML parser, takes the raw XML data as string input.""" self._lutron = lutron self._xml_db_str = xml_db_str self.areas = [] self.project_name = None def parse(self): """Main entrypoint into the parser. It interprets and creates all the relevant Lutron objects and stuffs them into the appropriate hierarchy.""" import xml.etree.ElementTree as ET root = ET.fromstring(self._xml_db_str) # The structure is something like this: # <Areas> # <Area ...> # <DeviceGroups ...> # <Scenes ...> # <ShadeGroups ...> # <Outputs ...> # <Areas ...> # <Area ...> # The GUID is unique to the repeater and is useful for constructing unique # identifiers that won't change over time. self._lutron.set_guid(root.find('GUID').text) # First area is useless, it's the top-level project area that defines the # "house". It contains the real nested Areas tree, which is the one we want. top_area = root.find('Areas').find('Area') self.project_name = top_area.get('Name') areas = top_area.find('Areas') for area_xml in areas.getiterator('Area'): area = self._parse_area(area_xml) self.areas.append(area) return True def _parse_area(self, area_xml): """Parses an Area tag, which is effectively a room, depending on how the Lutron controller programming was done.""" area = Area(self._lutron, name=area_xml.get('Name'), integration_id=int(area_xml.get('IntegrationID')), occupancy_group_id=area_xml.get('OccupancyGroupAssignedToID')) for output_xml in area_xml.find('Outputs'): output = self._parse_output(output_xml) area.add_output(output) # device group in our case means keypad # device_group.get('Name') is the location of the keypad for device_group in area_xml.find('DeviceGroups'): if device_group.tag == 'DeviceGroup': devs = device_group.find('Devices') elif device_group.tag == 'Device': devs = [device_group] else: _LOGGER.info("Unknown tag in DeviceGroups child %s" % devs) devs = [] for device_xml in devs: if device_xml.tag != 'Device': continue if device_xml.get('DeviceType') in ( 'HWI_SEETOUCH_KEYPAD', 'SEETOUCH_KEYPAD', 'SEETOUCH_TABLETOP_KEYPAD', 'PICO_KEYPAD', 'HYBRID_SEETOUCH_KEYPAD', 'MAIN_REPEATER', 'HOMEOWNER_KEYPAD'): keypad = self._parse_keypad(device_xml, device_group) area.add_keypad(keypad) elif device_xml.get('DeviceType') == 'MOTION_SENSOR': motion_sensor = self._parse_motion_sensor(device_xml) area.add_sensor(motion_sensor) #elif device_xml.get('DeviceType') == 'VISOR_CONTROL_RECEIVER': return area def _parse_output(self, output_xml): """Parses an output, which is generally a switch controlling a set of lights/outlets, etc.""" output = Output(self._lutron, name=output_xml.get('Name'), watts=int(output_xml.get('Wattage')), output_type=output_xml.get('OutputType'), integration_id=int(output_xml.get('IntegrationID')), uuid=output_xml.get('UUID')) return output def _parse_keypad(self, keypad_xml, device_group): """Parses a keypad device (the Visor receiver is technically a keypad too).""" keypad = Keypad(self._lutron, name=keypad_xml.get('Name'), keypad_type=keypad_xml.get('DeviceType'), location=device_group.get('Name'), integration_id=int(keypad_xml.get('IntegrationID')), uuid=keypad_xml.get('UUID')) components = keypad_xml.find('Components') if components is None: return keypad for comp in components: if comp.tag != 'Component': continue comp_type = comp.get('ComponentType') if comp_type == 'BUTTON': button = self._parse_button(keypad, comp) keypad.add_button(button) elif comp_type == 'LED': led = self._parse_led(keypad, comp) keypad.add_led(led) return keypad def _parse_button(self, keypad, component_xml): """Parses a button device that part of a keypad.""" button_xml = component_xml.find('Button') name = button_xml.get('Engraving') button_type = button_xml.get('ButtonType') direction = button_xml.get('Direction') # Hybrid keypads have dimmer buttons which have no engravings. if button_type == 'SingleSceneRaiseLower': name = 'Dimmer ' + direction if not name: name = "Unknown Button" button = Button(self._lutron, keypad, name=name, num=int(component_xml.get('ComponentNumber')), button_type=button_type, direction=direction, uuid=button_xml.get('UUID')) return button def _parse_led(self, keypad, component_xml): """Parses an LED device that part of a keypad.""" component_num = int(component_xml.get('ComponentNumber')) led_base = 80 if keypad.type == 'MAIN_REPEATER': led_base = 100 led_num = component_num - led_base led = Led(self._lutron, keypad, name=('LED %d' % led_num), led_num=led_num, component_num=component_num, uuid=component_xml.find('LED').get('UUID')) return led def _parse_motion_sensor(self, sensor_xml): """Parses a motion sensor object. TODO: We don't actually do anything with these yet. There's a lot of info that needs to be managed to do this right. We'd have to manage the occupancy groups, what's assigned to them, and when they go (un)occupied. We'll handle this later. """ return MotionSensor(self._lutron, name=sensor_xml.get('Name'), integration_id=int(sensor_xml.get('IntegrationID')), uuid=sensor_xml.get('UUID')) class Lutron(object): """Main Lutron Controller class. This object owns the connection to the controller, the rooms that exist in the network, handles dispatch of incoming status updates, etc. """ # All Lutron commands start with one of these characters # See http://www.lutron.com/TechnicalDocumentLibrary/040249.pdf OP_EXECUTE = '#' OP_QUERY = '?' OP_RESPONSE = '~' def __init__(self, host, user, password): """Initializes the Lutron object. No connection is made to the remote device.""" self._host = host self._user = user self._password = password self._name = None self._conn = LutronConnection(host, user, password, self._recv) self._ids = {} self._legacy_subscribers = {} self._areas = [] self._guid = None @property def areas(self): """Return the areas that were discovered for this Lutron controller.""" return self._areas def set_guid(self, guid): self._guid = guid @property def guid(self): return self._guid @property def name(self): return self._name def subscribe(self, obj, handler): """Subscribes to status updates of the requested object. DEPRECATED The handler will be invoked when the controller sends a notification regarding changed state. The user can then further query the object for the state itself.""" if not isinstance(obj, LutronEntity): raise InvalidSubscription("Subscription target not a LutronEntity") _LOGGER.warning("DEPRECATED: Subscribing via Lutron.subscribe is obsolete. " "Please use LutronEntity.subscribe") if obj not in self._legacy_subscribers: self._legacy_subscribers[obj] = handler obj.subscribe(self._dispatch_legacy_subscriber, None) def register_id(self, cmd_type, obj): """Registers an object (through its integration id) to receive update notifications. This is the core mechanism how Output and Keypad objects get notified when the controller sends status updates.""" ids = self._ids.setdefault(cmd_type, {}) if obj.id in ids: raise IntegrationIdExistsError self._ids[cmd_type][obj.id] = obj def _dispatch_legacy_subscriber(self, obj, *args, **kwargs): """This dispatches the registered callback for 'obj'. This is only used for legacy subscribers since new users should register with the target object directly.""" if obj in self._legacy_subscribers: self._legacy_subscribers[obj](obj) def _recv(self, line): """Invoked by the connection manager to process incoming data.""" if line == '': return # Only handle query response messages, which are also sent on remote status # updates (e.g. user manually pressed a keypad button) if line[0] != Lutron.OP_RESPONSE: _LOGGER.debug("ignoring %s" % line) return parts = line[1:].split(',') cmd_type = parts[0] integration_id = int(parts[1]) args = parts[2:] if cmd_type not in self._ids: _LOGGER.info("Unknown cmd %s (%s)" % (cmd_type, line)) return ids = self._ids[cmd_type] if integration_id not in ids: _LOGGER.warning("Unknown id %d (%s)" % (integration_id, line)) return obj = ids[integration_id] handled = obj.handle_update(args) def connect(self): """Connects to the Lutron controller to send and receive commands and status""" self._conn.connect() def send(self, op, cmd, integration_id, *args): """Formats and sends the requested command to the Lutron controller.""" out_cmd = ",".join( (cmd, str(integration_id)) + tuple((str(x) for x in args))) self._conn.send(op + out_cmd) def load_xml_db(self, cache_path=None): """Load the Lutron database from the server. If a locally cached copy is available, use that instead. """ xml_db = None loaded_from = None if cache_path: try: with open(cache_path, 'rb') as f: xml_db = f.read() loaded_from = 'cache' except Exception: pass if not loaded_from: import urllib.request url = 'http://' + self._host + '/DbXmlInfo.xml' with urllib.request.urlopen(url) as xmlfile: xml_db = xmlfile.read() loaded_from = 'repeater' _LOGGER.info("Loaded xml db from %s" % loaded_from) parser = LutronXmlDbParser(lutron=self, xml_db_str=xml_db) assert(parser.parse()) # throw our own exception self._areas = parser.areas self._name = parser.project_name _LOGGER.info('Found Lutron project: %s, %d areas' % ( self._name, len(self.areas))) if cache_path and loaded_from == 'repeater': with open(cache_path, 'wb') as f: f.write(xml_db) return True class _RequestHelper(object): """A class to help with sending queries to the controller and waiting for responses. It is a wrapper used to help with executing a user action and then waiting for an event when that action completes. The user calls request() and gets back a threading.Event on which they then wait. If multiple clients of a lutron object (say an Output) want to get a status update on the current brightness (output level), we don't want to spam the controller with (near)identical requests. So, if a request is pending, we just enqueue another waiter on the pending request and return a new Event object. All waiters will be woken up when the reply is received and the wait list is cleared. NOTE: Only the first enqueued action is executed as the assumption is that the queries will be identical in nature. """ def __init__(self): """Initialize the request helper class.""" self.__lock = threading.Lock() self.__events = [] def request(self, action): """Request an action to be performed, in case one.""" ev = threading.Event() first = False with self.__lock: if len(self.__events) == 0: first = True self.__events.append(ev) if first: action() return ev def notify(self): with self.__lock: events = self.__events self.__events = [] for ev in events: ev.set() # This describes the type signature of the callback that LutronEntity # subscribers must provide. LutronEventHandler = Callable[['LutronEntity', Any, 'LutronEvent', Dict], None] class LutronEvent(Enum): """Base class for the events LutronEntity-derived objects can produce.""" pass class LutronEntity(object): """Base class for all the Lutron objects we'd like to manage. Just holds basic common info we'd rather not manage repeatedly.""" def __init__(self, lutron, name, uuid): """Initializes the base class with common, basic data.""" self._lutron = lutron self._name = name self._subscribers = [] self._uuid = uuid @property def name(self): """Returns the entity name (e.g. Pendant).""" return self._name @property def uuid(self): return self._uuid def _dispatch_event(self, event: LutronEvent, params: Dict): """Dispatches the specified event to all the subscribers.""" for handler, context in self._subscribers: handler(self, context, event, params) def subscribe(self, handler: LutronEventHandler, context): """Subscribes to events from this entity. handler: A callable object that takes the following arguments (in order) obj: the LutrongEntity object that generated the event context: user-supplied (to subscribe()) context object event: the LutronEvent that was generated. params: a dict of event-specific parameters context: User-supplied, opaque object that will be passed to handler. """ self._subscribers.append((handler, context)) def handle_update(self, args): """The handle_update callback is invoked when an event is received for the this entity. Returns: True - If event was valid and was handled. False - otherwise. """ return False class Output(LutronEntity): """This is the output entity in Lutron universe. This generally refers to a switched/dimmed load, e.g. light fixture, outlet, etc.""" _CMD_TYPE = 'OUTPUT' _ACTION_ZONE_LEVEL = 1 class Event(LutronEvent): """Output events that can be generated. LEVEL_CHANGED: The output level has changed. Params: level: new output level (float) """ LEVEL_CHANGED = 1 def __init__(self, lutron, name, watts, output_type, integration_id, uuid): """Initializes the Output.""" super(Output, self).__init__(lutron, name, uuid) self._watts = watts self._output_type = output_type self._level = 0.0 self._query_waiters = _RequestHelper() self._integration_id = integration_id self._lutron.register_id(Output._CMD_TYPE, self) def __str__(self): """Returns a pretty-printed string for this object.""" return 'Output name: "%s" watts: %d type: "%s" id: %d' % ( self._name, self._watts, self._output_type, self._integration_id) def __repr__(self): """Returns a stringified representation of this object.""" return str({'name': self._name, 'watts': self._watts, 'type': self._output_type, 'id': self._integration_id}) @property def id(self): """The integration id""" return self._integration_id def handle_update(self, args): """Handles an event update for this object, e.g. dimmer level change.""" _LOGGER.debug("handle_update %d -- %s" % (self._integration_id, args)) state = int(args[0]) if state != Output._ACTION_ZONE_LEVEL: return False level = float(args[1]) _LOGGER.debug("Updating %d(%s): s=%d l=%f" % ( self._integration_id, self._name, state, level)) self._level = level self._query_waiters.notify() self._dispatch_event(Output.Event.LEVEL_CHANGED, {'level': self._level}) return True def __do_query_level(self): """Helper to perform the actual query the current dimmer level of the output. For pure on/off loads the result is either 0.0 or 100.0.""" self._lutron.send(Lutron.OP_QUERY, Output._CMD_TYPE, self._integration_id, Output._ACTION_ZONE_LEVEL) def last_level(self): """Returns last cached value of the output level, no query is performed.""" return self._level @property def level(self): """Returns the current output level by querying the remote controller.""" ev = self._query_waiters.request(self.__do_query_level) ev.wait(1.0) return self._level @level.setter def level(self, new_level): """Sets the new output level.""" if self._level == new_level: return self._lutron.send(Lutron.OP_EXECUTE, Output._CMD_TYPE, self._integration_id, Output._ACTION_ZONE_LEVEL, "%.2f" % new_level) self._level = new_level ## At some later date, we may want to also specify fade and delay times # def set_level(self, new_level, fade_time, delay): # self._lutron.send(Lutron.OP_EXECUTE, Output._CMD_TYPE, # Output._ACTION_ZONE_LEVEL, new_level, fade_time, delay) @property def watts(self): """Returns the configured maximum wattage for this output (not an actual measurement).""" return self._watts @property def type(self): """Returns the output type. At present AUTO_DETECT or NON_DIM.""" return self._output_type @property def is_dimmable(self): """Returns a boolean of whether or not the output is dimmable.""" return self.type != 'NON_DIM' and not self.type.startswith('CCO_') class KeypadComponent(LutronEntity): """Base class for a keypad component such as a button, or an LED.""" def __init__(self, lutron, keypad, name, num, component_num, uuid): """Initializes the base keypad component class.""" super(KeypadComponent, self).__init__(lutron, name, uuid) self._keypad = keypad self._num = num self._component_num = component_num @property def number(self): """Returns the user-friendly number of this component (e.g. Button 1, or LED 1.""" return self._num @property def component_number(self): """Return the lutron component number, which is referenced in commands and events. This is different from KeypadComponent.number because this property is only used for interfacing with the controller.""" return self._component_num def handle_update(self, action, params): """Handle the specified action on this component.""" _LOGGER.debug('Keypad: "%s" Handling "%s" Action: %s Params: %s"' % ( self._keypad.name, self.name, action, params)) return False class Button(KeypadComponent): """This object represents a keypad button that we can trigger and handle events for (button presses).""" _ACTION_PRESS = 3 _ACTION_RELEASE = 4 class Event(LutronEvent): """Button events that can be generated. PRESSED: The button has been pressed. Params: None RELEASED: The button has been released. Not all buttons generate this event. Params: None """ PRESSED = 1 RELEASED = 2 def __init__(self, lutron, keypad, name, num, button_type, direction, uuid): """Initializes the Button class.""" super(Button, self).__init__(lutron, keypad, name, num, num, uuid) self._button_type = button_type self._direction = direction def __str__(self): """Pretty printed string value of the Button object.""" return 'Button name: "%s" num: %d type: "%s" direction: "%s"' % ( self.name, self.number, self._button_type, self._direction) def __repr__(self): """String representation of the Button object.""" return str({'name': self.name, 'num': self.number, 'type': self._button_type, 'direction': self._direction}) @property def button_type(self): """Returns the button type (Toggle, MasterRaiseLower, etc.).""" return self._button_type def press(self): """Triggers a simulated button press to the Keypad.""" self._lutron.send(Lutron.OP_EXECUTE, Keypad._CMD_TYPE, self._keypad.id, self.component_number, Button._ACTION_PRESS) def release(self): """Triggers a simulated button release to the Keypad.""" self._lutron.send(Lutron.OP_EXECUTE, Keypad._CMD_TYPE, self._keypad.id, self.component_number, Button._ACTION_RELEASE) def tap(self): """Triggers a simulated button tap to the Keypad.""" self.press() self.release() def handle_update(self, action, params): """Handle the specified action on this component.""" _LOGGER.debug('Keypad: "%s" %s Action: %s Params: %s"' % ( self._keypad.name, self, action, params)) ev_map = { Button._ACTION_PRESS: Button.Event.PRESSED, Button._ACTION_RELEASE: Button.Event.RELEASED } if action not in ev_map: _LOGGER.debug("Unknown action %d for button %d in keypad %s" % ( action, self.number, self._keypad.name)) return False self._dispatch_event(ev_map[action], {}) return True class Led(KeypadComponent): """This object represents a keypad LED that we can turn on/off and handle events for (led toggled by scenes).""" _ACTION_LED_STATE = 9 class Event(LutronEvent): """Led events that can be generated. STATE_CHANGED: The button has been pressed. Params: state: The boolean value of the new LED state. """ STATE_CHANGED = 1 def __init__(self, lutron, keypad, name, led_num, component_num, uuid): """Initializes the Keypad LED class.""" super(Led, self).__init__(lutron, keypad, name, led_num, component_num, uuid) self._state = False self._query_waiters = _RequestHelper() def __str__(self): """Pretty printed string value of the Led object.""" return 'LED keypad: "%s" name: "%s" num: %d component_num: %d"' % ( self._keypad.name, self.name, self.number, self.component_number) def __repr__(self): """String representation of the Led object.""" return str({'keypad': self._keypad, 'name': self.name, 'num': self.number, 'component_num': self.component_number}) def __do_query_state(self): """Helper to perform the actual query for the current LED state.""" self._lutron.send(Lutron.OP_QUERY, Keypad._CMD_TYPE, self._keypad.id, self.component_number, Led._ACTION_LED_STATE) @property def last_state(self): """Returns last cached value of the LED state, no query is performed.""" return self._state @property def state(self): """Returns the current LED state by querying the remote controller.""" ev = self._query_waiters.request(self.__do_query_state) ev.wait(1.0) return self._state @state.setter def state(self, new_state: bool): """Sets the new led state. new_state: bool """ self._lutron.send(Lutron.OP_EXECUTE, Keypad._CMD_TYPE, self._keypad.id, self.component_number, Led._ACTION_LED_STATE, int(new_state)) self._state = new_state def handle_update(self, action, params): """Handle the specified action on this component.""" _LOGGER.debug('Keypad: "%s" %s Action: %s Params: %s"' % ( self._keypad.name, self, action, params)) if action != Led._ACTION_LED_STATE: _LOGGER.debug("Unknown action %d for led %d in keypad %s" % ( action, self.number, self._keypad.name)) return False elif len(params) < 1: _LOGGER.debug("Unknown params %s (action %d on led %d in keypad %s)" % ( params, action, self.number, self._keypad.name)) return False self._state = bool(params[0]) self._query_waiters.notify() self._dispatch_event(Led.Event.STATE_CHANGED, {'state': self._state}) return True class Keypad(LutronEntity): """Object representing a Lutron keypad. Currently we don't really do much with it except handle the events (and drop them on the floor). """ _CMD_TYPE = 'DEVICE' def __init__(self, lutron, name, keypad_type, location, integration_id, uuid): """Initializes the Keypad object.""" super(Keypad, self).__init__(lutron, name, uuid) self._buttons = [] self._leds = [] self._components = {} self._location = location self._integration_id = integration_id self._type = keypad_type self._lutron.register_id(Keypad._CMD_TYPE, self) def add_button(self, button): """Adds a button that's part of this keypad. We'll use this to dispatch button events.""" self._buttons.append(button) self._components[button.component_number] = button def add_led(self, led): """Add an LED that's part of this keypad.""" self._leds.append(led) self._components[led.component_number] = led @property def id(self): """The integration id""" return self._integration_id @property def name(self): """Returns the name of this keypad""" return self._name @property def type(self): """Returns the keypad type""" return self._type @property def location(self): """Returns the location in which the keypad is installed""" return self._location @property def buttons(self): """Return a tuple of buttons for this keypad.""" return tuple(button for button in self._buttons) @property def leds(self): """Return a tuple of leds for this keypad.""" return tuple(led for led in self._leds) def handle_update(self, args): """The callback invoked by the main event loop if there's an event from this keypad.""" component = int(args[0]) action = int(args[1]) params = [int(x) for x in args[2:]] _LOGGER.debug("Updating %d(%s): c=%d a=%d params=%s" % ( self._integration_id, self._name, component, action, params)) if component in self._components: return self._components[component].handle_update(action, params) return False class PowerSource(Enum): """Enum values representing power source, reported by queries to battery-powered devices.""" # Values from ?HELP,?DEVICE,22 UNKNOWN = 0 BATTERY = 1 EXTERNAL = 2 class BatteryStatus(Enum): """Enum values representing battery state, reported by queries to battery-powered devices.""" # Values from ?HELP,?DEVICE,22 don't match the documentation, using what's in the doc. #?HELP says: # <0-NOT BATTERY POWERED, 1-DEVICE_BATTERY_STATUS_UNKNOWN, 2-DEVICE_BATTERY_STATUS_GOOD, 3-DEVICE_BATTERY_STATUS_LOW, 4-DEVICE_STATUS_MIA>5-DEVICE_STATUS_NOT_ACTIVATED> NORMAL = 1 LOW = 2 OTHER = 3 # not sure what this value means class MotionSensor(LutronEntity): """Placeholder class for the motion sensor device. Although sensors are represented in the XML, all of the protocol happens at the OccupancyGroup level. To read the state of an area, use area.occupancy_group. """ _CMD_TYPE = 'DEVICE' _ACTION_BATTERY_STATUS = 22 class Event(LutronEvent): """MotionSensor events that can be generated. STATUS_CHANGED: Battery status changed Params: power: PowerSource battery: BatteryStatus Note that motion events are reported by OccupancyGroup, not individual MotionSensors. """ STATUS_CHANGED = 1 def __init__(self, lutron, name, integration_id, uuid): """Initializes the motion sensor object.""" super(MotionSensor, self).__init__(lutron, name, uuid) self._integration_id = integration_id self._battery = None self._power = None self._lutron.register_id(MotionSensor._CMD_TYPE, self) self._query_waiters = _RequestHelper() self._last_update = None @property def id(self): """The integration id""" return self._integration_id def __str__(self): """Returns a pretty-printed string for this object.""" return 'MotionSensor {} Id: {} Battery: {} Power: {}'.format( self.name, self.id, self.battery_status, self.power_source) def __repr__(self): """String representation of the MotionSensor object.""" return str({'motion_sensor_name': self.name, 'id': self.id, 'battery' : self.battery_status, 'power' : self.power_source}) @property def _update_age(self): """Returns the time of the last poll in seconds.""" if self._last_update is None: return 1e6 else: return time.time() - self._last_update @property def battery_status(self): """Returns the current BatteryStatus.""" # Battery status won't change frequently but can't be retrieved for MONITORING. # So rate limit queries to once an hour. if self._update_age > 3600.0: ev = self._query_waiters.request(self._do_query_battery) ev.wait(1.0) return self._battery @property def power_source(self): """Returns the current PowerSource.""" self.battery_status # retrieved by the same query return self._power def _do_query_battery(self): """Helper to perform the query for the current BatteryStatus.""" component_num = 1 # doesn't seem to matter return self._lutron.send(Lutron.OP_QUERY, MotionSensor._CMD_TYPE, self._integration_id, component_num, MotionSensor._ACTION_BATTERY_STATUS) def handle_update(self, args): """Handle the specified action on this component.""" if len(args) != 6: _LOGGER.debug('Wrong number of args for MotionSensor update {}'.format(len(args))) return False _, action, _, power, battery, _ = args action = int(action) if action != MotionSensor._ACTION_BATTERY_STATUS: _LOGGER.debug("Unknown action %d for motion sensor {}".format(self.name)) return False self._power = PowerSource(int(power)) self._battery = BatteryStatus(int(battery)) self._last_update = time.time() self._query_waiters.notify() self._dispatch_event( MotionSensor.Event.STATUS_CHANGED, {'power' : self._power, 'battery': self._battery}) return True class OccupancyGroup(LutronEntity): """Represents one or more occupancy/vacancy sensors grouped into an Area.""" _CMD_TYPE = 'GROUP' _ACTION_STATE = 3 class State(Enum): """Possible states of an OccupancyGroup.""" OCCUPIED = 3 VACANT = 4 UNKNOWN = 255 class Event(LutronEvent): """OccupancyGroup event that can be generated. OCCUPANCY: Occupancy state has changed. Params: state: an OccupancyGroup.State """ OCCUPANCY = 1 def __init__(self, lutron, area, uuid): super(OccupancyGroup, self).__init__(lutron, 'Occ {}'.format(area.name), uuid) self._area = area self._integration_id = area.id self._state = None self._lutron.register_id(OccupancyGroup._CMD_TYPE, self) self._query_waiters = _RequestHelper() @property def id(self): """The integration id""" return self._integration_id @property def name(self): """Return the name of this OccupancyGroup, which is 'Occ' plus the name of the area.""" return 'Occ {}'.format(self._area.name) @property def state(self): """Returns the current occupancy state.""" # Poll for the first request. if self._state == None: ev = self._query_waiters.request(self._do_query_state) ev.wait(1.0) return self._state def __str__(self): """Returns a pretty-printed string for this object.""" return 'OccupancyGroup for Area "{}" Id: {} State: {}'.format( self._area.name, self.id, self.state.name) def __repr__(self): """Returns a stringified representation of this object.""" return str({'area_name' : self.area.name, 'id' : self.id, 'state' : self.state}) def _do_query_state(self): """Helper to perform the actual query for the current OccupancyGroup state.""" return self._lutron.send(Lutron.OP_QUERY, OccupancyGroup._CMD_TYPE, self._integration_id, OccupancyGroup._ACTION_STATE) def handle_update(self, args): """Handles an event update for this object, e.g. occupancy state change.""" action = int(args[0]) if action != OccupancyGroup._ACTION_STATE or len(args) != 2: return False try: self._state = OccupancyGroup.State(int(args[1])) except ValueError: self._state = OccupancyGroup.State.UNKNOWN self._query_waiters.notify() self._dispatch_event(OccupancyGroup.Event.OCCUPANCY, {'state': self._state}) return True class Area(object): """An area (i.e. a room) that contains devices/outputs/etc.""" def __init__(self, lutron, name, integration_id, occupancy_group_id): self._lutron = lutron self._name = name self._integration_id = integration_id self._occupancy_group_id = occupancy_group_id self._occupancy_group = None self._outputs = [] self._keypads = [] self._sensors = [] def add_output(self, output): """Adds an output object that's part of this area, only used during initial parsing.""" self._outputs.append(output) def add_keypad(self, keypad): """Adds a keypad object that's part of this area, only used during initial parsing.""" self._keypads.append(keypad) def add_sensor(self, sensor): """Adds a motion sensor object that's part of this area, only used during initial parsing.""" self._sensors.append(sensor) if not self._occupancy_group: # TODO: add the uuid for the occupancy group self._occupancy_group = OccupancyGroup(self._lutron, self, None) @property def name(self): """Returns the name of this area.""" return self._name @property def id(self): """The integration id of the area.""" return self._integration_id @property def occupancy_group(self): """Returns the OccupancyGroup for this area, or None.""" return self._occupancy_group @property def outputs(self): """Return the tuple of the Outputs from this area.""" return tuple(output for output in self._outputs) @property def keypads(self): """Return the tuple of the Keypads from this area.""" return tuple(keypad for keypad in self._keypads) @property def sensors(self): """Return the tuple of the MotionSensors from this area.""" return tuple(sensor for sensor in self._sensors)