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
package/dist/platform.js
ADDED
|
@@ -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
|
+
}
|