mirror of
https://github.com/nicolabs/musiccast-repairkit.git
synced 2026-02-22 18:25:24 +01:00
+ 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:
parent
4f928df0de
commit
0945b69410
10
Dockerfile
10
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/
|
||||
|
|
|
|||
108
README.md
108
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/))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
141
index.js
141
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<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);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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
66
scripts/audio-profile.js
Normal 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
10
scripts/debug.js
Normal 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
63
scripts/sync-volume.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue