tinkly 0.2.0__py3-none-any.whl
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.
- tinkly/__init__.py +0 -0
- tinkly/authentication.py +67 -0
- tinkly/client.py +399 -0
- tinkly/discovery.py +89 -0
- tinkly/errors.py +27 -0
- tinkly/frame.py +100 -0
- tinkly-0.2.0.dist-info/METADATA +5 -0
- tinkly-0.2.0.dist-info/RECORD +10 -0
- tinkly-0.2.0.dist-info/WHEEL +5 -0
- tinkly-0.2.0.dist-info/top_level.txt +1 -0
tinkly/__init__.py
ADDED
|
File without changes
|
tinkly/authentication.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Cryptographic helpers used by Twinkly authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import itertools
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
CHALLENGE_KEY = b'evenmoresecret!!'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def mac_bytes(mac: str) -> bytes:
|
|
13
|
+
"""Decode a MAC address into six bytes, ignoring non-hex separators.
|
|
14
|
+
|
|
15
|
+
Raises:
|
|
16
|
+
ValueError: The address does not contain exactly twelve hex digits.
|
|
17
|
+
"""
|
|
18
|
+
compact = re.sub(r'[^0-9a-fA-F]', '', mac)
|
|
19
|
+
if len(compact) != 12:
|
|
20
|
+
raise ValueError(f'MAC address must contain 12 hex digits: {mac!r}')
|
|
21
|
+
return bytes.fromhex(compact)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def xor_repeating(message: bytes, key: bytes) -> bytes:
|
|
25
|
+
"""XOR each message byte with a cycling key and return the resulting bytes.
|
|
26
|
+
|
|
27
|
+
An empty key produces an empty result.
|
|
28
|
+
"""
|
|
29
|
+
return bytes(m ^ k for m, k in zip(message, itertools.cycle(key)))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def derive_key(shared_key: bytes, mac: str) -> bytes:
|
|
33
|
+
"""XOR a shared key with the repeating six-byte device MAC address."""
|
|
34
|
+
return xor_repeating(shared_key, mac_bytes(mac))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def rc4(message: bytes, key: bytes) -> bytes:
|
|
38
|
+
"""Apply RC4 to a message using a non-empty key.
|
|
39
|
+
|
|
40
|
+
The same operation encrypts and decrypts. This reproduces the cipher used
|
|
41
|
+
by the device's authentication protocol.
|
|
42
|
+
"""
|
|
43
|
+
state = list(range(256))
|
|
44
|
+
j = 0
|
|
45
|
+
for i in range(256):
|
|
46
|
+
j = (j + state[i] + key[i % len(key)]) % 256
|
|
47
|
+
state[i], state[j] = state[j], state[i]
|
|
48
|
+
|
|
49
|
+
output = bytearray()
|
|
50
|
+
i = 0
|
|
51
|
+
j = 0
|
|
52
|
+
for b in message:
|
|
53
|
+
i = (i + 1) % 256
|
|
54
|
+
j = (j + state[i]) % 256
|
|
55
|
+
state[i], state[j] = state[j], state[i]
|
|
56
|
+
output.append(b ^ state[(state[i] + state[j]) % 256])
|
|
57
|
+
return bytes(output)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def make_challenge_response(challenge: bytes, mac: str) -> str:
|
|
61
|
+
"""Return the device authentication response as a SHA-1 hex digest.
|
|
62
|
+
|
|
63
|
+
Encrypt the raw challenge with RC4 using the shared key combined with the
|
|
64
|
+
device MAC address, then hash the encrypted bytes.
|
|
65
|
+
"""
|
|
66
|
+
encrypted = rc4(challenge, derive_key(CHALLENGE_KEY, mac))
|
|
67
|
+
return hashlib.sha1(encrypted).hexdigest()
|
tinkly/client.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Small HTTP client for Twinkly devices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from http import client
|
|
11
|
+
|
|
12
|
+
from .authentication import make_challenge_response
|
|
13
|
+
from .errors import AuthenticationError, ProtocolError, UnsupportedEndpointError
|
|
14
|
+
|
|
15
|
+
AUTH_HEADER = 'X-Auth-Token'
|
|
16
|
+
TWINKLY_API_PREFIX = '/xled/v1'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def getter(path: str, authenticated: bool = True) -> property:
|
|
20
|
+
"""Create a read-only endpoint property returning a fresh TwinklyResponse.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
path: Endpoint relative to the device's `/xled/v1/` API prefix.
|
|
24
|
+
authenticated: Obtain a token if needed and include it in each request.
|
|
25
|
+
|
|
26
|
+
Reading the property performs an HTTP GET every time; results are not cached.
|
|
27
|
+
Request errors propagate to the caller.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def read(self: TwinklyClient) -> TwinklyResponse:
|
|
31
|
+
"""Fetch the endpoint using this property's authentication setting."""
|
|
32
|
+
return self.get(path, authenticated=authenticated)
|
|
33
|
+
|
|
34
|
+
return property(read)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def setter(path: str) -> property:
|
|
38
|
+
"""Create a readable, writable authenticated endpoint property.
|
|
39
|
+
|
|
40
|
+
Reads perform HTTP GET and return a fresh TwinklyResponse. Assignments
|
|
41
|
+
accept a `dict[str, object]` JSON body and perform HTTP POST to the same
|
|
42
|
+
path, relative to `/xled/v1/`. Assignment discards the response; use
|
|
43
|
+
TwinklyClient.post when its result is needed. Request errors propagate.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def write(self: TwinklyClient, body: dict[str, object]) -> None:
|
|
47
|
+
"""POST the assigned JSON body and discard the response."""
|
|
48
|
+
self.post(path, body)
|
|
49
|
+
|
|
50
|
+
return getter(path).setter(write)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class TwinklyResponse:
|
|
55
|
+
"""Successful HTTP response and its decoded JSON object."""
|
|
56
|
+
|
|
57
|
+
http_status: int
|
|
58
|
+
"""HTTP status code returned by the device."""
|
|
59
|
+
data: dict[str, object]
|
|
60
|
+
"""Decoded JSON body, including any device application status code."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class AuthToken:
|
|
65
|
+
"""Authentication token, verification response, and optional expiry time."""
|
|
66
|
+
|
|
67
|
+
value: str = field(repr=False)
|
|
68
|
+
"""Base64 token used in HTTP headers and decoded for realtime UDP packets."""
|
|
69
|
+
challenge_response: str = field(repr=False)
|
|
70
|
+
"""Challenge response returned by login and sent back during verification."""
|
|
71
|
+
expires_at: float | None
|
|
72
|
+
"""Expiry as Unix time in seconds, or None if the device supplied no expiry."""
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def expired(self) -> bool:
|
|
76
|
+
"""Whether the known expiry time has passed; False if no expiry is known."""
|
|
77
|
+
return self.expires_at is not None and self.expires_at <= time.time()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class TwinklyClient:
|
|
82
|
+
"""Synchronous HTTP client for a single Twinkly device.
|
|
83
|
+
|
|
84
|
+
Endpoint properties return TwinklyResponse records on every read. Writable
|
|
85
|
+
properties POST assigned JSON dictionaries. Authentication is automatic
|
|
86
|
+
for protected endpoints. Each request opens and closes an HTTP connection;
|
|
87
|
+
retries, realtime frame pacing, and reconnection belong to the caller.
|
|
88
|
+
Serialize all use of a client; instances are not thread-safe.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
host: str
|
|
92
|
+
"""Device hostname or IPv4 address; HTTP requests use port 80."""
|
|
93
|
+
mac: str | None = None
|
|
94
|
+
"""Optional MAC address used to check the login challenge response."""
|
|
95
|
+
timeout: float = 5.0
|
|
96
|
+
"""HTTP connection and socket operation timeout in seconds."""
|
|
97
|
+
token: AuthToken | None = field(default=None, init=False)
|
|
98
|
+
"""Current authentication token, obtained automatically when needed."""
|
|
99
|
+
|
|
100
|
+
# Getters
|
|
101
|
+
current_movie = getter('led/movies/current')
|
|
102
|
+
"""Read the device's current movie selection."""
|
|
103
|
+
current_music_driver_set = getter('music/drivers/sets/current')
|
|
104
|
+
"""Read the currently selected music driver set."""
|
|
105
|
+
current_playlist_entry = getter('playlist/current')
|
|
106
|
+
"""Read the current entry in the device playlist."""
|
|
107
|
+
device_name = getter('device_name')
|
|
108
|
+
"""Read the device's configured name."""
|
|
109
|
+
effects = getter('led/effects')
|
|
110
|
+
"""Read the effects available on the device."""
|
|
111
|
+
firmware_version = getter('fw/version', authenticated=False)
|
|
112
|
+
"""Read the firmware version without logging in."""
|
|
113
|
+
mic_config = getter('mic/config')
|
|
114
|
+
"""Read the device's microphone configuration."""
|
|
115
|
+
mic_sample = getter('mic/sample')
|
|
116
|
+
"""Read a microphone sample reported by the device."""
|
|
117
|
+
movie_config = getter('led/movie/config')
|
|
118
|
+
"""Read the device's movie playback configuration."""
|
|
119
|
+
movies = getter('movies')
|
|
120
|
+
"""Read the device's stored movie listing."""
|
|
121
|
+
mqtt_config = getter('mqtt/config')
|
|
122
|
+
"""Read the device's MQTT configuration."""
|
|
123
|
+
music_driver_sets = getter('music/drivers/sets')
|
|
124
|
+
"""Read the available music driver sets."""
|
|
125
|
+
music_drivers = getter('music/drivers')
|
|
126
|
+
"""Read the music drivers available on the device."""
|
|
127
|
+
network_scan_results = getter('network/scan_results')
|
|
128
|
+
"""Read Wi-Fi scan results after starting a scan with start_network_scan()."""
|
|
129
|
+
network_status = getter('network/status')
|
|
130
|
+
"""Read the device's current network status."""
|
|
131
|
+
playlist = getter('playlist')
|
|
132
|
+
"""Read the device's configured playlist."""
|
|
133
|
+
status = getter('status', authenticated=False)
|
|
134
|
+
"""Read device status without logging in."""
|
|
135
|
+
summary = getter('summary')
|
|
136
|
+
"""Read the device summary supplied by the summary endpoint."""
|
|
137
|
+
|
|
138
|
+
# Setters
|
|
139
|
+
brightness = setter('led/out/brightness')
|
|
140
|
+
"""Read or update output brightness using mode, type, and value fields."""
|
|
141
|
+
current_effect = setter('led/effects/current')
|
|
142
|
+
"""Read the selected effect or select one by assigning an effect_id body."""
|
|
143
|
+
layout_full = setter('led/layout/full')
|
|
144
|
+
"""Read or upload the full LED layout, including coordinates and source."""
|
|
145
|
+
led_color = setter('led/color')
|
|
146
|
+
"""Read or update the static LED color using the device's color fields."""
|
|
147
|
+
led_config = setter('led/config')
|
|
148
|
+
"""Read or update the device's LED string configuration."""
|
|
149
|
+
led_mode = setter('led/mode')
|
|
150
|
+
"""Read the operating mode or set it by assigning a body with a mode field."""
|
|
151
|
+
saturation = setter('led/out/saturation')
|
|
152
|
+
"""Read or update output saturation using mode, type, and value fields."""
|
|
153
|
+
timer = setter('timer')
|
|
154
|
+
"""Read or update the device timer's time_now, time_on, and time_off fields."""
|
|
155
|
+
|
|
156
|
+
def authenticate(self) -> AuthToken:
|
|
157
|
+
"""Log in with a random challenge, verify the issued token, and return it.
|
|
158
|
+
|
|
159
|
+
Store the token on this client and calculate its expiry when supplied
|
|
160
|
+
by the device. Cache only a successfully verified, unexpired token.
|
|
161
|
+
If mac is set, check the returned challenge response before verification.
|
|
162
|
+
Invalid or non-positive expiry raises AuthenticationError; authentication
|
|
163
|
+
and request failures propagate without retrying verification.
|
|
164
|
+
"""
|
|
165
|
+
challenge = os.urandom(32)
|
|
166
|
+
login = self.post(
|
|
167
|
+
'login',
|
|
168
|
+
{'challenge': base64.b64encode(challenge).decode()},
|
|
169
|
+
authenticated=False,
|
|
170
|
+
)
|
|
171
|
+
data = login.data
|
|
172
|
+
token = _required_str(data, 'authentication_token')
|
|
173
|
+
challenge_response = _required_str(data, 'challenge-response')
|
|
174
|
+
|
|
175
|
+
if self.mac is not None:
|
|
176
|
+
expected = make_challenge_response(challenge, self.mac)
|
|
177
|
+
if challenge_response != expected:
|
|
178
|
+
raise AuthenticationError(
|
|
179
|
+
'Device challenge-response did not match the expected value'
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
expires_in = data.get('authentication_token_expires_in')
|
|
183
|
+
expires_at = None
|
|
184
|
+
if expires_in is not None:
|
|
185
|
+
if isinstance(expires_in, bool) or not isinstance(expires_in, int | str):
|
|
186
|
+
raise AuthenticationError(
|
|
187
|
+
'Login response did not contain numeric '
|
|
188
|
+
"'authentication_token_expires_in'"
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
seconds = int(expires_in)
|
|
192
|
+
except ValueError as err:
|
|
193
|
+
raise AuthenticationError(
|
|
194
|
+
"Login response has invalid 'authentication_token_expires_in'"
|
|
195
|
+
) from err
|
|
196
|
+
if seconds <= 0:
|
|
197
|
+
raise AuthenticationError(
|
|
198
|
+
"Login response requires positive 'authentication_token_expires_in'"
|
|
199
|
+
)
|
|
200
|
+
expires_at = time.time() + seconds
|
|
201
|
+
|
|
202
|
+
candidate = AuthToken(
|
|
203
|
+
value=token,
|
|
204
|
+
challenge_response=challenge_response,
|
|
205
|
+
expires_at=expires_at,
|
|
206
|
+
)
|
|
207
|
+
self._request(
|
|
208
|
+
'POST',
|
|
209
|
+
'verify',
|
|
210
|
+
{'challenge_response': challenge_response},
|
|
211
|
+
None,
|
|
212
|
+
'application/json',
|
|
213
|
+
candidate.value,
|
|
214
|
+
)
|
|
215
|
+
if candidate.expired:
|
|
216
|
+
raise AuthenticationError(
|
|
217
|
+
"Login 'authentication_token_expires_in' elapsed during verification"
|
|
218
|
+
)
|
|
219
|
+
self.token = candidate
|
|
220
|
+
return candidate
|
|
221
|
+
|
|
222
|
+
def get(self, path: str, authenticated: bool = True) -> TwinklyResponse:
|
|
223
|
+
"""GET an API-relative endpoint, optionally obtaining and sending a token."""
|
|
224
|
+
return self.request('GET', path, authenticated=authenticated)
|
|
225
|
+
|
|
226
|
+
def post(
|
|
227
|
+
self,
|
|
228
|
+
path: str,
|
|
229
|
+
body: dict[str, object],
|
|
230
|
+
authenticated: bool = True,
|
|
231
|
+
) -> TwinklyResponse:
|
|
232
|
+
"""POST a JSON object to an API-relative endpoint and return its response.
|
|
233
|
+
|
|
234
|
+
Authentication is enabled by default; login uses authenticated=False.
|
|
235
|
+
"""
|
|
236
|
+
return self.request('POST', path, body=body, authenticated=authenticated)
|
|
237
|
+
|
|
238
|
+
def post_bytes(
|
|
239
|
+
self,
|
|
240
|
+
path: str,
|
|
241
|
+
payload: bytes,
|
|
242
|
+
content_type: str,
|
|
243
|
+
authenticated: bool = True,
|
|
244
|
+
) -> TwinklyResponse:
|
|
245
|
+
"""POST raw bytes with the supplied Content-Type and return the response.
|
|
246
|
+
|
|
247
|
+
The path is relative to `/xled/v1/`. Content-Length is calculated from
|
|
248
|
+
payload. Authentication is enabled unless explicitly disabled.
|
|
249
|
+
"""
|
|
250
|
+
return self.request(
|
|
251
|
+
'POST',
|
|
252
|
+
path,
|
|
253
|
+
payload=payload,
|
|
254
|
+
content_type=content_type,
|
|
255
|
+
authenticated=authenticated,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def delete(self, path: str) -> TwinklyResponse:
|
|
259
|
+
"""DELETE an API-relative endpoint using authentication."""
|
|
260
|
+
return self.request('DELETE', path)
|
|
261
|
+
|
|
262
|
+
def echo(self, body: dict[str, object]) -> TwinklyResponse:
|
|
263
|
+
"""POST a JSON object to the authenticated echo endpoint."""
|
|
264
|
+
return self.post('echo', body)
|
|
265
|
+
|
|
266
|
+
def delete_layout_full(self) -> TwinklyResponse:
|
|
267
|
+
"""Delete the device's full LED layout and return its response."""
|
|
268
|
+
return self.delete('led/layout/full')
|
|
269
|
+
|
|
270
|
+
def start_network_scan(self) -> TwinklyResponse:
|
|
271
|
+
"""Start a Wi-Fi network scan and return the device's acknowledgement.
|
|
272
|
+
|
|
273
|
+
Read network_scan_results separately once the scan has completed.
|
|
274
|
+
This method does not wait or poll for completion.
|
|
275
|
+
"""
|
|
276
|
+
return self.get('network/scan')
|
|
277
|
+
|
|
278
|
+
def set_realtime_mode(self) -> TwinklyResponse:
|
|
279
|
+
"""Switch to realtime LED mode before sending UDP frames."""
|
|
280
|
+
return self.post('led/mode', {'mode': 'rt'})
|
|
281
|
+
|
|
282
|
+
def set_off_mode(self) -> TwinklyResponse:
|
|
283
|
+
"""Switch to off mode to turn off the device's LEDs."""
|
|
284
|
+
return self.post('led/mode', {'mode': 'off'})
|
|
285
|
+
|
|
286
|
+
def request(
|
|
287
|
+
self,
|
|
288
|
+
method: str,
|
|
289
|
+
path: str,
|
|
290
|
+
body: dict[str, object] | None = None,
|
|
291
|
+
payload: bytes | None = None,
|
|
292
|
+
content_type: str = 'application/json',
|
|
293
|
+
authenticated: bool = True,
|
|
294
|
+
) -> TwinklyResponse:
|
|
295
|
+
"""Send one HTTP request and validate the JSON object response.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
method: HTTP method such as GET, POST, or DELETE.
|
|
299
|
+
path: Endpoint relative to `/xled/v1/`, without a leading slash.
|
|
300
|
+
body: Optional JSON object; cannot be combined with payload.
|
|
301
|
+
payload: Optional raw request bytes.
|
|
302
|
+
content_type: Content-Type header; defaults to application/json.
|
|
303
|
+
authenticated: Obtain a token if needed and send X-Auth-Token.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
HTTP status and decoded JSON data. An empty response body is
|
|
307
|
+
treated as an empty JSON object.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ValueError: Both body and payload were supplied.
|
|
311
|
+
AuthenticationError: Authentication fails or HTTP 401 is returned.
|
|
312
|
+
HTTP 401 also clears the stored token.
|
|
313
|
+
UnsupportedEndpointError: The device returns HTTP 404.
|
|
314
|
+
ProtocolError: Sending or reading raises OSError or HTTPException,
|
|
315
|
+
another HTTP error is returned, the body is not a valid UTF-8
|
|
316
|
+
JSON object, or a supplied application code is not 1000.
|
|
317
|
+
"""
|
|
318
|
+
if body is not None and payload is not None:
|
|
319
|
+
raise ValueError('request cannot use both JSON body and binary payload')
|
|
320
|
+
token = self._auth_token() if authenticated else None
|
|
321
|
+
return self._request(method, path, body, payload, content_type, token)
|
|
322
|
+
|
|
323
|
+
def _request(
|
|
324
|
+
self,
|
|
325
|
+
method: str,
|
|
326
|
+
path: str,
|
|
327
|
+
body: dict[str, object] | None,
|
|
328
|
+
payload: bytes | None,
|
|
329
|
+
content_type: str,
|
|
330
|
+
token: str | None,
|
|
331
|
+
) -> TwinklyResponse:
|
|
332
|
+
"""Send and validate a request using an explicit token without renewal."""
|
|
333
|
+
headers = {'Content-Type': content_type}
|
|
334
|
+
if token is not None:
|
|
335
|
+
headers[AUTH_HEADER] = token
|
|
336
|
+
if body is not None:
|
|
337
|
+
payload = json.dumps(body).encode()
|
|
338
|
+
if payload is not None:
|
|
339
|
+
headers['Content-Length'] = str(len(payload))
|
|
340
|
+
|
|
341
|
+
connection = client.HTTPConnection(self.host, 80, timeout=self.timeout)
|
|
342
|
+
try:
|
|
343
|
+
connection.request(
|
|
344
|
+
method,
|
|
345
|
+
f'{TWINKLY_API_PREFIX}/{path}',
|
|
346
|
+
body=payload,
|
|
347
|
+
headers=headers,
|
|
348
|
+
)
|
|
349
|
+
response = connection.getresponse()
|
|
350
|
+
raw = response.read()
|
|
351
|
+
except (OSError, client.HTTPException) as err:
|
|
352
|
+
raise ProtocolError(
|
|
353
|
+
f'HTTP {method} {path!r} failed for Twinkly device at {self.host}'
|
|
354
|
+
) from err
|
|
355
|
+
finally:
|
|
356
|
+
connection.close()
|
|
357
|
+
|
|
358
|
+
if response.status == 401:
|
|
359
|
+
self.token = None
|
|
360
|
+
raise AuthenticationError('Device rejected the authentication token')
|
|
361
|
+
if response.status == 404:
|
|
362
|
+
text = raw.decode(errors='replace')
|
|
363
|
+
raise UnsupportedEndpointError(path, text)
|
|
364
|
+
if response.status < 200 or response.status >= 300:
|
|
365
|
+
raise ProtocolError(
|
|
366
|
+
f'HTTP {response.status} for {method} {path!r} from {self.host}'
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
data = json.loads(raw.decode() or '{}')
|
|
371
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as err:
|
|
372
|
+
raise ProtocolError(
|
|
373
|
+
f'Device returned invalid JSON for {method} {path!r}'
|
|
374
|
+
) from err
|
|
375
|
+
if not isinstance(data, dict):
|
|
376
|
+
raise ProtocolError(
|
|
377
|
+
f'Device returned a non-object JSON response for {method} {path!r}'
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
code = data.get('code')
|
|
381
|
+
if code is not None and code != 1000:
|
|
382
|
+
raise ProtocolError(f'Twinkly application failure for {method} {path!r}')
|
|
383
|
+
return TwinklyResponse(http_status=response.status, data=data)
|
|
384
|
+
|
|
385
|
+
def _auth_token(self) -> str:
|
|
386
|
+
"""Return the current token, authenticating if it is missing or expired."""
|
|
387
|
+
if self.token is None or self.token.expired:
|
|
388
|
+
self.authenticate()
|
|
389
|
+
if self.token is None:
|
|
390
|
+
raise AuthenticationError('Authentication did not produce a token')
|
|
391
|
+
return self.token.value
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _required_str(data: dict[str, object], key: str) -> str:
|
|
395
|
+
"""Read a required login string or raise AuthenticationError."""
|
|
396
|
+
value = data.get(key)
|
|
397
|
+
if not isinstance(value, str):
|
|
398
|
+
raise AuthenticationError(f'Login response did not contain string {key!r}')
|
|
399
|
+
return value
|
tinkly/discovery.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""UDP discovery for Twinkly devices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .errors import DiscoveryError
|
|
12
|
+
|
|
13
|
+
DISCOVERY_MESSAGE = b'\x01discover'
|
|
14
|
+
DISCOVERY_PORT = 5555
|
|
15
|
+
DEFAULT_BROADCAST = '255.255.255.255'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DiscoveredDevice:
|
|
20
|
+
"""Address and identifier advertised by a Twinkly discovery reply."""
|
|
21
|
+
|
|
22
|
+
ip_address: str
|
|
23
|
+
"""Device IPv4 address decoded from the discovery packet."""
|
|
24
|
+
device_id: str
|
|
25
|
+
"""Device identifier decoded from the NUL-terminated reply text."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_discovery_response(data: bytes) -> DiscoveredDevice:
|
|
29
|
+
"""Decode one discovery reply containing an IPv4 address and device ID.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
DiscoveryError: The packet is too short, lacks OK status, or lacks its
|
|
33
|
+
terminating NUL byte.
|
|
34
|
+
UnicodeDecodeError: The device identifier is not valid UTF-8.
|
|
35
|
+
"""
|
|
36
|
+
if len(data) < 7:
|
|
37
|
+
raise DiscoveryError(f'Discovery response is too short: {len(data)} bytes')
|
|
38
|
+
if data[4:6] != b'OK':
|
|
39
|
+
raise DiscoveryError(f'Discovery response status is not OK: {data[4:6]!r}')
|
|
40
|
+
if data[-1:] != b'\x00':
|
|
41
|
+
raise DiscoveryError('Discovery response is not NUL-terminated')
|
|
42
|
+
|
|
43
|
+
ip_address = str(ipaddress.ip_address(data[3::-1]))
|
|
44
|
+
device_id = data[6:-1].decode()
|
|
45
|
+
return DiscoveredDevice(ip_address, device_id)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def discover(
|
|
49
|
+
timeout: float = 2.0,
|
|
50
|
+
grouping: float = 0.1,
|
|
51
|
+
destination: str = DEFAULT_BROADCAST,
|
|
52
|
+
) -> Iterator[DiscoveredDevice]:
|
|
53
|
+
"""Broadcast a discovery request and yield each responding IPv4 address once.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
timeout: Seconds to wait for the first valid device response.
|
|
57
|
+
grouping: Seconds to collect further responses after the first valid
|
|
58
|
+
response. Later replies do not extend this window.
|
|
59
|
+
destination: IPv4 broadcast address or a specific device address.
|
|
60
|
+
|
|
61
|
+
Devices are yielded as replies arrive. Exhausting the iterator waits until
|
|
62
|
+
its deadline; an empty scan waits for timeout. Socket timeouts and the
|
|
63
|
+
echoed request are ignored. Other socket errors and malformed reply errors
|
|
64
|
+
propagate. Duplicate devices are identified by their advertised IP address.
|
|
65
|
+
Consume promptly, normally with list(discover()), to collect the full scan.
|
|
66
|
+
Time spent by the consumer between results counts toward the deadline and
|
|
67
|
+
can leave timely replies unread in the socket when the iterator resumes.
|
|
68
|
+
"""
|
|
69
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock:
|
|
70
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
71
|
+
sock.bind(('', 0))
|
|
72
|
+
sock.sendto(DISCOVERY_MESSAGE, (destination, DISCOVERY_PORT))
|
|
73
|
+
|
|
74
|
+
deadline = time.monotonic() + timeout
|
|
75
|
+
seen: set[str] = set()
|
|
76
|
+
while (remaining := deadline - time.monotonic()) > 0:
|
|
77
|
+
sock.settimeout(remaining)
|
|
78
|
+
try:
|
|
79
|
+
data, _address = sock.recvfrom(256)
|
|
80
|
+
except TimeoutError:
|
|
81
|
+
continue
|
|
82
|
+
if data == DISCOVERY_MESSAGE:
|
|
83
|
+
continue
|
|
84
|
+
device = parse_discovery_response(data)
|
|
85
|
+
if not seen:
|
|
86
|
+
deadline = time.monotonic() + grouping
|
|
87
|
+
if device.ip_address not in seen:
|
|
88
|
+
seen.add(device.ip_address)
|
|
89
|
+
yield device
|
tinkly/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exceptions for Twinkly protocol operations."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TinklyError(Exception):
|
|
5
|
+
"""Base error for Twinkly operations."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DiscoveryError(TinklyError):
|
|
9
|
+
"""A discovery reply has an invalid packet structure or status."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProtocolError(TinklyError):
|
|
13
|
+
"""The device returned a response that does not match the protocol."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedEndpointError(ProtocolError):
|
|
17
|
+
"""The device does not support a Twinkly endpoint."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: str, text: str) -> None:
|
|
20
|
+
"""Retain endpoint and 404 body; omit the body from the error message."""
|
|
21
|
+
self.path = path
|
|
22
|
+
self.text = text
|
|
23
|
+
super().__init__(f'Twinkly endpoint {path!r} is not supported (HTTP 404)')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthenticationError(TinklyError):
|
|
27
|
+
"""The authentication handshake failed."""
|
tinkly/frame.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Generation 2 realtime UDP frame sender using protocol version 3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import socket
|
|
7
|
+
from collections.abc import Buffer, Iterable
|
|
8
|
+
|
|
9
|
+
from .errors import ProtocolError
|
|
10
|
+
|
|
11
|
+
REALTIME_PORT = 7777
|
|
12
|
+
# Twinkly UDP v3 limits each fragment's pixel payload to 900 bytes, excluding headers.
|
|
13
|
+
# https://xled-docs.readthedocs.io/en/latest/protocol_details.html#version-3
|
|
14
|
+
MAX_FRAGMENT_SIZE = 900
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def frame_packets(
|
|
18
|
+
token: str,
|
|
19
|
+
frame: Buffer,
|
|
20
|
+
) -> Iterable[tuple[bytes, memoryview]]:
|
|
21
|
+
"""Yield protocol v3 headers and pixel payload views for one RGB frame.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
token: Base64 authentication token decoding to exactly eight bytes.
|
|
25
|
+
frame: Contiguous unsigned bytes containing three bytes per RGB LED.
|
|
26
|
+
|
|
27
|
+
Each pair contains a twelve-byte header and up to 900 bytes of pixel data.
|
|
28
|
+
Payload views share the input buffer, which must remain unchanged during
|
|
29
|
+
transmission. Fragment numbers start at zero and fit in one byte.
|
|
30
|
+
|
|
31
|
+
Validation happens as the iterator is consumed. Invalid tokens or more
|
|
32
|
+
than 256 fragments raise ProtocolError; invalid RGB buffers raise ValueError.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
raw_token = base64.b64decode(token, validate=True)
|
|
36
|
+
except ValueError as err:
|
|
37
|
+
raise ProtocolError('Twinkly realtime token is not valid base64') from err
|
|
38
|
+
if len(raw_token) != 8:
|
|
39
|
+
raise ProtocolError('Twinkly realtime token must decode to 8 bytes')
|
|
40
|
+
payload = frame_payload(frame)
|
|
41
|
+
for index, start in enumerate(range(0, len(payload), MAX_FRAGMENT_SIZE)):
|
|
42
|
+
fragment = payload[start : start + MAX_FRAGMENT_SIZE]
|
|
43
|
+
if index > 255:
|
|
44
|
+
raise ProtocolError(
|
|
45
|
+
'Realtime frame is too large for one-byte fragment numbers'
|
|
46
|
+
)
|
|
47
|
+
yield b'\x03' + raw_token + b'\x00\x00' + bytes((index,)), fragment
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def send_frame(
|
|
51
|
+
host: str, token: str, frame: Buffer, output: socket.socket | None = None
|
|
52
|
+
) -> int:
|
|
53
|
+
"""Send one RGB frame to UDP port 7777 and return total datagram bytes sent.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
host: Device hostname or IPv4 address.
|
|
57
|
+
token: Base64 token obtained through authentication.
|
|
58
|
+
frame: Contiguous unsigned RGB bytes, three per LED.
|
|
59
|
+
output: Optional caller-owned UDP socket. If omitted, create a socket
|
|
60
|
+
for this call and close it afterward. A supplied socket stays open.
|
|
61
|
+
|
|
62
|
+
Authenticate and switch the device to realtime mode before calling. This
|
|
63
|
+
function sends the fragments once without pacing, retries, or token renewal.
|
|
64
|
+
The byte count includes packet headers. Buffer validation, protocol, and
|
|
65
|
+
socket errors propagate; earlier fragments may already have been sent.
|
|
66
|
+
Callers must serialize complete frame sends to each controller, even when
|
|
67
|
+
using separate sockets, so fragments from different frames cannot interleave.
|
|
68
|
+
"""
|
|
69
|
+
if output is not None:
|
|
70
|
+
return _send_frame(output, host, token, frame)
|
|
71
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock:
|
|
72
|
+
return _send_frame(sock, host, token, frame)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _send_frame(output: socket.socket, host: str, token: str, frame: Buffer) -> int:
|
|
76
|
+
"""Send each header and payload as one datagram through an existing socket."""
|
|
77
|
+
sent = 0
|
|
78
|
+
address = (host, REALTIME_PORT)
|
|
79
|
+
for header, payload in frame_packets(token, frame):
|
|
80
|
+
sent += output.sendto(b''.join((header, payload)), address)
|
|
81
|
+
return sent
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def frame_payload(frame: Buffer) -> memoryview:
|
|
85
|
+
"""Expose an RGB buffer as a flat unsigned-byte memoryview without copying.
|
|
86
|
+
|
|
87
|
+
Accept buffers with format B and C-contiguous storage, including
|
|
88
|
+
multidimensional views. The byte count must be divisible by three.
|
|
89
|
+
Raise ValueError for a different element format, non-contiguous storage,
|
|
90
|
+
or an incomplete RGB pixel. The returned view shares the input buffer.
|
|
91
|
+
"""
|
|
92
|
+
view = memoryview(frame)
|
|
93
|
+
if view.format != 'B':
|
|
94
|
+
raise ValueError('Realtime frames must contain unsigned bytes')
|
|
95
|
+
if not view.c_contiguous:
|
|
96
|
+
raise ValueError('Realtime frames must be C-contiguous')
|
|
97
|
+
payload = view.cast('B')
|
|
98
|
+
if len(payload) % 3:
|
|
99
|
+
raise ValueError('Realtime frames must contain three bytes per LED')
|
|
100
|
+
return payload
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tinkly/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
tinkly/authentication.py,sha256=ixtBSLYUGJaNfdJkyPjgoAFcxrCQ6_MDcX9t038-Kls,2062
|
|
3
|
+
tinkly/client.py,sha256=QCyYfArXU3ECiByJFfsH33DSotvJ0xBhA3CRhUL2qVM,16053
|
|
4
|
+
tinkly/discovery.py,sha256=rh9lXGyvobkmddnhbISuHAWr5uy58PGEhjSunMJ-ALU,3387
|
|
5
|
+
tinkly/errors.py,sha256=e-Zon0wGJwaF0AuRyvzeRkhRDpKTkcbI0BJU41INriA,812
|
|
6
|
+
tinkly/frame.py,sha256=lUito5XfhlmTf8fqhV6ADOrNyFB02J_sZbkRX0xKRhw,4229
|
|
7
|
+
tinkly-0.2.0.dist-info/METADATA,sha256=xgnBEwBBcxAnU10ScuC4XoWUKV0Su0JrKJZIse3k3Kc,161
|
|
8
|
+
tinkly-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
tinkly-0.2.0.dist-info/top_level.txt,sha256=_Xnobvvai0ibsQGv7cGHG_r4hU58HkY6SVYZXEIebfc,7
|
|
10
|
+
tinkly-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tinkly
|