yeelight-lan 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yeelight_lan-0.2.2/LICENSE +21 -0
- yeelight_lan-0.2.2/PKG-INFO +76 -0
- yeelight_lan-0.2.2/README.md +59 -0
- yeelight_lan-0.2.2/pyproject.toml +21 -0
- yeelight_lan-0.2.2/setup.cfg +4 -0
- yeelight_lan-0.2.2/tests/test_yeelight.py +93 -0
- yeelight_lan-0.2.2/yeelight_lan/__init__.py +272 -0
- yeelight_lan-0.2.2/yeelight_lan/emulator.py +350 -0
- yeelight_lan-0.2.2/yeelight_lan.egg-info/PKG-INFO +76 -0
- yeelight_lan-0.2.2/yeelight_lan.egg-info/SOURCES.txt +11 -0
- yeelight_lan-0.2.2/yeelight_lan.egg-info/dependency_links.txt +1 -0
- yeelight_lan-0.2.2/yeelight_lan.egg-info/requires.txt +1 -0
- yeelight_lan-0.2.2/yeelight_lan.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nowplaylab (tryshyzov and Claude)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yeelight-lan
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Yeelight over the home network: discovery, commands and music mode, plus a strip emulator
|
|
5
|
+
Author: nowplaylab
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nowplaylab/yeelight-lan
|
|
8
|
+
Project-URL: Issues, https://github.com/nowplaylab/yeelight-lan/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: aiohttp>=3.8
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# yeelight-lan
|
|
19
|
+
|
|
20
|
+
**English** | [Русский](https://github.com/nowplaylab/yeelight-lan/blob/main/README.ru.md)
|
|
21
|
+
|
|
22
|
+
Control Yeelight lamps and light strips over your home network from Python: discovery, colour, brightness and **music mode** for flashing to the beat. No cloud, no account. Comes with a **strip emulator**, so you can build and test without the hardware.
|
|
23
|
+
|
|
24
|
+
Made by [nowplaylab](https://github.com/nowplaylab) for [NowPlaying](https://github.com/nowplaylab/nowplaying-display), where a strip follows the album cover and pulses with the bass.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pip install git+https://github.com/nowplaylab/yeelight-lan@v0.2.2
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Python 3.9+. Turn on **LAN Control** for the device in the Yeelight app first.
|
|
33
|
+
|
|
34
|
+
## Use
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
import yeelight_lan
|
|
39
|
+
|
|
40
|
+
devices = yeelight_lan.discover() # [Device(id, ip, name, model, ...)]
|
|
41
|
+
bulb = yeelight_lan.Bulb(devices[0].ip)
|
|
42
|
+
|
|
43
|
+
async def main():
|
|
44
|
+
await bulb.set_rgb(0xff6600) # smooth change, 500 ms
|
|
45
|
+
await bulb.set_bright(70)
|
|
46
|
+
print(await bulb.get_props('power', 'bright', 'rgb'))
|
|
47
|
+
|
|
48
|
+
# music mode: the device connects to you and takes commands with no reply and no rate limit
|
|
49
|
+
async with bulb.music() as m:
|
|
50
|
+
for level in (20, 100, 40, 100):
|
|
51
|
+
m.send('set_bright', [level, 'sudden', 30])
|
|
52
|
+
await asyncio.sleep(0.25)
|
|
53
|
+
|
|
54
|
+
asyncio.run(main())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Plain commands are limited by the device to **60 per minute per connection**; `Bulb` opens a short connection per command, which also survives Wi-Fi drops and device restarts. For anything faster, use `MusicMode`.
|
|
58
|
+
|
|
59
|
+
## Emulator
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
python -m yeelight_lan.emulator
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Pretends to be a Yeelight strip with LAN control on: answers discovery, takes commands on port 55443, enforces the 60/min limit and supports music mode. Open `http://<computer address>:47850` on any device at home to see the strip glow with whatever it was sent, plus a log of commands.
|
|
66
|
+
|
|
67
|
+
From tests:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
strip = await yeelight_lan.emulator.start(port=0, web=None, ssdp=False)
|
|
71
|
+
bulb = yeelight_lan.Bulb('127.0.0.1', strip.port)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Licence
|
|
75
|
+
|
|
76
|
+
MIT.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# yeelight-lan
|
|
2
|
+
|
|
3
|
+
**English** | [Русский](https://github.com/nowplaylab/yeelight-lan/blob/main/README.ru.md)
|
|
4
|
+
|
|
5
|
+
Control Yeelight lamps and light strips over your home network from Python: discovery, colour, brightness and **music mode** for flashing to the beat. No cloud, no account. Comes with a **strip emulator**, so you can build and test without the hardware.
|
|
6
|
+
|
|
7
|
+
Made by [nowplaylab](https://github.com/nowplaylab) for [NowPlaying](https://github.com/nowplaylab/nowplaying-display), where a strip follows the album cover and pulses with the bass.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install git+https://github.com/nowplaylab/yeelight-lan@v0.2.2
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Python 3.9+. Turn on **LAN Control** for the device in the Yeelight app first.
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import asyncio
|
|
21
|
+
import yeelight_lan
|
|
22
|
+
|
|
23
|
+
devices = yeelight_lan.discover() # [Device(id, ip, name, model, ...)]
|
|
24
|
+
bulb = yeelight_lan.Bulb(devices[0].ip)
|
|
25
|
+
|
|
26
|
+
async def main():
|
|
27
|
+
await bulb.set_rgb(0xff6600) # smooth change, 500 ms
|
|
28
|
+
await bulb.set_bright(70)
|
|
29
|
+
print(await bulb.get_props('power', 'bright', 'rgb'))
|
|
30
|
+
|
|
31
|
+
# music mode: the device connects to you and takes commands with no reply and no rate limit
|
|
32
|
+
async with bulb.music() as m:
|
|
33
|
+
for level in (20, 100, 40, 100):
|
|
34
|
+
m.send('set_bright', [level, 'sudden', 30])
|
|
35
|
+
await asyncio.sleep(0.25)
|
|
36
|
+
|
|
37
|
+
asyncio.run(main())
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Plain commands are limited by the device to **60 per minute per connection**; `Bulb` opens a short connection per command, which also survives Wi-Fi drops and device restarts. For anything faster, use `MusicMode`.
|
|
41
|
+
|
|
42
|
+
## Emulator
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
python -m yeelight_lan.emulator
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Pretends to be a Yeelight strip with LAN control on: answers discovery, takes commands on port 55443, enforces the 60/min limit and supports music mode. Open `http://<computer address>:47850` on any device at home to see the strip glow with whatever it was sent, plus a log of commands.
|
|
49
|
+
|
|
50
|
+
From tests:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
strip = await yeelight_lan.emulator.start(port=0, web=None, ssdp=False)
|
|
54
|
+
bulb = yeelight_lan.Bulb('127.0.0.1', strip.port)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Licence
|
|
58
|
+
|
|
59
|
+
MIT.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yeelight-lan"
|
|
7
|
+
version = "0.2.2"
|
|
8
|
+
description = "Yeelight over the home network: discovery, commands and music mode, plus a strip emulator"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = ["aiohttp>=3.8"]
|
|
13
|
+
authors = [{name = "nowplaylab"}]
|
|
14
|
+
classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Framework :: AsyncIO"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/nowplaylab/yeelight-lan"
|
|
18
|
+
Issues = "https://github.com/nowplaylab/yeelight-lan/issues"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
packages = ["yeelight_lan"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Проверки на встроенном эмуляторе: команды, лимит частоты, музыкальный режим, поиск."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import time
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import yeelight_lan
|
|
7
|
+
from yeelight_lan import emulator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class YeelightTest(unittest.IsolatedAsyncioTestCase):
|
|
11
|
+
async def asyncSetUp(self):
|
|
12
|
+
self.strip = await emulator.start(port=0, web=None, ssdp=False, host='127.0.0.1')
|
|
13
|
+
self.bulb = yeelight_lan.Bulb('127.0.0.1', self.strip.port)
|
|
14
|
+
|
|
15
|
+
async def asyncTearDown(self):
|
|
16
|
+
self.strip.server.close()
|
|
17
|
+
|
|
18
|
+
async def test_commands(self):
|
|
19
|
+
await self.bulb.set_rgb(0x123456)
|
|
20
|
+
await self.bulb.set_bright(42)
|
|
21
|
+
props = await self.bulb.get_props('rgb', 'bright', 'color_mode', 'power')
|
|
22
|
+
self.assertEqual(props, {'rgb': str(0x123456), 'bright': '42', 'color_mode': '1', 'power': 'on'})
|
|
23
|
+
await self.bulb.set_ct(2700)
|
|
24
|
+
self.assertEqual((await self.bulb.get_props('color_mode', 'ct')), {'color_mode': '2', 'ct': '2700'})
|
|
25
|
+
await self.bulb.set_power(False)
|
|
26
|
+
self.assertEqual(self.strip.power, 'off')
|
|
27
|
+
|
|
28
|
+
async def test_unknown_method(self):
|
|
29
|
+
with self.assertRaises(yeelight_lan.YeelightError):
|
|
30
|
+
await self.bulb.command('no_such_method', [])
|
|
31
|
+
|
|
32
|
+
async def test_no_device(self):
|
|
33
|
+
with self.assertRaises(yeelight_lan.YeelightError):
|
|
34
|
+
await yeelight_lan.Bulb('127.0.0.1', 1, timeout=1).set_rgb(1)
|
|
35
|
+
|
|
36
|
+
async def test_quota(self):
|
|
37
|
+
# лимит — 60 команд в минуту на одно соединение: шлём 61 по одному соединению
|
|
38
|
+
reader, writer = await asyncio.open_connection('127.0.0.1', self.strip.port)
|
|
39
|
+
for i in range(61):
|
|
40
|
+
writer.write(b'{"id":%d,"method":"set_bright","params":[50,"sudden",0]}\r\n' % (i + 1))
|
|
41
|
+
await writer.drain()
|
|
42
|
+
errors = 0
|
|
43
|
+
for _ in range(61 * 2):
|
|
44
|
+
line = await asyncio.wait_for(reader.readline(), 3)
|
|
45
|
+
errors += b'quota' in line
|
|
46
|
+
if errors:
|
|
47
|
+
break
|
|
48
|
+
writer.close()
|
|
49
|
+
self.assertEqual(errors, 1)
|
|
50
|
+
|
|
51
|
+
async def test_music_mode(self):
|
|
52
|
+
async with self.bulb.music(local_ip='127.0.0.1') as m:
|
|
53
|
+
self.assertTrue(m.alive)
|
|
54
|
+
for b in (10, 90, 30):
|
|
55
|
+
self.assertTrue(m.send('set_bright', [b, 'sudden', 0]))
|
|
56
|
+
await asyncio.sleep(0.3)
|
|
57
|
+
self.assertEqual(self.strip.bright, 30)
|
|
58
|
+
self.assertEqual(self.strip.music_cmds, 3)
|
|
59
|
+
|
|
60
|
+
async def test_music_mode_is_immediate(self):
|
|
61
|
+
# мелкие команды подряд не должны копиться в пачки: каждая доходит почти сразу
|
|
62
|
+
async with self.bulb.music(local_ip='127.0.0.1') as m:
|
|
63
|
+
await asyncio.sleep(0.1)
|
|
64
|
+
delays = []
|
|
65
|
+
for i in range(20):
|
|
66
|
+
n = len(self.strip.music_times)
|
|
67
|
+
sent = time.perf_counter()
|
|
68
|
+
m.send('set_bright', [10 + i, 'sudden', 0])
|
|
69
|
+
while len(self.strip.music_times) == n and time.perf_counter() - sent < 1:
|
|
70
|
+
await asyncio.sleep(0.001)
|
|
71
|
+
delays.append(self.strip.music_times[-1] - sent)
|
|
72
|
+
await asyncio.sleep(0.01)
|
|
73
|
+
self.assertLess(max(delays), 0.05, delays)
|
|
74
|
+
|
|
75
|
+
async def test_discover(self):
|
|
76
|
+
strip = await emulator.start(name='Поиск', port=0, web=None, ssdp=True)
|
|
77
|
+
await asyncio.sleep(0.2)
|
|
78
|
+
try:
|
|
79
|
+
found = await asyncio.get_running_loop().run_in_executor(
|
|
80
|
+
None, lambda: yeelight_lan.discover(1.0, target=('127.0.0.1', 1982)))
|
|
81
|
+
finally:
|
|
82
|
+
strip.server.close()
|
|
83
|
+
if strip.ssdp:
|
|
84
|
+
strip.ssdp.close()
|
|
85
|
+
mine = [d for d in found if d.id == strip.id]
|
|
86
|
+
if not mine:
|
|
87
|
+
self.skipTest('порт поиска 1982 занят (например, уже запущен эмулятор)')
|
|
88
|
+
d = mine[0]
|
|
89
|
+
self.assertEqual((d.name, d.port, d.color, d.music), ('Поиск', strip.port, True, True))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == '__main__':
|
|
93
|
+
unittest.main()
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Yeelight по домашней сети: поиск, команды и музыкальный режим (без облака и аккаунта).
|
|
2
|
+
|
|
3
|
+
На ленте или лампе в приложении Yeelight должно быть включено «Управление по локальной сети»
|
|
4
|
+
(LAN control). Протокол — Yeelight WiFi Light Inter-Operation Specification:
|
|
5
|
+
поиск SSDP на 239.255.255.250:1982, команды — строки JSON по TCP 55443.
|
|
6
|
+
|
|
7
|
+
import asyncio, yeelight_lan
|
|
8
|
+
devices = yeelight_lan.discover()
|
|
9
|
+
bulb = yeelight_lan.Bulb(devices[0].ip)
|
|
10
|
+
asyncio.run(bulb.set_rgb(0xff6600))
|
|
11
|
+
|
|
12
|
+
Обычные команды устройство принимает не чаще 60 в минуту на соединение. Для быстрых изменений
|
|
13
|
+
(мигание в такт) есть музыкальный режим — MusicMode: устройство само подключается к программе
|
|
14
|
+
и принимает команды без ответа и без ограничения частоты.
|
|
15
|
+
|
|
16
|
+
Эмулятор для проверки без железа: python -m yeelight_lan.emulator
|
|
17
|
+
"""
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import socket
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
__version__ = '0.2.2'
|
|
25
|
+
__all__ = ['Device', 'discover', 'local_ips', 'Bulb', 'MusicMode', 'YeelightError']
|
|
26
|
+
|
|
27
|
+
SSDP = ('239.255.255.250', 1982)
|
|
28
|
+
SEARCH = ('M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1982\r\nMAN: "ssdp:discover"\r\n'
|
|
29
|
+
'ST: wifi_bulb\r\n\r\n').encode()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _no_delay(writer):
|
|
33
|
+
"""Отправлять каждую команду сразу: без этого система копит мелкие пакеты (алгоритм Нейгла)
|
|
34
|
+
и команда может задержаться на десятки–сотни миллисекунд — для мигания в такт это заметно."""
|
|
35
|
+
sock = writer.get_extra_info('socket')
|
|
36
|
+
if sock is not None:
|
|
37
|
+
try:
|
|
38
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class YeelightError(Exception):
|
|
44
|
+
"""Устройство не ответило или вернуло ошибку (например, «client quota exceeded»)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Device:
|
|
49
|
+
id: str
|
|
50
|
+
ip: str
|
|
51
|
+
port: int = 55443
|
|
52
|
+
name: str = ''
|
|
53
|
+
model: str = ''
|
|
54
|
+
support: list = field(default_factory=list)
|
|
55
|
+
props: dict = field(default_factory=dict) # power, bright, rgb, ct, … из ответа на поиск
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def color(self):
|
|
59
|
+
"""Умеет ли цвет (а не только оттенки белого)."""
|
|
60
|
+
return 'set_rgb' in self.support or 'set_hsv' in self.support
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def music(self):
|
|
64
|
+
"""Умеет ли музыкальный режим."""
|
|
65
|
+
return 'set_music' in self.support
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse(data):
|
|
69
|
+
h = {}
|
|
70
|
+
for line in data.decode(errors='replace').split('\r\n')[1:]:
|
|
71
|
+
if ':' in line:
|
|
72
|
+
k, v = line.split(':', 1)
|
|
73
|
+
h[k.strip().lower()] = v.strip()
|
|
74
|
+
loc = h.get('location', '')
|
|
75
|
+
if not loc.startswith('yeelight://') or not h.get('id'):
|
|
76
|
+
return None
|
|
77
|
+
host, _, port = loc[len('yeelight://'):].partition(':')
|
|
78
|
+
props = {k: h[k] for k in ('power', 'bright', 'color_mode', 'ct', 'rgb', 'hue', 'sat') if k in h}
|
|
79
|
+
return Device(id=h['id'], ip=host, port=int(port or 55443), name=h.get('name', ''), model=h.get('model', ''),
|
|
80
|
+
support=h.get('support', '').split(), props=props)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def local_ips():
|
|
84
|
+
"""Адреса IPv4 этого компьютера во всех сетях (Wi-Fi, кабель, VPN…), кроме служебных 169.254.*."""
|
|
85
|
+
ips = set()
|
|
86
|
+
try:
|
|
87
|
+
ips.update(i[4][0] for i in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET))
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
for probe in ('192.168.0.1', '10.0.0.1', '8.8.8.8'): # адрес, через который ходит обычный трафик
|
|
91
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
92
|
+
try:
|
|
93
|
+
s.connect((probe, 1))
|
|
94
|
+
ips.add(s.getsockname()[0])
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
finally:
|
|
98
|
+
s.close()
|
|
99
|
+
return sorted(ip for ip in ips if not ip.startswith(('127.', '169.254.', '0.')))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _send_search(s, target):
|
|
103
|
+
"""Поиск в каждую сеть по отдельности: иначе система шлёт его только в одну — например,
|
|
104
|
+
в туннель VPN, — и лента в домашней сети его не услышит."""
|
|
105
|
+
if target != SSDP:
|
|
106
|
+
s.sendto(SEARCH, target)
|
|
107
|
+
return
|
|
108
|
+
for ip in local_ips() + ['127.0.0.1']:
|
|
109
|
+
try:
|
|
110
|
+
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ip))
|
|
111
|
+
s.sendto(SEARCH, target)
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def discover(timeout=2.0, target=SSDP):
|
|
117
|
+
"""Найти устройства Yeelight в сети (блокирующий вызов, ~timeout секунд)."""
|
|
118
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
119
|
+
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
|
120
|
+
s.settimeout(0.3)
|
|
121
|
+
s.bind(('', 0))
|
|
122
|
+
found = {}
|
|
123
|
+
try:
|
|
124
|
+
end = time.time() + timeout
|
|
125
|
+
_send_search(s, target)
|
|
126
|
+
again = False
|
|
127
|
+
while time.time() < end:
|
|
128
|
+
if not again and time.time() > end - timeout / 2:
|
|
129
|
+
_send_search(s, target) # UDP может потеряться — спросим ещё раз
|
|
130
|
+
again = True
|
|
131
|
+
try:
|
|
132
|
+
data, _ = s.recvfrom(4096)
|
|
133
|
+
except socket.timeout:
|
|
134
|
+
continue
|
|
135
|
+
d = _parse(data)
|
|
136
|
+
if d:
|
|
137
|
+
found[d.id] = d
|
|
138
|
+
finally:
|
|
139
|
+
s.close()
|
|
140
|
+
return list(found.values())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Bulb:
|
|
144
|
+
"""Устройство Yeelight по адресу. Каждая команда — своё короткое соединение: так проще
|
|
145
|
+
переживать обрывы Wi-Fi и перезапуски устройства."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, ip, port=55443, timeout=3.0):
|
|
148
|
+
self.ip, self.port, self.timeout = ip, port, timeout
|
|
149
|
+
self._id = 0
|
|
150
|
+
|
|
151
|
+
async def command(self, method, params=()):
|
|
152
|
+
"""Отправить команду, вернуть result (список). YeelightError при ошибке или без ответа."""
|
|
153
|
+
try:
|
|
154
|
+
reader, writer = await asyncio.wait_for(asyncio.open_connection(self.ip, self.port), self.timeout)
|
|
155
|
+
except (OSError, asyncio.TimeoutError) as e:
|
|
156
|
+
raise YeelightError(f'нет связи с {self.ip}: {e}') from None
|
|
157
|
+
_no_delay(writer)
|
|
158
|
+
try:
|
|
159
|
+
self._id += 1
|
|
160
|
+
cid = self._id
|
|
161
|
+
writer.write(json.dumps({'id': cid, 'method': method, 'params': list(params)}).encode() + b'\r\n')
|
|
162
|
+
await writer.drain()
|
|
163
|
+
end = time.time() + self.timeout
|
|
164
|
+
while time.time() < end:
|
|
165
|
+
line = await asyncio.wait_for(reader.readline(), self.timeout)
|
|
166
|
+
if not line:
|
|
167
|
+
break
|
|
168
|
+
msg = json.loads(line)
|
|
169
|
+
if msg.get('id') == cid:
|
|
170
|
+
if 'error' in msg:
|
|
171
|
+
raise YeelightError(str(msg['error'].get('message')))
|
|
172
|
+
return msg.get('result')
|
|
173
|
+
except (OSError, ValueError, asyncio.TimeoutError) as e:
|
|
174
|
+
raise YeelightError(f'{method}: {e}') from None
|
|
175
|
+
finally:
|
|
176
|
+
writer.close()
|
|
177
|
+
raise YeelightError(f'{method}: нет ответа')
|
|
178
|
+
|
|
179
|
+
# удобные обёртки; effect — 'smooth' или 'sudden', duration — мс
|
|
180
|
+
async def get_props(self, *names):
|
|
181
|
+
names = names or ('power', 'bright', 'color_mode', 'rgb', 'ct', 'hue', 'sat', 'name')
|
|
182
|
+
return dict(zip(names, await self.command('get_prop', names)))
|
|
183
|
+
|
|
184
|
+
async def set_power(self, on, effect='smooth', duration=500):
|
|
185
|
+
return await self.command('set_power', ['on' if on else 'off', effect, duration])
|
|
186
|
+
|
|
187
|
+
async def set_rgb(self, rgb, effect='smooth', duration=500):
|
|
188
|
+
return await self.command('set_rgb', [int(rgb) & 0xffffff, effect, duration])
|
|
189
|
+
|
|
190
|
+
async def set_hsv(self, hue, sat, effect='smooth', duration=500):
|
|
191
|
+
return await self.command('set_hsv', [int(hue) % 360, max(0, min(100, int(sat))), effect, duration])
|
|
192
|
+
|
|
193
|
+
async def set_ct(self, kelvin, effect='smooth', duration=500):
|
|
194
|
+
return await self.command('set_ct_abx', [int(kelvin), effect, duration])
|
|
195
|
+
|
|
196
|
+
async def set_bright(self, percent, effect='smooth', duration=500):
|
|
197
|
+
return await self.command('set_bright', [max(1, min(100, int(percent))), effect, duration])
|
|
198
|
+
|
|
199
|
+
def music(self, local_ip=None):
|
|
200
|
+
return MusicMode(self, local_ip)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class MusicMode:
|
|
204
|
+
"""Музыкальный режим: устройство подключается к нам и принимает команды без ответа и лимита.
|
|
205
|
+
|
|
206
|
+
async with bulb.music() as m:
|
|
207
|
+
m.send('set_bright', [80, 'sudden', 30])
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self, bulb, local_ip=None):
|
|
211
|
+
self.bulb = bulb
|
|
212
|
+
self.local_ip = local_ip
|
|
213
|
+
self._server = None
|
|
214
|
+
self._conn = None
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def alive(self):
|
|
218
|
+
if not self._conn:
|
|
219
|
+
return False
|
|
220
|
+
r, w = self._conn
|
|
221
|
+
return not (w.is_closing() or r.at_eof())
|
|
222
|
+
|
|
223
|
+
async def start(self, timeout=5.0):
|
|
224
|
+
got = asyncio.get_running_loop().create_future()
|
|
225
|
+
|
|
226
|
+
def on_conn(r, w):
|
|
227
|
+
if got.done():
|
|
228
|
+
w.close()
|
|
229
|
+
else:
|
|
230
|
+
_no_delay(w)
|
|
231
|
+
got.set_result((r, w))
|
|
232
|
+
self._server = await asyncio.start_server(on_conn, '0.0.0.0', 0)
|
|
233
|
+
port = self._server.sockets[0].getsockname()[1]
|
|
234
|
+
ip = self.local_ip
|
|
235
|
+
if not ip: # свой адрес, через который видно устройство
|
|
236
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
237
|
+
try:
|
|
238
|
+
probe.connect((self.bulb.ip, 1))
|
|
239
|
+
ip = probe.getsockname()[0]
|
|
240
|
+
finally:
|
|
241
|
+
probe.close()
|
|
242
|
+
try:
|
|
243
|
+
await self.bulb.command('set_music', [1, ip, port])
|
|
244
|
+
self._conn = await asyncio.wait_for(got, timeout)
|
|
245
|
+
except (YeelightError, asyncio.TimeoutError) as e:
|
|
246
|
+
await self.stop()
|
|
247
|
+
raise YeelightError(f'музыкальный режим не включился: {e}') from None
|
|
248
|
+
return self
|
|
249
|
+
|
|
250
|
+
def send(self, method, params=()):
|
|
251
|
+
"""Отправить команду (без ответа). False — соединение оборвалось."""
|
|
252
|
+
if not self.alive:
|
|
253
|
+
return False
|
|
254
|
+
try:
|
|
255
|
+
self._conn[1].write(json.dumps({'id': 1, 'method': method, 'params': list(params)}).encode() + b'\r\n')
|
|
256
|
+
return True
|
|
257
|
+
except (OSError, RuntimeError):
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
async def stop(self):
|
|
261
|
+
if self._conn:
|
|
262
|
+
self._conn[1].close()
|
|
263
|
+
self._conn = None
|
|
264
|
+
if self._server:
|
|
265
|
+
self._server.close()
|
|
266
|
+
self._server = None
|
|
267
|
+
|
|
268
|
+
async def __aenter__(self):
|
|
269
|
+
return await self.start()
|
|
270
|
+
|
|
271
|
+
async def __aexit__(self, *exc):
|
|
272
|
+
await self.stop()
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Эмулятор ленты Yeelight (Lightstrip) — чтобы делать и проверять управление без настоящей ленты.
|
|
2
|
+
|
|
3
|
+
Ведёт себя как лента с включённым «Управлением по локальной сети» (LAN control):
|
|
4
|
+
- отвечает на поиск SSDP (239.255.255.250:1982, ST: wifi_bulb);
|
|
5
|
+
- принимает команды JSON по TCP 55443: set_power, toggle, set_bright, set_rgb, set_hsv,
|
|
6
|
+
set_ct_abx, start_cf/stop_cf, get_prop, set_music, set_name;
|
|
7
|
+
- как настоящая лента, ограничивает обычные команды: не больше 60 в минуту на соединение
|
|
8
|
+
(лишние получают ошибку «client quota exceeded»);
|
|
9
|
+
- «музыкальный режим» (set_music [1, host, port]): лента сама подключается к host:port и
|
|
10
|
+
принимает оттуда команды без ответа и без ограничения частоты — так делают мигание в такт.
|
|
11
|
+
|
|
12
|
+
Показ: http://<адрес компьютера>:47850 (с любого устройства дома) — полоса светится текущим цветом и яркостью, внизу журнал команд.
|
|
13
|
+
|
|
14
|
+
python -m yeelight_lan.emulator [--name "Лента"] [--port 55443] [--web 47850]
|
|
15
|
+
|
|
16
|
+
Из своих проверок: strip = await yeelight_lan.emulator.start(port=0, web=None, ssdp=False)
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import colorsys
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import socket
|
|
24
|
+
import struct
|
|
25
|
+
|
|
26
|
+
import yeelight_lan
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
from aiohttp import web
|
|
30
|
+
|
|
31
|
+
SSDP_ADDR, SSDP_PORT = '239.255.255.250', 1982
|
|
32
|
+
SUPPORT = ('get_prop set_default set_power toggle set_bright start_cf stop_cf set_scene cron_add cron_get '
|
|
33
|
+
'cron_del set_ct_abx set_rgb set_hsv set_adjust adjust_bright adjust_ct adjust_color set_music set_name')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Strip:
|
|
37
|
+
def __init__(self, name, port):
|
|
38
|
+
# постоянный номер: после перезапуска эмулятор — та же «лента», выбирать заново не надо
|
|
39
|
+
self.id = '0x' + hashlib.sha1(f'{socket.gethostname()}|{name}|{port}'.encode()).hexdigest()[:16]
|
|
40
|
+
self.name = name
|
|
41
|
+
self.port = port
|
|
42
|
+
self.power = 'on'
|
|
43
|
+
self.bright = 80
|
|
44
|
+
self.color_mode = 1 # 1 — rgb, 2 — ct, 3 — hsv
|
|
45
|
+
self.rgb = 0xff8800
|
|
46
|
+
self.ct = 4000
|
|
47
|
+
self.hue, self.sat = 30, 100
|
|
48
|
+
self.flowing = 0
|
|
49
|
+
self.music_on = 0
|
|
50
|
+
self.log = [] # (время, откуда, команда)
|
|
51
|
+
self.changes = asyncio.Event()
|
|
52
|
+
self.quota = {} # соединение -> [времена команд за минуту]
|
|
53
|
+
self.music_cmds = 0
|
|
54
|
+
self.music_started = 0
|
|
55
|
+
self.music_times = [] # time.perf_counter() прихода команд музыкального режима (для замеров)
|
|
56
|
+
|
|
57
|
+
# ---------- состояние ----------
|
|
58
|
+
|
|
59
|
+
def color_hex(self):
|
|
60
|
+
if self.color_mode == 2: # белый: приблизим цвет по температуре
|
|
61
|
+
k = max(1700, min(6500, self.ct))
|
|
62
|
+
t = (k - 1700) / 4800
|
|
63
|
+
r, g, b = 255, int(170 + 70 * t), int(80 + 175 * t)
|
|
64
|
+
return '#%02x%02x%02x' % (r, g, b)
|
|
65
|
+
if self.color_mode == 3:
|
|
66
|
+
r, g, b = colorsys.hsv_to_rgb(self.hue / 360, self.sat / 100, 1)
|
|
67
|
+
return '#%02x%02x%02x' % (int(r * 255), int(g * 255), int(b * 255))
|
|
68
|
+
return '#%06x' % self.rgb
|
|
69
|
+
|
|
70
|
+
def props(self):
|
|
71
|
+
return {'power': self.power, 'bright': str(self.bright), 'ct': str(self.ct), 'rgb': str(self.rgb),
|
|
72
|
+
'hue': str(self.hue), 'sat': str(self.sat), 'color_mode': str(self.color_mode),
|
|
73
|
+
'flowing': str(self.flowing), 'music_on': str(self.music_on), 'name': self.name}
|
|
74
|
+
|
|
75
|
+
def note(self, src, text):
|
|
76
|
+
self.log.append((time.strftime('%H:%M:%S'), src, text))
|
|
77
|
+
del self.log[:-60]
|
|
78
|
+
self.changes.set()
|
|
79
|
+
|
|
80
|
+
# ---------- команды ----------
|
|
81
|
+
|
|
82
|
+
def run(self, method, params):
|
|
83
|
+
"""-> result (список) или поднимает ValueError с текстом ошибки."""
|
|
84
|
+
if method == 'get_prop':
|
|
85
|
+
p = self.props()
|
|
86
|
+
return [p.get(k, '') for k in params]
|
|
87
|
+
if method == 'set_power':
|
|
88
|
+
self.power = params[0]
|
|
89
|
+
elif method == 'toggle':
|
|
90
|
+
self.power = 'off' if self.power == 'on' else 'on'
|
|
91
|
+
elif method == 'set_bright':
|
|
92
|
+
self.bright = max(1, min(100, int(params[0])))
|
|
93
|
+
elif method == 'set_rgb':
|
|
94
|
+
self.rgb = int(params[0]) & 0xffffff
|
|
95
|
+
self.color_mode = 1
|
|
96
|
+
self.power = 'on'
|
|
97
|
+
elif method == 'set_hsv':
|
|
98
|
+
self.hue, self.sat = int(params[0]) % 360, max(0, min(100, int(params[1])))
|
|
99
|
+
self.color_mode = 3
|
|
100
|
+
self.power = 'on'
|
|
101
|
+
elif method == 'set_ct_abx':
|
|
102
|
+
self.ct = max(1700, min(6500, int(params[0])))
|
|
103
|
+
self.color_mode = 2
|
|
104
|
+
self.power = 'on'
|
|
105
|
+
elif method == 'start_cf':
|
|
106
|
+
self.flowing = 1
|
|
107
|
+
elif method == 'stop_cf':
|
|
108
|
+
self.flowing = 0
|
|
109
|
+
elif method == 'set_name':
|
|
110
|
+
self.name = str(params[0])
|
|
111
|
+
elif method == 'set_music':
|
|
112
|
+
pass # обрабатывается в соединении
|
|
113
|
+
else:
|
|
114
|
+
raise ValueError('method not supported')
|
|
115
|
+
return ['ok']
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _Ssdp(asyncio.DatagramProtocol):
|
|
119
|
+
def __init__(self, strip):
|
|
120
|
+
self.strip = strip
|
|
121
|
+
self.transport = None
|
|
122
|
+
|
|
123
|
+
def connection_made(self, transport):
|
|
124
|
+
self.transport = transport
|
|
125
|
+
|
|
126
|
+
def datagram_received(self, data, addr):
|
|
127
|
+
text = data.decode(errors='replace')
|
|
128
|
+
if 'M-SEARCH' not in text or 'wifi_bulb' not in text:
|
|
129
|
+
return
|
|
130
|
+
# адрес, с которого нас видно этому клиенту
|
|
131
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
132
|
+
try:
|
|
133
|
+
probe.connect((addr[0], 1))
|
|
134
|
+
ip = probe.getsockname()[0]
|
|
135
|
+
finally:
|
|
136
|
+
probe.close()
|
|
137
|
+
strip, p = self.strip, self.strip.props()
|
|
138
|
+
reply = ('HTTP/1.1 200 OK\r\nCache-Control: max-age=3600\r\nDate: \r\nExt: \r\n'
|
|
139
|
+
f'Location: yeelight://{ip}:{strip.port}\r\nServer: POSIX UPnP/1.0 YGLC/1\r\n'
|
|
140
|
+
f'id: {strip.id}\r\nmodel: strip6\r\nfw_ver: 18\r\nsupport: {SUPPORT}\r\n'
|
|
141
|
+
f'power: {p["power"]}\r\nbright: {p["bright"]}\r\ncolor_mode: {p["color_mode"]}\r\n'
|
|
142
|
+
f'ct: {p["ct"]}\r\nrgb: {p["rgb"]}\r\nhue: {p["hue"]}\r\nsat: {p["sat"]}\r\nname: {p["name"]}\r\n\r\n')
|
|
143
|
+
self.transport.sendto(reply.encode(), addr)
|
|
144
|
+
strip.note(addr[0], 'поиск в сети → ответила')
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def ssdp(strip):
|
|
148
|
+
"""Отвечаем на поиск ленты в сети (DatagramProtocol — работает на Python 3.9+ и на всех системах)."""
|
|
149
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
150
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
151
|
+
sock.bind(('', SSDP_PORT))
|
|
152
|
+
joined = set()
|
|
153
|
+
|
|
154
|
+
def join():
|
|
155
|
+
# слушаем поиск в каждой сети компьютера (а не только в той, что система считает главной —
|
|
156
|
+
# с VPN это туннель); сети появляются и пропадают, поэтому проверяем раз в 30 с
|
|
157
|
+
for ip in yeelight_lan.local_ips() + ['127.0.0.1']:
|
|
158
|
+
if ip in joined:
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
|
|
162
|
+
socket.inet_aton(SSDP_ADDR) + socket.inet_aton(ip))
|
|
163
|
+
joined.add(ip)
|
|
164
|
+
except OSError:
|
|
165
|
+
pass
|
|
166
|
+
join()
|
|
167
|
+
sock.setblocking(False)
|
|
168
|
+
loop = asyncio.get_running_loop()
|
|
169
|
+
transport, _ = await loop.create_datagram_endpoint(lambda: _Ssdp(strip), sock=sock)
|
|
170
|
+
|
|
171
|
+
async def rejoin():
|
|
172
|
+
while not transport.is_closing():
|
|
173
|
+
await asyncio.sleep(30)
|
|
174
|
+
join()
|
|
175
|
+
strip.ssdp_task = asyncio.create_task(rejoin())
|
|
176
|
+
return transport
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def handle(strip, reader, writer):
|
|
180
|
+
peer = writer.get_extra_info('peername')[0]
|
|
181
|
+
times = []
|
|
182
|
+
strip.note(peer, 'подключение')
|
|
183
|
+
try:
|
|
184
|
+
while True:
|
|
185
|
+
line = await reader.readline()
|
|
186
|
+
if not line:
|
|
187
|
+
break
|
|
188
|
+
try:
|
|
189
|
+
cmd = json.loads(line)
|
|
190
|
+
cid, method, params = cmd.get('id'), cmd['method'], cmd.get('params', [])
|
|
191
|
+
except (ValueError, KeyError):
|
|
192
|
+
writer.write(b'{"id":0,"error":{"code":-1,"message":"invalid command"}}\r\n')
|
|
193
|
+
continue
|
|
194
|
+
now = time.time()
|
|
195
|
+
times[:] = [t for t in times if now - t < 60]
|
|
196
|
+
if len(times) >= 60 and method != 'get_prop':
|
|
197
|
+
writer.write(json.dumps({'id': cid, 'error': {'code': -1, 'message': 'client quota exceeded'}}).encode() + b'\r\n')
|
|
198
|
+
strip.note(peer, f'{method}: отказ — больше 60 команд в минуту')
|
|
199
|
+
continue
|
|
200
|
+
times.append(now)
|
|
201
|
+
if method == 'set_music':
|
|
202
|
+
if params and int(params[0]) == 1:
|
|
203
|
+
asyncio.create_task(music(strip, params[1], int(params[2])))
|
|
204
|
+
result = ['ok']
|
|
205
|
+
else:
|
|
206
|
+
strip.music_on = 0
|
|
207
|
+
result = ['ok']
|
|
208
|
+
else:
|
|
209
|
+
try:
|
|
210
|
+
result = strip.run(method, params)
|
|
211
|
+
except (ValueError, IndexError, TypeError) as e:
|
|
212
|
+
writer.write(json.dumps({'id': cid, 'error': {'code': -1, 'message': str(e)}}).encode() + b'\r\n')
|
|
213
|
+
continue
|
|
214
|
+
writer.write(json.dumps({'id': cid, 'result': result}).encode() + b'\r\n')
|
|
215
|
+
if method != 'get_prop':
|
|
216
|
+
writer.write(json.dumps({'method': 'props', 'params': strip.props()}).encode() + b'\r\n')
|
|
217
|
+
strip.note(peer, f'{method} {params}')
|
|
218
|
+
await writer.drain()
|
|
219
|
+
except (ConnectionError, asyncio.IncompleteReadError):
|
|
220
|
+
pass
|
|
221
|
+
finally:
|
|
222
|
+
strip.note(peer, 'отключение')
|
|
223
|
+
writer.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def music(strip, host, port):
|
|
227
|
+
"""Музыкальный режим: лента подключается к программе и слушает команды без ответов и лимита."""
|
|
228
|
+
try:
|
|
229
|
+
reader, writer = await asyncio.open_connection(host, port)
|
|
230
|
+
except OSError as e:
|
|
231
|
+
strip.note(host, f'музыкальный режим: не подключилась ({e})')
|
|
232
|
+
return
|
|
233
|
+
strip.music_on = 1
|
|
234
|
+
strip.music_started = time.time()
|
|
235
|
+
strip.music_cmds = 0
|
|
236
|
+
strip.note(host, f'музыкальный режим: подключилась к {host}:{port}')
|
|
237
|
+
try:
|
|
238
|
+
while strip.music_on:
|
|
239
|
+
line = await reader.readline()
|
|
240
|
+
if not line:
|
|
241
|
+
break
|
|
242
|
+
try:
|
|
243
|
+
cmd = json.loads(line)
|
|
244
|
+
strip.music_times.append(time.perf_counter())
|
|
245
|
+
del strip.music_times[:-500]
|
|
246
|
+
strip.run(cmd['method'], cmd.get('params', []))
|
|
247
|
+
strip.music_cmds += 1
|
|
248
|
+
strip.changes.set()
|
|
249
|
+
except (ValueError, KeyError, IndexError, TypeError):
|
|
250
|
+
pass
|
|
251
|
+
finally:
|
|
252
|
+
strip.music_on = 0
|
|
253
|
+
strip.note(host, f'музыкальный режим: закрыт ({strip.music_cmds} команд)')
|
|
254
|
+
writer.close()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
PAGE = '''<!doctype html><html lang="ru"><meta charset="utf-8"><title>Эмулятор ленты Yeelight</title>
|
|
258
|
+
<style>
|
|
259
|
+
body{margin:0;background:#0d0d0f;color:#eee;font:15px/1.4 system-ui,sans-serif;padding:32px}
|
|
260
|
+
h1{font-size:20px;margin:0 0 4px}.sub{color:#888;margin-bottom:28px}
|
|
261
|
+
.wall{height:220px;border-radius:14px;background:#141417;position:relative;overflow:hidden}
|
|
262
|
+
.glow{position:absolute;inset:0;transition:background .12s, opacity .12s}
|
|
263
|
+
.strip{position:absolute;left:6%;right:6%;top:46%;height:14px;border-radius:7px;transition:background .12s, box-shadow .12s, opacity .12s}
|
|
264
|
+
.stats{display:flex;gap:28px;margin:18px 0;color:#aaa}.stats b{color:#fff;font-weight:600}
|
|
265
|
+
.log{font:13px/1.6 ui-monospace,Consolas,monospace;color:#9a9aa5;max-height:300px;overflow:auto;background:#141417;border-radius:10px;padding:12px 14px}
|
|
266
|
+
</style>
|
|
267
|
+
<h1 id="name">Лента</h1><div class="sub">Эмулятор Yeelight Lightstrip · LAN 55443 · поиск SSDP</div>
|
|
268
|
+
<div class="wall"><div class="glow" id="glow"></div><div class="strip" id="strip"></div></div>
|
|
269
|
+
<div class="stats"><span>Питание: <b id="power"></b></span><span>Цвет: <b id="color"></b></span><span>Яркость: <b id="bright"></b></span>
|
|
270
|
+
<span>Музыкальный режим: <b id="music"></b></span></div>
|
|
271
|
+
<div class="log" id="log"></div>
|
|
272
|
+
<script>
|
|
273
|
+
const $=id=>document.getElementById(id);
|
|
274
|
+
const es=new EventSource('/events');
|
|
275
|
+
es.onmessage=e=>{const s=JSON.parse(e.data);
|
|
276
|
+
const on=s.power==='on', a=on?Math.max(.08,s.bright/100):0;
|
|
277
|
+
$('strip').style.background=on?s.hex:'#2a2a2e'; $('strip').style.opacity=on?(.35+.65*s.bright/100):1;
|
|
278
|
+
$('strip').style.boxShadow=on?`0 0 ${20+60*s.bright/100}px ${10+30*s.bright/100}px ${s.hex}`:'none';
|
|
279
|
+
$('glow').style.background=`radial-gradient(ellipse 70% 90% at 50% 50%, ${s.hex}, transparent 70%)`; $('glow').style.opacity=a*.6;
|
|
280
|
+
$('name').textContent=s.name; $('power').textContent=on?'вкл':'выкл'; $('color').textContent=s.hex.toUpperCase();
|
|
281
|
+
$('bright').textContent=s.bright+'%'; $('music').textContent=s.music?('да, '+s.music_rate+' команд/с'):'нет';
|
|
282
|
+
$('log').innerHTML=s.log.slice().reverse().map(l=>l.join(' ')).join('<br>');};
|
|
283
|
+
</script>'''
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def web_app(strip, port):
|
|
287
|
+
async def page(r):
|
|
288
|
+
return web.Response(text=PAGE, content_type='text/html')
|
|
289
|
+
|
|
290
|
+
async def events(r):
|
|
291
|
+
resp = web.StreamResponse(headers={'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store'})
|
|
292
|
+
await resp.prepare(r)
|
|
293
|
+
last_n, last_t = 0, time.time()
|
|
294
|
+
try:
|
|
295
|
+
while True:
|
|
296
|
+
rate = (strip.music_cmds - last_n) / max(0.05, time.time() - last_t)
|
|
297
|
+
last_n, last_t = strip.music_cmds, time.time()
|
|
298
|
+
st = {'hex': strip.color_hex(), 'bright': strip.bright, 'power': strip.power, 'name': strip.name,
|
|
299
|
+
'music': strip.music_on, 'music_rate': round(rate, 1), 'log': strip.log[-25:]}
|
|
300
|
+
await resp.write(f'data: {json.dumps(st, ensure_ascii=False)}\n\n'.encode())
|
|
301
|
+
strip.changes.clear()
|
|
302
|
+
try:
|
|
303
|
+
await asyncio.wait_for(strip.changes.wait(), 0.5)
|
|
304
|
+
except asyncio.TimeoutError:
|
|
305
|
+
pass
|
|
306
|
+
await asyncio.sleep(1 / 30)
|
|
307
|
+
except (ConnectionError, asyncio.CancelledError):
|
|
308
|
+
pass
|
|
309
|
+
return resp
|
|
310
|
+
|
|
311
|
+
app = web.Application()
|
|
312
|
+
app.add_routes([web.get('/', page), web.get('/events', events),
|
|
313
|
+
web.get('/state', lambda r: web.json_response(dict(strip.props(), hex=strip.color_hex(),
|
|
314
|
+
music_cmds=strip.music_cmds)))])
|
|
315
|
+
runner = web.AppRunner(app)
|
|
316
|
+
await runner.setup()
|
|
317
|
+
await web.TCPSite(runner, '0.0.0.0', port).start() # показ можно открыть с телефона или телевизора
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def start(name='Лента (эмулятор)', port=55443, web=47850, ssdp=True, host='0.0.0.0'):
|
|
321
|
+
"""Запустить эмулятор в текущем цикле asyncio. port=0 — любой свободный (смотри strip.port).
|
|
322
|
+
web=None — без страницы показа, ssdp=False — не отвечать на поиск в сети."""
|
|
323
|
+
strip = Strip(name, port)
|
|
324
|
+
server = await asyncio.start_server(lambda r, w: handle(strip, r, w), host, port)
|
|
325
|
+
strip.port = server.sockets[0].getsockname()[1]
|
|
326
|
+
strip.server = server
|
|
327
|
+
if web:
|
|
328
|
+
await web_app(strip, web)
|
|
329
|
+
strip.ssdp = None
|
|
330
|
+
if ssdp:
|
|
331
|
+
try:
|
|
332
|
+
strip.ssdp = await globals()['ssdp'](strip)
|
|
333
|
+
except OSError as e: # порт 1982 занят — без поиска, по адресу подключиться можно
|
|
334
|
+
strip.note('-', f'поиск в сети недоступен: {e}')
|
|
335
|
+
return strip
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def main():
|
|
339
|
+
ap = argparse.ArgumentParser(description='Эмулятор ленты Yeelight')
|
|
340
|
+
ap.add_argument('--name', default='Лента (эмулятор)')
|
|
341
|
+
ap.add_argument('--port', type=int, default=55443)
|
|
342
|
+
ap.add_argument('--web', type=int, default=47850)
|
|
343
|
+
a = ap.parse_args()
|
|
344
|
+
strip = await start(a.name, a.port, a.web)
|
|
345
|
+
print(f'Эмулятор ленты: LAN {strip.port}, показ http://<адрес компьютера>:{a.web}', flush=True)
|
|
346
|
+
await asyncio.Event().wait()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == '__main__':
|
|
350
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yeelight-lan
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Yeelight over the home network: discovery, commands and music mode, plus a strip emulator
|
|
5
|
+
Author: nowplaylab
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nowplaylab/yeelight-lan
|
|
8
|
+
Project-URL: Issues, https://github.com/nowplaylab/yeelight-lan/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: aiohttp>=3.8
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# yeelight-lan
|
|
19
|
+
|
|
20
|
+
**English** | [Русский](https://github.com/nowplaylab/yeelight-lan/blob/main/README.ru.md)
|
|
21
|
+
|
|
22
|
+
Control Yeelight lamps and light strips over your home network from Python: discovery, colour, brightness and **music mode** for flashing to the beat. No cloud, no account. Comes with a **strip emulator**, so you can build and test without the hardware.
|
|
23
|
+
|
|
24
|
+
Made by [nowplaylab](https://github.com/nowplaylab) for [NowPlaying](https://github.com/nowplaylab/nowplaying-display), where a strip follows the album cover and pulses with the bass.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pip install git+https://github.com/nowplaylab/yeelight-lan@v0.2.2
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Python 3.9+. Turn on **LAN Control** for the device in the Yeelight app first.
|
|
33
|
+
|
|
34
|
+
## Use
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
import yeelight_lan
|
|
39
|
+
|
|
40
|
+
devices = yeelight_lan.discover() # [Device(id, ip, name, model, ...)]
|
|
41
|
+
bulb = yeelight_lan.Bulb(devices[0].ip)
|
|
42
|
+
|
|
43
|
+
async def main():
|
|
44
|
+
await bulb.set_rgb(0xff6600) # smooth change, 500 ms
|
|
45
|
+
await bulb.set_bright(70)
|
|
46
|
+
print(await bulb.get_props('power', 'bright', 'rgb'))
|
|
47
|
+
|
|
48
|
+
# music mode: the device connects to you and takes commands with no reply and no rate limit
|
|
49
|
+
async with bulb.music() as m:
|
|
50
|
+
for level in (20, 100, 40, 100):
|
|
51
|
+
m.send('set_bright', [level, 'sudden', 30])
|
|
52
|
+
await asyncio.sleep(0.25)
|
|
53
|
+
|
|
54
|
+
asyncio.run(main())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Plain commands are limited by the device to **60 per minute per connection**; `Bulb` opens a short connection per command, which also survives Wi-Fi drops and device restarts. For anything faster, use `MusicMode`.
|
|
58
|
+
|
|
59
|
+
## Emulator
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
python -m yeelight_lan.emulator
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Pretends to be a Yeelight strip with LAN control on: answers discovery, takes commands on port 55443, enforces the 60/min limit and supports music mode. Open `http://<computer address>:47850` on any device at home to see the strip glow with whatever it was sent, plus a log of commands.
|
|
66
|
+
|
|
67
|
+
From tests:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
strip = await yeelight_lan.emulator.start(port=0, web=None, ssdp=False)
|
|
71
|
+
bulb = yeelight_lan.Bulb('127.0.0.1', strip.port)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Licence
|
|
75
|
+
|
|
76
|
+
MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_yeelight.py
|
|
5
|
+
yeelight_lan/__init__.py
|
|
6
|
+
yeelight_lan/emulator.py
|
|
7
|
+
yeelight_lan.egg-info/PKG-INFO
|
|
8
|
+
yeelight_lan.egg-info/SOURCES.txt
|
|
9
|
+
yeelight_lan.egg-info/dependency_links.txt
|
|
10
|
+
yeelight_lan.egg-info/requires.txt
|
|
11
|
+
yeelight_lan.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp>=3.8
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yeelight_lan
|