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,313 @@
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, MatterbridgeEndpoint, onOffLight, dimmableLight, bridgedNode, } from 'matterbridge';
8
+ import { OnOff, LevelControl } from 'matterbridge/matter/clusters';
9
+ import { LitetouchConnection } from './litetouchConnection.js';
10
+ export class LitetouchPlatform extends MatterbridgeDynamicPlatform {
11
+ connection = null;
12
+ devices = new Map();
13
+ deviceTypes = new Map();
14
+ // Track last known brightness level for each dimmer (0-100%)
15
+ // Used to restore brightness on 'on' command instead of going to 100%
16
+ lastDimmerLevels = new Map();
17
+ // Pending 'on' commands - delayed to allow level commands to override
18
+ // Apple Home sends 'on' BEFORE moveToLevelWithOnOff, causing a flash
19
+ pendingOnTimers = new Map();
20
+ constructor(matterbridge, log, config) {
21
+ super(matterbridge, log, config);
22
+ this.log.logName = 'LitetouchPlatform';
23
+ }
24
+ async onStart(reason) {
25
+ this.log.info(`Starting Litetouch platform${reason ? ` (${reason})` : ''}`);
26
+ const config = this.config;
27
+ // Validate configuration
28
+ if (!config.serialPort) {
29
+ this.log.error('Serial port not configured');
30
+ return;
31
+ }
32
+ const dimmers = config.dimmers || [];
33
+ const switches = config.switches || [];
34
+ if (dimmers.length === 0 && switches.length === 0) {
35
+ this.log.warn('No loads configured');
36
+ return;
37
+ }
38
+ // Create Matter devices for each load
39
+ await this.createDevices(dimmers, switches);
40
+ // Set up serial connection
41
+ const connectionConfig = {
42
+ serialPort: config.serialPort,
43
+ baudRate: config.baudRate || 9600,
44
+ pollingInterval: config.pollingInterval || 2000,
45
+ commandTimeout: config.commandTimeout || 1000,
46
+ debug: config.debug || false,
47
+ };
48
+ this.connection = new LitetouchConnection(connectionConfig);
49
+ // Set up all load addresses for polling with device type info
50
+ const allAddresses = [
51
+ ...dimmers.map(d => d.address),
52
+ ...switches.map(s => s.address),
53
+ ];
54
+ // Convert deviceTypes to the format expected by LitetouchConnection
55
+ const deviceTypeMap = new Map();
56
+ for (const [address, type] of this.deviceTypes) {
57
+ deviceTypeMap.set(address, type);
58
+ }
59
+ this.connection.setLoadAddresses(allAddresses, deviceTypeMap);
60
+ // Handle status updates from polling
61
+ this.connection.on('loadStatus', (status) => {
62
+ this.handleLoadStatus(status);
63
+ });
64
+ this.connection.on('error', (err) => {
65
+ this.log.error(`Connection error: ${err.message}`);
66
+ });
67
+ // Open connection
68
+ try {
69
+ await this.connection.open();
70
+ this.log.info('Serial connection established');
71
+ }
72
+ catch (err) {
73
+ this.log.error(`Failed to open serial port: ${err}`);
74
+ }
75
+ }
76
+ /**
77
+ * Create Matter devices for all configured loads
78
+ */
79
+ async createDevices(dimmers, switches) {
80
+ // Create dimmer devices
81
+ for (const dimmer of dimmers) {
82
+ const device = await this.createDimmerDevice(dimmer);
83
+ this.devices.set(dimmer.address, device);
84
+ this.deviceTypes.set(dimmer.address, 'dimmer');
85
+ }
86
+ // Create switch devices
87
+ for (const sw of switches) {
88
+ const device = await this.createSwitchDevice(sw);
89
+ this.devices.set(sw.address, device);
90
+ this.deviceTypes.set(sw.address, 'switch');
91
+ }
92
+ this.log.info(`Created ${dimmers.length} dimmers and ${switches.length} switches`);
93
+ }
94
+ /**
95
+ * Create a dimmable light device
96
+ */
97
+ async createDimmerDevice(load) {
98
+ const config = this.config;
99
+ // Create dimmable light device with bridgedNode device type
100
+ // bridgedNode MUST be included for proper bridge mode operation
101
+ const device = new MatterbridgeEndpoint([bridgedNode, dimmableLight], { id: `dimmer-${load.address}` }, config.debug || false);
102
+ // Add bridged device basic information using fluent API
103
+ device
104
+ .createDefaultBridgedDeviceBasicInformationClusterServer(load.name, `lt-${load.address}`, 0x0001, // Vendor ID
105
+ 'Litetouch', load.address)
106
+ .addRequiredClusterServers();
107
+ // Add command handlers
108
+ device.addCommandHandler('on', async () => {
109
+ // Apple Home sends 'on' BEFORE moveToLevelWithOnOff, which causes a flash
110
+ // Delay the 'on' command to allow a level command to override it
111
+ const existingTimer = this.pendingOnTimers.get(load.address);
112
+ if (existingTimer) {
113
+ clearTimeout(existingTimer);
114
+ }
115
+ const lastLevel = this.lastDimmerLevels.get(load.address) || 100;
116
+ this.log.debug(`Dimmer ${load.address} ON command -> delaying (will restore to ${lastLevel}%)`);
117
+ const timer = setTimeout(async () => {
118
+ this.pendingOnTimers.delete(load.address);
119
+ this.log.debug(`Dimmer ${load.address} ON command executing -> ${lastLevel}%`);
120
+ await this.setDimmerLevel(load.address, lastLevel);
121
+ }, 150); // Wait 150ms for a level command to arrive
122
+ this.pendingOnTimers.set(load.address, timer);
123
+ });
124
+ device.addCommandHandler('off', async () => {
125
+ // Cancel any pending 'on' command to prevent it firing after this off
126
+ const pendingTimer = this.pendingOnTimers.get(load.address);
127
+ if (pendingTimer) {
128
+ clearTimeout(pendingTimer);
129
+ this.pendingOnTimers.delete(load.address);
130
+ this.log.debug(`Dimmer ${load.address} cancelled pending ON`);
131
+ }
132
+ this.log.debug(`Dimmer ${load.address} OFF command`);
133
+ await this.setDimmerLevel(load.address, 0);
134
+ });
135
+ device.addCommandHandler('toggle', async () => {
136
+ // Use OnOff cluster state, not currentLevel (which retains last value when off)
137
+ const isOn = device.getAttribute(OnOff.Cluster.id, 'onOff');
138
+ if (isOn) {
139
+ this.log.debug(`Dimmer ${load.address} TOGGLE command -> OFF`);
140
+ await this.setDimmerLevel(load.address, 0);
141
+ }
142
+ else {
143
+ const lastLevel = this.lastDimmerLevels.get(load.address) || 100;
144
+ this.log.debug(`Dimmer ${load.address} TOGGLE command -> ${lastLevel}%`);
145
+ await this.setDimmerLevel(load.address, lastLevel);
146
+ }
147
+ });
148
+ device.addCommandHandler('moveToLevel', async ({ request }) => {
149
+ const level = Math.round((request.level / 254) * 100);
150
+ // Cancel any pending 'on' command - this level command takes precedence
151
+ const pendingTimer = this.pendingOnTimers.get(load.address);
152
+ if (pendingTimer) {
153
+ clearTimeout(pendingTimer);
154
+ this.pendingOnTimers.delete(load.address);
155
+ this.log.debug(`Dimmer ${load.address} cancelled pending ON`);
156
+ }
157
+ this.log.debug(`Dimmer ${load.address} MOVE_TO_LEVEL command: ${level}%`);
158
+ await this.setDimmerLevel(load.address, level);
159
+ });
160
+ device.addCommandHandler('moveToLevelWithOnOff', async ({ request }) => {
161
+ const level = Math.round((request.level / 254) * 100);
162
+ // Cancel any pending 'on' command - this level command takes precedence
163
+ const pendingTimer = this.pendingOnTimers.get(load.address);
164
+ if (pendingTimer) {
165
+ clearTimeout(pendingTimer);
166
+ this.pendingOnTimers.delete(load.address);
167
+ this.log.debug(`Dimmer ${load.address} cancelled pending ON`);
168
+ }
169
+ this.log.debug(`Dimmer ${load.address} MOVE_TO_LEVEL_WITH_ONOFF command: ${level}%`);
170
+ await this.setDimmerLevel(load.address, level);
171
+ });
172
+ // Register device immediately - Matterbridge 3.3.8 expects this in onStart
173
+ try {
174
+ await this.registerDevice(device);
175
+ this.log.info(`Registered dimmer device: ${load.name}`);
176
+ }
177
+ catch (err) {
178
+ this.log.error(`Failed to register dimmer ${load.name}: ${err}`);
179
+ }
180
+ return device;
181
+ }
182
+ /**
183
+ * Create an on/off switch device
184
+ */
185
+ async createSwitchDevice(load) {
186
+ const config = this.config;
187
+ // Create on/off light device with bridgedNode device type
188
+ // bridgedNode MUST be included for proper bridge mode operation
189
+ const device = new MatterbridgeEndpoint([bridgedNode, onOffLight], { id: `switch-${load.address}` }, config.debug || false);
190
+ // Add bridged device basic information using fluent API
191
+ device
192
+ .createDefaultBridgedDeviceBasicInformationClusterServer(load.name, `lt-${load.address}`, 0x0001, 'Litetouch', load.address)
193
+ .addRequiredClusterServers();
194
+ // Add command handlers
195
+ device.addCommandHandler('on', async () => {
196
+ this.log.debug(`Switch ${load.address} ON command`);
197
+ await this.setSwitch(load.address, true);
198
+ });
199
+ device.addCommandHandler('off', async () => {
200
+ this.log.debug(`Switch ${load.address} OFF command`);
201
+ await this.setSwitch(load.address, false);
202
+ });
203
+ device.addCommandHandler('toggle', async () => {
204
+ const currentState = device.getAttribute(OnOff.Cluster.id, 'onOff');
205
+ this.log.debug(`Switch ${load.address} TOGGLE command -> ${!currentState}`);
206
+ await this.setSwitch(load.address, !currentState);
207
+ });
208
+ // Register device immediately - Matterbridge 3.3.8 expects this in onStart
209
+ try {
210
+ await this.registerDevice(device);
211
+ this.log.info(`Registered switch device: ${load.name}`);
212
+ }
213
+ catch (err) {
214
+ this.log.error(`Failed to register switch ${load.name}: ${err}`);
215
+ }
216
+ return device;
217
+ }
218
+ /**
219
+ * Set dimmer level via serial
220
+ */
221
+ async setDimmerLevel(address, level) {
222
+ if (!this.connection?.isConnected) {
223
+ this.log.warn(`Cannot set dimmer ${address}: not connected`);
224
+ return;
225
+ }
226
+ try {
227
+ await this.connection.setDimmer(address, level);
228
+ // Immediately query to get the actual state
229
+ await this.connection.queryLoad(address);
230
+ }
231
+ catch (err) {
232
+ this.log.error(`Failed to set dimmer ${address}: ${err}`);
233
+ }
234
+ }
235
+ /**
236
+ * Set switch state via serial
237
+ */
238
+ async setSwitch(address, on) {
239
+ if (!this.connection?.isConnected) {
240
+ this.log.warn(`Cannot set switch ${address}: not connected`);
241
+ return;
242
+ }
243
+ try {
244
+ await this.connection.setRelay(address, on);
245
+ // Immediately query to get the actual state
246
+ await this.connection.queryLoad(address);
247
+ }
248
+ catch (err) {
249
+ this.log.error(`Failed to set switch ${address}: ${err}`);
250
+ }
251
+ }
252
+ /**
253
+ * Handle status updates from polling
254
+ */
255
+ handleLoadStatus(status) {
256
+ const device = this.devices.get(status.address);
257
+ if (!device) {
258
+ return;
259
+ }
260
+ const deviceType = this.deviceTypes.get(status.address);
261
+ try {
262
+ if (deviceType === 'dimmer') {
263
+ // Update OnOff state
264
+ const isOn = status.level > 0;
265
+ device.setAttribute(OnOff.Cluster.id, 'onOff', isOn, this.log);
266
+ // Update LevelControl ONLY when light is on
267
+ // Matter spec requires currentLevel to be between minLevel (1) and maxLevel (254)
268
+ // When off, we leave currentLevel at its last value
269
+ if (isOn) {
270
+ // Save the last known level for restoring on 'on' command
271
+ this.lastDimmerLevels.set(status.address, status.level);
272
+ // Convert 1-100 to 1-254 (Matter range)
273
+ const matterLevel = Math.max(1, Math.round((status.level / 100) * 254));
274
+ device.setAttribute(LevelControl.Cluster.id, 'currentLevel', matterLevel, this.log);
275
+ }
276
+ this.log.debug(`Dimmer ${status.address} status: ${status.level}% (on=${isOn})`);
277
+ }
278
+ else if (deviceType === 'switch') {
279
+ // Update OnOff state
280
+ const isOn = status.level > 0;
281
+ device.setAttribute(OnOff.Cluster.id, 'onOff', isOn, this.log);
282
+ this.log.debug(`Switch ${status.address} status: ${isOn ? 'ON' : 'OFF'}`);
283
+ }
284
+ }
285
+ catch (err) {
286
+ // Silently ignore errors when device is not yet active
287
+ this.log.debug(`Status update skipped for ${status.address}: device not ready`);
288
+ }
289
+ }
290
+ async onConfigure() {
291
+ this.log.info('Configuring Litetouch platform');
292
+ // Start polling after Matter server is ready
293
+ if (this.connection?.isConnected) {
294
+ this.connection.startPolling();
295
+ this.log.info('Polling started');
296
+ }
297
+ }
298
+ async onShutdown(reason) {
299
+ this.log.info(`Shutting down Litetouch platform${reason ? ` (${reason})` : ''}`);
300
+ // Clear any pending on timers
301
+ for (const timer of this.pendingOnTimers.values()) {
302
+ clearTimeout(timer);
303
+ }
304
+ this.pendingOnTimers.clear();
305
+ if (this.connection) {
306
+ await this.connection.close();
307
+ this.connection = null;
308
+ }
309
+ this.devices.clear();
310
+ this.deviceTypes.clear();
311
+ }
312
+ }
313
+ //# sourceMappingURL=platform.js.map
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "matterbridge-litetouch",
3
+ "version": "1.0.0",
4
+ "description": "Matterbridge plugin for Litetouch 2000 lighting control systems",
5
+ "author": "signal15",
6
+ "license": "PolyForm-Noncommercial-1.0.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/signal15/matterbridge-litetouch.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/signal15/matterbridge-litetouch/issues"
13
+ },
14
+ "homepage": "https://github.com/signal15/matterbridge-litetouch#readme",
15
+ "type": "module",
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "watch": "tsc --watch",
21
+ "clean": "rimraf dist",
22
+ "lint": "eslint src --ext .ts",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "matterbridge",
27
+ "matter",
28
+ "litetouch",
29
+ "lighting",
30
+ "home-automation",
31
+ "smart-home"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ },
36
+ "dependencies": {
37
+ "@serialport/parser-readline": "^12.0.0",
38
+ "serialport": "^12.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^20.10.0",
42
+ "@types/serialport": "^8.0.5",
43
+ "eslint": "^8.55.0",
44
+ "matterbridge": "^3.5.0",
45
+ "rimraf": "^5.0.5",
46
+ "typescript": "^5.3.0"
47
+ },
48
+ "files": [
49
+ "dist/**/*.js",
50
+ "dist/**/*.d.ts",
51
+ "config.schema.json"
52
+ ]
53
+ }