pax_global_header00006660000000000000000000000064147051507610014520gustar00rootroot0000000000000052 comment=0ccc8f894b1a7c5c0544e0675cd9a8a615b6d27c ttls-1.9.0/000077500000000000000000000000001470515076100125155ustar00rootroot00000000000000ttls-1.9.0/.github/000077500000000000000000000000001470515076100140555ustar00rootroot00000000000000ttls-1.9.0/.github/workflows/000077500000000000000000000000001470515076100161125ustar00rootroot00000000000000ttls-1.9.0/.github/workflows/codeql-analysis.yml000066400000000000000000000044651470515076100217360ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ main ] pull_request: # The branches below must be a subset of the branches above branches: [ main ] schedule: - cron: '22 14 * * 2' jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 ttls-1.9.0/.github/workflows/release.yml000066400000000000000000000007761470515076100202670ustar00rootroot00000000000000name: Publish to PyPI on: release: types: [published] jobs: pypi_release: name: Build with Poetry and Publish to PyPI runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.12" - name: Install and configure Poetry run: | pip install poetry poetry config pypi-token.pypi "${{ secrets.PYPI_TOKEN }}" - name: Publish package run: poetry publish --build ttls-1.9.0/.github/workflows/test.yml000066400000000000000000000015051470515076100176150ustar00rootroot00000000000000name: Tests on: [push, pull_request] jobs: ruff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: chartboost/ruff-action@v1 test: runs-on: ubuntu-latest needs: ruff strategy: matrix: python-version: - "3.9" - "3.10" - "3.11" - "3.12" - "3.13" steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install and configure Poetry run: | pip install poetry poetry config virtualenvs.in-project true - name: Install dependencies run: poetry install - name: Pytest run: | poetry run pytest ttls-1.9.0/.pre-commit-config.yaml000066400000000000000000000006001470515076100167720ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.4.0 hooks: - id: check-merge-conflict - id: debug-statements - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-json - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.9 hooks: - id: ruff - id: ruff-format ttls-1.9.0/.vscode/000077500000000000000000000000001470515076100140565ustar00rootroot00000000000000ttls-1.9.0/.vscode/settings.json000066400000000000000000000001241470515076100166060ustar00rootroot00000000000000{ "ruff.lineLength": 120, "editor.defaultFormatter": "charliermarsh.ruff" } ttls-1.9.0/LICENSE000066400000000000000000000023121470515076100135200ustar00rootroot00000000000000Copyright (c) 2019 Jakob Schlyter Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ttls-1.9.0/README.md000066400000000000000000000006041470515076100137740ustar00rootroot00000000000000# Twinkly Twinkly Little Star `ttls` is a small package to help you make async requests to Twinkly LEDs. A command line tool (also called `ttls`) is also included, as well as some examples how to create both loadable movies and realtime sequences. Written based on the [excellent XLED documentation](https://xled-docs.readthedocs.io/en/latest/) by [@scrool](https://github.com/scrool). ttls-1.9.0/examples/000077500000000000000000000000001470515076100143335ustar00rootroot00000000000000ttls-1.9.0/examples/movie_xmas.py000066400000000000000000000025131470515076100170550ustar00rootroot00000000000000"""Generate example of Twinkly movie file""" import argparse import random from ttls.client import TwinklyFrame RED = (0xFF, 0x00, 0x00) GREEN = (0x00, 0xFF, 0x00) BLUE = (0x00, 0x00, 0xFF) def generate_xmas_frame(n: int) -> TwinklyFrame: """Generate a very merry frame""" res = [] for _ in range(n): if random.random() > 0.5: res.append(RED) else: res.append(GREEN) return res def main() -> None: parser = argparse.ArgumentParser() parser.add_argument( "--leds", dest="leds", metavar="n", type=int, default=105, required=False, help="Number of LEDs", ) parser.add_argument( "--count", dest="count", metavar="n", type=int, default=10, required=False, help="Number of iterations", ) parser.add_argument( "--output", dest="output", metavar="filename", type=str, default="movie.bin", help="Output file", ) args = parser.parse_args() movie = [] for _ in range(args.count): frame = [v for sublist in generate_xmas_frame(args.leds) for v in sublist] movie.extend(frame) with open(args.output, "wb") as f: f.write(bytes(movie)) if __name__ == "__main__": main() ttls-1.9.0/examples/realtime_xmas.py000066400000000000000000000025321470515076100175410ustar00rootroot00000000000000"""Example of sending realtime frames to Twinkly""" import argparse import asyncio import random import time from ttls.client import Twinkly, TwinklyFrame RED = (0xFF, 0x00, 0x00) GREEN = (0x00, 0xFF, 0x00) BLUE = (0x00, 0x00, 0xFF) def generate_xmas_frame(n: int) -> TwinklyFrame: """Generate a very merry frame""" res = [] for _ in range(n): if random.random() > 0.5: res.append(RED) else: res.append(GREEN) return res async def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--host", metavar="hostname", required=True, help="Device address") parser.add_argument( "--count", dest="count", metavar="n", type=int, default=10, required=False, help="Number of iterations", ) parser.add_argument( "--delay", dest="delay", metavar="seconds", type=float, default=0.2, required=False, help="Delay between frames", ) args = parser.parse_args() t = Twinkly(host=args.host) await t.interview() await t.set_mode("rt") for _ in range(0, args.count): frame = generate_xmas_frame(t.length) await t.send_frame(frame) time.sleep(args.delay) await t.close() if __name__ == "__main__": asyncio.run(main()) ttls-1.9.0/pyproject.toml000066400000000000000000000016051470515076100154330ustar00rootroot00000000000000# PEP 518: https://www.python.org/dev/peps/pep-0518/ [tool.poetry] name = "ttls" version = "1.9.0" description = "Twinkly Twinkly Little Star" authors = ["Jakob Schlyter "] license = "BSD-2-Clause" classifiers = ["License :: OSI Approved :: BSD License"] readme = "README.md" repository = "https://github.com/jschlyter/ttls" [tool.poetry.scripts] ttls = "ttls.cli:main" [tool.poetry.dependencies] python = "^3.9" aiohttp = "^3.8.5" [tool.poetry.group.dev.dependencies] pytest = "^8" aiounittest = "^1.4.2" ruff = "^0.6.9" pytest-ruff = "^0.4.1" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.ruff] line-length = 120 [tool.ruff.lint] select = [ # pycodestyle "E", # Pyflakes "F", # pyupgrade "UP", # flake8-bugbear "B", # flake8-simplify "SIM", # isort "I", ] ignore = ["E501"] ttls-1.9.0/tests/000077500000000000000000000000001470515076100136575ustar00rootroot00000000000000ttls-1.9.0/tests/test_colours.py000066400000000000000000000022051470515076100167550ustar00rootroot00000000000000import unittest from ttls.colours import TwinklyColour class TestTwinklyColours(unittest.TestCase): def test_colours_rgb(self): col = TwinklyColour(1, 2, 3) self.assertEqual(col.as_twinkly_tuple(), (1, 2, 3)) self.assertEqual(tuple(col), (1, 2, 3)) self.assertEqual(col.as_dict(), {"blue": 3, "green": 2, "red": 1}) col = TwinklyColour.from_twinkly_tuple((1, 2, 3)) self.assertEqual(col.as_twinkly_tuple(), (1, 2, 3)) self.assertEqual(tuple(col), (1, 2, 3)) self.assertEqual(col.as_dict(), {"blue": 3, "green": 2, "red": 1}) rgb = TwinklyColour.from_twinkly_tuple((1, 2, 3)) self.assertEqual(rgb.as_dict(), {"blue": 3, "green": 2, "red": 1}) def test_colours_rgbw(self): col = TwinklyColour(1, 2, 3, 4) self.assertEqual(col.as_twinkly_tuple(), (4, 1, 2, 3)) self.assertEqual(tuple(col), (1, 2, 3, 4)) self.assertEqual(col.as_dict(), {"blue": 3, "green": 2, "red": 1, "white": 4}) rgbw = TwinklyColour.from_twinkly_tuple((1, 2, 3, 4)) self.assertEqual(rgbw.as_dict(), {"blue": 4, "green": 3, "red": 2, "white": 1}) ttls-1.9.0/tests/test_config.py000066400000000000000000000006151470515076100165370ustar00rootroot00000000000000import unittest from ttls.client import Twinkly class TestTwinklyConfig(unittest.TestCase): def setUp(self): self.client = Twinkly(host="192.0.2.1") def test_set_default_mode(self): self.client.default_mode = "movie" self.assertEqual(self.client.default_mode, "movie") with self.assertRaises(ValueError): self.client.default_mode = "b0rken" ttls-1.9.0/tests/test_generic.py000066400000000000000000000137441470515076100167150ustar00rootroot00000000000000import logging import unittest import uuid from typing import Any import aiounittest import pytest from ttls.client import ( TWINKLY_RETURN_CODE, TWINKLY_RETURN_CODE_OK, Twinkly, TwinklyError, TwinklyFrame, ) _LOGGER = logging.getLogger(__name__) class TwinklyMock(Twinkly): async def send_frame(self, frame: TwinklyFrame) -> None: _LOGGER.debug("MOCK: send_frame()") async def _post(self, endpoint: str, **kwargs) -> Any: _LOGGER.debug("MOCK: POST endpoint %s", endpoint) async def _get(self, endpoint: str, **kwargs) -> Any: _LOGGER.debug("MOCK: GET endpoint %s", endpoint) if self._api_version == 1: return await self._get_v1(endpoint, **kwargs) return await self._get_v2(endpoint, **kwargs) async def _get_v1(self, endpoint: str, **kwargs) -> Any: if endpoint == "gestalt": return { "product_name": "Twinkly", "hardware_version": "100", "bytes_per_led": 3, "hw_id": "e00000", "flash_size": 64, "led_type": 14, "product_code": "TWS250STP-B", "fw_family": "F", "device_name": "Xmas tree", "uptime": "21172191", "mac": "aa:bb:cc:dd:ee:ff", "uuid": str(uuid.uuid4()), "max_supported_led": 500, "number_of_led": 250, "led_profile": "RGB", "frame_rate": 23.77, "measured_frame_rate": 25, "movie_capacity": 5397, "max_movies": 55, "copyright": "LEDWORKS 2021", TWINKLY_RETURN_CODE: TWINKLY_RETURN_CODE_OK, } if endpoint == "device_name": # Code should be 1000 return {TWINKLY_RETURN_CODE: TWINKLY_RETURN_CODE_OK + 1} if endpoint == "movies": # Attribute "movies" is missing from the response return {TWINKLY_RETURN_CODE: TWINKLY_RETURN_CODE_OK} _LOGGER.warning("Endpoint %s not yet implemented") return async def _get_v2(self, endpoint: str, **kwargs) -> Any: if endpoint == "gestalt": return { "artnet_en": False, "cloud": True, "device_config": { "drv_params": { "t0h": 7000, "t0l": 2000, "t1h": 3500, "t1l": 5500, "tendh": 4000, "tendl": 12500, }, "led_drv": "d9865c", "led_id": 136, "led_profile": "RGBW", "ports": [ {"port_id": 0, "strings": [{"len": 250, "start": 1}]}, {"port_id": 1, "strings": [{"len": 250, "start": 1}]}, {"port_id": 2, "strings": [{"len": 250, "start": 1}]}, {"port_id": 3, "strings": [{"len": 250, "start": 1}]}, ], }, "device_name": "MockPro", "frame_rate": 9, "group": {"mode": "none", "offset": 0, "size": 0, "uid": ""}, "max_capacity": 6722, "max_movies": 24, "max_playlists": 4, "max_steps": 16, "max_supported_led": 1500, "movie_capacity": 6544, "network": { "dhcp": True, "gateway": "192.168.0.0", "ip": "192.0.2.1", "netmask": "255.255.255.0", }, "number_of_led": 1000, "osc_en": False, "poe_en": True, "product_code": "TWPROCTRLPLC21", "rest_locked": False, "result": {"code": 1000}, "ui_en": True, "uptime": 365688633, } if endpoint == "device/name": # Code should be 1000 return {"result": {TWINKLY_RETURN_CODE: TWINKLY_RETURN_CODE_OK + 1}} if endpoint == "movies": # Attribute "movies" is missing from the response return { "size": 4, "max": 24, "available_frames": 6544, "max_capacity": 6722, "result": {TWINKLY_RETURN_CODE: TWINKLY_RETURN_CODE_OK}, } _LOGGER.warning("Endpoint %s not yet implemented") return class TestTwinklyGeneric(aiounittest.AsyncTestCase): @classmethod def setUpClass(self): logging.basicConfig(level=logging.DEBUG) def setUp(self): self.client = TwinklyMock(host="192.0.2.1", api_version=1) self.client_v2 = TwinklyMock(host="192.0.2.1", api_version=2) async def test_get_details(self): res = await self.client.get_details() self.assertEqual(res["product_name"], "Twinkly") async def test_validation_error_code(self): with pytest.raises(TwinklyError) as e: await self.client.get_name() assert "Invalid response from Twinkly" in str(e.value) async def test_validation_error_string(self): with pytest.raises(TwinklyError) as e: await self.client.get_saved_movies() assert "Invalid response from Twinkly" in str(e.value) async def test_get_details_v2(self): res = await self.client_v2.get_details() self.assertEqual(res["product_code"], "TWPROCTRLPLC21") async def test_validation_error_code_v2(self): with pytest.raises(TwinklyError) as e: await self.client_v2.get_name() assert "Invalid response from Twinkly" in str(e.value) async def test_validation_error_string_v2(self): with pytest.raises(TwinklyError) as e: await self.client_v2.get_saved_movies() assert "Invalid response from Twinkly" in str(e.value) if __name__ == "__main__": unittest.main() ttls-1.9.0/ttls/000077500000000000000000000000001470515076100135035ustar00rootroot00000000000000ttls-1.9.0/ttls/__init__.py000066400000000000000000000001061470515076100156110ustar00rootroot00000000000000from importlib.metadata import version __version__ = version("ttls") ttls-1.9.0/ttls/cli.py000066400000000000000000000231631470515076100146310ustar00rootroot00000000000000""" Twinkly Twinkly Little Star https://github.com/jschlyter/ttls Copyright (c) 2019 Jakob Schlyter. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ import argparse import asyncio import json import logging import re import sys from .client import ( TWINKLY_MODES, TWINKLY_MUSIC_DRIVERS, TWINKLY_MUSIC_DRIVERS_OFFICIAL, TWINKLY_MUSIC_DRIVERS_UNOFFICIAL, Twinkly, ) from .colours import TwinklyColour logger = logging.getLogger(__name__) async def command_name(t: Twinkly, args: argparse.Namespace): if args.name is None: return await t.get_name() return await t.set_name(args.name) async def command_network(t: Twinkly, args: argparse.Namespace): return await t.get_network_status() async def command_firmware(t: Twinkly, args: argparse.Namespace): return await t.get_firmware_version() async def command_details(t: Twinkly, args: argparse.Namespace): return await t.get_details() async def command_power(t: Twinkly, args: argparse.Namespace): if args.on: return await t.turn_on() elif args.off: return await t.turn_off() else: on = await t.is_on() if on: return "on" else: return "off" async def command_brightness(t: Twinkly, args: argparse.Namespace): if args.pct is None: return await t.get_brightness() return await t.set_brightness(args.pct) async def command_mode(t: Twinkly, args: argparse.Namespace): if args.mode is None: return await t.get_mode() return await t.set_mode(args.mode) async def command_mqtt(t: Twinkly, args: argparse.Namespace): if args.mqtt_json is None: return await t.get_mqtt() data = json.loads(args.mqtt_json) return await t.set_mqtt(data) async def command_movie(t: Twinkly, args: argparse.Namespace): if args.movie_file is None: return await t.get_movie_config() with open(args.movie_file, "rb") as f: movie = f.read() await t.interview() params = { "frame_delay": args.movie_delay, "leds_number": t.length, "frames_number": int(len(movie) / 3 / t.length), } await t.set_mode("movie") await t.set_movie_config(params) return await t.upload_movie(movie) async def command_static(t: Twinkly, args: argparse.Namespace): await t.interview() # match on r,g,b or r,g,b,w if m := re.match(r"(\d+),(\d+),(\d+)(?:,(\d+))?", args.colour): r = int(m.group(1)) g = int(m.group(2)) b = int(m.group(3)) # w is optional; convert to int if set if w := m.group(4): w = int(w) c = TwinklyColour(r, g, b, w) else: raise ValueError("Colour argument is not in r,g,b or r,g,b,w format") return await t.set_static_colour(c) async def command_summary(t: Twinkly, args: argparse.Namespace): return await t.summary() async def command_music(t: Twinkly, args: argparse.Namespace): if args.on: return await t.music_on() elif args.off: return await t.music_off() elif args.next: return await t.next_music_driver() elif args.prev: return await t.previous_music_driver() elif args.current: return await t.get_current_music_driver() elif args.driver: return await t.set_current_music_driver(args.driver) elif args.list: if args.list == "all": return TWINKLY_MUSIC_DRIVERS elif args.list == "official": return TWINKLY_MUSIC_DRIVERS_OFFICIAL elif args.list == "unofficial": return TWINKLY_MUSIC_DRIVERS_UNOFFICIAL async def main_loop() -> None: """Main function""" parser = argparse.ArgumentParser(description="Twinkly Twinkly Little Star") parser.add_argument("--host", metavar="hostname", required=True, help="Device address") parser.add_argument("--debug", action="store_true", help="Enable debugging") parser.add_argument("--json", action="store_true", help="Output result as compact JSON") subparsers = parser.add_subparsers(dest="command") parser_network = subparsers.add_parser("network", help="Get network status") parser_network.set_defaults(func=command_network) parser_firmware = subparsers.add_parser("firmware", help="Get firmware version") parser_firmware.set_defaults(func=command_firmware) parser_details = subparsers.add_parser("details", help="Get device details") parser_details.set_defaults(func=command_details) parser_name = subparsers.add_parser("name", help="Get or set device name") parser_name.add_argument("--name", metavar="name", type=str, required=False) parser_name.set_defaults(func=command_name) parser_power = subparsers.add_parser("power", help="Get or set device power state ('on', 'off')") parser_power.add_argument("--on", action="store_true", required=False, help="Turn device on") parser_power.add_argument("--off", action="store_true", required=False, help="Turn device off") parser_power.set_defaults(func=command_power) parser_brightness = subparsers.add_parser("brightness", help="Get or set LED brightness") parser_brightness.add_argument( "--pct", metavar="value", type=int, required=False, help="Percent brightness (1-100)", ) parser_brightness.set_defaults(func=command_brightness) parser_mode = subparsers.add_parser("mode", help="Get or set LED operation mode") parser_mode.add_argument( "--mode", choices=TWINKLY_MODES, required=False, ) parser_mode.set_defaults(func=command_mode) parser_mqtt = subparsers.add_parser("mqtt", help="Get or set MQTT configuration") parser_mqtt.add_argument( "--json", dest="mqtt_json", metavar="mqtt", type=str, required=False, help="MQTT config as JSON", ) parser_mqtt.set_defaults(func=command_mqtt) parser_movie = subparsers.add_parser("movie", help="Movie configuration") parser_movie.add_argument( "--delay", dest="movie_delay", metavar="milliseconds", type=int, default=100, required=False, help="Delay between frames", ) parser_movie.add_argument( "--file", dest="movie_file", metavar="filename", type=str, required=False, help="Movie file", ) parser_movie.set_defaults(func=command_movie) parser_colour = subparsers.add_parser("static", help="Set static") parser_colour.add_argument( "--colour", dest="colour", metavar="colour", type=str, required=True, help="Colour", ) parser_colour.set_defaults(func=command_static) parser_summary = subparsers.add_parser("summary", help="Get device summary") parser_summary.set_defaults(func=command_summary) parser_music = subparsers.add_parser("music", help="Twinkly Music device control") parser_music.add_argument( "--on", action="store_true", help="Turn on Twinkly Music", ) parser_music.add_argument( "--off", action="store_true", help="Turn off Twinkly Music", ) parser_music.add_argument( "--next", action="store_true", help="Select next official music driver", ) parser_music.add_argument( "--prev", action="store_true", help="Select previous official music driver", ) parser_music.add_argument( "--current", action="store_true", help="Get the current music driver", ) parser_music.add_argument( "--driver", metavar="name", type=str, choices=list(TWINKLY_MUSIC_DRIVERS.keys()), help="Set a music driver", ) parser_music.add_argument( "--list", metavar="type", choices=["all", "official", "unofficial"], nargs="?", const="all", help="List all, official, or unofficial music drivers (default: all)", ) parser_music.set_defaults(func=command_music) args = parser.parse_args() if args.debug: logging.basicConfig(level=logging.DEBUG) t = Twinkly(host=args.host) try: res = await args.func(t, args) except AttributeError: parser.print_help() await t.close() sys.exit(0) if args.json: print(json.dumps(res, indent=None, separators=(",", ":"))) else: if res is not None: print(json.dumps(res, indent=4)) await t.close() def main() -> None: asyncio.run(main_loop()) if __name__ == "__main__": main() ttls-1.9.0/ttls/client.py000066400000000000000000000524741470515076100153470ustar00rootroot00000000000000""" Twinkly Twinkly Little Star https://github.com/jschlyter/ttls Copyright (c) 2019 Jakob Schlyter. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ from __future__ import annotations import base64 import logging import os import socket import time from itertools import cycle, islice from typing import Any, Callable, Optional, Tuple from aiohttp import ( ClientResponseError, ClientSession, ClientTimeout, ServerDisconnectedError, ) from aiohttp.web_exceptions import HTTPUnauthorized from .colours import TwinklyColour, TwinklyColourTuple _LOGGER = logging.getLogger(__name__) TwinklyFrame = list[TwinklyColourTuple] TwinklyResult = Optional[dict] TWINKLY_MODES = [ "color", "demo", "effect", "movie", "off", "playlist", "rt", ] RT_PAYLOAD_MAX_LIGHTS = 300 TWINKLY_MUSIC_DRIVERS_OFFICIAL = { "VU Meter": "00000000-0000-0000-0000-000000000001", "Beat Hue": "00000000-0000-0000-0000-000000000002", "Psychedelica": "00000000-0000-0000-0000-000000000003", "Red Vertigo": "00000000-0000-0000-0000-000000000004", "Dancing Bands": "00000000-0000-0000-0000-000000000005", "Diamond Swirl": "00000000-0000-0000-0000-000000000006", "Joyful Stripes": "00000000-0000-0000-0000-000000000007", "Angel Fade": "00000000-0000-0000-0000-000000000008", "Clockwork": "00000000-0000-0000-0000-000000000009", "Sipario": "00000000-0000-0000-0000-00000000000A", "Sunset": "00000000-0000-0000-0000-00000000000B", "Elevator": "00000000-0000-0000-0000-00000000000C", } TWINKLY_MUSIC_DRIVERS_UNOFFICIAL = { "VU Meter 2": "00000000-0000-0000-0000-000001000001", "Beat Hue 2": "00000000-0000-0000-0000-000001000002", "Psychedelica 2": "00000000-0000-0000-0000-000001000003", "Sparkle": "00000000-0000-0000-0000-000001000005", "Sparkle Hue": "00000000-0000-0000-0000-000001000006", "Psycho Sparkle": "00000000-0000-0000-0000-000001000007", "Psycho Hue": "00000000-0000-0000-0000-000001000008", "Red Line": "00000000-0000-0000-0000-000001000009", "Red Vertigo 2": "00000000-0000-0000-0000-000002000004", "Dancing Bands 2": "00000000-0000-0000-0000-000002000005", "Diamond Swirl 2": "00000000-0000-0000-0000-000002000006", "Angel Fade 2": "00000000-0000-0000-0000-000002000008", "Clockwork 2": "00000000-0000-0000-0000-000002000009", "Sunset 2": "00000000-0000-0000-0000-00000200000B", } TWINKLY_MUSIC_DRIVERS = { **TWINKLY_MUSIC_DRIVERS_OFFICIAL, **TWINKLY_MUSIC_DRIVERS_UNOFFICIAL, } TWINKLY_RETURN_CODE = "code" TWINKLY_RETURN_CODE_OK = 1000 DEFAULT_TIMEOUT = 3 class Twinkly: def __init__( self, host: str, session: ClientSession | None = None, timeout: int | None = None, api_version: int | None = None, ): self.host = host self._timeout = ClientTimeout(total=timeout or DEFAULT_TIMEOUT) if session: self._session = session self._shared_session = True else: self._session = None self._shared_session = False self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._headers: dict[str, str] = {} self._rt_port = 7777 self._expires = None self._token = None self._details: dict[str, str | int] = {} self._default_mode = "movie" self._api_version = api_version @property def base(self) -> str: if self._api_version is None: raise ValueError("api version isn't set") return f"http://{self.host}/xled/v{self._api_version}" @property def length(self) -> int: return int(self._details["number_of_led"]) def is_rgbw(self) -> bool: return self._details["led_profile"] == "RGBW" def is_rgb(self) -> bool: return self._details["led_profile"] == "RGB" @property def default_mode(self) -> str: return self._default_mode @default_mode.setter def default_mode(self, mode: str | None = None) -> str: if not mode: return if mode not in TWINKLY_MODES: raise ValueError("Invalid mode") if mode == "off": _LOGGER.warning("Setting default mode to off") self._default_mode = mode async def close(self) -> None: if not self._shared_session: await self._get_session().close() self._session = None async def interview(self, force: bool | None = False) -> None: if len(self._details) == 0 or force: self._details = await self.get_details() mode = await self.get_mode() if mode.get("mode") != "off": self.default_mode = mode.get("mode") def _get_session(self): if not self._session: self._session = ClientSession() return self._session async def _info(self) -> Any: _LOGGER.debug("INFO") try: async with self._get_session().get( f"http://{self.host}/xled/info", timeout=self._timeout, raise_for_status=True, ) as r: _LOGGER.debug("INFO response %d", r.status) return await r.json() except (ClientResponseError, ServerDisconnectedError) as e: raise e async def get_api_version(self) -> int: if self._api_version is None: self._api_version = await self.detect_api_version() return self._api_version async def detect_api_version(self) -> int: try: self._api_version = 1 _ = await self.get_details() return 1 except (ClientResponseError, TwinklyError): pass try: self._api_version = 2 _ = await self.get_details() return 2 except (ClientResponseError, TwinklyError): pass self._api_version = None return None async def _post(self, endpoint: str, **kwargs) -> Any: await self.get_api_version() await self.ensure_token() _LOGGER.debug("POST endpoint %s", endpoint) if "json" in kwargs: _LOGGER.debug("POST payload %s", kwargs["json"]) headers = kwargs.pop("headers", self._headers) retry_num = kwargs.pop("retry_num", 0) try: async with self._get_session().post( f"{self.base}/{endpoint}", headers=headers, timeout=self._timeout, raise_for_status=True, **kwargs, ) as r: _LOGGER.debug("POST response %d", r.status) return await r.json() except ClientResponseError as e: if e.status == HTTPUnauthorized.status_code: await self._handle_authorized(self._post, endpoint, exception=e, retry_num=retry_num, **kwargs) else: raise e async def _get(self, endpoint: str, **kwargs) -> Any: await self.get_api_version() await self.ensure_token() _LOGGER.debug("GET endpoint %s", endpoint) headers = kwargs.pop("headers", self._headers) retry_num = kwargs.pop("retry_num", 0) try: async with self._get_session().get( f"{self.base}/{endpoint}", headers=headers, timeout=self._timeout, raise_for_status=True, **kwargs, ) as r: _LOGGER.debug("GET response %d", r.status) return await r.json() except ClientResponseError as e: if e.status == HTTPUnauthorized.status_code: return await self._handle_authorized( self._get, endpoint, exception=e, retry_num=retry_num, **kwargs, ) else: raise e async def _handle_authorized(self, request_method: Callable, endpoint: str, exception: Exception, **kwargs) -> None: max_retries = 1 retry_num = kwargs.pop("retry_num", 0) if retry_num >= max_retries: _LOGGER.debug(f"Invalid token for request. Maximum retries of {max_retries} exceeded.") raise exception retry_num += 1 _LOGGER.debug( "Invalid token for request. " + f"Refreshing token and attempting retry {retry_num} of {max_retries}." ) await self.refresh_token() return await request_method(endpoint, headers=self._headers, retry_num=retry_num, **kwargs) async def refresh_token(self) -> None: await self.login() await self.verify_login() _LOGGER.debug("Authentication token refreshed") async def ensure_token(self) -> str: if self._expires is None or self._expires <= time.time(): _LOGGER.debug("Authentication token expired, will refresh") await self.refresh_token() else: _LOGGER.debug("Authentication token still valid") return self._token or "" async def login(self) -> None: challenge = base64.b64encode(os.urandom(32)).decode() payload = {"challenge": challenge} async with self._get_session().post( f"{self.base}/login", json=payload, timeout=self._timeout, raise_for_status=True, ) as r: data = await r.json() self._token = data["authentication_token"] self._headers["X-Auth-Token"] = self._token self._expires = time.time() + data["authentication_token_expires_in"] async def logout(self) -> None: await self._post("logout", json={}) self._token = None async def verify_login(self) -> None: await self._post("verify", json={}) async def get_name(self) -> Any: endpoint = "device_name" if await self.get_api_version() == 1 else "device/name" return self._valid_response(await self._get(endpoint)) async def set_name(self, name: str) -> Any: endpoint = "device_name" if await self.get_api_version() == 1 else "device/name" return await self._post(endpoint, json={"name": name}) async def reset(self) -> Any: return self._valid_response(await self._get("reset")) async def get_network_status(self) -> Any: endpoint = "network/status" if await self.get_api_version() == 1 else "network/eth/status" return self._valid_response(await self._get(endpoint)) async def get_firmware_version(self) -> Any: endpoint = "fw/version" if await self.get_api_version() == 1 else "fw/ct1/version" return self._valid_response(await self._get(endpoint)) async def get_details(self) -> Any: return self._valid_response(await self._get("gestalt")) async def is_on(self) -> bool | None: mode = await self.get_mode() if mode is None: return None return mode.get("mode", "off") != "off" async def turn_on(self) -> Any: return await self.set_mode(self._default_mode) async def turn_off(self) -> Any: return await self.set_mode("off") async def get_brightness(self) -> Any: return self._valid_response(await self._get("led/out/brightness")) async def set_brightness(self, percent: int) -> Any: args = {"value": percent, "type": "A"} if await self.get_api_version() >= 2: args["mode"] = "enabled" return await self._post("led/out/brightness", json=args) async def get_mode(self) -> Any: endpoint = "led/mode" if await self.get_api_version() == 1 else "application/mode" return self._valid_response(await self._get(endpoint)) async def set_mode(self, mode: str) -> Any: endpoint = "led/mode" if await self.get_api_version() == 1 else "application/mode" return await self._post(endpoint, json={"mode": mode}) async def get_mqtt(self) -> Any: return self._valid_response(await self._get("mqtt/config")) async def set_mqtt(self, data: dict) -> Any: return await self._post("mqtt/config", json=data) async def send_frame(self, frame: TwinklyFrame) -> None: await self.interview() if len(frame) != self.length: raise ValueError("Invalid frame length") token = await self.ensure_token() header = bytes([0x01]) + bytes(base64.b64decode(token)) + bytes([self.length]) payload = [] for x in frame: payload.extend(list(x)) self._socket.sendto(header + bytes(payload), (self.host, self._rt_port)) async def send_frame_2(self, frame: TwinklyFrame) -> None: await self.interview() if len(frame) != self.length: raise ValueError("Invalid frame length") token = await self.ensure_token() frame_segments = [frame[i : i + RT_PAYLOAD_MAX_LIGHTS] for i in range(0, len(frame), RT_PAYLOAD_MAX_LIGHTS)] for i in range(0, len(frame_segments)): header = bytes([len(frame_segments)]) + bytes(base64.b64decode(token)) + bytes([0, 0]) + bytes([i]) payload = [] for x in frame_segments[i]: payload.extend(list(x)) self._socket.sendto(header + bytes(payload), (self.host, self._rt_port)) async def get_movie_config(self) -> Any: if await self.get_api_version() != 1: raise NotImplementedError return self._valid_response(await self._get("led/movie/config")) async def set_movie_config(self, data: dict) -> Any: return await self._post("led/movie/config", json=data) async def upload_movie(self, movie: bytes) -> Any: return await self._post( "led/movie/full", data=movie, headers={"Content-Type": "application/octet-stream"}, ) async def set_static_colour( self, colour: TwinklyColour | TwinklyColourTuple | list[TwinklyColour] | list[TwinklyColourTuple], ) -> None: if not self._details: await self.interview() if isinstance(colour, list): colour = colour[0] if isinstance(colour, Tuple): colour = TwinklyColour.from_twinkly_tuple(colour) if await self.get_api_version() == 1: await self._post( "led/color", json=colour.as_dict(), ) await self.set_mode("color") else: await self.set_mode("color") await self._post( "led/color", json=colour.as_dict(), ) async def set_cycle_colours( self, colour: TwinklyColour | TwinklyColourTuple | list[TwinklyColour] | list[TwinklyColourTuple], ) -> None: if isinstance(colour, TwinklyColour): sequence = [colour.as_twinkly_tuple()] elif isinstance(colour, Tuple): sequence = [colour] elif isinstance(colour, list): sequence = [c.as_twinkly_tuple() for c in colour] if isinstance(colour[0], TwinklyColour) else colour else: raise TypeError("Unknown colour format") frame = list(islice(cycle(sequence), self.length)) movie = bytes([item for t in frame for item in t]) await self.upload_movie(movie) await self.set_movie_config( { "frames_number": 1, "loop_type": 0, "frame_delay": 1000, "leds_number": self.length, } ) await self.set_mode("movie") async def summary(self) -> Any: return self._valid_response(await self._get("summary")) async def music_on(self) -> Any: return await self._post("music/enabled", json={"enabled": 1}) async def music_off(self) -> Any: return await self._post("music/enabled", json={"enabled": 0}) async def get_music_drivers(self) -> Any: """ This endpoint is not currently used by the Twinkly app, but was discovered through trial & error. It raises a 400 error ('unexpected content-length header') when called from aiohttp, but returns JSON when called via requests. This endpoint was used to map driver names to IDs and identify the 'unofficial' drivers on the device. {"code": 1000, "drivers_number": 26, "unique_ids": []} """ # return await self._get("music/drivers") raise NotImplementedError async def next_music_driver(self) -> Any: return await self._post("music/drivers/current", json={"action": "next"}) async def previous_music_driver(self) -> Any: return await self._post("music/drivers/current", json={"action": "prev"}) async def get_current_music_driver(self) -> Any: if await self.get_api_version() != 1: raise NotImplementedError return self._valid_response(await self._get("music/drivers/current")) async def set_current_music_driver(self, driver_name: str) -> Any: unique_id = self._music_driver_id(driver_name) if not unique_id: _LOGGER.error(f"'{driver_name}' is an invalid music driver") return # An explicit driver cannot be set unless next/previous driver was called first current_driver = await self.get_current_music_driver() if current_driver["handle"] == -1: await self.next_music_driver() return await self._post("music/drivers/current", json={"unique_id": unique_id}) def _music_driver_id(self, driver_name: str) -> Any: if driver_name in TWINKLY_MUSIC_DRIVERS_OFFICIAL: return TWINKLY_MUSIC_DRIVERS_OFFICIAL[driver_name] elif driver_name in TWINKLY_MUSIC_DRIVERS_UNOFFICIAL: _LOGGER.warn(f"Music driver '{driver_name}'is defined, but is not officially supported") return TWINKLY_MUSIC_DRIVERS_UNOFFICIAL[driver_name] else: return None async def get_saved_movies(self) -> Any: return self._valid_response(await self._get("movies"), check_for="movies") async def get_current_movie(self) -> Any: return self._valid_response(await self._get("movies/current")) async def set_current_movie(self, movie_id: int) -> Any: return await self._post("movies/current", json={"id": movie_id}) async def get_current_colour(self) -> Any: return self._valid_response(await self._get("led/color")) async def get_predefined_effects(self) -> Any: """Get the list of predefined effects.""" if await self.get_api_version() != 1: raise NotImplementedError return self._valid_response(await self._get("led/effects")) async def get_current_predefined_effect(self) -> Any: """Get current effect.""" if await self.get_api_version() != 1: raise NotImplementedError return self._valid_response(await self._get("led/effects/current")) async def set_current_predefined_effect(self, effect_id: int) -> None: """Set current effect.""" await self._post( "led/effects/current", json={"effect_id": effect_id}, ) async def get_playlist(self) -> Any: """Get the playlist.""" endpoint = "playlist" if await self.get_api_version() == 1 else "playlists" return self._valid_response(await self._get(endpoint)) async def get_current_playlist_entry(self) -> Any: """Get current playlist.""" if await self.get_api_version() != 1: raise NotImplementedError return self._valid_response(await self._get("playlist/current")) async def set_current_playlist_entry(self, entry_id: int) -> None: """Jump to specific effect in the playlist.""" await self._post( "playlist/current", json={"id": entry_id}, ) def _valid_response(self, response: dict[Any, Any], check_for: str | None = None) -> dict[Any, Any]: """Validate twinkly-responses from the API.""" result = response.get("result") if response and self._api_version >= 2 else response if ( result and result.get(TWINKLY_RETURN_CODE) == TWINKLY_RETURN_CODE_OK and (not check_for or check_for in response) ): _LOGGER.debug("Twinkly response: %s", response) return response raise TwinklyError(f"Invalid response from Twinkly: {response}") class TwinklyError(ValueError): """Error from the API.""" ttls-1.9.0/ttls/colours.py000066400000000000000000000033021470515076100155410ustar00rootroot00000000000000from dataclasses import dataclass from typing import Dict, Optional, Tuple, Union ColourDict = Dict[str, int] ColourTuple = Union[Tuple[int, int, int], Tuple[int, int, int, int]] TwinklyColourTuple = Union[Tuple[int, int, int], Tuple[int, int, int, int]] @dataclass(frozen=True) class TwinklyColour: red: int green: int blue: int white: Optional[int] = None def as_twinkly_tuple(self) -> TwinklyColourTuple: """Convert TwinklyColour to a tuple as used by Twinkly: (R,G,B) or (W,R,G,B)""" if self.white is not None: return (self.white, self.red, self.green, self.blue) else: return (self.red, self.green, self.blue) def as_tuple(self) -> ColourTuple: """Convert TwinklyColour to a tuple: (R,G,B) or (R,G,B,W)""" if self.white is not None: return (self.red, self.green, self.blue, self.white) else: return (self.red, self.green, self.blue) def __iter__(self): yield from self.as_tuple() def as_dict(self) -> ColourDict: """Convert TwinklyColour to a dict wth color names used by set-led functions.""" if self.white is not None: return { "red": self.red, "green": self.green, "blue": self.blue, "white": self.white, } else: return {"red": self.red, "green": self.green, "blue": self.blue} @classmethod def from_twinkly_tuple(cls, t): if len(t) == 4: return cls(red=t[1], green=t[2], blue=t[3], white=t[0]) elif len(t) == 3: return cls(red=t[0], green=t[1], blue=t[2]) raise TypeError("Unknown colour format")