From 0945b694101a6d99ae4fc94a2fea54a381a0d6e9 Mon Sep 17 00:00:00 2001 From: nicobo Date: Fri, 26 Mar 2021 21:31:32 +0100 Subject: [PATCH] + ability to load external scripts + using yamaha-yxc-nodejs for easier addition of new scripts + can now sync the volume of several devices --- Dockerfile | 10 +++ README.md | 108 +++++++++++++++++++---------- docker-compose.yml | 5 +- index.js | 145 ++++++++++++++++++--------------------- package.json | 8 ++- scripts/audio-profile.js | 66 ++++++++++++++++++ scripts/debug.js | 10 +++ scripts/sync-volume.js | 63 +++++++++++++++++ 8 files changed, 298 insertions(+), 117 deletions(-) create mode 100644 scripts/audio-profile.js create mode 100644 scripts/debug.js create mode 100644 scripts/sync-volume.js diff --git a/Dockerfile b/Dockerfile index 2745091..68c93e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,15 @@ +# See https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#node-gyp-alpine +FROM node:alpine as builder +## Install build toolchain, install node deps and compile native add-ons +RUN apk add --no-cache python make g++ +WORKDIR /usr/src/app +COPY package.json ./ +RUN npm install --production + FROM node:alpine EXPOSE 41100/udp CMD [ "node", "index.js" ] WORKDIR /usr/src/app +COPY --from=builder /usr/src/app/node_modules ./node_modules/ COPY index.js package.json ./ +COPY scripts/* ./scripts/ diff --git a/README.md b/README.md index 6be7fe0..f0260a6 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,62 @@ -# musiccat-repairkit +# MusicCast "repair kit" -Instead of having to manually change the sound program when you're switching from e.g. TV to Spotify and vice verse, let the computer do it for you. +This program will automatically update the settings of your MusicCast© devices for you, that cannot be done by the devices alone. -When the input source of your Yamaha receiver changes, the sound program and clear voice settings are automatically changed. +It currently implements the following use cases : -## Usage +- change the sound program when you're switching from e.g. TV to Spotify and vice verse +- synchronize the volume of two devices -Specify the following env variables before running `index.js` - YAMAHA_IP # The ip address to your receiver +## Command line usage + +When running the program you need to specify which scenarios to run. +The scenarios are `.js` scripts which implement the use cases above. More details in the next sections. + +Use the `-s` command line option to specify which script to load : + + node . -s ./scripts/sync-volume.js ./scripts/debug.js --source=192.168.1.42 --target=192.168.1.43 --target=192.168.1.44 + +Or in a configuration file : + +```json +{ + "conf": { + "sync-volume": { + "source": "192.168.1.43", + "target": [ + "192.168.1.43", + "192.168.1.44" + ] + } + } +} +``` + +Then use the `--config` option : + + node . -s ./scripts/sync-volume.js ./scripts/debug.js --config config.json + +You can define generic options at the top level and scenario-specific options under a prefix named after the script's name (its filename without extension). +For instance with `--source 1.2.3.4 --conf.sync-volume.source 5.6.7.8`, `1.2.3.4` will be used as the *source* parameter by default but `5.6.7.8` will override its value for the *sync-volume* scenario only. + +You can pass those arguments multiple times or give several values if you need to. + +The following environment variables may be specified before running `index.js` : + LOCAL_IP # Your local ip address to use, 0.0.0.0 could work in some setups PORT # Port listening for events from the receiver, defaults to 41100 Example - YAMAHA_IP=192.168.1.216 LOCAL_IP=192.168.1.187 node . + PORT=44444 LOCAL_IP=192.168.1.187 node . -## Misc + +## Scenarios + +### Automatic sound program depending on the source + +When the input source of your Yamaha receiver changes, the sound program and clear voice settings are automatically changed. Currently the following mappings from source to sound program are hard coded @@ -25,59 +65,53 @@ Currently the following mappings from source to sound program are hard coded spotify => music with clear_voice disabled airplay => music with clear_voice disabled -## Use case : sync volume from a device to another one +On the command line, use `-s scripts/sync-volume.js` to enable this script and use the `--conf-sync-volume-source` option to set the hostname or IP address of the receiver. -1. Subscribe to events at the source device. It's UDP : the code for this part comes from [axelo/yamaha-sound-program-by-source](https://github.com/axelo/yamaha-sound-program-by-source)). +Top-level options (e.g. `--source`) and configuration file are also valid. -2. In the events received, identify those who define a volume update. - # 2. Récupérer le volume sur la chaîne (controlé par télécommand IR / bouton en facade) - GET http://192.168.1.31/YamahaExtendedControl/v1/main/getStatus - -> contient /volume = 32 - # 3. Positionner le volume des enceintes à la même valeur - GET http://192.168.1.33/YamahaExtendedControl/v1/main/setVolume?volume=20 +### Sync volume of two devices + +If you have a Yamaha MusicCast receiver (like *CRX N470D*) *wirelessly* connected to Yamaha MusicCast speakers (like a MusicCast 20 stereo pair), you may have noticed that using the front volume button or the IR remote from the CRX will not update the volume of the linked speakers. Those hardware buttons only work with speakers wired to the CRX receiver. Your only option to set the same volume to all connected devices is to use the Yahama MusicCast mobile app, which is far less user-friendly than the physical remote. + +This program will solve this by listening to a source device and applying any volume change to a target one. + +On the command line, use `-s scripts/audio-profile.js` to enable this script and use the following options : +- `--conf-audio-profile-source` sets the hostname or IP address of the *master* receiver +- `--conf-audio-profile-target` is a space-separated list of *slave* devices that will be updated with the master's volume + +Top-level options (e.g. `--source`) and configuration file are also valid. + ## Docker build & run -Create a local .env +Update `docker-compose.yml` to reflect the IP addresses of your setup (or use a `.env` file or set environment variables). Build : docker-compose build -Run locally, then stop : +Run locally : docker-compose up --detach - docker-compose down Deploy on a swarm : docker stack deploy -c docker-compose.yml musiccast-repairkit -## MQTT -https://github.com/ppt000/musiccast2mqtt -https://musiccast2mqtt.readthedocs.io/en/latest/ +## Logging and debugging - pip install --user musicast2mqtt - musiccast2mqtt +Will log network activity : -Marche bien, mais c'est du Python 2. -On voit bien les events de mise à jour du volume. -Le projet contient de la doc sur le protocole MusicCast également ! + NODE_DEBUG="net" node index.js ... # References -http://habitech.s3.amazonaws.com/PDFs/YAM/MusicCast/Yamaha%20MusicCast%20HTTP%20simplified%20API%20for%20ControlSystems.pdf - -https://www.pdf-archive.com/2017/04/21/yxc-api-spec-advanced/yxc-api-spec-advanced.pdf - -https://github.com/samvdb/php-musiccast-api - - -# TODO - -Rename index.js to server.js and remove the start script from package.json (server.js is the default for npm start) +- http://habitech.s3.amazonaws.com/PDFs/YAM/MusicCast/Yamaha%20MusicCast%20HTTP%20simplified%20API%20for%20ControlSystems.pdf +- https://www.pdf-archive.com/2017/04/21/yxc-api-spec-advanced/yxc-api-spec-advanced.pdf +- https://github.com/samvdb/php-musiccast-api +- [musiccast2mqtt, another implementation with MQTT, but old](https://github.com/ppt000/musiccast2mqtt) ([doc](https://musiccast2mqtt.readthedocs.io/en/latest/)) diff --git a/docker-compose.yml b/docker-compose.yml index 1bf819e..0419004 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,12 @@ -version: "3.2" +version: "3.7" services: musiccast-repairkit: build: . image: nicolabs/musiccast-repairkit + command: "-s ./scripts/sync-volume.js --source ${YAMAHA_SOURCE_IP} --target ${YAMAHA_SPEAKER_IP}" + # See https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#handling-kernel-signals + init: true ports: - target: 41100 published: 41100 diff --git a/index.js b/index.js index 85a445d..1c0d780 100644 --- a/index.js +++ b/index.js @@ -1,40 +1,13 @@ +const yargs = require('yargs'); +const path = require('path'); const udp = require('dgram'); const server = udp.createSocket('udp4'); const http = require('http'); +const YamahaYXC = require('yamaha-yxc-nodejs'); -const YAMAHA_IP = process.env.YAMAHA_IP; -const SPEAKERS_IP = process.env.SPEAKERS_IP; const LOCAL_IP = process.env.LOCAL_IP || "0.0.0.0"; const INCOMING_EVENT_SERVER_PORT = parseInt(process.env.PORT) || 41100; -console.log("YAMAHA_IP=",YAMAHA_IP); -console.log("SPEAKERS_IP=",SPEAKERS_IP); - -const inputSourceToSoundProgam = inputSource => { - switch (inputSource) { - case 'airplay': - case 'spotify': - return 'music'; - - case 'tv': - case 'bd_dvd': - return 'tv_program'; - - default: - return undefined; - } -}; - -const inputSourceShouldUseClearVoice = inputSource => { - switch (inputSource) { - case 'tv': - case 'bd_dvd': - return true; - - default: - return false; - } -}; const send = (host, path, headers) => http @@ -66,56 +39,17 @@ const send = (host, path, headers) => console.error('Error', err.message); }); -const sendSetSoundProgram = soundProgram => - send( - YAMAHA_IP, - `/YamahaExtendedControl/v1/main/setSoundProgram?program=${soundProgram}` - ); - -const sendSetClearVoice = enabled => - send( - YAMAHA_IP, - `/YamahaExtendedControl/v1/main/setClearVoice?enabled=${ - enabled ? 'true' : 'false' - }` - ); - -const setVolume = (host,volume) => - send(host,'/YamahaExtendedControl/v1/main/setVolume?volume='+volume); - -const sendEventServerAddress = port => - send(YAMAHA_IP, +const sendEventServerAddress = (hostname,port) => + send(hostname, '/YamahaExtendedControl/v1', { 'X-AppName': 'MusicCast/1', 'X-AppPort': port }); -const handleIncomingEvent = event => { - // TODO Log all events at debug level - const isInputChanged = event.main && typeof event.main.input !== 'undefined'; - - // e.g. { main: { volume: 47 }, device_id: 'AC44F2852577' } - if ( event.main && typeof event.main.volume !== 'undefined' ) { - console.log(event); - setVolume(SPEAKERS_IP,event.main.volume); - } - - if (isInputChanged) { - const soundProgram = inputSourceToSoundProgam(event.main.input); - const setClearVoice = inputSourceShouldUseClearVoice(event.main.input); - - if (soundProgram) { - console.log('Changing sound program to', soundProgram); - //sendSetSoundProgram(soundProgram); - } - - console.log('Setting clear voice to', setClearVoice); - // sendSetClearVoice(setClearVoice); - } -}; server.on('close', () => { console.log('Server is closed!'); + // TODO ? Notify the device not to send events anymore ? }); server.on('error', error => { @@ -128,13 +62,16 @@ server.on('message', (msg, _info) => { try { body = JSON.parse(msg.toString('utf8')); + // console.log(body); } catch (err) { console.warn('Could not parse event', msg.toString()); return; } - // console.log(body); - handleIncomingEvent(body); + // Runs each scenario on this event + for ( s=0 ; s { @@ -147,11 +84,63 @@ server.on('listening', () => { ipaddr + ':' + port ); - sendEventServerAddress(port); + // Register at each configured 'source' + var sourcesDict = {}; + for ( s=0 ; s sendEventServerAddress(port), 5 * 60 * 1000); + // After 10 minutes the receiver will drop this server to be notified unless we + // say hi again, so to be on the safe side, ask again every 5 minutes. + setInterval(() => sendEventServerAddress(sourcesList[s],port), 5 * 60 * 1000); + } }); +// Command line parsing +const argv = yargs + .option('s', { + alias: ['scripts'], + describe: 'Load these .js files each implementing a scenario', + requiresArg: true, + type: 'array', + demandOption: true + }) + // Configuration as a whole .json file + .config() + .help() + .alias('help', 'h') + .argv; +console.log("argv:", argv); + +// Instanciates the handlers for each scenario +var scenarii = []; +const scripts = argv.scripts; +for ( var s=0 ; s=10" + }, + "dependencies": { + "yamaha-yxc-nodejs": "0.0.13", + "yargs": "^16.2.0" + } } diff --git a/scripts/audio-profile.js b/scripts/audio-profile.js new file mode 100644 index 0000000..1891d1d --- /dev/null +++ b/scripts/audio-profile.js @@ -0,0 +1,66 @@ +const YamahaYXC = require('yamaha-yxc-nodejs'); + +const inputSourceToSoundProgam = inputSource => { + switch (inputSource) { + case 'airplay': + case 'spotify': + return 'music'; + + case 'tv': + case 'bd_dvd': + return 'tv_program'; + + default: + return undefined; + } +}; + +const inputSourceShouldUseClearVoice = inputSource => { + switch (inputSource) { + case 'tv': + case 'bd_dvd': + return true; + + default: + return false; + } +}; + + +module.exports = class Scenario { + + /** + Expects a single 'source' field with the hostname/ip of the MusicCast device to listen to. + + E.g. { "source": "192.168.1.42" } + */ + constructor( configuration ) { + this.source = new YamahaYXC(configuration.source); + console.debug("Source : ",this.source); + } + + /** + When the input changed : + - will set the sound program according to the rules defined in inputSourceToSoundProgam + - will set "clear voice" on or off according to the rules defined in inputSourceShouldUseClearVoice + */ + onEvent( event ) { + + const isInputChanged = event.main && typeof event.main.input !== 'undefined'; + + if (isInputChanged) { + const soundProgram = inputSourceToSoundProgam(event.main.input); + const setClearVoice = inputSourceShouldUseClearVoice(event.main.input); + + if (soundProgram) { + console.log('Changing sound program to', soundProgram); + this.source.setSound(soundProgram); + } + + console.log('Setting clear voice to', setClearVoice); + this.source.setClearVoice(setClearVoice); + } + + } + +} diff --git a/scripts/debug.js b/scripts/debug.js new file mode 100644 index 0000000..ae3d708 --- /dev/null +++ b/scripts/debug.js @@ -0,0 +1,10 @@ +module.exports = class Scenario { + + constructor( configuration ) { + console.debug("Debug configuration :",configuration); + } + + onEvent( event ) { + console.debug("<<<",event); + } +} diff --git a/scripts/sync-volume.js b/scripts/sync-volume.js new file mode 100644 index 0000000..64c5302 --- /dev/null +++ b/scripts/sync-volume.js @@ -0,0 +1,63 @@ +const YamahaYXC = require('yamaha-yxc-nodejs'); + +module.exports = class Scenario { + + /** + Expects a configuration in the form : + + { + "source": , + "target": [ ] + + E.g. { "source": "192.168.1.42", "targets": [ "192.168.1.43", "192.168.1.44" ] } + */ + constructor( configuration ) { + + // Instanciates the source device and enrich with technical infos + var source = new YamahaYXC(configuration.source); + source.getDeviceInfo(). + then( result => { + source.deviceInfo = JSON.parse(result); + console.debug("Source :",source); + } + ); + this.source = source; + + // Instanciates the target devices + this.targets = []; + if ( Array.isArray(configuration.target) ) { + for ( var t=0 ; t we filter out those events + if ( typeof event.device_id != undefined && event.device_id != this.source.deviceInfo.device_id ) { + return; + } + + // OK, let's handle this volume change + console.log("<<<", event); + + for ( var t=0 ; t>>", this.targets[t].ip, ".setVolumeTo:", event.main.volume); + this.targets[t].setVolumeTo(event.main.volume); + } + } + +}