aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDeterminant <[email protected]>2024-08-16 15:08:45 -0700
committerDeterminant <[email protected]>2024-08-16 15:08:45 -0700
commitfbe966903dd031e93150141ed0e8a758f778c524 (patch)
treeae8bfc177531253e19b88d39e534288ee64e5cc5
parent773a788835503182af3901d65471e0e53e0f5a5f (diff)
finish
-rw-r--r--README.rst17
-rwxr-xr-xapp.mjs (renamed from ctrl.mjs)255
-rw-r--r--figures/ap-page.jpgbin0 -> 844925 bytes
-rw-r--r--figures/main-page.jpgbin0 -> 874527 bytes
-rw-r--r--profile.yaml47
-rw-r--r--xplane.mjs100
6 files changed, 286 insertions, 133 deletions
diff --git a/README.rst b/README.rst
index 4e447b8..4c49a85 100644
--- a/README.rst
+++ b/README.rst
@@ -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``.
diff --git a/ctrl.mjs b/app.mjs
index 6c326f1..0c060cf 100755
--- a/ctrl.mjs
+++ b/app.mjs
@@ -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
new file mode 100644
index 0000000..094c3f9
--- /dev/null
+++ b/figures/ap-page.jpg
Binary files differ
diff --git a/figures/main-page.jpg b/figures/main-page.jpg
new file mode 100644
index 0000000..9bb5d35
--- /dev/null
+++ b/figures/main-page.jpg
Binary files differ
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->
diff --git a/xplane.mjs b/xplane.mjs
index 688f85f..5f94625 100644
--- a/xplane.mjs
+++ b/xplane.mjs
@@ -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");
+}