From fbe966903dd031e93150141ed0e8a758f778c524 Mon Sep 17 00:00:00 2001 From: Determinant Date: Fri, 16 Aug 2024 15:08:45 -0700 Subject: finish --- app.mjs | 456 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100755 app.mjs (limited to 'app.mjs') 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(); + }); +}); -- cgit v1.2.3-70-g09d2