From 52731f9d30edd747e3d1750569d9a85238aeb168 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 9 May 2021 16:21:39 -0700 Subject: [PATCH 1/7] Update __init__.py --- liplib/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/liplib/__init__.py b/liplib/__init__.py index 8494e0e..aea21c0 100644 --- a/liplib/__init__.py +++ b/liplib/__init__.py @@ -269,7 +269,7 @@ async def ping(self): await self.writer.drain() async def logout(self): - """Logout and severe the connect to the bridge.""" + """Logout and sever the connection to the bridge.""" async with self._write_lock: if self._state != LipServer.State.Opened: return From df80c9f34646d61c9ef1f918ae3e274cc0a63600 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 9 May 2021 16:42:14 -0700 Subject: [PATCH 2/7] Update __init__.py --- liplib/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/liplib/__init__.py b/liplib/__init__.py index aea21c0..a252c31 100644 --- a/liplib/__init__.py +++ b/liplib/__init__.py @@ -92,7 +92,8 @@ def _process_scenes(devices, device): # pylint: disable=too-many-instance-attributes class LipServer: - """Async class to communicate with a the bridge.""" + """Async class to communicate with a the bridge. This class is the client, + so it should be called LipServerClient but it is not.""" READ_SIZE = 1024 DEFAULT_USER = b"lutron" From fd58d0ab95f74d95461dfc7e591505de853655ed Mon Sep 17 00:00:00 2001 From: dulitz Date: Thu, 27 May 2021 12:51:04 -0700 Subject: [PATCH 3/7] C # not returned by Caseta or Radio Ra 2 Selectlarify the parser for the integration JSON. Add some constants used by Homeworks QS. --- liplib/__init__.py | 115 ++++++++++++++++++++------------------------- 1 file changed, 50 insertions(+), 65 deletions(-) diff --git a/liplib/__init__.py b/liplib/__init__.py index a252c31..bf4cda0 100644 --- a/liplib/__init__.py +++ b/liplib/__init__.py @@ -1,8 +1,8 @@ """ -Interface module for Lutron Integration Protocol (LIP) over Telnet. +Interface module for Lutron Integration Protocol (LIP). -This module connects to a Lutron hub through the Telnet interface which must be -enabled through the integration menu in the Lutron mobile app. +This module connects to a Lutron hub through the tcp/23 ("telnet") interface which +must be enabled through the integration menu in the Lutron mobile app. Authors: upsert (https://github.com/upsert) @@ -26,74 +26,56 @@ _LOGGER = logging.getLogger(__name__) - -async def async_load_integration_report(fname: str) -> list: +def load_integration_report(integration_report) -> list: """Process a JSON integration report and return a list of devices. Each returned device will have an 'id', 'name', 'type' and optionally a list of button IDs under 'buttons' for remotes - and an 'area_name' attribute if the device is assigned - to an area. + and an 'area_name' attribute if the device is assigned to an area. + + To generate an integration report in the Lutron (Radio Ra2 Select) app, + click the gear, then Advanced, then "Send Integration Report." """ devices = [] - with open(fname, encoding='utf-8') as conf_file: - integration_report = json.load(conf_file) - # _LOGGER.debug(integration) - if "LIPIdList" in integration_report: - # lights and switches are in Zones - if "Zones" in integration_report["LIPIdList"]: - _process_zones(devices, integration_report) - # remotes are in Devices, except ID 1 which is the bridge itself - if "Devices" in integration_report["LIPIdList"]: - for device in integration_report["LIPIdList"]["Devices"]: - # extract scenes from integration ID 1 - the smart bridge - if device["ID"] == 1 and "Buttons" in device: - _process_scenes(devices, device) - elif device["ID"] != 1 and "Buttons" in device: - device_obj = {CONF_ID: device["ID"], - CONF_NAME: device["Name"], - CONF_TYPE: "sensor", - CONF_BUTTONS: - [b["Number"] - for b in device["Buttons"]]} - if "Area" in device and "Name" in device["Area"]: - device_obj[CONF_AREA_NAME] = device["Area"]["Name"] - devices.append(device_obj) - else: - _LOGGER.warning("'LIPIdList' not found in the Integration Report." - " No devices will be loaded.") - return devices - + lipidlist = integration_report.get("LIPIdList") + assert lipidlist, integration_report -def _process_zones(devices, integration_report): - """Process zones and append devices.""" - for zone in integration_report["LIPIdList"]["Zones"]: - # _LOGGER.debug(zone) + # lights and switches are in Zones + for zone in lipidlist.get("Zones", []): device_obj = {CONF_ID: zone["ID"], CONF_NAME: zone["Name"], CONF_TYPE: "light"} - if "Area" in zone and "Name" in zone["Area"]: - device_obj[CONF_AREA_NAME] = zone["Area"]["Name"] + name = zone.get("Area", {}).get("Name", "") + if name: + device_obj[CONF_AREA_NAME] = name devices.append(device_obj) + # remotes are in Devices, except ID 1 which is the bridge itself + for device in lipidlist.get("Devices", []): + # extract scenes from integration ID 1 - the smart bridge + if device["ID"] == 1: + for button in device.get("Buttons", []): + if not button["Name"].startswith("Button "): + _LOGGER.info("Found scene %d, %s", button["Number"], button["Name"]) + devices.append({CONF_ID: device["ID"], + CONF_NAME: button["Name"], + CONF_SCENE_ID: button["Number"], + CONF_TYPE: "scene"}) + else: + device_obj = {CONF_ID: device["ID"], + CONF_NAME: device["Name"], + CONF_TYPE: "sensor", + CONF_BUTTONS: [b["Number"] for b in device.get("Buttons", [])]} + name = device.get("Area", {}).get("Name", "") + device_obj[CONF_AREA_NAME] = name + devices.append(device_obj) -def _process_scenes(devices, device): - """Process scenes and append devices.""" - for button in device["Buttons"]: - if not button["Name"].startswith("Button "): - _LOGGER.info( - "Found scene %d, %s", button["Number"], - button["Name"]) - devices.append({CONF_ID: device["ID"], - CONF_NAME: button["Name"], - CONF_SCENE_ID: button["Number"], - CONF_TYPE: "scene"}) + return devices # pylint: disable=too-many-instance-attributes class LipServer: - """Async class to communicate with a the bridge. This class is the client, - so it should be called LipServerClient but it is not.""" + """Communicate with a Lutron bridge, repeater, or controller.""" READ_SIZE = 1024 DEFAULT_USER = b"lutron" @@ -106,20 +88,20 @@ class LipServer: class Action(IntEnum): """Action values.""" - # Get or Set Zone Level - SET = 1 - # Start Raising - RAISING = 2 - # Start Lowering - LOWERING = 3 - # Stop Raising/Lowering - STOP = 4 + SET = 1 # Get or Set Zone Level + RAISING = 2 # Start Raising + LOWERING = 3 # Start Lowering + STOP = 4 # Stop Raising/Lowering + + PRESET = 6 # SHADEGRP for Homeworks QS class Button(IntEnum): """Button values.""" PRESS = 3 RELEASE = 4 + HOLD = 5 # not returned by Caseta or Radio Ra 2 Select + DOUBLETAP = 6 # not returned by Caseta or Radio Ra 2 Select class State(IntEnum): """Connection state values.""" @@ -192,10 +174,12 @@ async def _read_until(self, value): self._read_buffer = self._read_buffer[match.end():] return match else: + assert isinstance(value, bytes), value where = self._read_buffer.find(value) if where != -1: + until = self._read_buffer[:where+len(value)] self._read_buffer = self._read_buffer[where + len(value):] - return True + return until try: read_data = await self.reader.read(LipServer.READ_SIZE) if not len(read_data): @@ -220,7 +204,7 @@ async def read(self): int(match.group(2)), int(match.group(3)), \ float(match.group(4)) except ValueError: - print("Exception in ", match.group(0)) + _LOGGER.warning(f"could not parse {match.group(0)}") if match is False: # attempt to reconnect _LOGGER.info("Reconnecting to the bridge %s", self._host) @@ -248,8 +232,9 @@ async def write(self, mode, integration, action, *args, value=None): except OSError as err: _LOGGER.warning("Error writing out to the bridge: %s", err) + async def query(self, mode, integration, action): - """Query a device to get its current state.""" + """Query a device to get its current state. Does not handle LED queries.""" if hasattr(action, "value"): action = action.value _LOGGER.debug("Sending query %s, integration %s, action %s", From 48ec8dbb8247901942ffc7d1b21e73aca3c53222 Mon Sep 17 00:00:00 2001 From: dulitz Date: Thu, 27 May 2021 13:09:17 -0700 Subject: [PATCH 4/7] use f-strings consistently since we are already defined as python 3.6+ --- liplib/__init__.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/liplib/__init__.py b/liplib/__init__.py index bf4cda0..f3a43e5 100644 --- a/liplib/__init__.py +++ b/liplib/__init__.py @@ -129,7 +129,7 @@ def is_connected(self) -> bool: async def open(self, host, port=23, username=DEFAULT_USER, password=DEFAULT_PASSWORD): - """Open a Telnet connection to the bridge.""" + """Open a telnet connection to the bridge.""" async with self._read_lock: async with self._write_lock: if self._state != LipServer.State.Closed: @@ -145,8 +145,7 @@ async def open(self, host, port=23, username=DEFAULT_USER, try: connection = await asyncio.open_connection(host, port) except OSError as err: - _LOGGER.warning("Error opening connection" - " to the bridge: %s", err) + _LOGGER.warning(f"cannot open connection to the bridge: {err}") self._state = LipServer.State.Closed return @@ -165,7 +164,7 @@ async def open(self, host, port=23, username=DEFAULT_USER, self._state = LipServer.State.Opened async def _read_until(self, value): - """Read until a given value is reached.""" + """Read until a given value is reached. Value may be regex or bytes.""" while True: if hasattr(value, "search"): # detected regular expression @@ -183,11 +182,11 @@ async def _read_until(self, value): try: read_data = await self.reader.read(LipServer.READ_SIZE) if not len(read_data): - _LOGGER.warning("Empty read from the bridge (clean disconnect)") + _LOGGER.warning("bridge disconnected") return False self._read_buffer += read_data except OSError as err: - _LOGGER.warning("Error reading from the bridge: %s", err) + _LOGGER.warning(f"error reading from the bridge: {err}") return False async def read(self): @@ -207,43 +206,41 @@ async def read(self): _LOGGER.warning(f"could not parse {match.group(0)}") if match is False: # attempt to reconnect - _LOGGER.info("Reconnecting to the bridge %s", self._host) + _LOGGER.info(f"Reconnecting to the bridge {self._host}") self._state = LipServer.State.Closed await self.open(self._host, self._port, self._username, self._password) return None, None, None, None async def write(self, mode, integration, action, *args, value=None): - """Write a list of values out to the Telnet interface.""" + """Write a list of values to the bridge.""" if hasattr(action, "value"): action = action.value async with self._write_lock: if self._state != LipServer.State.Opened: return - data = "#{},{},{}".format(mode, integration, action) + data = f"#{mode},{integration},{action}" if value is not None: - data += ",{}".format(value) + data += f",{value}" for arg in args: if arg is not None: - data += ",{}".format(arg) + data += f",{arg}" try: self.writer.write((data + "\r\n").encode("ascii")) await self.writer.drain() except OSError as err: - _LOGGER.warning("Error writing out to the bridge: %s", err) + _LOGGER.warning(f"Error writing to the bridge: {err}") async def query(self, mode, integration, action): """Query a device to get its current state. Does not handle LED queries.""" if hasattr(action, "value"): action = action.value - _LOGGER.debug("Sending query %s, integration %s, action %s", - mode, integration, action) + _LOGGER.debug(f"Sending query {mode}, integration {integration}, action {action}") async with self._write_lock: if self._state != LipServer.State.Opened: return - self.writer.write("?{},{},{}\r\n".format(mode, integration, - action).encode()) + self.writer.write(f"?{mode},{integration},{action}\r\n".encode()) await self.writer.drain() async def ping(self): @@ -255,7 +252,7 @@ async def ping(self): await self.writer.drain() async def logout(self): - """Logout and sever the connection to the bridge.""" + """Close the connection to the bridge.""" async with self._write_lock: if self._state != LipServer.State.Opened: return From ad8f9973c76adbe07317e6e203a0bb8304b9cf32 Mon Sep 17 00:00:00 2001 From: dulitz Date: Thu, 27 May 2021 13:38:24 -0700 Subject: [PATCH 5/7] add error detection during login. also add a constant used by Homeworks QS --- liplib/__init__.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/liplib/__init__.py b/liplib/__init__.py index f3a43e5..3607a4f 100644 --- a/liplib/__init__.py +++ b/liplib/__init__.py @@ -81,12 +81,13 @@ class LipServer: DEFAULT_USER = b"lutron" DEFAULT_PASSWORD = b"integration" DEFAULT_PROMPT = b"GNET> " + LOGIN_PROMPT = b"login: " RESPONSE_RE = re.compile(b"~([A-Z]+),([0-9.]+),([0-9.]+),([0-9.]+)\r\n") OUTPUT = "OUTPUT" DEVICE = "DEVICE" class Action(IntEnum): - """Action values.""" + """Action numbers for the OUTPUT command in the Lutron Integration Protocol.""" SET = 1 # Get or Set Zone Level RAISING = 2 # Start Raising @@ -96,12 +97,14 @@ class Action(IntEnum): PRESET = 6 # SHADEGRP for Homeworks QS class Button(IntEnum): - """Button values.""" + """Action numbers for the DEVICE command in the Lutron Integration Protocol.""" - PRESS = 3 - RELEASE = 4 - HOLD = 5 # not returned by Caseta or Radio Ra 2 Select - DOUBLETAP = 6 # not returned by Caseta or Radio Ra 2 Select + PRESS = 3 + RELEASE = 4 + HOLD = 5 # not returned by Caseta or Radio Ra 2 Select + DOUBLETAP = 6 # not returned by Caseta or Radio Ra 2 Select + + LEDSTATE = 9 # "Button" is a misnomer; this queries LED state class State(IntEnum): """Connection state values.""" @@ -141,25 +144,30 @@ async def open(self, host, port=23, username=DEFAULT_USER, self._username = username self._password = password + def cleanup(err): + _LOGGER.warning(f"error opening connection to Lutron {host}:{port}: {err}") + self._state = LipServer.State.Closed + # open connection try: connection = await asyncio.open_connection(host, port) except OSError as err: - _LOGGER.warning(f"cannot open connection to the bridge: {err}") - self._state = LipServer.State.Closed - return + return cleanup(err) self.reader = connection[0] self.writer = connection[1] # do login - await self._read_until(b"login: ") + if await self._read_until(self.LOGIN_PROMPT) is False: + return cleanup('no login prompt') self.writer.write(username + b"\r\n") await self.writer.drain() - await self._read_until(b"password: ") + if await self._read_until(b"password: ") is False: + return cleanup('no password prompt') self.writer.write(password + b"\r\n") await self.writer.drain() - await self._read_until(self.prompt) + if await self._read_until(self.prompt) is False: + return cleanup('login failed') self._state = LipServer.State.Opened From 46ad6bddaec84d89a8a8db5a59a22b765cbdd436 Mon Sep 17 00:00:00 2001 From: dulitz Date: Thu, 27 May 2021 14:06:45 -0700 Subject: [PATCH 6/7] updated to reflect testing with Homeworks QS --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 86fb577..946d25f 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,20 @@ Interface module for Lutron Integration Protocol (LIP) over Telnet. This module connects to a Lutron hub through the Telnet interface which must be enabled through the integration menu in the Lutron mobile app. -Supported bridges / main repeaters: +Supported bridges / main repeaters / controllers: - [Lutron Caseta](http://www.casetawireless.com) Smart Bridge **PRO** (L-BDGPRO2-WH) -- [Ra2 Select](http://www.lutron.com/en-US/Products/Pages/WholeHomeSystems/RA2Select/Overview.aspx) Main Repeater (RR-SEL-REP-BL or RR-SEL-REP2S-BL) +- [Radio Ra2 Select](http://www.lutron.com/en-US/Products/Pages/WholeHomeSystems/RA2Select/Overview.aspx) Main Repeater (RR-SEL-REP-BL or RR-SEL-REP2S-BL) +- Radio Ra2 +- Homeworks QS -Other bridges / main repeaters that use the Lutron Integration Protocol (e.g. Radio Ra2, HomeWorks QS) may also work with this library, but are untested. +Other bridges / main repeaters that use the Lutron Integration Protocol (e.g. Quantum, Athena, myRoom) should also work with this library, but are untested. -This module is designed to use selected commands from the [Lutron Integration Protocol](http://www.lutron.com/TechnicalDocumentLibrary/040249.pdf). The command set most closely resembles RadioRa 2, but not all features listed for RadioRa 2 are supported. +This module is designed to use selected commands from the [Lutron Integration Protocol](http://www.lutron.com/TechnicalDocumentLibrary/040249.pdf). Not all features documented in the protocol are supported by this module. If you implement an extension, please submit a pull request. In addition to sending and receiving commands, a function is provided to process a JSON Integration Report obtained by a user from the Lutron mobile app. +An interface to the obsolete Lutron Homeworks Illumination system is [available here](https://github.com/dulitz/porter/blob/main/illiplib.py). It is drop-in compatible with this interface. + Authors: upsert (https://github.com/upsert) From 65244e50195c57c29c8bffe3ff505235d64a54d7 Mon Sep 17 00:00:00 2001 From: dulitz Date: Thu, 27 May 2021 14:09:40 -0700 Subject: [PATCH 7/7] clarify that 'telnet' must be turned on in the app only for Caseta PRO and Radio Ra2 Select; for others the integrator can turn it on --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 946d25f..1cff888 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # liplib -Interface module for Lutron Integration Protocol (LIP) over Telnet. +Interface module for Lutron Integration Protocol (LIP) over TCP port 23, or "telnet." -This module connects to a Lutron hub through the Telnet interface which must be enabled through the integration menu in the Lutron mobile app. +This module connects to a Lutron hub through the telnet interface which for Caseta PRO and Radio Ra2 Select must be enabled through the integration menu in the Lutron mobile app. Supported bridges / main repeaters / controllers: - [Lutron Caseta](http://www.casetawireless.com) Smart Bridge **PRO** (L-BDGPRO2-WH)