aboutsummaryrefslogtreecommitdiff
path: root/app.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'app.mjs')
-rwxr-xr-xapp.mjs456
1 files changed, 456 insertions, 0 deletions
diff --git a/app.mjs b/app.mjs
new file mode 100755
index 0000000..0c060cf
--- /dev/null
+++ b/app.mjs
@@ -0,0 +1,456 @@
+#!/usr/bin/env node
+
+import { registerFont } from "canvas";
+
+//registerFont("./ocr-a-ext.ttf", { family: "OCR A Extended" });
+
+import { discover, HAPTIC } from "loupedeck";
+import { readFile } from "fs/promises";
+import { parse } from "yaml";
+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;
+let pressed = new Set();
+let highlighted = new Set();
+
+// detects and opens first connected device
+const device = await discover();
+
+const isNumber = (x) => {
+ return !isNaN(x);
+};
+
+const isObject = (obj) => {
+ return obj != null && obj.constructor.name === "Object";
+};
+
+const takeAction = (labeled, type, haptics) => {
+ if (!isObject(labeled)) {
+ return;
+ }
+ let actionSpec = labeled[type];
+ if (actionSpec === undefined) {
+ return;
+ }
+ if (actionSpec.hasOwnProperty("xplane_cmd")) {
+ xplane.sendCommand(actionSpec.xplane_cmd);
+ }
+ if (haptics) {
+ device.vibrate(HAPTIC.REV_FASTEST);
+ }
+};
+
+const getKeyInfo = (i) => {
+ if (!pages[currentPage].hasOwnProperty("keys")) {
+ return null;
+ }
+ const keys = pages[currentPage].keys;
+ if (Array.isArray(keys) && i < keys.length) {
+ return keys[i];
+ }
+ return null;
+};
+
+const rectifyLabel = (label) => {
+ let text;
+ let text2 = null;
+ let font2 = null;
+ let size = labelSize;
+ let color_bg = null;
+ let color_fg = null;
+ if (isObject(label)) {
+ text = label.text;
+ if (label.hasOwnProperty("size")) {
+ size = label.size;
+ }
+ if (label.hasOwnProperty("text2")) {
+ text2 = label.text2;
+ font2 = `${size * 0.9}px '${labelFont}'`;
+ }
+ if (label.hasOwnProperty("color_bg")) {
+ color_bg = label.color_bg;
+ }
+ if (label.hasOwnProperty("color_fg")) {
+ color_fg = label.color_fg;
+ }
+ } else {
+ text = label.toString();
+ }
+ let font = `${size}px '${labelFont}'`;
+ return { text, text2, font, font2, color_bg, color_fg };
+};
+
+const drawKey = (key, label, down) => {
+ if (label && label.hasOwnProperty("display")) {
+ // not an input, but a display gauge
+ return;
+ }
+
+ device.drawKey(key, (c) => {
+ const padding = 10;
+ const bg = down ? "white" : "black";
+ const fg = down ? "black" : "white";
+ const w = c.canvas.width;
+ const h = c.canvas.height;
+
+ // draw background
+ c.fillStyle = bg;
+ c.fillRect(0, 0, w, h);
+ c.fillStyle = fg;
+ c.lineWidth = 2;
+ c.strokeStyle = fg;
+ c.strokeRect(padding, padding, w - padding * 2, h - padding * 2);
+
+ if (label) {
+ drawDoubleLineText(c, label);
+ }
+ });
+};
+
+const drawSideKnobs = (side, labels, highlight) => {
+ device.drawScreen(side, (c) => {
+ const light = pages[currentPage].hasOwnProperty("color")
+ ? pages[currentPage].color
+ : "white";
+ if (!highlight) {
+ highlight = [false, false, false];
+ }
+ for (let i = 0; i < 3; i++) {
+ const hl = highlight[i];
+ const y_offset = (i * c.canvas.height) / 3;
+ const x_padding = 8;
+ const y_padding = 3;
+ const bg = hl ? light : "black";
+ const fg = hl ? "black" : light;
+ const w = c.canvas.width;
+ const h = c.canvas.height / 3;
+ c.fillStyle = bg;
+ c.fillRect(0, y_offset, w, h);
+ c.fillStyle = fg;
+ c.lineWidth = 2;
+ c.strokeStyle = fg;
+ c.strokeRect(
+ x_padding,
+ y_padding + y_offset,
+ w - x_padding * 2,
+ h - y_padding * 2,
+ );
+ if (labels && labels.length > i && labels[i]) {
+ const { text, font, color_bg, color_fg } = rectifyLabel(
+ labels[i],
+ );
+ if (color_bg) {
+ c.fillStyle = color_bg;
+ c.fillRect(
+ x_padding + 2,
+ y_padding + y_offset + 2,
+ w - x_padding * 2 - 2,
+ h - y_padding * 2 - 2,
+ );
+ }
+ c.font = font;
+ const {
+ width,
+ actualBoundingBoxAscent,
+ actualBoundingBoxDescent,
+ } = c.measureText(text);
+ const x_axis = (h - width) / 2;
+ const y_axis =
+ w / 2 +
+ (actualBoundingBoxAscent - actualBoundingBoxDescent) / 2;
+ c.rotate((90 * Math.PI) / 180);
+ c.fillStyle = hl ? "black" : "white";
+ c.fillText(text, x_axis + y_offset, -(w - y_axis));
+ c.resetTransform();
+ }
+ }
+ });
+};
+
+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 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();
+ }
+};
+
+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 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));
+
+ 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.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);
+ }
+ });
+};
+
+const loadPage = (page) => {
+ const { left, right, keys } = page || {};
+ if (!left) {
+ return;
+ }
+ drawSideKnobs("left", left);
+ drawSideKnobs("right", right);
+ for (let i = 0; i < 12; i++) {
+ const key = Array.isArray(keys) && keys.length > i ? keys[i] : null;
+ drawKey(i, key, false);
+ if (key && key.hasOwnProperty("display")) {
+ drawGauge(i, key, NaN);
+ }
+ }
+};
+
+// Observe connect events
+device.on("connect", async () => {
+ console.info("connected");
+ for (let i = 0; i < pages.length; i++) {
+ 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]);
+});
+
+const handleKnobEvent = (id) => {
+ const { left, right } = pages[currentPage] || {};
+ let pos = { T: 0, C: 1, B: 2 }[id.substring(4, 5)];
+ let side = { L: ["left", left], R: ["right", right] }[id.substring(5, 6)];
+ if ((side[0] == "left" && !left) || (side[0] == "right" && !right)) {
+ return;
+ }
+ let mask = [false, false, false];
+ mask[pos] = true;
+ drawSideKnobs(side[0], side[1], mask);
+ if (!highlighted.has(id)) {
+ highlighted.add(id);
+ setTimeout(() => {
+ drawSideKnobs(side[0], side[1], [false, false, false]);
+ highlighted.delete(id);
+ }, 200);
+ }
+ return side[1][pos];
+};
+
+// React to button presses
+device.on("down", ({ id }) => {
+ if (isNumber(id)) {
+ if (id >= pages.length) {
+ return;
+ }
+ console.info(`switch to page: ${id}`);
+ currentPage = id;
+ loadPage(pages[currentPage]);
+ } else {
+ takeAction(handleKnobEvent(id), "pressed", false);
+ }
+});
+
+// React to knob turns
+device.on("rotate", ({ id, delta }) => {
+ takeAction(handleKnobEvent(id), delta > 0 ? "inc" : "dec", false);
+});
+
+const clearStaleButton = (touches) => {
+ const s = new Set(
+ touches.map((o) => o.target.key).filter((k) => k !== undefined),
+ );
+ for (const k of pressed.keys()) {
+ if (!s.has(k)) {
+ const key = getKeyInfo(k);
+ if (key) {
+ drawKey(k, key, false);
+ }
+ pressed.delete(k);
+ }
+ }
+};
+
+device.on("touchstart", ({ changedTouches, touches }) => {
+ clearStaleButton(changedTouches);
+ const target = changedTouches[0].target;
+ if (target.key === undefined) {
+ return;
+ }
+ pressed.add(target.key);
+ const key = getKeyInfo(target.key);
+ if (key) {
+ drawKey(target.key, key, true);
+ takeAction(key, "pressed", true);
+ }
+});
+
+device.on("touchmove", ({ changedTouches, touches }) => {
+ clearStaleButton(changedTouches);
+});
+
+device.on("touchend", ({ changedTouches, touches }) => {
+ clearStaleButton(changedTouches);
+ const target = changedTouches[0].target;
+ if (target.key === undefined) {
+ return;
+ }
+ pressed.delete(target.key);
+ const key = getKeyInfo(target.key);
+ if (key) {
+ drawKey(target.key, key, false);
+ }
+});
+
+process.on("SIGINT", () => {
+ device.close().then(() => {
+ process.exit();
+ });
+});