/**
* RGB module - For controlling RGB LED's
* @module j5e/rgb
* @requires module:j5e/animation
* @requires module:j5e/easing
* @requires module:j5e/fn
*/
import { normalizeIO, normalizeDevice, normalizeMulti, constrain, map, getProvider, timer, asyncForEach } from "j5e/fn";
import { inOutSine, outSine } from "j5e/easing";
import Animation from "j5e/animation";
/**
* Class representing an RGB LED
* @classdesc The RGB class allows for control of RGB LED's
* @async
*/
class RGB {
#state = {
// red, green, and blue store the raw color set via .color()
red: 0,
green: 0,
blue: 0,
intensity: 100,
sink: false,
interval: null,
// values takes state into account, such as on/off and intensity
values: {
red: 0,
green: 0,
blue: 0
}
};
static colors = ["red", "green", "blue"];
/**
* Instantiate an RGB LED
* @param {number[]|string[]|object[]|object} io - An array of pin identifiers or IO Options in RGB order, or an RGB IO options object (See {@tutorial C-INSTANTIATING})
* @param {object} [io.red] - Pin identifier or IO Options for the red channel
* @param {object} [io.green] - Pin identifier or IO Options for the green channel
* @param {object} [io.blue] - Pin identifier or IO Options for the blue channel
* @example
* <caption>Using an array of pin numbers</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
* rgb.blink();
*
* @example
* <caption>Using an array of pin identifiers</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB(["A1", "A3", "A4"]);
* rgb.color("#663399");
* rgb.blink();
*
* @example
* <caption>Using an array of option objects</caption>
* import RGB from "j5e/rgb";
* import PCA9685 from "PCA9685Expander"
*
* const rgb = await new RGB([
* { pin: 0, io: PCA9685 },
* { pin: 1, io: PCA9685 },
* { pin: 2, io: PCA9685 }
* ]);
* rgb.color("#663399");
* rgb.blink();
*
* @example
* <caption>Using an RGB options object</caption>
* import RGB from "j5e/rgb";
* import PCA9685 from "PCA9685Expander"
*
* const rgb = await new RGB({
* red: 0,
* green: {
* io: PCA9685
* pin: 1
* },
* blue: 3
* });
* rgb.color("#663399");
* rgb.blink();
*
*/
constructor(io) {
return (async() => {
if (Array.isArray(io)) {
if (io.length !== 3) {
throw "RGB expects three pins";
}
io = {
red: io[0],
green: io[1],
blue: io[2]
};
}
io.red = normalizeIO(io.red);
io.green = normalizeIO(io.green);
io.blue = normalizeIO(io.blue);
this.LOW = {
red: 0,
green: 0,
blue: 0
};
this.io = {};
this.HIGH = {};
this.keys = ["red", "green", "blue"];
await asyncForEach(RGB.colors, async(color, index) => {
let ioOptions = io[color];
const Provider = await getProvider(ioOptions, "PWM");
this.io[color] = new Provider({
pin: ioOptions.pin
});
if (this.io[color].resolution) {
this.HIGH[color] = (1 << this.io[color].resolution) - 1;
} else {
this.HIGH[color] = 1;
}
this.#state[color] = this.HIGH[color];
});
this.configure();
this.off();
return this;
})();
}
/**
* Configure an RGB LED
* @returns {RGB} The instance on which the method was called
* @param {object} options - Device configuration options
* @param {number} [options.sink=false] - True if an element is common anode
* @example
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
* rgb.blink();
*/
configure(options = {}) {
options = normalizeDevice(options);
if (typeof options.sink !== "undefined") {
this.#state.sink = true;
}
this.initialize(options);
return this;
}
/**
* If the RGB is on
* @type {boolean}
* @readonly
*/
get isOn() {
return RGB.colors.some((color) => {
return this.#state[color] > 0;
});
}
/**
* If the RGB is pulsing, blinking or running an animation
* @type {boolean}
* @readonly
*/
get isRunning() {
return !!this.#state.interval;
}
/**
* If the RGB is wired for common anode
* @type {boolean}
* @readonly
*/
get isAnode() {
return this.#state.isAnode;
}
/**
* The current RGB values
* @type {number[]}
* @readonly
*/
get values() {
return Object.assign({}, this.#state.values);
}
initialize(options) {
}
/**
* Internal method that writes the current LED value to the IO
* @private
*/
write(colors) {
RGB.colors.forEach((color, index) => {
let value = constrain(colors[color], 0, 1);
if (this.#state.sink) {
value = 1 - value;
}
value = map(value, 0, 1, this.LOW[color], this.HIGH[color]);
this.io[color].write(value);
});
}
/**
* internal method use to update the color in the private state
* @private
*/
update(colors) {
colors = colors || this.color();
this.#state.values = this.toScaledRGB(this.#state.intensity, colors);
this.write(this.#state.values);
Object.assign(this.#state, colors);
}
/**
* Control an RGB LED's color value. Accepts Hexadecimal strings, an array of color values, and RGB object or a separate argument for each color.
*
* @param {String|Array|Object|Number} red Hexadecimal color string, Array of color values, RGB object {red, green, blue}, or the value of the red channel [0, 1]
* @param {Number} [green] The value of the green channel [0, 1]
* @param {Number} [blue] The value of the blue channel [0, 1]
*
* @return {RGB}
* @example
* <caption>Use a hex value to make it purple</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
*
* @example
* <caption>Use an RGB String to make it purple</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("rgb(0.4, 0.2, 0.6)");
*
* @example
* <caption>Use an RGBA String to make it darker purple</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("rgba(0.4, 0.2, 0.6, 50%)");
*
* @example
* <caption>Use an array to make it purple</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color([0.4, 0.2, 0.6]);
*
* @example
* <caption>Use an object to make it purple</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color({
* red: 0.4,
* green: 0.2,
* blue: 0.6
* });
*
* @example
* <caption>Use seperate Red, Green, and Blue arguments to make it purple</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color(0.4, 0.2, 0.6);
*/
color(red, green, blue) {
let colors;
if (arguments.length === 0) {
// Return a copy of the state values,
// not a reference to the state object itself.
colors = this.isOn ? this.#state : state.prev;
const result = RGB.colors.reduce((current, color) => {
return (current[color] = Math.round(colors[color]), current);
}, {});
return result;
}
let update = this.toRGB(red, green, blue);
// Validate all color values before writing any values
RGB.colors.forEach(color => {
let value = update[color];
if (value == null) {
throw new Error("RGB.color: invalid color ([" + [update.red, update.green, update.blue].join(",") + "])");
}
value = constrain(value, 0, 1);
update[color] = value;
});
this.update(update);
return this;
};
/**
* Turn an RGB LED on with whatever the current color value is
* @return {RGB}
* @example
* <caption>Make it on</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.on(); // Default color is white
*/
on() {
let colors;
if (!this.isOn) {
colors = this.#state.prev || {
red: 1,
green: 1,
blue: 1
};
this.#state.prev = null;
this.update(colors);
}
return this;
}
/**
* Turn an RGB LED off
* @return {RGB}
* @example
* <caption>Make it purple for five seconds</caption>
* import RGB from "j5e/rgb";
* import {timer} from "j5e/fn";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
*
* time.setTimeout(function() {
* rgb.off();
* }, 5000);
*/
off() {
if (this.isOn) {
this.#state.prev = RGB.colors.reduce((current, color) => {
return (current[color] = this.#state[color], current);
}, {});
RGB.colors.forEach(color => {
this.#state.values[color] = this.LOW[color];
});
this.update({
red: 0,
green: 0,
blue: 0
});
}
return this;
}
/**
* Toggle the on/off state of an RGB LED
* @return {RGB}
* @example
* <caption>Make it purple for five seconds</caption>
* import RGB from "j5e/rgb";
* import {timer} from "j5e/fn";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.toggle(); // Turns RGB LED on
*
* time.setTimeout(function() {
* rgb.toggle(); // Turns it off
* }, 5000);
*/
toggle() {
return this[this.isOn ? "off" : "on"]();
}
/**
* Blink an RGB LED on a fixed interval
* @param {Number} duration=100 - Time in ms on, time in ms off
* @param {Function} callback - Method to call on blink
* @return {RGB}
* @example
* <caption>Make it blink</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
* rgb.blink();
*
* @example
* <caption>Make it blink slowly</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
* rgb.blink(5000);
*/
blink(duration = 100, callback) {
// Avoid traffic jams
this.stop();
if (typeof duration === "function") {
callback = duration;
duration = 100;
}
this.#state.interval = timer.setInterval(() => {
this.toggle();
if (typeof callback === "function") {
callback();
}
}, duration);
return this;
}
/**
* fade Fade an RGB LED from its current value to a new value
* @param {Number[]|String|Object} val Hexadecimal color string, CSS color name, Array of color values, RGB object {red, green, blue}
* @param {Number} [time=1000] Time in ms that a fade will take
* @param {function} [callback] A function to run when the fade is complete
* @return {RGB}
* @example
* <caption>Fade on to purple</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.fade("#663399");
*
* @example
* <caption>Fade on to purple over 3 seconds</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.fade("#663399", 3000);
*
* @example
* <caption>Fade on to purple over 3 seconds and then blink</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.fade("#663399", 3000, function() {
* rgb.blink();
* });
*/
fade(val, time = 1000, callback) {
this.stop();
const options = {
duration: typeof time === "number" ? time : 1000,
keyFrames: [null, val || "#ffffff"],
easing: outSine,
oncomplete: function() {
this.stop();
if (typeof callback === "function") {
callback();
}
}
};
if (typeof val === "function") {
callback = val;
}
if (typeof time === "function") {
callback = time;
}
this.animate(options);
return this;
}
/**
* Fade an RGB LED to full brightness
* @param {Number} [time=1000] Time in ms that a fade will take
* @param {function} [callback] A function to run when the fade is complete
* @return {RGB}
* @example
* <caption>Fade an RGB LED to white over half a second</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.fadeIn(500);
*/
fadeIn(time = 1000, callback) {
return this.fade([1, 1, 1], time, callback);
}
/**
* Fade an RGB LED off
* @param {Number} [time=1000] Time in ms that a fade will take
* @param {function} [callback] A function to run when the fade is complete
* @return {RGB}
* @example
* <caption>Fade out an RGB LED over half a second
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
* rgb.fadeOut(500);
*/
fadeOut(time = 1000, callback) {
return this.fade([0, 0, 0], time, callback);
}
/**
* Pulse an RGB LED on a fixed interval
* @param {Number} duration=1000 - Time in ms on, time in ms off
* @param {Function} callback - Method to call on pulse
* @return {RGB}
* @example
* <caption>Make it pulse</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
* rgb.pulse();
*
* @example
* <caption>Make it pulse slowly</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
* rgb.pulse(5000);
*/
pulse(time = 1000, callback) {
var options = {
duration: typeof time === "number" ? time : 1000,
cuePoints: [0, 1],
keyFrames: [[0, 0, 0], [1, 1, 1]],
metronomic: true,
loop: true,
easing: inOutSine,
onloop: function() {
/* istanbul ignore else */
if (typeof callback === "function") {
callback();
}
}
};
if (typeof time === "object") {
Object.assign(options, time);
}
if (typeof time === "function") {
callback = time;
}
return this.animate(options);
}
/**
* Animate an RGB LED
* @param {Object} options (See {@tutorial D-ANIMATING})
* @return {RGB}
* @example
* <caption>Animate an RGB LED using an animation segment options object</caption>
* import RGB from "j5e/rgb";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.animate({
* duration: 4000,
* cuePoints: [0, 0.33, 0.66, 1],
* keyFrames: ["#000000", "#FF0000", "#00FFFF", "#FFFFFF"],
* loop: true,
* metronomic: true
* });
*/
animate(options) {
// Avoid traffic jams
this.stop();
this.#state.isRunning = true;
this.#state.animation = this.#state.animation || new Animation(this);
this.#state.animation.enqueue(options);
return this;
}
/**
* Stop the RGB LED from pulsing, blinking, fading, or animating
* @return {RGB}
* @example
* <caption>Make it pulse for five seconds and then stop</caption>
* import RGB from "j5e/rgb";
* import {timer} from "j5e/fn";
*
* const rgb = await new RGB([13, 12, 14]);
* rgb.color("#663399");
* rgb.pulse(250);
* timer.setTimeout(function() {
* rgb.stop();
* }, 5000);
*/
stop() {
if (this.#state.interval) {
timer.clearInterval(this.#state.interval);
}
if (this.#state.animation) {
this.#state.animation.stop();
}
this.#state.interval = null;
return this;
}
intensity(intensity) {
if (arguments.length === 0) {
return this.#state.intensity;
}
this.#state.intensity = constrain(intensity, 0, 100);
this.update();
return this;
};
/**
* toScaledRGB
* Scale the output values based on the current intensity
* @private
*/
toScaledRGB(intensity, colors) {
var scale = intensity / 100;
return RGB.colors.reduce(function(current, color) {
return (current[color] = colors[color] * scale, current);
}, {});
}
/**
* toRGB
* Convert a color to an object
* @private
*/
toRGB(red, green, blue) {
let update = {};
let flags = 0;
let input;
if (typeof red !== "undefined") {
// 0b100
flags |= 1 << 2;
}
if (typeof green !== "undefined") {
// 0b010
flags |= 1 << 1;
}
if (typeof blue !== "undefined") {
// 0b001
flags |= 1 << 0;
}
if ((flags | 0x04) === 0x04) {
input = red;
if (input == null) {
throw new Error("Invalid color (" + input + ")");
}
/* istanbul ignore else */
if (Array.isArray(input)) {
// color([Byte, Byte, Byte])
update = {
red: input[0],
green: input[1],
blue: input[2]
};
} else if (typeof input === "object") {
// color({
// red: Byte,
// green: Byte,
// blue: Byte
// });
update = {
red: input.red,
green: input.green,
blue: input.blue
};
} else if (typeof input === "string") {
// color("#ffffff") or color("ffffff")
let re = new RegExp("^#?[0-9A-Fa-f]{6}$");
if (re.test(input)) {
// remove the leading # if there is one
if (input.length === 7 && input[0] === "#") {
input = input.slice(1);
}
update = {
red: parseInt(input.slice(0, 2), 16) / 255,
green: parseInt(input.slice(2, 4), 16) / 255,
blue: parseInt(input.slice(4, 6), 16) / 255
};
} else {
// color("rgba(r, g, b, a)") or color("rgb(r, g, b)")
// color("rgba(r g b a)") or color("rgb(r g b)")
if (/^rgb/.test(input)) {
const args = input.match(/^rgba?\(([^)]+)\)$/)[1].split(/[\s,]+/);
// If the values were %...
if (this.isPercentString(args[0])) {
args[0] = Math.round((parseInt(value, 10) / 100));
args[1] = Math.round((parseInt(value, 10) / 100));
args[2] = Math.round((parseInt(value, 10) / 100));
}
update = {
red: parseInt(args[0], 10),
green: parseInt(args[1], 10),
blue: parseInt(args[2], 10)
};
// If rgba(...)
if (args.length > 3) {
if (this.isPercentString(args[3])) {
args[3] = parseInt(args[3], 10) / 100;
}
update = this.toScaledRGB(100 * parseFloat(args[3]), update);
}
} else {
// color name
return this.toRGB(converter.keyword.rgb(input.toLowerCase()));
}
}
}
} else {
// color(red, green, blue)
update = {
red: red,
green: green,
blue: blue
};
}
return update;
}
/**
* normalize
* @private
* @param [number || object] keyFrames An array of step values or a keyFrame objects
*/
normalize(keyFrames) {
// If user passes null as the first element in keyFrames use current value
if (keyFrames[0] === null) {
keyFrames[0] = this.#state.values;
}
return keyFrames.reduce((accum, frame) => {
let normalized = {};
const value = frame;
let color = null;
let intensity = this.#state.intensity;
if (frame !== null) {
// Frames that are just numbers are not allowed
// because it is ambiguous.
if (typeof value === "number") {
throw new Error("RGB LEDs expect a complete keyFrame object or hexadecimal string value");
}
if (typeof value === "string") {
color = value;
}
if (Array.isArray(value)) {
color = value;
} else {
if (typeof value === "object") {
if (typeof value.color !== "undefined") {
color = value.color;
} else {
color = value;
}
}
}
if (typeof frame.intensity === "number") {
intensity = frame.intensity;
delete frame.intensity;
}
normalized.easing = frame.easing;
normalized.value = this.toScaledRGB(intensity, this.toRGB(color));
} else {
normalized = frame;
}
accum.push(normalized);
return accum;
}, []);
}
/**
* render
* @private
*/
render(frames) {
return this.color(frames[0]);
};
/**
* isPercentString
* @private
* @returns boolean
*/
isPercentString(input) {
return typeof input === "string" && input.endsWith("%");
}
}
export default RGB;