hello-lights 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/lib/cli/repl-command.js +11 -11
- package/lib/cli/serve/index.js +37 -7
- package/lib/commander.js +11 -3
- package/lib/index.js +3 -1
- package/lib/rest-commander.js +126 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,6 +99,34 @@ $ npm run cli -- serve --port 3000 # start the HTTP server on a custom port
|
|
|
99
99
|
|
|
100
100
|
Use `--help` for the full list of options, including `--serial-num` to target a specific device and `--selector multi` to control multiple traffic lights at once.
|
|
101
101
|
|
|
102
|
+
## HTTP Server REST API
|
|
103
|
+
|
|
104
|
+
The `serve` command starts an HTTP server that exposes the Commander interface as a REST API. By default it listens on port 9000.
|
|
105
|
+
|
|
106
|
+
| Endpoint | Method | Body | Response |
|
|
107
|
+
|---|---|---|---|
|
|
108
|
+
| `/run` | POST | Command string (plain text) | 202 Accepted |
|
|
109
|
+
| `/run?reset=true` | POST | Command string (plain text) | 202 Accepted (resets lights first) |
|
|
110
|
+
| `/cancel` | POST | — | 200 OK |
|
|
111
|
+
| `/definitions` | POST | Definition string (plain text) | 202 Accepted |
|
|
112
|
+
| `/commands` | GET | — | 200 + JSON array of command names |
|
|
113
|
+
| `/commands/:name` | GET | — | 200 + help text (`text/x-ansi`) or 404 |
|
|
114
|
+
| `/info` | GET | — | 200 + JSON array of `{ serialNum, status }` |
|
|
115
|
+
|
|
116
|
+
Examples:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
$ curl -X POST http://localhost:9000/run -d 'blink 3 green 300'
|
|
120
|
+
$ curl -X POST http://localhost:9000/run?reset=true -d 'twinkle red 400'
|
|
121
|
+
$ curl -X POST http://localhost:9000/cancel
|
|
122
|
+
$ curl -X POST http://localhost:9000/definitions -d '(def foo (blink 1 green 300))'
|
|
123
|
+
$ curl http://localhost:9000/commands
|
|
124
|
+
$ curl http://localhost:9000/commands/turn
|
|
125
|
+
$ curl http://localhost:9000/info
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The server also serves a browser demo at the root URL (`/`).
|
|
129
|
+
|
|
102
130
|
## Running as a Linux Service
|
|
103
131
|
|
|
104
132
|
You can set up `hello-lights serve` to run automatically as a systemd user service on Linux.
|
package/lib/cli/repl-command.js
CHANGED
|
@@ -13,8 +13,8 @@ class CommanderRepl {
|
|
|
13
13
|
this.manager = this.commander.manager;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
formatCommandNames() {
|
|
17
|
-
let names = this.commander.
|
|
16
|
+
async formatCommandNames() {
|
|
17
|
+
let names = await this.commander.fetchCommandNames();
|
|
18
18
|
let parts = [' '];
|
|
19
19
|
names.forEach((name, i) => {
|
|
20
20
|
parts.push(` ${name}`);
|
|
@@ -23,7 +23,7 @@ class CommanderRepl {
|
|
|
23
23
|
return parts.join('');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
help(commandName) {
|
|
26
|
+
async help(commandName) {
|
|
27
27
|
if (commandName === undefined) {
|
|
28
28
|
this.logger.log([
|
|
29
29
|
`Commands for the traffic light`,
|
|
@@ -34,30 +34,30 @@ class CommanderRepl {
|
|
|
34
34
|
`> [command]`,
|
|
35
35
|
`> { [... multi line command] }`,
|
|
36
36
|
` available commands:`,
|
|
37
|
-
this.formatCommandNames()
|
|
37
|
+
await this.formatCommandNames()
|
|
38
38
|
].join('\n'));
|
|
39
39
|
} else {
|
|
40
|
-
this.commander.help(commandName);
|
|
40
|
+
await this.commander.help(commandName);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
execute(text, context, filename, callback) {
|
|
44
|
+
async execute(text, context, filename, callback) {
|
|
45
45
|
text = text.trim();
|
|
46
46
|
let match;
|
|
47
47
|
if (!text) {
|
|
48
48
|
} else if (text === 'cancel') {
|
|
49
|
-
this.commander.cancel();
|
|
49
|
+
await this.commander.cancel();
|
|
50
50
|
} else if (text === 'help') {
|
|
51
|
-
this.help();
|
|
51
|
+
await this.help();
|
|
52
52
|
} else if (match = text.match(/^help\s+(.+)/)) { // eslint-disable-line no-cond-assign
|
|
53
|
-
this.help(match[1]);
|
|
53
|
+
await this.help(match[1]);
|
|
54
54
|
} else if (text === 'exit' || text === 'quit') {
|
|
55
|
-
this.commander.cancel();
|
|
55
|
+
await this.commander.cancel();
|
|
56
56
|
this.commander.close();
|
|
57
57
|
this.logger.log('Bye');
|
|
58
58
|
process.exit(0);
|
|
59
59
|
} else if (text === 'check device') {
|
|
60
|
-
this.commander.logInfo();
|
|
60
|
+
await this.commander.logInfo();
|
|
61
61
|
} else if (this.supportsNewDevice() && text === 'new device') {
|
|
62
62
|
this.newDevice();
|
|
63
63
|
} else if (text.startsWith('{')) {
|
package/lib/cli/serve/index.js
CHANGED
|
@@ -10,14 +10,44 @@ function createApp(commander) {
|
|
|
10
10
|
const app = express();
|
|
11
11
|
|
|
12
12
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
13
|
+
app.use(express.text({type: '*/*'}));
|
|
14
|
+
|
|
13
15
|
app.post('/run', (req, res) => {
|
|
14
|
-
let
|
|
15
|
-
req.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
let reset = req.query.reset === 'true';
|
|
17
|
+
commander.run(req.body || '', reset);
|
|
18
|
+
res.sendStatus(202);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
app.post('/cancel', async (req, res) => {
|
|
22
|
+
await commander.cancel();
|
|
23
|
+
res.sendStatus(200);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
app.post('/definitions', (req, res) => {
|
|
27
|
+
commander.runDefinitions(req.body || '');
|
|
28
|
+
res.sendStatus(202);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
app.get('/commands', async (req, res) => {
|
|
32
|
+
res.json(await commander.fetchCommandNames());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.get('/commands/:name', (req, res) => {
|
|
36
|
+
let command = commander.interpreter.lookup(req.params.name);
|
|
37
|
+
if (!command) {
|
|
38
|
+
res.status(404).send(`Command not found: "${req.params.name}"`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let helpText = commander.formatter.format(command.meta);
|
|
42
|
+
res.type('text/x-ansi').send(helpText);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.get('/info', (req, res) => {
|
|
46
|
+
if (commander.manager) {
|
|
47
|
+
res.json(commander.manager.info());
|
|
48
|
+
} else {
|
|
49
|
+
res.json([]);
|
|
50
|
+
}
|
|
21
51
|
});
|
|
22
52
|
|
|
23
53
|
return app;
|
package/lib/commander.js
CHANGED
|
@@ -89,7 +89,7 @@ class Commander {
|
|
|
89
89
|
/**
|
|
90
90
|
* Cancels any currently executing command.
|
|
91
91
|
*/
|
|
92
|
-
cancel() {
|
|
92
|
+
async cancel() {
|
|
93
93
|
this.interpreter.cancel();
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -248,11 +248,19 @@ class Commander {
|
|
|
248
248
|
return this.interpreter.commands;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Fetches all supported command names.
|
|
253
|
+
* @returns {Promise<string[]>} A promise that resolves with the command names.
|
|
254
|
+
*/
|
|
255
|
+
async fetchCommandNames() {
|
|
256
|
+
return this.interpreter.commandNames;
|
|
257
|
+
}
|
|
258
|
+
|
|
251
259
|
/**
|
|
252
260
|
* Logs the help info for the given command name.
|
|
253
261
|
* @param {string} commandName - Name of the command to log help info.
|
|
254
262
|
*/
|
|
255
|
-
help(commandName) {
|
|
263
|
+
async help(commandName) {
|
|
256
264
|
let command = this.interpreter.lookup(commandName);
|
|
257
265
|
if (!command) {
|
|
258
266
|
this.logger.error(`Command not found: "${commandName}"`);
|
|
@@ -264,7 +272,7 @@ class Commander {
|
|
|
264
272
|
/**
|
|
265
273
|
* Logs information about known traffic lights.
|
|
266
274
|
*/
|
|
267
|
-
logInfo() {
|
|
275
|
+
async logInfo() {
|
|
268
276
|
this.selector.logInfo(this.logger);
|
|
269
277
|
}
|
|
270
278
|
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const {Commander} = require('./commander');
|
|
2
|
+
const {RestCommander} = require('./rest-commander');
|
|
2
3
|
|
|
3
4
|
module.exports = {
|
|
4
5
|
commands: require('./commands'),
|
|
@@ -6,5 +7,6 @@ module.exports = {
|
|
|
6
7
|
physical: require('./physical'),
|
|
7
8
|
devices: require('./devices'),
|
|
8
9
|
selectors: require('./selectors'),
|
|
9
|
-
Commander
|
|
10
|
+
Commander,
|
|
11
|
+
RestCommander
|
|
10
12
|
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
////////////////////////////////////////////////
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
|
|
6
|
+
////////////////////////////////////////////////
|
|
7
|
+
|
|
8
|
+
function request(baseUrl, method, path, body) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let url = new URL(path, baseUrl);
|
|
11
|
+
let client = url.protocol === 'https:' ? https : http;
|
|
12
|
+
let options = {
|
|
13
|
+
method,
|
|
14
|
+
hostname: url.hostname,
|
|
15
|
+
port: url.port,
|
|
16
|
+
path: url.pathname + url.search,
|
|
17
|
+
headers: {}
|
|
18
|
+
};
|
|
19
|
+
if (body != null) {
|
|
20
|
+
options.headers['Content-Type'] = 'text/plain';
|
|
21
|
+
}
|
|
22
|
+
let req = client.request(options, (res) => {
|
|
23
|
+
let data = '';
|
|
24
|
+
res.on('data', chunk => data += chunk);
|
|
25
|
+
res.on('end', () => resolve({statusCode: res.statusCode, body: data}));
|
|
26
|
+
});
|
|
27
|
+
req.on('error', reject);
|
|
28
|
+
if (body != null) req.write(body);
|
|
29
|
+
req.end();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
////////////////////////////////////////////////
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A Commander client that controls a traffic light via a remote REST API.
|
|
37
|
+
* Communicates with a server started by the `serve` command.
|
|
38
|
+
*/
|
|
39
|
+
class RestCommander {
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a new RestCommander instance.
|
|
43
|
+
* @param {object} [options] - Options.
|
|
44
|
+
* @param {string} [options.host='http://localhost:9000'] - The base URL of
|
|
45
|
+
* the remote server (e.g. `http://localhost:9000`).
|
|
46
|
+
* @param {object} [options.logger=console] - A Console-like object for logging.
|
|
47
|
+
*/
|
|
48
|
+
constructor({host = 'http://localhost:9000', logger = console} = {}) {
|
|
49
|
+
this.host = host.replace(/\/+$/, ''); // strip trailing slashes
|
|
50
|
+
this.logger = logger;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Executes a command on the remote server (fire-and-forget).
|
|
55
|
+
* @param {string} command - Command to execute.
|
|
56
|
+
* @param {boolean} [reset=false] - Whether to reset the traffic light first.
|
|
57
|
+
*/
|
|
58
|
+
async run(command, reset = false) {
|
|
59
|
+
let path = reset ? '/run?reset=true' : '/run';
|
|
60
|
+
await request(this.host, 'POST', path, command);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Cancels any currently executing command on the remote server.
|
|
65
|
+
*/
|
|
66
|
+
async cancel() {
|
|
67
|
+
await request(this.host, 'POST', '/cancel');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Executes definition-only commands on the remote server.
|
|
72
|
+
* @param {string} command - Command with definitions to execute.
|
|
73
|
+
*/
|
|
74
|
+
async runDefinitions(command) {
|
|
75
|
+
await request(this.host, 'POST', '/definitions', command);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fetches all available command names from the remote server.
|
|
80
|
+
* @returns {Promise<string[]>} Array of command names.
|
|
81
|
+
*/
|
|
82
|
+
async fetchCommandNames() {
|
|
83
|
+
let res = await request(this.host, 'GET', '/commands');
|
|
84
|
+
return JSON.parse(res.body);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Logs the help info for the given command name from the remote server.
|
|
89
|
+
* @param {string} commandName - Name of the command.
|
|
90
|
+
*/
|
|
91
|
+
async help(commandName) {
|
|
92
|
+
let res = await request(this.host, 'GET', `/commands/${encodeURIComponent(commandName)}`);
|
|
93
|
+
if (res.statusCode === 404) {
|
|
94
|
+
this.logger.error(res.body);
|
|
95
|
+
} else {
|
|
96
|
+
this.logger.log(res.body);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Logs information about known traffic lights from the remote server.
|
|
102
|
+
*/
|
|
103
|
+
async logInfo() {
|
|
104
|
+
let res = await request(this.host, 'GET', '/info');
|
|
105
|
+
let devicesInfo = JSON.parse(res.body);
|
|
106
|
+
if (devicesInfo.length === 0) {
|
|
107
|
+
this.logger.log('No devices found');
|
|
108
|
+
} else {
|
|
109
|
+
this.logger.log('Known devices:');
|
|
110
|
+
devicesInfo.forEach(info =>
|
|
111
|
+
this.logger.log(`device ${info.serialNum}: ${info.status}`));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Closes this instance. No-op for REST client.
|
|
117
|
+
*/
|
|
118
|
+
close() {}
|
|
119
|
+
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
////////////////////////////////////////////////
|
|
123
|
+
|
|
124
|
+
module.exports = {RestCommander};
|
|
125
|
+
|
|
126
|
+
////////////////////////////////////////////////
|