lutron-integration 0.0.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: lutron-integration
3
+ Version: 0.0.0a1
4
+ Summary: Lutron Integration Protocol client, focused on QS Standalone
5
+ Keywords: QSE-CI-NWK-E,Lutron,QS Standalone,Grafik Eye
6
+ Author: Andy Lutomirski
7
+ Author-email: Andy Lutomirski <luto@kernel.org>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Maintainer: Andy Lutomirski
13
+ Maintainer-email: Andy Lutomirski <luto@kernel.org>
14
+ Requires-Python: >=3.12
15
+ Project-URL: Repository, https://github.com/amluto/lutron-integration
16
+ Description-Content-Type: text/markdown
17
+
18
+ # What is this? #
19
+
20
+ lutron_integration is a a client library for the Lutron Integration Protocol.
21
+ This is a protocol used by a number of different Lutron products, documented here:
22
+
23
+ https://assets.lutron.com/a/documents/040249.pdf
24
+
25
+ lutron_integration is intended to be able to support all dialects of the protocol,
26
+ but it is currently primarily targetted at the "QS Standalone" dialect. Other dialects
27
+ are barely, if at all, implemented.
28
+
29
+ If you want to set up a QS Standalone system, you need a Lutron [QSE-CI-NWK-E](https://assets.lutron.com/a/documents/369373_qse-ci-nwk-e_eng.pdf),
30
+ at least one other Lutron device (both to make it useful and to provide power), and some appropriate wire.
31
+ Lutron's devices are generally intended for professional installation, and they provide extensive
32
+ documentation on their website.
33
+
34
+ Programming your QSE-CI-NWK-E is outside the scope of this library, but it's fairly
35
+ easy to do over Telnet. (In general, a skilled Lutron installer will install a QS
36
+ system and will install the QSE-CI-NWK-E as well if requested to do so. They will program
37
+ the rest of the system (which requires no proprietary software for a standalone
38
+ system and can be done by following instructions in the manual), and they may or
39
+ may not program the QSE-CI-NWK-E.) Integrating some other system, e,g, whatever
40
+ system uses this library, is the job of whoever sets up that system, i.e. you!
41
+
42
+ To set it up, you will need a computer that allows you to control its IP and subnet
43
+ settings and to set up an IP on the subnet 192.168.250.0/24, and you'll need to connect
44
+ to 192.168.250.1/24. You can often fudge this by connecting to the same L2 network
45
+ that the QSE-CI-NWK-E is on, even via wifi, and adding a secondary address.
46
+
47
+ - On a Mac, the command resembles `sudo ifconfig en0 alias 192.168.250.2 255.255.255.0`
48
+ - On Linux, the command resembles `sudo ip addr add 192.168.250.2/24 dev eth0`
49
+ - On Windows, you can use the GUI, painfully. I'm sure there is some way to do it via Powershell or ipconfig
50
+
51
+ Then you can telnet to 192.168.250.1 and configure a real IP address with
52
+ commands like `#ETHERNET,0,[IP address]` and `#ETHERNET,1,[subnet mask]` --
53
+ see [page 19][lip]. The QSE-CI-NWK-E does not appear to support DHCP at all,
54
+ and Lutron recommends using static addresses even for other dialects. Or you can
55
+ use RS232.
56
+
57
+ lutron_integration presently has no dependencies at all outside the standard library,
58
+ and I would like to keep it that way unless there is a fairly compelling reason
59
+ to add a dependency.
60
+
61
+ # Usage #
62
+
63
+ Users of this library are responsible for connecting to the integration access point
64
+ on their own, which generally involves figuring out what IP address and TCP port
65
+ (hint: 23) to connect to and using `await asyncio.open_connection(address, port)`
66
+ or doing whatever incantation is appropriate on your platform to connect to a
67
+ serial port.
68
+
69
+ (Lots more to write here)
70
+
71
+ lutron_integration is fully async and very strongly respects the idea of
72
+ structured concurrency: it does not create tasks at all. If you want the
73
+ library do so something, you call it. If you are not actively calling it,
74
+ it does nothing!
75
+
76
+
77
+ [lip]: https://assets.lutron.com/a/documents/040249.pdf
@@ -0,0 +1,60 @@
1
+ # What is this? #
2
+
3
+ lutron_integration is a a client library for the Lutron Integration Protocol.
4
+ This is a protocol used by a number of different Lutron products, documented here:
5
+
6
+ https://assets.lutron.com/a/documents/040249.pdf
7
+
8
+ lutron_integration is intended to be able to support all dialects of the protocol,
9
+ but it is currently primarily targetted at the "QS Standalone" dialect. Other dialects
10
+ are barely, if at all, implemented.
11
+
12
+ If you want to set up a QS Standalone system, you need a Lutron [QSE-CI-NWK-E](https://assets.lutron.com/a/documents/369373_qse-ci-nwk-e_eng.pdf),
13
+ at least one other Lutron device (both to make it useful and to provide power), and some appropriate wire.
14
+ Lutron's devices are generally intended for professional installation, and they provide extensive
15
+ documentation on their website.
16
+
17
+ Programming your QSE-CI-NWK-E is outside the scope of this library, but it's fairly
18
+ easy to do over Telnet. (In general, a skilled Lutron installer will install a QS
19
+ system and will install the QSE-CI-NWK-E as well if requested to do so. They will program
20
+ the rest of the system (which requires no proprietary software for a standalone
21
+ system and can be done by following instructions in the manual), and they may or
22
+ may not program the QSE-CI-NWK-E.) Integrating some other system, e,g, whatever
23
+ system uses this library, is the job of whoever sets up that system, i.e. you!
24
+
25
+ To set it up, you will need a computer that allows you to control its IP and subnet
26
+ settings and to set up an IP on the subnet 192.168.250.0/24, and you'll need to connect
27
+ to 192.168.250.1/24. You can often fudge this by connecting to the same L2 network
28
+ that the QSE-CI-NWK-E is on, even via wifi, and adding a secondary address.
29
+
30
+ - On a Mac, the command resembles `sudo ifconfig en0 alias 192.168.250.2 255.255.255.0`
31
+ - On Linux, the command resembles `sudo ip addr add 192.168.250.2/24 dev eth0`
32
+ - On Windows, you can use the GUI, painfully. I'm sure there is some way to do it via Powershell or ipconfig
33
+
34
+ Then you can telnet to 192.168.250.1 and configure a real IP address with
35
+ commands like `#ETHERNET,0,[IP address]` and `#ETHERNET,1,[subnet mask]` --
36
+ see [page 19][lip]. The QSE-CI-NWK-E does not appear to support DHCP at all,
37
+ and Lutron recommends using static addresses even for other dialects. Or you can
38
+ use RS232.
39
+
40
+ lutron_integration presently has no dependencies at all outside the standard library,
41
+ and I would like to keep it that way unless there is a fairly compelling reason
42
+ to add a dependency.
43
+
44
+ # Usage #
45
+
46
+ Users of this library are responsible for connecting to the integration access point
47
+ on their own, which generally involves figuring out what IP address and TCP port
48
+ (hint: 23) to connect to and using `await asyncio.open_connection(address, port)`
49
+ or doing whatever incantation is appropriate on your platform to connect to a
50
+ serial port.
51
+
52
+ (Lots more to write here)
53
+
54
+ lutron_integration is fully async and very strongly respects the idea of
55
+ structured concurrency: it does not create tasks at all. If you want the
56
+ library do so something, you call it. If you are not actively calling it,
57
+ it does nothing!
58
+
59
+
60
+ [lip]: https://assets.lutron.com/a/documents/040249.pdf
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "lutron-integration"
3
+ version = "0.0.0a1"
4
+ description = "Lutron Integration Protocol client, focused on QS Standalone"
5
+ authors = [
6
+ {name = "Andy Lutomirski", email = "luto@kernel.org"}
7
+ ]
8
+ maintainers = [
9
+ {name = "Andy Lutomirski", email = "luto@kernel.org"}
10
+ ]
11
+ license = "MIT"
12
+ keywords = ["QSE-CI-NWK-E", "Lutron", "QS Standalone", "Grafik Eye"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ ]
18
+ readme = "README.md"
19
+ requires-python = ">=3.12"
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/amluto/lutron-integration"
24
+
25
+ [build-system]
26
+ requires = ["uv_build >= 0.9.8, <0.10.0"]
27
+ build-backend = "uv_build"
@@ -0,0 +1,457 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ import re
4
+ import collections
5
+ from collections.abc import Callable, Iterable
6
+ import logging
7
+ from . import types
8
+
9
+ _LOGGER = logging.getLogger(__name__)
10
+
11
+ # A message we received that, when converted to uppercase,
12
+ # starts with one of these prefixes, is considered to be a synchronous
13
+ # reply to a query. Additionally, the empty string is a synchronous
14
+ # reply. We consider a query to be answered when we receive a synchronous
15
+ # reply followed by a prompt.
16
+ #
17
+ # This appears to be completely reliably on QSE-CI-NWK-E with one
18
+ # exception: #OUTPUT and ?OUTPUT commands, at least on verrsion
19
+ # 8.60, produce no output per se. The NWK sends a ~OUTPUT, but
20
+ # that's entirely indistinguishable from an *unsolicited* message.
21
+ #
22
+ # The best solution found so far is to avoid ever sending #OUTPUT
23
+ # or ?OUTPUT, which, conveniently, is never necessary on QS
24
+ # standalone, as #DEVICE and ?DEVICE handle all cases and more.
25
+ _REPLY_PREFIXES = [
26
+ b"~DETAILS",
27
+ b"~ERROR",
28
+ b"~INTEGRATIONID",
29
+ b"~PROGRAMMING",
30
+ b"~ETHERNET",
31
+ b"~MONITORING",
32
+ ]
33
+
34
+
35
+ class LoginError(Exception):
36
+ """Exception raised when login fails."""
37
+
38
+ message: bytes
39
+
40
+ def __init__(self, message: bytes) -> None:
41
+ self.message = message
42
+ super().__init__(message.decode("utf-8", errors="replace"))
43
+
44
+
45
+ class ProtocolError(Exception):
46
+ """Exception raised when the protocol doesn't parse correctly."""
47
+
48
+ def __init__(self, message: str) -> None:
49
+ super().__init__(str)
50
+
51
+
52
+ class DisconnectedError(Exception):
53
+ """Exception raised when we aren't connected."""
54
+
55
+ def __init__(self) -> None:
56
+ super().__init__("Disconnected")
57
+
58
+
59
+ @dataclass
60
+ class _Conn:
61
+ r: asyncio.StreamReader
62
+ w: asyncio.StreamWriter
63
+
64
+
65
+ @dataclass
66
+ class _CurrentQuery:
67
+ reply: None | bytes
68
+ unsolicited_messages: None | list[bytes]
69
+
70
+
71
+ # Monitoring messages may be arbitrarily interspersed with actual replies, and
72
+ # there is no mechanism in the protocol to tell which messages are part of a reply vs.
73
+ # which are asynchronously received monitoring messages.
74
+ #
75
+ # On the bright side, once we enable prompts, we at least know that all direct
76
+ # replies to queries (critically, ?DETAILS) will be received before the QSE>
77
+ # prompt. However, we do not know *which* QSE> prompt they preceed because
78
+ # unsolicited messages end with '\r\nQSE>'. Thanks, Lutron.
79
+ #
80
+ # We manage to parse the protocol by observing that the incoming stream is a
81
+ # stream of messages where each message either ends with b'\r\nQSE>' or
82
+ # is just b'QSE>' (no newline). We further observe that no actual logical
83
+ # line of the protocol can start with a Q (everything starts with ~), so
84
+ # we can't get confused by a stray Q at the start of a line.
85
+ #
86
+ # This does not handle the #PASSWD flow.
87
+
88
+
89
+ class LutronConnection:
90
+ """Represents an established Lutron connection."""
91
+
92
+ __lock: asyncio.Lock
93
+ __cond: asyncio.Condition
94
+
95
+ __conn: _Conn | None # TODO: There is no value to ever nulling this out
96
+ __prompt_prefix: bytes
97
+
98
+ # Unsolicited messages that we have read are enqueued at the
99
+ # end of __unsolicited_queue and are popped from the front.
100
+ #
101
+ # Protected by __lock
102
+ __unsolicited_queue: collections.deque[bytes]
103
+
104
+ # While running a query, this is not None and it contains the
105
+ # collected results
106
+ #
107
+ # Protected by __lock
108
+ __current_query: _CurrentQuery | None
109
+
110
+ # Are we currently reading? The login code does not count.
111
+ #
112
+ # Protected by __lock
113
+ __currently_reading: bool
114
+
115
+ # Only used by __read_one_message(), which is never called
116
+ # concurrently with itself.
117
+ __buffered_byte: bytes
118
+
119
+ # This lock serves (solely) to prevent raw_query() from being called
120
+ # concurrently with itself.
121
+ __query_lock: asyncio.Lock
122
+
123
+ # TODO: We should possibly track when we are in a bad state and quickly fail future operations
124
+
125
+ def __init__(
126
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
127
+ ) -> None:
128
+ self.__conn = _Conn(reader, writer)
129
+ self.__lock = asyncio.Lock()
130
+ self.__cond = asyncio.Condition(self.__lock)
131
+ self.__unsolicited_queue = collections.deque()
132
+ self.__buffered_byte = b""
133
+ self.__query_lock = asyncio.Lock()
134
+
135
+ @classmethod
136
+ async def create_from_connnection(
137
+ cls, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
138
+ ) -> "LutronConnection":
139
+ self = LutronConnection(reader, writer)
140
+ assert self.__conn is not None
141
+
142
+ # When we first connect, the MONITORING state is uknown, which is rather annoying.
143
+ # To function sensibly, we need:
144
+ #
145
+ # Diagnostic Monitoring (1): otherwise errors will be ignored and we won't find out about them
146
+ # Reply State (11): Queries will never be answered if this is off
147
+ # Prompt State (12): The prompt is how we tell that the system has finished processing a request
148
+ #
149
+ # And, awkwardly, until we set these, we might be in a state where the system
150
+ # is entirely silent, and we don't really know what replies to expect.
151
+
152
+ # Send the commands to enable the above monitoring modes
153
+ self.__conn.w.write(
154
+ b"".join(b"#MONITORING,%d,1\r\n" % mode for mode in (1, 11, 12))
155
+ )
156
+
157
+ # In response, we really have no idea what to expect, except that there really ought
158
+ # be at least one prompt. So we'll do an outrageous cheat and send a command with
159
+ # a known reply that we wouldn't otherwise see so we can wait for it.
160
+ self.__conn.w.write(b"?MONITORING,2\r\n")
161
+
162
+ await self.__conn.r.readuntil(b"~MONITORING,2,")
163
+ data = await self.__conn.r.readuntil(b">")
164
+
165
+ m = re.fullmatch(b"\\d\r\n([A-Za-z0-9]+)>", data)
166
+ if not m:
167
+ raise ProtocolError(
168
+ f"Could not parse {(b'~MONITORING,2,' + data + b'>')!r} as a monitoring ping reply"
169
+ )
170
+ self.__prompt_prefix = m[1]
171
+
172
+ self.__currently_reading = False
173
+ self.__current_query = None
174
+
175
+ return self
176
+
177
+ # This returns the protocol name as inferred from the prompt.
178
+ # For example, QS Standalong is b'QSE'.
179
+ @property
180
+ def protocol_name(self) -> bytes:
181
+ return self.__prompt_prefix
182
+
183
+ # This is the meat of the reader. This function is the only thing that reads from
184
+ # the underlying StreamReader, and it is never called concurrently.
185
+ #
186
+ # We don't use cancellation ourselves, but we want to recover cleanly from
187
+ # a client cancelling a call, which means that we can never await something
188
+ # that might result in a cancellation while we are storing data that
189
+ # we've read in a local variable.
190
+ async def __read_one_message(self) -> bytes:
191
+ assert self.__currently_reading
192
+ # This needs to be cancelable and then runnable again without losing data
193
+ assert self.__conn is not None
194
+ if not self.__buffered_byte:
195
+ self.__buffered_byte = await self.__conn.r.read(1)
196
+
197
+ if not self.__buffered_byte:
198
+ # We got EOF.
199
+ raise DisconnectedError()
200
+
201
+ if self.__buffered_byte == self.__prompt_prefix[0:1]:
202
+ # We got Q and expect SE>
203
+ expected = self.__prompt_prefix[1:] + b">"
204
+ data = await self.__conn.r.readexactly(len(expected))
205
+ if data != expected:
206
+ raise ProtocolError(f"Expected {expected!r} but received {data!r}")
207
+ self.__buffered_byte = b""
208
+ return b""
209
+ else:
210
+ # We got the first byte of a message and expect the rest of it
211
+ # followed by b'\r\nQSE>'
212
+ data = await self.__conn.r.readuntil(b"\r\n" + self.__prompt_prefix + b">")
213
+ result = (
214
+ self.__buffered_byte + data[: -(len(self.__prompt_prefix) + 1)]
215
+ ) # strip the QSE>
216
+ self.__buffered_byte = b""
217
+ return result
218
+
219
+ def __is_message_a_reply(self, message: bytes) -> bool:
220
+ # If it's blank (i.e. they send b'QSE>'), then it's a reply.
221
+ if not message:
222
+ return True
223
+
224
+ # Is it in our list of reply prefixes?
225
+ upper = message.upper()
226
+ for prefix in _REPLY_PREFIXES:
227
+ if upper.startswith(prefix):
228
+ return True
229
+
230
+ # Otherwise it's not a reply. (Note that messages like ~DEVICE
231
+ # may well be sent as a result of a query, but they are not sent
232
+ # as a reply to the query -- they're sent as though they're
233
+ # unsolicited.)
234
+
235
+ # Sanity check: we expect exactly one b'\r\n', and it will be at the
236
+ # end.
237
+ assert message.endswith(b"\r\n")
238
+ assert b"\r\n" not in message[:-2], (
239
+ f"Unsolicited message {message!r} has too many lines"
240
+ )
241
+ return False
242
+
243
+ # Reads one message and stores the result in the appropriate member variables(s)
244
+ #
245
+ # Caller must hold self.__cond
246
+ async def __read_and_dispatch(self):
247
+ # TODO: I'm not thrilled with this unlock-and-relock sequence.
248
+ # Getting rid of it would require refactoring the callers.
249
+ self.__cond.release()
250
+ try:
251
+ data = await self.__read_one_message()
252
+ finally:
253
+ await self.__cond.acquire()
254
+
255
+ if not self.__is_message_a_reply(data):
256
+ self.__unsolicited_queue.append(data)
257
+
258
+ if (
259
+ self.__current_query
260
+ and self.__current_query.unsolicited_messages is not None
261
+ ):
262
+ self.__current_query.unsolicited_messages.append(data)
263
+ _LOGGER.debug("Received semi-solicited message: %s", repr(data))
264
+ else:
265
+ _LOGGER.debug("Received unsolicited message: %s", repr(data))
266
+
267
+ self.__cond.notify_all()
268
+ else:
269
+ if self.__current_query is None:
270
+ _LOGGER.error("Received unexpected syncronous message %s", repr(data))
271
+ return # No need to notify_all()
272
+
273
+ if self.__current_query.reply is not None:
274
+ _LOGGER.error(
275
+ "Received syncronous message %s before handling prior sync message %s",
276
+ (repr(data), repr(self.__current_query.reply)),
277
+ )
278
+ return # No need to notify_all()
279
+
280
+ _LOGGER.debug("Received synchronous message: %s", repr(data))
281
+ self.__current_query.reply = data
282
+ self.__cond.notify_all()
283
+
284
+ # Reads until predicate returns true. May be called concurrently.
285
+ # Needs to tolerate cancellation.
286
+ #
287
+ # Caller must hold self.__cond
288
+ async def __wait_for_data(self, predicate: Callable[[], bool]):
289
+ assert self.__cond.locked()
290
+
291
+ while True:
292
+ if predicate():
293
+ return
294
+
295
+ if self.__currently_reading:
296
+ await self.__cond.wait()
297
+ continue
298
+
299
+ try:
300
+ self.__currently_reading = True
301
+ await self.__read_and_dispatch()
302
+ finally:
303
+ self.__currently_reading = False
304
+
305
+ async def __raw_query(
306
+ self, command: bytes, unsolicited_out: None | list[bytes] = None
307
+ ) -> bytes:
308
+ assert self.__conn is not None
309
+
310
+ async with self.__query_lock:
311
+ async with self.__cond:
312
+ if self.__current_query:
313
+ raise ProtocolError(
314
+ "raw_query called while a query in in progress (did you cancel and try again)"
315
+ )
316
+ self.__current_query = _CurrentQuery(
317
+ reply=None, unsolicited_messages=unsolicited_out
318
+ )
319
+
320
+ assert b"\r\n" not in command
321
+
322
+ self.__conn.w.write(command + b"\r\n")
323
+ await self.__conn.w.drain()
324
+
325
+ async with self.__cond:
326
+ await self.__wait_for_data(
327
+ lambda: self.__current_query is not None
328
+ and self.__current_query.reply is not None
329
+ )
330
+
331
+ assert self.__current_query is not None
332
+ assert self.__current_query.reply is not None
333
+ reply = self.__current_query.reply
334
+ self.__current_query = None
335
+ return reply
336
+
337
+ # Issues a command and returns the synchronous reply.
338
+ async def raw_query(self, command: bytes) -> bytes:
339
+ return await self.__raw_query(command, None)
340
+
341
+ # Issues a command and returns the synchronous reply and
342
+ # a copy of all unsolicited messages received starting just before
343
+ # sending the command and receiving the synchronous reply.
344
+ # This is inherently racy and may return earlier unsolicited messages
345
+ # as well. It will not prevent read_unsolicited() from receiving the
346
+ # same messages.
347
+ #
348
+ # This is useful for probing devices or outputs without interfering
349
+ # with whatever other code might be using read_unsolicited()
350
+ async def raw_query_collect(self, command: bytes) -> tuple[bytes, list[bytes]]:
351
+ unsolicited_out: list[bytes] = []
352
+ reply = await self.__raw_query(command, unsolicited_out)
353
+ return (reply, unsolicited_out)
354
+
355
+ # Helper to ping the other end
356
+ async def ping(self) -> None:
357
+ await self.raw_query(b"")
358
+
359
+ async def send_device_command(
360
+ self,
361
+ dev: types.SerialNumber | bytes,
362
+ component: int,
363
+ action: types.DeviceAction,
364
+ params: Iterable[bytes],
365
+ ) -> None:
366
+ if isinstance(dev, types.SerialNumber):
367
+ sn = dev.sn
368
+ else:
369
+ sn = dev
370
+ command = b"#DEVICE,%s,%d,%d,%s" % (
371
+ sn,
372
+ component,
373
+ action.value,
374
+ b",".join(params),
375
+ )
376
+ await self.raw_query(command)
377
+
378
+ # Reads one single unsolicited message
379
+ async def read_unsolicited(self) -> bytes:
380
+ async with self.__cond:
381
+ await self.__wait_for_data(lambda: len(self.__unsolicited_queue) >= 1)
382
+
383
+ result = self.__unsolicited_queue.popleft()
384
+ return result
385
+
386
+ # Higher-level queries
387
+
388
+ # Gets the most recent for all components that respond to probes.
389
+ async def probe_device(
390
+ self, dev_id: types.SerialNumber | bytes
391
+ ) -> tuple[bytes, list[bytes]]:
392
+ if isinstance(dev_id, bytes):
393
+ target = dev_id
394
+ else:
395
+ assert isinstance(dev_id, types.SerialNumber)
396
+ target = dev_id.sn
397
+ return await self.raw_query_collect(b"?DEVICE,%s,0,0" % target)
398
+
399
+ async def disconnect(self) -> None:
400
+ if self.__conn is None:
401
+ return None
402
+ self.__conn.w.close()
403
+ await self.__conn.w.wait_closed()
404
+ self._conn = None
405
+ return None
406
+
407
+
408
+ async def login(
409
+ reader: asyncio.StreamReader,
410
+ writer: asyncio.StreamWriter,
411
+ username: bytes,
412
+ password: None | bytes,
413
+ ) -> LutronConnection:
414
+ """
415
+ Authenticate with a Lutron device over an asyncio stream.
416
+
417
+ Waits for the login prompt, sends the username, and validates the response.
418
+
419
+ Args:
420
+ reader: The asyncio StreamReader for receiving data
421
+ writer: The asyncio StreamWriter for sending data
422
+ username: The username as bytes
423
+ password: The password as bytes, or None if no password is required
424
+
425
+ Returns:
426
+ LutronConnection object representing the established connection
427
+
428
+ Raises:
429
+ LoginError: If the login fails, containing the error message from the server
430
+ """
431
+ # Wait for the login prompt
432
+ await reader.readuntil(b"login: ")
433
+
434
+ # Send the username (line-oriented protocol requires newline)
435
+ writer.write(username + b"\r\n")
436
+ await writer.drain()
437
+
438
+ # Read the server's response
439
+ response = await reader.readline()
440
+ response = response.strip()
441
+
442
+ # Check if login was successful
443
+ if response == b"connection established":
444
+ return await LutronConnection.create_from_connnection(reader, writer)
445
+ else:
446
+ raise LoginError(response)
447
+
448
+
449
+ async def dump_replies(conn: LutronConnection):
450
+ try:
451
+ async with asyncio.timeout(0.25):
452
+ while True:
453
+ # It would be nice to just do this until it would block, but StreamReader doesn't
454
+ # have a nonblocking read operation
455
+ print(repr(await conn.read_unsolicited()))
456
+ except TimeoutError:
457
+ pass
@@ -0,0 +1,365 @@
1
+ from dataclasses import dataclass
2
+ import re
3
+ from . import types, connection
4
+ import logging
5
+
6
+ _LOGGER = logging.getLogger(__name__)
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ArraySpec:
11
+ count: int
12
+ base: int # First component number
13
+ stride: int = 1 # Spacing between component numbers
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ComponentGroup:
18
+ name: str # Programmer-friendly name, like "ZONE"
19
+ desc: str # Base description without number, like "Zone Controller"
20
+ array_spec: ArraySpec | None = None # Array specification (for array mode)
21
+ numbers: tuple[int, ...] | None = (
22
+ None # Explicit list of component numbers (for arbitrary mode)
23
+ )
24
+
25
+ @property
26
+ def count(self) -> int:
27
+ if self.array_spec is not None:
28
+ return self.array_spec.count
29
+ else:
30
+ assert self.numbers is not None
31
+ return len(self.numbers)
32
+
33
+ def __post_init__(self):
34
+ # Validate that exactly one mode is specified
35
+ if (self.array_spec is None) == (self.numbers is None):
36
+ raise ValueError("Must specify either array_spec or numbers but not both")
37
+
38
+ if self.numbers is not None and not self.numbers:
39
+ raise ValueError("numbers cannot be an empty list")
40
+
41
+ # Initialize per-group cache
42
+ object.__setattr__(self, "_cache", {})
43
+
44
+ def lookup_component(self, number: int) -> int | None:
45
+ """Check if this group contains a component number.
46
+
47
+ Returns the 1-based index if found, None otherwise.
48
+ """
49
+ if self.numbers is not None:
50
+ # Arbitrary mode
51
+ try:
52
+ return self.numbers.index(number) + 1
53
+ except ValueError:
54
+ return None
55
+ else:
56
+ # Array mode
57
+ assert self.array_spec is not None
58
+ if number >= self.array_spec.base:
59
+ offset = number - self.array_spec.base
60
+ if offset % self.array_spec.stride == 0:
61
+ index = offset // self.array_spec.stride + 1
62
+ if 1 <= index <= self.array_spec.count:
63
+ return index
64
+ return None
65
+
66
+ def component_number(self, index: int) -> int | None:
67
+ """Get the component number for a 1-based index.
68
+
69
+ Args:
70
+ index: 1-based index into this component group
71
+
72
+ Returns:
73
+ Component number if index is valid, None otherwise.
74
+ """
75
+ if index < 1 or index > self.count:
76
+ return None
77
+
78
+ if self.numbers is not None:
79
+ # Arbitrary mode
80
+ return self.numbers[index - 1]
81
+ else:
82
+ # Array mode
83
+ assert self.array_spec is not None
84
+ return self.array_spec.base + (index - 1) * self.array_spec.stride
85
+
86
+
87
+ class DeviceClass:
88
+ """Represents a device type with its component groups and individual components."""
89
+
90
+ groups: dict[str, ComponentGroup]
91
+
92
+ def __init__(self, groups: list[ComponentGroup]):
93
+ """Initialize a device class with component groups and individual components.
94
+
95
+ Args:
96
+ groups: List of ComponentGroup instances
97
+ components: Dictionary mapping component names to ComponentGroup instances
98
+ """
99
+ self.groups = {g.name: g for g in groups}
100
+
101
+ def lookup_component(self, number: int) -> tuple[ComponentGroup, int] | None:
102
+ """Resolves a component number to a ComponentGroup and index within the group"""
103
+
104
+ # TODO: Consider adding a cache
105
+
106
+ # Search through component groups to find a match
107
+ for group in self.groups.values():
108
+ index = group.lookup_component(number)
109
+ if index is not None:
110
+ return (group, index)
111
+
112
+ return None
113
+
114
+
115
+ FAMILY_TO_CLASS: dict[bytes, DeviceClass] = {}
116
+
117
+ # Grafik Eye QS Device Definition
118
+ GrafikEyeQS = DeviceClass(
119
+ groups=[
120
+ ComponentGroup(
121
+ name="ZONE", desc="Zone Controller", array_spec=ArraySpec(count=24, base=1)
122
+ ),
123
+ ComponentGroup(
124
+ name="SHADE_OPEN", desc="Shade Column Open", numbers=(38, 44, 50)
125
+ ),
126
+ ComponentGroup(
127
+ name="SHADE_PRESET", desc="Shade Column Preset", numbers=(39, 45, 51)
128
+ ),
129
+ ComponentGroup(
130
+ name="SHADE_CLOSE", desc="Shade Column Close", numbers=(40, 46, 56)
131
+ ),
132
+ ComponentGroup(
133
+ name="SHADE_LOWER", desc="Shade Column Lower", numbers=(41, 52, 57)
134
+ ),
135
+ ComponentGroup(
136
+ name="SHADE_RAISE", desc="Shade Column Raise", numbers=(47, 53, 58)
137
+ ),
138
+ ComponentGroup(
139
+ name="SCENE_BUTTON", desc="Scene Button", numbers=(70, 71, 76, 77)
140
+ ),
141
+ ComponentGroup(name="SCENE_OFF_BUTTON", desc="Scene Off Button", numbers=(83,)),
142
+ ComponentGroup(
143
+ name="SCENE_CONTROLLER", desc="Scene Controller", numbers=(141,)
144
+ ),
145
+ ComponentGroup(name="LOCAL_CCI", desc="Local CCI", numbers=(163,)),
146
+ ComponentGroup(
147
+ name="TIMECLOCK_CONTROLLER", desc="Timeclock Controller", numbers=(166,)
148
+ ),
149
+ # The LEDs are not available in QS Standalone
150
+ ComponentGroup(
151
+ name="SCENE_LED",
152
+ desc="Scene LED",
153
+ array_spec=ArraySpec(count=4, base=201, stride=9),
154
+ ),
155
+ ComponentGroup(name="SCENE_OFF_LED", desc="Scene Off LED", numbers=(237,)),
156
+ ComponentGroup(
157
+ name="SHADE_OPEN_LED",
158
+ desc="Shade Column Open LED",
159
+ array_spec=ArraySpec(count=3, base=174, stride=9),
160
+ ),
161
+ ComponentGroup(
162
+ name="SHADE_PRESET_LED",
163
+ desc="Shade Column Preset LED",
164
+ array_spec=ArraySpec(count=3, base=175, stride=9),
165
+ ),
166
+ ComponentGroup(
167
+ name="SHADE_CLOSE_LED",
168
+ desc="Shade Column Close LED",
169
+ array_spec=ArraySpec(count=3, base=211, stride=9),
170
+ ),
171
+ ComponentGroup(
172
+ name="WIRELESS_OCC_SENSOR",
173
+ desc="Wireless Occupancy Sensor",
174
+ array_spec=ArraySpec(count=30, base=500),
175
+ ),
176
+ ComponentGroup(
177
+ name="ECOSYSTEM_OCC_SENSOR",
178
+ desc="EcoSystem Ballast Occupancy Sensor",
179
+ array_spec=ArraySpec(count=64, base=700),
180
+ ),
181
+ # These components are not documented.
182
+ # TODO: Confirm the behavior of the zone buttons on a 16-zone unit
183
+ ComponentGroup(name="MASTER_RAISE", desc="Master Raise Button", numbers=(74,)),
184
+ ComponentGroup(name="MASTER_LOWER", desc="Master Lower Button", numbers=(75,)),
185
+ ComponentGroup(
186
+ name="ZONE_RAISE",
187
+ desc="Zone Raise Button",
188
+ array_spec=ArraySpec(count=8, base=36, stride=6),
189
+ ),
190
+ ComponentGroup(
191
+ name="ZONE_LOWER",
192
+ desc="Zone Lower Button",
193
+ array_spec=ArraySpec(count=8, base=37, stride=6),
194
+ ),
195
+ ComponentGroup(name="TIMECLOCK_BUTTON", desc="Timeclock Button", numbers=(68,)),
196
+ ComponentGroup(name="OK_BUTTON", desc="OK Button", numbers=(69,)),
197
+ ComponentGroup(
198
+ name="SWITCH_GROUP_BUTTON", desc="Swich Group Button", numbers=(80,)
199
+ ),
200
+ ]
201
+ )
202
+
203
+ Shade = DeviceClass(
204
+ groups=[
205
+ # Yes, Lutron really did not document any components!
206
+ # Shades accept a target position as "light level" (action 14)
207
+ # on component 0 and report their position via this action as well.
208
+ ComponentGroup(
209
+ name="SHADE",
210
+ desc="Shade Position",
211
+ numbers=(0,),
212
+ ),
213
+ ]
214
+ )
215
+
216
+ Keypad = DeviceClass(
217
+ groups=[
218
+ # Buttons 1-7: most keypads have these, even if they claim to have fewer
219
+ # buttons. They might be hiding under the cover plate.
220
+ ComponentGroup(
221
+ name="BUTTON", desc="Button", array_spec=ArraySpec(count=7, base=1)
222
+ ),
223
+ # The top raise/lower buttons only sometimes exist. The bottom raise/lower
224
+ # buttons are very common. It's a bit unclear what happens if they are
225
+ # physically absent (e.g. on a "7-button" Architrave keypad, not all
226
+ # programming features are present because the bottom raise/lower buttons
227
+ # don't exist.)
228
+ ComponentGroup(name="BUTTON_TOP_LOWER", desc="Button Top Lower", numbers=(16,)),
229
+ ComponentGroup(name="BUTTON_TOP_RAISE", desc="Button Top Raise", numbers=(17,)),
230
+ ComponentGroup(
231
+ name="BUTTON_BOTTOM_LOWER", desc="Button Top Lower", numbers=(18,)
232
+ ),
233
+ ComponentGroup(
234
+ name="BUTTON_BOTTOM_RAISE", desc="Button Top Raise", numbers=(19,)
235
+ ),
236
+ ]
237
+ )
238
+
239
+ # TODO: This isn't great: a shade power supply is 'SHADES(3)' but is not a shade
240
+ FAMILY_TO_CLASS[b"KEYPAD(1)"] = Keypad
241
+ FAMILY_TO_CLASS[b"GRAFIK_EYE(2)"] = GrafikEyeQS
242
+ FAMILY_TO_CLASS[b"SHADES(3)"] = Shade
243
+
244
+
245
+ def action_to_friendly_str(action: int):
246
+ try:
247
+ return types.DeviceAction(action).name
248
+ except ValueError:
249
+ return str(action)
250
+
251
+
252
+ @dataclass
253
+ class DeviceUpdateValues:
254
+ component: int
255
+ action: types.DeviceAction
256
+ params: tuple[bytes]
257
+
258
+
259
+ @dataclass
260
+ class DeviceUpdate:
261
+ """Represents a parsed device update message."""
262
+
263
+ serial_number: types.SerialNumber
264
+ component: int
265
+ action: types.DeviceAction
266
+ value: tuple[bytes, ...]
267
+
268
+
269
+ _DEVICE_UPDATE_RE = re.compile(rb"~DEVICE,([^,]+),(\d+),(\d+)(?:,([^\r]*))?\r\n", re.S)
270
+
271
+
272
+ def decode_device_update(
273
+ message: bytes, iidmap: types.IntegrationIDMap
274
+ ) -> DeviceUpdate | None:
275
+ """Parse a ~DEVICE message into a DeviceUpdate.
276
+
277
+ Args:
278
+ message: Raw ~DEVICE message bytes
279
+ universe: LutronUniverse for resolving device identifiers
280
+
281
+ Returns:
282
+ DeviceUpdate if message was parsed successfully, None otherwise
283
+ """
284
+
285
+ # ~DEVICE,<identifier>,<component>,<action>[,<params>]\r\n
286
+ match = _DEVICE_UPDATE_RE.fullmatch(message)
287
+ if not match:
288
+ _LOGGER.debug(f"Failed to parse device message: {message!r}")
289
+ return None
290
+
291
+ device_identifier = match[1]
292
+ component = int(match[2])
293
+ action_int = int(match[3])
294
+ value = tuple(match[4].split(b",")) if match[4] else ()
295
+
296
+ # Resolve device identifier to serial number
297
+ try:
298
+ sn = types.SerialNumber(device_identifier)
299
+ except ValueError:
300
+ # Not a serial number, try integration ID
301
+ if device_identifier in iidmap.device_ids:
302
+ sn = iidmap.device_ids[device_identifier]
303
+ else:
304
+ _LOGGER.debug("Unknown device identifier: %s", device_identifier)
305
+ return None
306
+
307
+ try:
308
+ action = types.DeviceAction(action_int)
309
+ except ValueError:
310
+ _LOGGER.debug(f"Unknown action {action_int} in update {message!r}")
311
+ return None
312
+
313
+ return DeviceUpdate(
314
+ serial_number=sn, component=component, action=action, value=value
315
+ )
316
+
317
+
318
+ async def probe_device(
319
+ conn: connection.LutronConnection,
320
+ iidmap: types.IntegrationIDMap,
321
+ dev_id: types.SerialNumber | bytes,
322
+ ) -> list[DeviceUpdate]:
323
+ result: list[DeviceUpdate] = []
324
+ _, updates = await conn.probe_device(dev_id)
325
+
326
+ for update in updates:
327
+ decoded = decode_device_update(update, iidmap)
328
+ if decoded is not None:
329
+ result.append(decoded)
330
+
331
+ return result
332
+
333
+
334
+ _IIDLINE_RE = re.compile(
335
+ b"~INTEGRATIONID,([^,]+),(DEVICE|OUTPUT),([0-9A-Fa-fx]+)(?:,([0-9]+))?", re.S
336
+ )
337
+
338
+
339
+ async def enumerate_iids(conn: connection.LutronConnection) -> types.IntegrationIDMap:
340
+ iidmap = types.IntegrationIDMap()
341
+
342
+ integration_ids = await conn.raw_query(b"?INTEGRATIONID,3")
343
+ iidlines = integration_ids.split(b"\r\n")
344
+
345
+ if not iidlines or iidlines[-1] != b"":
346
+ raise types.ParseError("~INTEGRATIONIDS,3 list does not split correctly")
347
+ iidlines = iidlines[:-1]
348
+
349
+ for line in iidlines:
350
+ m = _IIDLINE_RE.fullmatch(line)
351
+ if not m:
352
+ raise types.ParseError(f"Integration id line {line!r} does not parse")
353
+ name = m[1]
354
+ if name == b"(Not Set)":
355
+ # If we ever support a dialect that does not support ?DETAILS, then we could
356
+ # use the (Not Set) lines as a way to discover devices without integration IDs.
357
+ # This is not necessary for QS Standalone.
358
+ continue
359
+ sn = types.SerialNumber(m[3])
360
+ if m[2] == b"DEVICE":
361
+ iidmap.device_ids[name] = sn
362
+ else:
363
+ iidmap.output_ids[name] = (sn, int(m[4]))
364
+
365
+ return iidmap
@@ -0,0 +1,141 @@
1
+ from dataclasses import dataclass, field
2
+ from . import connection, types, devices
3
+ from .types import SerialNumber
4
+
5
+ # Notes:
6
+ #
7
+ # Integration ids for devices appear to be stored on the devices (or at least if you
8
+ # wipe the flash on the NWK via #RESET,2 then they are not cleared). You cannot
9
+ # set an integration id for a nonexistent device. Setting an integration id for
10
+ # a device that does exist is quite slow.
11
+ #
12
+ # Integration ids for *outputs* seem to be rather different. They can be set quite
13
+ # quickly, but only for devices that actually exist, but you can integration ids
14
+ # for absurdly large output numbers that do not actually exist. Additionally,
15
+ # #RESET,2 seems to erase all the output integration ids.
16
+ #
17
+ # My hypotheses is that output integration IDs are translated in the NWK
18
+ # to/from device commands and states. For example, Grafik Eye has documented
19
+ # DEVICE component numbers for up to 24 zone controllers. Monitoring shows
20
+ # ~DEVICE if no integrationid is assigned and ~OUTPUT if an integration id
21
+ # is assigned. This mapping might not even care about the device type --
22
+ # it might be the case that action 14 (light level) always maps to OUTPUT
23
+ # action 1 (light level), and that the component number maps straight through.
24
+
25
+ # The integrationid system tries to be well behaved. It tries to reject
26
+ # duplicates. You can set any number of device integration ids to
27
+ # b"(Not Set)", in which case they appear to be not set. But it does
28
+ # not prevent you from setting an integration id that looks like a
29
+ # serial number, and you can cause #INTEGRATIONID to go into an
30
+ # infinite loop by first setting a device's integration id to its own
31
+ # serial number and then trying to change it. (This appears to be
32
+ # recoverable by rebooting the NWK.)
33
+
34
+
35
+ @dataclass
36
+ class DeviceDetails:
37
+ sn: SerialNumber
38
+ integration_id: bytes
39
+ family: bytes
40
+ product: bytes
41
+
42
+ # raw_attrs likely contains at least b'CODE', b'BOOT', and b'HW',
43
+ # and it also contins b'SN', b'INTEGRATIONID', etc.
44
+ raw_attrs: dict[bytes, bytes]
45
+
46
+
47
+ _DETAILS_KEYS = {
48
+ "SN": "sn",
49
+ "INTEGRATIONID": "integration_id",
50
+ "FAMILY": "family",
51
+ "PRODUCT": "product",
52
+ }
53
+
54
+
55
+ def parse_details(data: bytes) -> list[DeviceDetails]:
56
+ """
57
+ Parse a reply to ?DETAILS into a list of dictionaries, one per device
58
+
59
+ Each line is formatted as
60
+ ~DETAILS,KEY1:VALUE1,KEY2:VALUE2,...
61
+
62
+ Args:
63
+ data: Raw bytes containing multiple ~DETAILS lines separated by \r\n
64
+
65
+ Returns:
66
+ List of dictionaries, where each dict represents one device with
67
+ keys and values as bytes
68
+ """
69
+ result: list[DeviceDetails] = []
70
+
71
+ # Split into lines
72
+ lines = data.split(b"\r\n")
73
+
74
+ if not lines or lines[-1] != b"":
75
+ raise types.ParseError("~DETAILS list does not split correctly")
76
+ lines = lines[:-1]
77
+
78
+ for line in lines:
79
+ # Check if line starts with ~DETAILS,
80
+ if not line.startswith(b"~DETAILS,"):
81
+ raise types.ParseError(f"Unexpected details line: {line!r}")
82
+
83
+ # Remove the ~DETAILS, prefix
84
+ details_str = line[9:] # len(b'~DETAILS,') == 9
85
+
86
+ # Split by comma to get key-value pairs
87
+ pairs = details_str.split(b",")
88
+
89
+ attrs: dict[bytes, bytes] = {}
90
+ for pair in pairs:
91
+ # Split by first colon only (values might contain colons)
92
+ if b":" in pair:
93
+ key, value = pair.split(b":", 1)
94
+ attrs[key] = value
95
+ else:
96
+ raise types.ParseError("Details entry {pair!r} has no comma")
97
+
98
+ device = DeviceDetails(
99
+ sn=SerialNumber(attrs[b"SN"]),
100
+ integration_id=attrs[b"INTEGRATIONID"],
101
+ family=attrs[b"FAMILY"],
102
+ product=attrs[b"PRODUCT"],
103
+ raw_attrs=attrs,
104
+ )
105
+
106
+ result.append(device)
107
+
108
+ return result
109
+
110
+
111
+ # TODO: This isn't just QSE. Homeworks QS, Quantum, and myRoom Plus support this.
112
+ @dataclass
113
+ class IntegrationIDRecord:
114
+ iid: bytes
115
+ style: bytes # either b'DEVICE' or b'OUTPUT'
116
+ sn: SerialNumber
117
+
118
+
119
+ @dataclass
120
+ class LutronUniverse:
121
+ devices_by_sn: dict[SerialNumber, DeviceDetails] = field(default_factory=dict)
122
+ iidmap: types.IntegrationIDMap = field(default_factory=types.IntegrationIDMap)
123
+
124
+ # NB: It's possible for devices_by_sn and iidmap to be out of sync
125
+ # with each other if configuration changes between when we read
126
+ # ?DETAILS and ?INTEGRATIONID.
127
+
128
+
129
+ async def enumerate_universe(conn: connection.LutronConnection) -> LutronUniverse:
130
+ all_devices = parse_details(await conn.raw_query(b"?DETAILS,ALL_DEVICES"))
131
+
132
+ universe = LutronUniverse()
133
+ universe.devices_by_sn = {d.sn: d for d in all_devices}
134
+
135
+ # We could map iids to devices like this:
136
+ # {d.integration_id: d for d in all_devices if d.integration_id != b'(Not Set)'}
137
+ # but it's redundant with iidmap.
138
+
139
+ universe.iidmap = await devices.enumerate_iids(conn)
140
+
141
+ return universe
@@ -0,0 +1,105 @@
1
+ from dataclasses import dataclass, field
2
+ import re
3
+ from enum import Enum, unique
4
+
5
+ _SN_RE = re.compile(b"(?:0x)?([0-9A-Fa-f]{0,8})", re.S)
6
+
7
+
8
+ # A serial number as reported by the integration access point
9
+ # is an optional 0x followed by
10
+ # 8 hexadecimal digits, with inconsistent case. The NWK accepts a serial
11
+ # number with up to two 0x prefixes followed by any number (maybe up to
12
+ # some limit) of zeros followed by case-insensitive hex digits.
13
+ #
14
+ # Some but not all commands will accept an integration id in place of
15
+ # a serial number. The NWK can get extremely confused if there is an
16
+ # integration id that is also a well-formed serial number.
17
+ #
18
+ # (All of this comes from testing a QSE-CI-NWK-E, but I expect
19
+ # it to be compatible with other integration access points
20
+ # as well.)
21
+ #
22
+ # This class represents a canonicalized serial number. It's hashable.
23
+ @dataclass(order=False, eq=True, frozen=True)
24
+ class SerialNumber:
25
+ sn: bytes
26
+
27
+ def __init__(self, sn: bytes):
28
+ m = _SN_RE.fullmatch(sn)
29
+ if not m:
30
+ raise ValueError(f"Malformed serial number {sn!r}")
31
+ sn = m[1]
32
+ object.__setattr__(self, "sn", b"0" * (8 - len(sn)) + sn.upper())
33
+
34
+ def __repr__(self):
35
+ return f"SerialNumber({self.sn!r})"
36
+
37
+ def __str__(self):
38
+ return self.sn.decode()
39
+
40
+
41
+ # These are DEVICE actions. OUTPUT actions are different.
42
+ @unique
43
+ class DeviceAction(Enum):
44
+ ENABLE = 1
45
+ DISABLE = 2
46
+ PRESS_CLOSE_UNOCC = 3 # Press button or close shades or room unoccupied
47
+ RELEASE_OPEN_OCC = 4 # Release button or open shades or room occupied
48
+ HOLD = 5
49
+ DOUBLE_TAP = 6
50
+ CURRENT_SCENE = 7
51
+ LED_STATE = 9
52
+ SCENE_SAVE = 12
53
+ LIGHT_LEVEL = 14
54
+ ZONE_LOCK = 15
55
+ SCENE_LOCK = 16
56
+ SEQUENCE_STATE = 17
57
+ START_RAISING = 18
58
+ START_LOWERING = 19
59
+ STOP_RAISING_LOWERING = 20
60
+ HOLD_RELEASE = 32 # for keypads -- I have no idea what it does
61
+ TIMECLOCK_STATE = 34 # 0 = disabled, 1 = enabled
62
+
63
+ # 21 is a mysterious property of the SHADE component of shades.
64
+ # It seems to have the value 0 most of the time but has other values when the shade
65
+ # is moving.
66
+ MOTOR_MYSTERY = 21
67
+
68
+
69
+ @unique
70
+ class OutputAction(Enum):
71
+ LIGHT_LEVEL = 1
72
+ START_RAISING = 2
73
+ START_LOWERING = 3
74
+ STOP_RAISING_LOWERING = 4
75
+ START_FLASHING = 5
76
+ PULSE_TIME = 6
77
+ TILT_LEVEL = 9
78
+ LIFT_TILT_LEVEL = 10
79
+ START_RAISING_TILT = 11
80
+ START_LOWERING_TILT = 12
81
+ STOP_RAISING_LOWERING_TILT = 13
82
+ START_RAISING_LIFT = 14
83
+ START_LOWERING_LIFT = 15
84
+ STOP_RAISING_LOWERING_LIFT = 16
85
+ DMX_COLOR_LEVEL = 17
86
+
87
+
88
+ @dataclass
89
+ class IntegrationIDMap:
90
+ # Maps output integration ids to the device sn and output/zone number
91
+ output_ids: dict[bytes, tuple[SerialNumber, int]] = field(default_factory=dict)
92
+
93
+ # Maps device integration ids to the device sn
94
+ device_ids: dict[bytes, SerialNumber] = field(default_factory=dict)
95
+
96
+ # We don't bother storing the reverse mapping anywhere -- we need
97
+ # to be able to control outputs that don't have integration IDs,
98
+ # and there appears to be no benefit to ever sending an #OUTPUT command.
99
+
100
+
101
+ class ParseError(Exception):
102
+ """Exception raised when a message doesn't parse correctly."""
103
+
104
+ def __init__(self, message: str) -> None:
105
+ super().__init__(str)