+ ability to load external scripts

+ using yamaha-yxc-nodejs for easier addition of new scripts
+ can now sync the volume of several devices
This commit is contained in:
nicobo 2021-03-26 21:31:32 +01:00
parent 4f928df0de
commit 0945b69410
No known key found for this signature in database
GPG key ID: 2581E71C5FA5285F
8 changed files with 298 additions and 117 deletions

View file

@ -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/

108
README.md
View file

@ -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/))

View file

@ -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

145
index.js
View file

@ -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<scenarii.length ; s++ ) {
scenarii[s].handler.onEvent(body);
}
});
server.on('listening', () => {
@ -147,11 +84,63 @@ server.on('listening', () => {
ipaddr + ':' + port
);
sendEventServerAddress(port);
// Register at each configured 'source'
var sourcesDict = {};
for ( s=0 ; s<scenarii.length ; s++ ) {
var scenario = scenarii[s];
if ( scenario.conf && typeof scenario.conf !== 'undefined' &&
scenario.conf.source && typeof scenario.conf.source !== 'undefined' ) {
sourcesDict[scenario.conf.source] = true;
}
}
sourcesList = Object.keys(sourcesDict);
for ( s=0 ; s<sourcesList.length ; s++ ) {
console.log("Registering with port",port,"at ",sourcesList[s])
sendEventServerAddress(sourcesList[s],port);
// 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(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<scripts.length ; s++ ) {
var scenarioModule = scripts[s];
console.log("Loading scenario :", scenarioModule);
var scenarioClass = require(scenarioModule);
var scenarioName = path.basename(scenarioModule, path.extname(scenarioModule));
console.log("Scenario name :", scenarioName);
var conf = Object.assign({}, argv, argv.conf[scenarioName]);
console.log("Scenario conf. :", conf);
scenarii.push({
name: scenarioName,
conf: conf,
handler: new scenarioClass(conf)
});
}
console.log("Scenarii :", scenarii);
server.bind(INCOMING_EVENT_SERVER_PORT, LOCAL_IP);

View file

@ -5,5 +5,11 @@
"author": "nicobo (https://www.nicolabs.net/people/nicobo)",
"license": "MIT",
"private": true,
"dependencies": {}
"engines": {
"node": ">=10"
},
"dependencies": {
"yamaha-yxc-nodejs": "0.0.13",
"yargs": "^16.2.0"
}
}

66
scripts/audio-profile.js Normal file
View file

@ -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);
}
}
}

10
scripts/debug.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = class Scenario {
constructor( configuration ) {
console.debug("Debug configuration :",configuration);
}
onEvent( event ) {
console.debug("<<<",event);
}
}

63
scripts/sync-volume.js Normal file
View file

@ -0,0 +1,63 @@
const YamahaYXC = require('yamaha-yxc-nodejs');
module.exports = class Scenario {
/**
Expects a configuration in the form :
{
"source": <hostname/ip of the MusicCast device to listen to volume changes>,
"target": [ <list of hostname/ip of the MC devices to reflect the volume of the source device> ]
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<configuration.target.length ; t++ ) {
this.targets.push( new YamahaYXC(configuration.target[t]) );
}
} else {
// We allow 'configuration.target' to be a string if only one value is given
this.targets.push( new YamahaYXC(configuration.target) );
}
console.debug("Targets :",this.targets);
}
/**
When the volume of the source has changed, will set the volume of all target devices to the same value.
E.g. event = { main: { volume: 47 }, device_id: 'AC44F2852577' }
*/
onEvent( event ) {
// Filters out events without a volume
if ( !event.main || typeof event.main.volume == 'undefined' ) {
return;
}
// It seems that a device may report for another, linked one => 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.length ; t++ ) {
console.log(">>>", this.targets[t].ip, ".setVolumeTo:", event.main.volume);
this.targets[t].setVolumeTo(event.main.volume);
}
}
}