diff options
author | Determinant <[email protected]> | 2024-08-16 15:08:45 -0700 |
---|---|---|
committer | Determinant <[email protected]> | 2024-08-16 15:08:45 -0700 |
commit | fbe966903dd031e93150141ed0e8a758f778c524 (patch) | |
tree | ae8bfc177531253e19b88d39e534288ee64e5cc5 | |
parent | 773a788835503182af3901d65471e0e53e0f5a5f (diff) |
finish
-rw-r--r-- | README.rst | 17 | ||||
-rwxr-xr-x | app.mjs (renamed from ctrl.mjs) | 255 | ||||
-rw-r--r-- | figures/ap-page.jpg | bin | 0 -> 844925 bytes | |||
-rw-r--r-- | figures/main-page.jpg | bin | 0 -> 874527 bytes | |||
-rw-r--r-- | profile.yaml | 47 | ||||
-rw-r--r-- | xplane.mjs | 100 |
6 files changed, 286 insertions, 133 deletions
@@ -2,8 +2,21 @@ Loupedeck Control ----------------- - Setup: ``npm install`` -- Run: ``./ctrl.mjs`` +- Run: ``./app.mjs`` + + +Demo +---- + +.. raw:: html + + <div align="center"> + <img src="https://raw.githubusercontent.com/Determinant/loupedeck-ctrl/main/figures/main-page.jpg" width="70%"> + <img src="https://raw.githubusercontent.com/Determinant/loupedeck-ctrl/main/figures/ap-page.jpg" width="70%"> + </div> + +Video: https://photos.app.goo.gl/1hAQ19DZQRo4RRr9A Profile is currently in ``profile.yaml``. -Permission issue: copy ``50-loupedeck.rules`` to be under ``/etc/udev/rules.d`` and then ``sudo udevadm control --reload-rules && sudo udevadm trigger``. +Linux permission issue: copy ``50-loupedeck.rules`` to be under ``/etc/udev/rules.d`` and then ``sudo udevadm control --reload-rules && sudo udevadm trigger``. @@ -7,11 +7,12 @@ import { registerFont } from "canvas"; import { discover, HAPTIC } from "loupedeck"; import { readFile } from "fs/promises"; import { parse } from "yaml"; -import { subscribeDataRef, sendCommand } from "./xplane.mjs"; +import { XPlane } from "./xplane.mjs"; const labelFont = "OCR A Extended"; const labelSize = 22; const pages = parse(await readFile("./profile.yaml", "utf8")); +const xplane = new XPlane(); // state of the controller let currentPage = pages[0].hasOwnProperty("default") ? pages[0].default : 1; @@ -38,7 +39,7 @@ const takeAction = (labeled, type, haptics) => { return; } if (actionSpec.hasOwnProperty("xplane_cmd")) { - sendCommand(actionSpec.xplane_cmd); + xplane.sendCommand(actionSpec.xplane_cmd); } if (haptics) { device.vibrate(HAPTIC.REV_FASTEST); @@ -107,31 +108,7 @@ const drawKey = (key, label, down) => { c.strokeRect(padding, padding, w - padding * 2, h - padding * 2); if (label) { - const { text, text2, font, font2 } = rectifyLabel(label); - // draw the label - c.font = font; - const m1 = c.measureText(text); - const x1 = (w - m1.width) / 2; - if (text2 != null) { - const m2 = c.measureText(text2); - const h1 = - m1.actualBoundingBoxAscent - m1.actualBoundingBoxDescent; - const h2 = - m2.actualBoundingBoxAscent - m2.actualBoundingBoxDescent; - const sep = h1; - const y1 = h / 2 + h1 / 2 - sep; - const x2 = (w - m2.width) / 2; - const y2 = y1 + h1 / 2 + sep + h2 / 2; - c.fillText(text, x1, y1); - c.font = font2; - c.fillText(text2, x2, y2); - } else { - const y1 = - h / 2 + - (m1.actualBoundingBoxAscent - m1.actualBoundingBoxDescent) / - 2; - c.fillText(text, x1, y1); - } + drawDoubleLineText(c, label); } }); }; @@ -164,7 +141,7 @@ const drawSideKnobs = (side, labels, highlight) => { w - x_padding * 2, h - y_padding * 2, ); - if (labels && labels.length > i) { + if (labels && labels.length > i && labels[i]) { const { text, font, color_bg, color_fg } = rectifyLabel( labels[i], ); @@ -196,71 +173,147 @@ const drawSideKnobs = (side, labels, highlight) => { }); }; -const drawGauge = (key, label, value) => { - device.drawKey(key, (c) => { - const display = label.display; - const padding = 10; - const bg = "black"; - const fg = "white"; - const w = c.canvas.width; - const h = c.canvas.height; +const drawDoubleLineText = (c, spec) => { + const { text, text2, font, font2 } = rectifyLabel(spec); + const w = c.canvas.width; + const h = c.canvas.height; + + c.font = font; + const m1 = c.measureText(text); + const x1 = (w - m1.width) / 2; + if (text2 != null) { + const m2 = c.measureText(text2); + const h1 = m1.actualBoundingBoxAscent - m1.actualBoundingBoxDescent; + const h2 = m2.actualBoundingBoxAscent - m2.actualBoundingBoxDescent; + const sep = h1; + const y1 = h / 2 + h1 / 2 - sep; + const x2 = (w - m2.width) / 2; + const y2 = y1 + h1 / 2 + sep + h2 / 2; + c.fillText(text, x1, y1); + c.font = font2; + c.fillText(text2, x2, y2); + } else { + const y1 = + h / 2 + + (m1.actualBoundingBoxAscent - m1.actualBoundingBoxDescent) / 2; + c.fillText(text, x1, y1); + } +}; - const min = display.min; - const max = display.max; - const stops = display.stops; +const formatDisplayText = (display, value) => { + if (isNaN(value)) { + return "X"; + } + if (display.formatter) { + return Function( + "$value", + `"use strict"; return(\`${display.formatter}\`);`, + )(value); + } else { + return value + .toFixed(display.decimals ? display.decimals : 0) + .toString(); + } +}; - if (isNaN(value)) { - value = 0; - } - let reading = (value - min) / max; - if (isNaN(reading)) { - reading = 0; - } +const drawTextGauge = (c, display, value) => { + const bg = "black"; + const fg = "white"; + const w = c.canvas.width; + const h = c.canvas.height; + + const text = formatDisplayText(display, value); + const m = c.measureText(text); + + // draw background + c.fillStyle = bg; + c.fillRect(0, 0, w, h); + c.fillStyle = fg; + c.strokeStyle = fg; + c.lineWidth = 1; + + drawDoubleLineText(c, { + text, + text2: display.tag, + }); +}; - const text = value.toString(); +const drawMeterGauge = (c, display, value) => { + const bg = "black"; + const fg = "white"; + const w = c.canvas.width; + const h = c.canvas.height; + + const { min, max, stops } = display || {}; + + if (min == null) { + return; + } + + let reading = (value - min) / (max - min); + if (isNaN(reading)) { + reading = min; + } + + const text = formatDisplayText(display, value); + + // draw background + c.fillStyle = bg; + c.fillRect(0, 0, w, h); + c.strokeStyle = fg; + c.lineWidth = 1; + const x0 = w / 2; + const y0 = h / 2 + 5; + const outer = 40; + const width = 5; + const inner = outer - width; + for (let i = 0; i < stops.length; i++) { + const theta0 = + Math.PI * (1 + (stops[i].value_begin - min) / (max - min)) + 0.05; + const theta1 = Math.PI * (1 + (stops[i].value_end - min) / (max - min)); - // draw background - c.fillStyle = bg; - c.fillRect(0, 0, w, h); - c.strokeStyle = fg; - c.lineWidth = 1; - const x0 = w / 2; - const y0 = h / 2 + 5; - const outer = 40; - const width = 5; - const inner = outer - width; - for (let i = 0; i < stops.length; i++) { - const theta0 = - Math.PI * (1 + (stops[i].value_begin - min) / max) + 0.05; - const theta1 = Math.PI * (1 + (stops[i].value_end - min) / max); - - c.beginPath(); - c.lineWidth = width; - c.strokeStyle = stops[i].color; - c.arc(x0, y0, outer - width / 2, theta0, theta1); - c.stroke(); - - c.beginPath(); - c.lineWidth = 2; - const cos = Math.cos(theta1); - const sin = Math.sin(theta1); - c.moveTo(x0 + cos * (inner - 2), y0 + sin * (inner - 2)); - c.lineTo(x0 + cos * (outer + 2), y0 + sin * (outer + 2)); - c.stroke(); - } - c.strokeStyle = fg; - c.lineWidth = 2; c.beginPath(); - c.moveTo(x0, y0); - const theta = Math.PI * (1 + reading); - c.lineTo(x0 + Math.cos(theta) * inner, y0 + Math.sin(theta) * inner); + c.lineWidth = width; + c.strokeStyle = stops[i].color; + c.arc(x0, y0, outer - width / 2, theta0, theta1); c.stroke(); - const size = display.font ? display.font : labelSize; - c.font = `${size * 0.9}px '${labelFont}'`; - c.fillStyle = fg; - const m = c.measureText(text); - c.fillText(text, (w - m.width) / 2, h / 2 + 25); + c.beginPath(); + c.lineWidth = 2; + const cos = Math.cos(theta1); + const sin = Math.sin(theta1); + c.moveTo(x0 + cos * (inner - 2), y0 + sin * (inner - 2)); + c.lineTo(x0 + cos * (outer + 2), y0 + sin * (outer + 2)); + c.stroke(); + } + c.strokeStyle = fg; + c.lineWidth = 2; + c.beginPath(); + c.moveTo(x0, y0); + const theta = Math.PI * (1 + reading); + c.lineTo(x0 + Math.cos(theta) * inner, y0 + Math.sin(theta) * inner); + c.stroke(); + + const size = display.font ? display.font : labelSize; + c.font = `${size * 0.9}px '${labelFont}'`; + c.fillStyle = fg; + const m = c.measureText(text); + c.fillText(text, (w - m.width) / 2, h / 2 + 25); +}; + +const drawGauge = (key, label, value) => { + const types = { + meter: drawMeterGauge, + text: drawTextGauge, + }; + device.drawKey(key, (c) => { + const display = label.display; + if (!display.hasOwnProperty("type")) { + return; + } + if (types[display.type]) { + types[display.type](c, display, value); + } }); }; @@ -276,9 +329,6 @@ const loadPage = (page) => { drawKey(i, key, false); if (key && key.hasOwnProperty("display")) { drawGauge(i, key, NaN); - if (key.display.hasOwnProperty("xplane_dataref")) { - subscribeDataRef(key.display.xplane_dataref); - } } } }; @@ -287,10 +337,29 @@ const loadPage = (page) => { device.on("connect", async () => { console.info("connected"); for (let i = 0; i < pages.length; i++) { - const color = pages[i].hasOwnProperty("color") - ? pages[i].color - : "white"; - await device.setButtonColor({ id: i, color: pages[i].color }); + const page = pages[i]; + const color = page.hasOwnProperty("color") ? pages[i].color : "white"; + await device.setButtonColor({ id: i, color: page.color }); + // subscribe the data feeds + const { keys } = page || {}; + for (let j = 0; j < 12; j++) { + const key = Array.isArray(keys) && keys.length > j ? keys[j] : null; + if ( + key && + key.hasOwnProperty("display") && + key.display.hasOwnProperty("xplane_dataref") + ) { + await xplane.subscribeDataRef( + key.display.xplane_dataref, + 10, + (v) => { + if (currentPage == i) { + drawGauge(j, key, v); + } + }, + ); + } + } } loadPage(pages[currentPage]); }); diff --git a/figures/ap-page.jpg b/figures/ap-page.jpg Binary files differnew file mode 100644 index 0000000..094c3f9 --- /dev/null +++ b/figures/ap-page.jpg diff --git a/figures/main-page.jpg b/figures/main-page.jpg Binary files differnew file mode 100644 index 0000000..9bb5d35 --- /dev/null +++ b/figures/main-page.jpg diff --git a/profile.yaml b/profile.yaml index e13daa3..51b2880 100644 --- a/profile.yaml +++ b/profile.yaml @@ -16,7 +16,18 @@ color_bg: '#ff0000' keys: - display: - xplane_dataref: sim/cockpit2/engine/indicators/MPR_in_hg + type: meter + xplane_dataref: sim/cockpit2/engine/indicators/MPR_in_hg[0] + min: 10 + max: 30 + stops: + - color: 'green' + value_begin: 13 + value_end: 30 + decimals: 2 + - display: + type: meter + xplane_dataref: sim/cockpit2/engine/indicators/engine_speed_rpm[0] min: 0 max: 3000 stops: @@ -29,15 +40,39 @@ - color: 'red' value_begin: 2700 value_end: 3000 - - - - + - display: + type: meter + xplane_dataref: sim/flightmodel/controls/flaprat + min: 0 + max: 1 + stops: + - color: 'gray' + value_begin: 0 + value_end: 0.2 + - color: 'gray' + value_begin: 0.4 + value_end: 0.6 + - color: 'gray' + value_begin: 0.8 + value_end: 1 + formatter: '${($value * 100).toFixed(0)}%' - text: Flap text2: Up - - - - + pressed: + xplane_cmd: sim/flight_controls/flaps_up + - display: + type: text + xplane_dataref: sim/cockpit2/gauges/indicators/airspeed_kts_pilot + tag: IAS + - display: + type: text + xplane_dataref: sim/cockpit2/gauges/indicators/altitude_ft_pilot + tag: ALT - - text: Flap text2: Down + pressed: + xplane_cmd: sim/flight_controls/flaps_down - - - @@ -87,7 +122,7 @@ xplane_cmd: sim/GPS/g1000n3_fms_inner_down pressed: xplane_cmd: sim/GPS/g1000n3_cursor - - '' + - keys: # 12 touchable keys from left to right, top to bottom - text: -D-> @@ -1,36 +1,72 @@ import dgram from "node:dgram"; -const xplaneAddr = "localhost"; -const xplanePort = 49000; -const socket = dgram.createSocket("udp4"); +export class XPlane { + constructor(xplaneAddr = "localhost", xplanePort = 49000) { + this.socket = dgram.createSocket("udp4"); + this.subscribed = []; + this.xplaneAddr = xplaneAddr; + this.xplanePort = xplanePort; + this.socket.on("message", (msg, rinfo) => { + if (msg.subarray(0, 5).toString() != "RREF,") { + console.info("dropping unrelated message"); + return; + } + let num = (msg.length - 5) / 8; + for (let i = 0; i < num; i++) { + const idx = msg.readInt32LE(5 + i * 8); + if (idx < 0) { + console.info(`sender index ${idx} should be >= 0`); + return; + } + if (idx >= this.subscribed.length) { + console.info(`sender index ${idx} > subscribed.length`); + return; + } + const v = msg.readFloatLE(9 + i * 8); + //console.info(`${this.subscribed[idx].ref} = ${v}`); + this.subscribed[idx].handler(v); + } + }); + this.socket.bind(0); + } -socket.on("message", (msg, rinfo) => { - console.log(msg, rinfo); -}); + async subscribeDataRef(dataRef, freq, handler) { + const idx = this.subscribed.length; + if (handler) { + this.subscribed.push({ ref: dataRef, handler }); + } + let buffer = Buffer.alloc(4 + 1 + 4 * 2 + 400); + let off = buffer.write("RREF"); + off = buffer.writeUInt8(0, off); // null terminated + off = buffer.writeInt32LE(freq, off); // xint frequency + off = buffer.writeInt32LE(idx, off); // xint sender index + off += buffer.write(dataRef, off); // char[400] dataref + off = buffer.writeUInt8(0, off); // null terminated + console.info(`x-plane subscribed[${idx}] => ${dataRef}`); + await this.socket.send( + buffer, + 0, + buffer.length, + this.xplanePort, + this.xplaneAddr, + ); + } + //subscribeDataRef("sim/flightmodel/position/indicated_airspeed"); -socket.bind(10080); - -export const subscribeDataRef = async (dataRef) => { - //const dataRef = "sim/flightmodel/position/indicated_airspeed"; - let buffer = Buffer.alloc(4 + 1 + 4 * 2 + 400); - let off = buffer.write("RREF"); - off = buffer.writeUInt8(0, off); // null terminated - off = buffer.writeInt32LE(1, off); // xint frequency - off = buffer.writeInt32LE(0, off); // xint client - off += buffer.write(dataRef, off); // char[400] dataref - off = buffer.writeUInt8(0, off); // null terminated - console.info(`x-plane dataref: ${dataRef}`); - await socket.send(buffer, 0, buffer.length, xplanePort, xplaneAddr); -}; - -export const sendCommand = async (cmd) => { - let buffer = Buffer.alloc(4 + 1 + cmd.length + 1); - let off = buffer.write("CMND"); - off = buffer.writeUInt8(0, off); // null terminated - off += buffer.write(cmd, off); // command - off = buffer.writeUInt8(0, off); // null terminated - console.info(`x-plane cmd: ${cmd}`); - await socket.send(buffer, 0, buffer.length, xplanePort, xplaneAddr); -}; - -//sendCommand("sim/GPS/g1000n1_hdg_down"); + async sendCommand(cmd) { + let buffer = Buffer.alloc(4 + 1 + cmd.length + 1); + let off = buffer.write("CMND"); + off = buffer.writeUInt8(0, off); // null terminated + off += buffer.write(cmd, off); // command + off = buffer.writeUInt8(0, off); // null terminated + console.info(`x-plane cmd: ${cmd}`); + await this.socket.send( + buffer, + 0, + buffer.length, + this.xplanePort, + this.xplaneAddr, + ); + } + //sendCommand("sim/GPS/g1000n1_hdg_down"); +} |