mpd-avr-client
Version:
AVR as a MPD client
240 lines (214 loc) • 6.29 kB
JavaScript
;
const { join } = require('path');
const { access, writeFile } = require('fs/promises');
const {
createInterface,
moveCursor,
cursorTo,
clearLine,
} = require('node:readline');
const {
Observable,
Subject,
from,
race,
of,
EMPTY,
concatMap,
forkJoin,
defer,
asapScheduler,
delay,
} = require('rxjs');
const {
map,
mergeScan,
tap,
take,
last,
filter,
expand,
switchMap,
catchError,
takeUntil,
} = require('rxjs/operators');
const process = require('process');
const AppTerminator = require('../src/app-terminator');
const { getInstance: getLircClient } = require('../src/clients/lirc-client');
const tvServiceFactory = require('../src/services/tv-service-factory');
const describe = 'Map remote control LIRC codes to IRCC codes';
/** @type {import('yargs').CommandModule<{}, MapKeysCommandOptions>} */
module.exports = {
command: 'map-keys',
describe,
builder: (yargs) =>
yargs
.usage(describe)
.usage('Usage: $0 map-keys [options]')
.example(
'$0 map-keys -r /dev/input/event0 -b ~/.mpd-avr-client/bravia-launch-profile.json -C ~/.mpd-avr-client'
)
.option('C', {
alias: 'directory',
description: 'Specify the working directory',
default: process.cwd(),
type: 'string',
})
.option('infraredDevice', {
alias: 'r',
nargs: 1,
type: 'string',
description:
'Provide the path to an infrared receiver device, e.g. `/dev/input/event0`.',
demandOption: true,
})
.option('braviaLaunchProfile', {
alias: 'b',
nargs: 1,
type: 'string',
description:
'Provide the path to a launch profile for Sony Bravia TV when mapping remote control buttons to IRCC events',
}),
handler: (commandOptions) => {
const cwd = commandOptions.directory;
/**
* @desc Protocol clients
*/
const lircClient = getLircClient(commandOptions); // read-only client
const appTerminator = new AppTerminator(lircClient);
/**
* @desc Services
*/
const tvService = tvServiceFactory(commandOptions);
/**
* @desc Scope members
*/
const lircCode$ = lircClient.publisher().pipe(map(({ data }) => data));
const destroy$ = appTerminator.publisher();
const appendToLircCodeIrccCodePair = (
lircCodeIrccCodePairs,
button,
irccCode
) =>
race(
lircCode$,
new Observable((subscriber) => {
const readline = createInterface({
input: process.stdin,
output: process.stdout,
terminal: false, // prevent echoing duplicate lines
});
readline.on('line', () => subscriber.next(undefined));
return () => readline.close();
})
).pipe(
take(1),
map((lircCode) => [
...lircCodeIrccCodePairs,
[button, lircCode, irccCode],
])
);
const askQuestion = (query) =>
new Observable((subscriber) => {
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
readline.question(query, (answer) => {
subscriber.next(answer);
subscriber.complete();
});
return () => readline.close();
});
const handleFileName = (fileName) => {
if (fileName === '') {
return of('');
}
const filePath = join(cwd, fileName);
return from(access(filePath)).pipe(
switchMap(() =>
askQuestion(
`⚠️ File "${fileName}" exists under ${cwd}. Override? [y/N]: `
)
),
map((answer) => {
const override = answer.trim().toLowerCase() === 'y';
return override ? filePath : '';
}),
catchError(() => of(filePath))
);
};
const getFilePath = () =>
askQuestion('💾 Enter file name: ').pipe(
switchMap(handleFileName),
/**
* @desc tell RxJS to execute this on the next microtask/macroTask boundary to avoid the stdin leaking into the retry
*/
delay(0, asapScheduler)
);
const getValidFilePath = () =>
defer(() =>
of('').pipe(
expand((filePath) => (filePath ? EMPTY : getFilePath())),
filter((path) => path !== ''),
take(1)
)
);
const buttonIrccCodePairs$ = tvService
.getButtonIrccCodePairs()
.pipe(concatMap(from));
buttonIrccCodePairs$
.pipe(
mergeScan(
(acc, [button, irccCode]) => {
if (acc.length) {
console.log('');
}
console.log(
`🔘 Press the remote control button corresponding to the "${button}" action`
);
console.log('--- or press Enter to skip ---');
return appendToLircCodeIrccCodePair(acc, button, irccCode);
},
[],
// process emissions sequentially
1
),
tap((lircCodeIrccCodePairs) => {
const [_, lircCode] = lircCodeIrccCodePairs.at(-1);
moveCursor(process.stdout, 0, -1);
cursorTo(process.stdout, 0);
clearLine(process.stdout, 'right');
if (lircCode) {
process.stdout.write(`\x1b[2K♒ ${lircCode}\n`);
} else {
process.stdout.write(`\x1b[2K¯\\_(ツ)_/¯ ${lircCode}\n`);
}
}),
last(),
switchMap((lircCodeIrccCodePairs) =>
forkJoin([of(lircCodeIrccCodePairs), getValidFilePath()])
),
switchMap(([lircCodeIrccCodePairs, filePath]) =>
forkJoin([
of(filePath),
writeFile(filePath, JSON.stringify(lircCodeIrccCodePairs, null, 2)),
])
),
takeUntil(destroy$)
)
.subscribe({
next: ([filePath]) =>
console.log(`✨ File successfully saved to: ${filePath}`),
error: (err) => console.error('❌ An error occurred:', err),
complete: () => appTerminator.onExit(),
});
/**
* @desc OnDestroy
*/
process.on('SIGINT', () => {
appTerminator.onExit(true);
process.exit(130);
});
},
};