diff --git a/jest.config.js b/jest.config.js index 3e3fd57..4383c69 100644 --- a/jest.config.js +++ b/jest.config.js @@ -27,9 +27,7 @@ module.exports = { coverageDirectory: "coverage", // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "/node_modules/" - // ], + coveragePathIgnorePatterns: ["/src/index.ts"], // Indicates which provider should be used to instrument code for coverage coverageProvider: "v8", diff --git a/package.json b/package.json index 388692e..926cf11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-led-control", - "version": "1.0.1", + "version": "2.0.0", "description": "Control different types of LEDs from your Raspberry Pi.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/classes/Animation.test.ts b/src/classes/Animation.test.ts new file mode 100644 index 0000000..17c615c --- /dev/null +++ b/src/classes/Animation.test.ts @@ -0,0 +1,317 @@ +import Animation from "./Animation"; + +jest.useFakeTimers(); + +it("Does not allow animations with refresh rates lower than 1.", () => { + new Animation(() => 1, 1); + expect(() => { + new Animation(() => 1, 0.99); + }).toThrow(Error); + expect(() => { + new Animation(() => 1, 0); + }).toThrow(Error); + expect(() => { + new Animation(() => 1, -1); + }).toThrow(Error); + expect(() => { + new Animation(() => 1, -10000); + }).toThrow(Error); +}); + +it("Calculates the correct value when passed a time.", () => { + const anim = new Animation((t) => t / 500, 1); + + expect(anim.calculate(0)).toBeCloseTo(0, 5); + expect(anim.calculate(1)).toBeCloseTo(1 / 500, 5); + expect(anim.calculate(50)).toBeCloseTo(50 / 500, 5); + expect(anim.calculate(499)).toBeCloseTo(499 / 500, 5); + expect(anim.calculate(500)).toBeCloseTo(1, 5); +}); + +it("Calculates the correct value when not passed a time.", () => { + const start = Date.now(); + const anim = new Animation((t) => t / 500, 1); + expect(anim.calculate()).toBeCloseTo(0, 5); + anim.start(); + expect(anim.calculate()).toBeCloseTo(0, 5); + + setTimeout(() => { + const now = Date.now(); + expect(anim.calculate()).toBeCloseTo((now - start) / 500, 5); + }, 15); + setTimeout(() => { + const now = Date.now(); + expect(anim.calculate()).toBeCloseTo((now - start) / 500, 5); + }, 370); + setTimeout(() => { + expect(anim.calculate()).toBeCloseTo(1, 5); + }, 500); + setTimeout(() => { + expect(anim.calculate()).toBeCloseTo(1, 5); + anim.stop(); + }, 758); + + jest.runAllTimers(); +}); + +it("Clamps output within the range of 0-1 when the curve function returns something outside that range.", () => { + const anim = new Animation((t) => t / 500, 1); + + expect(anim.calculate(-1)).toBeCloseTo(0, 5); + expect(anim.calculate(-50)).toBeCloseTo(0, 5); + expect(anim.calculate(501)).toBeCloseTo(1, 5); + expect(anim.calculate(563)).toBeCloseTo(1, 5); +}); + +it("Does not call subscribers until the animation is started.", () => { + const mock = jest.fn(); + new Animation(() => 1, 100).subscribe(mock); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(0); + }, 2000); + jest.runAllTimers(); +}); + +it("Calls the subscribers the appropriate number of times.", () => { + const mock = jest.fn(); + const anim = new Animation(() => 1, 100).subscribe(mock); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(20); + anim.stop(); + }, 2000); + jest.runAllTimers(); +}); + +it("Stops calling subscribers after having been stopped.", () => { + const mock = jest.fn(); + const anim = new Animation(() => 1, 100).subscribe(mock); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(5); + anim.stop(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(5); + }, 500); + }, 500); + jest.runAllTimers(); +}); + +it("Does nothing when start() and stop() are called when they don't need to be.", () => { + const mock = jest.fn(); + const anim = new Animation(() => 1, 100) + .start() + .subscribe(mock) + .stop() + .start(); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(5); + anim.stop(); + anim.stop(); + anim.start(); + setTimeout(() => { + anim.start(); + }, 250); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(10); + anim.stop().start().stop(); + }, 500); + }, 500); + jest.runAllTimers(); +}); + +it("Does not call subscribers which have unsubscribed.", () => { + const mock = jest.fn(); + const anim = new Animation(() => 1, 100).subscribe(mock); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(20); + anim.unsubscribe(mock); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(20); + anim.stop(); + }, 2000); + }, 2000); + jest.runAllTimers(); +}); + +it("Does not allow the same function to subscribe multiple times.", () => { + const mock = jest.fn(); + const anim = new Animation(() => 1, 100).subscribe(mock).subscribe(mock); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(20); + anim.subscribe(mock); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(40); + anim.stop(); + }, 2000); + }, 2000); + jest.runAllTimers(); +}); + +it("Does nothing when a function which isn't subscribed unsubscribes.", () => { + const mock = jest.fn(); + const anim = new Animation(() => 1, 100); + anim.unsubscribe(mock); + anim.subscribe(mock); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(20); + anim.unsubscribe(mock); + anim.unsubscribe(mock); + anim.unsubscribe(mock); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(20); + anim.unsubscribe(mock); + anim.stop(); + anim.unsubscribe(mock); + }, 2000); + }, 2000); + jest.runAllTimers(); +}); + +it("Calls subscribers with the correct value.", () => { + let mock = jest.fn(); + let anim = new Animation(() => 0.5, 100).subscribe(mock); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(3); + expect((mock.mock.calls[0] as any)[0]).toEqual(0.5); + expect((mock.mock.calls[1] as any)[0]).toEqual(0.5); + expect((mock.mock.calls[2] as any)[0]).toEqual(0.5); + anim.stop(); + + mock = jest.fn(); + anim = new Animation((time: number) => time / 400, 100).subscribe(mock); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(3); + expect((mock.mock.calls[0] as any)[0]).toBeCloseTo(0.25, 5); + expect((mock.mock.calls[1] as any)[0]).toBeCloseTo(0.5, 5); + expect((mock.mock.calls[2] as any)[0]).toBeCloseTo(0.75, 5); + anim.stop(); + }, 300); + }, 300); + jest.runAllTimers(); +}); + +it("Clamps overflow values outside of the range of 0-1.", () => { + let mock = jest.fn(); + let anim = new Animation(() => 1.5, 100).subscribe(mock); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(1); + expect((mock.mock.calls[0] as any)[0]).toEqual(1); + anim.stop(); + + mock = jest.fn(); + anim = new Animation(() => -1.5, 100).subscribe(mock); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(1); + expect((mock.mock.calls[0] as any)[0]).toBeCloseTo(0, 5); + anim.stop(); + }, 100); + }, 100); + jest.runAllTimers(); +}); + +it("Correctly reports whether an animation is running.", () => { + const mock = jest.fn(); + const anim = new Animation(() => 1.5, 100); + expect(anim.isRunning()).toEqual(false); + anim.subscribe(mock); + expect(anim.isRunning()).toEqual(false); + anim.start(); + expect(anim.isRunning()).toEqual(true); + anim.stop(); + expect(anim.isRunning()).toEqual(false); + anim.start(); + expect(anim.isRunning()).toEqual(true); + setTimeout(() => { + expect(anim.isRunning()).toEqual(true); + anim.stop(); + expect(anim.isRunning()).toEqual(false); + anim.start(); + expect(anim.isRunning()).toEqual(true); + anim.stop(); + }, 100); + jest.runAllTimers(); +}); + +it("Restores progress of animation after being previously stopped.", () => { + const mock = jest.fn(); + const anim = new Animation((time: number) => time / 800, 100).subscribe( + mock + ); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(3); + expect((mock.mock.calls[0] as any)[0]).toBeCloseTo(1 / 8, 5); + expect((mock.mock.calls[1] as any)[0]).toBeCloseTo(1 / 4, 5); + expect((mock.mock.calls[2] as any)[0]).toBeCloseTo(3 / 8, 5); + anim.stop(); + setTimeout(() => { + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(5); + expect((mock.mock.calls[3] as any)[0]).toBeCloseTo(1 / 2, 5); + expect((mock.mock.calls[4] as any)[0]).toBeCloseTo(5 / 8, 5); + anim.stop(); + }, 200); + }, 600); + }, 300); + jest.runAllTimers(); +}); + +it("Does not restore progress of animation after being reset.", () => { + const mock = jest.fn(); + const anim = new Animation((time: number) => time / 800, 100).subscribe( + mock + ); + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(3); + expect((mock.mock.calls[0] as any)[0]).toBeCloseTo(1 / 8, 5); + expect((mock.mock.calls[1] as any)[0]).toBeCloseTo(1 / 4, 5); + expect((mock.mock.calls[2] as any)[0]).toBeCloseTo(3 / 8, 5); + anim.reset(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(5); + expect((mock.mock.calls[3] as any)[0]).toBeCloseTo(1 / 8, 5); + expect((mock.mock.calls[4] as any)[0]).toBeCloseTo(1 / 4, 5); + anim.stop(); + anim.reset(); + setTimeout(() => { + anim.start(); + setTimeout(() => { + expect(mock).toHaveBeenCalledTimes(10); + expect((mock.mock.calls[5] as any)[0]).toBeCloseTo( + 1 / 8, + 5 + ); + expect((mock.mock.calls[6] as any)[0]).toBeCloseTo( + 1 / 4, + 5 + ); + expect((mock.mock.calls[7] as any)[0]).toBeCloseTo( + 3 / 8, + 5 + ); + expect((mock.mock.calls[8] as any)[0]).toBeCloseTo( + 1 / 2, + 5 + ); + expect((mock.mock.calls[9] as any)[0]).toBeCloseTo( + 5 / 8, + 5 + ); + anim.stop(); + }, 500); + }, 400); + }, 200); + }, 300); + jest.runAllTimers(); +}); diff --git a/src/classes/Animation.ts b/src/classes/Animation.ts new file mode 100644 index 0000000..9325bf4 --- /dev/null +++ b/src/classes/Animation.ts @@ -0,0 +1,241 @@ +class Animation { + /** + * Timestamp in milliseconds since 01-01-1970 at which this Animation's + * {@link #start()} function was called. If the Animation has not yet been + * started, this is equal to null. If the Animation was started and then + * subsequently stopped, this will still be the accurate start time until + * it is started again. However, since this value is used when calculating + * the next animation's frame, we must update this value whenever the + * animation is paused and then resumed. + * @private + */ + private startTime: number | null = null; + /** + * Time at which the Animation was last stopped. If this Animation has not + * yet been stopped, then this is null. Otherwise, it is used if the + * Animation is resumed again in order to resume the Animation at the same + * spot. + * @private + */ + private stopTime: number | null = null; + /** + * Frequency at which this Animation should call all of its subscribers. + * This does not alter the frequency of the Curve. + * @private + */ + private readonly frameRate: number; + /** + * Curve function passed in the constructor. This is used to calculate + * how the Animation should appear at any given point in time. + * @private + */ + private readonly curve: (time: number) => number; + /** + * NodeJS Interval which is responsible for calling all the subscribers + * at each frame update. If the Animation is not running, then this is + * null. + * @private + */ + private timer: NodeJS.Timer | null = null; + /** + * Array of subscriber functions which should be called every time the + * Animation updates. + * @private + */ + private readonly subscribers: ((newValue: number) => void)[] = []; + + /** + * Creates an Animation which refreshes at a rate of 60 times per second. + * @param curve Function defining the curve of the animation, where time is + * the input and the output is a value between 0 and 1 inclusive. If a value + * outside that range is returned, it will be clamped within the range. The + * function is called every refreshRate ms, or whenever {@link #calculate()} + * is called. Its argument is the current time minus the time the animation + * was started. If the animation hasn't been started yet, it's passed 0. + * It is possible for users to pass a custom time to {@link #calculate()}, + * which will be passed to your curve function instead of the calculated + * time. + */ + public constructor(curve: (time: number) => number); + /** + * Creates an Animation. + * @param curve Function defining the curve of the animation, where time is + * the input and the output is a value between 0 and 1 inclusive. If a value + * outside that range is returned, it will be clamped within the range. The + * function is called every refreshRate ms, or whenever {@link #calculate()} + * is called. Its argument is the current time minus the time the animation + * was started. If the animation hasn't been started yet, it's passed 0. + * It is possible for users to pass a custom time to {@link #calculate()}, + * which will be passed to your curve function instead of the calculated + * time. + * @param refreshRate How often in ms this animation should be refreshed, i.e. + * how often should the subscribers be called with an update. Must be + * greater than or equal to 1. Anything less than 15 is likely unnecessary, + * and may not even be processed in a timely manner. If the passed value is + * not an integer, it is rounded to the closest integer. + */ + public constructor(curve: (time: number) => number, refreshRate: number); + public constructor(curve: (time: number) => number, refreshRate?: number) { + if (refreshRate === undefined) { + refreshRate = 60; + } else if (refreshRate < 1) { + throw new Error( + "Cannot create an animation with a refresh rate less than 1!" + ); + } + this.frameRate = Math.floor(refreshRate); + this.curve = curve; + } + + /** + * Start the Animation. Will call all subscribers at an interval set by the + * frequency passed to the constructor. The Animation will continue to run + * even if it has no subscribers. Nothing happens if you start an animation + * which is already running. If you start an Animation which was previously + * running and then stopped, it will resume progress from where it stopped. + * @returns this + */ + public start(): Animation { + if (!this.isRunning()) { + if (this.startTime === null) { + this.startTime = Date.now(); + } + // In order to resume where we left off we have to adjust start time + if (this.stopTime !== null) { + this.startTime += Date.now() - this.stopTime; + this.stopTime = null; + } + this.timer = setInterval(() => { + this.callSubscribers(); + }, this.frameRate); + } + return this; + } + + /** + * Stop the Animation. Any subscribers to this Animation will not be called + * again until the Animation is started again. Nothing happens if you stop + * an animation which is already stopped. If you start an animation which + * was previously running and then stopped, it will resume progress from + * where it stopped. + * @returns this + */ + public stop(): Animation { + if (this.isRunning()) { + this.stopTime = Date.now(); + clearInterval(this.timer as NodeJS.Timer); + this.timer = null; + } + return this; + } + + /** + * Reset the timer on this animation. If the timer is running, it does not + * stop. Instead, the animation gets moved back to its starting position, + * as if it has just started. If it is stopped, then the start time is + * reset to zero and when the animation is started again, it will start + * from the beginning. + * @returns this + */ + public reset(): Animation { + if (this.isRunning()) { + this.startTime = Date.now(); + } else { + this.startTime = null; + this.stopTime = null; + } + return this; + } + + /** + * Calculate the animation's value at the current time, compared to when the + * animation was started. For example, if 500 milliseconds has passed since + * the animation was started, then 500 will be passed to the curve function + * and its result will be returned. If the animation has not yet started, + * it will pass 0 to the curve function. + */ + public calculate(): number; + /** + * Calculate the animation's value at a custom time. Time is measured in + * milliseconds. So, if you want to retrieve what the animation will be + * at after 500 milliseconds of running, you may pass 500 to this function. + * @param time The time to calculate this animation at. This number may + * be negative, as long as that does not break the curve function passed + * to the constructor. + */ + public calculate(time: number): number; + public calculate(time?: number): number { + if (time === undefined) { + if (this.startTime === null) { + time = 0; + } else { + time = Date.now() - this.startTime; + } + } + return Math.max(0, Math.min(1, this.curve(time))); + } + + /** + * Whether this Animation is currently running or not. + */ + public isRunning(): boolean { + return this.timer !== null; + } + + /** + * Subscribe a function to be called whenever this Animation updates. If the + * Animation is running, the function you pass in will be called at the + * frequency of whatever framerate you passed into the constructor. + * @param listener Listener function which is called to notify you whenever + * this Animation updates frames. It is passed a number which is the + * output value of the Animation, which you can use to display in whatever + * way your application is designed to use it. The value passed is the + * number of milliseconds since this Animation was started. If you pause + * the animation using {@link #stopAnimation()} and then later resume it, + * the animation resumes where it left off. Passing the same instance of + * a function twice will not let you subscribe multiple times. + */ + public subscribe(listener: (newValue: number) => void): Animation { + if (!this.subscribers.includes(listener)) { + this.subscribers.push(listener); + } + return this; + } + + /** + * Stop a function from listening to this Animation. The function must be + * the exact same instance as the function you passed into + * {@link #subscribe()}, not just a functionally identical one. If you pass + * a function which is already not subscribed, nothing will happen. + * @param listener Function to unsubscribe from listening to this Animation. + * If this function is not currently listening, nothing happens. + */ + public unsubscribe(listener: (newValue: number) => void): Animation { + const index = this.subscribers.indexOf(listener); + if (index >= 0) { + this.subscribers.splice(index, 1); + } + return this; + } + + /** + * Create a copy of this Animation, copying the curve function and the frame + * rate. A new instance of the curve function isn't created. + */ + public copy(): Animation { + return new Animation(this.curve, this.frameRate); + } + + /** + * Call all the subscriber functions with the current progress. + * @private + */ + private callSubscribers(): void { + const now = Date.now(); + for (const sub of this.subscribers) { + sub(this.calculate(now - (this.startTime ?? 0))); + } + } +} + +export default Animation; diff --git a/src/classes/Blinker.test.ts b/src/classes/Blinker.test.ts deleted file mode 100644 index 9a11a82..0000000 --- a/src/classes/Blinker.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import Blinker from "./Blinker"; - -jest.useFakeTimers(); - -it("Throws error when milliseconds equal to 0.", () => { - expect(() => { - new Blinker(0); - }).toThrow(Error); -}); - -it("Throws error when milliseconds less than 0.", () => { - expect(() => { - new Blinker(-100); - }).toThrow(Error); -}); - -it("Auto-starts when only milliseconds is passed to constructor.", () => { - expect( - new Promise((resolve, reject) => { - const timeout: NodeJS.Timer = setTimeout(() => { - resolve(false); - }, 1000); - try { - new Blinker(100).subscribe(() => { - resolve(true); - clearTimeout(timeout); - }); - } catch (e) { - reject(e); - } - }) - ).resolves.toEqual(true); -}); - -it("Auto-starts when start = true is passed to constructor.", () => { - expect( - new Promise((resolve, reject) => { - let blinker: Blinker; - const timeout: NodeJS.Timer = setTimeout(() => { - resolve(false); - blinker.stop(); - }, 1000); - try { - blinker = new Blinker(100, true).subscribe(() => { - clearTimeout(timeout); - blinker.stop(); - resolve(true); - }); - } catch (e) { - reject(e); - } - }) - ).resolves.toEqual(true); -}); - -it("Does not auto-start when start = false is passed to constructor.", () => { - expect( - new Promise((resolve, reject) => { - let blinker: Blinker; - const timeout: NodeJS.Timer = setTimeout(() => { - resolve(true); - blinker.stop(); - }, 1000); - try { - blinker = new Blinker(100, false).subscribe(() => { - clearTimeout(timeout); - blinker.stop(); - resolve(false); - }); - } catch (e) { - reject(e); - } - }) - ).resolves.toEqual(true); -}); - -it("Calls all subscribers within at the appropriate period of time.", () => { - jest.spyOn(global, "setInterval"); - const interval = 765; - const callback1 = jest.fn(); - const callback2 = jest.fn(); - const callback3 = jest.fn(); - const blinker = new Blinker(interval) - .subscribe(callback1) - .subscribe(callback2) - .subscribe(callback3); - - setTimeout(() => { - expect(setInterval).toHaveBeenLastCalledWith( - expect.any(Function), - interval - ); - expect(callback1).toHaveBeenCalledTimes(5); - expect(callback2).toHaveBeenCalledTimes(5); - expect(callback3).toHaveBeenCalledTimes(5); - blinker.stop(); - }, interval * 5); - jest.advanceTimersByTime(interval * 10); -}); - -it("Does not call subscribers once stopped.", () => { - jest.spyOn(global, "setInterval"); - const interval = 765; - const callback1 = jest.fn(); - const callback2 = jest.fn(); - const callback3 = jest.fn(); - const blinker = new Blinker(interval) - .subscribe(callback1) - .subscribe(callback2) - .subscribe(callback3); - - setTimeout(() => { - expect(setInterval).toHaveBeenLastCalledWith( - expect.any(Function), - interval - ); - expect(callback1).toHaveBeenCalledTimes(5); - expect(callback2).toHaveBeenCalledTimes(5); - expect(callback3).toHaveBeenCalledTimes(5); - blinker.stop(); - - setTimeout(() => { - expect(setInterval).toHaveBeenLastCalledWith( - expect.any(Function), - interval - ); - expect(callback1).toHaveBeenCalledTimes(5); - expect(callback2).toHaveBeenCalledTimes(5); - expect(callback3).toHaveBeenCalledTimes(5); - }, interval * 5); - }, interval * 5); - jest.advanceTimersByTime(interval * 10); -}); - -it("Calls subscribers once started.", () => { - jest.spyOn(global, "setInterval"); - const interval = 765; - const callback1 = jest.fn(); - const callback2 = jest.fn(); - const callback3 = jest.fn(); - const blinker = new Blinker(interval, false) - .subscribe(callback1) - .subscribe(callback2) - .subscribe(callback3); - - setTimeout(() => { - expect(callback1).toHaveBeenCalledTimes(0); - expect(callback2).toHaveBeenCalledTimes(0); - expect(callback3).toHaveBeenCalledTimes(0); - blinker.start(); - - setTimeout(() => { - expect(setInterval).toHaveBeenLastCalledWith( - expect.any(Function), - interval - ); - expect(callback1).toHaveBeenCalledTimes(5); - expect(callback2).toHaveBeenCalledTimes(5); - expect(callback3).toHaveBeenCalledTimes(5); - blinker.stop(); - }, interval * 5); - }, interval * 5); - jest.advanceTimersByTime(interval * 10); -}); - -it("Does not allow a subscriber to be subscribed multiple times.", () => { - const interval = 432; - const callback = jest.fn(); - const blinker = new Blinker(interval) - .subscribe(callback) - .subscribe(callback); - - setTimeout(() => { - expect(callback).toHaveBeenCalledTimes(3); - blinker.stop(); - }, interval * 3); - jest.advanceTimersByTime(interval * 6); -}); - -it("Does not call subscribers which have unsubscribed.", () => { - const interval = 891; - const callback = jest.fn(); - const blinker = new Blinker(interval).subscribe(callback); - - setTimeout(() => { - expect(callback).toHaveBeenCalledTimes(3); - blinker.unsubscribe(callback); - setTimeout(() => { - expect(callback).toHaveBeenCalledTimes(3); - blinker.stop(); - }, interval * 3); - }, interval * 3); - jest.advanceTimersByTime(interval * 9); -}); - -it("Alternates between on and off, starting with on.", () => { - const interval = 639; - const callback = jest.fn(); - const blinker = new Blinker(interval).subscribe(callback); - - setTimeout(() => { - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenLastCalledWith(true); - expect(blinker.state).toEqual(true); - }, interval); - setTimeout(() => { - expect(callback).toHaveBeenCalledTimes(2); - expect(callback).toHaveBeenLastCalledWith(false); - expect(blinker.state).toEqual(false); - }, interval * 2); - setTimeout(() => { - expect(callback).toHaveBeenCalledTimes(3); - expect(callback).toHaveBeenLastCalledWith(true); - expect(blinker.state).toEqual(true); - blinker.stop(); - }, interval * 3); - jest.advanceTimersByTime(interval * 3); -}); - -it("isActive properly reports whether the blinker is running.", () => { - const interval = 113; - const blinker = new Blinker(interval).subscribe(jest.fn()); - expect(blinker.isActive).toEqual(true); - blinker.stop(); - expect(blinker.isActive).toEqual(false); - blinker.start(); - expect(blinker.isActive).toEqual(true); -}); diff --git a/src/classes/Blinker.ts b/src/classes/Blinker.ts deleted file mode 100644 index 558fdee..0000000 --- a/src/classes/Blinker.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Handles blinking between an on and off state at a given frequency. What any given - * Blinker toggles on and off is up to the client, by using the {@link Blinker#subscribe} - * method. Blinker will continue to run even if there are no subscribers, until - * {@link Blinker#stop} is called. - */ -class Blinker { - /** - * Number of milliseconds between each toggle of the state on and off. - * @private - */ - private readonly millis: number; - /** - * Node.JS interval timer, if the blinker is currently running. Otherwise null. - * @private - */ - private timer: NodeJS.Timer | null = null; - /** - * The state that was most recently passed to all subscribers. Even if there are - * no current subscribers, this state will continue to update until {@link #stop} - * is called. - * @private - */ - private currentState = false; - /** - * List of all listening functions. Each function is called every time the state of - * this Blinker is toggled, passing the new state. - * @private - */ - private listeners: ((state: boolean) => void)[] = []; - - /** - * Constructor - * @param millis Number of milliseconds between each toggle of the state of this Blinker. - * Expected to be greater than 0. - * @throws Error if millis is less than or equal to 0. - */ - public constructor(millis: number); - /** - * Constructor - * @param millis Number of milliseconds between each toggle of the state of this Blinker. - * Expected to be greater than 0. - * @param start Whether the blinker should start as soon as constructed. If set to false, - * you can start the Blinker by calling {@link #start()} - * @throws Error if millis is less than or equal to 0. - */ - public constructor(millis: number, start: boolean); - /** - * Constructor - * @param millis Number of milliseconds between each toggle of the state of this Blinker. - * Expected to be greater than 0. - * @param start Whether this Blinker should immediately be started upon construction. - * Defaults true. - * @throws Error if millis is less than or equal to 0. - */ - public constructor(millis: number, start?: boolean) { - if (millis <= 0) { - throw new Error("Milliseconds must be greater than or equal to 1."); - } - this.millis = millis; - if (start || start === undefined) { - this.start(); - } - } - - /** - * Subscribe to any changes in this Blinker's state. - * @param fn Function which should be called every time the state of this Blinker changes. - * If you wish to stop listening, pass the same instance of this Function to - * {@link #unsubscribe}. Passing a Function which is already subscribed has no effect. - */ - public subscribe(fn: (state: boolean) => void): Blinker { - if (!this.listeners.includes(fn)) { - this.listeners.push(fn); - } - return this; - } - - /** - * Unsubscribe from changes to this Blinker's state. - * @param fn Function which you want to stop listening for changes. This must be the - * same instance of the Function that was originally passed to {@link #subscribe}. - * Passing a Function which is not currently subscribed has no effect. - */ - public unsubscribe(fn: (state: boolean) => void): Blinker { - const idx = this.listeners.indexOf(fn); - if (idx >= 0) { - this.listeners.splice(idx, 1); - } - return this; - } - - /** - * Start blinking. Has no effect if this Blinker is already in the process of blinking. - */ - public start(): Blinker { - if (this.timer === null) { - this.timer = setInterval(() => { - this.currentState = !this.currentState; - for (const listener of this.listeners) { - listener(this.currentState); - } - }, this.millis); - } - return this; - } - - /** - * Stop blinking. Has no effect if this Blinker is not currently blinking. - */ - public stop(): Blinker { - if (this.timer !== null) { - clearInterval(this.timer); - this.timer = null; - } - return this; - } - - /** - * Get the current state of this Blinker. - */ - public get state() { - return this.currentState; - } - - /** - * Get whether this Blinker is currently Blinking. If true, then {@link #subscribe} - * will eventually be called. Calling {@link #start} is not necessary. - */ - public get isActive() { - return this.timer !== null; - } -} - -export default Blinker; diff --git a/src/classes/Curves.test.ts b/src/classes/Curves.test.ts new file mode 100644 index 0000000..f7768d7 --- /dev/null +++ b/src/classes/Curves.test.ts @@ -0,0 +1,126 @@ +import Curves from "./Curves"; + +const sigFig = 5; +// Offset is used because many of these tests are around points of discontinuity. +// This offset is intentionally larger than the number of sig figs measured. +const offset = 0.001; + +/************** + * Square * + **************/ + +it("Correctly handles square function at frequency == 1", () => { + const square = Curves.Square(1); + expect(square(0)).toBeCloseTo(0, sigFig); + expect(square(offset)).toBeCloseTo(1, sigFig); + expect(square(Math.PI - offset)).toBeCloseTo(1, sigFig); + expect(square(Math.PI + offset)).toBeCloseTo(0, sigFig); + expect(square(Math.PI + offset)).toBeCloseTo(0, sigFig); +}); + +it("Correctly handles square function at frequency > 1", () => { + const square = Curves.Square(3); + expect(square(0)).toBeCloseTo(0, sigFig); + expect(square(offset)).toBeCloseTo(1, sigFig); + expect(square(Math.PI / 3 - offset)).toBeCloseTo(1, sigFig); + expect(square(Math.PI / 3 + offset)).toBeCloseTo(0, sigFig); + expect(square(Math.PI / 3 + offset)).toBeCloseTo(0, sigFig); +}); + +it("Correctly handles square function at 0 < frequency < 1", () => { + const square = Curves.Square(1 / 60); + expect(square(0)).toBeCloseTo(0, sigFig); + expect(square(offset)).toBeCloseTo(1, sigFig); + expect(square(Math.PI * 60 - offset)).toBeCloseTo(1, sigFig); + expect(square(Math.PI * 60 + offset)).toBeCloseTo(0, sigFig); + expect(square(Math.PI * 60 + offset)).toBeCloseTo(0, sigFig); +}); + +it("Correctly handles square function at frequency == 0", () => { + const square = Curves.Square(0); + expect(square(0)).toBeCloseTo(0, sigFig); + expect(square(offset)).toBeCloseTo(0, sigFig); + expect(square(Math.PI - offset)).toBeCloseTo(0, sigFig); + expect(square(Math.PI + offset)).toBeCloseTo(0, sigFig); + expect(square(Math.PI + offset)).toBeCloseTo(0, sigFig); +}); + +/**************** + * Sawtooth * + ****************/ + +it("Correctly handles sawtooth function at frequency == 1", () => { + const sawtooth = Curves.Sawtooth(1); + expect(sawtooth(0)).toBeCloseTo(0, sigFig); + expect(sawtooth(offset)).toBeCloseTo(offset, sigFig); + expect(sawtooth(3 - offset)).toBeCloseTo(1 - offset, sigFig); + expect(sawtooth(3 + offset)).toBeCloseTo(offset, sigFig); + expect(sawtooth(1000 + offset)).toBeCloseTo(offset, sigFig); +}); + +it("Correctly handles sawtooth function at frequency > 1", () => { + const sawtooth = Curves.Sawtooth(4); + expect(sawtooth(0)).toBeCloseTo(0, sigFig); + expect(sawtooth(offset)).toBeCloseTo(offset * 4, sigFig); + expect(sawtooth(3 - offset)).toBeCloseTo(1 - offset * 4, sigFig); + expect(sawtooth(3 + offset)).toBeCloseTo(offset * 4, sigFig); + expect(sawtooth(1000 + offset)).toBeCloseTo(offset * 4, sigFig); +}); + +it("Correctly handles sawtooth function at 0 < frequency < 1", () => { + const sawtooth = Curves.Sawtooth(1 / 4); + expect(sawtooth(0)).toBeCloseTo(0, sigFig); + expect(sawtooth(offset)).toBeCloseTo(offset / 4, sigFig); + expect(sawtooth(3 - offset)).toBeCloseTo(3 / 4 - offset / 4, sigFig); + expect(sawtooth(3 + offset)).toBeCloseTo(3 / 4 + offset / 4, sigFig); + expect(sawtooth(6 + offset)).toBeCloseTo(1 / 2 + offset / 4, sigFig); +}); + +it("Correctly handles sawtooth function at frequency == 0", () => { + const sawtooth = Curves.Sawtooth(0); + expect(sawtooth(0)).toBeCloseTo(0, sigFig); + expect(sawtooth(offset)).toBeCloseTo(0, sigFig); + expect(sawtooth(3 - offset)).toBeCloseTo(0, sigFig); + expect(sawtooth(3 + offset)).toBeCloseTo(0, sigFig); + expect(sawtooth(1000 + offset)).toBeCloseTo(0, sigFig); +}); + +/************ + * Sine * + ************/ + +it("Correctly handles sine function at frequency == 1", () => { + const sine = Curves.Sine(1); + expect(sine(0)).toBeCloseTo(0.5, sigFig); + expect(sine(Math.PI / 2)).toBeCloseTo(1, sigFig); + expect(sine(40)).toBeCloseTo(0.872556, sigFig); + expect(sine((3 / 2) * Math.PI)).toBeCloseTo(0, sigFig); + expect(sine(Math.PI + offset)).toBeCloseTo(0.4995, sigFig); +}); + +it("Correctly handles sine function at frequency > 1", () => { + const sine = Curves.Sine(5); + expect(sine(0)).toBeCloseTo(0.5, sigFig); + expect(sine(Math.PI / 2)).toBeCloseTo(1, sigFig); + expect(sine(40)).toBeCloseTo(0.063351, sigFig); + expect(sine((3 / 2) * Math.PI)).toBeCloseTo(0, sigFig); + expect(sine(Math.PI + offset)).toBeCloseTo(0.4975, sigFig); +}); + +it("Correctly handles sine function at 0 < frequency < 1", () => { + const sine = Curves.Sine(1 / 11); + expect(sine(0)).toBeCloseTo(0.5, sigFig); + expect(sine(Math.PI / 2)).toBeCloseTo(0.571157, sigFig); + expect(sine(40)).toBeCloseTo(0.262584, sigFig); + expect(sine((3 / 2) * Math.PI)).toBeCloseTo(0.707707, sigFig); + expect(sine(Math.PI + offset)).toBeCloseTo(0.640909, sigFig); +}); + +it("Correctly handles sine function at frequency == 0", () => { + const sine = Curves.Sine(0); + expect(sine(0)).toBeCloseTo(0.5, sigFig); + expect(sine(Math.PI / 2)).toBeCloseTo(0.5, sigFig); + expect(sine(40)).toBeCloseTo(0.5, sigFig); + expect(sine((3 / 2) * Math.PI)).toBeCloseTo(0.5, sigFig); + expect(sine(Math.PI + offset)).toBeCloseTo(0.5, sigFig); +}); diff --git a/src/classes/Curves.ts b/src/classes/Curves.ts new file mode 100644 index 0000000..743453f --- /dev/null +++ b/src/classes/Curves.ts @@ -0,0 +1,47 @@ +/** + * Curves is not a class which should be instantiated. Instead, it is a + * collection of curve function generators. Curve functions are functions which + * generate waveforms that can be passed to Animations. + */ +class Curves { + /** + * Create a square waveform Curve function. Returns another function which + * you may pass into your Animations. + * @param frequency Frequency multiplier for the waveform. A higher number + * means the wave will repeat more frequently. + * @constructor + */ + public static Square(frequency: number): (time: number) => number { + return (time: number): number => { + return Math.ceil(Math.sin(time * frequency)); + }; + } + + /** + * Create a sawtooth waveform Curve function. Returns another function which + * you may pass into your Animations. + * @param frequency Frequency multiplier for the waveform. A higher number + * means the wave will repeat more frequently. + * @constructor + */ + public static Sawtooth(frequency: number): (time: number) => number { + return (time: number): number => { + return (time * frequency) % 1; + }; + } + + /** + * Create a sine waveform Curve function. Returns another function which + * you may pass into your Animations. + * @param frequency Frequency multiplier for the waveform. A higher number + * means the wave will repeat more frequently. + * @constructor + */ + public static Sine(frequency: number): (time: number) => number { + return (time: number): number => { + return (Math.sin(time * frequency) + 1) / 2; + }; + } +} + +export default Curves; diff --git a/src/classes/LED.test.ts b/src/classes/LED.test.ts new file mode 100644 index 0000000..49657b7 --- /dev/null +++ b/src/classes/LED.test.ts @@ -0,0 +1,309 @@ +import LED from "./LED"; +import { Gpio } from "../../__mocks__/pigpio"; +import Animation from "./Animation"; + +jest.useFakeTimers(); + +const digitalSpy = jest.spyOn(Gpio.prototype, "digitalWrite"); +const pwmSpy = jest.spyOn(Gpio.prototype, "pwmWrite"); + +/** + * Helper function that allows for blackbox testing of writes to GPIO pins. + * @param pin + * @param value + * @param invert + */ +function testBasicWrite(pin: number, value: number | boolean, invert: boolean) { + const led = new LED(pin, invert); + if (typeof value === "number") { + led.write(value); + if (invert) { + value = 255 - value; + } + expect(digitalSpy).toHaveBeenCalledTimes(0); + expect(pwmSpy).toHaveBeenCalledTimes(1); + + expect((pwmSpy.mock.instances[0] as any).pin).toEqual(pin); + expect((pwmSpy.mock.calls[0] as any)[0]).toEqual(value); + } else { + led.write(value); + if (invert) { + value = !value; + } + expect(digitalSpy).toHaveBeenCalledTimes(1); + expect(pwmSpy).toHaveBeenCalledTimes(0); + + expect((digitalSpy.mock.instances[0] as any).pin).toEqual(pin); + expect((digitalSpy.mock.calls[0] as any)[0]).toEqual(value ? 1 : 0); + } + jest.clearAllMocks(); +} + +it("Does not allow pins less than 0.", () => { + expect(() => { + new LED(-2, false); + }).toThrow(Error); +}); + +it("Writes the correct values on digital writes.", () => { + testBasicWrite(11, true, false); + testBasicWrite(13, true, false); + testBasicWrite(13, false, false); + testBasicWrite(14, true, false); + testBasicWrite(16, false, false); +}); + +it("Writes the correct values on PWM writes.", () => { + testBasicWrite(11, 50, false); + testBasicWrite(13, 100, false); + testBasicWrite(13, 115, false); + testBasicWrite(14, 135, false); + testBasicWrite(16, 200, false); +}); + +it("Does not allow pin IDs less than 0.", () => { + expect(() => { + testBasicWrite(-1, 50, false); + }).toThrow(Error); + expect(() => { + testBasicWrite(-1, 0, false); + }).toThrow(Error); + expect(() => { + testBasicWrite(-50, false, false); + }).toThrow(Error); + expect(() => { + testBasicWrite(-1, true, false); + }).toThrow(Error); +}); + +it("Does not allow PWM writes less than 0.", () => { + testBasicWrite(1, -0, false); + expect(() => { + testBasicWrite(1, -1, false); + }).toThrow(Error); + expect(() => { + testBasicWrite(1, -50, false); + }).toThrow(Error); + expect(() => { + testBasicWrite(1, -256, false); + }).toThrow(Error); + expect(() => { + testBasicWrite(1, -400, false); + }).toThrow(Error); +}); + +it("Does not allow PWM writes greater than 255.", () => { + expect(() => { + testBasicWrite(1, 256, false); + }).toThrow(Error); + expect(() => { + testBasicWrite(1, 300, false); + }).toThrow(Error); +}); + +it("Properly inverts writes.", () => { + testBasicWrite(1, 50, true); + testBasicWrite(1, 255, true); + testBasicWrite(1, 0, true); + testBasicWrite(1, false, true); + testBasicWrite(1, true, true); +}); + +it("Turns off all pins with off().", () => { + const led = new LED(1, false); + led.write(25); + expect(digitalSpy).toHaveBeenCalledTimes(0); + led.off(); + expect(digitalSpy).toHaveBeenCalledTimes(1); + expect((digitalSpy.mock.calls[0] as any)[0]).toEqual(0); + led.write(true); + led.off(); + expect(digitalSpy).toHaveBeenCalledTimes(3); + expect((digitalSpy.mock.calls[2] as any)[0]).toEqual(0); + led.off(); + expect(digitalSpy).toHaveBeenCalledTimes(4); + expect((digitalSpy.mock.calls[3] as any)[0]).toEqual(0); +}); + +it("Throws an error if you attempt to start an animation before setting one", () => { + const led = new LED(1, false); + expect(() => { + led.startAnimation(); + }).toThrow(Error); +}); + +it("Starts animation automatically when no boolean is passed", () => { + const led = new LED(1, false); + // Timers run off of whole ms only. We must round for accurate results. + const framerate = Math.floor((1 / 60) * 1000) / 1000; + led.animate( + new Animation((t: number) => { + return ((t / 1000) * 2) % 1; + }, framerate * 1000) + ); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(3); + led.stopAnimation(); + }, framerate * 3 * 1000); + jest.runAllTimers(); +}); + +it("Starts animation automatically when true is passed", () => { + const led = new LED(1, false); + // Timers run off of whole ms only. We must round for accurate results. + const framerate = Math.floor((1 / 60) * 1000) / 1000; + led.animate( + new Animation((t: number) => { + return ((t / 1000) * 2) % 1; + }, framerate * 1000), + true + ); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(3); + led.stopAnimation(); + }, framerate * 3 * 1000); + jest.runAllTimers(); +}); + +it("Starts animation when startAnimation() is called", () => { + const led = new LED(1, false); + // Timers run off of whole ms only. We must round for accurate results. + const framerate = Math.floor((1 / 60) * 1000) / 1000; + led.animate( + new Animation((t: number) => { + return ((t / 1000) * 2) % 1; + }, framerate * 1000), + false + ); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(0); + led.startAnimation(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(3); + led.stopAnimation(); + }, framerate * 3 * 1000); + }, framerate * 3 * 1000); + jest.runAllTimers(); +}); + +it("Stops animation with stopAnimation()", () => { + const led = new LED(1, false); + // Timers run off of whole ms only. We must round for accurate results. + const framerate = Math.floor((1 / 60) * 1000) / 1000; + led.animate( + new Animation((t: number) => { + return ((t / 1000) * 2) % 1; + }, framerate * 1000) + ); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(digitalSpy).toHaveBeenCalledTimes(0); + expect(pwmSpy).toHaveBeenCalledTimes(3); + led.stopAnimation(); + setTimeout(() => { + expect(digitalSpy).toHaveBeenCalledTimes(0); + expect(pwmSpy).toHaveBeenCalledTimes(3); + }, framerate * 3 * 1000); + }, framerate * 3 * 1000); + jest.runAllTimers(); +}); + +it("Stops animation with off()", () => { + const led = new LED(1, false); + // Timers run off of whole ms only. We must round for accurate results. + const framerate = Math.floor((1 / 60) * 1000) / 1000; + led.animate( + new Animation((t: number) => { + return ((t / 1000) * 2) % 1; + }, framerate * 1000) + ); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(digitalSpy).toHaveBeenCalledTimes(0); + expect(pwmSpy).toHaveBeenCalledTimes(3); + led.off(); + setTimeout(() => { + expect(digitalSpy).toHaveBeenCalledTimes(1); + expect(pwmSpy).toHaveBeenCalledTimes(3); + }, framerate * 3 * 1000); + }, framerate * 3 * 1000); + jest.runAllTimers(); +}); + +it("Follows a provided animation curve", () => { + const led = new LED(1, false); + // Timers run off of whole ms only. We must round for accurate results. + const framerate = Math.floor((1 / 60) * 1000) / 1000; + led.animate( + new Animation((t: number) => { + return ((t / 1000) * 2) % 1; + }, framerate * 1000) + ); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(3); + // 1/60s == 16ms, due to setTimeout() rounding down + expect((pwmSpy.mock.calls[0] as any)[0]).toBeCloseTo( + Math.round(framerate * 2 * 255) + ); + expect((pwmSpy.mock.calls[1] as any)[0]).toBeCloseTo( + Math.round(framerate * 4 * 255) + ); + expect((pwmSpy.mock.calls[2] as any)[0]).toBeCloseTo( + Math.round(framerate * 6 * 255) + ); + led.stopAnimation(); + }, framerate * 3 * 1000); + jest.runAllTimers(); +}); + +it("Restarts animation where it left off with startAnimation()", () => { + const led = new LED(1, false); + // Timers run off of whole ms only. We must round for accurate results. + const framerate = Math.floor((1 / 60) * 1000) / 1000; + led.animate( + new Animation((t: number) => { + return ((t / 1000) * 2) % 1; + }, framerate * 1000) + ); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(3); + expect((pwmSpy.mock.calls[0] as any)[0]).toBeCloseTo( + Math.round(framerate * 2 * 255) + ); + expect((pwmSpy.mock.calls[1] as any)[0]).toBeCloseTo( + Math.round(framerate * 4 * 255) + ); + expect((pwmSpy.mock.calls[2] as any)[0]).toBeCloseTo( + Math.round(framerate * 6 * 255) + ); + led.stopAnimation(); + setTimeout(() => { + led.startAnimation(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(8); + expect((pwmSpy.mock.calls[3] as any)[0]).toBeCloseTo( + Math.round(framerate * 8 * 255) + ); + expect((pwmSpy.mock.calls[4] as any)[0]).toBeCloseTo( + Math.round(framerate * 10 * 255) + ); + expect((pwmSpy.mock.calls[5] as any)[0]).toBeCloseTo( + Math.round(framerate * 12 * 255) + ); + expect((pwmSpy.mock.calls[6] as any)[0]).toBeCloseTo( + Math.round(framerate * 14 * 255) + ); + expect((pwmSpy.mock.calls[7] as any)[0]).toBeCloseTo( + Math.round(framerate * 16 * 255) + ); + led.stopAnimation(); + }, framerate * 1000 * 5); + }, framerate * 1000 * 4); + }, framerate * 1000 * 3); + jest.runAllTimers(); +}); diff --git a/src/classes/LED.ts b/src/classes/LED.ts new file mode 100644 index 0000000..2022b44 --- /dev/null +++ b/src/classes/LED.ts @@ -0,0 +1,142 @@ +import { Gpio } from "pigpio"; +import Animation from "./Animation"; + +/** + * An LED is a type of connection on a Raspberry Pi GPIO board which has two + * wires: a cathode and an anode. This class does not care about the cathode, + * and only cares which GPIO pin the anode is connected to. Using an LED, you + * can send digital or a pulse modulated signal to the LED (or, whatever you + * have decided to plug in). You may also create Animations which are able to + * dynamically modulate the pulse modulation, allowing you to create things + * like blinking, fading, and custom complex animations. + */ +class LED { + /** + * GPIO pin on the Raspberry Pi that this LED is connected to. + * @private + * @readonly + */ + private readonly pin: Gpio; + /** + * The animation that was last running. It's unknown if it's still running + * or not. To determine that, check {@link Animation#isRunning()}. If this + * is set to null, then this LED has not had an animation run on it since + * instantiation. + * @private + */ + private animation: Animation | null; + /** + * Whether it's necessary to invert the signal strength before it is + * displayed in writes. For example, when set to true, 255 is interpreted + * as off while 0 is interpreted as on. Similarly, true is off while false + * is on. + * @private + * @readonly + */ + private readonly invert: boolean; + + /** + * Constructor + * @param pin {number} GPIO pin corresponding to the LED + * @param invert {boolean} Whether the signals to these lights should be + * inverted before writing. E.g., true = off and false = on. + */ + constructor(pin: number, invert: boolean) { + if (pin < 0) { + throw new Error("GPIO pins must be greater than or equal to 0."); + } + this.pin = new Gpio(pin, { mode: Gpio.OUTPUT }); + this.invert = invert; + this.animation = null; + } + + /** + * Animate this LED following the provided Animation curve. + * @param animation The animation to animate this LED with. + */ + public animate(animation: Animation): void; + /** + * Animate this LED following the provided Animation curve. + * @param animation The Animation to animate this LED with. + * @param autoStart Whether this Animation should automatically start + * as soon as it is set. If not, then you must call + * {@link #startAnimation()} to start the Animation. + */ + public animate(animation: Animation, autoStart: boolean): void; + public animate(animation: Animation, autoStart?: boolean): void { + this.stopAnimation(); + this.animation = animation.copy(); + this.animation.subscribe((val) => { + this.pin.pwmWrite(Math.round(val * 255)); + }); + if (autoStart || autoStart === undefined) { + this.animation.start(); + } + } + + /** + * Start the Animation which was set by {@link #animate()}. If the + * Animation is already running, does nothing. + * @throws If no Animation has been set yet via {@link #animate()}. + * @see #animate() + */ + public startAnimation(): void { + if (this.animation == null) { + throw new Error("No animation is set on this LED."); + } + this.animation.start(); + } + + /** + * Stop any Animation which is currently happening. If the LED is not + * currently animating, nothing happens. Animation is still stored, and + * can be resumed by calling {@link #startAnimation()}. The Animation + * will resume from the same location at which it stopped. This method does + * NOT overwrite the last frame of the Animation. If you'd like to also + * turn off the LED when the Animation is stopped, look at {@link #off()}. + */ + public stopAnimation(): void { + this.animation?.stop(); + } + + /** + * Turn off the LED. Also disables any Animation. + * Accomplishes this by writing a digital off signal to the LED. + */ + public off(): void { + this.stopAnimation(); + this.write(false); + } + + /** + * Write an on/off state to the LED. + * @param value Whether the LED should be turned on. + */ + public write(value: boolean): void; + /** + * Write a PWM state to all three LEDs. + * @param value LED value. Expected to be between 0 and 255, inclusive. + * @throws Error if provided a color value outside the range 0-255. + */ + public write(value: number): void; + public write(value: number | boolean): void { + if (typeof value === "number") { + if (value < 0 || value > 255) { + throw new Error( + "Values must be between 0 and 255 (inclusively) or boolean values." + ); + } + if (this.invert) { + value = 255 - value; + } + this.pin.pwmWrite(value); + } else { + if (this.invert) { + value = !value; + } + this.pin.digitalWrite(value ? 1 : 0); + } + } +} + +export default LED; diff --git a/src/classes/LEDArray.test.ts b/src/classes/LEDArray.test.ts new file mode 100644 index 0000000..a99afa9 --- /dev/null +++ b/src/classes/LEDArray.test.ts @@ -0,0 +1,847 @@ +import LEDArray from "./LEDArray"; +import { Gpio } from "../../__mocks__/pigpio"; +import Animation from "./Animation"; + +jest.useFakeTimers(); + +const digitalSpy = jest.spyOn(Gpio.prototype, "digitalWrite"); +const pwmSpy = jest.spyOn(Gpio.prototype, "pwmWrite"); + +it("Allows construction without the invert argument", () => { + new LEDArray([1, 2, 3]); + new LEDArray([1, 2, 3]); + new LEDArray([4, 5, 6, 7]); + new LEDArray([8]); + new LEDArray([9, 10]); +}); + +it("Requires the length of the invert array to be equal to the number of pins", () => { + expect(() => { + new LEDArray([1, 2, 3], []); + }).toThrow(Error); + expect(() => { + new LEDArray([1, 2, 3], [false]); + }).toThrow(Error); + expect(() => { + new LEDArray([1, 2, 3], [false, true]); + }).toThrow(Error); + expect(() => { + new LEDArray([1, 2, 3], [false, true, true, false]); + }).toThrow(Error); + new LEDArray([1, 2, 3], [false, true, true]); +}); + +it("Does not allow pins less than 0", () => { + expect(() => { + new LEDArray([1, -2, 3]); + }).toThrow(Error); + expect(() => { + new LEDArray([-1, 2, 3]); + }).toThrow(Error); + expect(() => { + new LEDArray([-1, -2, -3]); + }).toThrow(Error); +}); + +it("Requires write arrays to have the same size as the number of LEDs", () => { + const leds = new LEDArray([4, 5, 6]); + expect(() => { + leds.write(false, false); + }).toThrow(Error); + expect(() => { + leds.write(false, true, true, true); + }).toThrow(Error); + expect(() => { + leds.write(34, 59, 100, 241); + }).toThrow(Error); + expect(() => { + leds.write(34, 59); + }).toThrow(Error); + leds.write(34, 59, 100); + leds.write(false, true, true); +}); + +it("Requires write arrays to all be of the same type", () => { + const leds = new LEDArray([4, 5, 6]); + expect(() => { + // @ts-ignore Intentional test + leds.write(false, 15, true); + }).toThrow(Error); + expect(() => { + // @ts-ignore Intentional test + leds.write(15, 15, true); + }).toThrow(Error); +}); + +it("Does not allow PWM writes less than 0", () => { + const leds = new LEDArray([4, 5, 6]); + expect(() => { + leds.write(0, -5, 0); + }).toThrow(Error); + expect(() => { + leds.write(-1, 0, 0); + }).toThrow(Error); + expect(() => { + leds.write(0, 0, -0.0001); + }).toThrow(Error); + leds.write(-0, 0, 0); + leds.write(0, 0, 0); +}); + +it("Does not allow PWM writes greater than 255", () => { + const leds = new LEDArray([4, 5, 6, 7]); + expect(() => { + leds.write(0, 345, 0, 0); + }).toThrow(Error); + expect(() => { + leds.write(1, 0, 256, 58); + }).toThrow(Error); + expect(() => { + leds.write(0, 0, 255.0001, 128); + }).toThrow(Error); + leds.write(0, 255, 0, 0); + leds.write(255, 255, 255, 255); +}); + +it("Sends writes to the relevant pins", () => { + const leds = new LEDArray([58, 3, 135, 1, 18, 4, 5]); + const pwmVals = [11, 16, 13, 38, 31, 3, 15]; + const digitalVals = [true, true, false, true, false, false, true]; + + expect(pwmSpy).toHaveBeenCalledTimes(0); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...pwmVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...digitalVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(7); + + expect((pwmSpy.mock.calls[0] as any)[0]).toEqual(pwmVals[0]); + expect((pwmSpy.mock.calls[1] as any)[0]).toEqual(pwmVals[1]); + expect((pwmSpy.mock.calls[2] as any)[0]).toEqual(pwmVals[2]); + expect((pwmSpy.mock.calls[3] as any)[0]).toEqual(pwmVals[3]); + expect((pwmSpy.mock.calls[4] as any)[0]).toEqual(pwmVals[4]); + expect((pwmSpy.mock.calls[5] as any)[0]).toEqual(pwmVals[5]); + expect((pwmSpy.mock.calls[6] as any)[0]).toEqual(pwmVals[6]); + + expect((digitalSpy.mock.calls[0] as any)[0]).toEqual( + digitalVals[0] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[1] as any)[0]).toEqual( + digitalVals[1] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[2] as any)[0]).toEqual( + digitalVals[2] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[3] as any)[0]).toEqual( + digitalVals[3] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[4] as any)[0]).toEqual( + digitalVals[4] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[5] as any)[0]).toEqual( + digitalVals[5] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[6] as any)[0]).toEqual( + digitalVals[6] ? 1 : 0 + ); +}); + +it("Does not invert writes when no invert argument is passed", () => { + const leds = new LEDArray([58, 3, 135, 1, 18, 4, 5]); + const pwmVals = [11, 16, 13, 38, 31, 3, 15]; + const digitalVals = [true, true, false, true, false, false, true]; + + expect(pwmSpy).toHaveBeenCalledTimes(0); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...pwmVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...digitalVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(7); + + expect((pwmSpy.mock.calls[0] as any)[0]).toEqual(pwmVals[0]); + expect((pwmSpy.mock.calls[1] as any)[0]).toEqual(pwmVals[1]); + expect((pwmSpy.mock.calls[2] as any)[0]).toEqual(pwmVals[2]); + expect((pwmSpy.mock.calls[3] as any)[0]).toEqual(pwmVals[3]); + expect((pwmSpy.mock.calls[4] as any)[0]).toEqual(pwmVals[4]); + expect((pwmSpy.mock.calls[5] as any)[0]).toEqual(pwmVals[5]); + expect((pwmSpy.mock.calls[6] as any)[0]).toEqual(pwmVals[6]); + + expect((digitalSpy.mock.calls[0] as any)[0]).toEqual( + digitalVals[0] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[1] as any)[0]).toEqual( + digitalVals[1] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[2] as any)[0]).toEqual( + digitalVals[2] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[3] as any)[0]).toEqual( + digitalVals[3] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[4] as any)[0]).toEqual( + digitalVals[4] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[5] as any)[0]).toEqual( + digitalVals[5] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[6] as any)[0]).toEqual( + digitalVals[6] ? 1 : 0 + ); +}); + +it("Does not invert writes when a false invert argument is explicitly passed", () => { + const leds = new LEDArray([58, 3, 135, 1, 18, 4, 5], false); + const pwmVals = [11, 16, 13, 38, 31, 3, 15]; + const digitalVals = [true, true, false, true, false, false, true]; + + expect(pwmSpy).toHaveBeenCalledTimes(0); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...pwmVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...digitalVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(7); + + expect((pwmSpy.mock.calls[0] as any)[0]).toEqual(pwmVals[0]); + expect((pwmSpy.mock.calls[1] as any)[0]).toEqual(pwmVals[1]); + expect((pwmSpy.mock.calls[2] as any)[0]).toEqual(pwmVals[2]); + expect((pwmSpy.mock.calls[3] as any)[0]).toEqual(pwmVals[3]); + expect((pwmSpy.mock.calls[4] as any)[0]).toEqual(pwmVals[4]); + expect((pwmSpy.mock.calls[5] as any)[0]).toEqual(pwmVals[5]); + expect((pwmSpy.mock.calls[6] as any)[0]).toEqual(pwmVals[6]); + + expect((digitalSpy.mock.calls[0] as any)[0]).toEqual( + digitalVals[0] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[1] as any)[0]).toEqual( + digitalVals[1] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[2] as any)[0]).toEqual( + digitalVals[2] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[3] as any)[0]).toEqual( + digitalVals[3] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[4] as any)[0]).toEqual( + digitalVals[4] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[5] as any)[0]).toEqual( + digitalVals[5] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[6] as any)[0]).toEqual( + digitalVals[6] ? 1 : 0 + ); +}); + +it("Properly inverts writes when a singular invert boolean is passed", () => { + const leds = new LEDArray([58, 3, 135, 1, 18, 4, 5], true); + const pwmVals = [11, 16, 13, 38, 31, 3, 15]; + const digitalVals = [true, true, false, true, false, false, true]; + + expect(pwmSpy).toHaveBeenCalledTimes(0); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...pwmVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...digitalVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(7); + + expect((pwmSpy.mock.calls[0] as any)[0]).toEqual(255 - pwmVals[0]); + expect((pwmSpy.mock.calls[1] as any)[0]).toEqual(255 - pwmVals[1]); + expect((pwmSpy.mock.calls[2] as any)[0]).toEqual(255 - pwmVals[2]); + expect((pwmSpy.mock.calls[3] as any)[0]).toEqual(255 - pwmVals[3]); + expect((pwmSpy.mock.calls[4] as any)[0]).toEqual(255 - pwmVals[4]); + expect((pwmSpy.mock.calls[5] as any)[0]).toEqual(255 - pwmVals[5]); + expect((pwmSpy.mock.calls[6] as any)[0]).toEqual(255 - pwmVals[6]); + + expect((digitalSpy.mock.calls[0] as any)[0]).toEqual( + !digitalVals[0] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[1] as any)[0]).toEqual( + !digitalVals[1] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[2] as any)[0]).toEqual( + !digitalVals[2] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[3] as any)[0]).toEqual( + !digitalVals[3] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[4] as any)[0]).toEqual( + !digitalVals[4] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[5] as any)[0]).toEqual( + !digitalVals[5] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[6] as any)[0]).toEqual( + !digitalVals[6] ? 1 : 0 + ); +}); + +it("Properly inverts writes when an array of invert booleans is passed", () => { + const leds = new LEDArray( + [58, 3, 135, 1, 18, 4, 5], + [true, false, true, true, false, false, true] + ); + const pwmVals = [11, 16, 13, 38, 31, 3, 15]; + const digitalVals = [true, true, false, true, false, false, true]; + + expect(pwmSpy).toHaveBeenCalledTimes(0); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...pwmVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.write(...digitalVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(7); + + expect((pwmSpy.mock.calls[0] as any)[0]).toEqual(255 - pwmVals[0]); + expect((pwmSpy.mock.calls[1] as any)[0]).toEqual(pwmVals[1]); + expect((pwmSpy.mock.calls[2] as any)[0]).toEqual(255 - pwmVals[2]); + expect((pwmSpy.mock.calls[3] as any)[0]).toEqual(255 - pwmVals[3]); + expect((pwmSpy.mock.calls[4] as any)[0]).toEqual(pwmVals[4]); + expect((pwmSpy.mock.calls[5] as any)[0]).toEqual(pwmVals[5]); + expect((pwmSpy.mock.calls[6] as any)[0]).toEqual(255 - pwmVals[6]); + + expect((digitalSpy.mock.calls[0] as any)[0]).toEqual( + !digitalVals[0] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[1] as any)[0]).toEqual( + digitalVals[1] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[2] as any)[0]).toEqual( + !digitalVals[2] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[3] as any)[0]).toEqual( + !digitalVals[3] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[4] as any)[0]).toEqual( + digitalVals[4] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[5] as any)[0]).toEqual( + digitalVals[5] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[6] as any)[0]).toEqual( + !digitalVals[6] ? 1 : 0 + ); +}); + +it("Turns off all LEDs with off()", () => { + const leds = new LEDArray([58, 3, 135, 1, 18, 4, 5], false); + const pwmVals = [11, 16, 13, 38, 31, 3, 15]; + const digitalVals = [true, true, false, true, false, false, true]; + + expect(pwmSpy).toHaveBeenCalledTimes(0); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.off(); + expect(pwmSpy).toHaveBeenCalledTimes(0); + expect(digitalSpy).toHaveBeenCalledTimes(7); + leds.write(...pwmVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(7); + leds.write(...digitalVals); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(14); + leds.off(); + expect(pwmSpy).toHaveBeenCalledTimes(7); + expect(digitalSpy).toHaveBeenCalledTimes(21); + + expect((pwmSpy.mock.calls[0] as any)[0]).toEqual(pwmVals[0]); + expect((pwmSpy.mock.calls[1] as any)[0]).toEqual(pwmVals[1]); + expect((pwmSpy.mock.calls[2] as any)[0]).toEqual(pwmVals[2]); + expect((pwmSpy.mock.calls[3] as any)[0]).toEqual(pwmVals[3]); + expect((pwmSpy.mock.calls[4] as any)[0]).toEqual(pwmVals[4]); + expect((pwmSpy.mock.calls[5] as any)[0]).toEqual(pwmVals[5]); + expect((pwmSpy.mock.calls[6] as any)[0]).toEqual(pwmVals[6]); + + expect((digitalSpy.mock.calls[0] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[1] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[2] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[3] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[4] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[5] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[6] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[7] as any)[0]).toEqual( + digitalVals[0] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[8] as any)[0]).toEqual( + digitalVals[1] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[9] as any)[0]).toEqual( + digitalVals[2] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[10] as any)[0]).toEqual( + digitalVals[3] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[11] as any)[0]).toEqual( + digitalVals[4] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[12] as any)[0]).toEqual( + digitalVals[5] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[13] as any)[0]).toEqual( + digitalVals[6] ? 1 : 0 + ); + expect((digitalSpy.mock.calls[14] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[15] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[16] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[17] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[18] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[19] as any)[0]).toEqual(0); + expect((digitalSpy.mock.calls[20] as any)[0]).toEqual(0); +}); + +it("Requires animation arrays to be of the same length as there are LEDs", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate([ + new Animation((t) => t / 400, 100), + new Animation((t) => t / 400, 100), + new Animation((t) => t / 400, 100), + ]); + leds.animate([ + new Animation((t) => t / 400, 100), + new Animation((t) => t / 500, 100), + new Animation((t) => t / 400, 100), + ]); + expect(() => { + leds.animate([ + new Animation((t) => t / 400, 100), + new Animation((t) => t / 400, 100), + new Animation((t) => t / 400, 100), + new Animation((t) => t / 400, 100), + ]); + }).toThrow(Error); + expect(() => { + leds.animate([ + new Animation((t) => t / 400, 100), + new Animation((t) => t / 400, 100), + ]); + }).toThrow(Error); + leds.stopAnimation(); +}); + +it("Requires animation values array to be of the same length as there are LEDs", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate(new Animation((t) => t / 750, 100), [255, 255, 255]); + leds.animate(new Animation((t) => t / 750, 100), [255, 35, 172]); + expect(() => { + leds.animate(new Animation((t) => t / 750, 100), [255, 35, 172, 212]); + }).toThrow(Error); + expect(() => { + leds.animate(new Animation((t) => t / 750, 100), [255, 35]); + }).toThrow(Error); + leds.stopAnimation(); +}); + +it("Requires all values within the animation value array to be of the same type", () => { + const leds = new LEDArray([7, 10, 15]); + expect(() => { + leds.animate( + new Animation((t) => t / 750, 100), + // @ts-ignore Intentional test + [255, false, 172] + ); + }).toThrow(Error); + expect(() => { + leds.animate( + new Animation((t) => t / 750, 100), + // @ts-ignore Intentional test + [true, 35, false] + ); + }).toThrow(Error); + expect(() => { + leds.animate( + new Animation((t) => t / 750, 100), + // @ts-ignore Intentional test + [false, true, 190] + ); + }).toThrow(Error); + expect(() => { + leds.animate( + new Animation((t) => t / 750, 100), + // @ts-ignore Intentional test + [83, true, false] + ); + }).toThrow(Error); + leds.stopAnimation(); +}); + +it("Requires the values array if a single Animation is passed in the Animation options argument", () => { + const leds = new LEDArray([7, 10, 15]); + expect(() => { + leds.animate({ + animation: new Animation((t) => t / 38, 100), + }); + }).toThrow(Error); + leds.animate({ + animation: new Animation((t) => t / 38, 100), + values: [40, 55, 90], + }); + leds.stopAnimation(); +}); + +it("Does not require a values array if an Animation array is passed in the Animation options argument", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate({ + animation: [ + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + ], + }); + leds.animate({ + animation: [ + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + ], + values: [40, 55, 90], + }); + leds.stopAnimation(); +}); + +it("Starts animation automatically if autoStart is not set in the animation options", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate({ + animation: [ + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + ], + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(3); + }, 100); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(6); + leds.stopAnimation(); + }, 200); + jest.runAllTimers(); +}); + +it("Starts animation automatically if autoStart is set to true in the animation options", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate({ + animation: [ + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + ], + autoStart: true, + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(3); + }, 100); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(6); + leds.stopAnimation(); + }, 200); + jest.runAllTimers(); +}); + +it("Does not start animation automatically if autoStart is set to false in the animation options", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate({ + animation: [ + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + ], + autoStart: false, + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(0); + }, 100); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(0); + leds.stopAnimation(); + }, 200); + jest.runAllTimers(); +}); + +it("Throws an error if you attempt to start an animation before setting one", () => { + const leds = new LEDArray([7, 10, 15]); + expect(() => { + leds.startAnimation(); + }).toThrow(Error); + leds.animate({ + animation: [ + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + ], + autoStart: false, + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(0); + leds.startAnimation(); + leds.stopAnimation(); + expect(pwmSpy).toHaveBeenCalledTimes(0); + leds.stopAnimation(); + }, 100); + jest.runAllTimers(); +}); + +it("Starts animation when startAnimation() is called", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate({ + animation: [ + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + new Animation((t) => t / 38, 100), + ], + autoStart: false, + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(0); + leds.startAnimation(); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(6); + leds.stopAnimation(); + }, 200); + }, 100); + jest.runAllTimers(); +}); + +it("Stops animation when stopAnimation() is called", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate({ + animation: [ + new Animation((t) => t / 38, 100), + new Animation((t) => t / 50, 100), + new Animation((t) => t / 21, 100), + ], + autoStart: false, + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + leds.stopAnimation(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(0); + leds.startAnimation(); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(6); + leds.stopAnimation(); + setTimeout(() => { + leds.startAnimation(); + expect(pwmSpy).toHaveBeenCalledTimes(6); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(9); + leds.stopAnimation(); + leds.stopAnimation(); + }, 100); + }, 100); + }, 200); + }, 100); + jest.runAllTimers(); +}); + +it("Stops animation when off() is called", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate({ + animation: new Animation((t) => t / 38, 100), + values: [40, 55, 90], + autoStart: false, + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(0); + leds.startAnimation(); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(6); + expect(digitalSpy).toHaveBeenCalledTimes(0); + leds.off(); + expect(digitalSpy).toHaveBeenCalledTimes(3); + setTimeout(() => { + leds.startAnimation(); + expect(pwmSpy).toHaveBeenCalledTimes(6); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(9); + expect(digitalSpy).toHaveBeenCalledTimes(3); + leds.off(); + leds.off(); + expect(digitalSpy).toHaveBeenCalledTimes(9); + leds.off(); + }, 100); + }, 100); + }, 200); + }, 100); + jest.runAllTimers(); +}); + +it("Follows a provided singular animation curve for all values", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate({ + animation: new Animation((t) => t / 1000, 100), + values: [40, 55, 255], + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(3); + expect((pwmSpy.mock.calls[0] as any)[0]).toBeCloseTo( + Math.round(0.1 * 40), + 5 + ); + expect((pwmSpy.mock.calls[1] as any)[0]).toBeCloseTo( + Math.round(0.1 * 55), + 5 + ); + expect((pwmSpy.mock.calls[2] as any)[0]).toBeCloseTo( + Math.round(0.1 * 255), + 5 + ); + leds.stopAnimation(); + }, 100); + jest.runAllTimers(); +}); + +it("Follows the animation curve for each individual LED when passed an animation array", () => { + const leds = new LEDArray([7, 10, 15]); + leds.animate({ + animation: [ + new Animation((t) => t / 1000, 100), + new Animation((t) => t / 2000, 100), + new Animation((t) => t / 2500, 100), + ], + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(3); + expect((pwmSpy.mock.calls[0] as any)[0]).toBeCloseTo( + Math.round(0.1 * 255), + 5 + ); + expect((pwmSpy.mock.calls[1] as any)[0]).toBeCloseTo( + Math.round(0.05 * 255), + 5 + ); + expect((pwmSpy.mock.calls[2] as any)[0]).toBeCloseTo( + Math.round(0.04 * 255), + 5 + ); + leds.stopAnimation(); + }, 100); + jest.runAllTimers(); +}); + +it("Restarts a singular animation where it left off when startAnimation() is called", () => { + const leds = new LEDArray([7, 10]); + leds.animate({ + animation: new Animation((t) => t / 1000, 100), + values: [40, 194], + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(2); + expect((pwmSpy.mock.calls[0] as any)[0]).toBeCloseTo( + Math.round(0.1 * 40), + 5 + ); + expect((pwmSpy.mock.calls[1] as any)[0]).toBeCloseTo( + Math.round(0.1 * 194), + 5 + ); + leds.stopAnimation(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(2); + leds.startAnimation(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(4); + expect((pwmSpy.mock.calls[2] as any)[0]).toBeCloseTo( + Math.round(0.2 * 40), + 5 + ); + expect((pwmSpy.mock.calls[3] as any)[0]).toBeCloseTo( + Math.round(0.2 * 194), + 5 + ); + leds.off(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(4); + leds.startAnimation(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(6); + expect((pwmSpy.mock.calls[4] as any)[0]).toBeCloseTo( + Math.round(0.3 * 40), + 5 + ); + expect((pwmSpy.mock.calls[5] as any)[0]).toBeCloseTo( + Math.round(0.3 * 194), + 5 + ); + leds.stopAnimation(); + }, 100); + }, 100); + }, 100); + }, 100); + }, 100); + jest.runAllTimers(); +}); + +it("Restarts an animation array where they all left off when startAnimation() is called", () => { + const leds = new LEDArray([7, 10]); + leds.animate({ + animation: [ + new Animation((t) => t / 1000, 100), + new Animation((t) => t / 2500, 100), + ], + }); + expect(pwmSpy).toHaveBeenCalledTimes(0); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(2); + expect((pwmSpy.mock.calls[0] as any)[0]).toBeCloseTo( + Math.round(0.1 * 255), + 5 + ); + expect((pwmSpy.mock.calls[1] as any)[0]).toBeCloseTo( + Math.round(0.04 * 255), + 5 + ); + leds.stopAnimation(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(2); + leds.startAnimation(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(4); + expect((pwmSpy.mock.calls[2] as any)[0]).toBeCloseTo( + Math.round(0.2 * 255), + 5 + ); + expect((pwmSpy.mock.calls[3] as any)[0]).toBeCloseTo( + Math.round(0.08 * 255), + 5 + ); + leds.off(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(4); + leds.startAnimation(); + setTimeout(() => { + expect(pwmSpy).toHaveBeenCalledTimes(6); + expect((pwmSpy.mock.calls[4] as any)[0]).toBeCloseTo( + Math.round(0.3 * 255), + 5 + ); + expect((pwmSpy.mock.calls[5] as any)[0]).toBeCloseTo( + Math.round(0.12 * 255), + 5 + ); + leds.stopAnimation(); + }, 100); + }, 100); + }, 100); + }, 100); + }, 100); + jest.runAllTimers(); +}); diff --git a/src/classes/LEDArray.ts b/src/classes/LEDArray.ts new file mode 100644 index 0000000..37e50ea --- /dev/null +++ b/src/classes/LEDArray.ts @@ -0,0 +1,352 @@ +import LED from "./LED"; +import Animation from "./Animation"; + +type AnimationOptions = { + animation: Animation[] | Animation; + values?: number[]; + autoStart?: boolean; +}; + +/** + * An LED array is a set of multiple LED objects which are controlled in bulk. + * Typical use cases for this would probably be: + * - An RGB LED with 3 individual LED components for each color. + * - A set of LEDs which collectively make up a display or shape. + */ +class LEDArray { + /** + * Array of individual LEDs contained within this LED. + * @private + */ + private readonly leds: LED[] = []; + /** + * Array of false values equal to the size of {@link #leds}. Creating this + * array beforehand saves resources later when the user calls + * {@link #off()}. This is the only purpose of this array. It should not be + * modified. + * @private + */ + private readonly falseArray: boolean[] = []; + /** + * The animation that was last running. It's unknown if it's still running + * or not. To determine that, check {@link Animation#isRunning()}. If this + * is set to null, then this LED array has not had an animation run on it + * since instantiation. There are two ways to run an animation on an LED + * array, which changes how the options object is structured. For more + * info, look at the {@link #animate()} method. + * @private + */ + private animation: AnimationOptions | null = null; + + /** + * Constructor + * @param pins Pin numbers for each of the different LEDs connected to + * this LED array. Expected to all be greater than or equal to 0. + * @param invert Optional array of booleans for each of the different LEDs + * signifying whether the corresponding LED index should be inverted. + * Alternatively, a single boolean value for all the LEDs. Expected size to + * be equal to the length of the passed pins array. If not passed, then + * a value of "false" is assumed. + * @throws Error if the length of pins array is not equal to length of + * invert array. + * @throws Error if any pin is less than 0. + */ + constructor(pins: number[], invert?: boolean[] | boolean) { + if (Array.isArray(invert) && pins.length !== invert.length) { + throw new Error( + "Length of pins array is not equal to length of invert array." + ); + } + if (invert === undefined) { + invert = false; + } + + for (let i = 0; i < pins.length; i++) { + const pin = pins[i]; + if (pin < 0) { + throw new Error( + "Pin index " + i + " (" + pin + ") is less than 0." + ); + } + const pinInvert = typeof invert === "boolean" ? invert : invert[i]; + this.leds.push(new LED(pin, pinInvert)); + this.falseArray.push(false); + } + } + + /** + * Start an animation across this LED array's LEDs. + * @param animation Array of animations, which each animation corresponding + * to the LED at the same index, passed in the constructor. Expected to be + * the same length as the number of LEDs in this LEDArray. The animation + * will immediately start. If you do not want the animation to + * automatically start, pass false to the autoStart parameter. + * @throws Error if the length of the animation array is not equal to the + * number of LEDs. + */ + animate(animation: Animation[]): void; + /** + * Start an animation across this LED array's LEDs. + * @param animation Animation to be used for all the LEDs in this LED + * array, scaled to the appropriate value based on what is passed in + * the values argument of this function. + * @param values Array of values to cap the animation at, with each value + * corresponding to a LED at the same index. For example, in an LED array + * with 3 LEDs, passing in the array [200, 100, 50] means that when the + * animation reaches a point of half brightness, the three LEDs would be at + * a brightness of 100, 50, and 25 respectively. It is expected that the + * array's length is equal to the number of LEDs in this LEDArray. If you + * wish to have more control over the animation by having different + * proportions at different points throughout the Animation, take a look at + * the different parameters to this method. + * @throws Error if the length of the values array isn't equal to the total + * number of LEDs in this LEDArray. + */ + animate(animation: Animation, values: number[]): void; + /** + * Start an animation across this LED array's LEDs. + * @param options Options argument which allows more verbose control over + * how the animation will operate. Properties: + * - animation: Animation or Animation[]. If you pass a single Animation, + * then you must also pass the values of the animation via the values + * property. Otherwise, the animation array must have equal length + * to the number of LEDs. + * - autoStart?: boolean. If true or undefined, the animation immediately + * starts within this function call. Otherwise, you must call + * {@link #startAnimation()} to start the animation. + * - values: boolean[]|number[]. The values corresponding to the animation. + * This is not necessary if you are passing multiple animation + * functions. This is because, in that case, the value of each LED in + * the animation is controlled by its corresponding animation. This + * allows complex animations for each individual LED. On the other + * hand, if animation is a single Animation, the value of each LED is + * not controlled by its own animation. You must include this value + * in order to specify the proportions of the brightness of each LED, + * which will be maintained throughout the animation. Expected to be + * defined and non-null if animation is a single Animation and not an + * array. Also expected to be the same length as the number of LEDs. + * @throws Error if the length of the animation array is not equal to the + * number of LEDs in this LEDArray. + * @throws Error if the length of the values array is not equal to the + * number of LEDs in this LEDArray. + * @throws Error if the values array is not of all the same type. + * @throws Error if a single Animation is provided, but no values array is + * provided. + */ + animate(options: AnimationOptions): void; + animate( + arg1: Animation[] | AnimationOptions | Animation, + arg2?: number[] + ): void { + this.stopAnimation(); + let options: AnimationOptions; + if (Array.isArray(arg1) || arg1 instanceof Animation) { + options = { + animation: arg1, + values: arg2, + }; + } else { + options = arg1; + } + if (options.autoStart === undefined) { + options.autoStart = true; + } + + if (options.animation instanceof Animation) { + if (options.values === undefined) { + throw new Error( + "Singular animation provided but proportions values provided." + ); + } + if (options.values.length !== this.leds.length) { + throw new Error( + "Length of values array is not equal to the number of LEDs." + ); + } + for (let i = 0; i < options.values.length; i++) { + if (typeof options.values[i] !== typeof options.values[0]) { + throw new Error( + "Animation values are not all of the same type." + ); + } + } + // In the absence of an animation array, we could create our own, + // but it's more efficient to just have a single Animation and + // write to the LEDs directly at this level. + this.animation = { + animation: options.animation.copy(), + autoStart: options.autoStart, + values: options.values, + }; + (this.animation.animation as Animation).subscribe((newValue) => { + if ( + this.animation === null || + this.animation.values === undefined + ) { + return; + } + for (let i = 0; i < this.leds.length; i++) { + const led = this.leds[i]; + const ratio = this.animation.values[i] / 255; + led.write(Math.round(newValue * ratio * 255)); + } + }); + + if (options.autoStart) { + this.startAnimation(); + } + } else { + if (options.animation.length !== this.leds.length) { + throw new Error( + "Length of animations array is not equal to the number of LEDs." + ); + } + // An animation array can just have the individual animations + // passed onto the individual LEDs. + for (let i = 0; i < options.animation.length; i++) { + this.leds[i].animate(options.animation[i], options.autoStart); + } + this.animation = { + animation: options.animation, + autoStart: options.autoStart, + values: options.values, + }; + } + } + + /** + * Start the Animation which was set by {@link #animate()}. If the + * Animation is already running, does nothing. + * @throws If no Animation has been set yet via {@link #animate()}. + * @see #animate() + */ + startAnimation(): void { + if (this.animation === null) { + throw new Error("Animation has not yet been set by animate()"); + } + if (this.animation.animation instanceof Animation) { + // Master Animation for all LEDs at a specified proportion. + this.animation.animation.start(); + } else { + // Animation[] -- Each animation is passed directly to LEDs + for (let i = 0; i < this.leds.length; i++) { + this.leds[i].startAnimation(); + } + } + } + + /** + * Stop any Animation which is currently happening. If the LED is not + * currently animating, nothing happens. Animation is still stored, and + * can be resumed by calling {@link #startAnimation()}. The Animation + * will resume from the same location at which it stopped. This method does + * NOT overwrite the last frame of the Animation. If you'd like to also + * turn off the LED when the Animation is stopped, look at {@link #off()}. + */ + stopAnimation(): void { + if (this.animation !== null) { + if (this.animation.animation instanceof Animation) { + // Master Animation for all LEDs at a specified proportion. + this.animation.animation.stop(); + } else { + // Animation[] -- Each animation is passed directly to LEDs + for (let i = 0; i < this.leds.length; i++) { + this.leds[i].stopAnimation(); + } + } + } + } + + /** + * Turn off the LED. Also disables any Animation. + * Accomplishes this by writing a digital off signal to each of the LEDs. + */ + off(): void { + this.stopAnimation(); + this.write(...this.falseArray); + } + + /** + * Write a set of PWM values to each of the corresponding LEDs. This method + * does not stop the currently-running Animation, and whatever you write + * will get overwritten on the next Animation frame, if one exists. Writes + * should be sent in order of the LED pins which were passed in the + * constructor. For example, if an array of [4, 5, 8] was passed as the + * pins, a call of write(255, 128, 42) should result in pin 4 receiving + * 255, 5 receiving 128, and 8 receiving 42 in chronological order. + * @param values Array of numbers, where each number in the array + * corresponds to one of the LEDs. Therefore, array is expected to be the + * same length as the number of LEDs in this LEDArray. All numbers are also + * expected to be between 0 and 255 inclusively. For each LED, if it has + * invert enabled, the value sent to the GPIO pin will be 255 - values[i]. + * All the values in the array are expected to be of the same type (i.e., + * either all numbers or all booleans). + * @throws Error if values array is not the same size as the total number of + * LEDs. + * @throws Error if any number is outside the range 0-255. All the LEDs + * before the first one outside of range will still be written. + * @throws Error if any of the values in the array are not numbers. + */ + write(...values: number[]): void; + /** + * Write a set of digital values to each of the corresponding LEDs. This + * method does not stop the currently-running Animation, and whatever you + * write will get overwritten on the next Animation frame, if one exists. + * Writes should be sent in order of the LED pins which were passed in the + * constructor. For example, if an array of [4, 5, 8] was passed as the + * pins, a call of write(false, true, false) should result in pin 4 + * receiving false, 5 receiving true, and 8 receiving false in + * chronological order. + * @param values Array of booleans, where each boolean in the array + * corresponds to one of the LEDs. Therefore, array is expected to be the + * same length as the number of LEDs in this LEDArray. For each LED, if it + * has invert enabled, the value sent to the GPIO pin will be !values[i]. + * All the values in the array are expected to be of the same type (i.e., + * either all numbers or all booleans). + * @throws Error if values array is not the same size as the total number of + * LEDs. + * @throws Error if any of the values in the array are not booleans. + */ + write(...values: boolean[]): void; + write(...values: number[] | boolean[]): void { + if (values.length !== this.leds.length) { + throw new Error( + "Number of values and number of LEDs do not match." + ); + } + if (typeof values[0] === "number") { + for (let i = 0; i < values.length; i++) { + const value = values[i]; + if (typeof value !== "number") { + throw new Error( + "LED value at index " + i + "is not a number." + ); + } + if (value < 0 || value > 255) { + throw new Error( + "LED with index " + + i + + " has a value outside of the range 0-255 (" + + value + + ")" + ); + } + + this.leds[i].write(value); + } + } else { + for (let i = 0; i < values.length; i++) { + const value = values[i]; + if (typeof value !== "boolean") { + throw new Error( + "LED value at index " + i + "is not a boolean." + ); + } + + this.leds[i].write(value); + } + } + } +} + +export default LEDArray; diff --git a/src/classes/Lights.test.ts b/src/classes/Lights.test.ts deleted file mode 100644 index e56664f..0000000 --- a/src/classes/Lights.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -import Lights from "./Lights"; -import { Gpio } from "../../__mocks__/pigpio"; - -jest.useFakeTimers(); - -const digitalSpy = jest.spyOn(Gpio.prototype, "digitalWrite"); -const pwmSpy = jest.spyOn(Gpio.prototype, "pwmWrite"); - -interface IDiode { - pins: [number, number, number]; - vals: [boolean, boolean, boolean] | [number, number, number]; - invert: boolean; - writeAsArray: boolean; -} -class DigitalDiode implements IDiode { - pins: [number, number, number]; - vals: [boolean, boolean, boolean]; - invert: boolean; - writeAsArray: boolean; - - constructor( - pin1: number, - pin2: number, - pin3: number, - val1: boolean, - val2: boolean, - val3: boolean, - invert: boolean, - writeAsArray: boolean - ) { - this.pins = [pin1, pin2, pin3]; - this.vals = [val1, val2, val3]; - this.invert = invert; - this.writeAsArray = writeAsArray; - } -} -class PwmDiode implements IDiode { - pins: [number, number, number]; - vals: [number, number, number]; - invert: boolean; - writeAsArray: boolean; - - constructor( - pin1: number, - pin2: number, - pin3: number, - val1: number, - val2: number, - val3: number, - invert: boolean, - writeAsArray: boolean - ) { - this.pins = [pin1, pin2, pin3]; - this.vals = [val1, val2, val3]; - this.invert = invert; - this.writeAsArray = writeAsArray; - } -} - -/** - * Helper function that allows for blackbox testing of writes to GPIO pins. - * @param diodes Array of LED diodes. - */ -function testBasicWrites(diodes: IDiode[]) { - // Count the total number of digital pins and pwm diodes. - let digitalDiodes = 0; - let pwmDiodes = 0; - for (const diode of diodes) { - if (diode instanceof PwmDiode) { - pwmDiodes++; - } else { - digitalDiodes++; - } - } - - // Call the write methods for each diode. - for (const diode of diodes) { - const lights = new Lights( - diode.pins[0], - diode.pins[1], - diode.pins[2], - diode.invert - ); - if (diode instanceof PwmDiode) { - if (diode.writeAsArray) { - lights.write(diode.vals); - } else { - lights.write(diode.vals[0], diode.vals[1], diode.vals[2]); - } - } else if (diode instanceof DigitalDiode) { - if (diode.writeAsArray) { - lights.write(diode.vals); - } else { - lights.write(diode.vals[0], diode.vals[1], diode.vals[2]); - } - } - } - // Confirm the correct methods have been called. Each will be called 3 times, - // one for each pin. - expect(digitalSpy).toHaveBeenCalledTimes(digitalDiodes * 3); - expect(pwmSpy).toHaveBeenCalledTimes(pwmDiodes * 3); - - // We don't know which of the three pins is written to first in each write() call, - // however we do know that all 3 of them will be called in succession. So, - // verify that each pin for each diode is written to with the correct value. - let pwmDiodesSeen = 0; - let digitalDiodesSeen = 0; - for (const diode of diodes) { - // Two different spies, so we must figure out which spy is necessary for this diode, - // and then get the index based off of how many other diodes previously used this spy. - let spy, spyIndex; - if (diode instanceof PwmDiode) { - spy = pwmSpy; - spyIndex = pwmDiodesSeen++ * 3; - } else { - spy = digitalSpy; - spyIndex = digitalDiodesSeen++ * 3; - } - let pinsSeen = 0; - // For each call to the spy (there's always 3 calls, corresponding to 3 pins) - for (let i = 0; i < 3; i++) { - // And for each pin on the diode - for (let j = 0; j < diode.pins.length; j++) { - // If this mock call index corresponds to the current diode pin - if ( - (spy.mock.instances[spyIndex + i] as any).pin === - diode.pins[j] - ) { - // Confirm that the value passed to this mock call matches the diode - // value that was passed to this function. Digital diodes written as a 1/0, - // but we store true/false in our tests. - if (diode instanceof DigitalDiode) { - // Invert expected output if necessary - let expected; - if (diode.invert) { - expected = diode.vals[j] ? 0 : 1; - } else { - expected = diode.vals[j] ? 1 : 0; - } - expect( - (spy.mock.calls[spyIndex + i] as any)[0] - ).toEqual(expected); - } else if (diode instanceof PwmDiode) { - // Invert expected output if necessary - let expected = diode.vals[j]; - if (diode.invert) { - expected = 255 - expected; - } - expect( - (spy.mock.calls[spyIndex + i] as any)[0] - ).toEqual(expected); - } - pinsSeen++; - break; - } - } - } - // For each diode, all 3 pins should have received a GPIO write. - expect(pinsSeen).toEqual(3); - } -} - -it("Does not allow pins less than 0.", () => { - expect(() => { - new Lights(0, 1, -2, false); - }).toThrow(Error); -}); - -it("Writes the correct values on digital writes.", () => { - testBasicWrites([ - new DigitalDiode(11, 12, 13, true, true, false, false, false), - new DigitalDiode(14, 15, 16, false, true, true, false, false), - new DigitalDiode(18, 19, 20, false, false, false, false, false), - ]); -}); - -it("Writes the correct values on PWM writes.", () => { - testBasicWrites([ - new PwmDiode(10, 100, 1353, 50, 150, 10, false, false), - new PwmDiode(7, 6, 4, 13, 123, 84, false, false), - new PwmDiode(31, 65, 1, 50, 63, 137, false, false), - ]); -}); - -it("Does not allow pin IDs less than 0.", () => { - expect(() => { - testBasicWrites([ - new PwmDiode(10, -100, 1353, 50, 150, 10, false, false), - ]); - }).toThrow(Error); -}); - -it("Does not allow PWM writes less than 0.", () => { - expect(() => { - testBasicWrites([ - new PwmDiode(10, 100, 1353, -50, 150, 10, false, false), - ]); - }).toThrow(Error); -}); - -it("Does not allow PWM writes greater than 255.", () => { - expect(() => { - testBasicWrites([ - new PwmDiode(10, 100, 1353, 50, 256, 10, false, false), - ]); - }).toThrow(Error); -}); - -it("Does not allow writes of different values to the same pin.", () => { - expect(() => { - testBasicWrites([ - new PwmDiode(10, 10, 1353, 50, 256, 10, false, false), - new DigitalDiode(10, 10, 1353, false, false, false, false, false), - ]); - }).toThrow(Error); -}); - -it("Accepts writes in the form of an array.", () => { - testBasicWrites([ - new DigitalDiode(10, 100, 1353, true, false, true, false, true), - new PwmDiode(10, 100, 1353, 50, 150, 10, false, true), - ]); -}); - -it("Properly inverts writes.", () => { - testBasicWrites([ - new PwmDiode(1, 2, 3, 50, 150, 10, true, true), - new PwmDiode(1, 2, 3, 0, 0, 0, true, true), - new PwmDiode(1, 2, 3, 255, 255, 255, true, true), - new DigitalDiode(1, 2, 3, true, true, true, true, true), - new DigitalDiode(1, 2, 3, false, false, true, true, false), - new DigitalDiode(1, 2, 3, true, true, false, true, false), - ]); -}); - -it("Does not allow digital writes to all three LEDs at the same time.", () => { - expect(() => { - testBasicWrites([ - new DigitalDiode(1, 2, 3, true, true, true, false, true), - ]); - }).toThrow(Error); - expect(() => { - testBasicWrites([ - new DigitalDiode(1, 2, 3, true, true, true, false, false), - ]); - }).toThrow(Error); - expect(() => { - testBasicWrites([ - new DigitalDiode(1, 2, 3, false, false, false, true, true), - ]); - }).toThrow(Error); - expect(() => { - testBasicWrites([ - new DigitalDiode(1, 2, 3, false, false, false, true, false), - ]); - }).toThrow(Error); -}); - -it("Does not allow mixed writes of PWM and digital.", () => { - expect(() => { - const lights = new Lights(1, 2, 3, false); - // @ts-ignore Intentional test - lights.write(false, 100, true); - }).toThrow(Error); -}); - -it("Turns off all pins with off().", () => { - const lights = new Lights(1, 2, 3, false); - lights.write(25, 66, 127); - expect(digitalSpy).toHaveBeenCalledTimes(0); - lights.off(); - expect(digitalSpy).toHaveBeenCalledTimes(3); - expect((digitalSpy.mock.calls[0] as any)[0]).toEqual(0); - expect((digitalSpy.mock.calls[1] as any)[0]).toEqual(0); - expect((digitalSpy.mock.calls[2] as any)[0]).toEqual(0); - lights.write(true, false, true); - lights.off(); - expect(digitalSpy).toHaveBeenCalledTimes(9); - expect((digitalSpy.mock.calls[6] as any)[0]).toEqual(0); - expect((digitalSpy.mock.calls[7] as any)[0]).toEqual(0); - expect((digitalSpy.mock.calls[8] as any)[0]).toEqual(0); - lights.write([false, false, true]); - lights.off(); - expect(digitalSpy).toHaveBeenCalledTimes(15); - expect((digitalSpy.mock.calls[12] as any)[0]).toEqual(0); - expect((digitalSpy.mock.calls[13] as any)[0]).toEqual(0); - expect((digitalSpy.mock.calls[14] as any)[0]).toEqual(0); - lights.write([11, 22, 33]); - lights.off(); - expect(digitalSpy).toHaveBeenCalledTimes(18); - expect((digitalSpy.mock.calls[15] as any)[0]).toEqual(0); - expect((digitalSpy.mock.calls[16] as any)[0]).toEqual(0); - expect((digitalSpy.mock.calls[17] as any)[0]).toEqual(0); -}); - -it("Enables PWM flashing with startFlashing()", () => { - const lights = new Lights(1, 2, 3, false); - lights.startFlashing(255, 70, 0, 500); - setTimeout(() => { - expect(pwmSpy).toHaveBeenCalledTimes(12); - lights.stopFlashing(); - }, 2000); - jest.runAllTimers(); -}); - -it("Enables digital flashing with startFlashing()", () => { - const lights = new Lights(1, 2, 3, false); - lights.startFlashing(true, false, false, 500); - setTimeout(() => { - expect(digitalSpy).toHaveBeenCalledTimes(12); - lights.stopFlashing(); - }, 2000); - jest.runAllTimers(); -}); - -it("Stops flashing with stopFlashing()", () => { - const digitalLights = new Lights(1, 2, 3, false); - const pwmLights = new Lights(1, 2, 3, false); - digitalLights.startFlashing(true, false, false, 500); - pwmLights.startFlashing(100, 200, 255, 250); - setTimeout(() => { - expect(digitalSpy).toHaveBeenCalledTimes(12); - expect(pwmSpy).toHaveBeenCalledTimes(24); - digitalLights.stopFlashing(); - pwmLights.stopFlashing(); - setTimeout(() => { - expect(digitalSpy).toHaveBeenCalledTimes(12); - expect(pwmSpy).toHaveBeenCalledTimes(24); - }, 2000); - }, 2000); - jest.runAllTimers(); -}); - -it("Stops flashing with off()", () => { - const digitalLights = new Lights(1, 2, 3, false); - const pwmLights = new Lights(1, 2, 3, false); - digitalLights.startFlashing(true, false, false, 500); - pwmLights.startFlashing(100, 200, 255, 250); - setTimeout(() => { - expect(digitalSpy).toHaveBeenCalledTimes(12); - expect(pwmSpy).toHaveBeenCalledTimes(24); - digitalLights.off(); - pwmLights.off(); - setTimeout(() => { - expect(digitalSpy).toHaveBeenCalledTimes(18); - expect(pwmSpy).toHaveBeenCalledTimes(24); - }, 2000); - }, 2000); - jest.runAllTimers(); -}); - -it("Does not allow flashing at a frequency less than or equal to 0.", () => { - const digitalLights = new Lights(1, 2, 3, false); - const pwmLights = new Lights(1, 2, 3, false); - expect(() => { - digitalLights.startFlashing(true, false, false, 0); - }).toThrow(Error); - expect(() => { - pwmLights.startFlashing(100, 200, 255, 0); - }).toThrow(Error); - expect(() => { - digitalLights.startFlashing(true, false, false, -10); - }).toThrow(Error); - expect(() => { - pwmLights.startFlashing(100, 200, 255, -10); - }).toThrow(Error); - setTimeout(() => { - expect(digitalSpy).toHaveBeenCalledTimes(0); - expect(pwmSpy).toHaveBeenCalledTimes(0); - }, 2000); - jest.runAllTimers(); -}); - -it("Does not allow flashing values less than 0.", () => { - const lights = new Lights(1, 2, 3, false); - expect(() => { - lights.startFlashing(100, -200, 255, 100); - }).toThrow(Error); - setTimeout(() => { - expect(digitalSpy).toHaveBeenCalledTimes(0); - expect(pwmSpy).toHaveBeenCalledTimes(0); - }, 500); - jest.runAllTimers(); -}); - -it("Does not allow flashing values greater than 255.", () => { - const lights = new Lights(1, 2, 3, false); - expect(() => { - lights.startFlashing(100, 200, 256, 100); - }).toThrow(Error); - setTimeout(() => { - expect(digitalSpy).toHaveBeenCalledTimes(0); - expect(pwmSpy).toHaveBeenCalledTimes(0); - }, 500); - jest.runAllTimers(); -}); diff --git a/src/classes/Lights.ts b/src/classes/Lights.ts deleted file mode 100644 index fa61c73..0000000 --- a/src/classes/Lights.ts +++ /dev/null @@ -1,272 +0,0 @@ -import Blinker from "./Blinker"; -import { Gpio } from "pigpio"; - -type ColorArray = [number, number, number] | [boolean, boolean, boolean]; - -/** - * Raspberry Pi GPIO RGB light API. - */ -class Lights { - /** - * GPIO pin connected to the red LED - * @private - */ - private redLight: Gpio; - /** - * GPIO pin connected to the green LED - * @private - */ - private greenLight: Gpio; - /** - * GPIO pin connected to the blue LED - * @private - */ - private blueLight: Gpio; - /** - * Whether it's necessary to invert the colors before they are displayed in PWM writes. - * For example, when set to true, 255 is interpreted as off while 0 is interpreted as on. - * @private - * @readonly - */ - private readonly invert: boolean; - /** - * Last blinker to be used. Null if blinker has not yet been used. Blinker may still be active. - * Thus, you should probably call {@link Blinker#stop} before altering this variable. - * @private - */ - private blinker: Blinker | null = null; - - /** - * Constructor - * @param redGpioPort {number} GPIO pin corresponding to the red LED - * @param greenGpioPort {number} GPIO pin corresponding to the green LED - * @param blueGpioPort {number} GPIO pin corresponding to the blue LED - * @param invert {boolean} Whether the signals to these lights should be inverted before - * writing. E.g., true = off and false = on. - */ - constructor( - redGpioPort: number, - greenGpioPort: number, - blueGpioPort: number, - invert: boolean - ) { - if ( - redGpioPort == greenGpioPort || - greenGpioPort == blueGpioPort || - redGpioPort == blueGpioPort - ) { - throw new Error("GPIO pins must all be unique."); - } - if (redGpioPort < 0 || greenGpioPort < 0 || blueGpioPort < 0) { - throw new Error("GPIO pins must be greater than or equal to 0."); - } - this.redLight = new Gpio(redGpioPort, { mode: Gpio.OUTPUT }); - this.greenLight = new Gpio(greenGpioPort, { mode: Gpio.OUTPUT }); - this.blueLight = new Gpio(blueGpioPort, { mode: Gpio.OUTPUT }); - this.invert = invert; - } - - /** - * Start flashing a given color at a given frequency. LED will continue to flash until - * the program stops or {@link stopFlashing} or {@link off} is called. {@link write} does - * NOT stop flashing. - * @param red {number} PWM value to flash for the red LED. - * Expected to be between 0 and 255 inclusively. - * @param green {number} PWM value to flash for the green LED. - * Expected to be between 0 and 255 inclusively. - * @param blue {number} PWM value to flash for the blue LED. - * Expected to be between 0 and 255 inclusively. - * @param frequency {number} Frequency in milliseconds to flash the LEDs at. - * Expected to be greater than or equal to 1. - * @throws Error if any of the three color values passed fall outside the range 0-255. - * @throws Error if the passed frequency is less than or equal to 0. - */ - public startFlashing( - red: number, - green: number, - blue: number, - frequency: number - ): void; - /** - * Start flashing a given color at a given frequency. LED will continue to flash until - * the program stops or {@link stopFlashing} or {@link off} is called. {@link write} does - * NOT stop flashing. - * @param red {boolean} Boolean digital write value for the red LED. - * @param green {boolean} Boolean digital write value for the green LED. - * @param blue {boolean} Boolean digital write value for the blue LED. - * @param frequency {number} Frequency in milliseconds to flash the LEDs at. - * Expected to be greater than or equal to 1. - * @throws Error if the passed frequency is less than or equal to 0. - */ - public startFlashing( - red: boolean, - green: boolean, - blue: boolean, - frequency: number - ): void; - public startFlashing( - red: number | boolean, - green: number | boolean, - blue: number | boolean, - frequency: number - ): void { - if (frequency <= 0) { - throw new Error( - "Flash frequency must be greater than or equal to 1." - ); - } - - if ( - typeof red === "boolean" && - typeof green === "boolean" && - typeof blue === "boolean" - ) { - this.stopFlashing(); - - this.blinker = new Blinker(frequency).subscribe( - (state: boolean) => { - if (state) { - this.write(red, blue, green); - } else { - this.write(false, false, false); - } - } - ); - } else if ( - typeof red === "number" && - typeof green === "number" && - typeof blue === "number" - ) { - if ( - red < 0 || - red > 255 || - green < 0 || - green > 255 || - blue < 0 || - blue > 255 - ) { - throw new Error( - "Color values passed to startFlashing() must be between 0 and 255 inclusively." - ); - } - this.stopFlashing(); - - this.blinker = new Blinker(frequency).subscribe( - (state: boolean) => { - if (state) { - this.write(red, green, blue); - } else { - this.write(0, 0, 0); - } - } - ); - } - } - - /** - * Stop any flashing which is currently happening. If the Light is not currently flashing, - * nothing happens. - */ - public stopFlashing(): void { - this.blinker?.stop(); - } - - /** - * Turn off all three LEDs. Also disables any flashing. - * Accomplishes this by writing a digital off signal to all 3 LEDs. - */ - public off(): void { - this.stopFlashing(); - this.write(false, false, false); - } - - /** - * Write an on/off state to all three LEDs. - * @param rgb Array of 3 boolean states OR values between 0-255 to assign to the red, - * green, and blue LEDs respectively. - * @throws Error if an illegal combination of arguments is provided. - */ - public write(rgb: ColorArray): void; - /** - * Write an on/off state to all three LEDs. - * @param red Whether the red LED should be turned on. - * @param green Whether the green LED should be turned on. - * @param blue Whether the blue LED should be turned on. - * @throws Error if an illegal combination of arguments is provided. - */ - public write(red: boolean, green: boolean, blue: boolean): void; - /** - * Write a PWM state to all three LEDs. - * @param red Red LED value. Expected to be between 0 and 255, inclusive. - * @param green Green LED value. Expected to be between 0 and 255, inclusive. - * @param blue Blue LED value. Expected to be between 0 and 255, inclusive. - * @throws Error if provided a color value outside the range 0-255. - * @throws Error if an illegal combination of arguments is provided. - */ - public write(red: number, green: number, blue: number): void; - public write( - red: number | boolean | ColorArray, - green?: number | boolean, - blue?: number | boolean - ): void { - if (Array.isArray(red)) { - green = red[1]; - blue = red[2]; - red = red[0]; - } - - if ( - typeof red === "number" && - typeof green === "number" && - typeof blue === "number" - ) { - if ( - red < 0 || - red > 255 || - green < 0 || - green > 255 || - blue < 0 || - blue > 255 - ) { - throw new Error( - "Colors must be between 0 and 255 (inclusively) or boolean values." - ); - } - if (this.invert) { - red = 255 - red; - green = 255 - green; - blue = 255 - blue; - } - - this.redLight.pwmWrite(red); - this.greenLight.pwmWrite(green); - this.blueLight.pwmWrite(blue); - } else if ( - typeof red === "boolean" && - typeof green === "boolean" && - typeof blue === "boolean" - ) { - if (this.invert) { - red = !red; - green = !green; - blue = !blue; - } - - if (red && green && blue) { - throw new Error( - "You may not digital write to all three LEDs at the same time." - ); - } - - this.redLight.digitalWrite(red ? 1 : 0); - this.greenLight.digitalWrite(green ? 1 : 0); - this.blueLight.digitalWrite(blue ? 1 : 0); - } else { - throw new Error( - "Arguments to Lights.write() must be either all numbers or all booleans." - ); - } - } -} - -export default Lights; diff --git a/src/index.ts b/src/index.ts index 3b81a74..886ac4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,12 @@ -import Lights from "./classes/Lights"; -import Blinker from "./classes/Blinker"; -export { Lights, Blinker }; +import Animation from "./classes/Animation"; +import Curves from "./classes/Curves"; +import LED from "./classes/LED"; +import LEDArray from "./classes/LEDArray"; + +/* + +No logic should go in this file. Only direct imports and exports. + +*/ + +export { Animation, Curves, LED, LEDArray };