634 lines
No EOL
16 KiB
JavaScript
Executable file
634 lines
No EOL
16 KiB
JavaScript
Executable file
#!/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<Notification>),
|
|
* 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>} 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>} 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>} 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>} 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<VerboseMode, LogType[]>}
|
|
*/
|
|
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<Path, FileContainer>}
|
|
*/
|
|
#fileMap
|
|
/**
|
|
* @type Set<DeviceName>
|
|
*/
|
|
#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<DeviceName, FileContainer[]>}
|
|
*/
|
|
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() |