mqtt2elasticsearch 1.0.0 → 2.0.0
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 +81 -28
- package/config.js +100 -37
- package/index.js +58 -125
- package/lib/document.js +89 -0
- package/lib/elastic.js +159 -0
- package/lib/install.js +18 -0
- package/package.json +40 -19
- package/.npmignore +0 -3
- package/.travis.yml +0 -4
package/README.md
CHANGED
|
@@ -2,44 +2,97 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mqtt-smarthome/mqtt-smarthome)
|
|
4
4
|
[](http://badge.fury.io/js/mqtt2elasticsearch)
|
|
5
|
-
[](https://travis-ci.org/hobbyquaker/mqtt2elasticsearch)
|
|
7
|
-
[](https://github.com/sindresorhus/xo)
|
|
5
|
+
[](https://github.com/hobbyquaker/mqtt2elasticsearch/actions/workflows/ci.yml)
|
|
8
6
|
[![License][mit-badge]][mit-url]
|
|
9
7
|
|
|
10
|
-
|
|
8
|
+
Index MQTT messages in Elasticsearch — searchable history and Kibana dashboards for everything on
|
|
9
|
+
your broker.
|
|
11
10
|
|
|
11
|
+
Subscribes to the topics you name and writes one document per message, in bulk, into a daily index.
|
|
12
|
+
Unlike [influx4mqtt](https://github.com/hobbyquaker/influx4mqtt), which keeps numbers only, this
|
|
13
|
+
keeps **every** message: text payloads, JSON, the lot.
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
Built on [mqtt-interfaces-core](https://github.com/hobbyquaker/mqtt-interfaces-core), so it has the
|
|
16
|
+
same options, `--install`, `<name>/info` and maintenance topics as the rest of the `xyz2mqtt` fleet.
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
Requires **Elasticsearch 7 or newer** (or OpenSearch). Version 1.x of this package wrote ES 6
|
|
19
|
+
mapping types and does not work with anything released since 2019.
|
|
16
20
|
|
|
21
|
+
## Install
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
```
|
|
24
|
+
npm install -g mqtt2elasticsearch
|
|
25
|
+
mqtt2elasticsearch -u mqtt://broker -e http://elastic:9200
|
|
26
|
+
sudo mqtt2elasticsearch --install -n elastic -u mqtt://broker # systemd service
|
|
27
|
+
```
|
|
19
28
|
|
|
29
|
+
### Docker
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
docker run -d --name mqtt2elasticsearch --restart unless-stopped \
|
|
33
|
+
-e MQTT2ELASTICSEARCH_MQTT_URL=mqtt://broker \
|
|
34
|
+
-e MQTT2ELASTICSEARCH_ES_URL=http://elastic:9200 \
|
|
35
|
+
-e MQTT2ELASTICSEARCH_SUBSCRIBE='+/status/#' \
|
|
36
|
+
ghcr.io/hobbyquaker/mqtt2elasticsearch
|
|
20
37
|
```
|
|
21
|
-
Usage: mqtt2elasticsearch [options]
|
|
22
|
-
|
|
23
|
-
Options:
|
|
24
|
-
-e, --elastic-url elasticsearch url [default: "http://localhost:9200"]
|
|
25
|
-
-v, --verbosity possible values: "error", "warn", "info", "debug"
|
|
26
|
-
[default: "info"]
|
|
27
|
-
-n, --name instance name. used as connected topic and client id
|
|
28
|
-
prefix [default: "elasticsearch"]
|
|
29
|
-
-u, --url mqtt broker url. May contain user/password
|
|
30
|
-
[default: "mqtt://localhost"]
|
|
31
|
-
-m, --mqtt-smarthome parse mqtt-smarthome payloads [boolean]
|
|
32
|
-
-s, --subscribe mqtt topic to subscribe. may be repeated [required]
|
|
33
|
-
-h, --help Show help [boolean]
|
|
34
|
-
-k, --insecure allow tls connections with invalid certificates[boolean]
|
|
35
|
-
--version Show version number [boolean]
|
|
36
|
-
-i, --index [default: "mqtt"]
|
|
37
|
-
-t, --type [default: "mqtt"]
|
|
38
|
-
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
Example: `$ mqtt2elasticsearch -s '#'`
|
|
42
38
|
|
|
39
|
+
## What gets indexed
|
|
40
|
+
|
|
41
|
+
One document per message, in `<index>-YYYY.MM.DD` (UTC), created from an index template the adapter
|
|
42
|
+
writes on start:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"@timestamp": 1699999999000,
|
|
47
|
+
"topic": "hm/status/Wohnzimmer/TEMPERATURE",
|
|
48
|
+
"payload": "{\"val\":21.5,\"ts\":1699999999000}",
|
|
49
|
+
"val_number": 21.5,
|
|
50
|
+
"ts": 1699999999000
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **`{val, ts, lc}` payloads are unpacked** (`--mqtt-smarthome`, on by default). The value goes in a
|
|
55
|
+
**type-suffixed field** — `val_number`, `val_boolean`, `val_string` — because Elasticsearch fixes
|
|
56
|
+
a field's type on first use, and one shared `val` would take the type of whatever arrived first
|
|
57
|
+
and then reject everything else.
|
|
58
|
+
- **The device's `ts` becomes `@timestamp`** when there is one, so a chart shows when a value was
|
|
59
|
+
measured rather than when it was indexed.
|
|
60
|
+
- **Retained messages are skipped.** The broker replays them on every reconnect, which would file
|
|
61
|
+
bursts of duplicates under the reconnect time. `--retained-messages` keeps them.
|
|
62
|
+
- **Daily indices** make retention a matter of deleting old indices, which is the only way
|
|
63
|
+
Elasticsearch deletes cheaply. Use ILM or a cron job.
|
|
64
|
+
|
|
65
|
+
## Options
|
|
66
|
+
|
|
67
|
+
| Option | Default | Meaning |
|
|
68
|
+
| ---------------------- | ----------------------- | ---------------------------------------------------- |
|
|
69
|
+
| `-s, --subscribe` | `#` | topic to index, `+`/`#` wildcards; repeat for more |
|
|
70
|
+
| `-u, --mqtt-url` | `mqtt://localhost` | broker url |
|
|
71
|
+
| `-n, --name` | `elasticsearch` | instance name = topic prefix of its own topics |
|
|
72
|
+
| `-e, --es-url` | `http://localhost:9200` | Elasticsearch base url |
|
|
73
|
+
| `-i, --index` | `mqtt` | index name prefix; one index per day |
|
|
74
|
+
| `--es-username` | | http basic auth |
|
|
75
|
+
| `--es-password` | | http basic auth |
|
|
76
|
+
| `--es-api-key` | | api key, used instead of username/password |
|
|
77
|
+
| `-m, --mqtt-smarthome` | `true` | unpack `{val, ts, lc}` into `val_<type>`, `ts`, `lc` |
|
|
78
|
+
| `--retained-messages` | `false` | also index retained messages |
|
|
79
|
+
| `--buf-length` | `500` | write once this many documents are buffered |
|
|
80
|
+
| `--buf-interval` | `10` | seconds between writes |
|
|
81
|
+
| `-v, --verbosity` | `info` | `error`, `warn`, `info`, `debug` |
|
|
82
|
+
|
|
83
|
+
Every option is also an environment variable (`MQTT2ELASTICSEARCH_SUBSCRIBE`, …); several
|
|
84
|
+
subscriptions are comma separated there. Put credentials in the instance's env file rather than on
|
|
85
|
+
the command line, where a process list would show them — `--install` does that for you.
|
|
86
|
+
|
|
87
|
+
## Topics of its own
|
|
88
|
+
|
|
89
|
+
| Topic | Meaning |
|
|
90
|
+
| --------------------------------- | --------------------------------------------------------------------- |
|
|
91
|
+
| `<name>/connected` | `2` Elasticsearch is accepting writes · `1` broker only · `0` stopped |
|
|
92
|
+
| `<name>/info` | endpoint, index, subscriptions, documents indexed |
|
|
93
|
+
| `<name>/maintenance/set/loglevel` | `error` \| `warn` \| `info` \| `debug` at runtime |
|
|
94
|
+
| `<name>/maintenance/set/restart` | graceful restart |
|
|
95
|
+
| `<name>/maintenance/stats` | memory, cpu, event loop lag, uptime |
|
|
43
96
|
|
|
44
97
|
## License
|
|
45
98
|
|
package/config.js
CHANGED
|
@@ -1,37 +1,100 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Adapter options on top of the core's parseConfig(): the shared MQTT / name / maintenance
|
|
3
|
+
* options, MQTT2ELASTICSEARCH_* environment variables and --config-schema come from
|
|
4
|
+
* mqtt-interfaces-core; only what is specific to Elasticsearch is defined here.
|
|
5
|
+
*
|
|
6
|
+
* Targets Elasticsearch 7+ and OpenSearch. `--type` is gone: mapping types were removed in ES 7.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {parseConfig} from 'mqtt-interfaces-core';
|
|
10
|
+
import pkg from './package.json' with {type: 'json'};
|
|
11
|
+
|
|
12
|
+
export const OPTIONS = {
|
|
13
|
+
subscribe: {
|
|
14
|
+
alias: 's',
|
|
15
|
+
type: 'array',
|
|
16
|
+
describe: 'mqtt topic to index, with + and # wildcards (repeatable)',
|
|
17
|
+
default: ['#'],
|
|
18
|
+
},
|
|
19
|
+
'es-url': {
|
|
20
|
+
alias: 'e',
|
|
21
|
+
type: 'string',
|
|
22
|
+
describe: 'elasticsearch base url',
|
|
23
|
+
default: 'http://localhost:9200',
|
|
24
|
+
},
|
|
25
|
+
index: {
|
|
26
|
+
alias: 'i',
|
|
27
|
+
type: 'string',
|
|
28
|
+
describe: 'index name prefix; one index per day (<prefix>-YYYY.MM.DD)',
|
|
29
|
+
default: 'mqtt',
|
|
30
|
+
},
|
|
31
|
+
'es-username': {
|
|
32
|
+
type: 'string',
|
|
33
|
+
describe: 'elasticsearch username (http basic auth)',
|
|
34
|
+
},
|
|
35
|
+
'es-password': {
|
|
36
|
+
type: 'string',
|
|
37
|
+
describe: 'elasticsearch password',
|
|
38
|
+
secret: true,
|
|
39
|
+
},
|
|
40
|
+
'es-api-key': {
|
|
41
|
+
type: 'string',
|
|
42
|
+
describe: 'elasticsearch api key, used instead of username/password',
|
|
43
|
+
secret: true,
|
|
44
|
+
},
|
|
45
|
+
'mqtt-smarthome': {
|
|
46
|
+
alias: 'm',
|
|
47
|
+
type: 'boolean',
|
|
48
|
+
describe: 'unpack {val, ts, lc} payloads into val_<type>, ts and lc fields',
|
|
49
|
+
default: true,
|
|
50
|
+
},
|
|
51
|
+
'retained-messages': {
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
describe: 'also index retained messages, which the broker replays on every reconnect',
|
|
54
|
+
default: false,
|
|
55
|
+
},
|
|
56
|
+
'buf-length': {
|
|
57
|
+
type: 'number',
|
|
58
|
+
describe: 'write to elasticsearch once this many documents are buffered',
|
|
59
|
+
default: 500,
|
|
60
|
+
},
|
|
61
|
+
'buf-interval': {
|
|
62
|
+
type: 'number',
|
|
63
|
+
describe: 'seconds between writes, however few documents are buffered',
|
|
64
|
+
default: 10,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** yargs .check(): value ranges the option types cannot express. */
|
|
69
|
+
export function check(argv) {
|
|
70
|
+
if (!(argv.bufLength >= 1)) {
|
|
71
|
+
throw new Error('--buf-length must be >= 1');
|
|
72
|
+
}
|
|
73
|
+
if (!(argv.bufInterval >= 1)) {
|
|
74
|
+
throw new Error('--buf-interval must be >= 1 second');
|
|
75
|
+
}
|
|
76
|
+
if (!/^https?:\/\//.test(String(argv.esUrl))) {
|
|
77
|
+
throw new Error('--es-url must start with http:// or https://');
|
|
78
|
+
}
|
|
79
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(String(argv.index))) {
|
|
80
|
+
throw new Error('--index must be lower case and start with a letter or digit');
|
|
81
|
+
}
|
|
82
|
+
for (const topic of argv.subscribe || []) {
|
|
83
|
+
if (!topic || /#.+/.test(topic)) {
|
|
84
|
+
throw new Error(`--subscribe ${topic}: # is only allowed as the last level`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default parseConfig({
|
|
91
|
+
pkg,
|
|
92
|
+
options: OPTIONS,
|
|
93
|
+
defaults: {name: 'elasticsearch'},
|
|
94
|
+
check,
|
|
95
|
+
examples: [
|
|
96
|
+
['$0 -u mqtt://broker -e http://elastic:9200', 'index every topic'],
|
|
97
|
+
['$0 -u mqtt://broker -s "+/status/#" -s "$SYS/#"', 'index two topic patterns'],
|
|
98
|
+
['sudo $0 --install -n elastic -u mqtt://broker', 'install as mqtt2elasticsearch@elastic'],
|
|
99
|
+
],
|
|
100
|
+
});
|
package/index.js
CHANGED
|
@@ -1,133 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
config.subscribe = [config.subscribe];
|
|
35
|
-
}
|
|
36
|
-
config.subscribe.forEach(topic => {
|
|
37
|
-
log.info('mqtt subscribe', topic);
|
|
38
|
-
mqtt.subscribe(topic);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
mqtt.on('close', () => {
|
|
43
|
-
if (mqttConnected) {
|
|
44
|
-
mqttConnected = false;
|
|
45
|
-
log.info('mqtt closed ' + config.url);
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
mqtt.on('error', err => {
|
|
50
|
-
log.error('mqtt', err.message);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
mqtt.on('message', (topic, payload, msg) => {
|
|
54
|
-
if (msg.retain) {
|
|
3
|
+
/**
|
|
4
|
+
* mqtt2elasticsearch — index MQTT messages in Elasticsearch, on mqtt-interfaces-core.
|
|
5
|
+
*
|
|
6
|
+
* A *sink*: it has no device of its own, its subject is what every other adapter publishes. The
|
|
7
|
+
* core's `listen` gives it the subscriptions (absolute topics, not under `<name>/`);
|
|
8
|
+
* lib/document.js decides what a message becomes and lib/elastic.js writes documents in bulk.
|
|
9
|
+
*
|
|
10
|
+
* `<name>/connected` reports 2 while Elasticsearch is accepting writes and 1 while it is not.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {createAdapter} from 'mqtt-interfaces-core';
|
|
14
|
+
import config from './config.js';
|
|
15
|
+
import pkg from './package.json' with {type: 'json'};
|
|
16
|
+
import {handle as handleInstall} from './lib/install.js';
|
|
17
|
+
import {document} from './lib/document.js';
|
|
18
|
+
import {createWriter, putTemplate} from './lib/elastic.js';
|
|
19
|
+
|
|
20
|
+
handleInstall(config); // --install / --uninstall never reach the rest
|
|
21
|
+
|
|
22
|
+
let writer;
|
|
23
|
+
let indexed = 0;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One message -> one document.
|
|
27
|
+
*
|
|
28
|
+
* Retained messages are skipped by default: the broker replays the retained value of every
|
|
29
|
+
* matching topic on each reconnect, and indexing those would file a burst of duplicates under the
|
|
30
|
+
* reconnect time. `--retained-messages` keeps them for anyone who wants the snapshot.
|
|
31
|
+
*/
|
|
32
|
+
function index(topic, value, raw, packet) {
|
|
33
|
+
if (packet.retain && !config.retainedMessages) {
|
|
55
34
|
return;
|
|
56
35
|
}
|
|
36
|
+
indexed++;
|
|
37
|
+
writer.add(document(topic, raw, {mqttSmarthome: config.mqttSmarthome}));
|
|
38
|
+
}
|
|
57
39
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
data.lc = json.lc;
|
|
74
|
-
}
|
|
75
|
-
} catch (err) {}
|
|
76
|
-
}
|
|
77
|
-
const body = JSON.stringify(data);
|
|
78
|
-
|
|
79
|
-
const d = new Date();
|
|
80
|
-
const date = d.getDate();
|
|
81
|
-
if (date !== currentDate) {
|
|
82
|
-
const index = config.index + '-' + d.getFullYear() + '.' + ('0' + (d.getMonth() + 1)).substr(-2) + '.' + ('0' + date).substr(-2);
|
|
83
|
-
url = config.elasticUrl + '/' + index + '/' + config.type + '/';
|
|
84
|
-
currentDate = date;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
log.debug('post', url, body);
|
|
88
|
-
request.post({
|
|
89
|
-
url,
|
|
90
|
-
headers: {
|
|
91
|
-
'Content-Type': 'application/json'
|
|
92
|
-
},
|
|
93
|
-
body,
|
|
94
|
-
strictSSL: !config.insecure
|
|
95
|
-
}, (err, res) => {
|
|
96
|
-
if (err) {
|
|
97
|
-
log.error(err);
|
|
98
|
-
} else if (res.statusCode !== 200 && res.statusCode !== 201) {
|
|
99
|
-
log.error(res.statusCode, res.body);
|
|
100
|
-
}
|
|
101
|
-
});
|
|
40
|
+
const adapter = createAdapter({
|
|
41
|
+
pkg,
|
|
42
|
+
config,
|
|
43
|
+
deviceLabel: 'elasticsearch',
|
|
44
|
+
info: () => ({
|
|
45
|
+
elasticsearch: config.esUrl,
|
|
46
|
+
index: config.index,
|
|
47
|
+
subscribe: config.subscribe,
|
|
48
|
+
mqttSmarthome: config.mqttSmarthome,
|
|
49
|
+
buffered: writer ? writer.size : 0,
|
|
50
|
+
indexed,
|
|
51
|
+
}),
|
|
52
|
+
// a sink: the topics are the user's, anywhere on the broker, not under <name>/
|
|
53
|
+
listen: Object.fromEntries(config.subscribe.map((topic) => [topic, index])),
|
|
54
|
+
onShutdown: () => writer.stop(),
|
|
102
55
|
});
|
|
103
56
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
};
|
|
109
|
-
data.mappings[config.type] = {
|
|
110
|
-
properties: {
|
|
111
|
-
'@timestamp': {type: 'date'}
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
if (config.mqttSmarthome) {
|
|
115
|
-
data.mappings[config.type].properties.ts = {type: 'date'};
|
|
116
|
-
data.mappings[config.type].properties.lc = {type: 'date'};
|
|
117
|
-
data.mappings[config.type].properties.val_number = {type: 'float'}; // eslint-disable-line camelcase
|
|
118
|
-
}
|
|
119
|
-
log.debug('create mappings', JSON.stringify(data));
|
|
120
|
-
request.put({
|
|
121
|
-
url: config.elasticUrl + '/_template/' + config.index,
|
|
122
|
-
headers: {
|
|
123
|
-
'Content-Type': 'application/json'
|
|
124
|
-
},
|
|
125
|
-
body: JSON.stringify(data),
|
|
126
|
-
strictSSL: !config.insecure
|
|
127
|
-
}, err => {
|
|
128
|
-
if (err) {
|
|
129
|
-
log.error(err.message);
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
}
|
|
57
|
+
writer = createWriter(config, {
|
|
58
|
+
log: adapter.log,
|
|
59
|
+
onState: (up) => adapter.setDeviceConnected(up),
|
|
60
|
+
});
|
|
133
61
|
|
|
62
|
+
adapter.log.info(`elasticsearch at ${config.esUrl}, index ${config.index}-YYYY.MM.DD`);
|
|
63
|
+
// the template decides the field types of every index created from now on; a failure is logged
|
|
64
|
+
await putTemplate(config, {log: adapter.log});
|
|
65
|
+
writer.start();
|
|
66
|
+
adapter.start();
|
package/lib/document.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MQTT message -> the Elasticsearch document, and the index it goes in. Pure, so the mapping
|
|
3
|
+
* rules are testable without a cluster.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Two digits, for the date in an index name. */
|
|
7
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The index a message belongs in: one per day, so retention is a matter of deleting old indices
|
|
11
|
+
* rather than deleting documents — which is the only way Elasticsearch deletes cheaply.
|
|
12
|
+
*
|
|
13
|
+
* The date is **UTC**, as Logstash and Filebeat write theirs. 1.x used the host's local time,
|
|
14
|
+
* which puts an index boundary at 22:00 or 23:00 UTC depending on the season and makes the
|
|
15
|
+
* indices of a DST changeover overlap or gap. `@timestamp` is what Kibana charts against; the
|
|
16
|
+
* index name only has to be stable and sortable.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} prefix `--index`
|
|
19
|
+
* @param {Date} date
|
|
20
|
+
*/
|
|
21
|
+
export function indexName(prefix, date) {
|
|
22
|
+
return `${prefix}-${date.getUTCFullYear()}.${pad(date.getUTCMonth() + 1)}.${pad(date.getUTCDate())}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The document.
|
|
27
|
+
*
|
|
28
|
+
* `topic` and `payload` are always there. With `--mqtt-smarthome` a `{val, ts, lc}` payload is
|
|
29
|
+
* unpacked as well, and the value is indexed under a **type-suffixed** field — `val_number`,
|
|
30
|
+
* `val_boolean`, `val_string`. Elasticsearch fixes a field's type on first use, so one shared
|
|
31
|
+
* `val` field would take the type of whatever arrived first and then reject everything else;
|
|
32
|
+
* splitting by type is what keeps a number searchable as a number.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} topic
|
|
35
|
+
* @param {string} raw the payload as it arrived
|
|
36
|
+
* @param {{mqttSmarthome?: boolean, now?: number}} [options]
|
|
37
|
+
*/
|
|
38
|
+
export function document(topic, raw, {mqttSmarthome = false, now = Date.now()} = {}) {
|
|
39
|
+
const doc = {'@timestamp': now, topic, payload: raw};
|
|
40
|
+
if (!mqttSmarthome) {
|
|
41
|
+
return doc;
|
|
42
|
+
}
|
|
43
|
+
let json;
|
|
44
|
+
try {
|
|
45
|
+
json = JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return doc; // a plain payload; `payload` already holds it
|
|
48
|
+
}
|
|
49
|
+
if (!json || typeof json !== 'object' || json.val === undefined) {
|
|
50
|
+
return doc;
|
|
51
|
+
}
|
|
52
|
+
const type = json.val === null ? 'null' : typeof json.val;
|
|
53
|
+
doc[`val_${type}`] = json.val;
|
|
54
|
+
if (Number(json.ts) > 0) {
|
|
55
|
+
doc.ts = json.ts;
|
|
56
|
+
doc['@timestamp'] = json.ts; // the device's own time is the better one to chart against
|
|
57
|
+
}
|
|
58
|
+
if (Number(json.lc) > 0) {
|
|
59
|
+
doc.lc = json.lc;
|
|
60
|
+
}
|
|
61
|
+
return doc;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The index template every daily index is created from.
|
|
66
|
+
*
|
|
67
|
+
* Elasticsearch 7 removed mapping types and 8 removed the last of the legacy `_template` shape, so
|
|
68
|
+
* this is the modern one: `PUT _index_template/<name>` with the mapping directly under
|
|
69
|
+
* `template.mappings`, no type level anywhere. 1.x wrote the ES 6 shape and fails on anything
|
|
70
|
+
* current.
|
|
71
|
+
*/
|
|
72
|
+
export function indexTemplate(prefix, {mqttSmarthome = false} = {}) {
|
|
73
|
+
const properties = {
|
|
74
|
+
'@timestamp': {type: 'date'},
|
|
75
|
+
topic: {type: 'keyword'},
|
|
76
|
+
payload: {type: 'text'},
|
|
77
|
+
};
|
|
78
|
+
if (mqttSmarthome) {
|
|
79
|
+
properties.ts = {type: 'date'};
|
|
80
|
+
properties.lc = {type: 'date'};
|
|
81
|
+
properties.val_number = {type: 'double'};
|
|
82
|
+
properties.val_boolean = {type: 'boolean'};
|
|
83
|
+
properties.val_string = {type: 'text'};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
index_patterns: [`${prefix}-*`],
|
|
87
|
+
template: {mappings: {properties}},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/lib/elastic.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Elasticsearch client: the index template, and a bulk writer.
|
|
3
|
+
*
|
|
4
|
+
* 1.x posted one HTTP request per MQTT message, which is a request per state change of every
|
|
5
|
+
* device in the house. This buffers and uses the `_bulk` API instead — the same trade as
|
|
6
|
+
* influx4mqtt, and the reason the buffer has to be flushed on shutdown.
|
|
7
|
+
*
|
|
8
|
+
* Targets Elasticsearch 7+ (and OpenSearch): typeless documents, `_index_template`. ES 6 and its
|
|
9
|
+
* mapping types are not supported.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {indexName, indexTemplate} from './document.js';
|
|
13
|
+
|
|
14
|
+
/** Auth header from an api key or basic credentials, if any were given. */
|
|
15
|
+
export function authHeader({esApiKey, esUsername, esPassword}) {
|
|
16
|
+
if (esApiKey) {
|
|
17
|
+
return {authorization: `ApiKey ${esApiKey}`};
|
|
18
|
+
}
|
|
19
|
+
if (esUsername) {
|
|
20
|
+
return {authorization: 'Basic ' + Buffer.from(`${esUsername}:${esPassword || ''}`).toString('base64')};
|
|
21
|
+
}
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `PUT _index_template/<prefix>` — so every daily index gets the right field types.
|
|
27
|
+
*
|
|
28
|
+
* A failure is logged, not fatal: an existing template, or a cluster that only allows writes, is
|
|
29
|
+
* no reason to refuse to record anything.
|
|
30
|
+
*/
|
|
31
|
+
export async function putTemplate(config, {log = console, fetchImpl = globalThis.fetch} = {}) {
|
|
32
|
+
const base = String(config.esUrl).replace(/\/+$/, '');
|
|
33
|
+
const url = `${base}/_index_template/${encodeURIComponent(config.index)}`;
|
|
34
|
+
const body = JSON.stringify(indexTemplate(config.index, {mqttSmarthome: config.mqttSmarthome}));
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetchImpl(url, {
|
|
37
|
+
method: 'PUT',
|
|
38
|
+
headers: {'content-type': 'application/json', ...authHeader(config)},
|
|
39
|
+
body,
|
|
40
|
+
});
|
|
41
|
+
if (response.status >= 200 && response.status < 300) {
|
|
42
|
+
log.debug('elasticsearch index template', config.index, 'is up to date');
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
log.warn('elasticsearch index template rejected', response.status, (await response.text()).slice(0, 200));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
log.warn('elasticsearch index template failed —', error.message);
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A buffer of documents, written with the `_bulk` API.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} config parsed config
|
|
56
|
+
* @param {{log?: object, fetchImpl?: Function, onState?: (up: boolean) => void, now?: () => Date}} [deps]
|
|
57
|
+
*/
|
|
58
|
+
export function createWriter(
|
|
59
|
+
config,
|
|
60
|
+
{log = console, fetchImpl = globalThis.fetch, onState, now = () => new Date()} = {},
|
|
61
|
+
) {
|
|
62
|
+
const base = String(config.esUrl).replace(/\/+$/, '');
|
|
63
|
+
const url = `${base}/_bulk`;
|
|
64
|
+
const headers = {'content-type': 'application/x-ndjson', ...authHeader(config)};
|
|
65
|
+
const max = Number(config.bufLength) > 0 ? Number(config.bufLength) : 500;
|
|
66
|
+
let buffer = [];
|
|
67
|
+
let timer = null;
|
|
68
|
+
let up = null;
|
|
69
|
+
let inFlight = null;
|
|
70
|
+
|
|
71
|
+
function state(next) {
|
|
72
|
+
if (next !== up) {
|
|
73
|
+
up = next;
|
|
74
|
+
if (onState) {
|
|
75
|
+
onState(next);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function send(docs) {
|
|
81
|
+
// ndjson: an action line and a source line per document, and a trailing newline
|
|
82
|
+
const body =
|
|
83
|
+
docs
|
|
84
|
+
.map(({index, doc}) => JSON.stringify({index: {_index: index}}) + '\n' + JSON.stringify(doc))
|
|
85
|
+
.join('\n') + '\n';
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
response = await fetchImpl(url, {method: 'POST', headers, body});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
log.warn('elasticsearch unreachable —', error.message, `(${docs.length} documents dropped)`);
|
|
91
|
+
state(false);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
if (response.status < 200 || response.status >= 300) {
|
|
95
|
+
log.error('elasticsearch bulk failed', response.status, (await response.text()).slice(0, 300));
|
|
96
|
+
state(false);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
// a 200 can still contain per-document failures — a mapping conflict looks like this
|
|
100
|
+
try {
|
|
101
|
+
const result = await response.json();
|
|
102
|
+
if (result && result.errors) {
|
|
103
|
+
const first = (result.items || []).find((i) => i.index && i.index.error);
|
|
104
|
+
log.warn(
|
|
105
|
+
'elasticsearch rejected some documents:',
|
|
106
|
+
first ? JSON.stringify(first.index.error) : 'unknown',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// a body we cannot parse does not change that the request succeeded
|
|
111
|
+
}
|
|
112
|
+
log.debug('elasticsearch wrote', docs.length, 'documents');
|
|
113
|
+
state(true);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function flush() {
|
|
118
|
+
if (buffer.length === 0) {
|
|
119
|
+
return inFlight || Promise.resolve(true);
|
|
120
|
+
}
|
|
121
|
+
const docs = buffer;
|
|
122
|
+
buffer = [];
|
|
123
|
+
inFlight = Promise.resolve(inFlight)
|
|
124
|
+
.catch(() => {})
|
|
125
|
+
.then(() => send(docs));
|
|
126
|
+
return inFlight;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
/** Buffer one document, in the index for today. */
|
|
131
|
+
add(doc) {
|
|
132
|
+
buffer.push({index: indexName(config.index, now()), doc});
|
|
133
|
+
if (buffer.length >= max) {
|
|
134
|
+
flush();
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
flush,
|
|
138
|
+
get size() {
|
|
139
|
+
return buffer.length;
|
|
140
|
+
},
|
|
141
|
+
get url() {
|
|
142
|
+
return url;
|
|
143
|
+
},
|
|
144
|
+
start() {
|
|
145
|
+
const seconds = Number(config.bufInterval) > 0 ? Number(config.bufInterval) : 10;
|
|
146
|
+
timer = setInterval(flush, seconds * 1000);
|
|
147
|
+
if (timer.unref) {
|
|
148
|
+
timer.unref();
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
async stop() {
|
|
152
|
+
if (timer) {
|
|
153
|
+
clearInterval(timer);
|
|
154
|
+
timer = null;
|
|
155
|
+
}
|
|
156
|
+
await flush();
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --install / --uninstall: systemd template service mqtt2elasticsearch@<name>
|
|
3
|
+
* (mqtt-interfaces-core installer). Nothing privileged and no local state.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {createInstaller} from 'mqtt-interfaces-core';
|
|
7
|
+
|
|
8
|
+
export const SERVICE = 'mqtt2elasticsearch';
|
|
9
|
+
export const ENV_PREFIX = 'MQTT2ELASTICSEARCH';
|
|
10
|
+
|
|
11
|
+
const installer = createInstaller({
|
|
12
|
+
service: SERVICE,
|
|
13
|
+
envPrefix: ENV_PREFIX,
|
|
14
|
+
description: `${SERVICE} %i - MQTT to Elasticsearch`,
|
|
15
|
+
documentation: 'https://github.com/hobbyquaker/mqtt2elasticsearch',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const {unitFile, envFile, installService, uninstallService, handle} = installer;
|
package/package.json
CHANGED
|
@@ -1,42 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mqtt2elasticsearch",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Send MQTT messages to Elasticsearch",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Send MQTT messages to Elasticsearch. Follows the mqtt-smarthome architecture.",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "index.js",
|
|
6
7
|
"bin": {
|
|
7
8
|
"mqtt2elasticsearch": "index.js"
|
|
8
9
|
},
|
|
9
10
|
"preferGlobal": true,
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"config.js",
|
|
14
|
+
"lib/"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": "^20.19 || ^22.12 || >=24"
|
|
18
|
+
},
|
|
10
19
|
"scripts": {
|
|
11
|
-
"
|
|
20
|
+
"start": "node index.js",
|
|
21
|
+
"lint": "eslint . && prettier --check .",
|
|
22
|
+
"format": "prettier --write . && eslint --fix .",
|
|
23
|
+
"test": "node --test",
|
|
24
|
+
"deploy": "bash deploy.sh"
|
|
12
25
|
},
|
|
13
26
|
"repository": {
|
|
14
27
|
"type": "git",
|
|
15
|
-
"url": "https://github.com/hobbyquaker/mqtt2elasticsearch"
|
|
28
|
+
"url": "git+https://github.com/hobbyquaker/mqtt2elasticsearch.git"
|
|
16
29
|
},
|
|
30
|
+
"homepage": "https://github.com/hobbyquaker/mqtt2elasticsearch",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/hobbyquaker/mqtt2elasticsearch/issues"
|
|
33
|
+
},
|
|
34
|
+
"author": "Sebastian Raff <hobbyquaker@gmail.com> (https://github.com/hobbyquaker)",
|
|
35
|
+
"license": "MIT",
|
|
17
36
|
"keywords": [
|
|
18
37
|
"mqtt",
|
|
19
|
-
"smarthome",
|
|
38
|
+
"mqtt-smarthome",
|
|
39
|
+
"home-automation",
|
|
20
40
|
"elasticsearch",
|
|
41
|
+
"opensearch",
|
|
42
|
+
"kibana",
|
|
21
43
|
"log"
|
|
22
44
|
],
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
45
|
+
"mqttInterfaces": {
|
|
46
|
+
"spec": "2.0",
|
|
47
|
+
"envPrefix": "MQTT2ELASTICSEARCH",
|
|
48
|
+
"needs": [
|
|
49
|
+
"network"
|
|
50
|
+
],
|
|
51
|
+
"serviceExtra": []
|
|
27
52
|
},
|
|
28
|
-
"homepage": "https://github.com/hobbyquaker/mqtt2elasticsearch",
|
|
29
53
|
"dependencies": {
|
|
30
|
-
"mqtt": "^
|
|
31
|
-
"request": "^2.85.0",
|
|
32
|
-
"yalm": "^4.1.0",
|
|
33
|
-
"yargs": "^11.0.0"
|
|
54
|
+
"mqtt-interfaces-core": "^0.13.0"
|
|
34
55
|
},
|
|
35
56
|
"devDependencies": {
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
57
|
+
"@eslint/js": "^9",
|
|
58
|
+
"eslint": "^9",
|
|
59
|
+
"eslint-config-prettier": "^10",
|
|
60
|
+
"globals": "^16",
|
|
61
|
+
"prettier": "^3"
|
|
41
62
|
}
|
|
42
63
|
}
|
package/.npmignore
DELETED
package/.travis.yml
DELETED