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.
@@ -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