#
tokens: 7730/50000 8/8 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```