Adds new remap orchestrator
This commit is contained in:
parent
6980360fe4
commit
ae5f8f8779
6 changed files with 641 additions and 2 deletions
634
.config/evremap/orchestrator
Executable file
634
.config/evremap/orchestrator
Executable file
|
|
@ -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<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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue