matterbridge-litetouch 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.
- package/LICENSE +123 -0
- package/README.md +214 -0
- package/config.schema.json +95 -0
- package/dist/commandQueue.d.ts +58 -0
- package/dist/commandQueue.js +122 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +20 -0
- package/dist/litetouchConnection.d.ts +102 -0
- package/dist/litetouchConnection.js +284 -0
- package/dist/platform.d.ts +44 -0
- package/dist/platform.js +313 -0
- package/package.json +53 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Litetouch 2000 serial communication handler.
|
|
3
|
+
*
|
|
4
|
+
* Protocol:
|
|
5
|
+
* - ASCII over RS-232, carriage return (\r) terminated
|
|
6
|
+
* - Commands start with a space, format: " XX YY-Z VVV"
|
|
7
|
+
* - XX: command code (10 = set, 18 = query)
|
|
8
|
+
* - YY-Z: module-output address (e.g., "01-1", "07-4")
|
|
9
|
+
* - VVV: value (000-001 for relays, 000-250 for dimmers)
|
|
10
|
+
*/
|
|
11
|
+
import { EventEmitter } from 'events';
|
|
12
|
+
export interface LoadStatus {
|
|
13
|
+
address: string;
|
|
14
|
+
level: number;
|
|
15
|
+
raw: number;
|
|
16
|
+
}
|
|
17
|
+
export interface LitetouchConfig {
|
|
18
|
+
serialPort: string;
|
|
19
|
+
baudRate: number;
|
|
20
|
+
pollingInterval: number;
|
|
21
|
+
commandTimeout: number;
|
|
22
|
+
debug: boolean;
|
|
23
|
+
}
|
|
24
|
+
export type DeviceType = 'dimmer' | 'switch';
|
|
25
|
+
export declare class LitetouchConnection extends EventEmitter {
|
|
26
|
+
private port;
|
|
27
|
+
private parser;
|
|
28
|
+
private commandQueue;
|
|
29
|
+
private config;
|
|
30
|
+
private pollingTimer;
|
|
31
|
+
private loadAddresses;
|
|
32
|
+
private deviceTypes;
|
|
33
|
+
private currentPollingIndex;
|
|
34
|
+
private pendingResponse;
|
|
35
|
+
private connected;
|
|
36
|
+
constructor(config: LitetouchConfig);
|
|
37
|
+
/**
|
|
38
|
+
* Set the list of load addresses to poll with their device types
|
|
39
|
+
*/
|
|
40
|
+
setLoadAddresses(addresses: string[], deviceTypes?: Map<string, DeviceType>): void;
|
|
41
|
+
/**
|
|
42
|
+
* Open the serial port and start communication
|
|
43
|
+
*/
|
|
44
|
+
open(): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Set up the readline parser for CR-terminated messages
|
|
47
|
+
*/
|
|
48
|
+
private setupParser;
|
|
49
|
+
/**
|
|
50
|
+
* Handle incoming response from Litetouch
|
|
51
|
+
*/
|
|
52
|
+
private handleResponse;
|
|
53
|
+
/**
|
|
54
|
+
* Parse a status response from Litetouch
|
|
55
|
+
* Response format: "18 VVV" where VVV is the level (000-250)
|
|
56
|
+
* The address must be provided since responses don't include it
|
|
57
|
+
*/
|
|
58
|
+
private parseStatusResponse;
|
|
59
|
+
/**
|
|
60
|
+
* Process a command from the queue - sends it to the serial port
|
|
61
|
+
*/
|
|
62
|
+
private processCommand;
|
|
63
|
+
/**
|
|
64
|
+
* Query the status of a load
|
|
65
|
+
*/
|
|
66
|
+
queryLoad(address: string): Promise<string | null>;
|
|
67
|
+
/**
|
|
68
|
+
* Set a relay on or off
|
|
69
|
+
*/
|
|
70
|
+
setRelay(address: string, on: boolean): Promise<string | null>;
|
|
71
|
+
/**
|
|
72
|
+
* Set a dimmer level (0-100 percentage)
|
|
73
|
+
*/
|
|
74
|
+
setDimmer(address: string, level: number): Promise<string | null>;
|
|
75
|
+
/**
|
|
76
|
+
* Start the polling loop to query load statuses
|
|
77
|
+
*/
|
|
78
|
+
startPolling(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Poll the next load in the list
|
|
81
|
+
*/
|
|
82
|
+
private pollNextLoad;
|
|
83
|
+
/**
|
|
84
|
+
* Stop the polling loop
|
|
85
|
+
*/
|
|
86
|
+
stopPolling(): void;
|
|
87
|
+
/**
|
|
88
|
+
* Close the serial port and clean up
|
|
89
|
+
*/
|
|
90
|
+
close(): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Check if the connection is open
|
|
93
|
+
*/
|
|
94
|
+
get isConnected(): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Get the command queue length
|
|
97
|
+
*/
|
|
98
|
+
get queueLength(): number;
|
|
99
|
+
private log;
|
|
100
|
+
private debug;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=litetouchConnection.d.ts.map
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Litetouch 2000 serial communication handler.
|
|
3
|
+
*
|
|
4
|
+
* Protocol:
|
|
5
|
+
* - ASCII over RS-232, carriage return (\r) terminated
|
|
6
|
+
* - Commands start with a space, format: " XX YY-Z VVV"
|
|
7
|
+
* - XX: command code (10 = set, 18 = query)
|
|
8
|
+
* - YY-Z: module-output address (e.g., "01-1", "07-4")
|
|
9
|
+
* - VVV: value (000-001 for relays, 000-250 for dimmers)
|
|
10
|
+
*/
|
|
11
|
+
import { SerialPort } from 'serialport';
|
|
12
|
+
import { ReadlineParser } from '@serialport/parser-readline';
|
|
13
|
+
import { EventEmitter } from 'events';
|
|
14
|
+
import { CommandQueue } from './commandQueue.js';
|
|
15
|
+
export class LitetouchConnection extends EventEmitter {
|
|
16
|
+
port = null;
|
|
17
|
+
parser = null;
|
|
18
|
+
commandQueue;
|
|
19
|
+
config;
|
|
20
|
+
pollingTimer = null;
|
|
21
|
+
loadAddresses = [];
|
|
22
|
+
deviceTypes = new Map();
|
|
23
|
+
currentPollingIndex = 0;
|
|
24
|
+
pendingResponse = null;
|
|
25
|
+
connected = false;
|
|
26
|
+
constructor(config) {
|
|
27
|
+
super();
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.commandQueue = new CommandQueue();
|
|
30
|
+
this.commandQueue.setProcessor(this.processCommand.bind(this));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Set the list of load addresses to poll with their device types
|
|
34
|
+
*/
|
|
35
|
+
setLoadAddresses(addresses, deviceTypes) {
|
|
36
|
+
this.loadAddresses = addresses;
|
|
37
|
+
if (deviceTypes) {
|
|
38
|
+
this.deviceTypes = deviceTypes;
|
|
39
|
+
}
|
|
40
|
+
this.currentPollingIndex = 0;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Open the serial port and start communication
|
|
44
|
+
*/
|
|
45
|
+
async open() {
|
|
46
|
+
if (this.port?.isOpen) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
this.port = new SerialPort({
|
|
51
|
+
path: this.config.serialPort,
|
|
52
|
+
baudRate: this.config.baudRate,
|
|
53
|
+
dataBits: 8,
|
|
54
|
+
stopBits: 1,
|
|
55
|
+
parity: 'none',
|
|
56
|
+
}, (err) => {
|
|
57
|
+
if (err) {
|
|
58
|
+
this.log(`Failed to open serial port: ${err.message}`);
|
|
59
|
+
reject(err);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.log(`Serial port opened: ${this.config.serialPort}`);
|
|
63
|
+
this.setupParser();
|
|
64
|
+
this.connected = true;
|
|
65
|
+
this.emit('connected');
|
|
66
|
+
resolve();
|
|
67
|
+
});
|
|
68
|
+
this.port.on('error', (err) => {
|
|
69
|
+
this.log(`Serial port error: ${err.message}`);
|
|
70
|
+
this.stopPolling();
|
|
71
|
+
this.commandQueue.clear();
|
|
72
|
+
this.emit('error', err);
|
|
73
|
+
});
|
|
74
|
+
this.port.on('close', () => {
|
|
75
|
+
this.log('Serial port closed');
|
|
76
|
+
this.connected = false;
|
|
77
|
+
this.stopPolling();
|
|
78
|
+
this.commandQueue.clear();
|
|
79
|
+
this.emit('disconnected');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Set up the readline parser for CR-terminated messages
|
|
85
|
+
*/
|
|
86
|
+
setupParser() {
|
|
87
|
+
if (!this.port)
|
|
88
|
+
return;
|
|
89
|
+
this.parser = this.port.pipe(new ReadlineParser({ delimiter: '\r' }));
|
|
90
|
+
this.parser.on('data', (data) => {
|
|
91
|
+
this.handleResponse(data);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Handle incoming response from Litetouch
|
|
96
|
+
*/
|
|
97
|
+
handleResponse(data) {
|
|
98
|
+
const trimmed = data.trim();
|
|
99
|
+
this.debug(`Received: "${trimmed}"`);
|
|
100
|
+
// Capture pending address before clearing pendingResponse
|
|
101
|
+
const pendingAddress = this.pendingResponse?.address;
|
|
102
|
+
if (this.pendingResponse) {
|
|
103
|
+
clearTimeout(this.pendingResponse.timeout);
|
|
104
|
+
this.pendingResponse.resolve(trimmed);
|
|
105
|
+
this.pendingResponse = null;
|
|
106
|
+
}
|
|
107
|
+
// Parse and emit status updates (pass the pending address for correlation)
|
|
108
|
+
const status = this.parseStatusResponse(trimmed, pendingAddress);
|
|
109
|
+
if (status) {
|
|
110
|
+
this.emit('loadStatus', status);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Parse a status response from Litetouch
|
|
115
|
+
* Response format: "18 VVV" where VVV is the level (000-250)
|
|
116
|
+
* The address must be provided since responses don't include it
|
|
117
|
+
*/
|
|
118
|
+
parseStatusResponse(response, address) {
|
|
119
|
+
// Match response pattern: 18 VVV (command code echo followed by value)
|
|
120
|
+
const match = response.match(/^18\s+(\d{3})$/);
|
|
121
|
+
if (!match || !address) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const raw = parseInt(match[1], 10);
|
|
125
|
+
const deviceType = this.deviceTypes.get(address);
|
|
126
|
+
// Convert raw value to percentage based on device type
|
|
127
|
+
// Dimmers: 0-250 maps linearly to 0-100%
|
|
128
|
+
// Switches: 0 = off, 1-250 = on (any non-zero value means on)
|
|
129
|
+
let level;
|
|
130
|
+
if (deviceType === 'switch') {
|
|
131
|
+
level = raw > 0 ? 100 : 0;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Default to dimmer behavior (linear 0-250 -> 0-100)
|
|
135
|
+
level = Math.round((raw / 250) * 100);
|
|
136
|
+
}
|
|
137
|
+
return { address, level, raw };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Process a command from the queue - sends it to the serial port
|
|
141
|
+
*/
|
|
142
|
+
async processCommand(cmd) {
|
|
143
|
+
if (!this.port?.isOpen) {
|
|
144
|
+
throw new Error('Serial port not open');
|
|
145
|
+
}
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const timeoutMs = this.config.commandTimeout;
|
|
148
|
+
// Extract address from query commands (format: " 18 MM-O")
|
|
149
|
+
let address;
|
|
150
|
+
const queryMatch = cmd.command.match(/^\s*18\s+(\d{1,2}-\d{1,2})/);
|
|
151
|
+
if (queryMatch) {
|
|
152
|
+
address = queryMatch[1];
|
|
153
|
+
}
|
|
154
|
+
// Set up timeout for response
|
|
155
|
+
const timeout = setTimeout(() => {
|
|
156
|
+
this.pendingResponse = null;
|
|
157
|
+
this.debug(`Command timeout: "${cmd.command}"`);
|
|
158
|
+
resolve(null); // Resolve with null on timeout instead of rejecting
|
|
159
|
+
}, timeoutMs);
|
|
160
|
+
this.pendingResponse = { resolve, reject, timeout, address };
|
|
161
|
+
// Send command with carriage return terminator
|
|
162
|
+
const fullCommand = `${cmd.command}\r`;
|
|
163
|
+
this.debug(`Sending: "${cmd.command}"`);
|
|
164
|
+
this.port.write(fullCommand, (err) => {
|
|
165
|
+
if (err) {
|
|
166
|
+
clearTimeout(timeout);
|
|
167
|
+
this.pendingResponse = null;
|
|
168
|
+
reject(err);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Query the status of a load
|
|
175
|
+
*/
|
|
176
|
+
async queryLoad(address) {
|
|
177
|
+
const command = ` 18 ${address}`;
|
|
178
|
+
return this.commandQueue.enqueuePolling(command);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Set a relay on or off
|
|
182
|
+
*/
|
|
183
|
+
async setRelay(address, on) {
|
|
184
|
+
const value = on ? '001' : '000';
|
|
185
|
+
const command = ` 10 ${address} ${value}`;
|
|
186
|
+
return this.commandQueue.enqueueHighPriority(command);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Set a dimmer level (0-100 percentage)
|
|
190
|
+
*/
|
|
191
|
+
async setDimmer(address, level) {
|
|
192
|
+
// Clamp to 0-100 and convert to 0-250 range
|
|
193
|
+
const clampedLevel = Math.max(0, Math.min(100, level));
|
|
194
|
+
const rawValue = Math.round((clampedLevel / 100) * 250);
|
|
195
|
+
const value = rawValue.toString().padStart(3, '0');
|
|
196
|
+
const command = ` 10 ${address} ${value}`;
|
|
197
|
+
return this.commandQueue.enqueueHighPriority(command);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Start the polling loop to query load statuses
|
|
201
|
+
*/
|
|
202
|
+
startPolling() {
|
|
203
|
+
if (this.pollingTimer) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.log(`Starting polling loop (${this.loadAddresses.length} loads, ${this.config.pollingInterval}ms interval)`);
|
|
207
|
+
this.pollNextLoad();
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Poll the next load in the list
|
|
211
|
+
*/
|
|
212
|
+
pollNextLoad() {
|
|
213
|
+
if (this.loadAddresses.length === 0) {
|
|
214
|
+
// No loads to poll, check again later
|
|
215
|
+
this.pollingTimer = setTimeout(() => this.pollNextLoad(), this.config.pollingInterval);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const address = this.loadAddresses[this.currentPollingIndex];
|
|
219
|
+
this.currentPollingIndex = (this.currentPollingIndex + 1) % this.loadAddresses.length;
|
|
220
|
+
this.queryLoad(address).catch((err) => {
|
|
221
|
+
this.debug(`Polling error for ${address}: ${err.message}`);
|
|
222
|
+
});
|
|
223
|
+
// Schedule next poll
|
|
224
|
+
this.pollingTimer = setTimeout(() => this.pollNextLoad(), this.config.pollingInterval);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Stop the polling loop
|
|
228
|
+
*/
|
|
229
|
+
stopPolling() {
|
|
230
|
+
if (this.pollingTimer) {
|
|
231
|
+
clearTimeout(this.pollingTimer);
|
|
232
|
+
this.pollingTimer = null;
|
|
233
|
+
this.log('Polling stopped');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Close the serial port and clean up
|
|
238
|
+
*/
|
|
239
|
+
async close() {
|
|
240
|
+
this.stopPolling();
|
|
241
|
+
this.commandQueue.clear();
|
|
242
|
+
if (this.pendingResponse) {
|
|
243
|
+
clearTimeout(this.pendingResponse.timeout);
|
|
244
|
+
this.pendingResponse.reject(new Error('Connection closing'));
|
|
245
|
+
this.pendingResponse = null;
|
|
246
|
+
}
|
|
247
|
+
if (this.port?.isOpen) {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
this.port.close((err) => {
|
|
250
|
+
if (err) {
|
|
251
|
+
this.log(`Error closing port: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
this.port = null;
|
|
254
|
+
this.parser = null;
|
|
255
|
+
this.connected = false;
|
|
256
|
+
resolve();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
this.port = null;
|
|
261
|
+
this.parser = null;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Check if the connection is open
|
|
265
|
+
*/
|
|
266
|
+
get isConnected() {
|
|
267
|
+
return this.connected && !!this.port?.isOpen;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Get the command queue length
|
|
271
|
+
*/
|
|
272
|
+
get queueLength() {
|
|
273
|
+
return this.commandQueue.length;
|
|
274
|
+
}
|
|
275
|
+
log(message) {
|
|
276
|
+
console.log(`[Litetouch] ${message}`);
|
|
277
|
+
}
|
|
278
|
+
debug(message) {
|
|
279
|
+
if (this.config.debug) {
|
|
280
|
+
console.log(`[Litetouch:Debug] ${message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
//# sourceMappingURL=litetouchConnection.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matterbridge Dynamic Platform for Litetouch 2000 Lighting System
|
|
3
|
+
*
|
|
4
|
+
* Creates Matter devices for each configured dimmer and switch load,
|
|
5
|
+
* handles commands from Matter controllers, and polls for status updates.
|
|
6
|
+
*/
|
|
7
|
+
import { MatterbridgeDynamicPlatform, PlatformConfig, PlatformMatterbridge } from 'matterbridge';
|
|
8
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
9
|
+
export declare class LitetouchPlatform extends MatterbridgeDynamicPlatform {
|
|
10
|
+
private connection;
|
|
11
|
+
private devices;
|
|
12
|
+
private deviceTypes;
|
|
13
|
+
private lastDimmerLevels;
|
|
14
|
+
private pendingOnTimers;
|
|
15
|
+
constructor(matterbridge: PlatformMatterbridge, log: AnsiLogger, config: PlatformConfig);
|
|
16
|
+
onStart(reason?: string): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Create Matter devices for all configured loads
|
|
19
|
+
*/
|
|
20
|
+
private createDevices;
|
|
21
|
+
/**
|
|
22
|
+
* Create a dimmable light device
|
|
23
|
+
*/
|
|
24
|
+
private createDimmerDevice;
|
|
25
|
+
/**
|
|
26
|
+
* Create an on/off switch device
|
|
27
|
+
*/
|
|
28
|
+
private createSwitchDevice;
|
|
29
|
+
/**
|
|
30
|
+
* Set dimmer level via serial
|
|
31
|
+
*/
|
|
32
|
+
private setDimmerLevel;
|
|
33
|
+
/**
|
|
34
|
+
* Set switch state via serial
|
|
35
|
+
*/
|
|
36
|
+
private setSwitch;
|
|
37
|
+
/**
|
|
38
|
+
* Handle status updates from polling
|
|
39
|
+
*/
|
|
40
|
+
private handleLoadStatus;
|
|
41
|
+
onConfigure(): Promise<void>;
|
|
42
|
+
onShutdown(reason?: string): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=platform.d.ts.map
|