From 2e301c67e21618bf3fd6d4bc1c70e569ba599cee Mon Sep 17 00:00:00 2001 From: Determinant Date: Wed, 14 Aug 2024 22:13:43 -0700 Subject: get loupedeck to work with X-Plane --- ctrl.mjs | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ index.mjs | 179 ----------------------------------------------- profile.yaml | 221 ++++++++++++++++++++++++++++++++++++++++++++++++---------- xplane.mjs | 37 ++++++++++ 4 files changed, 444 insertions(+), 215 deletions(-) create mode 100755 ctrl.mjs delete mode 100644 index.mjs create mode 100644 xplane.mjs diff --git a/ctrl.mjs b/ctrl.mjs new file mode 100755 index 0000000..bcab1c5 --- /dev/null +++ b/ctrl.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +import { registerFont } from 'canvas'; + +registerFont('./ocr-a-ext.ttf', { family: 'ocr' }); + +import { discover } from 'loupedeck' + +import { readFile } from 'fs/promises'; +import { parse } from 'yaml' +import { sendCommand } from './xplane.mjs'; +import { isNumberObject } from 'util/types'; + +const pages = parse(await readFile("./profile.yaml", "utf8")); + +// state of the controller +let currentPage; +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 doAction = (labeled, type) => { + if (!isObject(labeled)) { + return; + } + let actionSpec = labeled[type]; + if (actionSpec === undefined) { + return; + } + if (actionSpec.hasOwnProperty('xplane_cmd')) { + sendCommand(actionSpec.xplane_cmd); + } +}; + +const rectifyLabel = (label) => { + let text; + let font = "22px ocr"; + if (isObject(label)) { + text = label.text; + if (label.hasOwnProperty('size')) { + font = `${label.size}px ocr`; + } + } else { + text = label.toString(); + } + return { text, font } +} + +const drawKey = (key, label, down) => { + device.drawKey(key, (c) => { + const { text, font } = rectifyLabel(label); + const padding = 10; + const bg = down ? "white" : "black"; + const fg = down ? "black" : "white"; + const w = c.canvas.width; + const h = c.canvas.height; + 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); + c.font = font; + const { width, actualBoundingBoxAscent, actualBoundingBoxDescent } = c.measureText(text); + const x_axis = (w - width) / 2; + const y_axis = h / 2 + (actualBoundingBoxAscent - actualBoundingBoxDescent) / 2; + c.fillText(text, x_axis, y_axis); + }) +}; + +const drawSideKnobs = (side, labels, highlight) => { + device.drawScreen(side, (c) => { + 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 ? "white" : "black"; + const fg = hl ? "black" : "white"; + 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); + const { text, font } = rectifyLabel(labels[i]); + 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.fillText(text, x_axis + y_offset, -(w - y_axis)); + c.resetTransform(); + } + }) +}; + +const loadPage = (page) => { + const { left, right, keys } = page || {}; + if (!left) { + return + } + drawSideKnobs('left', left); + drawSideKnobs('right', right); + for (let i = 0; i < 12; i++) { + drawKey(i, keys[i], false); + } +}; + +// Observe connect events +device.on('connect', () => { + console.info('Connection successful!') + currentPage = 1; + loadPage(pages[currentPage]); +}) + +// React to button presses +device.on('down', ({ id }) => { + if (isNumber(id)) { + console.info(`switch to page: ${id}`) + if (id == 0) { + return + } + currentPage = id; + loadPage(pages[currentPage]); + } else { + const { left, right, keys } = pages[currentPage] || {}; + if (!left) { + return + } + let pos = {"T": 0, "C": 1, "B": 2}[id.substring(4, 5)]; + let side = {"L": ['left', left], "R": ['right', right]}[id.substring(5, 6)]; + 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); + } + doAction(side[1][pos], 'pressed'); + } +}) + +// React to knob turns +device.on('rotate', ({ id, delta }) => { + const { left, right, keys } = pages[currentPage] || {}; + if (!left) { + return + } + let pos = {"T": 0, "C": 1, "B": 2}[id.substring(4, 5)]; + let side = {"L": ['left', left], "R": ['right', right]}[id.substring(5, 6)]; + 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); + } + doAction(side[1][pos], delta > 0 ? 'inc' : 'dec'); +}) + +const clearStaleButton = (touches) => { + const s = new Set(touches.map(o => o.target.key).filter(k => k !== undefined)); + for (const key of pressed.keys()) { + if (!s.has(key)) { + drawKey(key, pages[currentPage].keys[key], false); + pressed.delete(key); + } + } +}; + +device.on('touchstart', ({ changedTouches, touches }) => { + clearStaleButton(changedTouches); + const target = changedTouches[0].target; + if (target.key === undefined) { + return + } + pressed.add(target.key); + const key = pages[currentPage].keys[target.key]; + drawKey(target.key, key, true); + doAction(key, 'pressed'); + //device.vibrate() +}) + +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); + drawKey(target.key, pages[currentPage].keys[target.key], false) +}) + +process.on('SIGINT', () => { + device.close().then(() => { + console.info("shutdown") + process.exit() + }) +}) diff --git a/index.mjs b/index.mjs deleted file mode 100644 index 193b8cd..0000000 --- a/index.mjs +++ /dev/null @@ -1,179 +0,0 @@ -import { registerFont } from 'canvas'; - -registerFont('./ocr-a-ext.ttf', { family: 'ocr' }); - -import { discover } from 'loupedeck' - -import { readFile } from 'fs/promises'; -import { parse } from 'yaml' - -const pages = parse(await readFile("./profile.yaml", "utf8")); - -// Detects and opens first connected device -const device = await discover() - -const isObject = (obj) => { - return obj != null && obj.constructor.name === "Object" -}; - -const rectifyLabel = (label) => { - let text; - let font; - if (isObject(label)) { - text = label.text; - font = `${label.size}px ocr`; - } else { - text = label.toString(); - font = "24px ocr"; - } - return { text, font } -} - -const drawKey = (key, label, down) => { - device.drawKey(key, (c) => { - const { text, font } = rectifyLabel(label); - const padding = 10; - const bg = down ? "white" : "black"; - const fg = down ? "black" : "white"; - const w = c.canvas.width; - const h = c.canvas.height; - 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); - c.font = font; - const { width, actualBoundingBoxAscent, actualBoundingBoxDescent } = c.measureText(text); - const x_axis = (w - width) / 2; - const y_axis = h / 2 + (actualBoundingBoxAscent - actualBoundingBoxDescent) / 2; - c.fillText(text, x_axis, y_axis); - }) -}; - -const drawSideKnobs = (side, labels, highlight) => { - device.drawScreen(side, (c) => { - 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 ? "white" : "black"; - const fg = hl ? "black" : "white"; - 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); - const { text, font } = rectifyLabel(labels[i]); - 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.fillText(text, x_axis + y_offset, -(w - y_axis)); - c.resetTransform(); - } - }) -}; - -const loadPage = (page) => { - const { left, right, keys } = page || {}; - if (!left) { - return - } - drawSideKnobs('left', left); - drawSideKnobs('right', right); - for (let i = 0; i < 12; i++) { - drawKey(i, keys[i], false); - } -}; - -let currentPage; -let pressed = new Set(); -let highlighted = new Set(); - -// Observe connect events -device.on('connect', () => { - console.info('Connection successful!') - currentPage = 1; - loadPage(pages[currentPage]); -}) - -// React to button presses -device.on('down', ({ id }) => { - console.info(`switch to page: ${id}`) - if (id == 0) { - return - } - currentPage = id; - loadPage(pages[currentPage]); -}) - -// React to knob turns -device.on('rotate', ({ id, delta }) => { - const { left, right, keys } = pages[currentPage] || {}; - if (!left) { - return - } - let pos = {"T": 0, "C": 1, "B": 2}[id.substring(4, 5)]; - let side = {"L": ['left', left], "R": ['right', right]}[id.substring(5, 6)]; - 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); - } -}) - -const clearStaleButton = (touches) => { - const s = new Set(touches.map(o => o.target.key).filter(k => k !== undefined)); - for (const key of pressed.keys()) { - if (!s.has(key)) { - drawKey(key, pages[currentPage].keys[key], false); - pressed.delete(key); - } - } -}; - -device.on('touchstart', ({ changedTouches, touches }) => { - clearStaleButton(changedTouches); - const target = changedTouches[0].target; - if (target.key === undefined) { - return - } - pressed.add(target.key); - drawKey(target.key, pages[currentPage].keys[target.key], true); - //device.vibrate() -}) - -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); - drawKey(target.key, pages[currentPage].keys[target.key], false) -}) - -process.on('SIGINT', () => { - device.close().then(() => { - console.info("shutdown") - process.exit() - }) -}) diff --git a/profile.yaml b/profile.yaml index a7f3210..07f1e33 100644 --- a/profile.yaml +++ b/profile.yaml @@ -3,49 +3,198 @@ # page 1 - left: # left knob labels - - HDG - - ALT - - CRS - right: - # right knob labels + - text: FMS out + size: 20 + inc: + xplane_cmd: sim/GPS/g1000n1_fms_outer_up + dec: + xplane_cmd: sim/GPS/g1000n1_fms_outer_down - text: FMS in size: 20 + inc: + xplane_cmd: sim/GPS/g1000n1_fms_inner_up + dec: + xplane_cmd: sim/GPS/g1000n1_fms_inner_down + - text: BARO + inc: + xplane_cmd: sim/GPS/g1000n1_baro_up + dec: + xplane_cmd: sim/GPS/g1000n1_baro_down + right: + # right knob labels - text: FMS out size: 20 - - BARO + inc: + xplane_cmd: sim/GPS/g1000n2_fms_outer_up + dec: + xplane_cmd: sim/GPS/g1000n2_fms_outer_down + - text: FMS in + size: 20 + inc: + xplane_cmd: sim/GPS/g1000n2_fms_inner_up + dec: + xplane_cmd: sim/GPS/g1000n2_fms_inner_down + - '' keys: # 12 touchable keys from left to right, top to bottom - - A - - B - - C - - D - - E - - F - - G - - H - - I - - J - - K - - L + - text: -D-> + pressed: + xplane_cmd: sim/GPS/g1000n1_direct + - text: MENU + pressed: + xplane_cmd: sim/GPS/g1000n1_menu + - text: Direct + pressed: + xplane_cmd: sim/GPS/g1000n2_direct + - text: MENU + pressed: + xplane_cmd: sim/GPS/g1000n2_menu + - text: FPL + pressed: + xplane_cmd: sim/GPS/g1000n1_fpl + - text: PROC + pressed: + xplane_cmd: sim/GPS/g1000n1_proc + - text: FPL + pressed: + xplane_cmd: sim/GPS/g1000n2_fpl + - text: PROC + pressed: + xplane_cmd: sim/GPS/g1000n2_proc + - text: CLR + pressed: + xplane_cmd: sim/GPS/g1000n1_clr + - text: ENT + pressed: + xplane_cmd: sim/GPS/g1000n1_ent + - text: CLR + pressed: + xplane_cmd: sim/GPS/g1000n2_clr + - text: ENT + pressed: + xplane_cmd: sim/GPS/g1000n2_ent + # page 2 - left: - - A - - B - - C + - text: HDG + inc: + xplane_cmd: sim/GPS/g1000n1_hdg_up + dec: + xplane_cmd: sim/GPS/g1000n1_hdg_down + pressed: + xplane_cmd: sim/GPS/g1000n1_hdg_sync + - text: ALT + inc: + xplane_cmd: sim/GPS/g1000n1_alt_inner_up + dec: + xplane_cmd: sim/GPS/g1000n1_alt_inner_down + - text: CRS + inc: + xplane_cmd: sim/GPS/g1000n1_crs_up + dec: + xplane_cmd: sim/GPS/g1000n1_crs_down + pressed: + xplane_cmd: sim/GPS/g1000n1_crs_sync + right: - - X - - Y - - Z + - '' + - '' + - '' + keys: + - text: AP + pressed: + xplane_cmd: sim/GPS/g1000n1_ap + - text: FD + pressed: + xplane_cmd: sim/GPS/g1000n1_fd + - text: HDG + pressed: + xplane_cmd: sim/GPS/g1000n1_hdg + - text: ALT + pressed: + xplane_cmd: sim/GPS/g1000n1_alt + - text: NAV + pressed: + xplane_cmd: sim/GPS/g1000n1_nav + - text: VNV + pressed: + xplane_cmd: sim/GPS/g1000n1_vnv + - text: APR + pressed: + xplane_cmd: sim/GPS/g1000n1_apr + - text: BC + pressed: + xplane_cmd: sim/GPS/g1000n1_bc + - text: VS + pressed: + xplane_cmd: sim/GPS/g1000n1_vs + - text: UP + pressed: + xplane_cmd: sim/GPS/g1000n1_nose_up + - text: FLC + pressed: + xplane_cmd: sim/GPS/g1000n1_flc + - text: DN + pressed: + xplane_cmd: sim/GPS/g1000n1_nose_down + +# page 3 +- left: + - text: COM in + size: 20 + inc: + xplane_cmd: sim/GPS/g1000n1_com_inner_up + dec: + xplane_cmd: sim/GPS/g1000n1_com_inner_down + pressed: + xplane_cmd: sim/GPS/g1000n1_com12 + - text: COM out + size: 20 + inc: + xplane_cmd: sim/GPS/g1000n1_com_outer_up + dec: + xplane_cmd: sim/GPS/g1000n1_com_outer_down + pressed: + xplane_cmd: sim/GPS/g1000n1_com12 + - '' + right: + - text: NAV in + size: 20 + inc: + xplane_cmd: sim/GPS/g1000n1_nav_inner_up + dec: + xplane_cmd: sim/GPS/g1000n1_nav_inner_down + pressed: + xplane_cmd: sim/GPS/g1000n1_nav12 + - text: NAV out + size: 20 + inc: + xplane_cmd: sim/GPS/g1000n1_nav_outer_up + dec: + xplane_cmd: sim/GPS/g1000n1_nav_outer_down + pressed: + xplane_cmd: sim/GPS/g1000n1_nav12 + - '' keys: - - 1 - - 2 - - 3 - - 4 - - 5 - - 6 - - 7 - - 8 - - 9 - - 10 - - 11 - - 12 + - text: <-COM + pressed: + xplane_cmd: sim/GPS/g1000n1_com_ff + - text: <-NAV + pressed: + xplane_cmd: sim/GPS/g1000n1_nav_ff + - '' + - '' + - text: COM1 M + size: 16 + - text: COM1 + pressed: + xplane_cmd: sim/GPS/monitor_audio_com1 + - '' + - '' + - text: COM2 M + size: 16 + - text: COM2 + pressed: + xplane_cmd: sim/GPS/monitor_audio_com2 + - '' + - '' diff --git a/xplane.mjs b/xplane.mjs new file mode 100644 index 0000000..54190c9 --- /dev/null +++ b/xplane.mjs @@ -0,0 +1,37 @@ +import dgram from 'node:dgram'; + +const xplaneAddr = "localhost"; +const xplanePort = 49000; +const socket = dgram.createSocket('udp4'); + +//socket.on('message', (msg, rinfo) => { +// console.log(msg, rinfo) +//}); +// +//socket.bind(10080); + +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.log(Array.from(buffer)); + 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(`sent command: ${cmd}`) + await socket.send(buffer, 0, buffer.length, xplanePort, xplaneAddr); +}; + +//command("sim/GPS/g1000n1_hdg_down"); -- cgit v1.2.3-70-g09d2