/**
* For working with generic sensor devices
* @module j5e/sensor
* @requires module:j5e/withinable
* @requires module:j5e/fn
*/
import Withinable from "j5e/withinable";
import { normalizeDevice, normalizeIO, getProvider, timer, map, fmap, constrain } from "j5e/fn";
/**
* Class representing a generic sensor
* @classdesc The Sensor class allows for input from sensor devices that connect to an ADC
* @async
* @extends module:j5e/withinable~Withinable
*/
class Sensor extends Withinable {
#state = {
intervalId: null,
enabled: null,
isScaled: false,
raw: null,
value: null,
median: null,
previousInterval: 100,
last: null,
samples: []
};
/**
* Instantiate a sensor
* @param {number|string|object} io - Pin identifier or IO Options (See {@tutorial C-INSTANTIATING})
* @example
* <caption>Using a pin number</caption>
* import Sensor from "j5e/sensor";
*
* const sensor = await new Sensor(12);
* sensor.on("change", data => {
* trace(data);
* });
*
* @example
* <caption>Using a pin identifier</caption>
* import Sensor from "j5e/sensor";
*
* const sensor = await new Sensor("A1");
* sensor.on("change", data => {
* trace(data);
* });
*/
constructor(io) {
return (async() => {
io = normalizeIO(io);
super();
const Provider = await getProvider(io, "Analog");
this.io = new Provider({
pin: io.pin,
});
this.configure({
interval: 100
});
return this;
})();
}
/**
* Configure a Sensor
* @returns {Sensor} The instance on which the method was called
* @param {object} options - Device configuration options
* @param {number} [options.aref=3.3] - Analog reference voltage
* @param {boolean} [options.enabled=true] - Wether the device is currently performing reads every <interval>ms
* @param {number} [options.interval=100] - Interval between readings in millseconds
* @param {number[]} [options.limit=null] - Limit the output range
* @param {number[]} [options.range=[0, N]] - The input range of the sensor
* @param {number[]} [options.scale=[0, N]] - The output range for the sensor's value
* @param {number} [options.threshold=1] - The minimum amount of change required to emit a "change" event
* @example
* <caption>Passing in Cofiguration Options</caption>
import Sensor from "j5e/sensor";
*
* const sensor = await new Sensor({
* pin: 12
* });
*
* sensor.configure({
* interval: 500
* });
*
* sensor.on("change", data => {
* trace(data);
* });
*/
configure(options = {}) {
options = normalizeDevice(options);
this.#state.aref = options.aref || 3.3;
this.#state.range = options.range || [0, 2 ** this.io.resolution - 1];
this.#state.scale = options.scale || [0, 2 ** this.io.resolution - 1];
this.#state.limit = options.limit || null;
this.#state.smoothing = options.smoothing || 10;
this.#state.threshold = options.threshold || 1;
if (typeof options.interval !== "undefined") {
this.interval = options.interval;
}
if (typeof options.enabled !== "undefined") {
if (options.enabled === false) {
this.disable();
} else {
this.enable();
}
}
return this;
}
/**
* Limits the output range
* @type {number[]}
*/
get limit() {
return this.#state.limit;
}
set limit(newLimit) {
this.#state.limit = newLimit;
}
/**
* The minimum amount of change required to emit a "change" event
* @type {number}
*/
get threshold() {
return this.#state.threshold;
}
set threshold(newThreshold) {
this.#state.threshold = newThreshold;
}
/**
* The interval between readings (in ms)
* @type {number}
*/
get interval() {
return this.#state.interval;
}
set interval(newInterval) {
this.#state.interval = newInterval;
if (this.#state.intervalId) {
timer.clearInterval(this.#state.intervalId);
}
if (this.#state.interval !== 0) {
this.#state.intervalId = timer.setInterval(this.eventProcessing.bind(this), newInterval);
}
}
/**
* The number of samples to take before finding the median
* @type {number}
*/
get smoothing() {
return this.#state.smoothing;
}
set smoothing(newSmoothing) {
this.#state.smoothing = newSmoothing;
}
/**
* The reference voltage
* @type {number}
*/
get aref() {
return this.#state.aref;
}
/**
* The number of samples to take before finding the median
* @type {number}
*/
get samples() {
return this.#state.samples;
}
/**
* The input range of the sensor
* @type {number[]}
* @readonly
*/
get range() {
return this.#state.range;
}
/**
* Get the most recent raw ADC reading
* @type {number}
* @readonly
*/
get raw() {
return this.#state.raw;
}
/**
* Get the most recent median ADC reading
* @type {number}
* @readonly
*/
get median() {
return this.#state.median;
}
/**
* The maximum possible ADC reading
* @type {number}
* @readonly
*/
get resolution() {
return 2 ** this.io.resolution - 1;
}
/**
* Get the most recent scaled raw reading
* @type {number}
* @readonly
*/
get scaled() {
let mapped, constrained;
if (this.#state.scale && this.#state.raw !== null) {
mapped = fmap(this.#state.raw, this.#state.range[0], this.#state.range[1], this.#state.scale[0], this.#state.scale[1]);
constrained = constrain(mapped, this.#state.scale[0], this.#state.scale[1]);
return constrained;
}
return this.#state.raw;
}
/**
* @property Get the most recent scaled median value
* @type {number}
* @readonly
*/
get value() {
return this.scaled;
}
/**
* Enable a disabled sensor.
* @return {Object} instance
* @example
* import Sensor from "j5e/sensor";
* const sensor = await new Sensor(12);
*
* sensor.disable();
*
* // Wait 5 seconds and then take readings
* timer.setTimeout(function() {
* sensor.enable();
* });
*/
enable() {
if (!this.#state.enabled) {
this.interval = this.#state.interval || this.#state.previousInterval;
this.#state.enabled = true;
}
return this;
}
/**
* Disable an enabled sensor.
* @return {Object} instance
* @example
* import Sensor from "j5e/sensor";
* const sensor = await new Sensor(12);
*
* // Take reading for 5 seconds and then stop
* timer.setTimeout(function() {
* sensor.disable();
* });
*/
disable() {
if (this.#state.enabled || this.#state.enabled === null) {
this.#state.enabled = false;
this.#state.previousInterval = this.#state.interval;
this.interval = 0;
}
return this;
}
/**
* Synchronous read of a sensor.
* @return {Number} sensor value
* @example
* import Sensor from "j5e/sensor";
* const sensor = await new Sensor(12);
*
* let myValue = sensor.read();
*/
read() {
this.#state.raw = this.io.read();
return this.#state.raw;
}
/**
* Sample a sensor
* @access private
*/
sample() {
this.read();
this.#state.samples.push(this.value);
if (this.#state.samples.length >= this.smoothing) {
// Filter the accumulated sample values to reduce analog reading noise
this.#state.median = median(this.samples);
this.emitEvents();
//Reset samples
this.#state.samples = [];
}
}
/**
* Internal method for emitting events
* @access private
*/
emitEvents() {
let err = null;
let boundary;
// Filter the accumulated sample values to reduce analog reading noise
const roundMedian = Math.round(this.#state.median);
this.emit("data", roundMedian);
// If the filtered (#state.median) value for this interval is at least ± the
// configured threshold from last, fire change events
if (this.#state.median <= (this.#state.last - this.threshold) || this.#state.median >= (this.#state.last + this.threshold)) {
this.emit("change", roundMedian);
// Update the instance-local `last` value (only) when a new change event
// has been emitted. For comparison in the next interval
this.#state.last = this.#state.median;
}
if (this.limit) {
if (this.#state.median <= this.limit[0]) {
boundary = "lower";
}
if (this.#state.median >= this.limit[1]) {
boundary = "upper";
}
if (boundary) {
this.emit("limit", {
boundary,
value: roundMedian
});
this.emit(`limit:${boundary}`, roundMedian);
}
}
}
/**
* Internal method for processing reads
* @access private
*/
eventProcessing() {
this.sample();
this.emit("raw", this.#state.raw);
}
/**
* scale/scaleTo Set a value scaling range
*
* @param {Number} low Lowerbound
* @param {Number} high Upperbound
* @return {Object} instance
*
* @param {Array} [ low, high] Lowerbound
* @return {Object} instance
* @example
* import Sensor from "j5e/sensor";
* const sensor = await new Sensor(12);
*
* // Scale all future values to 8-bit range
* sensor.scale([0, 255]);
*/
scale(low, high) {
if (typeof low === "undefined") {
return this.#state.scale;
} else {
this.isScaled = true;
this.#state.scale = Array.isArray(low) ?
low : [low, high];
return this;
}
}
/**
* scaleTo Scales value to integer representation
* @param {Number} low An array containing a lower and upper bound
*
* @param {Number} low A number to use as a lower bound
* @param {Number} high A number to use as an upper bound
* @return {Number} The scaled value
* @example
* import Sensor from "j5e/sensor";
* const sensor = await new Sensor(12);
*
* // Scale the returned value to 8-bit range
* sensor.scaleTo([0, 255]);
*/
scaleTo(low, high) {
const scale = Array.isArray(low) ? low : [low, high];
return map(this.#state.raw, 0, this.resolution, scale[0], scale[1]);
}
/**
* fscaleTo Scales value to single precision float representation
* @param {Number} low An array containing a lower and upper bound
*
* @param {Number} low A number to use as a lower bound
* @param {Number} high A number to use as an upper bound
* @return {Number} The scaled value
* @example
* import Sensor from "j5e/sensor";
* const sensor = await new Sensor(12);
*
* // Scale the returned value to float between 0 and 1
* sensor.fscaleTo([0, 1]);
*/
fscaleTo(low, high) {
const scale = Array.isArray(low) ? low : [low, high];
return fmap(this.#state.raw, 0, this.resolution, scale[0], scale[1]);
}
}
export default Sensor;
// To reduce noise in sensor readings, sort collected samples
// from high to low and select the value in the center.
function median(input) {
// faster than default comparitor (even for small n)
const sorted = input.sort((a, b) => a - b);
const len = sorted.length;
const half = Math.floor(len / 2);
// If the length is odd, return the midpoint m
// If the length is even, return average of m & m + 1
return len % 2 ? sorted[half] : (sorted[half - 1] + sorted[half]) / 2;
};