# Directory Structure ``` ├── .gitignore ├── .python-version ├── LICENSE.md ├── pyproject.toml ├── README.md ├── src │ ├── tuyactl │ │ ├── __main__.py │ │ └── cli.py │ └── tuyad │ └── __main__.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.13 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | *.json 2 | __pycache__ 3 | 4 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # tuyactl 2 | 3 | This project provides a command-line interface (`tuyactl`) for controlling Tuya devices. It interacts with a separate Tuya Server (I'm still thinking a better way of doing that). 4 | 5 | ## Requirements 6 | 7 | * **uv:** A fast and modern Python package installer and runner. Install it by following the instructions on the [uv documentation site](https://docs.astral.sh/uv/installation/). 8 | * **Tuya Local Keys:** You will need the local keys for your Tuya devices. Follow the [tinytuya setup wizard](https://github.com/jasonacox/tinytuya#setup-wizard---getting-local-keys) to obtain these. 9 | 10 | ## Quick Start 11 | 12 | 1. **Install `uv`:** 13 | 14 | Follow the official installation instructions on the [uv documentation site](https://docs.astral.sh/uv/installation/). The recommended method is to use the standalone installer, which you can download and run with the following command: 15 | 16 | ```bash 17 | curl -LsSf https://astral.sh/uv/install.sh | sh 18 | ``` 19 | 20 | 2. **Obtain Tuya Local Keys:** 21 | 22 | Follow the [tinytuya setup wizard](https://github.com/jasonacox/tinytuya#setup-wizard---getting-local-keys) to get the local keys for your Tuya devices. Place the resulting `snapshot.json` file in your home directory (`~`). You can customize the location of this file using environment variables (see below). 23 | 24 | 3. **Run the server:** 25 | 26 | ``` 27 | nohup tuyad > tuyad.log 2>&1 & 28 | ``` 29 | 30 | 3. **Run `tuyactl`:** 31 | 32 | To see the available commands and options, run: 33 | 34 | ```bash 35 | tuyactl --help 36 | ``` 37 | 38 | To execute a specific command, use the following syntax: 39 | 40 | ```bash 41 | tuyactl <command> [options] 42 | ``` 43 | 44 | Replace `<command>` with one of the available commands: `list`, `on`, `off`, `color`, `brightness`, `temperature`, `mode`, `music`. Use the `-- 45 | help` option to see the available options for each command. 46 | 47 | For example, to list all your Tuya devices, run: 48 | 49 | ```bash 50 | tuyactl list 51 | ``` 52 | 53 | ## Configuration 54 | 55 | * **`snapshot.json` Location:** You can customize the location of the `snapshot.json` file (containing your Tuya device keys) using environment va 56 | riables. (Details on this to be added later). 57 | 58 | ``` -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- ```markdown 1 | Copyright (c) 2025 Cabra Lattice (@cabra.lat) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | ``` -------------------------------------------------------------------------------- /src/tuyactl/cli.py: -------------------------------------------------------------------------------- ```python 1 | from tuyactl.__main__ import main 2 | 3 | if __name__ == '__main__': 4 | main() ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "tuyactl" 3 | version = "0.0.1" 4 | description = "A server and client cli tool for controlling Tuya devices" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "colour>=0.1.5", 9 | "numpy>=2.2.2", 10 | "quart>=0.20.0", 11 | "scipy>=1.15.1", 12 | "tinytuya>=1.16.1", 13 | ] 14 | [[project.authors]] 15 | name = "@cabra.lat" 16 | email = "[email protected]" 17 | 18 | 19 | [project.scripts] 20 | tuyactl = "tuyactl.cli:main" 21 | tuyad = "tuyad.__main__:main" 22 | 23 | [build-system] 24 | requires = [ "hatchling",] 25 | build-backend = "hatchling.build" 26 | 27 | [tool.hatch.build.targets.wheel] 28 | packages = ["src/tuyactl", "src/tuyad"] 29 | ``` -------------------------------------------------------------------------------- /src/tuyactl/__main__.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python 2 | import requests 3 | import json 4 | import sys 5 | import argparse 6 | 7 | BASE_URL = 'http://localhost:5000' 8 | 9 | def send_command(command, parameters=None): 10 | url = f'{BASE_URL}/{command}' 11 | headers = {'Content-type': 'application/json'} 12 | try: 13 | if parameters: 14 | print(f"Sending command: {url} with parameters {parameters}") 15 | response = requests.post(url, headers=headers, data=json.dumps(parameters)) 16 | else: 17 | print(f"Sending command: {url}") 18 | response = requests.get(url, headers=headers) 19 | response.raise_for_status() 20 | return response.json() 21 | except requests.exceptions.RequestException as e: 22 | print(f"Error: {e}") 23 | return {"status": "error", "message": str(e)} 24 | 25 | def main(): 26 | parser = argparse.ArgumentParser(description='Control Tuya devices.') 27 | subparsers = parser.add_subparsers(title='commands', dest='command', help='Available commands') 28 | 29 | # Device list command 30 | device_list_parser = subparsers.add_parser('list', help='List all devices') 31 | 32 | # Turn on command 33 | on_parser = subparsers.add_parser('on', help='Turn on a device') 34 | on_parser.add_argument('devices', type=str, nargs='*', help='Device(s) name(s)') 35 | on_parser.add_argument('--all', action='store_true', help='Turn on all devices') 36 | 37 | # Turn off command 38 | off_parser = subparsers.add_parser('off', help='Turn off a device') 39 | off_parser.add_argument('devices', type=str, nargs='*', help='Device(s) name(s)') 40 | off_parser.add_argument('--all', action='store_true', help='Turn off all devices') 41 | 42 | # Set color command 43 | color_parser = subparsers.add_parser('color', help='Set the color of a device') 44 | color_parser.add_argument('color', type=str, help='Color value') 45 | color_parser.add_argument('devices', type=str, nargs='*', help='Device(s) name(s)') 46 | color_parser.add_argument('--all', action='store_true', help='Set color for all devices') 47 | 48 | # Set brightness command 49 | brightness_parser = subparsers.add_parser('brightness', help='Set the brightness of a device') 50 | brightness_parser.add_argument('brightness', type=int, help='Brightness value') 51 | brightness_parser.add_argument('devices', nargs='*', type=str, help='Device(s) name(s)') 52 | brightness_parser.add_argument('--all', action='store_true', help='Set brightness for all devices') 53 | 54 | # Set colour temperature command 55 | colourtemp_parser = subparsers.add_parser('temperature', help='Set the colour temperature of a device') 56 | colourtemp_parser.add_argument('temperature', type=int, help='Colour temperature value') 57 | colourtemp_parser.add_argument('devices', nargs='*', type=str, help='Device(s) name(s)') 58 | colourtemp_parser.add_argument('--all', action='store_true', help='Set colour temperature for all devices') 59 | 60 | # Set mode command 61 | mode_parser = subparsers.add_parser('mode', help='Set the mode of a device') 62 | mode_parser.add_argument('mode', type=str, choices=['white', 'colour', 'scene', 'music'], help='Mode value') 63 | mode_parser.add_argument('devices', nargs='*', type=str, help='Device(s) name(s)') 64 | mode_parser.add_argument('--all', action='store_true', help='Set mode for all devices') 65 | 66 | # Music sim command 67 | music_parser = subparsers.add_parser('music', help='Simulate music mode for a device') 68 | music_parser.add_argument('devices', nargs='*', type=str, help='Device(s) name(s)') 69 | music_parser.add_argument('--all', action='store_true', help='Music mode using all devices') 70 | music_parser.add_argument('--stop', action='store_true', help='Stop music mode for selected devices') 71 | 72 | args = parser.parse_args() 73 | 74 | parameters = {} 75 | endpoint = args.command 76 | if args.command == 'on': 77 | endpoint = 'turn_on' 78 | if args.all: 79 | parameters["all"] = True 80 | elif args.devices: 81 | parameters["devices"] = args.devices 82 | else: 83 | print("Error: Device(s) name(s) required when not using --all") 84 | sys.exit(1) 85 | 86 | elif args.command == 'off': 87 | endpoint = 'turn_off' 88 | if args.all: 89 | parameters["all"] = True 90 | elif args.devices: 91 | parameters["devices"] = args.devices 92 | else: 93 | print("Error: Device(s) name(s) required when not using --all") 94 | sys.exit(1) 95 | 96 | elif args.command == 'color': 97 | endpoint = 'set_color' 98 | if args.all: 99 | parameters["all"] = True 100 | elif args.devices: 101 | parameters["devices"] = args.devices 102 | if args.color: 103 | parameters["color"] = args.color 104 | 105 | elif args.command == 'brightness': 106 | endpoint = 'set_brightness' 107 | if args.all: 108 | parameters["all"] = True 109 | elif args.devices: 110 | parameters["devices"] = args.devices 111 | if args.brightness: 112 | parameters["brightness"] = args.brightness 113 | 114 | elif args.command == 'temperature': 115 | endpoint = 'set_temperature' 116 | if args.all: 117 | parameters["all"] = True 118 | elif args.devices: 119 | parameters["devices"] = args.devices 120 | if args.temperature: 121 | parameters["temperature"] = args.temperature 122 | 123 | elif args.command == 'mode': 124 | endpoint = 'set_mode' 125 | if args.all: 126 | parameters["all"] = True 127 | elif args.devices: 128 | parameters["devices"] = args.devices 129 | if args.mode: 130 | parameters["mode"] = args.mode 131 | 132 | elif args.command == 'music': 133 | if args.all: 134 | parameters["all"] = True 135 | elif args.devices: 136 | parameters["devices"] = args.devices 137 | if args.stop: 138 | parameters["stop"] = args.stop 139 | 140 | elif endpoint: 141 | print("Error: Invalid command") 142 | 143 | if endpoint: 144 | result = send_command(endpoint, parameters if parameters else None) 145 | if endpoint == 'list': 146 | for device in result: 147 | print(device) 148 | return 149 | print(json.dumps(result)) 150 | return 151 | 152 | parser.print_help() 153 | 154 | if __name__ == '__main__': 155 | main() 156 | ``` -------------------------------------------------------------------------------- /src/tuyad/__main__.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python 2 | import tinytuya 3 | import json 4 | import os 5 | import struct 6 | import subprocess 7 | import asyncio 8 | import aiofiles 9 | import numpy as np 10 | import time 11 | import random 12 | import logging 13 | from scipy.fft import fft 14 | from scipy.signal import butter, filtfilt 15 | from quart import Quart, request, jsonify 16 | from colour import Color 17 | from collections import deque 18 | 19 | logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") 20 | 21 | DEVICES_FILE = os.environ.get('DEVICES', os.path.expanduser('~/snapshot.json')) 22 | XDG_RUNTIME_DIR = os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}') 23 | AUDIO_TARGET = os.environ.get('TUYA_MCP_AUDIO_TARGET', 'alsa_output.pci-0000_00_1b.0.analog-stereo.monitor') 24 | CHANNELS = 2 25 | SAMPLE_RATE = 48000 26 | BUFFER_SIZE = SAMPLE_RATE * CHANNELS # 16 bits LE gives 500ms of data 27 | CHUNK_SIZE = 1024 28 | 29 | app = Quart(__name__) 30 | devices = [] 31 | stop_event = asyncio.Event() 32 | 33 | async def load_devices(): 34 | global devices 35 | try: 36 | async with aiofiles.open(DEVICES_FILE, 'r') as f: 37 | data = await f.read() 38 | devices = json.loads(data).get("devices", []) 39 | logging.info("Devices loaded") 40 | except FileNotFoundError: 41 | logging.error(f"Error: {DEVICES_FILE} not found.") 42 | raise 43 | except json.JSONDecodeError: 44 | logging.error(f"Error: Invalid JSON format in {DEVICES_FILE}.") 45 | raise 46 | 47 | async def control_device(device, action, *args, timeout=0.5, retries=1, **kwargs): 48 | try: 49 | device_id = device['id' ] 50 | device_name = device['name'] 51 | local_key = device['key' ] 52 | ip_address = device['ip' ] 53 | version = device['ver' ] 54 | 55 | d = tinytuya.BulbDevice( 56 | device_id, 57 | ip_address, 58 | local_key, 59 | connection_retry_limit=retries, 60 | connection_timeout=timeout) 61 | d.set_version(version) 62 | function = getattr(d, action) 63 | logging.info(f"Executing {action} on {device_name}: {args} {kwargs}") 64 | function(*args, **kwargs) 65 | logging.info(f"Executed {action} on {device_name}") 66 | except Exception as e: 67 | logging.error(f"Error controlling {device_name or 'unknown device'}: {e}") 68 | raise 69 | 70 | async def handle_action_over_devices(action, request_data, *args, **kwargs): 71 | all_devices = request_data.get('all', False) 72 | devices_names = request_data.get('devices', []) 73 | 74 | kwargs['nowait'] = kwargs.get('nowait',True) 75 | 76 | if not all_devices and not devices_names: 77 | return jsonify({"status": "error", "message": "At least one device name must be provided"}), 400 78 | 79 | tasks = [] 80 | for device in devices: 81 | if device.get('name') in devices_names or all_devices: 82 | tasks.append(asyncio.create_task(control_device(device, action, *args, **kwargs))) 83 | 84 | return jsonify({ 85 | "status": "success", 86 | "message": f"Action {action} executed over {'all devices' if all_devices else 'devices: '}{','.join(devices_names)}." 87 | }) 88 | 89 | def generate_dp27_payload(mode: int, 90 | hue: int, 91 | saturation: int, 92 | value: int, 93 | brightness: int = 1000, 94 | white_brightness: int = 0, 95 | temperature: int = 1000) -> str: 96 | """ 97 | Generate a DP27 payload for Tuya music mode. 98 | 99 | :param mode: 0 for jumping mode, 1 for gradient mode. 100 | :param hue: Hue value (0-360). 101 | :param saturation: Saturation (0-1000). 102 | :param value: Value (0-1000). 103 | :param brightness: Brightness (0-1000), default is 1000. 104 | :param white_brightness: White brightness (0-1000), default is 1000. 105 | :param temperature: Color temperature (0-1000), default is 1000. 106 | :return: DP27 string payload. 107 | """ 108 | return f"{mode:01X}{hue:04X}{saturation:04X}{brightness:04X}{white_brightness:04X}{temperature:04X}" 109 | 110 | from collections import deque 111 | 112 | class AsyncDeque(deque): 113 | def __init__(self, *args, **kwargs): 114 | super().__init__(*args, **kwargs) 115 | self._not_empty = asyncio.Condition() # For signaling when items are added 116 | self._stopped = False # To indicate when the deque is stopped 117 | 118 | def __aiter__(self): 119 | return self 120 | 121 | async def __anext__(self): 122 | async with self._not_empty: 123 | while not self and not self._stopped: 124 | await self._not_empty.wait() # Wait until an item is added or stopped 125 | if self._stopped and not self: 126 | raise StopAsyncIteration 127 | return self.popleft() 128 | 129 | async def put(self, item): 130 | """Add an item to the deque and notify waiting consumers.""" 131 | async with self._not_empty: 132 | self.append(item) 133 | self._not_empty.notify() 134 | 135 | async def stop(self): 136 | """Stop all waiting consumers by notifying them.""" 137 | async with self._not_empty: 138 | self._stopped = True 139 | self._not_empty.notify_all() 140 | 141 | async def __aenter__(self): 142 | """Enter context, returning the deque.""" 143 | return self 144 | 145 | async def __aexit__(self, exc_type, exc_val, exc_tb): 146 | """Exit context, ensuring proper cleanup.""" 147 | await self.stop() 148 | 149 | class AudioBufferManager: 150 | def __init__(self): 151 | self.sample_rate = SAMPLE_RATE 152 | self.chunk_size = CHUNK_SIZE 153 | self.max_buffer_size = 1 154 | self.consumers = [] 155 | self.lock = asyncio.Lock() 156 | 157 | async def add_audio_data(self, data): 158 | """Add new audio data to the buffer and all consumer queues""" 159 | async with self.lock: 160 | # Add to all consumer queues 161 | for queue in self.consumers: 162 | await queue.put(data) 163 | 164 | def register_consumer(self): 165 | """Create a new async generator for a consumer""" 166 | queue = AsyncDeque(maxlen=self.max_buffer_size) 167 | self.consumers.append(queue) 168 | return queue 169 | 170 | buffer_manager = AudioBufferManager() 171 | 172 | async def audio_reader(): 173 | global stop_event, buffer_manager 174 | 175 | process = await asyncio.create_subprocess_exec( 176 | #'parec', f'--device={AUDIO_TARGET}', '--format=s16', f'--rate={SAMPLE_RATE}', 177 | 'pw-record', f'--target={AUDIO_TARGET}', '--format=s16', f'--rate={SAMPLE_RATE}', '-', 178 | env={'XDG_RUNTIME_DIR': XDG_RUNTIME_DIR}, 179 | stdout=asyncio.subprocess.PIPE, 180 | ) 181 | logging.info("Initializing audio reader task.") 182 | 183 | try: 184 | while not stop_event.is_set(): 185 | chunk = await process.stdout.read(CHUNK_SIZE) 186 | #if not any(chunk): logging.info('empty chunk') 187 | await buffer_manager.add_audio_data(chunk) 188 | except Exception as e: 189 | logging.error(f"Audio reader error: {e}") 190 | raise 191 | 192 | async def audio_consumer(device, freq_range, *args, timeout=0.5, retries=1, **kwargs): 193 | global stop_event, buffer_manager 194 | consumer = buffer_manager.register_consumer() 195 | device_id = device['id'] 196 | device_name = device['name'] 197 | local_key = device['key'] 198 | ip_address = device['ip'] 199 | version = float(device['ver']) 200 | 201 | d = tinytuya.BulbDevice( 202 | device_id, 203 | ip_address, 204 | local_key, 205 | connection_retry_limit=retries, 206 | connection_timeout=timeout) 207 | d.set_version(version) 208 | d.set_socketPersistent(True) 209 | d.set_mode('music') 210 | 211 | status = d.status() 212 | error_message = status.get('Error',None) 213 | if error_message: 214 | logging.error(f'{device_name} error: {error_message}') 215 | return 216 | 217 | logging.info(f"{device_name} will handle {freq_range[0]} Hz - {freq_range[1]} Hz") 218 | 219 | # Parameters for beat detection and processing 220 | BEAT_THRESHOLD_FACTOR = 1.5 # Base beat detection threshold. Dynamic adjustment target. 221 | DYNAMIC_ADJUSTMENT_RATE = 0.01 # How quickly the threshold adapts 222 | RUNNING_AVG_COEFF = 0.90 # For smoothing the volume (increased responsiveness, was 0.975) 223 | MIN_BRIGHTNESS = 0 # Ensure this is never negative. 224 | MAX_BRIGHTNESS = 1000 225 | MIN_SATURATION = 0 226 | MAX_SATURATION = 1000 227 | 228 | # Beat detection variables 229 | history_length = int(SAMPLE_RATE / CHUNK_SIZE) # 1 second history 230 | volume_history = np.zeros(history_length) 231 | current_index = 0 232 | dynamic_threshold = 0 233 | last_beat_time = 0 234 | 235 | # Color rotation parameters 236 | sensitivity=1.5 # Beat detection sensitivity (higher = more sensitive) 237 | decay_rate=0.97 # How quickly peaks decay over time 238 | flash_duration=0.1 # Duration of light flash in seconds 239 | 240 | hue = 0 241 | brightness = 0 242 | saturation = 0 243 | 244 | def calculate_rms(audio_data): 245 | """Calculate root mean square of audio chunk""" 246 | return np.sqrt(np.mean(np.square(audio_data))) 247 | 248 | def is_beat(current_volume): 249 | """Improved beat detection using dynamic threshold""" 250 | nonlocal dynamic_threshold, last_beat_time 251 | # Update dynamic threshold 252 | dynamic_threshold = decay_rate * dynamic_threshold + (1 - decay_rate) * current_volume 253 | 254 | # Check if current volume exceeds threshold and cooldown has passed 255 | now = time.time() 256 | if current_volume > dynamic_threshold * sensitivity and (now - last_beat_time) > flash_duration: 257 | last_beat_time = now 258 | return True 259 | return False 260 | 261 | async def fade_back(device, duration): 262 | """Gradual fade back to rhythm-based brightness""" 263 | start_time = time.time() 264 | while time.time() - start_time < duration: 265 | brightness = int(1000 * (1 - (time.time() - start_time)/duration)) 266 | value = generate_dp27_payload(0, hue, saturation, brightness) 267 | payload = device.generate_payload(tinytuya.CONTROL, {"27": value}) 268 | device.send(payload) 269 | await asyncio.sleep(0.05) 270 | 271 | async def fft_analysis(np_audio): 272 | # FFT analysis focused on bass frequencies 273 | fft_data = np.fft.rfft(np_audio) 274 | frequencies = np.fft.rfftfreq(len(np_audio), 1/SAMPLE_RATE) 275 | mask = (frequencies >= freq_range[0]) & (frequencies <= freq_range[1]) 276 | fft_abs = np.abs(fft_data[mask]) 277 | energy = np.sum(fft_abs) 278 | # Convert to dB scale 279 | volume_db = 10 * np.log10(energy + 1e-9) # Add epsilon to avoid log(0) 280 | 281 | max_index = np.argmax(fft_abs) 282 | dominant_frequency = frequencies[mask][max_index] 283 | 284 | return dominant_frequency, volume_db 285 | 286 | try: 287 | async for audio_chunk in consumer: 288 | if stop_event.is_set(): break 289 | np_audio = np.frombuffer(audio_chunk, dtype=np.int16).astype(float) 290 | 291 | frequency, volume = await fft_analysis(np_audio) 292 | 293 | hue = int(frequency / freq_range[1] * 360) if frequency > 0 else hue 294 | 295 | # Beat detection 296 | if is_beat(volume): 297 | # Flash on beat 298 | brightness = MAX_BRIGHTNESS 299 | saturation = MAX_SATURATION 300 | 301 | # Schedule return to rhythm-based brightness 302 | asyncio.create_task(fade_back(d, flash_duration)) 303 | else: 304 | # Base rhythm mode 305 | brightness = int(np.clip(volume * 10, 0, 1000)) # Map volume to brightness 306 | saturation = 1000 307 | 308 | value = generate_dp27_payload(0, hue, saturation, brightness) # Full brightness 309 | logging.info(f"Sending {value} (" + \ 310 | f"H: {hue:03d} " + \ 311 | f"S: {saturation:04d} " + \ 312 | f"V: {brightness:04d} " + \ 313 | f"vol: {volume:10.2f} dB " + \ 314 | f"freq: {frequency:10.2f} Hz) " + \ 315 | f"to {device_name}") 316 | payload = d.generate_payload(tinytuya.CONTROL, {"27": value}) 317 | d.send(payload) 318 | 319 | except Exception as e: 320 | logging.error(f"Error controlling {device_name or 'unknown device'}: {e}") 321 | d.set_white() 322 | raise 323 | 324 | async def music_mode(request_data): 325 | global stop_event 326 | 327 | stop = request_data.get('stop', False) 328 | all_devices = request_data.get('all', False) 329 | devices_names = request_data.get('devices', []) 330 | 331 | if stop: 332 | stop_event.set() 333 | return jsonify({"status": "success", "message": "Music mode stopped"}) 334 | 335 | stop_event.clear() 336 | selected_devices = [d for d in devices if all_devices or d.get('name') in devices_names] 337 | if not selected_devices: 338 | return jsonify({"status": "error", "message": "No matching devices found"}), 404 339 | 340 | minf = 10 341 | maxf = 20000 342 | step = (maxf - minf) // len(selected_devices) 343 | frequency_ranges = [(minf + i * step, minf + (i + 1) * step) for i in range(len(selected_devices))] 344 | 345 | asyncio.create_task(audio_reader()) 346 | 347 | tasks = [] 348 | for device, freq_range in zip(selected_devices, frequency_ranges): 349 | task = asyncio.create_task(audio_consumer(device, freq_range)) 350 | tasks.append(task) 351 | 352 | return jsonify({"status": "success", "message": "Music mode started"}) 353 | 354 | @app.route('/list', methods=['GET']) 355 | async def device_list(): 356 | await load_devices() 357 | return jsonify([{ 'name': d.get('name'), 'id': d['id'], 'ip': d['ip'], 'version': d["ver"] } for d in devices]) 358 | 359 | @app.route('/turn_on', methods=['POST']) 360 | async def turn_on(): 361 | request_data = await request.get_json() 362 | return await handle_action_over_devices('turn_on', request_data) 363 | 364 | @app.route('/turn_off', methods=['POST']) 365 | async def turn_off(): 366 | request_data = await request.get_json() 367 | return await handle_action_over_devices('turn_off', request_data) 368 | 369 | @app.route('/set_mode', methods=['POST']) 370 | async def set_mode(): 371 | request_data = await request.get_json() 372 | mode = request_data.get('mode') 373 | return await handle_action_over_devices('set_mode', request_data, mode) 374 | 375 | @app.route('/set_brightness', methods=['POST']) 376 | async def set_brightness(): 377 | request_data = await request.get_json() 378 | brightness = request_data.get('brightness') 379 | return await handle_action_over_devices('set_brightness', request_data, brightness) 380 | 381 | @app.route('/set_temperature', methods=['POST']) 382 | async def set_temperature(): 383 | request_data = await request.get_json() 384 | temperature = request_data.get('temperature') 385 | return await handle_action_over_devices('set_colourtemp', request_data, temperature) 386 | 387 | @app.route('/set_color', methods=['POST']) 388 | async def set_color(): 389 | request_data = await request.get_json() 390 | color_input = request_data.get('color') 391 | r, g, b = Color(color_input).rgb 392 | rgb = int(255 * r), int(255 * g), int(255 * b) 393 | return await handle_action_over_devices('set_colour', request_data, *rgb) 394 | 395 | @app.route('/music', methods=['POST']) 396 | async def music(): 397 | request_data = await request.get_json() 398 | return await music_mode(request_data) 399 | 400 | def main(): 401 | asyncio.run(load_devices()) 402 | logging.info("Daemon started") 403 | app.run(host='0.0.0.0', port=5000, debug=False) 404 | 405 | if __name__ == '__main__': 406 | main() 407 | ```