yeelight-raw 1.0.0

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.
Files changed (4) hide show
  1. package/README.md +83 -0
  2. package/example.js +39 -0
  3. package/index.js +147 -0
  4. package/package.json +10 -0
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # yeelight-raw
2
+
3
+ Reliable fire-and-forget Yeelight LAN control using raw TCP writes
4
+
5
+ ## Why this exists
6
+
7
+ I have experienced two significant issues while using `yeelight-node`:
8
+
9
+ 1. **Listener accumulation** — `_sendCommand` registers new `data`/`error` listeners on every call, causing MaxListeners warnings and eventual command drops.
10
+ 2. **Premature connected flag** — `_connect()` sets `this.connected = true` synchronously before the TCP connection is established, causing double-connect and more listener accumulation on repeated calls.
11
+
12
+ `yeelight-raw` bypasses all of that by writing raw JSON directly to a single persistent `net.Socket`. One socket, one set of listeners, no accumulation.
13
+
14
+ ## Install
15
+
16
+ ```
17
+ npm install yeelight-raw
18
+ ```
19
+
20
+ No dependencies, `yeelight-raw` uses only Node.js built-ins (`net`, `dgram`, `events`).
21
+
22
+ ## Quick start
23
+
24
+ ```javascript
25
+ const { discover } = require('yeelight-raw');
26
+
27
+ const disc = discover((device) => {
28
+ console.log(`Found: ${device.ip}:${device.port}`);
29
+
30
+ device.on('error', (err) => console.error('Error:', err));
31
+ device.on('close', () => console.log('Disconnected'));
32
+
33
+ device.send('set_power', ['on', 'smooth', 500, 0]);
34
+ device.send('set_bright', [80, 'smooth', 500]);
35
+ device.send('set_rgb', [255 * 65536 + 128 * 256 + 0, 'smooth', 500]);
36
+ });
37
+
38
+ setTimeout(() => disc.stop(), 10_000);
39
+ ```
40
+
41
+ ## Known device (no discovery)
42
+
43
+ ```javascript
44
+ const { YeelightDevice } = require('yeelight-raw');
45
+
46
+ const device = new YeelightDevice({ ip: '192.168.1.100', port: 55443 });
47
+ device.on('error', (err) => console.error(err));
48
+ device.send('toggle', []);
49
+ ```
50
+
51
+ ## Common commands
52
+
53
+ | Action | method | params |
54
+ |---|---|---|
55
+ | Power on | `set_power` | `['on', 'smooth', 500, 0]` |
56
+ | Power off | `set_power` | `['off', 'smooth', 500, 0]` |
57
+ | Brightness | `set_bright` | `[value_1_to_100, 'smooth', 500]` |
58
+ | RGB colour | `set_rgb` | `[r*65536 + g*256 + b, 'smooth', 500]` |
59
+ | Toggle | `toggle` | `[]` |
60
+ | Set name | `set_name` | `['My Light']` |
61
+
62
+ ## API
63
+
64
+ ### `discover(callback?)` → `YeelightDiscovery`
65
+
66
+ Starts SSDP discovery and calls `callback(device)` for each new device found. Returns the `YeelightDiscovery` instance.
67
+
68
+ ### `YeelightDiscovery`
69
+
70
+ - `search()` — send another M-SEARCH multicast
71
+ - `stop()` — close the UDP socket and stop discovery
72
+ - Events: `device`, `error`
73
+
74
+ ### `YeelightDevice`
75
+
76
+ - `send(method, params)` — fire-and-forget command; connects lazily on first call
77
+ - `destroy()` — close the TCP socket
78
+ - Properties: `id`, `ip`, `port`, `state` (`{ on, brightness, rgb }`)
79
+ - Events: `error`, `close`
80
+
81
+ ## License
82
+
83
+ MIT
package/example.js ADDED
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const { discover } = require('.');
4
+
5
+ // Optional: pass a command as CLI args, e.g.:
6
+ // node example.js set_power on smooth 500 0
7
+ // node example.js toggle
8
+ // node example.js set_bright 50 smooth 500
9
+ const [,, method, ...rawParams] = process.argv;
10
+
11
+ // Coerce numeric strings to numbers so params are typed correctly
12
+ const params = rawParams.map(p => isNaN(p) ? p : Number(p));
13
+
14
+ console.log('Searching for Yeelight devices (10s timeout)...\n');
15
+
16
+ const disc = discover((device) => {
17
+ console.log(`Found device:`);
18
+ console.log(` ID: ${device.id}`);
19
+ console.log(` IP: ${device.ip}:${device.port}`);
20
+ console.log(` Power: ${device.state.on ? 'on' : 'off'}`);
21
+ console.log(` Brightness: ${device.state.brightness}%`);
22
+ console.log(` RGB: ${device.state.rgb}`);
23
+
24
+ device.on('error', (err) => console.error(` [error] ${err.message}`));
25
+ device.on('close', () => console.log(` [close] ${device.ip} disconnected`));
26
+
27
+ if (method) {
28
+ console.log(`\n -> Sending: ${method}(${params.join(', ')})`);
29
+ device.send(method, params);
30
+ }
31
+ });
32
+
33
+ disc.on('error', (err) => console.error('Discovery error:', err.message));
34
+
35
+ setTimeout(() => {
36
+ disc.stop();
37
+ console.log('\nDone.');
38
+ process.exit(0);
39
+ }, 10_000);
package/index.js ADDED
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+ const dgram = require('dgram');
5
+ const { EventEmitter } = require('events');
6
+
7
+ const MULTICAST_ADDR = '239.255.255.250';
8
+ const MULTICAST_PORT = 1982;
9
+ const SEARCH_MSG = Buffer.from(
10
+ 'M-SEARCH * HTTP/1.1\r\n' +
11
+ `HOST: ${MULTICAST_ADDR}:${MULTICAST_PORT}\r\n` +
12
+ 'MAN: "ssdp:discover"\r\n' +
13
+ 'ST: wifi_bulb'
14
+ );
15
+
16
+ class YeelightDevice extends EventEmitter {
17
+ constructor({ id, ip, port, state = {} }) {
18
+ super();
19
+ this.id = id;
20
+ this.ip = ip;
21
+ this.port = Number(port);
22
+ this.state = state;
23
+
24
+ this._connected = false;
25
+ this._queue = [];
26
+
27
+ this._socket = new net.Socket();
28
+ this._socket.setEncoding('utf8');
29
+ this._socket.setKeepAlive(true, 10000);
30
+ this._socket.on('connect', () => {
31
+ this._connected = true;
32
+ for (const payload of this._queue) this._socket.write(payload);
33
+ this._queue = [];
34
+ });
35
+ this._socket.on('error', (err) => this.emit('error', err));
36
+ this._socket.on('close', () => {
37
+ this._connected = false;
38
+ this.emit('close');
39
+ });
40
+ }
41
+
42
+ _connectIfNeeded() {
43
+ if (!this._connected && !this._socket.connecting) {
44
+ this._socket.connect(this.port, this.ip);
45
+ }
46
+ }
47
+
48
+ // Fire-and-forget. Connects lazily on first call; queues the write until
49
+ // the TCP connection is established.
50
+ send(method, params) {
51
+ const payload = JSON.stringify({ id: 1, method, params }) + '\r\n';
52
+ if (this._connected) {
53
+ this._socket.write(payload);
54
+ } else {
55
+ this._queue.push(payload);
56
+ this._connectIfNeeded();
57
+ }
58
+ }
59
+
60
+ destroy() {
61
+ this._socket.destroy();
62
+ }
63
+ }
64
+
65
+ class YeelightDiscovery extends EventEmitter {
66
+ constructor() {
67
+ super();
68
+ this._known = new Set();
69
+ this._socket = null;
70
+ this._bind();
71
+ }
72
+
73
+ _bind() {
74
+ const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
75
+
76
+ socket.on('message', (msg) => {
77
+ const data = this._parse(msg.toString());
78
+ if (!data['id'] || !data['Location']) return;
79
+
80
+ const id = parseInt(data['id'], 16);
81
+ if (isNaN(id) || this._known.has(id)) return;
82
+ this._known.add(id);
83
+
84
+ const m = data['Location'].match(/yeelight:\/\/([\d.]+):(\d+)/);
85
+ if (!m) return;
86
+
87
+ const device = new YeelightDevice({
88
+ id,
89
+ ip: m[1],
90
+ port: m[2],
91
+ state: {
92
+ on: data['power'] === 'on',
93
+ brightness: parseInt(data['bright'], 10) || 0,
94
+ rgb: data['rgb'] || '0',
95
+ },
96
+ });
97
+ this.emit('device', device);
98
+ });
99
+
100
+ socket.on('error', (err) => {
101
+ this.emit('error', err);
102
+ socket.close();
103
+ setTimeout(() => this._bind(), 2000);
104
+ });
105
+
106
+ socket.on('listening', () => {
107
+ socket.addMembership(MULTICAST_ADDR);
108
+ this.search();
109
+ });
110
+
111
+ socket.bind(MULTICAST_PORT);
112
+ this._socket = socket;
113
+ }
114
+
115
+ // Send an SSDP M-SEARCH multicast. Safe to call multiple times.
116
+ search() {
117
+ if (!this._socket) return;
118
+ try {
119
+ this._socket.send(SEARCH_MSG, 0, SEARCH_MSG.length, MULTICAST_PORT, MULTICAST_ADDR);
120
+ } catch (_) {}
121
+ }
122
+
123
+ // Close the UDP socket and stop discovery.
124
+ stop() {
125
+ if (this._socket) {
126
+ try { this._socket.close(); } catch (_) {}
127
+ this._socket = null;
128
+ }
129
+ }
130
+
131
+ _parse(message) {
132
+ const result = {};
133
+ for (const line of message.split('\r\n')) {
134
+ const i = line.indexOf(': ');
135
+ if (i !== -1) result[line.slice(0, i)] = line.slice(i + 2);
136
+ }
137
+ return result;
138
+ }
139
+ }
140
+
141
+ function discover(callback) {
142
+ const disc = new YeelightDiscovery();
143
+ if (typeof callback === 'function') disc.on('device', callback);
144
+ return disc;
145
+ }
146
+
147
+ module.exports = { YeelightDevice, YeelightDiscovery, discover };
package/package.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "yeelight-raw",
3
+ "version": "1.0.0",
4
+ "description": "Reliable fire-and-forget Yeelight LAN control — raw TCP writes, no listener accumulation",
5
+ "main": "index.js",
6
+ "keywords": ["yeelight", "xiaomi", "smart-light", "iot", "lan-control"],
7
+ "license": "MIT",
8
+ "engines": { "node": ">=14" },
9
+ "repository": { "type": "git", "url": "" }
10
+ }