loupedeck-commander 1.2.12 → 1.3.1

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,499 @@
1
+
2
+ import { loadImage } from 'canvas'
3
+ //import { loadImage } from "https://deno.land/x/canvas/mod.ts";
4
+ import { calcDelta, invertColor } from './utils.mjs'
5
+ import format from 'string-template'
6
+
7
+ import { shellinterface, httpinterface, opcuainterface, profileEmitter } from '../interfaces/interfaces.mjs'
8
+
9
+ export const ButtonType = {
10
+ NONE: '',
11
+ TOGGLE: 'TOGGLE',
12
+ PUSH: 'PUSH'
13
+ }
14
+
15
+
16
+
17
+ export class Button {
18
+ #profile
19
+ #params
20
+ //#data
21
+ #index = 0
22
+ #enforcedIndex = -1
23
+ #event
24
+ #keys
25
+ #states
26
+ // Timestamp when button was pressed
27
+ timeStampPressed
28
+ // Timestamp when button was released
29
+ timeStampReleased
30
+ // Time actually hold the button in ms
31
+ timeHold
32
+ #counter = 0
33
+
34
+ constructor(id, width, height, data = {}, key, profile = {}) {
35
+ this.#states = {}
36
+ this.#keys = []
37
+
38
+ this.id = id
39
+ this.#index = 0
40
+ this.#enforcedIndex = -1
41
+
42
+ this.#profile = profile
43
+ this.#params = {
44
+ "key": key,
45
+ "state": '',
46
+ "width": width,
47
+ "height": height,
48
+ "min": 0,
49
+ "max": 100,
50
+ "x": 0,
51
+ "y": 0,
52
+ "color": '#000000',
53
+ "text": '',
54
+ "textColor": '#ffffff',
55
+ "textAlign": 'center',
56
+ "textBaseline": 'top',
57
+ "font": '16px Arial',
58
+ "value": 50,
59
+ "minPressed": 25,
60
+ "moveLeft": false,
61
+ "moveRight": false,
62
+ "moveUp": false,
63
+ "moveDown": false,
64
+ "nodeid": '',
65
+ "type": ButtonType.TOGGLE,
66
+ "group": key,
67
+ "filter": "",
68
+ "blink": false,
69
+ "vibrate": false,
70
+ "profile": "",
71
+ "brightness": undefined,
72
+ "default": "0"
73
+ }
74
+
75
+ if (profile.parameters) {
76
+ let params = Object.keys(profile.parameters)
77
+ for (let i = 0; i < params.length; i++) {
78
+ const k = params[i]
79
+ this.#params[k] = profile.parameters[k]
80
+ }
81
+ }
82
+ // enforce key, so it's not oveerwritten somewhere else
83
+ this.#params.key = key
84
+
85
+ // New approach: Use all data from data.params:
86
+ if (data.params) {
87
+ this.#params = {
88
+ ...this.#params,
89
+ ...data.params
90
+ }
91
+
92
+ // Format the params:
93
+ this.#params.group = format(this.#params.group, this.getParams({}))
94
+ this.#params.nodeid = format(this.#params.nodeid, this.getParams({}))
95
+ }
96
+
97
+ if (data.states) {
98
+ this.#states = data.states
99
+ this.#keys = Object.keys(this.#states)
100
+
101
+ var defaultIndex = this.#keys.indexOf(this.#params.default)
102
+ if (defaultIndex >= 0) {
103
+ this.#index = defaultIndex
104
+ } else {
105
+ this.#params.default = this.#keys[0]
106
+ // console.info(` button ${id} default set to ${this.#params.default}, available states`, this.#keys)
107
+ }
108
+ this.#params.state = this.#keys[this.#index]
109
+ }
110
+
111
+ // Set the default value for the button:
112
+
113
+ // Legacy approach: Use data directly:
114
+ if (data.minPressed) {
115
+ console.warn("Legacy approach: minPressed is deprecated, use params.minPressed instead")
116
+ this.#params.minPressed = data.minPressed
117
+ }
118
+ if (data.text) {
119
+ console.warn("Legacy approach: text is deprecated, use params.text instead")
120
+ this.#params.text = data.text
121
+ }
122
+ if (data.nodeid) {
123
+ this.#params.nodeid = format(data.nodeid, this.getParams({}))
124
+ console.warn("Legacy approach: nodeid is deprecated, use params.nodeid instead", this.#params.nodeid)
125
+ }
126
+ if (data.type) {
127
+ console.warn("Legacy approach: type is deprecated, use params.type instead")
128
+ this.#params.type = data.type
129
+ }
130
+
131
+ if (data.default) {
132
+ console.warn("Legacy approach: default is deprecated, use params.default instead")
133
+ this.#index = this.#keys.indexOf(data.default)
134
+ }
135
+
136
+ if (data.group) {
137
+ console.warn("Legacy approach: group is deprecated, use params.group instead")
138
+ this.#params.group = format(data.group, this.getParams({}))
139
+ }
140
+ // End Legacy approach
141
+ }
142
+
143
+ setState(index = 0) {
144
+ this.#index = index
145
+ }
146
+
147
+ /**
148
+ * Draw a physical button (color in RGBA format) on a device
149
+ * @param {*} device
150
+ * @param {*} id - id of the physical button
151
+ * @returns
152
+ */
153
+ async drawPhysical(device, id) {
154
+ const elem = this.getCurrentElement()
155
+ if (!elem || !elem.color) { return }
156
+
157
+ const r = parseInt(elem.color.slice(1, 3), 16)
158
+ const g = parseInt(elem.color.slice(3, 5), 16)
159
+ const b = parseInt(elem.color.slice(5, 7), 16)
160
+
161
+ try {
162
+ var idx = parseInt(id, 10);
163
+
164
+ const val = {
165
+ id: idx,
166
+ color: `rgba(${r}, ${g}, ${b})`
167
+ }
168
+ // console.log(' Set Button Color',id, val.id,elem.color, val.color)
169
+ device.setButtonColor(val)
170
+ } catch (error) {
171
+ console.error(' Error', error)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Draw the Touch-Screen Elementat the given row and column
177
+ * @param {*} row
178
+ * @param {*} column
179
+ * @param {*} ctx
180
+ */
181
+ async draw(row, column, ctx) {
182
+ // Calculate the x/y position based on the row and column and the width/height properties of the button
183
+ const x = column * this.#params.width
184
+ const y = row * this.#params.height
185
+
186
+ // Modifications in draw:
187
+ // - Support color and textColor settings also in params section which
188
+ // can be overwriten by button state property. This way color and textColor
189
+ // can be defined overall for all states without repeating them in every state.
190
+ // - Same for blink property.
191
+
192
+ let params = this.getParams(this.getCurrentElement())
193
+
194
+ let color = params.color
195
+ let textColor = params.textColor
196
+
197
+ if (params.blink && this.#counter % 2 == 0) {
198
+ // If blink is set, we switch textcolor and background color:
199
+ color = params.textColor
200
+ textColor = params.color
201
+ }
202
+
203
+ // Apply the color as fillStyle:
204
+ ctx.fillStyle = color
205
+ // Draw the background of the button::
206
+ ctx.fillRect(x, y, params.width, params.height)
207
+
208
+ // if we have an icon/image draw it on top of the button
209
+ if (params.imgBuffer) {
210
+ ctx.drawImage(params.imgBuffer, x, y, params.width, params.height)
211
+ }
212
+ // if we have a text defined, draw it on top:
213
+ if (params.text != undefined && params.text != '') {
214
+ ctx.fillStyle = textColor
215
+ ctx.font = params.font
216
+ ctx.textBaseline = params.textBaseline;
217
+ ctx.textAlign = params.textAlign;
218
+ let dynamicText = format(params.text, params)
219
+ let tx = x
220
+ let ty = y
221
+ // Handle Text Horizontal Alignment
222
+ switch (params.textAlign) {
223
+ case "center":
224
+ tx += this.#params.width / 2;
225
+ break;
226
+ case "left":
227
+ tx += 6;
228
+ break;
229
+ case "right":
230
+ tx += this.#params.width - 6;
231
+ break;
232
+ }
233
+ // Handle Text Vertical Alignment
234
+ switch (params.textBaseline) {
235
+ case "middle":
236
+ ty += this.#params.height / 2;
237
+ break;
238
+ case "top":
239
+ ty += 6;
240
+ ctx.textBaseline = "top";
241
+ break;
242
+ case "bottom":
243
+ ty += this.#params.height - 6;
244
+ ctx.textBaseline = "bottom";
245
+ break;
246
+ }
247
+ ctx.fillText(dynamicText, x + 6, y + 6)
248
+ }
249
+
250
+ this.#counter = this.#counter + 1
251
+ }
252
+
253
+ async load(globalConfig) {
254
+ this.#profile = globalConfig
255
+ for (let i = 0; i < this.#keys.length; i++) {
256
+ const key = this.#keys[i]
257
+ const elem = this.#states[key]
258
+ const file = elem.image
259
+ if (file !== undefined && file !== '') {
260
+ try {
261
+ this.#states[key].imgBuffer = await loadImage(file)
262
+ } catch (e) {
263
+ console.error('No such image', file)
264
+ return false
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ getCurrentElement() {
271
+ const state = this.#keys[this.#index]
272
+ return this.#states[state]
273
+ }
274
+
275
+ /**
276
+ * Triggered when either a physical button, touchscreen button or Knob Button was pressed
277
+ * Updates the timeStampPressed and timeHold, and checks if the button was hold long enough (minPressed attribute)
278
+ * If the button type was not TOGGLE, it will decrease the index to the previous state.
279
+ * @returns
280
+ */
281
+ pressed() {
282
+ this.timeStampPressed = Date.now()
283
+ this.updateState(this.#index, "pressed", true)
284
+ this.#index++
285
+
286
+ if (this.#enforcedIndex >= 0) {
287
+ console.log("Enforced Index", this.#enforcedIndex)
288
+ this.#index = this.#enforcedIndex
289
+ }
290
+
291
+ // Update the State according to the correctly pressed state
292
+ if (this.#index < 0) { this.#index = this.#keys.length - 1 }
293
+ this.#index %= this.#keys.length
294
+
295
+ return true
296
+ }
297
+
298
+ /**
299
+ * Triggered when either a physical button, touchscreen button or Knob Button was released
300
+ * Updates the timeStampReleased and timeHold, and checks if the button was hold long enough (minPressed attribute)
301
+ * If the button type was not TOGGLE, it will decrease the index to the previous state.
302
+ * @returns
303
+ */
304
+ released() {
305
+ let elem = this.getCurrentElement()
306
+ if (!elem) { return false }
307
+ this.timeStampReleased = Date.now()
308
+ this.timeHold = this.timeStampReleased - this.timeStampPressed
309
+
310
+ let bExecute = true
311
+ if (this.timeHold < this.#params.minPressed) {
312
+ // Update the State according to the not correct pressed state
313
+ console.log('Did not hold minimum time of ', this.#params.minPressed, 'only', this.timeHold)
314
+ bExecute = false
315
+ }
316
+
317
+ // Update the State according to the correctly pressed state
318
+ switch (this.#params.type) {
319
+ case ButtonType.TOGGLE:
320
+ // do nothing
321
+ break
322
+ default:
323
+ this.#index--
324
+ break
325
+ }
326
+
327
+ if (this.#enforcedIndex >= 0 && this.#enforcedIndex != this.#index) {
328
+ // If we have an enforced index, we set it to the enforced index:
329
+ console.log("Enforcing Index", this.#enforcedIndex, "instead of", this.#index)
330
+ this.#index = this.#enforcedIndex
331
+ }
332
+
333
+ // Update the State according to the correctly pressed state
334
+ if (this.#index < 0) { this.#index = this.#keys.length - 1 }
335
+ this.#index %= this.#keys.length
336
+
337
+
338
+ this.updateState(this.#index, "released", bExecute)
339
+
340
+ return true
341
+ }
342
+
343
+
344
+ updateState(index, eventType, bExecute) {
345
+ this.#index = index
346
+ this.#event = eventType
347
+ if (bExecute)
348
+ this.runCommand()
349
+ return true
350
+ }
351
+
352
+ /**
353
+ * Triggered when one of the physical KNOBS is rotated
354
+ * Calculates the new value based on the delta, and store it in the params.value
355
+ * @param {*} delta
356
+ * @returns
357
+ */
358
+ async rotated(delta) {
359
+ if (!this.getCurrentElement()) { return false }
360
+
361
+ this.#event = "rotated"
362
+ this.#params.value = calcDelta(this.#params.value, delta, this.#params.min, this.#params.max)
363
+ return this.runCommand()
364
+ }
365
+
366
+ /**
367
+ * This function is called when the value of a button is changed from outside (e.g. coming from an opcua subscription)
368
+ * @param {*} buttonID: the ID of the button that caused the change
369
+ * @param {*} nodeid: the nodeid that changed
370
+ * @param {*} val : the current value of the given nodeid
371
+ * @returns
372
+ */
373
+ async changed(buttonID, nodeid, val) {
374
+ // Only handle updates within the same group identified by nodeid
375
+ if (nodeid !== this.#params.nodeid) {
376
+ return
377
+ }
378
+
379
+ this.#index = 0;
380
+ this.#enforcedIndex = -1;
381
+ for (let i = 0; i < this.#keys.length; i++) {
382
+ let stateStr = this.#keys[i].toString()
383
+ let valStr = val.toString()
384
+ // check if the state-name is same as the value we get from outside:
385
+ if (valStr == stateStr) {
386
+ if (i !== this.#index) {
387
+ console.info("Changed index", this.id, nodeid, val, i)
388
+ this.#index = i;
389
+ console.info("enforce State index", this.id, nodeid, val, i)
390
+ this.#enforcedIndex = this.#index;
391
+ }
392
+ break;
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Handle a touchmove event on a touchscreen button
399
+ * This function calculates the new value based on the x and y coordinates of the touchmove event.
400
+ * It updates the moveLeft, moveRight, moveUp and moveDown parameters based on the x and y coordinates.
401
+ * It also calculates the delta for the value based on the y coordinate and updates the value parameter.
402
+ * @param {*} x
403
+ * @param {*} y
404
+ * @returns
405
+ */
406
+ async touchmove(x, y) {
407
+
408
+ let delta = 0
409
+ if (x > this.#params.x) {
410
+ this.#params.moveRight = true
411
+ this.#params.moveLeft = false
412
+ } else if (x < this.#params.x) {
413
+ this.#params.moveRight = false
414
+ this.#params.moveLeft = true
415
+ }
416
+
417
+ if (y > this.#params.y) {
418
+ this.#params.moveDown = true
419
+ this.#params.moveUp = false
420
+ delta = -1
421
+ } else if (y < this.#params.y) {
422
+ this.#params.moveDown = false
423
+ this.#params.moveUp = true
424
+ delta = 1
425
+ }
426
+
427
+ this.#params.x = (x % 100)
428
+ this.#params.y = (y % 100)
429
+
430
+ // Calculate delta for value no touchmove up/down
431
+ this.#params.value = calcDelta(this.#params.value, delta, this.#params.min, this.#params.max)
432
+
433
+ // console.log(`d: ${this.#params.moveDown} r: ${this.#params.moveRight} `)
434
+ return false
435
+ }
436
+
437
+ getParams(elem) {
438
+ // Call an action - include dynamic parameters
439
+ // and also all attributes of elem + global config
440
+ const params = {
441
+ ...this.#profile.parameters,
442
+ ...this.#params,
443
+ ...elem,
444
+ id: this.id,
445
+ //key: this.key,
446
+ event: this.#event,
447
+ pressed: this.#event == "pressed",
448
+ released: this.#event == "released",
449
+ rotated: this.#event == "rotated",
450
+ }
451
+ return params
452
+ }
453
+
454
+ /**
455
+ * Run a command based on the current element's parameters.
456
+ * This function checks if the current element has a command, http request or opcua request defined.
457
+ * If so, it will execute the command using the respective interface (shell, http or opcua).
458
+ * It will also emit profile and brightness changes if defined in the parameters.
459
+ * @returns
460
+ */
461
+ async runCommand() {
462
+ //const elem = this.getCurrentElement()
463
+ let params = this.getParams(this.getCurrentElement())
464
+
465
+ // Only continue, if we have an element:
466
+ if (!params) {
467
+ return
468
+ }
469
+ // Filter for Event Type:
470
+ if (params.filter && params.filter != this.#event) {
471
+ return
472
+ }
473
+
474
+ if (params.profile !== undefined && params.profile !== '') {
475
+ profileEmitter.emit("profileChanged", params.profile)
476
+ }
477
+
478
+ if (params.brightness !== undefined) {
479
+ profileEmitter.emit("brightnessChanged", params.brightness)
480
+ }
481
+
482
+ if (params.vibrate !== undefined) {
483
+ profileEmitter.emit("vibrate", params.vibrate)
484
+ }
485
+
486
+ let res = ''
487
+ if ('cmd' in params && shellinterface) {
488
+ res = await shellinterface.call(params.cmd, params)
489
+ }
490
+ if ('http' in params && httpinterface) {
491
+ res = await httpinterface.call(params.http, params)
492
+ }
493
+ if ('opcua' in params && opcuainterface) {
494
+ res = await opcuainterface.call(params.opcua, params)
495
+ }
496
+
497
+ return res
498
+ }
499
+ }
package/common/utils.mjs CHANGED
@@ -1,17 +1,29 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs'
2
+ import YAML from 'yaml'
2
3
 
3
4
  /**
4
- * Read a JSON File
5
+ * Read a Config file in either JSON or YAML Fileformat
6
+ * @param {string} fileName - The name of the file to read
7
+ * @returns {object} - The parsed object from the file
5
8
  */
6
- export function readJSONFile (fileName) {
7
- let data
9
+ export function readConfigFile (fileName) {
10
+ let obj = undefined
8
11
  try {
9
- data = readFileSync(fileName, 'utf8')
10
- return JSON.parse(data)
12
+ //console.log("Reading File:", fileName)
13
+ let data = readFileSync(fileName, 'utf8')
14
+ // If the file is a YAML file, parse it
15
+ if (fileName.endsWith('.yaml') || fileName.endsWith('.yml')) {
16
+ obj = YAML.parse(data)
17
+ } else if (fileName.endsWith('.json')) {
18
+ // If the file is a JSON file, parse it
19
+ obj = JSON.parse(data)
20
+ // automatically convert all json to yaml
21
+ writeYAMLFile(fileName.replace(".json",".yaml"), obj)
22
+ }
11
23
  } catch (error) {
12
- console.info(`Error reading File: ${fileName}`)
13
- return data
24
+ console.info(`Error reading File: ${fileName}`, error)
14
25
  }
26
+ return obj
15
27
  }
16
28
 
17
29
  /**
@@ -22,6 +34,15 @@ export function writeJSONFile (fileName, jsonObj) {
22
34
  writeFileSync(fileName, data)
23
35
  }
24
36
 
37
+ /**
38
+ * Write a JSON File
39
+ */
40
+ export function writeYAMLFile (fileName, jsonObj) {
41
+ const data = YAML.stringify(jsonObj)
42
+ writeFileSync(fileName, data)
43
+ }
44
+
45
+
25
46
  /**
26
47
  * Calculate the delta of a value within a range between min and max with overflow
27
48
  * @param {*} data
package/config.yaml ADDED
@@ -0,0 +1,6 @@
1
+ application: Example
2
+ profiles:
3
+ - name: profile-1
4
+ file: profile-1.yaml
5
+ - name: profile-2
6
+ file: profile-2.yaml
@@ -57,7 +57,7 @@ export class BaseIf extends EventEmitter {
57
57
 
58
58
  LogDebug(...args){
59
59
  if (this.options && this.options.verbose){
60
- let str = new String(args)
60
+ let str = args.join(' ')
61
61
  process.stdout.write(str.toString())
62
62
  }
63
63
  }
@@ -0,0 +1,34 @@
1
+ import * as shellif from '../interfaces/shellif.mjs'
2
+ import * as httpif from '../interfaces/httpif.mjs'
3
+ import * as opcuaif from '../interfaces/opcuaif.mjs'
4
+ import { EventEmitter } from 'node:events'
5
+
6
+ export var opcuainterface = undefined
7
+ export var httpinterface = undefined
8
+ export var shellinterface = undefined
9
+ export var profileEmitter = undefined
10
+
11
+ export async function InitializeInterfaces(appConfig){
12
+ if (opcuainterface === undefined ){
13
+ opcuainterface = new opcuaif.OPCUAIf()
14
+ }
15
+ // the opcua interface needs the profile to register nodes with subscriptions:
16
+ opcuainterface.init(appConfig.parameters,appConfig)
17
+ if (httpinterface === undefined)
18
+ httpinterface = new httpif.HTTPif()
19
+ if (shellinterface === undefined)
20
+ shellinterface = new shellif.SHELLif()
21
+ if (profileEmitter === undefined)
22
+ profileEmitter = new EventEmitter()
23
+ }
24
+
25
+ export async function StopInterfaces(){
26
+ if (opcuainterface !== undefined )
27
+ await opcuainterface.stop()
28
+ if (httpinterface !== undefined)
29
+ await httpinterface.stop()
30
+ if (shellinterface !== undefined)
31
+ await shellinterface.stop()
32
+
33
+ }
34
+