diff --git a/.gitignore b/.gitignore index 3c25e1e..dc9966e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ *.log +*.swp +*.swo diff --git a/examples/balls/Ball.ts b/examples/balls/Ball.ts index 7ca6b65..8e8a4ee 100644 --- a/examples/balls/Ball.ts +++ b/examples/balls/Ball.ts @@ -1,4 +1,4 @@ -import { Rect } from '@laser-dac/draw'; +import { Circle } from '@laser-dac/draw'; import Victor = require('victor'); interface BallOptions { @@ -12,8 +12,8 @@ export class Ball { x: number; y: number; vector: Victor = new Victor( - (Math.random() - 0.5) / 200, - (Math.random() - 0.5) / 200 + (Math.random() - 0.5) / 2, + (Math.random() - 0.5) / 2 ); constructor(options: BallOptions) { @@ -22,9 +22,9 @@ export class Ball { this.radius = options.radius; } - update = () => { - this.x += this.vector.x; - this.y += this.vector.y; + update = (timeStep: number) => { + this.x += this.vector.x * timeStep; + this.y += this.vector.y * timeStep; this.updateBounds(); }; @@ -32,31 +32,34 @@ export class Ball { updateBounds = () => { // Left bound. if (this.x - this.radius <= 0) { + this.x = this.radius; this.vector.invertX(); } // Right bound. - if (this.x + this.radius >= 1) { + if (this.x + this.radius > 1) { + this.x = 1 - this.radius; this.vector.invertX(); } // Top bound. if (this.y - this.radius <= 0) { + this.y = this.radius; this.vector.invertY(); } // Bottom bound. - if (this.y + this.radius >= 1) { + if (this.y + this.radius > 1) { + this.y = 1 - this.radius; this.vector.invertY(); } }; draw = () => - new Rect({ - x: this.x - this.radius, - y: this.y - this.radius, - width: this.radius * 2, - height: this.radius * 2, + new Circle({ + x: this.x, + y: this.y, + radius: this.radius, color: [0, 1, 0] }); } diff --git a/examples/balls/index.ts b/examples/balls/index.ts index d75dcb8..1bc6c52 100644 --- a/examples/balls/index.ts +++ b/examples/balls/index.ts @@ -1,6 +1,5 @@ import { DAC } from '@laser-dac/core'; -import { Simulator } from '@laser-dac/simulator'; -import { EtherDream } from '@laser-dac/ether-dream'; +import { getDevices } from '@laser-dac/device-selector'; import { Scene, Rect } from '@laser-dac/draw'; import { Ball } from './Ball'; @@ -8,18 +7,15 @@ const NUMBER_OF_BALLS = 4; (async () => { const dac = new DAC(); - dac.use(new Simulator()); - if (process.env.DEVICE) { - dac.use(new EtherDream()); - } + dac.useAll(await getDevices()); await dac.start(); const balls: Ball[] = []; for (let i = 0; i < NUMBER_OF_BALLS; i++) { balls.push( new Ball({ - x: 0.5, - y: 0.5, + x: Math.random(), + y: Math.random(), radius: Math.random() / 5 + 0.05 }) ); @@ -28,6 +24,8 @@ const NUMBER_OF_BALLS = 4; const scene = new Scene({ resolution: 70 }); + + let lastTime = Date.now(); function renderFrame() { const bounds = new Rect({ x: 0, @@ -38,10 +36,13 @@ const NUMBER_OF_BALLS = 4; }); scene.add(bounds); + const curTime = Date.now(); + const timeStep = (curTime - lastTime) / 1000; balls.forEach(ball => { - ball.update(); + ball.update(timeStep); scene.add(ball.draw()); }); + lastTime = curTime; } scene.start(renderFrame); diff --git a/examples/calibration/index.ts b/examples/calibration/index.ts new file mode 100755 index 0000000..3211c42 --- /dev/null +++ b/examples/calibration/index.ts @@ -0,0 +1,54 @@ +import * as express from 'express'; +import { Server as WebSocketServer } from 'ws'; +import * as http from 'http'; +import * as path from 'path'; +import { Renderer } from './renderer'; + +const PORT = 8321; + +const renderer = new Renderer(); + +const server = http.createServer(); +const app = express(); +app.use(express.static(path.join(__dirname, '/public'))); +const wss = new WebSocketServer({ server }); + +server.on('request', app); +server.listen(PORT, function() { + console.log(`Started Square Interactive demo on http://localhost:${PORT}`); +}); + +wss.on('connection', function connection(ws, req) { + const id = req.url!.replace('/?id=', ''); + + function send(type: string, data: any) { + ws.send(JSON.stringify({ type, data })); + } + + function sendSettings() { + send("SettingsToClient", renderer.getParams()); + } + + function sendStats() { + send("stats", renderer.getStats()); + }; + + const statsInterval = setInterval(sendStats, 300); + sendSettings(); + sendStats(); + + ws.on('message', function incoming(message) { + // TODO: should handle illegal JSON so the server can't crash... + const payload = JSON.parse(message as string); + if (payload.type === 'UPDATEPARAMS') { + console.log('Updating Parameters', payload.data); + renderer.updateParams(payload.data); + } else { + console.log('Unknown websocket message', payload); + } + }); + + ws.on('close', function incoming(message) { + clearInterval(statsInterval); + }); +}); diff --git a/examples/calibration/public/index.html b/examples/calibration/public/index.html new file mode 100755 index 0000000..4ee1698 --- /dev/null +++ b/examples/calibration/public/index.html @@ -0,0 +1,19 @@ + + + + Laser Calibration + + + +
+
+
+
+
+ +
+

+        
+    
+
diff --git a/examples/calibration/public/index.js b/examples/calibration/public/index.js
new file mode 100755
index 0000000..8880c3d
--- /dev/null
+++ b/examples/calibration/public/index.js
@@ -0,0 +1,62 @@
+import WebsocketClient from './websocket.js';
+import uuid from './uuid.js';
+
+const host = window.document.location.host.replace(/:.*/, '');
+const ws = new WebsocketClient();
+
+const ppsInput = document.getElementById('pps');
+const resolutionInput = document.getElementById('resolution');
+const blankingPointsInput = document.getElementById('blankingPoints');
+const maxWaitPointsInput = document.getElementById('maxWaitPoints');
+
+const statsTable = document.getElementById('stats-table');
+
+function sendMessage(msg) {
+  console.log("Sending:", msg.data);
+  ws.send(JSON.stringify(msg));
+}
+
+function sendFormValues() {
+  sendMessage({
+    type: 'UPDATEPARAMS',
+    data: {
+      pps: parseInt(ppsInput.value, 10),
+      resolution: parseInt(resolutionInput.value, 10),
+      blankingPoints: parseInt(blankingPointsInput.value, 10),
+      maxWaitPoints: parseInt(maxWaitPointsInput.value, 10)
+    }
+  });
+}
+
+ws.onopen = function() {
+  console.log('Websocket connection opened.');
+
+  ppsInput.addEventListener('input', sendFormValues);
+  resolutionInput.addEventListener('input', sendFormValues);
+  blankingPointsInput.addEventListener('input', sendFormValues);
+  maxWaitPointsInput.addEventListener('input', sendFormValues);
+};
+
+function displayStats(stats) {
+  statsTable.innerText = JSON.stringify(stats, null, 2);
+}
+
+function updateSettings(settings) {
+  ppsInput.value = settings.pps;
+  resolutionInput.value = settings.resolution;
+  blankingPointsInput.value = settings.blankingPoints;
+  maxWaitPointsInput.value = settings.maxWaitPoints;
+}
+
+ws.onmessage = function(ev) {
+  const message = JSON.parse(ev.data);
+  switch (message.type) {
+    case 'stats': displayStats(message.data); break;
+    case 'SettingsToClient': updateSettings(message.data); break;
+    default: console.error("Unknown message type: " + message.type, message)
+  }
+};
+
+const uniqueId = uuid();
+
+ws.open(`ws://${host}:8321?id=${uniqueId}`);
diff --git a/examples/calibration/public/uuid.js b/examples/calibration/public/uuid.js
new file mode 100755
index 0000000..2a1883f
--- /dev/null
+++ b/examples/calibration/public/uuid.js
@@ -0,0 +1,24 @@
+function guid() {
+  return (
+    s4() +
+    s4() +
+    '-' +
+    s4() +
+    '-' +
+    s4() +
+    '-' +
+    s4() +
+    '-' +
+    s4() +
+    s4() +
+    s4()
+  );
+}
+
+function s4() {
+  return Math.floor((1 + Math.random()) * 0x10000)
+    .toString(16)
+    .substring(1);
+}
+
+export default guid;
diff --git a/examples/calibration/public/websocket.js b/examples/calibration/public/websocket.js
new file mode 100755
index 0000000..fad50dc
--- /dev/null
+++ b/examples/calibration/public/websocket.js
@@ -0,0 +1,62 @@
+function WebSocketClient() {
+  this.autoReconnectInterval = 1 * 1000; // ms
+}
+WebSocketClient.prototype.open = function(url) {
+  this.url = url;
+  this.instance = new WebSocket(this.url);
+  this.instance.onopen = this.onopen;
+  this.instance.onmessage = event => {
+    this.onmessage(event);
+  };
+  this.instance.onclose = e => {
+    switch (e.code) {
+      case 1000: // CLOSE_NORMAL
+        console.log('WebSocket: closed');
+        break;
+      default:
+        // Abnormal closure
+        this.reconnect(e);
+        break;
+    }
+    this.onclose(e);
+  };
+  this.instance.onerror = e => {
+    switch (e.code) {
+      case 'ECONNREFUSED':
+        this.reconnect(e);
+        break;
+      default:
+        this.onerror(e);
+        break;
+    }
+  };
+};
+WebSocketClient.prototype.send = function(data, option) {
+  try {
+    this.instance.send(data, option);
+  } catch (e) {
+    console.warn('Failed sending websocket msg', e);
+  }
+};
+WebSocketClient.prototype.reconnect = function(e) {
+  console.log(`WebSocketClient: retry in ${this.autoReconnectInterval}ms`, e);
+  var that = this;
+  setTimeout(function() {
+    console.log('WebSocketClient: reconnecting...');
+    that.open(that.url);
+  }, this.autoReconnectInterval);
+};
+WebSocketClient.prototype.onopen = function(e) {
+  console.log('WebSocketClient: open', arguments);
+};
+WebSocketClient.prototype.onmessage = function(e) {
+  console.log('WebSocketClient: message', arguments);
+};
+WebSocketClient.prototype.onerror = function(e) {
+  console.log('WebSocketClient: error', arguments);
+};
+WebSocketClient.prototype.onclose = function(e) {
+  console.log('WebSocketClient: closed', arguments);
+};
+
+export default WebSocketClient;
diff --git a/examples/calibration/renderer.ts b/examples/calibration/renderer.ts
new file mode 100755
index 0000000..b1a1232
--- /dev/null
+++ b/examples/calibration/renderer.ts
@@ -0,0 +1,93 @@
+import { DAC } from '@laser-dac/core';
+import { getDevices } from '@laser-dac/device-selector';
+import { Circle, Scene, Rect, loadIldaFile, Ilda } from '@laser-dac/draw';
+import * as path from 'path';
+
+interface IClient {
+  x: number;
+  y: number;
+  id: string;
+}
+
+export class Renderer {
+  pps = 25000;
+  resolution = 70;
+  blankingPoints = 24;
+  maxWaitPoints = 10;
+
+  dac: DAC;
+  scene: Scene;
+
+  constructor() {
+    this.dac = new DAC();
+
+    this.scene = new Scene({
+      resolution: this.resolution,
+      blankingPoints: this.blankingPoints,
+      maxWaitPoints: this.maxWaitPoints,
+    });
+
+    this.start();
+  }
+
+  getStats() {
+    return this.dac.getStats();
+  }
+
+  updateParams(data: any) {
+    this.pps = data.pps;
+    this.dac.setPointsRate(this.pps);
+    this.resolution = data.resolution;
+    this.blankingPoints = data.blankingPoints;
+    this.maxWaitPoints = data.maxWaitPoints;
+
+    this.scene.setOptions({
+      resolution: this.resolution,
+      blankingPoints: this.blankingPoints,
+      maxWaitPoints: this.maxWaitPoints,
+    });
+  }
+
+  getParams() {
+    return {
+      pps: this.pps,
+      resolution: this.resolution,
+      blankingPoints: this.blankingPoints,
+      maxWaitPoints: this.maxWaitPoints,
+    }
+  }
+
+  removeClient(id: string) {
+    const index = this.activeClients.findIndex(client => client.id === id);
+    if (index >= 0) {
+      this.activeClients.splice(index, 1);
+    }
+  }
+
+  renderTestPattern() {
+    const bounds = new Rect({
+      x: 0,
+      y: 0,
+      width: 1,
+      height: 1,
+      color: [1, 1, 1]
+    });
+    this.scene.add(bounds);
+
+    const circle = new Circle({
+      x: 0.5,
+      y: 0.5,
+      radius: 0.25,
+      color: [1, 1, 1]
+    });
+    this.scene.add(circle);
+  }
+
+  async start() {
+    this.dac.useAll(await getDevices());
+    await this.dac.start();
+
+    this.scene.start(this.renderTestPattern.bind(this), 60);
+    this.dac.stream(this.scene, this.pps);
+  }
+}
diff --git a/examples/font/index.ts b/examples/font/index.ts
index 3c0cd46..a172261 100644
--- a/examples/font/index.ts
+++ b/examples/font/index.ts
@@ -1,6 +1,5 @@
 import { DAC } from '@laser-dac/core';
-import { Simulator } from '@laser-dac/simulator';
-import { EtherDream } from '@laser-dac/ether-dream';
+import { getDevices } from '@laser-dac/device-selector';
 import { Scene, HersheyFont, loadHersheyFont, Timeline } from '@laser-dac/draw';
 import * as path from 'path';
 
@@ -38,10 +37,7 @@ const textAnimation = new Timeline({
 
 (async () => {
   const dac = new DAC();
-  dac.use(new Simulator());
-  if (process.env.DEVICE) {
-    dac.use(new EtherDream());
-  }
+  dac.useAll(await getDevices());
   await dac.start();
 
   const scene = new Scene();
diff --git a/examples/ilda-animation/index.ts b/examples/ilda-animation/index.ts
index f97fcf8..648a36a 100644
--- a/examples/ilda-animation/index.ts
+++ b/examples/ilda-animation/index.ts
@@ -1,6 +1,5 @@
 import { DAC } from '@laser-dac/core';
-import { Simulator } from '@laser-dac/simulator';
-import { EtherDream } from '@laser-dac/ether-dream';
+import { getDevices } from '@laser-dac/device-selector';
 import { Scene, Ilda, loadIldaFile } from '@laser-dac/draw';
 import * as path from 'path';
 
@@ -8,10 +7,7 @@ const boeing = loadIldaFile(path.resolve(__dirname, './boeing.ild'));
 
 (async () => {
   const dac = new DAC();
-  dac.use(new Simulator());
-  if (process.env.DEVICE) {
-    dac.use(new EtherDream());
-  }
+  dac.useAll(await getDevices());
   await dac.start();
 
   const scene = new Scene();
diff --git a/examples/pong/renderer.ts b/examples/pong/renderer.ts
index 81d3e97..a77e5bd 100644
--- a/examples/pong/renderer.ts
+++ b/examples/pong/renderer.ts
@@ -1,6 +1,5 @@
 import { DAC } from '@laser-dac/core';
-import { Simulator } from '@laser-dac/simulator';
-import { EtherDream } from '@laser-dac/ether-dream';
+import { getDevices } from '@laser-dac/device-selector';
 import { Scene, Rect } from '@laser-dac/draw';
 import { Player } from './Player';
 import { Ball } from './Ball';
@@ -27,10 +26,7 @@ export class Renderer {
 
   async start() {
     const dac = new DAC();
-    dac.use(new Simulator());
-    if (process.env.DEVICE) {
-      dac.use(new EtherDream());
-    }
+    dac.useAll(await getDevices());
     await dac.start();
 
     const ball = new Ball({
diff --git a/examples/square-interactive/renderer.ts b/examples/square-interactive/renderer.ts
index c71ddf1..495761c 100755
--- a/examples/square-interactive/renderer.ts
+++ b/examples/square-interactive/renderer.ts
@@ -1,6 +1,5 @@
 import { DAC } from '@laser-dac/core';
-import { Simulator } from '@laser-dac/simulator';
-import { EtherDream } from '@laser-dac/ether-dream';
+import { getDevices } from '@laser-dac/device-selector';
 import { Scene, Rect, loadIldaFile, Ilda } from '@laser-dac/draw';
 import * as path from 'path';
 
@@ -48,10 +47,7 @@ export class Renderer {
 
   async start() {
     const dac = new DAC();
-    dac.use(new Simulator());
-    if (process.env.DEVICE) {
-      dac.use(new EtherDream());
-    }
+    dac.useAll(await getDevices());
     await dac.start();
 
     const scene = new Scene();
diff --git a/examples/static-shapes/index.ts b/examples/static-shapes/index.ts
index 5301ea3..b0ab64e 100644
--- a/examples/static-shapes/index.ts
+++ b/examples/static-shapes/index.ts
@@ -1,14 +1,10 @@
 import { DAC } from '@laser-dac/core';
-import { Simulator } from '@laser-dac/simulator';
-import { Helios } from '@laser-dac/helios';
+import { getDevices } from '@laser-dac/device-selector';
 import { Scene, Rect, Path, Line } from '@laser-dac/draw';
 
 (async () => {
   const dac = new DAC();
-  dac.use(new Simulator());
-  if (process.env.DEVICE) {
-    dac.use(new Helios());
-  }
+  dac.useAll(await getDevices());
   await dac.start();
 
   const scene = new Scene({
diff --git a/examples/svg-path/index.ts b/examples/svg-path/index.ts
index bd71d1a..54df4fe 100644
--- a/examples/svg-path/index.ts
+++ b/examples/svg-path/index.ts
@@ -1,6 +1,5 @@
 import { DAC } from '@laser-dac/core';
-import { Simulator } from '@laser-dac/simulator';
-import { EtherDream } from '@laser-dac/ether-dream';
+import { getDevices } from '@laser-dac/device-selector';
 import { Scene, Svg, loadSvgFile } from '@laser-dac/draw';
 import * as path from 'path';
 
@@ -8,10 +7,8 @@ const logoFile = loadSvgFile(path.resolve(__dirname, './logo.svg'));
 
 (async () => {
   const dac = new DAC();
-  dac.use(new Simulator());
-  if (process.env.DEVICE) {
-    dac.use(new EtherDream());
-  }
+  dac.useAll(await getDevices());
+
   await dac.start();
 
   const scene = new Scene({
diff --git a/packages/beyond/src/BeyondLib.ts b/packages/beyond/src/BeyondLib.ts
index 7393a3e..405c82a 100644
--- a/packages/beyond/src/BeyondLib.ts
+++ b/packages/beyond/src/BeyondLib.ts
@@ -26,7 +26,10 @@ const libPath = path
   // dont'get placed inside the "app.asar" bundle, but instead get placed in a separate directory called "app.asar.unpacked"
   .replace('app.asar', 'app.asar.unpacked');
 
-const BeyondLib = ffi.Library(libPath, {
+// Even Windows 64-bit is called win32 here.
+const isSupported = process.platform === "win32";
+
+const BeyondLib = isSupported && ffi.Library(libPath, {
   ldbCreate: ['int', []],
   ldbDestroy: ['int', []],
   ldbBeyondExeReady: ['int', []],
diff --git a/packages/beyond/src/index.ts b/packages/beyond/src/index.ts
index b107e9d..8f73305 100644
--- a/packages/beyond/src/index.ts
+++ b/packages/beyond/src/index.ts
@@ -13,6 +13,9 @@ export class Beyond extends Device {
   private started = false;
 
   async start() {
+    if (!this.isSupported()) {
+      return false;
+    }
     this.stop();
     beyondLib.ldbCreate();
     this.started = true;
@@ -37,6 +40,10 @@ export class Beyond extends Device {
     }
   }
 
+  isSupported(): boolean {
+    return process.platform === "win32";
+  }
+
   private convertPoint(p: Point) {
     return {
       x: relativeToPosition(p.x),
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 142d35f..c2db7ce 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,6 +1,8 @@
 const DEFAULT_POINTS_RATE = 30000;
 const DEFAULT_FPS = 30;
 
+const shutdownCallbacks: {(): void;}[] = [];
+
 export interface Point {
   x: number;
   y: number;
@@ -14,9 +16,30 @@ export interface Scene {
 }
 
 export abstract class Device {
+  protected pointsRate: number = 30000;
+
   abstract start(): Promise;
   abstract stop(): void;
   abstract stream(scene: Scene, pointsRate: number, fps: number): void;
+  // This is for any last-minute synchronous cleanup so the laser doesn't
+  // stay on after the program stops.
+  onShutdownSync(): void {};
+
+  isSupported(): boolean {
+    return true;
+  }
+
+  setPointsRate(pointsRate: number) {
+    this.pointsRate = pointsRate;
+  }
+
+  getPointsRate(): number {
+    return this.pointsRate;
+  }
+
+  getStats(): Object {
+    return {};
+  }
 }
 
 export class DAC {
@@ -24,6 +47,11 @@ export class DAC {
 
   use(device: Device) {
     this.devices.push(device);
+    shutdownCallbacks.push(device.onShutdownSync);
+  }
+
+  useAll(devices: Device[]) {
+    devices.forEach((device) => this.use(device));
   }
 
   remove(device: Device) {
@@ -58,4 +86,61 @@ export class DAC {
       device.stream(scene, pointsRate, fps);
     }
   }
+
+  setPointsRate(pointsRate: number) {
+    this.devices.forEach((device) => device.setPointsRate(pointsRate));
+  }
+
+  getStats(): Object {
+    const allDeviceStats = this.devices.reduce((stats, device) => {
+      const deviceStats = device.getStats();
+      if (deviceStats) {
+        stats[device.constructor.name] = deviceStats;
+      }
+      return stats;
+    }, {});
+
+    return {
+      devices: allDeviceStats
+    };
+  }
+}
+
+function exitHandler(options: any, exitCode: any) {
+  if (options.cleanup) {
+    console.info("Cleaning up");
+    shutdownCallbacks.forEach((fn) => fn());
+  }
+
+  if (exitCode || exitCode === 0) {
+    console.info(exitCode);
+  }
+
+  if (options.exit) {
+    process.exit();
+  }
 }
+
+// Based on an answer from
+// https://stackoverflow.com/questions/14031763/doing-a-cleanup-action-just-before-node-js-exits
+function gracefulShutdown() {
+  // Prevent the program from closing instantly.
+  process.stdin.resume();
+
+  // Do something when app is closing.
+  process.on('exit', exitHandler.bind(null, {cleanup: true}));
+
+  // Catch ctrl+c event.
+  process.on('SIGINT', exitHandler.bind(null, {exit: true}));
+
+  process.on('SIGTERM', exitHandler.bind(null, {exit: true}));
+
+  // Catch "kill pid" (for example: nodemon restart).
+  process.on('SIGUSR1', exitHandler.bind(null, {exit: true}));
+  process.on('SIGUSR2', exitHandler.bind(null, {exit: true}));
+
+  // Catch uncaught exceptions.
+  process.on('uncaughtException', exitHandler.bind(null, {exit: true}));
+}
+
+gracefulShutdown();
diff --git a/packages/device-selector/README.md b/packages/device-selector/README.md
new file mode 100644
index 0000000..5d7e972
--- /dev/null
+++ b/packages/device-selector/README.md
@@ -0,0 +1,35 @@
+# @laser-dac/device-selector
+
+This package is an easy adapter for multiple devices.
+
+```
+yarn add @laser-dac/device-selector
+npm i @laser-dac/device-selector
+```
+
+## Usage
+
+```js
+import { DAC } from '@laser-dac/core';
+import { getDevices } from '@laser-dac/device-selector';
+
+const dac = new DAC();
+// Automatically select a supported device and a Simulator.
+dac.useAll(await getDevices());
+const started = await dac.start();
+if (started) {
+  const pps = 30000; // points per second
+  const fps = 120; // frames per second
+  // draw a horizontal red line from left to right in the center
+  // @laser-dac/draw can help you with drawing points!
+  const scene = {
+    points: [
+      { x: 0.1, y: 0.5, r: 1, g: 0, b: 0 },
+      { x: 0.9, y: 0.5, r: 1, g: 0, b: 0 }
+    ]
+  };
+  dac.stream(scene, pps, fps);
+}
+```
+
+See for more usage info and examples the [Laser DAC project on GitHub](https://github.com/Volst/laser-dac).
diff --git a/packages/device-selector/package.json b/packages/device-selector/package.json
new file mode 100644
index 0000000..4d78c84
--- /dev/null
+++ b/packages/device-selector/package.json
@@ -0,0 +1,35 @@
+{
+  "name": "@laser-dac/device-selector",
+  "version": "0.4.1",
+  "description": "Device-selecting adapter for laser-dac",
+  "license": "MIT",
+  "author": "Sterling Hirsh ",
+  "repository": "Volst/laser-dac",
+  "keywords": [
+    "laser"
+  ],
+  "engines": {
+    "node": ">=8.0"
+  },
+  "main": "dist/index.js",
+  "files": [
+    "dist"
+  ],
+  "publishConfig": {
+    "access": "public"
+  },
+  "dependencies": {
+    "@laser-dac/core": "^0.4.1",
+    "@laser-dac/beyond": "^0.3.0",
+    "@laser-dac/easylase": "^0.2.0",
+    "@laser-dac/ether-dream": "^0.4.1",
+    "@laser-dac/helios": "^0.3.0",
+    "@laser-dac/laserdock": "^0.4.0",
+    "@laser-dac/simulator": "^0.3.3"
+  },
+  "scripts": {
+    "build": "rm -rf dist && tsc -p tsconfig.build.json",
+    "watch": "tsc -p tsconfig.build.json --watch",
+    "prepublishOnly": "npm run -s build"
+  }
+}
diff --git a/packages/device-selector/src/index.ts b/packages/device-selector/src/index.ts
new file mode 100644
index 0000000..f916c4d
--- /dev/null
+++ b/packages/device-selector/src/index.ts
@@ -0,0 +1,52 @@
+import { Device } from '@laser-dac/core';
+
+import { Simulator } from '@laser-dac/simulator';
+
+import { Beyond } from '@laser-dac/beyond';
+import { Easylase } from '@laser-dac/easylase';
+import { EtherDream } from '@laser-dac/ether-dream';
+import { Helios } from '@laser-dac/helios';
+import { Laserdock } from '@laser-dac/laserdock';
+
+const apis: Record = {
+  helios: Helios,
+  laserdock: Laserdock,
+  beyond: Beyond,
+  easylase: Easylase,
+  'ether-dream': EtherDream // This one is a bit slower to check.
+};
+
+// Get the first DAC that works.
+export async function autoSelect(): Promise {
+  console.log("Searching for DAC in " + Object.keys(apis).join(', '));
+  for (const api in apis) {
+    const dac = new apis[api]();
+    if (await dac.start()) {
+      console.log("Auto Selected DAC " + api);
+      await dac.stop();
+      return dac;
+    }
+  }
+  console.log("No DAC found");
+  return null;
+}
+
+// Get a Simulator and the first autodetected device.
+// TODO: Get multiple devices instead of just the first?
+export async function getDevices(): Promise {
+  const devices: Device[] = [new Simulator()];
+  const requestedDevice = process.env.DEVICE;
+
+  if (requestedDevice) {
+    if (requestedDevice in apis) {
+      devices.push(new apis[requestedDevice]());
+    } else {
+      const autoSelected = await autoSelect();
+      if (autoSelected) {
+        devices.push(autoSelected);
+      }
+    }
+  }
+
+  return devices;
+}
diff --git a/packages/device-selector/tsconfig.build.json b/packages/device-selector/tsconfig.build.json
new file mode 100644
index 0000000..5a2694a
--- /dev/null
+++ b/packages/device-selector/tsconfig.build.json
@@ -0,0 +1,8 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "dist"
+  },
+  "include": ["src/index.ts"]
+}
diff --git a/packages/draw/src/Circle.ts b/packages/draw/src/Circle.ts
index 70f63d7..fc5db94 100644
--- a/packages/draw/src/Circle.ts
+++ b/packages/draw/src/Circle.ts
@@ -1,7 +1,7 @@
 import { Shape } from './Shape';
 import { Color, Point } from './Point';
+import { SceneOptions } from './Scene';
 import { Wait } from './Wait';
-import { BLANKING_AMOUNT } from './constants';
 
 // TODO: I don't like these options being duplicated in the class
 // I have a feeling there is a better way...
@@ -26,15 +26,15 @@ export class Circle extends Shape {
     this.color = options.color;
   }
 
-  draw(resolution: number): Point[] {
+  draw(options: SceneOptions): Point[] {
     const circumference = 2.0 * this.radius * Math.PI;
-    const pointCount = Math.round(circumference * resolution);
+    const pointCount = Math.round(circumference * options.resolution);
 
     const points: Point[] = new Wait({
       x: this.x + this.radius,
       y: this.y,
       color: [0, 0, 0],
-      amount: BLANKING_AMOUNT
+      amount: options.blankingPoints
     }).draw();
 
     // If there are less then 3 points just return blank
@@ -62,7 +62,7 @@ export class Circle extends Shape {
         x: this.x + this.radius,
         y: this.y,
         color: [0, 0, 0],
-        amount: BLANKING_AMOUNT
+        amount: options.blankingPoints
       }).draw()
     );
   }
diff --git a/packages/draw/src/CubicCurve.ts b/packages/draw/src/CubicCurve.ts
index 8923954..66786b7 100644
--- a/packages/draw/src/CubicCurve.ts
+++ b/packages/draw/src/CubicCurve.ts
@@ -1,4 +1,5 @@
 import { Shape } from './Shape';
+import { SceneOptions } from './Scene';
 import { Point, Color } from './Point';
 import Bezier = require('bezier-js');
 
@@ -29,7 +30,7 @@ export class CubicCurve extends Shape {
     this.color = options.color;
   }
 
-  draw(resolution: number): Point[] {
+  draw(options: SceneOptions): Point[] {
     const curve = new Bezier(
       this.from.x,
       this.from.y,
@@ -42,7 +43,7 @@ export class CubicCurve extends Shape {
     );
 
     const distance = curve.length();
-    const steps = Math.round(distance * resolution);
+    const steps = Math.round(distance * options.resolution);
     // When there are less than two steps, we can skip making a curve altogether.
     if (steps < 2) {
       return [new Point(this.to.x, this.to.y, this.color)];
diff --git a/packages/draw/src/HersheyFont.ts b/packages/draw/src/HersheyFont.ts
index 069d1da..29bddf0 100644
--- a/packages/draw/src/HersheyFont.ts
+++ b/packages/draw/src/HersheyFont.ts
@@ -2,6 +2,7 @@ import * as fs from 'fs';
 import { Shape } from './Shape';
 import { Color, Point } from './Point';
 import { Path } from './Path';
+import { SceneOptions } from './Scene';
 
 interface HersheyCharacter {
   leftPos: number;
@@ -40,7 +41,7 @@ export class HersheyFont implements Shape {
     this.color = options.color;
   }
 
-  draw(resolution: number): Point[] {
+  draw(options: SceneOptions): Point[] {
     const paths: string[] = [];
     let xpos = 0;
 
@@ -73,7 +74,7 @@ export class HersheyFont implements Shape {
       y: this.y,
       color: this.color,
       path: paths.join(' ')
-    }).draw(resolution);
+    }).draw(options);
   }
 }
 
diff --git a/packages/draw/src/Ilda.ts b/packages/draw/src/Ilda.ts
index 683ee5f..0575a7f 100644
--- a/packages/draw/src/Ilda.ts
+++ b/packages/draw/src/Ilda.ts
@@ -2,6 +2,7 @@ import * as fs from 'fs';
 import { fromByteArray, Section } from '@laser-dac/ilda-reader';
 import { Shape } from './Shape';
 import { Color, Point } from './Point';
+import { isBlankingPoint } from './helpers';
 
 export const XY_RESOLUTION = 65535;
 
@@ -47,15 +48,16 @@ export class Ilda extends Shape {
     const color = this.color;
     const size = this.size || 1;
 
-    return section.points.map((point: Point) => {
-      const isBlank = point.r === 0 && point.g === 0 && point.b === 0;
-      return {
-        x: x + (1 - convertCoordinate(point.x)) * size,
-        y: y + convertCoordinate(point.y) * size,
-        r: color !== undefined && !isBlank ? color[0] : point.r,
-        g: color !== undefined && !isBlank ? color[1] : point.g,
-        b: color !== undefined && !isBlank ? color[2] : point.b
-      };
+    // These are Point objects from the ilda file,
+    // not laser-dac Point objects.
+    return section.points.map((point) => {
+      const pointColor: Color = color !== undefined && !isBlankingPoint(point)
+        ? color
+        : [point.r, point.g, point.b];
+      return new Point(
+        x + (1 - convertCoordinate(point.x)) * size,
+        y + convertCoordinate(point.y) * size,
+        pointColor);
     });
   }
 }
diff --git a/packages/draw/src/Line.ts b/packages/draw/src/Line.ts
index 1d7e8ea..e7854bd 100644
--- a/packages/draw/src/Line.ts
+++ b/packages/draw/src/Line.ts
@@ -1,7 +1,7 @@
 import { Shape } from './Shape';
 import { Point, Color } from './Point';
 import { Wait } from './Wait';
-import { BLANKING_AMOUNT, MAX_WAIT_AMOUNT } from './constants';
+import { SceneOptions } from './Scene';
 
 interface Coordinates {
   x: number;
@@ -25,8 +25,8 @@ export class Line extends Shape {
   blankBefore: boolean;
   blankAfter: boolean;
 
-  waitAmount: number;
-  blankingAmount: number;
+  waitAmount: number | undefined;
+  blankingAmount: number | undefined;
 
   constructor(options: LineOptions) {
     super();
@@ -36,16 +36,18 @@ export class Line extends Shape {
     this.blankBefore = options.blankBefore || false;
     this.blankAfter = options.blankAfter || false;
 
-    this.waitAmount = options.waitAmount || MAX_WAIT_AMOUNT;
-    this.blankingAmount = options.blankingAmount || BLANKING_AMOUNT;
+    this.waitAmount = options.waitAmount;
+    this.blankingAmount = options.blankingAmount;
   }
 
-  draw(resolution: number) {
+  draw(options: SceneOptions): Point[] {
     const distanceX = this.from.x - this.to.x;
     const distanceY = this.from.y - this.to.y;
     // Calculate distance using the Pythagorean theorem.
     const distance = Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2));
-    const steps = Math.round(distance * resolution);
+    const steps = Math.round(distance * options.resolution);
+    const waitAmount = this.waitAmount ?? options.maxWaitPoints;
+    const blankingAmount = this.blankingAmount ?? options.blankingPoints;
 
     let points: Point[] = [];
 
@@ -55,7 +57,7 @@ export class Line extends Shape {
         x: this.from.x,
         y: this.from.y,
         color: [0, 0, 0],
-        amount: this.blankingAmount
+        amount: blankingAmount
       }).draw();
     }
 
@@ -77,7 +79,7 @@ export class Line extends Shape {
           x: this.to.x,
           y: this.to.y,
           color: this.color,
-          amount: this.waitAmount / 2
+          amount: waitAmount / 2
         }).draw()
       ];
     }
diff --git a/packages/draw/src/Path.ts b/packages/draw/src/Path.ts
index 8541de3..1d50580 100644
--- a/packages/draw/src/Path.ts
+++ b/packages/draw/src/Path.ts
@@ -1,5 +1,6 @@
 import { Shape } from './Shape';
 import { Point, Color } from './Point';
+import { SceneOptions } from './Scene';
 import { Line } from './Line';
 import { Wait } from './Wait';
 import { CubicCurve } from './CubicCurve';
@@ -7,7 +8,6 @@ import { SVGPathData } from 'svg-pathdata';
 import { CommandM, SVGCommand } from 'svg-pathdata/lib/types';
 import { QuadCurve } from './QuadCurve';
 import { flatten } from './helpers';
-import { BLANKING_AMOUNT, MAX_WAIT_AMOUNT } from './constants';
 import arcToBezier = require('svg-arc-to-cubic-bezier');
 
 interface PathOptions {
@@ -31,8 +31,8 @@ export class Path extends Shape {
   // Works exactly like SVG path. Learn everything about it: https://css-tricks.com/svg-path-syntax-illustrated-guide/
   path: string;
 
-  waitAmount: number;
-  blankingAmount: number;
+  waitAmount: number | undefined;
+  blankingAmount: number | undefined;
 
   constructor(options: PathOptions) {
     super();
@@ -43,8 +43,8 @@ export class Path extends Shape {
     this.color = options.color;
     this.path = options.path;
 
-    this.waitAmount = options.waitAmount || MAX_WAIT_AMOUNT;
-    this.blankingAmount = options.blankingAmount || BLANKING_AMOUNT;
+    this.waitAmount = options.waitAmount;
+    this.blankingAmount = options.blankingAmount;
   }
 
   transformSize = (command: SVGCommand) => {
@@ -76,7 +76,7 @@ export class Path extends Shape {
     return command;
   };
 
-  draw(resolution: number) {
+  draw(options: SceneOptions): Point[] {
     const pathData = new SVGPathData(this.path)
       // Transforms relative commands to absolute so we don't have to implement relative commands at all!
       .toAbs()
@@ -94,6 +94,12 @@ export class Path extends Shape {
     let prevX = 0;
     let prevY = 0;
 
+    // Any shapes should be constructed with undefined blankingAmount
+    // if this.blankingAmount is undefined. This will cause drawing to
+    // use the value from SceneOptions at render time. The same goes
+    // for waitAmount.
+    const blankingAmount = this.blankingAmount ?? options.blankingPoints;
+
     const points = pathData.commands.reduce(
       (accumulator: Point[], command: SVGCommand) => {
         let commandPoints: Point[] = [];
@@ -103,7 +109,7 @@ export class Path extends Shape {
             commandPoints = new Wait({
               x: command.x,
               y: command.y,
-              amount: this.blankingAmount
+              amount: blankingAmount
             }).draw();
 
             lastMoveCommand = command;
@@ -122,7 +128,7 @@ export class Path extends Shape {
               color: this.color,
               waitAmount: this.waitAmount,
               blankingAmount: this.blankingAmount
-            }).draw(resolution);
+            }).draw(options);
             prevX = toX;
             prevY = toY;
             break;
@@ -140,7 +146,7 @@ export class Path extends Shape {
                 control: { x: command.x2, y: command.y2 }
               },
               color: this.color
-            }).draw(resolution);
+            }).draw(options);
             prevX = command.x;
             prevY = command.y;
             break;
@@ -151,7 +157,7 @@ export class Path extends Shape {
               to: { x: command.x, y: command.y },
               control: { x: command.x1, y: command.y1 },
               color: this.color
-            }).draw(resolution);
+            }).draw(options);
             prevX = command.x;
             prevY = command.y;
             break;
@@ -184,7 +190,7 @@ export class Path extends Shape {
                   control: { x: curve.x2, y: curve.y2 }
                 },
                 color: this.color
-              }).draw(resolution);
+              }).draw(options);
               curvePrevX = curve.x;
               curvePrevY = curve.y;
               Array.prototype.push.apply(commandPoints, curvePoints);
@@ -206,7 +212,7 @@ export class Path extends Shape {
               blankAfter: true,
               waitAmount: this.waitAmount,
               blankingAmount: this.blankingAmount
-            }).draw(resolution);
+            }).draw(options);
             prevX = lastMoveCommand.x;
             prevY = lastMoveCommand.y;
             break;
diff --git a/packages/draw/src/QuadCurve.ts b/packages/draw/src/QuadCurve.ts
index 5b1f599..1468c62 100644
--- a/packages/draw/src/QuadCurve.ts
+++ b/packages/draw/src/QuadCurve.ts
@@ -1,5 +1,6 @@
 import { Shape } from './Shape';
 import { Point, Color } from './Point';
+import { SceneOptions } from './Scene';
 import Bezier = require('bezier-js');
 
 interface BezierCoordinates {
@@ -28,7 +29,7 @@ export class QuadCurve extends Shape {
     this.color = options.color;
   }
 
-  draw(resolution: number): Point[] {
+  draw(options: SceneOptions): Point[] {
     const curve = new Bezier(
       this.from.x,
       this.from.y,
@@ -39,7 +40,7 @@ export class QuadCurve extends Shape {
     );
 
     const distance = curve.length();
-    const steps = Math.round(distance * resolution);
+    const steps = Math.round(distance * options.resolution);
     // When there are less than two steps, we can skip making a curve altogether.
     if (steps < 2) {
       return [new Point(this.to.x, this.to.y, this.color)];
diff --git a/packages/draw/src/Rect.ts b/packages/draw/src/Rect.ts
index b478c48..adc06e2 100644
--- a/packages/draw/src/Rect.ts
+++ b/packages/draw/src/Rect.ts
@@ -1,11 +1,8 @@
 import { Shape } from './Shape';
-import { Color } from './Point';
+import { SceneOptions } from './Scene';
+import { Color, Point } from './Point';
 import { Line } from './Line';
 import { Wait } from './Wait';
-import { MAX_WAIT_AMOUNT } from './constants';
-
-//
-const WAIT_AMOUNT = MAX_WAIT_AMOUNT / 2;
 
 // TODO: I don't like these options being duplicated in the class
 // I have a feeling there is a better way...
@@ -33,7 +30,8 @@ export class Rect extends Shape {
     this.color = options.color;
   }
 
-  draw(resolution: number) {
+  draw(options: SceneOptions): Point[] {
+    const waitAmount = options.maxWaitPoints / 2;
     return [
       // Top.
       ...new Line({
@@ -41,13 +39,13 @@ export class Rect extends Shape {
         to: { x: this.x + this.width, y: this.y },
         color: this.color,
         blankBefore: true
-      }).draw(resolution),
+      }).draw(options),
 
       ...new Wait({
         x: this.x + this.width,
         y: this.y,
         color: this.color,
-        amount: WAIT_AMOUNT
+        amount: waitAmount
       }).draw(),
 
       // Right.
@@ -55,13 +53,13 @@ export class Rect extends Shape {
         from: { x: this.x + this.width, y: this.y },
         to: { x: this.x + this.width, y: this.y + this.height },
         color: this.color
-      }).draw(resolution),
+      }).draw(options),
 
       ...new Wait({
         x: this.x + this.width,
         y: this.y + this.height,
         color: this.color,
-        amount: WAIT_AMOUNT
+        amount: waitAmount
       }).draw(),
 
       // Bottom.
@@ -69,13 +67,13 @@ export class Rect extends Shape {
         from: { x: this.x + this.width, y: this.y + this.height },
         to: { x: this.x, y: this.y + this.height },
         color: this.color
-      }).draw(resolution),
+      }).draw(options),
 
       ...new Wait({
         x: this.x,
         y: this.y + this.height,
         color: this.color,
-        amount: WAIT_AMOUNT
+        amount: waitAmount
       }).draw(),
 
       // Left.
@@ -84,7 +82,7 @@ export class Rect extends Shape {
         to: { x: this.x, y: this.y },
         color: this.color,
         blankAfter: true
-      }).draw(resolution)
+      }).draw(options)
     ];
   }
 }
diff --git a/packages/draw/src/Scene.ts b/packages/draw/src/Scene.ts
index 121d802..208908d 100644
--- a/packages/draw/src/Scene.ts
+++ b/packages/draw/src/Scene.ts
@@ -1,32 +1,41 @@
 import { Point } from './Point';
 import { Shape } from './Shape';
+import { clamp } from './helpers';
 
-// Frames per second; 30fps will be enough for most use-cases.
-const DEFAULT_FPS = 30;
-const DEFAULT_RESOLUTION = 500;
-
-interface SceneOptions {
+export interface SceneOptions {
   // This number sets the requested number of points from a perpendicular line drawn from one side of the projection to the other.
   // Decreasing this number will make drawing faster but less accurate, increasing will make it slower but more accurate.
-  resolution?: number;
+  resolution: number;
+  fps: number;
+  blankingPoints: number;
+  // This value determines how many times a Wait-point should be applied for the sharpest possible angles (360 degree angles).
+  maxWaitPoints: number;
 }
 
+const defaultOptions: SceneOptions = {
+  resolution: 500,
+  fps: 30,
+  blankingPoints: 24,
+  maxWaitPoints: 10,
+};
+
 type TransformFn = (points: Point[]) => Point[];
 
 export class Scene {
   points: Point[] = [];
-  resolution: number;
   interval?: NodeJS.Timer;
+  options: SceneOptions;
 
-  constructor(options?: SceneOptions) {
-    this.resolution = (options && options.resolution) || DEFAULT_RESOLUTION;
+  constructor(options: Partial = {}) {
+    this.options = Object.assign(defaultOptions, options);
   }
 
   add(shape: Shape, transformer?: TransformFn) {
-    let points = shape.draw(this.resolution);
+    let points = shape.draw(this.options);
     if (transformer) {
       points = transformer(points);
     }
+    points = clamp(points);
     this.points = this.points.concat(points);
   }
 
@@ -34,7 +43,7 @@ export class Scene {
     this.points = [];
   }
 
-  start(renderFrame: () => void, fps: number = DEFAULT_FPS) {
+  start(renderFrame: () => void, fps: number = this.options.fps) {
     const ms = 1000 / fps;
     this.interval = setInterval(() => {
       this.reset();
@@ -53,4 +62,8 @@ export class Scene {
       this.interval = undefined;
     }
   }
+
+  setOptions(options: Partial) {
+    this.options = Object.assign(this.options, options);
+  }
 }
diff --git a/packages/draw/src/Shape.ts b/packages/draw/src/Shape.ts
index b6c5e11..faa7da8 100644
--- a/packages/draw/src/Shape.ts
+++ b/packages/draw/src/Shape.ts
@@ -1,7 +1,8 @@
 import { Point } from './Point';
+import { SceneOptions } from './Scene';
 
 export class Shape {
-  draw(resolution: number): Point[] {
+  draw(options: SceneOptions): Point[] {
     return [];
   }
 }
diff --git a/packages/draw/src/Svg.ts b/packages/draw/src/Svg.ts
index 068a79b..2f66689 100644
--- a/packages/draw/src/Svg.ts
+++ b/packages/draw/src/Svg.ts
@@ -1,10 +1,10 @@
 import * as fs from 'fs';
 import { Shape } from './Shape';
+import { SceneOptions } from './Scene';
 import { parse, Node } from 'svg-parser';
 import { Path } from './Path';
 import { Color, Point } from './Point';
 import { hexToRgb, flatten } from './helpers';
-import { BLANKING_AMOUNT, MAX_WAIT_AMOUNT } from './constants';
 
 const DEFAULT_COLOR: Color = [0, 1, 0];
 const ALLOWED_NODES = ['path', 'polyline', 'polygon', 'rect', 'line'];
@@ -25,8 +25,8 @@ export class Svg extends Shape {
   size: number;
   file: Node;
   color: Color;
-  waitAmount: number;
-  blankingAmount: number;
+  waitAmount: number | undefined;
+  blankingAmount: number | undefined;
   private pathNodes: Node[] = [];
 
   constructor(options: SvgOptions) {
@@ -36,8 +36,8 @@ export class Svg extends Shape {
     this.file = options.file;
     this.color = options.color || DEFAULT_COLOR;
     this.size = options.size || 1;
-    this.waitAmount = options.waitAmount || MAX_WAIT_AMOUNT;
-    this.blankingAmount = options.blankingAmount || BLANKING_AMOUNT;
+    this.waitAmount = options.waitAmount;
+    this.blankingAmount = options.blankingAmount;
   }
 
   parseViewBox(raw: string) {
@@ -71,7 +71,7 @@ export class Svg extends Shape {
     }
   };
 
-  draw(resolution: number) {
+  draw(options: SceneOptions): Point[] {
     const viewBox = this.parseViewBox(String(this.file.attributes.viewBox));
     const aspectRatio = viewBox.width / viewBox.height;
     const width = viewBox.width / this.size;
@@ -92,7 +92,7 @@ export class Svg extends Shape {
         height,
         waitAmount: this.waitAmount,
         blankingAmount: this.blankingAmount
-      }).draw(resolution);
+      }).draw(options);
     });
 
     return flatten(points) as Point[];
diff --git a/packages/draw/src/Timeline.ts b/packages/draw/src/Timeline.ts
index ea90ef0..ecfa5fa 100644
--- a/packages/draw/src/Timeline.ts
+++ b/packages/draw/src/Timeline.ts
@@ -1,4 +1,6 @@
 import { Shape } from './Shape';
+import { Point } from './Point';
+import { SceneOptions } from './Scene';
 
 interface TimelineOptions {
   items: TimelineItem[];
@@ -41,10 +43,10 @@ export class Timeline extends Shape {
     return currentItem;
   }
 
-  draw(resolution: number) {
+  draw(options: SceneOptions): Point[] {
     const item = this.getItem();
     if (item) {
-      return item.render().draw(resolution);
+      return item.render().draw(options);
     } else {
       return [];
     }
diff --git a/packages/draw/src/constants.ts b/packages/draw/src/constants.ts
deleted file mode 100644
index 09e68c6..0000000
--- a/packages/draw/src/constants.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-// TODO: find out what this does exactly
-export const RESOLUTION = 150;
-
-export const BLANKING_AMOUNT = 24;
-
-// This value determines how many times a Wait-point should be applied for the sharpest possible angles (360 degree angles).
-export const MAX_WAIT_AMOUNT = 10;
diff --git a/packages/draw/src/helpers.ts b/packages/draw/src/helpers.ts
index 4d66eac..4f62c69 100644
--- a/packages/draw/src/helpers.ts
+++ b/packages/draw/src/helpers.ts
@@ -27,6 +27,40 @@ export function flatten(arr: any[], result: any[] = []) {
   return result;
 }
 
-export function isBlankingPoint(point: Point) {
+export function isBlankingPoint(point: Pick) {
   return !point.r && !point.g && !point.b;
 }
+
+export function outOfBounds(p: Point): boolean {
+  return Math.min(p.x, p.y, p.r, p.g, p.b) < 0
+    || Math.max(p.x, p.y, p.r, p.g, p.b) > 1;
+}
+
+export function pointsEqual(a: Point, b: Point): boolean {
+  return a.x === b.x
+   && a.y === b.y
+   && a.r === b.r
+   && a.g === b.g
+   && a.b === b.b;
+}
+
+export function makeTransformer(fn: (p: Point) => Point): (points: Point[]) => Point[] {
+  return (points: Point[]) => points.map(fn);
+}
+
+export function clampPoint(point: Point): Point {
+  point.x = point.x < 0 ? 0 : point.x > 1 ? 1 : point.x;
+  point.y = point.y < 0 ? 0 : point.y > 1 ? 1 : point.y;
+  point.r = point.r < 0 ? 0 : point.r > 1 ? 1 : point.r;
+  point.g = point.g < 0 ? 0 : point.g > 1 ? 1 : point.g;
+  point.b = point.b < 0 ? 0 : point.b > 1 ? 1 : point.b;
+  return point;
+}
+
+export function monochromePoint(p: Point): Point {
+  p.r = p.g = p.b = Math.max(p.r, p.g, p.b);
+  return p;
+}
+
+export const clamp = makeTransformer(clampPoint);
+export const monochrome = makeTransformer(monochromePoint);
diff --git a/packages/draw/src/index.ts b/packages/draw/src/index.ts
index 0cc5714..2e9c8f7 100644
--- a/packages/draw/src/index.ts
+++ b/packages/draw/src/index.ts
@@ -8,4 +8,5 @@ export { HersheyFont, loadHersheyFont } from './HersheyFont';
 export { Timeline } from './Timeline';
 export { rotate } from './transformers/rotate';
 export { distort } from './transformers/distort';
+export { clamp, monochrome } from './helpers';
 export { Svg, loadSvgFile } from './Svg';
diff --git a/packages/easylase/src/EasylaseLib.ts b/packages/easylase/src/EasylaseLib.ts
index aabbc05..8de5bfc 100644
--- a/packages/easylase/src/EasylaseLib.ts
+++ b/packages/easylase/src/EasylaseLib.ts
@@ -37,7 +37,10 @@ const libPath = path
   // dont'get placed inside the "app.asar" bundle, but instead get placed in a separate directory called "app.asar.unpacked"
   .replace('app.asar', 'app.asar.unpacked');
 
-const EasylaseLib = ffi.Library(libPath, {
+// Even Windows 64-bit is called win32 here.
+const isSupported = process.platform === "win32";
+
+const EasylaseLib = isSupported && ffi.Library(libPath, {
   jmLaserEnumerateDevices: ['int', []],
   jmLaserStopOutput: ['int', ['int']],
   jmLaserCloseDevice: ['int', ['int']],
diff --git a/packages/easylase/src/index.ts b/packages/easylase/src/index.ts
index bdb82ee..b0ecb30 100644
--- a/packages/easylase/src/index.ts
+++ b/packages/easylase/src/index.ts
@@ -7,6 +7,10 @@ export class Easylase extends Device {
   deviceHandle?: number;
 
   async start() {
+    if (!this.isSupported()) {
+      return false;
+    }
+
     this.stop();
     const cards = easylaseLib.enumerateDevices();
     if (cards) {
@@ -32,6 +36,10 @@ export class Easylase extends Device {
     }
   }
 
+  isSupported(): boolean {
+    return process.platform === "win32";
+  }
+
   private convertPoint(p: Point) {
     return {
       x: relativeToPosition(p.x),
diff --git a/packages/helios/src/HeliosLib.ts b/packages/helios/src/HeliosLib.ts
index ed8e5bb..499db41 100644
--- a/packages/helios/src/HeliosLib.ts
+++ b/packages/helios/src/HeliosLib.ts
@@ -70,10 +70,17 @@ export function setShutter(dacNum: number, shutterValue: boolean): number {
   return HeliosLib.SetShutter(dacNum, shutterValue);
 }
 
+export enum FrameMode {
+  QueueLoop = 0,
+  ImmediateLoop = 1,
+  QueueSingle = 2,
+  ImmediateSingle = 3
+}
+
 export function writeFrame(
   dacNum: number,
   pps: number,
-  flags: 0 | 1,
+  flags: FrameMode,
   points: any[],
   numOfPoints: number
 ): number {
diff --git a/packages/helios/src/convert.ts b/packages/helios/src/convert.ts
index 0c185e5..5d08ec2 100644
--- a/packages/helios/src/convert.ts
+++ b/packages/helios/src/convert.ts
@@ -1,9 +1,14 @@
-// The Helios DAC firmware uses 12-bit integers for the resolution, ranging from 0 to 4096.
+// The Helios DAC firmware uses 12-bit integers for the resolution, ranging from 0 to 4095.
 // Source: https://github.com/Grix/helios_dac/blob/master/sdk/HeliosDac.h
-export const XY_RESOLUTION = 4096;
+export const XY_RESOLUTION = 4095;
 export const COLOR_RESOLUTION = 255;
 
-export function relativeToPosition(n: number) {
+export function relativeToX(n: number) {
+  return Math.floor(n * XY_RESOLUTION);
+}
+
+// Helios has 0 Y at the bottom, but laser-dac has 0 Y at the top.
+export function relativeToY(n: number) {
   return Math.floor((1 - n) * XY_RESOLUTION);
 }
 
diff --git a/packages/helios/src/index.ts b/packages/helios/src/index.ts
index e970c40..65b2b33 100644
--- a/packages/helios/src/index.ts
+++ b/packages/helios/src/index.ts
@@ -1,37 +1,108 @@
-import { Device } from '@laser-dac/core';
+import { Device, Point } from '@laser-dac/core';
 import * as heliosLib from './HeliosLib';
-import { relativeToPosition, relativeToColor } from './convert';
+import { relativeToX, relativeToY, relativeToColor } from './convert';
+import { Scene } from '@laser-dac/draw';
+
+enum FrameResult {
+  Success = 'Success',
+  NotReady = 'Not Ready',
+  Fail = 'Fail',
+  Empty = 'Empty'
+}
 
 // This controls the intensity signal of points written to the DAC.
 // For many laser projectors this won't make a difference, but some projectors map this to the shutter so the laser won't turn on if we don't pass the max value.
 const INTENSITY = 255;
 const MAX_POINTS = 4094;
+const MIN_PPS = 7;
+const MAX_PPS = 65535;
 
 export class Helios extends Device {
-  private interval?: NodeJS.Timer;
+  private interval?: NodeJS.Timeout;
+  private dacNum: number = 0;
+  private sendNextImmediate: boolean = false;
+  private lastPoint?: Point;
+  /*
+  We could initialize lastPoint to the center to capture the initial startup jump
+  but it doesn't seem that useful.
+  {
+    x: 0.5,
+    y: 0.5,
+    r: 0,
+    g: 0,
+    b: 0,
+  };
+   */
+
+  private stats = {
+    startTime: 0,
+    secondsPerPoint: 0,
+    msPerPoint: 0,
+    microsecondsPerPoint: 0,
+    maxTheoreticalAccel: 0,
+    fixedFrameRate: {
+      fps: 0,
+      allottedFrameMs: 0,
+      allottedFramePoints: 0,
+      frameAllotmentPercentOfDeviceLimit: 0,
+    },
+    points: {
+      [FrameResult.Success]: 0,
+      [FrameResult.NotReady]: 0,
+      [FrameResult.Fail]: 0,
+      [FrameResult.Empty]: 0, // Should always be 0.
+    },
+    frames: {
+      [FrameResult.Success]: 0,
+      [FrameResult.NotReady]: 0,
+      [FrameResult.Fail]: 0,
+      [FrameResult.Empty]: 0,
+    },
+    lastFrame: {
+      points: 0,
+      result: FrameResult.Empty,
+      drawMs: 0,
+      maxJumpX: 0,
+      maxJumpY: 0,
+      maxJump: 0,
+      maxSpeed: 0,
+      maxAccel: 0,
+    },
+    allFrames: {
+      maxJumpX: 0,
+      maxJumpY: 0,
+      maxJump: 0,
+      maxSpeed: 0,
+      maxAccel: 0,
+    }
+  };
 
   async start() {
     this.stop();
     const devices = heliosLib.openDevices();
     if (devices) {
-      heliosLib.setShutter(0, true);
+      heliosLib.setShutter(this.dacNum, true);
       return true;
     }
     return false;
   }
 
   stop() {
-    heliosLib.setShutter(0, false);
+    heliosLib.setShutter(this.dacNum, false);
     heliosLib.closeDevices();
     if (this.interval) {
       clearInterval(this.interval);
     }
   }
 
-  private convertPoint(p: heliosLib.IPoint) {
+  onShutdownSync() {
+    heliosLib.closeDevices();
+  }
+
+  private convertPoint(p: Point): heliosLib.IPoint {
     return {
-      x: relativeToPosition(p.x),
-      y: relativeToPosition(p.y),
+      x: relativeToX(p.x),
+      y: relativeToY(p.y),
       r: relativeToColor(p.r),
       g: relativeToColor(p.g),
       b: relativeToColor(p.b),
@@ -39,20 +110,197 @@ export class Helios extends Device {
     };
   }
 
+  setPointsRate(pointsRate: number) {
+    // TODO: Make this throw?
+    if (pointsRate > MAX_PPS) {
+      console.error(`Helios cannot exceed ${MAX_PPS} pps (${pointsRate} requested)`);
+      return;
+    } else if (pointsRate < MIN_PPS) {
+      console.error(`Helios cannot subsceed ${MIN_PPS} pps (${pointsRate} requested)`);
+      return;
+    }
+
+    super.setPointsRate(pointsRate);
+    this.sendNextImmediate = true;
+    this.stats.secondsPerPoint = 1 / pointsRate;
+    this.stats.msPerPoint = 1000 / pointsRate;
+    this.stats.microsecondsPerPoint = 1000000 / pointsRate;
+
+    // Going from 0 to 1 in a single point means a velocity of 1 Screen Unit (SU)
+    // in secondsPerPoint amount of time. secondsPerPoint is  1 / pointsRate seconds,
+    // so 1 / (1 / pointsRate) is just pointsRate pointsRate SU/s.
+    //
+    // Max acceleration would be going across the screen from 0 to 1 in a
+    // single point and then back to 0 on the next point. That's a change in
+    // velocity of 2 * pointsRate SU/s in one secondsPerPoint
+    // amount of time, measured in SU/s/s.
+    //
+    // But SU/s/s isn't that useful of a measurement since that acceleration only
+    // applies for the duration of a single point, which is usually in microseconds (us).
+    // So let's keep the speed in SU/s but make acceleration in SU/s/us
+    this.stats.maxTheoreticalAccel = 2 * pointsRate / this.stats.microsecondsPerPoint;
+  }
+
+  sendFrame(points: Point[], pointsRate: number): FrameResult {
+    if (!points.length) {
+      return FrameResult.Empty;
+    }
+
+    if (!this.sendNextImmediate && heliosLib.getStatus(this.dacNum) !== 1) {
+      return FrameResult.NotReady;
+    }
+
+    const frameMode = this.sendNextImmediate
+      ? heliosLib.FrameMode.ImmediateSingle
+      : heliosLib.FrameMode.QueueSingle;
+
+    this.sendNextImmediate = false;
+
+    const limitedPoints = points.length > MAX_POINTS ? points.slice(0, MAX_POINTS) : points;
+    const converted = limitedPoints.map(this.convertPoint);
+    const success = heliosLib.writeFrame(this.dacNum, pointsRate,
+     frameMode, converted, converted.length);
+
+    return success === 1 ? FrameResult.Success : FrameResult.Fail;
+  }
+
   stream(
-    scene: { points: heliosLib.IPoint[] },
+    scene: Scene,
     pointsRate: number,
     fps: number
   ) {
+    this.setPointsRate(pointsRate);
+    const frameTime = this.stats.fixedFrameRate.allottedFrameMs = Math.round(1000 / fps);
+    const allottedFramePoints = this.stats.fixedFrameRate.allottedFramePoints = Math.round(pointsRate / fps);
+
+    console.log(`Streaming at ${pointsRate} pps, ${fps} fps`);
+    console.log(`${frameTime}ms per frame, ${allottedFramePoints} points per frame`);
+
+    this.stats.fixedFrameRate.fps = fps;
+    this.stats.fixedFrameRate.frameAllotmentPercentOfDeviceLimit =
+     100 * allottedFramePoints / MAX_POINTS;
+    this.stats.startTime = Date.now();
+
     this.interval = setInterval(() => {
-      if (!scene.points.length) {
-        return;
-      }
-      if (heliosLib.getStatus(0) !== 1) {
-        return;
+      const points = scene.points;
+      const result = this.sendFrame(points, this.pointsRate);
+
+      this.stats.lastFrame.points = points.length;
+      this.stats.lastFrame.result = result;
+      this.stats.lastFrame.drawMs = 1000 * points.length / this.pointsRate;
+      this.stats.points[result] += points.length;
+      ++this.stats.frames[result];
+
+      this.recordContentStats(points);
+
+      switch (result) {
+        case FrameResult.Fail:
+          console.error("Helios failed sending a frame");
+          break;
+        case FrameResult.NotReady:
+          console.error("Helios not ready.");
+          break;
       }
-      const points = scene.points.map(this.convertPoint).slice(0, MAX_POINTS);
-      heliosLib.writeFrame(0, pointsRate, 0, points, points.length);
-    }, 1000 / fps);
+    }, frameTime);
+  }
+
+  private calculateStats() {
+    const duration = (Date.now() - this.stats.startTime) / 1000;
+    const successPps = Math.round(this.stats.points[FrameResult.Success] / duration);
+    const attemptedPoints = this.stats.points[FrameResult.Success] +
+      this.stats.points[FrameResult.NotReady] +
+      this.stats.points[FrameResult.Fail];
+
+    const attemptedPps = Math.round(attemptedPoints / duration);
+    const successPpsPercentOfNominal = 100 * successPps / this.pointsRate;
+    const attemptedPpsPercentOfNominal = 100 * attemptedPps / this.pointsRate;
+
+    const avgFramePoints = Math.round(this.stats.points[FrameResult.Success] /
+      this.stats.frames[FrameResult.Success]);
+
+    const avgFrameDisplayMs = Math.round(1000 * avgFramePoints / this.pointsRate);
+    const avgFrameAllotmentUtilization = 100 * avgFramePoints / this.stats.fixedFrameRate.allottedFramePoints;
+    const avgFrameDeviceLimitUtilization = 100 * avgFramePoints / MAX_POINTS;
+
+    const totalFrames = this.stats.frames[FrameResult.Success] +
+      this.stats.frames[FrameResult.NotReady] +
+      this.stats.frames[FrameResult.Fail] +
+      this.stats.frames[FrameResult.Empty];
+
+    const notReadyFramePercent = 100 * this.stats.frames[FrameResult.NotReady] / totalFrames;
+
+    return {
+      duration,
+      successPps,
+      attemptedPps,
+      successPpsPercentOfNominal,
+      attemptedPpsPercentOfNominal,
+      avgFramePoints,
+      avgFrameDisplayMs,
+      avgFrameAllotmentUtilization,
+      avgFrameDeviceLimitUtilization,
+      notReadyFramePercent,
+    }
+  }
+
+  recordContentStats(framePoints: Point[]) {
+    let maxJumpX = 0;
+    let maxJumpY = 0;
+    let maxJump = 0;
+    let maxSpeed = 0;
+    let maxAccel = 0;
+
+    let lastJumpX = 0;
+    let lastJumpY = 0;
+
+    framePoints.forEach((point: Point) => {
+      const lastPoint = this.lastPoint ?? point;
+      const jumpX = point.x - lastPoint.x;
+      const jumpXAbs = Math.abs(jumpX);
+
+      const jumpY = point.y - lastPoint.y;
+      const jumpYAbs = Math.abs(jumpY);
+
+      const largerJump = Math.max(jumpXAbs, jumpYAbs);
+
+      const largerSpeed = largerJump / this.stats.secondsPerPoint;
+
+      const jumpDiffX = Math.abs(jumpX - lastJumpX);
+      const jumpDiffY = Math.abs(jumpY - lastJumpY);
+      const largerJumpDiff = Math.max(jumpDiffX, jumpDiffY);
+
+      // Acceleration is mearued here in Screen Units per second per microsecond (SU/s/us).
+      const speedChange = largerJumpDiff / this.stats.secondsPerPoint;
+      const accel = speedChange / this.stats.microsecondsPerPoint;
+
+      maxJumpX = Math.max(maxJumpX, jumpXAbs);
+      maxJumpY = Math.max(maxJumpY, jumpYAbs);
+      maxJump = Math.max(maxJump, largerJump);
+      maxSpeed = Math.max(maxSpeed, largerSpeed);
+      maxAccel = Math.max(maxAccel, accel);
+
+      lastJumpX = jumpX;
+      lastJumpY = jumpY;
+      this.lastPoint = point;
+    });
+
+    this.stats.lastFrame.maxJumpX = maxJumpX;
+    this.stats.lastFrame.maxJumpY = maxJumpY;
+    this.stats.lastFrame.maxJump = maxJump;
+    this.stats.lastFrame.maxSpeed = maxSpeed;
+    this.stats.lastFrame.maxAccel = maxAccel;
+
+    this.stats.allFrames.maxJumpX = Math.max(this.stats.allFrames.maxJumpX, maxJumpX);
+    this.stats.allFrames.maxJumpY = Math.max(this.stats.allFrames.maxJumpY, maxJumpY);
+    this.stats.allFrames.maxJump = Math.max(this.stats.allFrames.maxJump, maxJump);
+    this.stats.allFrames.maxSpeed = Math.max(this.stats.allFrames.maxSpeed, maxSpeed);
+    this.stats.allFrames.maxAccel = Math.max(this.stats.allFrames.maxAccel, maxAccel);
+  }
+
+  getStats(): Object {
+    return {
+      calculated: this.calculateStats(),
+      ...this.stats
+    };
   }
 }