tinkly 0.2.0__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.
- tinkly-0.2.0/MANIFEST.in +3 -0
- tinkly-0.2.0/PKG-INFO +5 -0
- tinkly-0.2.0/README.md +45 -0
- tinkly-0.2.0/doc/contributing.md +27 -0
- tinkly-0.2.0/doc/index.md +76 -0
- tinkly-0.2.0/pyproject.toml +37 -0
- tinkly-0.2.0/scripts/build_docs.py +8 -0
- tinkly-0.2.0/scripts/griffe_tinkly.py +87 -0
- tinkly-0.2.0/setup.cfg +4 -0
- tinkly-0.2.0/tests/test_authentication.py +22 -0
- tinkly-0.2.0/tests/test_client.py +444 -0
- tinkly-0.2.0/tests/test_client_authentication.py +167 -0
- tinkly-0.2.0/tests/test_discovery.py +65 -0
- tinkly-0.2.0/tests/test_documentation.py +47 -0
- tinkly-0.2.0/tests/test_frame.py +109 -0
- tinkly-0.2.0/tinkly/__init__.py +0 -0
- tinkly-0.2.0/tinkly/authentication.py +67 -0
- tinkly-0.2.0/tinkly/client.py +399 -0
- tinkly-0.2.0/tinkly/discovery.py +89 -0
- tinkly-0.2.0/tinkly/errors.py +27 -0
- tinkly-0.2.0/tinkly/frame.py +100 -0
- tinkly-0.2.0/tinkly.egg-info/PKG-INFO +5 -0
- tinkly-0.2.0/tinkly.egg-info/SOURCES.txt +24 -0
- tinkly-0.2.0/tinkly.egg-info/dependency_links.txt +1 -0
- tinkly-0.2.0/tinkly.egg-info/top_level.txt +1 -0
- tinkly-0.2.0/uv.lock +445 -0
tinkly-0.2.0/MANIFEST.in
ADDED
tinkly-0.2.0/PKG-INFO
ADDED
tinkly-0.2.0/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# tinkly: a tiny interface to Twinkly v2 lights
|
|
2
|
+
|
|
3
|
+
I love the Twinkly lights but the [xled library](https://github.com/scrool/xled) is
|
|
4
|
+
filled with complicated cruft which obscures the actual communication layer, and seems
|
|
5
|
+
to no longer be in development.
|
|
6
|
+
|
|
7
|
+
In particular, xled has a totally unnecessary ZMQ queue that
|
|
8
|
+
slows everything down and prevents two clients from existing.
|
|
9
|
+
|
|
10
|
+
I re-implemented just the Twinkly communication part, and left all the other
|
|
11
|
+
parts behind. In particular, there is no CLI - for that, you'll have to see my
|
|
12
|
+
upcoming general project, "lyte".
|
|
13
|
+
|
|
14
|
+
## Device communication
|
|
15
|
+
|
|
16
|
+
This package provides UDP discovery, authentication, HTTP endpoint access for generation
|
|
17
|
+
2 Twinkly lights.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from tinkly.client import TwinklyClient
|
|
21
|
+
from tinkly.discovery import discover
|
|
22
|
+
from tinkly.frame import send_frame
|
|
23
|
+
|
|
24
|
+
devices = list(discover(timeout=2.0))
|
|
25
|
+
client = TwinklyClient(host=devices[0].ip_address)
|
|
26
|
+
info = client.get('gestalt', authenticated=False).data
|
|
27
|
+
client.mac = str(info['mac'])
|
|
28
|
+
token = client.authenticate()
|
|
29
|
+
|
|
30
|
+
client.set_realtime_mode()
|
|
31
|
+
frame = bytes((255, 0, 0)) * int(str(info['number_of_led']))
|
|
32
|
+
send_frame(client.host, token.value, frame)
|
|
33
|
+
client.set_off_mode()
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Frame buffers
|
|
37
|
+
|
|
38
|
+
`lyte` uses `numpy`, but I decided not to make that a dependency. Instead, we use
|
|
39
|
+
Python's buffer protocol `collections.abc.Buffer`. The input is C-style unsigned bytes
|
|
40
|
+
in RGB order, with three bytes per LED. Supported inputs include `bytes`, `bytearray`,
|
|
41
|
+
`array.array('B')`, `memoryview`, and C-contiguous NumPy `uint8` arrays.
|
|
42
|
+
|
|
43
|
+
## The API
|
|
44
|
+
|
|
45
|
+
https://rec.github.io/tinkly/
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Building and checking the documentation
|
|
2
|
+
|
|
3
|
+
The documentation tools are development dependencies. They require Python 3.12
|
|
4
|
+
or newer and are not installed with the runtime library.
|
|
5
|
+
|
|
6
|
+
From the repository root:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
uv sync --python 3.12
|
|
10
|
+
uv run python scripts/build_docs.py
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Open `.cache/reccy-docs/site/index.html` directly in your browser. The build uses
|
|
14
|
+
explicit HTML links so navigation works from disk. It also checks every page
|
|
15
|
+
reachable from the landing page for broken internal page links and empty content.
|
|
16
|
+
Generated HTML stays in the ignored `.cache/reccy-docs/` directory.
|
|
17
|
+
|
|
18
|
+
Reccy supplies the shared site builder. Griffe analyzes the package source
|
|
19
|
+
without importing tinkly or contacting devices. The extension in
|
|
20
|
+
`scripts/griffe_tinkly.py` describes the generated endpoint properties and gives
|
|
21
|
+
the package API index a module overview. Endpoint-specific descriptions come
|
|
22
|
+
from the docstrings following their declarations in `tinkly/client.py`.
|
|
23
|
+
|
|
24
|
+
Documentation source belongs in `doc/` as Markdown. Python build and verification
|
|
25
|
+
helpers belong in `scripts/`.
|
|
26
|
+
|
|
27
|
+
[Return to the usage guide](index.md).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Control Twinkly lights with Python
|
|
2
|
+
|
|
3
|
+
Tinkly lets Python programs find Twinkly controllers on the local network,
|
|
4
|
+
read their settings, change their output, and send RGB animation frames.
|
|
5
|
+
Each client connects to one controller. Python 3.12 or newer is required;
|
|
6
|
+
the installed library has no third-party runtime dependencies.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
From a checkout of this repository, create an environment and install the library:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
uv venv --python 3.12
|
|
14
|
+
uv pip install .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Find a device and change its brightness
|
|
18
|
+
|
|
19
|
+
Connect your computer and lights to the same local network, then run:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from tinkly.client import TwinklyClient
|
|
23
|
+
from tinkly.discovery import discover
|
|
24
|
+
|
|
25
|
+
devices = list(discover())
|
|
26
|
+
if not devices:
|
|
27
|
+
raise SystemExit('No Twinkly devices found on the local network')
|
|
28
|
+
|
|
29
|
+
for device in devices:
|
|
30
|
+
print(device.device_id, device.ip_address)
|
|
31
|
+
|
|
32
|
+
client = TwinklyClient(host=devices[0].ip_address)
|
|
33
|
+
print('Firmware:', client.firmware_version.data)
|
|
34
|
+
print('Brightness:', client.brightness.data)
|
|
35
|
+
|
|
36
|
+
client.brightness = {'mode': 'enabled', 'type': 'A', 'value': 75}
|
|
37
|
+
print('Updated brightness:', client.brightness.data)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This example chooses the first controller found. If you already know its address,
|
|
41
|
+
create the client directly with `TwinklyClient(host='192.168.1.23')`.
|
|
42
|
+
|
|
43
|
+
## Reading and writing settings
|
|
44
|
+
|
|
45
|
+
Reading a property sends a fresh request to the device. The result has two fields:
|
|
46
|
+
`data` contains the decoded JSON object, and `http_status` contains the HTTP status.
|
|
47
|
+
Writing a property sends the assigned dictionary to the device. Assignment does
|
|
48
|
+
not return a response; use `client.post()` if you need to inspect that response.
|
|
49
|
+
|
|
50
|
+
Firmware version and status can be read without authentication. Protected
|
|
51
|
+
requests obtain an authentication token automatically. Request failures raise
|
|
52
|
+
exceptions for your program to handle.
|
|
53
|
+
|
|
54
|
+
Authentication, echo, deleting a layout, starting a network scan, and switching
|
|
55
|
+
to realtime or off mode are methods because they perform actions.
|
|
56
|
+
|
|
57
|
+
## API reference
|
|
58
|
+
|
|
59
|
+
Choose the part of the library you need:
|
|
60
|
+
|
|
61
|
+
| Reference | What it covers |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| [HTTP client and settings](reference/tinkly/client.md) | Create a client, read and write properties, authenticate, and call endpoints directly. |
|
|
64
|
+
| [Device discovery](reference/tinkly/discovery.md) | Find controllers and read their addresses and identifiers. |
|
|
65
|
+
| [RGB frame transmission](reference/tinkly/frame.md) | Send packed RGB frames to a controller in realtime mode. |
|
|
66
|
+
| [Errors](reference/tinkly/errors.md) | Handle authentication, endpoint, discovery, and protocol failures. |
|
|
67
|
+
| [Authentication helpers](reference/tinkly/authentication.md) | Low-level MAC address and challenge-response functions. |
|
|
68
|
+
|
|
69
|
+
For multiple controllers, create one client per controller and send each its own
|
|
70
|
+
frame. Tinkly provides the transport; your application manages animation timing,
|
|
71
|
+
retries, reconnection, and coordination between controllers.
|
|
72
|
+
|
|
73
|
+
## Contributing
|
|
74
|
+
|
|
75
|
+
See [Building and checking the documentation](contributing.md) for the dev setup,
|
|
76
|
+
local build command, and generated-property documentation extension.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tinkly"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Low-level Twinkly discovery, authentication, HTTP, and realtime UDP transport"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
|
|
11
|
+
[tool.setuptools.packages.find]
|
|
12
|
+
where = ["."]
|
|
13
|
+
include = ["tinkly*"]
|
|
14
|
+
|
|
15
|
+
[tool.ruff.format]
|
|
16
|
+
quote-style = "single"
|
|
17
|
+
|
|
18
|
+
[tool.pytest.ini_options]
|
|
19
|
+
pythonpath = ["."]
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"griffelib>=2",
|
|
24
|
+
"pytest>=9.2.0",
|
|
25
|
+
"pyupgrade>=3.22.0",
|
|
26
|
+
"reccy",
|
|
27
|
+
"ruff>=0.17.3",
|
|
28
|
+
"ty>=0.0.97",
|
|
29
|
+
"tyro",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.uv.sources]
|
|
33
|
+
reccy = { git = "ssh://git@github.com/rec/reccy.git", branch = "main" }
|
|
34
|
+
|
|
35
|
+
[tool.reccy.documentation]
|
|
36
|
+
extensions = ["scripts/griffe_tinkly.py"]
|
|
37
|
+
use_directory_urls = false
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Describe tinkly's generated endpoint properties during static API analysis."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
import griffe
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EndpointProperties(griffe.Extension):
|
|
9
|
+
"""Recognize getter and setter declarations on TwinklyClient."""
|
|
10
|
+
|
|
11
|
+
def on_module_instance(self, *, mod: griffe.Module, **kwargs: object) -> None:
|
|
12
|
+
"""Give the generated package index a useful overview of its modules."""
|
|
13
|
+
if mod.path == 'tinkly':
|
|
14
|
+
mod.docstring = griffe.Docstring(
|
|
15
|
+
'Twinkly protocol API.\n\n'
|
|
16
|
+
'Import the symbols you need from these modules:\n\n'
|
|
17
|
+
'- [HTTP client and settings][tinkly.client]: TwinklyClient, '
|
|
18
|
+
'endpoint properties, authentication tokens, and HTTP responses.\n'
|
|
19
|
+
'- [Device discovery][tinkly.discovery]: find controllers on '
|
|
20
|
+
'the local network.\n'
|
|
21
|
+
'- [RGB frame transmission][tinkly.frame]: validate pixel '
|
|
22
|
+
'buffers and send realtime UDP frames.\n'
|
|
23
|
+
'- [Errors][tinkly.errors]: exceptions raised by protocol '
|
|
24
|
+
'operations.\n'
|
|
25
|
+
'- [Authentication helpers][tinkly.authentication]: MAC address '
|
|
26
|
+
'decoding and challenge-response calculations.',
|
|
27
|
+
parent=mod,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def on_attribute_instance(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
node: ast.AST | griffe.ObjectNode,
|
|
34
|
+
attr: griffe.Attribute,
|
|
35
|
+
**kwargs: object,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Add property labels, response types, and HTTP behavior to factory calls.
|
|
38
|
+
|
|
39
|
+
Only assignments on tinkly.client.TwinklyClient are processed. Preserve
|
|
40
|
+
any endpoint-specific attribute docstring after the generated details.
|
|
41
|
+
"""
|
|
42
|
+
if attr.parent is None or attr.parent.path != 'tinkly.client.TwinklyClient':
|
|
43
|
+
return
|
|
44
|
+
if not isinstance(node, ast.Assign) or not isinstance(
|
|
45
|
+
call := node.value, ast.Call
|
|
46
|
+
):
|
|
47
|
+
return
|
|
48
|
+
if not isinstance(call.func, ast.Name) or call.func.id not in (
|
|
49
|
+
'getter',
|
|
50
|
+
'setter',
|
|
51
|
+
):
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
path = ast.literal_eval(call.args[0])
|
|
55
|
+
authenticated = next(
|
|
56
|
+
(
|
|
57
|
+
ast.literal_eval(k.value)
|
|
58
|
+
for k in call.keywords
|
|
59
|
+
if k.arg == 'authenticated'
|
|
60
|
+
),
|
|
61
|
+
True,
|
|
62
|
+
)
|
|
63
|
+
writable = call.func.id == 'setter'
|
|
64
|
+
description = (
|
|
65
|
+
f'HTTP endpoint `GET {path}`.\n\n'
|
|
66
|
+
'Each read sends a fresh request and returns a '
|
|
67
|
+
'`tinkly.client.TwinklyResponse` containing `http_status` and `data`.\n\n'
|
|
68
|
+
f'Authentication is {"required" if authenticated else "not required"}.'
|
|
69
|
+
)
|
|
70
|
+
if writable:
|
|
71
|
+
description += (
|
|
72
|
+
f'\n\nAssign a `dict[str, object]` JSON body to send `POST {path}`. '
|
|
73
|
+
'Assignment does not return the response; use `post()` when needed.'
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
description += '\n\nThis property is read-only.'
|
|
77
|
+
description += '\n\nRequest errors propagate to the caller.'
|
|
78
|
+
if attr.docstring is not None:
|
|
79
|
+
description += f'\n\n{attr.docstring.value}'
|
|
80
|
+
|
|
81
|
+
attr.labels.difference_update({'class-attribute', 'instance-attribute'})
|
|
82
|
+
attr.labels.add('property')
|
|
83
|
+
if writable:
|
|
84
|
+
attr.labels.add('writable')
|
|
85
|
+
attr.annotation = griffe.ExprName('TwinklyResponse', parent=attr.parent)
|
|
86
|
+
attr.value = None
|
|
87
|
+
attr.docstring = griffe.Docstring(description, parent=attr)
|
tinkly-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from tinkly.authentication import CHALLENGE_KEY, derive_key, mac_bytes, rc4
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CryptoTests(unittest.TestCase):
|
|
9
|
+
def test_mac_bytes_accepts_common_formats(self) -> None:
|
|
10
|
+
expected = b'\x5c\xcf\x7f\x33\xaa\xff'
|
|
11
|
+
|
|
12
|
+
self.assertEqual(mac_bytes('5C:CF:7F:33:AA:FF'), expected)
|
|
13
|
+
self.assertEqual(mac_bytes('5c-cf-7f-33-aa-ff'), expected)
|
|
14
|
+
self.assertEqual(mac_bytes('5ccf7f33aaff'), expected)
|
|
15
|
+
|
|
16
|
+
def test_derive_key_matches_original_driver(self) -> None:
|
|
17
|
+
key = derive_key(CHALLENGE_KEY, '5C:CF:7F:33:AA:FF')
|
|
18
|
+
|
|
19
|
+
self.assertEqual(key, b'9\xb9\x1a]\xc7\x90.\xaa\x0cV\xc9\x8d9\xbb^\x12')
|
|
20
|
+
|
|
21
|
+
def test_rc4_known_vector(self) -> None:
|
|
22
|
+
self.assertEqual(rc4(b'Plaintext', b'Key').hex(), 'bbf316e8d940af0ad3')
|