From ae5f8f877933e2ee7c03f0bf81fc9b01af31de97 Mon Sep 17 00:00:00 2001 From: Michel Fedde Date: Mon, 14 Apr 2025 00:18:12 +0200 Subject: [PATCH] Adds new remap orchestrator --- ...s.Laptop => ATKeyboard.toml##class.Laptop} | 0 ...toml##class.PC => keychron.toml##class.PC} | 0 ...##class.work => keychron.toml##class.Work} | 0 .config/evremap/orchestrator | 634 ++++++++++++++++++ .config/hypr/config/Startup_Apps.conf | 2 + .config/yadm/bootstrap.d/20-Keymap.sh | 7 +- 6 files changed, 641 insertions(+), 2 deletions(-) rename .config/evremap/{evremap.toml##class.Laptop => ATKeyboard.toml##class.Laptop} (100%) rename .config/evremap/{evremap.toml##class.PC => keychron.toml##class.PC} (100%) rename .config/evremap/{evremap.toml##class.work => keychron.toml##class.Work} (100%) create mode 100755 .config/evremap/orchestrator diff --git a/.config/evremap/evremap.toml##class.Laptop b/.config/evremap/ATKeyboard.toml##class.Laptop similarity index 100% rename from .config/evremap/evremap.toml##class.Laptop rename to .config/evremap/ATKeyboard.toml##class.Laptop diff --git a/.config/evremap/evremap.toml##class.PC b/.config/evremap/keychron.toml##class.PC similarity index 100% rename from .config/evremap/evremap.toml##class.PC rename to .config/evremap/keychron.toml##class.PC diff --git a/.config/evremap/evremap.toml##class.work b/.config/evremap/keychron.toml##class.Work similarity index 100% rename from .config/evremap/evremap.toml##class.work rename to .config/evremap/keychron.toml##class.Work diff --git a/.config/evremap/orchestrator b/.config/evremap/orchestrator new file mode 100755 index 0000000..2a75630 --- /dev/null +++ b/.config/evremap/orchestrator @@ -0,0 +1,634 @@ +#!/bin/env node + +const fs = require('fs'); +const path = require("node:path"); +const child_process = require("node:child_process"); +const constants = require("node:constants"); +const os = require('os'); + +/** + * @typedef {{ + * message: string, + * summary: string, + * expire: number, + * printId: boolean, + * replaceid: number, + * urgency: "low"|"normal"|"critical" + * }} Notification + * + * @typedef {{ + * replace(notification: Partial), + * remove() + * }} ReplyNotification + */ +class NotificationHandler { + static #NOTIFICATION_COMMAND = 'notify-send -a "Remap Orchestrator" -i "/usr/share/icons/Gruvbox-Material-Dark/64x64/devices/input-keyboard.svg" -c "system,remapping"' + + static NOTIFICATION_MAX_EXPIRE = 2**16; + + /** + * @param {Partial} notification + * @return {string} + */ + #createNotificationCommand(notification) { + const command = [NotificationHandler.#NOTIFICATION_COMMAND]; + + if (notification.expire) { + command.push(`-t ${notification.expire}`); + } + + if (notification.printId ?? false) { + command.push("-p"); + } + + if (notification.replaceid) { + command.push(`-r ${notification.replaceid}`); + } + + if (notification.urgency) { + command.push(`-u ${notification.urgency}`) + } + + command.push(`"${notification.summary ?? ''}"`); + command.push(`"${notification.message ?? ''}"`); + + return command.join(' '); + } + + /** + * @param {Partial} notification + * @param {boolean} sync + * @returns {Buffer|ChildProcess} + */ + sendNotification(notification, sync = false) { + const command = this.#createNotificationCommand({ + expire: 5_000, + ...notification + }) + + if (sync) { + return child_process.execSync(command); + } + + return child_process.exec(command); + } + + /** + * @param {Partial} notification + * @returns {ReplyNotification} + */ + sendReplaceableNotification(notification) { + const id = parseInt(this.sendNotification({ + ...notification, + printId: true, + expire: NotificationHandler.NOTIFICATION_MAX_EXPIRE + }, true).toString()) + + const sendNotification = this.sendNotification.bind(this); + return { + /** + * @param {Partial} notification + */ + replace(notification) { + sendNotification({ + ...notification, + replaceid: id + }) + }, + remove() { + sendNotification( + { + replaceid: id, + expire: 1 + } + ); + } + } + } +} + +/** + * @typedef {"DEBUG"|"INFO"|"WARN"|"ERROR"|"CRITICAL"} LogType + * @typedef {"ORCHESTRATOR"|string} LogOrigin + * @typedef {"CHATTY"|"INFORMATIONAL"|"DEFAULT"|"ERRORSONLY"} VerboseMode + */ +class Logger { + /** + * @type {Map} + */ + static SHOULD_SEND_MAP = new Map( + [ + [ 'CHATTY', ['DEBUG', 'INFO', 'WARN', 'ERROR', 'CRITICAL'] ], + [ 'INFORMATIONAL', ['INFO', 'WARN', 'ERROR', 'CRITICAL'] ], + [ 'DEFAULT', ['WARN', 'ERROR', 'CRITICAL'] ], + [ 'ERRORSONLY', ['ERROR', 'CRITICAL'] ] + ] + ) + + /** @type {VerboseMode} */ + #verboseMode; + + /** + * @param {VerboseMode} verboseMode + */ + constructor(verboseMode) { + this.#verboseMode = verboseMode; + } + + /** + * @param {string} message + * @param {LogOrigin} origin + * @param {LogType} type + * @param {Date|null} date + */ + log( + message, + origin = "ORCHESTRATOR", + type = "INFO", + date = null + ) { + if (!this.#shouldSend(type)) { + return; + } + + const method = this.#getMethod(type); + + if (!date) { + date = new Date(); + } + + const prefix = `[${date.toISOString()}] ${origin}:${type}>`; + const content = this.#getMessageLines(prefix, message); + method(content); + } + + /** + * @param {LogType} type + */ + #getMethod(type) { + switch (type) { + case "DEBUG": + return console.debug; + default: + case "INFO": + return console.info; + case "WARN": + return console.warn; + case "ERROR": + case "CRITICAL": + return console.error; + } + } + + /** + * @param {string} prefix + * @param {string} message + */ + #getMessageLines(prefix, message) { + return message.split(os.EOL) + .filter(line => line) + .map((line, index) => `${prefix} ${index > 0 ? ' ' : ''}${line}`) + .join(os.EOL); + } + + /** + * @param {LogType} type + * @returns {boolean} + */ + #shouldSend(type) { + return Logger.SHOULD_SEND_MAP.get(this.#verboseMode).includes(type); + } +} + +/** + * @typedef {"waiting"|"running"|"failed"|"destroy"|"invalid"} FileContainerState + * + * @typedef {{ + * filepath: Path, + * device: DeviceName, + * process?: ChildProcess, + * state: FileContainerState + * }} FileContainer + * + * @typedef {string} Path + * @typedef {string} DeviceName + **/ + +class Orchestrator { + + /** @type string */ + static REMAP_COMMAND_TEMPLATE = 'evremap remap {file}'; + + static NOTIFICATION_STARTING = `Starting remap for {device}...` + static NOTIFICATION_READY = 'Remap for {device} ready' + static NOTIFICATION_CONNECTION_LOST = 'Lost connection to {device}...' + static NOTIFICATION_GENERIC_PROCESS_STOP = "Remap process for '{device}' was requested to stop."; + static NOTIFICATION_MISSING_FILE_PROCESS_STOP = "Remap process for '{device}' was requested to stop, due to a missing file."; + static NOTIFICATION_INVALID_FILE_PROCESS_STOP = "Remap process for '{device}' was requested to stop, due to a invalid file."; + /** + * @type {Map} + */ + #fileMap + /** + * @type Set + */ + #devices + + /** + * @type {string} + */ + #path + + /** @type {NotificationHandler} */ + #notifications + /** @type {Logger} */ + #logger + + /** @type {'waiting'|'running'|'stopping'|'stopped'|'failed'} */ + #state + constructor( + path, + logger + ) { + this.#path = path + this.#fileMap = new Map(); + this.#devices = new Set(); + this.#notifications = new NotificationHandler(); + this.#logger = logger; + this.#state = 'waiting'; + } + + async run() { + if (!this.#hasRightToRun()) { + this.#logger.log("Can not run orchestrator!", "ORCHESTRATOR", "CRITICAL") + this.#notifications.sendNotification({ + summary: "Unable to run Remap Orchestrator", + message: "Evremap requires additional rights from the user to run. Please see the evremap GitHub page under 'Running it' to learn how to set the rights.", + expire: 20_000 + }) + this.#state = 'failed'; + return; + } + + this.#state = 'running'; + do { + const prepareSteps = [ + this.#updateFileMap(), + this.#updateDevices() + ]; + await Promise.all(prepareSteps); + + this.#actFiles() + + await new Promise(resolve => setTimeout(resolve, 1000)); + } while(this.#state === 'running'); + this.#state = 'stopped'; + } + + stop() { + if (['waiting', 'failed', 'stopping'].includes(this.#state)) { + return; + } + + this.#state = 'stopping'; + + this.#fileMap.values().forEach((file) => { + this.#destroyProcess(file, 'GENERIC'); + }) + } + + async #updateFileMap() { + const directory = await fs.promises.opendir(this.#path); + + const availableFiles = []; + let directoryEntry; + while (directoryEntry = await directory.read()) { + if (directoryEntry.name.includes('##')) { + // YADM alternative files. We want only the resulting file. + continue; + } + + if (!directoryEntry.name.endsWith('.toml')) { + // evremap files ends with toml + continue; + } + + const filePath = path.resolve(directoryEntry.parentPath, directoryEntry.name); + availableFiles.push(filePath); + + if (this.#fileMap.has(filePath)) { + continue; + } + + this.#fileMap.set(filePath, this.#createFileContainer(filePath)); + } + await directory.close(); + + this.#fileMap.values() + .filter(fileContainer => !availableFiles.includes(fileContainer.filepath)) + .forEach(value => { + this.#fileMap.set(value.filepath, { + ...value, + state: "destroy" + }) + this.#logger.log( + `Marked ${value.filepath} as 'destroy' since file doesn't exist anymore...`, + "ORCHESTRATOR", + 'DEBUG' + ); + }) + + this.#checkDeviceDuplicates(); + } + + /** + * @param {Path} path + * @returns {FileContainer} + */ + #createFileContainer(path) { + const grepResult = child_process.execSync(`grep device_name "${path}"`).toString(); + const device = grepResult.split('=')[1].replaceAll('"', '').trim(); + + this.#logger.log( + `Create file watcher for ${device} with '${path}'`, + "ORCHESTRATOR", + "DEBUG" + ) + + return { + filepath: path, + state: "waiting", + device: device + } + } + + #checkDeviceDuplicates() { + /** + * @type {Map} + */ + const deviceMap = new Map(); + for (let value of this.#fileMap.values()) { + if (value.state === 'destroy') { + continue; + } + + if (!deviceMap.has(value.device)) { + deviceMap.set(value.device, []) + } + + deviceMap.get(value.device).push(value); + } + + const duplicates = deviceMap.values().filter(devices => devices.some(device => device.state !== 'invalid') && devices.length > 1); + for (let duplicate of duplicates) { + const container = duplicate[0]; + this.#notifications.sendNotification( + { + summary: "Duplicate remap files for device", + message: `The following device ${container.device} has multiple remap files. This is not possible. Please make sure only one is avaliable. +Marking all files as invalid and unloading currently loaded files... + +Files: +${duplicate.map((file) => `- ${file.filepath}`).join('\n')}`, + expire: NotificationHandler.NOTIFICATION_MAX_EXPIRE + } + ) + + duplicate.forEach((file) => { + this.#fileMap.set(file.filepath, { + ...file, + state: "invalid" + }); + }) + this.#logger.log( + `Marking device ${container.device} invalid due to multiple files. +Files: +${duplicate.map((file) => `- ${file.filepath}`).join('\n')}\` +`, + "ORCHESTRATOR", + "WARN" + ); + } + + const previousDuplicates = deviceMap.values().filter(devices => devices[0].state === 'invalid' && devices.length < 2); + previousDuplicates.forEach((previousDuplicate) => { + const duplicate = previousDuplicate[0]; + this.#fileMap.set(duplicate.filepath, { + ...duplicate, + state: 'waiting' + }) + + this.#logger.log( + `==> Marking device ${duplicate.device} as valid, due to having only one file`, + 'ORCHESTRATOR', + 'INFO' + ); + }) + } + + async #updateDevices() { + /** + * @var {string} + */ + const evremapResult = await new Promise((resolve, reject) => { + child_process.exec('evremap list-devices | grep Name:', (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + + resolve(stdout); + }) + }) + + /** + * @type {DeviceName[]} + */ + const devices = evremapResult.split('\n').map((result) => result.replace('Name: ', '')) + this.#devices = new Set(devices); + } + + #actFiles() { + for (let file of this.#fileMap.values()) { + switch (file.state) { + case "running": + continue; + case "failed": + case "waiting": + this.#runProcess(file); + break; + case "destroy": + this.#fileMap.delete(file.filepath); + this.#destroyProcess(file, 'REMOVED_FILE'); + break; + case 'invalid': + this.#destroyProcess(file, 'INVALID_FILE'); + break; + } + } + } +s + + /** + * @param {FileContainer} file + */ + #runProcess(file) { + if (!this.#devices.has(file.device)) { + return; + } + + const message = this.#notifications.sendReplaceableNotification({ + summary: "Please wait...", + message: Orchestrator.NOTIFICATION_STARTING.replace('{device}', file.device), + urgency: "critical", + }); + this.#logger.log( + `Started remap for device '${file.device}' with file '${file.filepath}'`, + 'ORCHESTRATOR', + 'INFO' + ); + + const command = Orchestrator.REMAP_COMMAND_TEMPLATE.replace('{file}', file.filepath) + const process = child_process.exec(command); + process.stderr.on('data', (data) => { + data = data.toString(); + if (data.includes("Going into read loop")) { + message.replace({ + summary: "Device ready", + message: Orchestrator.NOTIFICATION_READY.replace("{device}", file.device), + expire: 5_000, + urgency: 'normal' + }) + } + + const [dataMetadata, dataMessage] = data.split('] '); + if (!dataMessage) { + this.#logger.log( + dataMetadata, + `EVREMAP[${file.device}]`, + 'WARN', + ) + return; + } + + const [time, type, origin ] = dataMetadata.replace('[', '').split(' ').filter(value => value); + + this.#logger.log( + dataMessage.trim(), + origin.toUpperCase().replace('EVREMAP', `EVREMAP[${file.device}]`), + type, + new Date(Date.parse(time)) + ) + }) + process.on('exit', (code, signal) => { + const unexpected = code === null || code === 0; + + this.#logger.log( + `Device '${file.device}' ${unexpected ? "unexpectedly" : ''} left with code '${code}'.`, + `EVREMAP[${file.device}]`, + unexpected ? 'WARN' : 'INFO' + ) + + if (unexpected) { + return + } + + message.replace({ + summary: "Device left unexpected...", + message: Orchestrator.NOTIFICATION_CONNECTION_LOST.replace('{device}', file.device) + }) + + this.#fileMap.set(file.filepath, { + ...file, + state: 'failed' + }) + }) + + this.#fileMap.set(file.filepath, { + ...file, + process: process, + state: 'running' + }) + } + + /** + * @param {FileContainer} file + * @param {"GENERIC", "REMOVED_FILE", "INVALID_FILE"} reason + */ + #destroyProcess(file, reason) { + if (!file.process) { + return; + } + + if (file.process.exitCode !== null || file.process.killed) { + return; + } + + let notificationMessage = ''; + let logMessage = ''; + + switch (reason) { + default: + case "GENERIC": + notificationMessage = Orchestrator.NOTIFICATION_GENERIC_PROCESS_STOP.replace('{device}', file.device) + logMessage = `Removing process for device '${file.device}' from file '${file.filepath}', due to unknown reason` + break; + case "REMOVED_FILE": + notificationMessage = Orchestrator.NOTIFICATION_MISSING_FILE_PROCESS_STOP.replace('{device}', file.device) + logMessage = `Removing process for device '${file.device}' from file '${file.filepath}', due to a removed file` + break; + case "INVALID_FILE": + notificationMessage = Orchestrator.NOTIFICATION_INVALID_FILE_PROCESS_STOP.replace('{device}', file.device) + logMessage = `Removing process for device '${file.device}' from file '${file.filepath}', due to a invalid file` + break; + + } + + this.#logger.log( + logMessage, + 'ORCHESTRATOR', + 'INFO' + ) + + this.#notifications.sendNotification({ + summary: "Stopping process", + message: notificationMessage, + }) + + file.process.kill("SIGTERM"); + } + + #hasRightToRun() { + try { + const result = child_process.execSync('evremap list-devices').toString(); + return result.includes('os error 13'); + + } catch (e) { + this.#logger.log( + e.toString(), + 'ORCHESTRATOR', + 'ERROR' + ) + return false; + } + } +} + +/** + * @param {Orchestrator} orchestrator + */ +function prepareProcess(orchestrator) { + const stopMethod = orchestrator.stop.bind(orchestrator); + process.on('SIGTERM', stopMethod); + process.on('SIGINT', stopMethod); + process.on("exit", stopMethod); +} + +const evremapPath = path.resolve(process.env.HOME, '.config/evremap'); + +const logger = new Logger("CHATTY"); +const orchestrator = new Orchestrator(evremapPath, logger); + +prepareProcess(orchestrator); + +orchestrator.run() \ No newline at end of file diff --git a/.config/hypr/config/Startup_Apps.conf b/.config/hypr/config/Startup_Apps.conf index 5f6f45d..aac1815 100644 --- a/.config/hypr/config/Startup_Apps.conf +++ b/.config/hypr/config/Startup_Apps.conf @@ -40,3 +40,5 @@ exec-once = hyprswitch init --show-title --size-factor 5.5 --workspaces-per-row exec-once = hyprsunset & exec-once = sleep 1 && darkman run & + +exec-once = $HOME/.config/evremap/orchestrator &> $HOME/.log/evremap-orchestrator.log diff --git a/.config/yadm/bootstrap.d/20-Keymap.sh b/.config/yadm/bootstrap.d/20-Keymap.sh index 2c429df..4434336 100755 --- a/.config/yadm/bootstrap.d/20-Keymap.sh +++ b/.config/yadm/bootstrap.d/20-Keymap.sh @@ -3,5 +3,8 @@ echo "# Installing keymapping" yay -S --needed evremap -sudo ln -s -f ~/.config/evremap/evremap.toml /etc/evremap.toml -sudo systemctl enable --now evremap + +sudo gpasswd -a $USER input +echo 'KERNEL=="uinput", GROUP="input"' | sudo tee /etc/udev/rules.d/input.rules + +mkdir -p $HOME/.log