yeelight-lan 0.2.2__py3-none-any.whl

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.
@@ -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,7 @@
1
+ yeelight_lan/__init__.py,sha256=xMJmxZgiFpp1ADb-RIjQFAMxGRDGvRL6-INkXsd5Zp0,11369
2
+ yeelight_lan/emulator.py,sha256=FGZcTOtqEfZSJ8KOYGzR9KZfJrG_1MTwY2fTV79pQl0,17125
3
+ yeelight_lan-0.2.2.dist-info/licenses/LICENSE,sha256=YEAebu59O4eQUftdMcKSnu2g4u1K4X2V-Usk2vETUzs,1090
4
+ yeelight_lan-0.2.2.dist-info/METADATA,sha256=VZFSjvlFfbQGPdjSVN4R2qwVxSopw2RmuoUwtqPWhlc,2682
5
+ yeelight_lan-0.2.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ yeelight_lan-0.2.2.dist-info/top_level.txt,sha256=vKMjY88VJTRJYlU5JJnmGhT8QHE9LXk53SJAQAUrBuk,13
7
+ yeelight_lan-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ yeelight_lan