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 CHANGED
@@ -2,44 +2,97 @@
2
2
 
3
3
  [![mqtt-smarthome](https://img.shields.io/badge/mqtt-smarthome-blue.svg)](https://github.com/mqtt-smarthome/mqtt-smarthome)
4
4
  [![NPM version](https://badge.fury.io/js/mqtt2elasticsearch.svg)](http://badge.fury.io/js/mqtt2elasticsearch)
5
- [![Dependency Status](https://img.shields.io/gemnasium/hobbyquaker/mqtt2elasticsearch.svg?maxAge=2592000)](https://gemnasium.com/github.com/hobbyquaker/mqtt2elasticsearch)
6
- [![Build Status](https://travis-ci.org/hobbyquaker/mqtt2elasticsearch.svg?branch=master)](https://travis-ci.org/hobbyquaker/mqtt2elasticsearch)
7
- [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo)
5
+ [![CI](https://github.com/hobbyquaker/mqtt2elasticsearch/actions/workflows/ci.yml/badge.svg)](https://github.com/hobbyquaker/mqtt2elasticsearch/actions/workflows/ci.yml)
8
6
  [![License][mit-badge]][mit-url]
9
7
 
10
- > Send MQTT messages to Elasticsearch
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
- ### Install
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
- `$ sudo npm install -g mqtt2elasticsearch`
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
- ### Usage
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
- module.exports = require('yargs')
2
- .usage('Usage: $0 [options]')
3
- .describe('e', 'elasticsearch url')
4
- .describe('v', 'possible values: "error", "warn", "info", "debug"')
5
- .describe('n', 'instance name. used as connected topic and client id prefix')
6
- .describe('u', 'mqtt broker url. See https://github.com/mqttjs/MQTT.js#connect-using-a-url')
7
- .describe('m', 'parse mqtt-smarthome payloads')
8
- .describe('s', 'mqtt topic to subscribe. may be repeated')
9
- .describe('h', 'show help')
10
- .describe('k', 'allow tls connections with invalid certificates')
11
- .alias({
12
- e: 'elastic-url',
13
- i: 'index',
14
- t: 'type',
15
- h: 'help',
16
- k: 'insecure',
17
- n: 'name',
18
- u: 'url',
19
- v: 'verbosity',
20
- c: 'cleanup',
21
- m: 'mqtt-smarthome',
22
- s: 'subscribe'
23
- })
24
- .demand('subscribe')
25
- .boolean('mqtt-smarthome')
26
- .boolean('insecure')
27
- .default({
28
- e: 'http://localhost:9200',
29
- u: 'mqtt://localhost',
30
- n: 'elasticsearch',
31
- v: 'info',
32
- i: 'mqtt',
33
- t: 'mqtt'
34
- })
35
- .version()
36
- .help('help')
37
- .argv;
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
- const Mqtt = require('mqtt');
4
- const request = require('request');
5
- const log = require('yalm');
6
- const pkg = require('./package.json');
7
- const config = require('./config.js');
8
-
9
- process.title = pkg.name;
10
- log.setLevel(config.verbosity);
11
- log.info(pkg.name + ' ' + pkg.version + ' starting');
12
-
13
- let mqttConnected;
14
- let url;
15
- let currentDate;
16
-
17
- createMapping();
18
-
19
- log.debug('mqtt trying to connect', config.url);
20
-
21
- const mqtt = Mqtt.connect(config.url, {
22
- will: {topic: config.name + '/connected', payload: '0', retain: true},
23
- clientId: config.name + '_' + Math.random().toString(16).substr(2, 8),
24
- rejectUnauthorized: !config.insecure
25
- });
26
-
27
- mqtt.on('connect', () => {
28
- mqttConnected = true;
29
-
30
- log.info('mqtt connected', config.url);
31
- mqtt.publish(config.name + '/connected', '2', {retain: true});
32
-
33
- if (typeof config.subscribe === 'string') {
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
- payload = payload.toString();
59
- log.debug('mqtt <', topic, payload);
60
-
61
- const data = {
62
- '@timestamp': (new Date()).getTime(),
63
- topic,
64
- payload
65
- };
66
-
67
- if (config.mqttSmarthome) {
68
- try {
69
- const json = JSON.parse(payload);
70
- if (json && typeof json.val !== 'undefined') {
71
- data['val_' + (typeof json.val).toLowerCase()] = json.val;
72
- data.ts = json.ts;
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
- function createMapping() {
105
- const data = {
106
- index_patterns: [config.index + '-*'], // eslint-disable-line camelcase
107
- mappings: {}
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();
@@ -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": "1.0.0",
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
- "test": "camo-purge ; xo"
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
- "author": "Sebastian Raff <hobbyquaker@gmail.com> (https://github.com/hobbyquaker)",
24
- "license": "MIT",
25
- "bugs": {
26
- "url": "https://github.com/hobbyquaker/mqtt2elasticsearch/issues"
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": "^2.16.0",
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
- "camo-purge": "^1.0.2",
37
- "xo": "^0.20.3"
38
- },
39
- "xo": {
40
- "space": 4
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
@@ -1,3 +0,0 @@
1
- node_modules
2
- .DS_Store
3
- .idea
package/.travis.yml DELETED
@@ -1,4 +0,0 @@
1
- anguage: node_js
2
- node_js:
3
- - '6'
4
-