#!/usr/bin/env node
import { registerFont } from "canvas";
if (process.platform == "linux") {
console.warn(
"node-canvas does not support directly using font file in Linux (see https://github.com/Automattic/node-canvas/issues/2097#issuecomment-1803950952), please copy ./ocr-a-ext.ttf in this folder to your local font folder (~/.fonts/) or install it system-wide.",
);
} else {
registerFont(`${import.meta.dirname}/ocr-a-ext.ttf`, {
family: "OCR A Extended",
});
}
import { discover, HAPTIC } from "loupedeck";
import { readFile } from "fs/promises";
import { parse } from "yaml";
import { queue } from "async";
import { XPlane } from "./xplane.mjs";
const defaultFont = "OCR A Extended";
const defaultTextSize = 22;
const xplane = new XPlane();
if (process.argv.length > 3) {
console.error("./app.mjs [profile YAML file]");
}
const profile_file = process.argv[2];
const pages = parse(
await readFile(
profile_file ? profile_file : `${import.meta.dirname}/profile.yaml`,
"utf8",
),
);
const isNumber = (x) => {
return x != null && !isNaN(x);
};
const isObject = (obj) => {
return obj != null && obj.constructor.name === "Object";
};
const deg2Rad = (x) => (x / 180) * Math.PI;
// state of the controller
let currentPage =
isObject(pages[0]) && pages[0].default != null ? pages[0].default : 0;
let pressed = new Set();
let highlighted = new Set();
// detects and opens first connected device
let device;
// Render related variables
let renderStop = [];
let renderTasks;
while (!device) {
try {
device = await discover();
} catch (e) {
console.error(`${e}. retry in 5 secs`);
await new Promise((res) => setTimeout(res, 5000));
}
}
const getCurrentPage = () => {
return pages[currentPage] || {};
};
const getKeyConf = (i) => {
const keys = getCurrentPage().keys;
if (keys == null) {
return null;
}
if (Array.isArray(keys) && i < keys.length) {
return keys[i];
}
return null;
};
const getTextStyles = (conf) => {
// conf must be non-null
let font = [];
let color_bg = [];
let color_fg = [];
if (isObject(conf)) {
const size = Array.isArray(conf.size) ? conf.size : [conf.size];
color_bg = Array.isArray(conf.color_bg)
? conf.color_bg
: [conf.color_bg];
color_fg = Array.isArray(conf.color_fg)
? conf.color_fg
: [conf.color_fg];
for (let i = 0; i < size.length; i++) {
font.push(
`${size[i] ? size[i] : defaultTextSize}px '${defaultFont}'`,
);
}
} else {
font.push(`${defaultTextSize}px '${defaultFont}'`);
}
return {
font,
color_bg,
color_fg,
};
};
const getLabels = (conf) => {
let text;
if (isObject(conf)) {
text = Array.isArray(conf.label) ? conf.label : [conf.label];
} else {
text = [conf.toString()];
}
return text;
};
const transformValues = (conf, values) => {
const f = (exp, v) => Function("$d", `"use strict"; return(${exp});`)(v);
let last;
const exps = Array.isArray(conf.exp) ? conf.exp : [conf.exp];
let res = [];
for (let i = 0; i < values.length; i++) {
let exp = exps[i] || last;
if (exp) {
res[i] = f(exp, values[i]);
} else {
res[i] = values[i];
}
last = exp;
}
return res;
};
const formatValues = (conf, values_, n = 1) => {
const values = transformValues(conf, values_);
const f = (fmt) => {
if (fmt) {
return Function("$d", `"use strict"; return(\`${fmt}\`);`)(values);
}
if (!isNumber(values[0])) {
return "X";
}
return values[0].toFixed(0).toString();
};
let last;
let text = [];
const formatter = Array.isArray(conf.fmt) ? conf.fmt : [conf.fmt];
for (let i = 0; i < n; i++) {
let fmt = formatter[i] || last;
text.push(f(fmt));
last = fmt;
}
return { text, values };
};
const formatColors = (color_name, conf, values, n = 1) => {
const f = (fmt) => {
if (fmt) {
return Function("$d", `"use strict"; return(\`${fmt}\`);`)(values);
}
return "#fff";
};
let last;
let color = [];
const formatter = Array.isArray(conf[color_name])
? conf[color_name]
: [conf[color_name]];
for (let i = 0; i < n; i++) {
let fmt = formatter[i] || last;
color.push(f(fmt));
last = fmt;
}
return color;
};
const renderMultiLineText = (c, x0, y0, w, h, text, styles, conf) => {
const { font, color_fg } = styles;
c.save();
let sep = conf.sep;
if (sep == null) {
c.font = font[0];
const mx = c.measureText("x");
sep = mx.actualBoundingBoxAscent - mx.actualBoundingBoxDescent;
}
let ms = [];
let totalHeight = 0;
for (let i = 0; i < text.length; i++) {
c.font = font[i];
const m = c.measureText(text[i]);
ms.push(m);
totalHeight += m.actualBoundingBoxAscent - m.actualBoundingBoxDescent;
}
totalHeight += (text.length - 1) * sep;
let yBase = y0 + (h - totalHeight) / 2;
for (let i = 0; i < text.length; i++) {
const x =
x0 +
Math.max(
0,
w -
(ms[i].actualBoundingBoxRight -
ms[i].actualBoundingBoxLeft),
) /
2;
const textHeight =
ms[i].actualBoundingBoxAscent - ms[i].actualBoundingBoxDescent;
const y = yBase + textHeight;
c.font = font[i];
c.fillStyle = color_fg[i] || "#fff";
c.fillText(text[i], x, y);
yBase += textHeight + sep;
}
c.restore();
};
const drawKey = async (id, conf, pressed) => {
if (conf && conf.display != null) {
// not an input, but a display gauge
return;
}
await device.drawKey(id, (c) => {
const padding = 10;
const bg = pressed ? "white" : "black";
const fg = pressed ? "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 (conf != null) {
renderMultiLineText(
c,
0,
0,
w,
h,
getLabels(conf),
getTextStyles(conf),
conf,
);
}
// otherwise the empty key style is still drawn
});
};
const drawSideKnobs = async (side, confs, highlight) => {
await device.drawScreen(side, (c) => {
const page = getCurrentPage();
const light = page.color != null ? page.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;
// draw background
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 (Array.isArray(confs) && confs.length > i && confs[i] != null) {
const { font, color_bg } = getTextStyles(confs[i]);
const text = getLabels(confs[i]);
if (color_bg[0]) {
c.fillStyle = color_bg[0];
c.fillRect(
x_padding + 2,
y_padding + y_offset + 2,
w - x_padding * 2 - 2,
h - y_padding * 2 - 2,
);
}
c.translate(w, y_offset);
c.rotate(Math.PI / 2);
renderMultiLineText(
c,
0,
0,
h,
w,
text,
{ font, color_fg: [fg] },
confs[i],
);
c.resetTransform();
}
}
});
};
const renderTextGauge = (c, display, values_) => {
const bg = "black";
const w = c.canvas.width;
const h = c.canvas.height;
// draw background
c.fillStyle = bg;
c.fillRect(0, 0, w, h);
const { text, values } = formatValues(display, values_, display.fmt.length);
// TODO: cache this
const styles = getTextStyles({
size: display.size,
color_fg: formatColors("color_fg", display, values, values.length),
});
renderMultiLineText(c, 0, 0, w, h, text, styles, {});
};
const renderMeterGauge = (c, display, values) => {
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 = (Math.max(values[0], min) - min) / (max - min);
if (!isNumber(reading)) {
reading = 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;
// draw each arc segments
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();
}
// draw the needle
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();
// show the value text
const { text } = formatValues(display, values);
const { font } = getTextStyles(display);
c.font = font[0];
c.fillStyle = fg;
const m = c.measureText(text);
c.fillText(text, (w - m.width) / 2, h / 2 + 25);
};
const renderAttitudeIndicator = (c, display, values) => {
const bg = "black";
const fg = "white";
const w = c.canvas.width;
const h = c.canvas.height;
// draw background
c.fillStyle = bg;
c.fillRect(0, 0, w, h);
const pitch = values[0] || 0;
const roll = values[1] || 0;
const slip = values[2] || 0;
let src = isObject(display.navs) ? display.navs[values[3]] : null;
if (!isObject(src)) {
src = null;
}
const cdi = src ? values[src.def] : null;
const received = src ? values[src.received] : null;
const x0 = w / 2;
const y0 = h / 2;
const longMark = [-10, 10];
const shortMark = [-5, 5];
const longSep = 18;
const shortSep = longSep / 2;
c.translate(x0, y0);
c.save();
c.rotate(deg2Rad(-roll));
c.save();
c.translate(0, (pitch / 10) * longSep);
// draw horizon
c.fillStyle = "#0077b6";
c.fillRect(-w, -2 * h, 2 * w, 4 * h);
c.fillStyle = "#99582a";
c.fillRect(-w, 0, 2 * w, 4 * h);
// draw pitch marks
c.lineWidth = 1;
c.strokeStyle = fg;
c.beginPath();
c.moveTo(-0.75 * w, 0);
c.lineTo(0.75 * w, 0);
c.fillStyle = fg;
c.font = `10px ${defaultFont}`;
const drawMark = (i) => {
const y = longSep * i;
const sign = i < 0 ? -1 : 1;
c.fillText(sign * i * 10, longMark[0] - 15, y + 3);
c.moveTo(longMark[0], y);
c.lineTo(longMark[1], y);
c.moveTo(shortMark[0], y - sign * shortSep);
c.lineTo(shortMark[1], y - sign * shortSep);
};
for (let i = -6; i <= 6; i++) {
if (i != 0) {
drawMark(i);
}
}
c.stroke();
// draw bank angle arc
c.restore();
c.lineWidth = 1;
c.strokeStyle = fg;
c.beginPath();
const bankR = 30;
const theta0 = deg2Rad(-30);
const t15 = deg2Rad(-15);
const t10 = deg2Rad(-10);
const bankTicks = [10, 5, 10, 5, 5, 5, 5, 5, 10, 5, 10];
const bankSteps = [t15, t15, t10, t10, t10, t10, t10, t10, t15, t15];
c.save();
c.rotate(theta0);
c.moveTo(bankR, 0);
c.arc(0, 0, bankR, 0, deg2Rad(-120), true);
for (let i = 0; i < bankTicks.length; i++) {
c.moveTo(30, 0);
c.lineTo(30 + bankTicks[i], 0);
if (i < bankSteps.length) {
c.rotate(bankSteps[i]);
}
}
c.restore();
c.stroke();
c.beginPath();
c.lineWidth = 2;
c.moveTo(-3, -(bankR + 8));
c.lineTo(0, -bankR);
c.lineTo(3, -(bankR + 8));
c.stroke();
// draw center mark
c.restore();
c.lineWidth = 2;
c.strokeStyle = "yellow";
c.beginPath();
c.moveTo(-30, 0);
c.lineTo(-10, 0);
c.lineTo(-10, 8);
c.moveTo(30, 0);
c.lineTo(10, 0);
c.lineTo(10, 8);
c.rect(-1, -1, 2, 2);
c.moveTo(-3, -(bankR - 9));
c.lineTo(0, -(bankR - 1));
c.lineTo(3, -(bankR - 9));
const slipD = -slip * 2;
c.moveTo(-5 + slipD, -(bankR - 9));
c.lineTo(5 + slipD, -(bankR - 9));
c.stroke();
// draw vertical deflection dots
const pi2 = 2 * Math.PI;
const vdefX = w - 10 - x0;
const vdefR = 3;
c.strokeStyle = "white";
c.lineWidth = 1;
c.beginPath();
for (let i = -2; i <= 2; i++) {
if (i != 0) {
const vdefY = 13 * i;
c.moveTo(vdefX + vdefR, vdefY);
c.arc(vdefX, vdefY, vdefR, 0, pi2);
}
}
c.stroke();
if (isNumber(received) && received == 0) {
// draw CDI diamond
const cdiY = 13 * cdi;
const cdiH = 7;
const cdiW = 4;
c.fillStyle = "#2dfe54";
c.strokeStyle = "black";
c.beginPath();
c.moveTo(vdefX, cdiY + cdiH);
c.lineTo(vdefX - cdiW, cdiY);
c.lineTo(vdefX, cdiY - cdiH);
c.lineTo(vdefX + cdiW, cdiY);
c.stroke();
c.fill();
}
};
const mechanicalStyleNumber = (value, lowDigitStep = 1) => {
const split = (x) => {
const int = Math.trunc(x);
const float = (x - int).toFixed(2);
return { int, float };
};
// first handle the lowest bundle of digits
const lowDigits = Math.trunc(Math.log10(lowDigitStep)) + 1;
const low10 = Math.pow(10, lowDigits);
const lowMax = (low10 - lowDigitStep) / lowDigitStep;
let t = split((value % low10) / lowDigitStep);
let digits = [t.int];
let scroll = [t.float];
// remove the lowest bundle of digits
let i = 0;
value /= low10;
while (true) {
t = split(value % 10);
if (
((i > 0 && digits[i] == 9) || (i == 0 && digits[i] == lowMax)) &&
scroll[i] > 0
) {
scroll.push(scroll[i]);
} else {
if (value < 1) {
break;
}
scroll.push(0);
}
digits.push(t.int);
i += 1;
value /= 10;
}
return { digits, scroll, low10, lowDigits };
};
const renderMechanicalDisplay = (
c,
w,
h,
value,
padding = 20,
right = true,
wideWinWidth = 2,
lowDigitStep = 1,
size = defaultTextSize,
) => {
const bg = "black";
const fg = "white";
c.save();
c.font = `${size}px '${defaultFont}'`;
const m = c.measureText("x");
const y0 =
h / 2 + (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2;
let digitH = (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) * 2;
let digitW = (m.actualBoundingBoxRight - m.actualBoundingBoxLeft) * 1.2;
const sign = right ? -1 : 1;
let x = (right ? w : 0) + sign * padding;
c.strokeStyle = bg;
const narrowWinY = y0 - digitH * 0.95;
const narrowWinH = digitH * 1.25;
const wideWinX = x + sign * (wideWinWidth + (right ? -1 : 0)) * digitW;
const wideWinY = y0 - digitH * 1.5;
const wideWinW = wideWinWidth * digitW;
const wideWinH = digitH * 2.25;
c.fillStyle = bg;
c.fillRect(0, narrowWinY, w, narrowWinH);
c.fillRect(wideWinX, wideWinY, wideWinW, wideWinH);
c.rect(0, narrowWinY, w, narrowWinH);
c.rect(wideWinX, wideWinY, wideWinW, wideWinH);
c.stroke();
c.clip();
c.strokeStyle = fg;
c.fillStyle = fg;
if (!isNumber(value)) {
c.beginPath();
const y0 = narrowWinY;
const y1 = narrowWinY + narrowWinH;
c.moveTo(0, y0);
c.lineTo(w, y1);
c.moveTo(0, y1);
c.lineTo(w, y0);
c.stroke();
c.restore();
return;
}
let { digits, scroll, low10, lowDigits } = mechanicalStyleNumber(
value,
lowDigitStep,
);
const formatLowDigits = (x) => x.toFixed(0).padStart(lowDigits, "0");
for (let i = 0; i < digits.length; i++) {
const p = right ? i : digits.length - i - 1;
const y = y0 + scroll[p] * digitH;
let d, m1, m2, p1;
if (p == 0) {
d = digits[p] * lowDigitStep;
m1 = (d == 0 ? low10 : d) - lowDigitStep;
m2 = (m1 == 0 ? low10 : d) - lowDigitStep;
p1 = d + lowDigitStep;
if (p1 >= low10) {
p1 -= low10;
}
let p2 = p1 + lowDigitStep;
if (p2 >= low10) {
p2 -= low10;
}
d = formatLowDigits(d);
m1 = formatLowDigits(m1);
m2 = formatLowDigits(m2);
p1 = formatLowDigits(p1);
p2 = formatLowDigits(p2);
c.fillText(p2, x, y - digitH * 2);
} else {
d = digits[p];
m1 = d == 0 ? 9 : d - 1;
m2 = m1 == 0 ? 9 : m1 - 1;
p1 = d == 9 ? 0 : d + 1;
}
c.fillText(d, x, y);
c.fillText(m1, x, y + digitH);
c.fillText(m2, x, y + digitH * 2);
c.fillText(p1, x, y - digitH);
x += sign * digitW;
}
c.restore();
};
const renderIAS = (c, display, values) => {
const bg = "#555";
const w = c.canvas.width;
const h = c.canvas.height;
// draw background
c.fillStyle = bg;
c.fillRect(0, 0, w, h);
renderMechanicalDisplay(c, w, h, values[0], 20, true, 1);
};
const renderAltimeter = (c, display, values) => {
const bg = "#555";
const fg = "white";
const w = c.canvas.width;
const h = c.canvas.height;
// draw background
c.fillStyle = bg;
c.fillRect(0, 0, w, h);
renderMechanicalDisplay(c, w, h, values[0], 5, false, 2, 20, 18);
// draw floating vsi window
const vs = values[1];
const vsiBgX = w / 2 + 4;
c.fillRect(vsiBgX, 0, w - vsiBgX, h);
c.fillStyle = "#000";
const vsiH = 20;
const vsiX = vsiBgX + 2;
const vsiY =
(1 -
(Math.min(Math.max(isNumber(vs) ? vs : 0, -2000), 2000) + 2000) /
4000) *
(h - vsiH);
c.fillRect(vsiX, vsiY, w - vsiX, vsiH);
c.fillStyle = fg;
if (isNumber(vs)) {
c.font = `12px '${defaultFont}'`;
c.fillText(Math.trunc(vs / 10) * 10, vsiX + 2, vsiY + vsiH * 0.8);
}
const altB = values[2];
if (isNumber(altB)) {
c.fillStyle = "cyan";
c.font = `14px '${defaultFont}'`;
c.fillText(altB, 15, 18);
}
};
const renderHSI = (c, display, values) => {
const bg = "black";
const fg = "white";
const w = c.canvas.width;
const h = c.canvas.height;
// draw background
c.fillStyle = bg;
c.fillRect(0, 0, w, h);
const x0 = w / 2;
const y0 = h / 2;
const r = w / 2 - 5;
const f1 = 0.8;
const f2 = 0.9;
const cdiR = 0.4 * r;
const vdefR = 3;
const hdg = deg2Rad(values[0]);
const hdgB = deg2Rad(values[1]);
let src = isObject(display.navs) ? display.navs[values[2]] : null;
if (!isObject(src)) {
src = null;
}
const crs = src ? deg2Rad(values[src.crs]) : null;
const fromto = src ? values[src.fromto] : null;
let def = src ? Math.min(Math.max(values[src.def], -3), 3) : null;
if (!isNumber(def)) {
def = 0;
}
const received = src ? values[src.received] : null;
const polarXY = (theta, r) => {
const t = -theta - Math.PI / 2;
const dx = r * Math.cos(t);
const dy = -r * Math.sin(t);
return { dx, dy };
};
const pi2 = Math.PI * 2;
c.translate(x0, y0);
c.rotate(-hdg);
c.strokeStyle = fg;
c.lineWidth = 1;
c.beginPath();
for (let i = 0; i < 36; i++) {
const { dx, dy } = polarXY(deg2Rad(i * 10), r);
const f = (i & 1) == 0 ? f1 : f2;
c.moveTo(dx, dy);
c.lineTo(dx * f, dy * f);
}
c.fillStyle = fg;
c.font = `16px '${defaultFont}'`;
c.fillText("N", -5, -0.5 * r);
c.stroke();
if (crs != null) {
c.rotate(crs);
for (let i = -2; i <= 2; i++) {
const r = i == 0 ? 1 : vdefR;
const x = 13 * i;
c.moveTo(x + r, 0);
c.arc(x, 0, r, 0, pi2);
}
c.stroke();
c.beginPath();
c.lineWidth = 3;
c.strokeStyle = src.color ? src.color : "magenta";
if (isNumber(received) && received != 0) {
// draw CDI needle
const cdiX = 13 * def;
c.moveTo(cdiX, -(cdiR - 1));
c.lineTo(cdiX, cdiR - 1);
}
c.moveTo(0, -r);
c.lineTo(0, -(cdiR + 1));
// crs arrowhead
let y0 = -f1 * r;
let y1 = 0.8 * y0;
c.moveTo(0, y0);
c.lineTo(-5, y1);
c.lineTo(5, y1);
c.lineTo(0, y0);
c.moveTo(0, r);
c.lineTo(0, cdiR + 1);
// from/to arrowhead
if (fromto) {
let y0 = -cdiR;
let y1 = 0.4 * y0;
if (fromto != 1) {
y0 = -y0;
y1 = -y1;
}
c.moveTo(0, y0);
c.lineTo(-5, y1);
c.lineTo(5, y1);
c.lineTo(0, y0);
}
c.rotate(-crs);
}
if (isNumber(hdgB)) {
const bugW = 4;
const bugY1 = -(r - 5);
const bugY0 = -(r - 8);
c.stroke();
c.rotate(hdgB);
c.lineWidth = 1;
c.strokeStyle = "white";
c.fillStyle = "cyan";
c.beginPath();
c.moveTo(0, bugY1);
c.lineTo(-bugW, -(r + 1));
c.lineTo(-bugW, bugY0);
c.lineTo(bugW, bugY0);
c.lineTo(bugW, -(r + 1));
c.lineTo(0, bugY1);
c.fill();
}
c.stroke();
};
const renderBarGauge = (c, display, values_) => {
const bg = "black";
const fg = "white";
const w = c.canvas.width;
const h = c.canvas.height;
// draw background
c.fillStyle = bg;
c.fillRect(0, 0, w, h);
const slotWidth = 10;
const slotHeight = 60;
const barWidth = slotWidth * 0.6;
const { text, values } = formatValues(display, values_, display.fmt.length);
const label = getLabels(display);
// TODO: cache this
const { font, color_fg } = getTextStyles({
size: display.size,
color_fg: formatColors("color_fg", display, values, values.length),
});
c.rotate(Math.PI / 2);
let y = -(w - (slotWidth + 10) * text.length) / 2;
let x = (h - slotHeight) / 2;
c.strokeStyle = fg;
for (let i = 0; i < text.length; i++) {
c.lineWidth = 1;
c.strokeRect(x, y - barWidth, slotHeight, barWidth);
const r = Math.max(Math.min(values[i], 1), 0);
c.fillStyle = color_fg[i] ? color_fg[i] : fg;
const xx = x + slotHeight * (1 - r);
c.fillRect(xx + 1, y - barWidth + 1, slotHeight * r - 1, barWidth - 1);
c.lineWidth = 2;
c.moveTo(xx + 1, y + 2);
c.lineTo(xx + 1, y - barWidth - 2);
c.stroke();
c.fillStyle = fg;
c.font = font[i];
const t = `${label[i]} ${text[i]}`;
c.fillText(t, x, y - slotWidth + 2);
y -= slotWidth + 10;
}
};
const drawGauge = async (key, label, values) => {
const types = {
meter: renderMeterGauge,
text: renderTextGauge,
bar: renderBarGauge,
attitude: renderAttitudeIndicator,
ias: renderIAS,
alt: renderAltimeter,
hsi: renderHSI,
};
const display = label.display;
if (display.type == null) {
return;
}
if (types[display.type]) {
renderTasks.push({
key,
func: (c) => types[display.type](c, display, values),
});
}
};
const resetRendering = async () => {
for (let i = 0; i < renderStop.length; i++) {
renderStop[i]();
}
renderStop = [];
if (renderTasks) {
await renderTasks.pause();
}
renderTasks = queue(async (e) => {
const { key, func } = e;
await device.drawKey(key, func);
});
};
const loadPage = async (page) => {
await resetRendering();
// page is not null
const { left, right, keys } = page;
let pms = [];
pms.push(drawSideKnobs("left", left));
pms.push(drawSideKnobs("right", right));
for (let i = 0; i < 12; i++) {
const conf = Array.isArray(keys) && keys.length > i ? keys[i] : null;
pms.push(drawKey(i, conf, false));
if (isObject(conf) && conf.display != null) {
conf.renderStart();
}
}
await Promise.all(pms);
};
// Observe connect events
device.on("connect", async () => {
console.info("connected");
/*
for (let i = 3600; i > 1000; i -= 0.1) {
await device.drawKey(0, (c) => {
renderAltimeter(c, null, [i, 500]);
});
await new Promise((res) => setTimeout(res, 10));
}
*/
for (let i = 0; i < pages.length; i++) {
const page = pages[i] || {};
const keys = page.keys;
const color =
isObject(page) && page.color != null ? page.color : "white";
await device.setButtonColor({ id: i, color });
// subscribe the data feeds
for (let j = 0; j < 12; j++) {
const conf =
Array.isArray(keys) && keys.length > j ? keys[j] : null;
if (
isObject(conf) &&
conf.display != null &&
Array.isArray(conf.display.source)
) {
let values = [];
//conf.fps = 0;
for (let k = 0; k < conf.display.source.length; k++) {
values.push(null);
}
const freq = isNumber(conf.display.freq)
? conf.display.freq
: 1;
const msPerFrame = 1000 / freq;
conf.renderStart = () => {
let enabled = true;
let startTime = new Date();
let timeout;
function draw() {
if (!enabled) {
return;
}
drawGauge(j, conf, values);
//conf.fps++;
let frameTime = msPerFrame;
const elapsedTime = new Date() - startTime;
if (elapsedTime > 1000) {
startTime = new Date();
conf.fps = 0;
} else if (elapsedTime + frameTime > 1000) {
frameTime = 1000 - elapsedTime;
}
timeout = setTimeout(draw, frameTime);
}
draw();
renderStop.push(() => {
enabled = false;
clearTimeout(timeout);
});
};
for (let k = 0; k < conf.display.source.length; k++) {
const source = conf.display.source[k];
const xplane_dataref = source.xplane_dataref;
if (xplane_dataref != null) {
await xplane.subscribeDataRef(
xplane_dataref,
freq,
async (v) => (values[k] = v),
);
}
}
}
}
}
loadPage(getCurrentPage());
});
const handleKnobEvent = async (id) => {
const { left, right } = getCurrentPage();
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;
await 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];
};
const takeAction = (labeled, type, haptics) => {
if (!isObject(labeled)) {
return;
}
let actionSpec = labeled[type];
if (actionSpec == null) {
return;
}
if (actionSpec.xplane_cmd != null) {
xplane.sendCommand(actionSpec.xplane_cmd);
}
if (haptics) {
device.vibrate(HAPTIC.REV_FASTEST);
}
};
// React to button presses
device.on("down", async ({ id }) => {
if (isNumber(id)) {
if (id >= pages.length) {
return;
}
console.info(`switch to page: ${id}`);
currentPage = id;
loadPage(getCurrentPage());
} else {
takeAction(await handleKnobEvent(id), "pressed", false);
}
});
// React to knob turns
device.on("rotate", async ({ id, delta }) => {
takeAction(await handleKnobEvent(id), delta > 0 ? "inc" : "dec", false);
});
const clearStaleButton = async (touches) => {
const s = new Set(
touches.map((o) => o.target.key).filter((k) => k !== undefined),
);
for (const id of pressed.keys()) {
if (!s.has(id)) {
const conf = getKeyConf(id);
if (conf != null) {
await drawKey(id, conf, false);
}
pressed.delete(id);
}
}
};
device.on("touchstart", async ({ changedTouches, touches }) => {
clearStaleButton(changedTouches);
const target = changedTouches[0].target;
if (target.key === undefined) {
return;
}
pressed.add(target.key);
const key = getKeyConf(target.key);
if (key) {
await drawKey(target.key, key, true);
takeAction(key, "pressed", true);
}
});
device.on("touchmove", ({ changedTouches, touches }) => {
clearStaleButton(changedTouches);
});
device.on("touchend", async ({ changedTouches, touches }) => {
clearStaleButton(changedTouches);
const target = changedTouches[0].target;
if (target.key === undefined) {
return;
}
pressed.delete(target.key);
const key = getKeyConf(target.key);
if (key) {
await drawKey(target.key, key, false);
}
});
process.on("SIGINT", async () => {
await resetRendering();
await device.close();
await xplane.close();
process.exit();
});