@bobfrankston/lxlan 0.1.13 → 0.1.16
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.
- package/.hintrc +2 -1
- package/README.md +20 -3
- package/client.d.ts +6 -2
- package/client.js +41 -22
- package/device.d.ts +20 -5
- package/device.js +45 -14
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +6 -3
- package/protocol.d.ts +17 -1
- package/protocol.js +46 -1
- package/types.d.ts +28 -0
- package/types.js +24 -0
package/.hintrc
CHANGED
package/README.md
CHANGED
|
@@ -127,8 +127,13 @@ client.on('device', (device) => {
|
|
|
127
127
|
// New device discovered
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
client.on('state', (device) => {
|
|
130
|
+
client.on('state', (device, info) => {
|
|
131
131
|
// Device state updated
|
|
132
|
+
// info.primary = true if direct response from device
|
|
133
|
+
// info.primary = false if received via peer client broadcast
|
|
134
|
+
if (info.primary) {
|
|
135
|
+
console.log('Direct update from device');
|
|
136
|
+
}
|
|
132
137
|
});
|
|
133
138
|
|
|
134
139
|
client.on('deviceInfo', (device) => {
|
|
@@ -141,10 +146,22 @@ client.on('deviceInfo', (device) => {
|
|
|
141
146
|
|
|
142
147
|
- `@bobfrankston/colorlib` - Color space conversions (HSB ↔ RGB ↔ Kelvin)
|
|
143
148
|
|
|
149
|
+
## Keeping Transport Packages in Sync
|
|
150
|
+
|
|
151
|
+
**IMPORTANT:** `lxlan-browser` and `lxlan-node` are parallel transport adapters. When modifying types or exports in this core package, ensure both adapters are updated if needed. They should expose equivalent functionality for their respective platforms.
|
|
152
|
+
|
|
153
|
+
## Importing Types
|
|
154
|
+
|
|
155
|
+
Types should be imported from this package using `import type`:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
import type { StateEventInfo, DeviceState } from '@bobfrankston/lxlan';
|
|
159
|
+
```
|
|
160
|
+
|
|
144
161
|
## Related Packages
|
|
145
162
|
|
|
146
|
-
- **[@bobfrankston/lxlan-node](../lxlan-node)** - Node.js transport wrapper
|
|
147
|
-
- **[@bobfrankston/lxlan-browser](../lxlan-browser)** - Browser transport wrapper
|
|
163
|
+
- **[@bobfrankston/lxlan-node](../lxlan-node)** - Node.js transport wrapper (keep in sync!)
|
|
164
|
+
- **[@bobfrankston/lxlan-browser](../lxlan-browser)** - Browser transport wrapper (keep in sync!)
|
|
148
165
|
- **[@bobfrankston/rmfudp](../../../../utils/udp/rmfudp)** - Node.js UDP transport
|
|
149
166
|
- **[@bobfrankston/httpudp-client](../../../../utils/udp/httpudp-client)** - Browser WebSocket UDP client
|
|
150
167
|
- **[@bobfrankston/httpudp](../../../../utils/udp/httpudp)** - WebSocket-to-UDP proxy server
|
package/client.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface LxClientOptions {
|
|
|
15
15
|
discoveryInterval?: number;
|
|
16
16
|
/** Enable client-to-client state broadcast for multi-client sync (default false) */
|
|
17
17
|
clientBroadcastEnabled?: boolean;
|
|
18
|
+
/** Custom logger function (default: console.log) */
|
|
19
|
+
logger?: (msg: string, colors?: string) => void;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* LIFX LAN client for device discovery and control.
|
|
@@ -23,7 +25,7 @@ export interface LxClientOptions {
|
|
|
23
25
|
* Events:
|
|
24
26
|
* - `device` (device: LxDevice) - new device discovered
|
|
25
27
|
* - `message` (device: LxDevice, msg: LxMessage) - any message received
|
|
26
|
-
* - `state` (device: LxDevice) - light state updated
|
|
28
|
+
* - `state` (device: LxDevice, info: StateEventInfo) - light state updated (info.primary = true if from device, false if from peer broadcast)
|
|
27
29
|
* - `power` (device: LxDevice) - power state updated
|
|
28
30
|
* - `label` (device: LxDevice) - label updated
|
|
29
31
|
* - `group` (device: LxDevice) - group info updated
|
|
@@ -41,7 +43,9 @@ export declare class LxClient {
|
|
|
41
43
|
private transport;
|
|
42
44
|
private port;
|
|
43
45
|
private discoveryTimer?;
|
|
46
|
+
private discoveryIntervalMs;
|
|
44
47
|
private clientBroadcastEnabled;
|
|
48
|
+
private log;
|
|
45
49
|
/** Cached devices by MAC address (lowercase) */
|
|
46
50
|
devices: Map<string, LxDevice>;
|
|
47
51
|
constructor(options: LxClientOptions);
|
|
@@ -53,7 +57,7 @@ export declare class LxClient {
|
|
|
53
57
|
off(event: string, listener: (...args: any[]) => void): this;
|
|
54
58
|
/** Emit event */
|
|
55
59
|
private emit;
|
|
56
|
-
/** Start listening */
|
|
60
|
+
/** Start listening and begin auto-discovery if configured */
|
|
57
61
|
start(): Promise<void>;
|
|
58
62
|
/** Stop and cleanup */
|
|
59
63
|
stop(): void;
|
package/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hsbk16ToHsbk } from '@bobfrankston/colorlib';
|
|
2
2
|
import { LxDevice } from './device.js';
|
|
3
|
-
import { MessageType, LIFX_PORT } from './types.js';
|
|
3
|
+
import { MessageType, LIFX_PORT, MessageTypeName } from './types.js';
|
|
4
4
|
import { encodeMessage, decodeMessage, decodeState, decodeStatePower, decodeStateLabel, decodeStateVersion, decodeStateGroup, decodeStateService, decodeStateHostInfo, decodeStateHostFirmware, decodeStateWifiInfo, decodeStateInfo } from './protocol.js';
|
|
5
5
|
/**
|
|
6
6
|
* LIFX LAN client for device discovery and control.
|
|
@@ -9,7 +9,7 @@ import { encodeMessage, decodeMessage, decodeState, decodeStatePower, decodeStat
|
|
|
9
9
|
* Events:
|
|
10
10
|
* - `device` (device: LxDevice) - new device discovered
|
|
11
11
|
* - `message` (device: LxDevice, msg: LxMessage) - any message received
|
|
12
|
-
* - `state` (device: LxDevice) - light state updated
|
|
12
|
+
* - `state` (device: LxDevice, info: StateEventInfo) - light state updated (info.primary = true if from device, false if from peer broadcast)
|
|
13
13
|
* - `power` (device: LxDevice) - power state updated
|
|
14
14
|
* - `label` (device: LxDevice) - label updated
|
|
15
15
|
* - `group` (device: LxDevice) - group info updated
|
|
@@ -27,7 +27,9 @@ export class LxClient {
|
|
|
27
27
|
transport;
|
|
28
28
|
port;
|
|
29
29
|
discoveryTimer; // NodeJS.Timeout in Node, number in browser
|
|
30
|
+
discoveryIntervalMs;
|
|
30
31
|
clientBroadcastEnabled;
|
|
32
|
+
log;
|
|
31
33
|
/** Cached devices by MAC address (lowercase) */
|
|
32
34
|
devices = new Map();
|
|
33
35
|
constructor(options) {
|
|
@@ -35,6 +37,7 @@ export class LxClient {
|
|
|
35
37
|
this.transport = options.transport;
|
|
36
38
|
this.port = options.port ?? LIFX_PORT;
|
|
37
39
|
this.clientBroadcastEnabled = options.clientBroadcastEnabled ?? false;
|
|
40
|
+
this.log = options.logger ?? ((msg, colors) => console.log(msg));
|
|
38
41
|
this.transport.onMessage((data, rinfo) => {
|
|
39
42
|
// Ensure data is Uint8Array for protocol decoder
|
|
40
43
|
const buf = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
@@ -43,19 +46,7 @@ export class LxClient {
|
|
|
43
46
|
this.transport.onError((err) => {
|
|
44
47
|
this.emitter.emit('error', err);
|
|
45
48
|
});
|
|
46
|
-
|
|
47
|
-
this.discoveryTimer = setInterval(() => {
|
|
48
|
-
try {
|
|
49
|
-
this.discover();
|
|
50
|
-
}
|
|
51
|
-
catch (err) {
|
|
52
|
-
// Emit error but keep timer running
|
|
53
|
-
if (err instanceof Error) {
|
|
54
|
-
this.emitter.emit('error', err);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}, options.discoveryInterval);
|
|
58
|
-
}
|
|
49
|
+
this.discoveryIntervalMs = options.discoveryInterval ?? 0;
|
|
59
50
|
}
|
|
60
51
|
/** Register event listener */
|
|
61
52
|
on(event, listener) {
|
|
@@ -76,9 +67,19 @@ export class LxClient {
|
|
|
76
67
|
emit(event, ...args) {
|
|
77
68
|
return this.emitter.emit(event, ...args);
|
|
78
69
|
}
|
|
79
|
-
/** Start listening */
|
|
70
|
+
/** Start listening and begin auto-discovery if configured */
|
|
80
71
|
async start() {
|
|
81
72
|
await this.transport.bind();
|
|
73
|
+
if (this.discoveryIntervalMs > 0 && !this.discoveryTimer) {
|
|
74
|
+
this.discoveryTimer = setInterval(() => {
|
|
75
|
+
try {
|
|
76
|
+
this.discover();
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
this.emitter.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
80
|
+
}
|
|
81
|
+
}, this.discoveryIntervalMs);
|
|
82
|
+
}
|
|
82
83
|
}
|
|
83
84
|
/** Stop and cleanup */
|
|
84
85
|
stop() {
|
|
@@ -138,19 +139,25 @@ export class LxClient {
|
|
|
138
139
|
}
|
|
139
140
|
/** Handle incoming message */
|
|
140
141
|
handleMessage(data, fromIp, fromPort) {
|
|
142
|
+
this.log(`[LxClient] handleMessage: ${data.length} bytes from ${fromIp}:${fromPort}`);
|
|
141
143
|
let msg;
|
|
142
144
|
try {
|
|
143
145
|
msg = decodeMessage(data);
|
|
146
|
+
const typeName = MessageTypeName(msg.type);
|
|
147
|
+
this.log(`[LxClient] decoded message type=${msg.type} (${typeName}), target=${msg.target}`);
|
|
144
148
|
}
|
|
145
149
|
catch (e) {
|
|
146
150
|
this.emit('error', new Error(`Decode error: ${e.message}`));
|
|
147
151
|
return;
|
|
148
152
|
}
|
|
149
153
|
const mac = msg.target;
|
|
150
|
-
if (mac === '00:00:00:00:00:00')
|
|
151
|
-
|
|
154
|
+
if (mac === '00:00:00:00:00:00') {
|
|
155
|
+
this.log(`[LxClient] ignoring broadcast response (no target)`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
152
158
|
let device = this.devices.get(mac);
|
|
153
159
|
const isNew = !device;
|
|
160
|
+
this.log(`[LxClient] mac=${mac}, isNew=${isNew}, deviceCount=${this.devices.size}`);
|
|
154
161
|
if (!device) {
|
|
155
162
|
device = new LxDevice(mac, fromIp);
|
|
156
163
|
device.setTransport(this.transport);
|
|
@@ -161,12 +168,16 @@ export class LxClient {
|
|
|
161
168
|
device.port = fromPort;
|
|
162
169
|
device.markSeen();
|
|
163
170
|
// Check for duplicate message (some LIFX devices send responses twice)
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
// Use both sequence AND type - different types with same seq are valid (e.g., Ack + State)
|
|
172
|
+
if (device.isDuplicate(msg.sequence, msg.type)) {
|
|
173
|
+
this.log(`[LxClient] ignoring duplicate seq=${msg.sequence} type=${msg.type}`);
|
|
174
|
+
return;
|
|
166
175
|
}
|
|
167
176
|
if (isNew) {
|
|
168
177
|
this.emit('device', device);
|
|
169
178
|
}
|
|
179
|
+
const typeName = MessageTypeName(msg.type);
|
|
180
|
+
this.log(`[LxClient] processing msg.type=#${msg.type} ${typeName} (State=${MessageType.State})`);
|
|
170
181
|
this.emit('message', device, msg);
|
|
171
182
|
switch (msg.type) {
|
|
172
183
|
case MessageType.StateService: {
|
|
@@ -176,7 +187,12 @@ export class LxClient {
|
|
|
176
187
|
break;
|
|
177
188
|
}
|
|
178
189
|
case MessageType.State: {
|
|
190
|
+
// Hex dump first 20 bytes to debug power offset
|
|
191
|
+
const hex = Array.from(msg.payload.slice(0, 20)).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
|
192
|
+
this.log(`[LxClient] State payload[0:20]: ${hex}`, 'navy,white');
|
|
179
193
|
const state = decodeState(msg.payload);
|
|
194
|
+
this.log(`[LxClient] raw state.power=${state.power} (0=off, 65535=on) at offset 10`);
|
|
195
|
+
this.log(`[LxClient] raw state.hsbk=${JSON.stringify(state)} at offset 12`);
|
|
180
196
|
device.color = hsbk16ToHsbk(state.hsbk);
|
|
181
197
|
device.power = state.power > 0;
|
|
182
198
|
device.label = state.label;
|
|
@@ -184,11 +200,14 @@ export class LxClient {
|
|
|
184
200
|
// State messages also respond to SetPower and SetColor
|
|
185
201
|
device.markResponseReceived(MessageType.SetPower);
|
|
186
202
|
device.markResponseReceived(MessageType.SetColor);
|
|
187
|
-
|
|
203
|
+
// primary = true if from device, false if from peer client broadcast
|
|
204
|
+
const primary = fromIp === device.ip;
|
|
205
|
+
this.log(`[LxClient] emitting 'state' for ${device.mac}, power=${device.power}, primary=${primary}`);
|
|
206
|
+
this.emit('state', device, { primary });
|
|
188
207
|
// Multi-client state synchronization: broadcast to other clients
|
|
189
208
|
// Safe fail approach: only rebroadcast if from device IP
|
|
190
209
|
// If not from device.ip, assume it's a client broadcast - just update local state, don't rebroadcast
|
|
191
|
-
if (this.clientBroadcastEnabled &&
|
|
210
|
+
if (this.clientBroadcastEnabled && primary) {
|
|
192
211
|
this.broadcastState(data);
|
|
193
212
|
}
|
|
194
213
|
break;
|
package/device.d.ts
CHANGED
|
@@ -44,8 +44,8 @@ export declare class LxDevice {
|
|
|
44
44
|
group: GroupInfo;
|
|
45
45
|
/** Location membership */
|
|
46
46
|
location: GroupInfo;
|
|
47
|
-
/** Recent
|
|
48
|
-
private
|
|
47
|
+
/** Recent messages for deduplication (sequence:type -> timestamp) */
|
|
48
|
+
private recentMessages;
|
|
49
49
|
private transport?;
|
|
50
50
|
/** Pending requests awaiting responses (message type -> timeout) */
|
|
51
51
|
private pendingRequests;
|
|
@@ -80,11 +80,24 @@ export declare class LxDevice {
|
|
|
80
80
|
/** Set power on/off */
|
|
81
81
|
setPower(on: boolean): void;
|
|
82
82
|
/**
|
|
83
|
-
* Set brightness
|
|
83
|
+
* Set brightness without affecting hue, saturation, or kelvin.
|
|
84
|
+
* Uses SetWaveformOptional (type 119) — device keeps its current color temperature.
|
|
84
85
|
* @param brightness - Brightness 0-100
|
|
85
86
|
* @param duration - Transition time in milliseconds (default 0)
|
|
86
87
|
*/
|
|
87
88
|
setBrightness(brightness: number, duration?: number): void;
|
|
89
|
+
/**
|
|
90
|
+
* Set individual HSBK values without affecting unspecified fields.
|
|
91
|
+
* Uses SetWaveformOptional (type 119) — only fields present in the object are applied.
|
|
92
|
+
* @param values - Partial HSBK: { h?, s?, b?, k? } (h=0-360, s=0-100, b=0-100, k=1500-9000)
|
|
93
|
+
* @param duration - Transition time in milliseconds (default 0)
|
|
94
|
+
*/
|
|
95
|
+
setValues(values: {
|
|
96
|
+
h?: number;
|
|
97
|
+
s?: number;
|
|
98
|
+
b?: number;
|
|
99
|
+
k?: number;
|
|
100
|
+
}, duration?: number): void;
|
|
88
101
|
/**
|
|
89
102
|
* Set color - accepts flexible input formats
|
|
90
103
|
* @param color - Color as hex "#ff0000", RGB {r,g,b}, HSL {h,s,l}, HSB {h,s,b}, HSBK {h,s,b,k}, or Kelvin number
|
|
@@ -135,11 +148,13 @@ export declare class LxDevice {
|
|
|
135
148
|
/** Notify that a response was received for a message type */
|
|
136
149
|
markResponseReceived(messageType: number): void;
|
|
137
150
|
/**
|
|
138
|
-
* Check if message is a duplicate based on sequence number.
|
|
151
|
+
* Check if message is a duplicate based on sequence number AND type.
|
|
139
152
|
* LIFX devices sometimes send duplicate responses.
|
|
153
|
+
* Different message types with same sequence are NOT duplicates (e.g., Ack + State).
|
|
140
154
|
* @param sequence - Message sequence number
|
|
155
|
+
* @param type - Message type number
|
|
141
156
|
* @returns true if duplicate (already seen recently)
|
|
142
157
|
*/
|
|
143
|
-
isDuplicate(sequence: number): boolean;
|
|
158
|
+
isDuplicate(sequence: number, type: number): boolean;
|
|
144
159
|
}
|
|
145
160
|
//# sourceMappingURL=device.d.ts.map
|
package/device.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseColor, hsbkTo16 } from '@bobfrankston/colorlib';
|
|
2
2
|
import { MessageType, Products, LIFX_PORT } from './types.js';
|
|
3
|
-
import { encodeMessage, encodeSetPower, encodeSetColor, encodeSetLabel, encodeSetGroup, encodeSetLocation } from './protocol.js';
|
|
3
|
+
import { encodeMessage, encodeSetPower, encodeSetColor, encodeSetWaveformOptional, encodeSetLabel, encodeSetGroup, encodeSetLocation } from './protocol.js';
|
|
4
4
|
/**
|
|
5
5
|
* Represents a LIFX device on the LAN.
|
|
6
6
|
* State is cached and updated via events from LxClient.
|
|
@@ -44,8 +44,8 @@ export class LxDevice {
|
|
|
44
44
|
group = { id: '', label: '', updatedAt: 0 };
|
|
45
45
|
/** Location membership */
|
|
46
46
|
location = { id: '', label: '', updatedAt: 0 };
|
|
47
|
-
/** Recent
|
|
48
|
-
|
|
47
|
+
/** Recent messages for deduplication (sequence:type -> timestamp) */
|
|
48
|
+
recentMessages = new Map();
|
|
49
49
|
transport;
|
|
50
50
|
/** Pending requests awaiting responses (message type -> timeout) */
|
|
51
51
|
pendingRequests = new Map();
|
|
@@ -121,6 +121,7 @@ export class LxDevice {
|
|
|
121
121
|
/** Set power on/off */
|
|
122
122
|
setPower(on) {
|
|
123
123
|
this.requireTransport();
|
|
124
|
+
this.power = on;
|
|
124
125
|
const msg = encodeMessage({
|
|
125
126
|
type: MessageType.SetPower,
|
|
126
127
|
target: this.mac,
|
|
@@ -133,15 +134,42 @@ export class LxDevice {
|
|
|
133
134
|
this.startRequestTimeout(MessageType.SetPower);
|
|
134
135
|
}
|
|
135
136
|
/**
|
|
136
|
-
* Set brightness
|
|
137
|
+
* Set brightness without affecting hue, saturation, or kelvin.
|
|
138
|
+
* Uses SetWaveformOptional (type 119) — device keeps its current color temperature.
|
|
137
139
|
* @param brightness - Brightness 0-100
|
|
138
140
|
* @param duration - Transition time in milliseconds (default 0)
|
|
139
141
|
*/
|
|
140
142
|
setBrightness(brightness, duration = 0) {
|
|
143
|
+
this.setValues({ b: brightness }, duration);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Set individual HSBK values without affecting unspecified fields.
|
|
147
|
+
* Uses SetWaveformOptional (type 119) — only fields present in the object are applied.
|
|
148
|
+
* @param values - Partial HSBK: { h?, s?, b?, k? } (h=0-360, s=0-100, b=0-100, k=1500-9000)
|
|
149
|
+
* @param duration - Transition time in milliseconds (default 0)
|
|
150
|
+
*/
|
|
151
|
+
setValues(values, duration = 0) {
|
|
141
152
|
this.requireTransport();
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
const hsbk16 = hsbkTo16({
|
|
154
|
+
h: values.h ?? 0,
|
|
155
|
+
s: values.s ?? 0,
|
|
156
|
+
b: values.b ?? 0,
|
|
157
|
+
k: values.k ?? 3500
|
|
158
|
+
});
|
|
159
|
+
const msg = encodeMessage({
|
|
160
|
+
type: MessageType.SetWaveformOptional,
|
|
161
|
+
target: this.mac,
|
|
162
|
+
payload: encodeSetWaveformOptional(hsbk16, duration, {
|
|
163
|
+
setHue: values.h !== undefined,
|
|
164
|
+
setSaturation: values.s !== undefined,
|
|
165
|
+
setBrightness: values.b !== undefined,
|
|
166
|
+
setKelvin: values.k !== undefined
|
|
167
|
+
}),
|
|
168
|
+
ackRequired: true,
|
|
169
|
+
resRequired: true
|
|
170
|
+
});
|
|
171
|
+
this.transport.send(this.ip, this.port, msg);
|
|
172
|
+
this.startRequestTimeout(MessageType.SetWaveformOptional);
|
|
145
173
|
}
|
|
146
174
|
/**
|
|
147
175
|
* Set color - accepts flexible input formats
|
|
@@ -267,25 +295,28 @@ export class LxDevice {
|
|
|
267
295
|
this.clearRequestTimeout(messageType);
|
|
268
296
|
}
|
|
269
297
|
/**
|
|
270
|
-
* Check if message is a duplicate based on sequence number.
|
|
298
|
+
* Check if message is a duplicate based on sequence number AND type.
|
|
271
299
|
* LIFX devices sometimes send duplicate responses.
|
|
300
|
+
* Different message types with same sequence are NOT duplicates (e.g., Ack + State).
|
|
272
301
|
* @param sequence - Message sequence number
|
|
302
|
+
* @param type - Message type number
|
|
273
303
|
* @returns true if duplicate (already seen recently)
|
|
274
304
|
*/
|
|
275
|
-
isDuplicate(sequence) {
|
|
305
|
+
isDuplicate(sequence, type) {
|
|
276
306
|
const now = Date.now();
|
|
277
307
|
const DUPLICATE_WINDOW_MS = 2000; // 2 second window
|
|
308
|
+
const key = `${sequence}:${type}`;
|
|
278
309
|
// Clean old entries
|
|
279
|
-
for (const [
|
|
310
|
+
for (const [k, timestamp] of this.recentMessages.entries()) {
|
|
280
311
|
if (now - timestamp > DUPLICATE_WINDOW_MS) {
|
|
281
|
-
this.
|
|
312
|
+
this.recentMessages.delete(k);
|
|
282
313
|
}
|
|
283
314
|
}
|
|
284
|
-
// Check if we've seen this sequence recently
|
|
285
|
-
if (this.
|
|
315
|
+
// Check if we've seen this exact sequence+type recently
|
|
316
|
+
if (this.recentMessages.has(key)) {
|
|
286
317
|
return true;
|
|
287
318
|
}
|
|
288
|
-
this.
|
|
319
|
+
this.recentMessages.set(key, now);
|
|
289
320
|
return false;
|
|
290
321
|
}
|
|
291
322
|
}
|
package/index.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export { LxDevice } from './device.js';
|
|
|
3
3
|
export { LxClient, LxClientOptions } from './client.js';
|
|
4
4
|
export { UdpTransport, LxTransport, RemoteInfo } from './transport.js';
|
|
5
5
|
export { LxEventEmitter, LxEventEmitterBase } from './events.js';
|
|
6
|
-
export { encodeMessage, decodeMessage } from './protocol.js';
|
|
6
|
+
export { encodeMessage, decodeMessage, encodeSetWifiConfiguration, encodeGetWifiConfiguration, decodeStateWifiConfiguration, encodeSetLabel, encodeSetColor, encodeSetPower, encodeSetGroup, encodeSetLocation, encodeSetWaveformOptional, encodeGetService, decodeState, decodeStatePower, decodeStateLabel, decodeStateVersion, decodeStateGroup, decodeStateService, decodeStateHostInfo, decodeStateHostFirmware, decodeStateWifiInfo, decodeStateInfo, FrameFlags, ProtocolBits, nextSequence, getSource, } from './protocol.js';
|
|
7
7
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -2,5 +2,5 @@ export * from './types.js';
|
|
|
2
2
|
export { LxDevice } from './device.js';
|
|
3
3
|
export { LxClient } from './client.js';
|
|
4
4
|
export { LxEventEmitterBase } from './events.js';
|
|
5
|
-
export { encodeMessage, decodeMessage } from './protocol.js';
|
|
5
|
+
export { encodeMessage, decodeMessage, encodeSetWifiConfiguration, encodeGetWifiConfiguration, decodeStateWifiConfiguration, encodeSetLabel, encodeSetColor, encodeSetPower, encodeSetGroup, encodeSetLocation, encodeSetWaveformOptional, encodeGetService, decodeState, decodeStatePower, decodeStateLabel, decodeStateVersion, decodeStateGroup, decodeStateService, decodeStateHostInfo, decodeStateHostFirmware, decodeStateWifiInfo, decodeStateInfo, FrameFlags, ProtocolBits, nextSequence, getSource, } from './protocol.js';
|
|
6
6
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/lxlan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "LIFX LAN protocol library for device control via UDP",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -31,11 +31,14 @@
|
|
|
31
31
|
"url": "https://github.com/BobFrankston/lxlan.git"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@bobfrankston/colorlib": "^0.1.
|
|
34
|
+
"@bobfrankston/colorlib": "^0.1.8",
|
|
35
35
|
"@bobfrankston/udp-transport": "^1.0.2"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@types/node": "^25.
|
|
38
|
+
"@types/node": "^25.2.1"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
39
42
|
},
|
|
40
43
|
".dependencies": {
|
|
41
44
|
"@bobfrankston/colorlib": "file:../../../../utils/colorlib",
|
package/protocol.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HSBK16 } from '@bobfrankston/colorlib';
|
|
2
|
-
import { LxMessage } from './types.js';
|
|
2
|
+
import { LxMessage, WifiConfigState } from './types.js';
|
|
3
3
|
/** LIFX protocol frame flags */
|
|
4
4
|
export declare const FrameFlags: {
|
|
5
5
|
readonly ResRequired: 1;
|
|
@@ -31,6 +31,16 @@ export declare function decodeMessage(buf: Uint8Array): LxMessage;
|
|
|
31
31
|
export declare function encodeSetPower(level: boolean): Uint8Array;
|
|
32
32
|
/** Encode SetColor payload */
|
|
33
33
|
export declare function encodeSetColor(hsbk: HSBK16, duration?: number): Uint8Array;
|
|
34
|
+
/** Encode SetWaveformOptional payload (type 119)
|
|
35
|
+
* Allows setting individual HSBK fields without affecting others.
|
|
36
|
+
* Set the corresponding set_* flag to true to apply that field.
|
|
37
|
+
*/
|
|
38
|
+
export declare function encodeSetWaveformOptional(hsbk: HSBK16, duration?: number, flags?: {
|
|
39
|
+
setHue?: boolean;
|
|
40
|
+
setSaturation?: boolean;
|
|
41
|
+
setBrightness?: boolean;
|
|
42
|
+
setKelvin?: boolean;
|
|
43
|
+
}): Uint8Array;
|
|
34
44
|
/** Encode SetLabel payload */
|
|
35
45
|
export declare function encodeSetLabel(label: string): Uint8Array;
|
|
36
46
|
/** Encode SetGroup payload */
|
|
@@ -89,4 +99,10 @@ export declare function decodeStateInfo(payload: Uint8Array): {
|
|
|
89
99
|
};
|
|
90
100
|
/** Encode GetService payload (empty) */
|
|
91
101
|
export declare function encodeGetService(): Uint8Array;
|
|
102
|
+
/** Encode SetWifiConfiguration (Type 301) payload — 98 bytes */
|
|
103
|
+
export declare function encodeSetWifiConfiguration(ssid: string, pass: string, security?: number): Uint8Array;
|
|
104
|
+
/** Encode GetWifiConfiguration (Type 302) — no payload */
|
|
105
|
+
export declare function encodeGetWifiConfiguration(): Uint8Array;
|
|
106
|
+
/** Decode StateWifiConfiguration (Type 303) payload */
|
|
107
|
+
export declare function decodeStateWifiConfiguration(payload: Uint8Array): WifiConfigState;
|
|
92
108
|
//# sourceMappingURL=protocol.d.ts.map
|
package/protocol.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HEADER_SIZE } from './types.js';
|
|
1
|
+
import { HEADER_SIZE, WifiSecurity, WifiInterface } from './types.js';
|
|
2
2
|
/** LIFX protocol frame flags */
|
|
3
3
|
export const FrameFlags = {
|
|
4
4
|
ResRequired: 0x01, /** Response required from device */
|
|
@@ -154,6 +154,30 @@ export function encodeSetColor(hsbk, duration = 0) {
|
|
|
154
154
|
writeUInt32LE(buf, duration, 9);
|
|
155
155
|
return buf;
|
|
156
156
|
}
|
|
157
|
+
/** Encode SetWaveformOptional payload (type 119)
|
|
158
|
+
* Allows setting individual HSBK fields without affecting others.
|
|
159
|
+
* Set the corresponding set_* flag to true to apply that field.
|
|
160
|
+
*/
|
|
161
|
+
export function encodeSetWaveformOptional(hsbk, duration = 0, flags = {}) {
|
|
162
|
+
const buf = new Uint8Array(25);
|
|
163
|
+
buf[0] = 0; // reserved
|
|
164
|
+
buf[1] = 0; // transient = false (permanent)
|
|
165
|
+
writeUInt16LE(buf, hsbk.h, 2); // hue
|
|
166
|
+
writeUInt16LE(buf, hsbk.s, 4); // saturation
|
|
167
|
+
writeUInt16LE(buf, hsbk.b, 6); // brightness
|
|
168
|
+
writeUInt16LE(buf, hsbk.k, 8); // kelvin
|
|
169
|
+
writeUInt32LE(buf, duration, 10); // period (ms) — used as transition duration
|
|
170
|
+
// cycles (float32) at offset 14 — 1.0 for single transition
|
|
171
|
+
const view = new DataView(buf.buffer, buf.byteOffset + 14, 4);
|
|
172
|
+
view.setFloat32(0, 1.0, true);
|
|
173
|
+
writeUInt16LE(buf, 0, 18); // skew_ratio = 0
|
|
174
|
+
buf[20] = 0; // waveform = SAW
|
|
175
|
+
buf[21] = flags.setHue ? 1 : 0; // set_hue
|
|
176
|
+
buf[22] = flags.setSaturation ? 1 : 0; // set_saturation
|
|
177
|
+
buf[23] = flags.setBrightness ? 1 : 0; // set_brightness
|
|
178
|
+
buf[24] = flags.setKelvin ? 1 : 0; // set_kelvin
|
|
179
|
+
return buf;
|
|
180
|
+
}
|
|
157
181
|
/** Encode SetLabel payload */
|
|
158
182
|
export function encodeSetLabel(label) {
|
|
159
183
|
const buf = new Uint8Array(32);
|
|
@@ -256,6 +280,27 @@ export function decodeStateInfo(payload) {
|
|
|
256
280
|
export function encodeGetService() {
|
|
257
281
|
return new Uint8Array(0);
|
|
258
282
|
}
|
|
283
|
+
/** Encode SetWifiConfiguration (Type 301) payload — 98 bytes */
|
|
284
|
+
export function encodeSetWifiConfiguration(ssid, pass, security = WifiSecurity.WPA2) {
|
|
285
|
+
const buf = new Uint8Array(98);
|
|
286
|
+
buf[0] = WifiInterface.Station;
|
|
287
|
+
utf8Encode(ssid, buf, 1, 32);
|
|
288
|
+
utf8Encode(pass, buf, 33, 64);
|
|
289
|
+
buf[97] = security;
|
|
290
|
+
return buf;
|
|
291
|
+
}
|
|
292
|
+
/** Encode GetWifiConfiguration (Type 302) — no payload */
|
|
293
|
+
export function encodeGetWifiConfiguration() {
|
|
294
|
+
return new Uint8Array(0);
|
|
295
|
+
}
|
|
296
|
+
/** Decode StateWifiConfiguration (Type 303) payload */
|
|
297
|
+
export function decodeStateWifiConfiguration(payload) {
|
|
298
|
+
return {
|
|
299
|
+
iface: payload[0],
|
|
300
|
+
ssid: utf8Decode(payload, 1, 33),
|
|
301
|
+
security: payload[97],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
259
304
|
// Buffer compatibility helper
|
|
260
305
|
const BufferImpl = typeof Buffer !== 'undefined' ? Buffer : class {
|
|
261
306
|
static alloc(size) {
|
package/types.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export declare const MessageType: {
|
|
|
26
26
|
readonly StateVersion: 33;
|
|
27
27
|
readonly GetInfo: 34;
|
|
28
28
|
readonly StateInfo: 35;
|
|
29
|
+
readonly Acknowledge: 45;
|
|
29
30
|
readonly GetLocation: 48;
|
|
30
31
|
readonly SetLocation: 49;
|
|
31
32
|
readonly StateLocation: 50;
|
|
@@ -34,8 +35,30 @@ export declare const MessageType: {
|
|
|
34
35
|
readonly StateGroup: 53;
|
|
35
36
|
readonly Get: 101;
|
|
36
37
|
readonly SetColor: 102;
|
|
38
|
+
readonly SetWaveformOptional: 119;
|
|
37
39
|
readonly State: 107;
|
|
40
|
+
readonly SetWifiConfiguration: 301;
|
|
41
|
+
readonly GetWifiConfiguration: 302;
|
|
42
|
+
readonly StateWifiConfiguration: 303;
|
|
38
43
|
};
|
|
44
|
+
/** Wi-Fi security modes for onboarding */
|
|
45
|
+
export declare const WifiSecurity: {
|
|
46
|
+
readonly Open: 1;
|
|
47
|
+
readonly WEP: 2;
|
|
48
|
+
readonly WPA: 3;
|
|
49
|
+
readonly WPA2: 4;
|
|
50
|
+
};
|
|
51
|
+
/** Wi-Fi interface type */
|
|
52
|
+
export declare const WifiInterface: {
|
|
53
|
+
readonly Station: 6;
|
|
54
|
+
};
|
|
55
|
+
/** Wi-Fi configuration state (from Type 303 response) */
|
|
56
|
+
export interface WifiConfigState {
|
|
57
|
+
iface: number; /** Interface type (6 = Station) */
|
|
58
|
+
ssid: string;
|
|
59
|
+
security: number;
|
|
60
|
+
}
|
|
61
|
+
export declare function MessageTypeName(type: number): string;
|
|
39
62
|
export type MessageTypeValue = typeof MessageType[keyof typeof MessageType];
|
|
40
63
|
/** LIFX message structure */
|
|
41
64
|
export interface LxMessage {
|
|
@@ -85,6 +108,11 @@ export interface DeviceState {
|
|
|
85
108
|
uptime?: number;
|
|
86
109
|
downtime?: number;
|
|
87
110
|
}
|
|
111
|
+
/** Info passed with state events */
|
|
112
|
+
export interface StateEventInfo {
|
|
113
|
+
/** true if from device response, false if from peer client broadcast */
|
|
114
|
+
primary: boolean;
|
|
115
|
+
}
|
|
88
116
|
/** LIFX port */
|
|
89
117
|
export declare const LIFX_PORT = 56700;
|
|
90
118
|
/** Header size */
|
package/types.js
CHANGED
|
@@ -18,6 +18,7 @@ export const MessageType = {
|
|
|
18
18
|
StateVersion: 33,
|
|
19
19
|
GetInfo: 34,
|
|
20
20
|
StateInfo: 35,
|
|
21
|
+
Acknowledge: 45,
|
|
21
22
|
GetLocation: 48,
|
|
22
23
|
SetLocation: 49,
|
|
23
24
|
StateLocation: 50,
|
|
@@ -26,8 +27,31 @@ export const MessageType = {
|
|
|
26
27
|
StateGroup: 53,
|
|
27
28
|
Get: 101, /** GetColor */
|
|
28
29
|
SetColor: 102,
|
|
30
|
+
SetWaveformOptional: 119, /** Set HSBK with per-field control flags */
|
|
29
31
|
State: 107, /** LightState */
|
|
32
|
+
SetWifiConfiguration: 301, /** Onboarding: set Wi-Fi credentials */
|
|
33
|
+
GetWifiConfiguration: 302, /** Onboarding: query current Wi-Fi config */
|
|
34
|
+
StateWifiConfiguration: 303, /** Onboarding: response with current Wi-Fi config */
|
|
30
35
|
};
|
|
36
|
+
/** Wi-Fi security modes for onboarding */
|
|
37
|
+
export const WifiSecurity = {
|
|
38
|
+
Open: 1,
|
|
39
|
+
WEP: 2,
|
|
40
|
+
WPA: 3,
|
|
41
|
+
WPA2: 4,
|
|
42
|
+
};
|
|
43
|
+
/** Wi-Fi interface type */
|
|
44
|
+
export const WifiInterface = {
|
|
45
|
+
Station: 6, /** Station mode (join existing network) */
|
|
46
|
+
};
|
|
47
|
+
// This is for debuging so it doesn't have to be that
|
|
48
|
+
export function MessageTypeName(type) {
|
|
49
|
+
for (const [name, val] of Object.entries(MessageType)) {
|
|
50
|
+
if (val === type)
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
return `Unknown(${type})`;
|
|
54
|
+
}
|
|
31
55
|
/** LIFX port */
|
|
32
56
|
export const LIFX_PORT = 56700;
|
|
33
57
|
/** Header size */
|