dmx-hue
Version:
Art-Net node to control Philips Hue lights with DMX
371 lines (335 loc) • 11.6 kB
JavaScript
import fs from 'node:fs';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import minimist from 'minimist';
import inquirer from 'inquirer';
import chalk from 'chalk';
import Util from './lib/util.js';
import Hue from './lib/hue.js';
import { listenArtNet } from './lib/artnet.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const help = `${chalk.bold('Usage:')} dmx-hue [setup] [options]
Create an ArtNet DMX<>Hue bridge.
${chalk.bold('Options:')}
-h, --host Host address to listen on [default: '0.0.0.0']
-a, --address Set DMX address (range 1-511) [default: 1]
-u, --universe Art-Net universe [default: 0]
-t, --transition Set transition time in ms [default: 100]
Can also be set to 'channel' to enable a dedicated DMX
channel on which 1 step equals 100ms.
-c, --colorloop Enable colorloop feature
When enabled, setting all RGB channels of a light to 1 will
enable colorloop mode.
-w, --white Enable 2 additional channels for white balance control
-n, --no-limit Disable safety rate limiting
Warning: when this option is enabled, make sure to not send
more than <number_of_lights>/10 updates per second, or you
might overload your Hue bridge.
Note: options overrides settings saved during setup.
${chalk.bold('Commands:')}
setup Configure hue bridge and DMX options
-l, --list force List bridges on the network
-i, --ip Set bridge IP (use first bridge if not specified)
--force Force bridge setup if already configured
`;
export class DmxHue {
constructor(args) {
this._args = minimist(args, {
boolean: [
'list',
'force',
'help',
'version',
'colorloop',
'white',
'no-limit'
],
string: ['ip', 'host', 'transition'],
number: ['address', 'universe'],
alias: {
l: 'list',
i: 'ip',
h: 'host',
a: 'address',
t: 'transition',
c: 'colorloop',
w: 'white',
n: 'no-limit',
u: 'universe'
}
});
this._hue = new Hue();
this._lastUpdate = 0;
this._delayedUpdate = null;
}
async start(options) {
if (options.address <= 0 || options.address > 511) {
Util.exit('Invalid DMX address');
}
options = { ...options };
const dmxChannelsPerFixture = options.white ? 5 : 3;
const lights = await this._hue.getLights();
const ordered = options.order.reduce((r, id) => {
const light = lights.find((light) => String(light.id) === id.toString());
if (light && !options.disabled[id]) {
r.push(light);
}
return r;
}, []);
const remaining = lights.filter(
(light) =>
!options.disabled[light.id] && !ordered.some((o) => light.id === o.id)
);
options.lights = [...ordered, ...remaining];
options.transitionChannel = options.transition === 'channel';
options.colors = {};
const dmxChannelCount =
dmxChannelsPerFixture * options.lights.length +
(options.transitionChannel ? 1 : 0);
const extraDmxAddress = options.address + dmxChannelCount - 512;
if (extraDmxAddress >= 0) {
console.warn(
chalk.yellow(
'Warning: not enough DMX channels, some lights will be unavailable'
)
);
const lightsToRemove = Math.ceil(extraDmxAddress / dmxChannelsPerFixture);
options.lights = options.lights.slice(0, -lightsToRemove);
}
await listenArtNet(options.host, (data) => {
if (data.universe === options.universe) {
this._updateLights(data.dmx, options);
}
});
let currentAddress = options.address;
if (options.noLimit) {
console.warn(
chalk.yellow('Warning, safety rate limiting is disabled!\n')
);
}
console.log(chalk.bold(`DMX addresses on universe ${options.universe}:`));
if (options.transitionChannel) {
console.log(` ${chalk.cyan(currentAddress++)}: transition time`);
}
for (const light of options.lights) {
console.log(
` ${chalk.cyan(`${currentAddress}:`)} ${light.name} ${chalk.grey(
`(Hue ID: ${light.id})`
)}`
);
currentAddress += dmxChannelsPerFixture;
}
console.log('\nArtNet node started (CTRL+C to quit)');
}
setup(ip, force) {
this._hue.setupBridge(ip, force).then(() => this._setupOptions());
}
_hasColorChanged(previous, color, channels) {
if (!previous) {
return true;
}
for (let i = 0; i < channels; i++) {
if (previous[i] !== color[i]) {
return true;
}
}
return false;
}
_updateLights(dmxData, options) {
if (this._delayedUpdate) {
clearTimeout(this._delayedUpdate);
this._delayedUpdate = null;
}
let address = options.address - 1;
if (options.transitionChannel) {
options.transition = dmxData[address] * 100;
address++;
}
const dmxChannelsPerFixture = options.white ? 5 : 3;
const dmx = dmxData.slice(
address,
address + dmxChannelsPerFixture * options.lights.length
);
let j = 0;
const { length } = options.lights;
let indices = Array.from({ length }, (_, i) => i);
indices = this._shuffle(indices);
let i;
for (i of indices) {
const lightId = options.lights[i].id;
j = i * dmxChannelsPerFixture;
const color = dmx.slice(j, j + 5);
const previous = options.colors[lightId];
// Update light only if color changed
if (this._hasColorChanged(previous, color, dmxChannelsPerFixture)) {
// Rate limit Hue API to 0,1s between calls
const now = Date.now();
if (options.noLimit || now - this._lastUpdate >= 100) {
const state = options.white
? this._hue.createLightState(
color[0],
color[1],
color[2],
color[3],
color[4],
options
)
: this._hue.createLightState(
color[0],
color[1],
color[2],
undefined,
undefined,
options
);
this._lastUpdate = now;
this._hue.setLight(lightId, state);
options.colors[lightId] = color;
} else if (!this._delayedUpdate) {
// Make sure to apply update later if changes could not be applied
this._delayedUpdate = setTimeout(
() => this._updateLights(dmxData, options),
100
);
}
}
}
}
_setupOptions() {
const disabled = Util.config.get('disabledLights') ?? {};
const transition = Util.config.get('transition') ?? 0;
this._hue.getLights().then((lights) => {
return inquirer
.prompt([
{
type: 'input',
name: 'dmxAddress',
message: 'Set DMX address (range 1-511)',
default: Util.config.get('dmxAddress') ?? 1,
validate(input) {
const value = Number.parseInt(input, 10);
return value > 0 && value <= 511;
}
},
{
type: 'input',
name: 'universe',
message: 'Set Art-Net universe',
default: Util.config.get('universe') ?? 0,
validate: (input) => Number.parseInt(input, 10) >= 0
},
{
type: 'confirm',
name: 'colorloop',
message:
'Enable colorloop feature (when RGB channels are set to 1)',
default: Util.config.get('colorloop') ?? false
},
{
type: 'confirm',
name: 'white',
message:
'Enable white control feature (adds 2 DMX channels per light)',
default: Util.config.get('white') ?? false
},
{
type: 'confirm',
name: 'transitionChannel',
message: 'Use DMX channel for transition time',
default: transition === 'channel'
},
{
type: 'input',
name: 'transition',
message: 'Set transition time in ms',
default: transition === 'channel' ? '100' : transition,
when: (answers) => !answers.transitionChannel,
validate: (input) => Number.parseInt(input, 10) > 0
},
{
type: 'checkbox',
name: 'lights',
message: 'Choose lights to use',
choices: lights.map((light) => ({
name: light.name,
value: light.id,
checked: !disabled[light.id]
}))
}
])
.then((answers) => {
Util.config.set(
'dmxAddress',
Number.parseInt(answers.dmxAddress, 10)
);
Util.config.set('universe', Number.parseInt(answers.universe, 10));
Util.config.set('colorloop', answers.colorloop);
Util.config.set('white', answers.white);
Util.config.set(
'transition',
answers.transitionChannel
? 'channel'
: Number.parseInt(answers.transition, 10)
);
Util.config.set(
'disabledLights',
lights
.filter((light) => !answers.lights.includes(light.id))
.reduce((l, light) => {
l[light.id] = true;
return l;
}, {})
);
console.log(
`Configuration saved at ${chalk.green(Util.config.path)}`
);
});
});
}
_shuffle(array) {
let currentIndex = array.length;
let temporaryValue;
let randomIndex;
// While there remain elements to shuffle...
while (currentIndex !== 0) {
// Pick a remaining element...
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
// And swap it with the current element.
temporaryValue = array[currentIndex];
array[currentIndex] = array[randomIndex];
array[randomIndex] = temporaryValue;
}
return array;
}
async run() {
if (this._args.help) {
Util.exit(help, 0);
} else if (this._args.version) {
const file = fs.readFileSync(path.join(__dirname, 'package.json'));
const pkg = JSON.parse(file);
Util.exit(pkg.version, 0);
} else if (this._args._[0] === 'setup') {
return this._args.list
? this._hue.listBridges(true)
: this.setup(this._args.ip, this._args.force);
}
if (this._args.transition !== 'channel') {
const value = Number.parseInt(this._args.transition, 10);
this._args.transition = value >= 0 ? value : 0;
}
return this.start({
host: this._args.host,
address: this._args.address ?? Util.config.get('dmxAddress') ?? 1,
colorloop:
(this._args.colorloop || Util.config.get('colorloop')) ?? false,
white: (this._args.white || Util.config.get('white')) ?? false,
transition:
(this._args.transition || Util.config.get('transition')) ?? 100,
noLimit: (this._args['no-limit'] || Util.config.get('noLimit')) ?? false,
universe: this._args.universe ?? Util.config.get('universe') ?? 0,
disabled: Util.config.get('disabledLights') ?? {},
order: Util.config.get('lightsOrder') ?? []
});
}
}