├── .eslintrc ├── README.md ├── hacks └── leakySensor.js ├── hardware ├── BiggerMotor.js ├── Button.js ├── Doors.js ├── Elevator.js ├── LightButton.js ├── Motor.js ├── Panel.js └── ProximitySensor.js ├── software ├── Action.js ├── Controller.js ├── EventTarget.js └── SignalTarget.js ├── the-doors ├── index.html ├── the-doors.css └── the-doors.js ├── the-elevator ├── index.html ├── the-elevator.css └── the-elevator.js └── the-light-button ├── index.html ├── the-light-button.css └── the-light-button.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "globals": { 4 | "Set": true, 5 | "WeakMap": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "rules": { 11 | "no-case-declarations": 0, 12 | "no-fallthrough": 0 13 | } 14 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A JavaScript Elevator 2 | ===================== 3 | 4 | An introduction to programming for the Internet of Things through a metaphoric code explained through [a blog posts](https://medium.com/@WebReflection/a-javascript-elevator-9b55e1d4acc8). 5 | 6 | ### Demos 7 | 8 | * [the doors](https://webreflection.github.io/elevator/the-doors/) of a lift have at least a motor and a sensor (unless it chops people that are passing through). Doors can close or open, and everything is asynchronous and cancelable (unless you want to chop people that are passing through). 9 | * [the light button](https://webreflection.github.io/elevator/the-light-button/) does exactly what a button does, but it has an extra `switch(state)` method to switch on or off its internal light. It's important to understand that pressing the button, does not necessarily mean switching on the light. Remember, an elevator is a list of hardware driven by a controller, not a set of stand-alone modules with a proper purpose. 10 | * [the elevator](https://webreflection.github.io/elevator/the-elevator/) with the most basic, queue based, controller implementation. Click as many buttons you want from the floor (left) or from the elevator (right) and see it moving. 11 | 12 | ### Requirements 13 | 14 | You need a modern browser compatible with ECMAScript 2015 modules (ESM). 15 | 16 | Chrome, Web, or Safari are right now tested and supported. 17 | -------------------------------------------------------------------------------- /hacks/leakySensor.js: -------------------------------------------------------------------------------- 1 | // make the target class available 2 | import Doors from '../hardware/Doors.js'; 3 | 4 | // override WeakMap#set 5 | const set = WeakMap.prototype.set; 6 | Object.defineProperty( 7 | WeakMap.prototype, 8 | 'set', 9 | {value(self, data) { 10 | if (self instanceof Doors) { 11 | leak = data.sensor; 12 | } 13 | return set.call(this, self, data); 14 | }} 15 | ); 16 | 17 | // and exports the leaky sensor 18 | export let leak; 19 | -------------------------------------------------------------------------------- /hardware/BiggerMotor.js: -------------------------------------------------------------------------------- 1 | import Motor from './Motor.js'; 2 | 3 | // an Elevator Motor is just a Motor 4 | // with more power. In this case just more speed. 5 | export default class BiggerMotor extends Motor { 6 | constructor() { 7 | super().speed = 0.05; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /hardware/Button.js: -------------------------------------------------------------------------------- 1 | import SignalTarget from '../software/SignalTarget.js'; 2 | 3 | // the most basic Button ever does one thing and one thing only: 4 | // when you press it, it sends a signal that it's being pressed 5 | export default class Button extends SignalTarget { 6 | constructor(symbol) { 7 | super(); 8 | this.symbol = symbol; 9 | } 10 | 11 | // while in the Internet of Things world you'll have 12 | // a physical way to press such button, we need a 13 | // synthetic alternative to simulate actual pressing. 14 | press() { this.signal('press'); } 15 | } 16 | -------------------------------------------------------------------------------- /hardware/Doors.js: -------------------------------------------------------------------------------- 1 | import SignalTarget from '../software/SignalTarget.js'; 2 | 3 | // modern elevator doors have at least one motor 4 | import Motor from './Motor.js'; 5 | // and one sensor to avoid chopping people while doors close 6 | import ProximitySensor from './ProximitySensor.js'; 7 | 8 | // private properties are handled by this WeakMap 9 | const privates = new WeakMap; 10 | 11 | export default class Doors extends SignalTarget { 12 | 13 | // doors can be either closed or opened 14 | static get CLOSED() { return 0; } 15 | static get OPENED() { return 1; } 16 | 17 | constructor() { 18 | super(); 19 | // setup motor and sensor for these doors 20 | const motor = new Motor; 21 | const sensor = new ProximitySensor; 22 | // update doors position while motor is rotating 23 | motor.on('rotating', this); 24 | // react on proximity sensor signals 25 | sensor.on('proximity', this); 26 | // these are internal features nobody else 27 | // should handle, granting doors functionality integrity. 28 | privates.set(this, {motor, sensor, status: Doors.CLOSED}); 29 | } 30 | 31 | // a read only property to know if doors are opened or closed 32 | get status() { 33 | return privates.get(this).status; 34 | } 35 | 36 | // a public API to open doors 37 | open() { 38 | const info = privates.get(this); 39 | // if already opened, nothing happens 40 | if (info.status === Doors.OPENED) return; 41 | // otherwise move doors through the motor 42 | info.motor.rotate(1); 43 | } 44 | 45 | // same API goes for closing doors 46 | close() { 47 | const info = privates.get(this); 48 | // if laready closed, nothing happens 49 | if (info.status === Doors.CLOSED) return; 50 | // otherwise be sure nobody gets chopped while closing 51 | info.sensor.activate(); 52 | // and start moving doors through the motor 53 | info.motor.rotate(-1); 54 | } 55 | 56 | // whenever the sensor signals proximity 57 | // the doors.open() method is invoked 58 | onproximity() { this.open(); } 59 | 60 | // while the motor is rotaing 61 | onrotating(event) { 62 | const info = privates.get(this); 63 | // update the status between 0 and 1 64 | info.status = Math.max( 65 | Doors.CLOSED, 66 | Math.min( 67 | Doors.OPENED, 68 | info.status + event.detail 69 | ) 70 | ); 71 | // signal to a visual panels or other hardware 72 | // that doors are moving 73 | this.signal('moving'); 74 | // and if status is now opened or closed 75 | switch (info.status) { 76 | case Doors.CLOSED: 77 | case Doors.OPENED: 78 | // stop the sensor 79 | info.sensor.deactivate(); 80 | // and stop the motor 81 | info.motor.stop(); 82 | // notify whatever needs it that doors status changed 83 | // this is the equivalent of a `.then(...)` after 84 | // doors have finished opening or closing 85 | this.signal('changed'); 86 | break; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /hardware/Elevator.js: -------------------------------------------------------------------------------- 1 | import SignalTarget from '../software/SignalTarget.js'; 2 | 3 | // just like its doors, an elevator moves through a motor 4 | import BiggerMotor from './BiggerMotor.js'; 5 | 6 | // private properties are handled by this WeakMap 7 | const privates = new WeakMap; 8 | 9 | export default class Elevator extends SignalTarget { 10 | 11 | constructor() { 12 | super(); 13 | 14 | // setup the motor 15 | const motor = new BiggerMotor; 16 | motor.on('rotating', this); 17 | 18 | // the level is used as distance between 0 19 | // and the next floor to reach 20 | privates.set(this, {motor, level: 0, status: 0}); 21 | } 22 | 23 | // a read only property to know where is the elevator 24 | get status() { 25 | return privates.get(this).status; 26 | } 27 | 28 | // instead of open/close, an elevator 29 | // simply reaches a new level 30 | reach(level) { 31 | const info = privates.get(this); 32 | const rotation = level > info.level ? 1 : -1; 33 | info.level = level; 34 | info.motor.rotate(rotation); 35 | } 36 | 37 | // while moving, calculate when it's time to stop. 38 | // note: this is not how it actually works for real 39 | onrotating(event) { 40 | const info = privates.get(this); 41 | info.status += event.detail; 42 | // and whenever it reached the previous or next floor 43 | if ( 44 | (event.detail < 0 && info.status <= info.level) || 45 | (event.detail > 0 && info.status >= info.level) 46 | ) { 47 | // stop the motor 48 | info.motor.stop(); 49 | // reset status 50 | info.status = Math.round(info.status); 51 | // signal last precise movement 52 | this.signal('moving'); 53 | // and also signal the floor change 54 | this.signal('changed'); 55 | } else { 56 | // just signal that it's moving 57 | this.signal('moving'); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /hardware/LightButton.js: -------------------------------------------------------------------------------- 1 | import Button from './Button.js'; 2 | 3 | // a LightButton is exactly what it says: 4 | // a button with also a light connected to it 5 | export default class LightButton extends Button { 6 | 7 | // the light can be "on" or "off" 8 | static get ON() { return 'on'; } 9 | static get OFF() { return 'off'; } 10 | 11 | // the state is very simple: 12 | // either the button is on or off (default) 13 | constructor(symbol) { 14 | super(symbol).state = LightButton.OFF; 15 | } 16 | 17 | // the light switch is independent of the press signal 18 | // indeed the button can be switched on without pressing actions 19 | switch(state) { 20 | switch(state) { 21 | case LightButton.ON: 22 | case LightButton.OFF: 23 | // same state won't do anything 24 | if (this.state === state) return; 25 | // updated state will signal changes 26 | this.state = state; 27 | this.signal('light' + state); 28 | break; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /hardware/Motor.js: -------------------------------------------------------------------------------- 1 | import SignalTarget from '../software/SignalTarget.js'; 2 | 3 | // an elevator doors motor does literally 2 things: 4 | // it opens doors or it closes them. 5 | export default class Motor extends SignalTarget { 6 | constructor() { 7 | super(); 8 | // the rotating is used like a flag 9 | // based on the fact intervals are always 10 | // integers different from zero 11 | this.rotating = 0; 12 | // every motor has also different voltage, 13 | // power, or speed, which is in this demo 14 | // the most important for this motor to work. 15 | this.speed = 0.01; 16 | } 17 | 18 | // rotate is a public feature/API of the motor 19 | rotate(direction) { 20 | // it stops if it was moving already 21 | if (this.rotating) this.stop(); 22 | // and it starts rotating in a direction 23 | // which is either clockwise or anticlockwise 24 | this.rotating = setInterval( 25 | // per each rotation, a signal is sent 26 | rotateTheMotor, 27 | // 60 frames per seconds 28 | 1000/60, 29 | // to this motor 30 | this, 31 | // and with a direction higher or lower than 0 32 | direction < 0 ? -this.speed : +this.speed 33 | ); 34 | } 35 | 36 | // a motor should be able to stop at any given time 37 | stop() { 38 | clearInterval(this.rotating); 39 | this.rotating = 0; 40 | } 41 | } 42 | 43 | // and signaling *may* pass through the speed 44 | // even if it could be retrieved by the motor type itself 45 | function rotateTheMotor(motor, speed) { 46 | motor.signal('rotating', speed); 47 | } 48 | -------------------------------------------------------------------------------- /hardware/Panel.js: -------------------------------------------------------------------------------- 1 | import SignalTarget from '../software/SignalTarget.js'; 2 | 3 | // a panel is a convenient intermediate layer that could 4 | // group one or more buttons together and be mounted 5 | // either externally or internally the elevator 6 | export default class Panel extends SignalTarget { 7 | 8 | constructor(buttons) { 9 | super(); 10 | // each button signal will trigger the panel 11 | this.buttons = buttons.map(asPressEvent, this); 12 | } 13 | 14 | // delegate the press signal to whoever is listening 15 | onpress(event) { 16 | this.signal('press', event.currentTarget); 17 | } 18 | 19 | } 20 | 21 | // helper function to map every button 22 | function asPressEvent(button) { 23 | return button.on('press', this); 24 | } 25 | -------------------------------------------------------------------------------- /hardware/ProximitySensor.js: -------------------------------------------------------------------------------- 1 | import SignalTarget from '../software/SignalTarget.js'; 2 | 3 | // when active, a proximity sensor is capable of signaling 4 | // whenever someone, or something, is around 5 | export default class ProximitySensor extends SignalTarget { 6 | 7 | // a sensor can be either active or inactive (default) 8 | constructor() { super().active = false; } 9 | 10 | activate() { this.active = true; } 11 | deactivate() { this.active = false; } 12 | 13 | // once an object gets closer, the sensor signals its proximity 14 | // in the real world, a proximity sensor might send more data 15 | // like, as example, how far is the detected object. 16 | detect() { if (this.active) this.signal('proximity'); } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /software/Action.js: -------------------------------------------------------------------------------- 1 | // every button has a meaning 2 | const symbols = { 3 | ALARM: '🔔', 4 | CLOSE_DOORS: '▷│◁', 5 | OPEN_DOORS: '◁│▷', 6 | BASEMENT_FLOOR: 'S', 7 | GROUND_FLOOR: 'G', 8 | FIRST_FLOOR: '1', 9 | SECOND_FLOOR: '2', 10 | THIRD_FLOOR: '3' 11 | // ... and so on ... 12 | }; 13 | 14 | // and every meaning could be a floor 15 | const proto = { 16 | asFloor(symbol) { 17 | switch (symbol) { 18 | // floor simply cover a range, from 0 to X 19 | case symbols.BASEMENT_FLOOR: return 0; 20 | case symbols.GROUND_FLOOR: return 1; 21 | case symbols.FIRST_FLOOR: return 2; 22 | case symbols.SECOND_FLOOR: return 3; 23 | case symbols.THIRD_FLOOR: return 4; 24 | default: return -1; 25 | } 26 | }, 27 | asSymbol(floor) { 28 | for (const key in this) { 29 | if (this.asFloor(this[key]) === floor) { 30 | return this[key]; 31 | } 32 | } 33 | } 34 | }; 35 | 36 | export default Object.freeze(Object.setPrototypeOf(symbols, proto)); 37 | -------------------------------------------------------------------------------- /software/Controller.js: -------------------------------------------------------------------------------- 1 | import Action from './Action.js'; 2 | import SignalTarget from './SignalTarget.js'; 3 | 4 | import Doors from '../hardware/Doors.js'; 5 | import LightButton from '../hardware/LightButton.js'; 6 | 7 | // private properties are handled by this WeakMap 8 | const privates = new WeakMap; 9 | 10 | // trap utilities to avoid external 11 | // interferences/polyfills/pollution 12 | const {assign, freeze} = Object; 13 | 14 | // a controller is in charge of orchestrating 15 | // the elevator functionality as a whole. 16 | export default class Controller extends SignalTarget { 17 | 18 | constructor(elevator, doors, panels) { 19 | super(); 20 | 21 | // let's assume by contract doorsPanel 22 | // is mandatory at index 0 23 | const floor = Math.min.apply( 24 | Math, 25 | panels[0].buttons 26 | .filter(isFloor) 27 | .map(Action.asFloor) 28 | ); 29 | 30 | // store private variables 31 | privates.set(this, { 32 | doors, 33 | elevator, 34 | panels, 35 | queue: [], 36 | state: {floor, moving: false}, 37 | timer: 0 38 | }); 39 | 40 | // listen to all panels buttons 41 | panels.forEach(addPanelPress, this); 42 | 43 | // listen to doors too and propagate events 44 | doors 45 | .on('changed', this) 46 | .on('changed', propagate(this, 'doors')) 47 | .on('moving', propagate(this, 'doors')); 48 | 49 | // same goes for the elevator 50 | elevator 51 | .on('changed', this) 52 | .on('changed', propagate(this, 'elevator')) 53 | .on('moving', propagate(this, 'elevator')); 54 | 55 | } 56 | 57 | // a state is exposed as read-only (unique) object 58 | get state() { 59 | return freeze(assign({}, privates.get(this).state)); 60 | } 61 | 62 | // every event should eventually clear the timer 63 | // that scheduled doors closing and next action 64 | // this is why handleEvent is redefined, 65 | // to intercept all possible registered events. 66 | handleEvent(event) { 67 | const info = privates.get(this); 68 | 69 | // clear any waiting timer 70 | if (info.timer) { 71 | clearTimeout(info.timer); 72 | info.timer = 0; 73 | } 74 | 75 | // then analyze the action 76 | switch (event.type) { 77 | case 'press': 78 | this.onButtonPress(event); 79 | break; 80 | case 'changed': 81 | if (event.currentTarget === info.doors) { 82 | this.onDoorsChanged(event); 83 | } else { 84 | this.onElevatorChanged(event); 85 | } 86 | break; 87 | } 88 | } 89 | 90 | onButtonPress(event) { 91 | const button = event.detail; 92 | const info = privates.get(this); 93 | // if the button is about changing level/floor 94 | if (isFloor(button)) { 95 | // find out which one 96 | const floor = Action.asFloor(button.symbol); 97 | // be sure it's not already in the queue 98 | if (!info.queue.includes(floor)) { 99 | // in such case push it through 100 | info.queue.push(floor); 101 | // light eah button related to this floor on 102 | switchButton(info.panels, button.symbol, LightButton.ON); 103 | // verify doors state 104 | switch (info.doors.status) { 105 | // if opened, close them and let the event follow up 106 | case Doors.OPENED: 107 | info.doors.close(); 108 | break; 109 | // if the lift has closed doors 110 | case Doors.CLOSED: 111 | // and the elevator is not moving 112 | if (!info.state.moving) { 113 | // signal a doors change to trigger doors close logic 114 | info.doors.signal('changed'); 115 | } 116 | break; 117 | } 118 | } 119 | } else { 120 | // turn on the light for an instant 121 | if (button instanceof LightButton) { 122 | button.switch(LightButton.ON); 123 | setTimeout(() => button.switch(LightButton.OFF), 300); 124 | } 125 | // find out what to do 126 | switch (button.symbol) { 127 | case Action.ALARM: 128 | alert('ALARM ALARM'); 129 | break; 130 | // if it's about opening doors 131 | case Action.OPEN_DOORS: 132 | // in case these are already opened 133 | if (info.doors.status === Doors.OPENED) { 134 | // prepare for the next action, if any 135 | prepareNextAction(info); 136 | } 137 | // otherwise if the elevator is not moving 138 | else if (!info.state.moving) { 139 | // ask to open doors 140 | info.doors.open(); 141 | } 142 | break; 143 | // if it's about clsing doors 144 | case Action.CLOSE_DOORS: 145 | // just invoke it and let the rest 146 | // of the events flow 147 | info.doors.close(); 148 | break; 149 | } 150 | } 151 | } 152 | 153 | onDoorsChanged(event) { 154 | const doors = event.currentTarget; 155 | const info = privates.get(this); 156 | switch (doors.status) { 157 | case Doors.OPENED: 158 | prepareNextAction(info); 159 | break; 160 | case Doors.CLOSED: 161 | if (info.queue.length) { 162 | const level = info.queue[0]; 163 | if (level === Action.asFloor(info.state.floor.symbol)) { 164 | info.queue.shift(); 165 | info.doors.open(); 166 | } else { 167 | info.state.moving = true; 168 | info.elevator.reach(level); 169 | } 170 | } 171 | break; 172 | } 173 | } 174 | 175 | onElevatorChanged(event) { 176 | const info = privates.get(this); 177 | info.state.moving = false; 178 | info.state.floor = info.queue.shift(); 179 | switchButton( 180 | info.panels, 181 | Action.asSymbol(info.state.floor), 182 | LightButton.OFF 183 | ); 184 | info.doors.open(); 185 | } 186 | 187 | } 188 | 189 | function addPanelPress(panel) { 190 | // attach all panel events to this controller 191 | panel.on('press', this); 192 | } 193 | 194 | function isFloor(button) { 195 | // true if a button is associated to a floor 196 | return Action.asFloor(button.symbol) !== -1; 197 | } 198 | 199 | function prepareNextAction(info) { 200 | // if there is something to do 201 | if (info.queue.length) { 202 | // setup a timer to do it once doors are closed 203 | info.timer = setTimeout(() => info.doors.close(), 1000); 204 | } 205 | } 206 | 207 | function propagate(controller, prefix) { 208 | // re-signal events for UI sake. Pass parts around too 209 | return event => { 210 | controller.signal(`${prefix}:${event.type}`, event.currentTarget); 211 | }; 212 | } 213 | 214 | // find every button of every panel 215 | // that is related to a certain symbol 216 | // and switch its state on or off 217 | function switchButton(panels, symbol, state) { 218 | for (const panel of panels) { 219 | for (const button of panel.buttons) { 220 | if (button.symbol === symbol) { 221 | if (button instanceof LightButton) { 222 | button.switch(state); 223 | } 224 | } 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /software/EventTarget.js: -------------------------------------------------------------------------------- 1 | import EventTarget from 'https://unpkg.com/event-target@latest/esm/index.js'; 2 | 3 | // based on DOM specifications, 4 | // EventTarget can be used everywhere, 5 | // not just for browser related tasks. 6 | export default EventTarget; 7 | -------------------------------------------------------------------------------- /software/SignalTarget.js: -------------------------------------------------------------------------------- 1 | import EventTarget from './EventTarget.js'; 2 | 3 | // since we don't like to write long method names and 4 | // we also don't like to write new CustomEvent(type, {detail}) 5 | // every single time, we can lightly wrap the EventTarget 6 | // making more easy to deal with. 7 | export default class SignalTarget extends EventTarget { 8 | 9 | // a well known `obj.on(type, callback)` shortcut 10 | on(...args) { 11 | this.addEventListener(...args); 12 | return this; 13 | } 14 | 15 | // a way to handle events that does not need bindings all over 16 | // https://medium.com/@WebReflection/dom-handleevent-a-cross-platform-standard-since-year-2000-5bf17287fd38 17 | handleEvent(event) { this[`on${event.type}`](event); } 18 | 19 | // and a mechanical "signal" to notify any hardware 20 | // that is listening to the current instance. 21 | signal(signal, detail) { 22 | this.dispatchEvent(new CustomEvent(signal, {detail})); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /the-doors/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |