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.
- lutron_integration-0.0.0a1/PKG-INFO +77 -0
- lutron_integration-0.0.0a1/README.md +60 -0
- lutron_integration-0.0.0a1/pyproject.toml +27 -0
- lutron_integration-0.0.0a1/src/lutron_integration/__init__.py +0 -0
- lutron_integration-0.0.0a1/src/lutron_integration/connection.py +457 -0
- lutron_integration-0.0.0a1/src/lutron_integration/devices.py +365 -0
- lutron_integration-0.0.0a1/src/lutron_integration/py.typed +0 -0
- lutron_integration-0.0.0a1/src/lutron_integration/qse.py +141 -0
- lutron_integration-0.0.0a1/src/lutron_integration/types.py +105 -0
|
@@ -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"
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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)
|