frugal-iot-server 0.3.7 → 0.3.9
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/INSTALLATION.md +398 -0
- package/README.md +94 -19
- package/config.d/email.yaml +23 -0
- package/config.d/schema/devices.yaml +61 -0
- package/config.d/schema/modules.yaml +337 -39
- package/config.d/schema/topics.yaml +156 -52
- package/extras/mosquitto-bridge.conf.example +130 -0
- package/frugal-iot-server.js +190 -10
- package/lib/housekeeping.js +10 -4
- package/lib/mailer.js +43 -0
- package/lib/resetcode.js +103 -0
- package/package.json +13 -5
- package/public/index.html +16 -0
- package/public/service-worker.js +29 -29
- package/scripts/addbridge-pi.zsh +194 -0
- package/scripts/addbridge-prod.zsh +221 -0
- package/scripts/check-published-files.js +0 -0
- package/scripts/check-schema.js +126 -15
- package/scripts/clearretained.js +185 -0
- package/scripts/copy-schema-to-examples.zsh +9 -5
- package/scripts/diagnostic.zsh +47 -0
- package/scripts/init.zsh +1 -1
- package/scripts/install-pi.sh +646 -0
- package/scripts/not-published.txt +27 -0
- package/scripts/prerelease.zsh +91 -17
package/scripts/check-schema.js
CHANGED
|
@@ -21,12 +21,25 @@
|
|
|
21
21
|
* - A module topic overriding a field that its topic does not define, which is usually a
|
|
22
22
|
* misspelling.
|
|
23
23
|
*
|
|
24
|
+
* - A float or exponential topic with no "width", or one too small to hold its own min..max. Width
|
|
25
|
+
* is what decides how many decimals a reading is shown to; without it the UI guesses.
|
|
26
|
+
*
|
|
27
|
+
* - A numeric topic with no "units". Often right - plenty of readings are dimensionless - but
|
|
28
|
+
* worth a look, because the UI has nothing to put after the number.
|
|
29
|
+
*
|
|
30
|
+
* And, separately, things that are never deliberate and so count as errors:
|
|
31
|
+
*
|
|
32
|
+
* - A devices.yaml entry naming a module, or a leaf within a module, that does not exist. That
|
|
33
|
+
* silently loses a row from a card, with nothing to say why.
|
|
34
|
+
*
|
|
24
35
|
* Usage:
|
|
25
36
|
* node scripts/check-schema.js # checks ./config.d/schema
|
|
26
37
|
* node scripts/check-schema.js <dir> [<dir> ...] # each dir holding topics.yaml and modules.yaml
|
|
38
|
+
* node scripts/check-schema.js --resolve <otakey> # which devices.yaml entry that OTA key uses
|
|
27
39
|
*
|
|
28
|
-
* Exits 0
|
|
29
|
-
* something that may well be deliberate. Exits 1
|
|
40
|
+
* Exits 0 for warnings, so it can run from a prerelease script without blocking a release for
|
|
41
|
+
* something that may well be deliberate. Exits 1 for an error, or if it could not read a schema.
|
|
42
|
+
* Both callers guard it with "|| true", so neither blocks on either.
|
|
30
43
|
*/
|
|
31
44
|
|
|
32
45
|
import yaml from 'js-yaml';
|
|
@@ -36,6 +49,8 @@ import path from 'path';
|
|
|
36
49
|
// The types the logger records when a topic does not say - keep in step with shouldLog() in
|
|
37
50
|
// frugal-iot-logger/index.js
|
|
38
51
|
const TYPES_LOGGED_BY_DEFAULT = ['float', 'int', 'bool'];
|
|
52
|
+
// The types with decimals to decide, and so the ones that need a width
|
|
53
|
+
const NUMERIC_TYPES = ['float', 'exponential'];
|
|
39
54
|
|
|
40
55
|
function loadYaml(file) {
|
|
41
56
|
if (!existsSync(file)) return null;
|
|
@@ -48,9 +63,11 @@ function loadYaml(file) {
|
|
|
48
63
|
*/
|
|
49
64
|
function checkSchemaDir(dir) {
|
|
50
65
|
const topics = loadYaml(path.join(dir, 'topics.yaml'));
|
|
51
|
-
if (topics === null) return [`${dir}/topics.yaml does not exist`];
|
|
66
|
+
if (topics === null) return { warnings: [`${dir}/topics.yaml does not exist`], errors: [], missing: true };
|
|
52
67
|
const modules = loadYaml(path.join(dir, 'modules.yaml')) || {};
|
|
68
|
+
const devices = loadYaml(path.join(dir, 'devices.yaml')) || {};
|
|
53
69
|
const warnings = [];
|
|
70
|
+
const errors = [];
|
|
54
71
|
|
|
55
72
|
for (const [name, topic] of Object.entries(topics)) {
|
|
56
73
|
if (!topic || (typeof topic !== 'object')) {
|
|
@@ -76,6 +93,28 @@ function checkSchemaDir(dir) {
|
|
|
76
93
|
warnings.push(`topic "${name}" has a "duplicates" rule setting neither significantvalue nor`
|
|
77
94
|
+ ` significantdate, so it does nothing`);
|
|
78
95
|
}
|
|
96
|
+
// Width decides the decimals a reading is shown to, derived as width - intWidth - 1 where
|
|
97
|
+
// intWidth comes from min..max. Only a float has decimals to decide.
|
|
98
|
+
const hasDecimals = NUMERIC_TYPES.includes(topic.type);
|
|
99
|
+
if (hasDecimals && (topic.width === undefined)) {
|
|
100
|
+
warnings.push(`topic "${name}" is a ${topic.type} with no "width", so the UI has to guess how`
|
|
101
|
+
+ ` many decimals to show. Width counts every character including the sign and the point.`);
|
|
102
|
+
}
|
|
103
|
+
if (!hasDecimals && (topic.width !== undefined)) {
|
|
104
|
+
warnings.push(`topic "${name}" is a ${topic.type}, which has no decimals, so "width" does nothing`);
|
|
105
|
+
}
|
|
106
|
+
if (hasDecimals && (topic.width !== undefined)) {
|
|
107
|
+
const intWidth = Math.max(String(Math.trunc(topic.min ?? 0)).length,
|
|
108
|
+
String(Math.trunc(topic.max ?? 0)).length);
|
|
109
|
+
if (topic.width < intWidth) {
|
|
110
|
+
warnings.push(`topic "${name}" has width ${topic.width} but its range needs ${intWidth}`
|
|
111
|
+
+ ` characters before any decimal point, so values will overflow it`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (hasDecimals && (topic.units === undefined)) {
|
|
115
|
+
warnings.push(`topic "${name}" has no "units" - fine if it is dimensionless, but the UI has`
|
|
116
|
+
+ ` nothing to put after the number`);
|
|
117
|
+
}
|
|
79
118
|
}
|
|
80
119
|
|
|
81
120
|
// A module topic can override any field of the topic it is built from. A field the topic does not
|
|
@@ -98,30 +137,102 @@ function checkSchemaDir(dir) {
|
|
|
98
137
|
}
|
|
99
138
|
}
|
|
100
139
|
}
|
|
101
|
-
|
|
140
|
+
// A device entry picks the rows on a card, by twig (module/leaf) or by bare control module id.
|
|
141
|
+
// Naming something that does not exist loses a row silently, so these are errors not warnings.
|
|
142
|
+
for (const [key, device] of Object.entries(devices)) {
|
|
143
|
+
for (const listName of ['front', 'summary']) {
|
|
144
|
+
for (const entry of ((device && device[listName]) || [])) {
|
|
145
|
+
const [moduleName, leaf] = entry.split('/');
|
|
146
|
+
const module = modules[moduleName];
|
|
147
|
+
if (!module) {
|
|
148
|
+
errors.push(`devices "${key}" ${listName} names module "${moduleName}", which is not in modules.yaml`);
|
|
149
|
+
} else if (leaf !== undefined) {
|
|
150
|
+
const leaves = ((module.topics) || []).map((t) => t.leaf);
|
|
151
|
+
if (!leaves.includes(leaf)) {
|
|
152
|
+
errors.push(`devices "${key}" ${listName} names "${entry}", but module "${moduleName}"`
|
|
153
|
+
+ ` has ${leaves.length ? leaves.join(', ') : 'no leaves'}`);
|
|
154
|
+
}
|
|
155
|
+
} else if (!moduleName.startsWith('control')) {
|
|
156
|
+
warnings.push(`devices "${key}" ${listName} names module "${moduleName}" with no leaf.`
|
|
157
|
+
+ ` Only a control module renders as a whole - did you mean a "${moduleName}/leaf" twig?`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Longest match wins, so this is not a fault - but it is worth knowing which entry a key lands on
|
|
162
|
+
const shadowed = Object.keys(devices).filter((k) => (k !== key) && k.startsWith(key));
|
|
163
|
+
if (shadowed.length) {
|
|
164
|
+
warnings.push(`devices "${key}" is also a prefix of ${shadowed.join(', ')}; the longest match`
|
|
165
|
+
+ ` wins, so those take precedence for their own keys`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// summary is opt-out - a module contributes unless it says otherwise
|
|
170
|
+
for (const [moduleName, module] of Object.entries(modules)) {
|
|
171
|
+
if (module && (module.summary === true)) {
|
|
172
|
+
warnings.push(`module "${moduleName}" sets "summary: true", which is already the default -`
|
|
173
|
+
+ ` only "summary: false" does anything`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { warnings, errors };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Which devices.yaml entry an OTA key uses: exact device id, then exact key, then longest prefix.
|
|
181
|
+
function resolveDeviceEntry(devices, otakey) {
|
|
182
|
+
if (devices[otakey]) return [otakey, 'an exact match'];
|
|
183
|
+
const prefixes = Object.keys(devices).filter((k) => otakey.startsWith(k));
|
|
184
|
+
if (!prefixes.length) return [null, 'no match, so the defaults apply'];
|
|
185
|
+
const longest = prefixes.sort((a, b) => b.length - a.length)[0];
|
|
186
|
+
return [longest, prefixes.length > 1 ? `the longest of ${prefixes.length} matching prefixes` : 'a prefix'];
|
|
102
187
|
}
|
|
103
188
|
|
|
104
|
-
const
|
|
189
|
+
const argv = process.argv.slice(2);
|
|
190
|
+
const resolveAt = argv.indexOf('--resolve');
|
|
191
|
+
if (resolveAt !== -1) {
|
|
192
|
+
const otakey = argv[resolveAt + 1];
|
|
193
|
+
const dir = argv.find((a, i) => !a.startsWith('--') && (i !== resolveAt + 1)) || 'config.d/schema';
|
|
194
|
+
if (!otakey) {
|
|
195
|
+
console.error('--resolve needs an OTA key, e.g. --resolve sht30_c3_pico');
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
const devices = loadYaml(path.join(dir, 'devices.yaml')) || {};
|
|
199
|
+
const [entry, how] = resolveDeviceEntry(devices, otakey);
|
|
200
|
+
console.log(`${otakey} uses ${entry ? `the "${entry}" entry` : 'no entry'} (${how})`);
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const dirs = argv.length ? argv : ['config.d/schema'];
|
|
105
205
|
let unreadable = 0;
|
|
106
|
-
let
|
|
206
|
+
let totalWarnings = 0;
|
|
207
|
+
let totalErrors = 0;
|
|
107
208
|
|
|
108
209
|
for (const dir of dirs) {
|
|
109
|
-
const warnings = checkSchemaDir(dir);
|
|
110
|
-
if (
|
|
210
|
+
const { warnings, errors, missing } = checkSchemaDir(dir);
|
|
211
|
+
if (missing) {
|
|
111
212
|
console.error(`${dir}: ${warnings[0]}`);
|
|
112
213
|
unreadable++;
|
|
113
214
|
continue;
|
|
114
215
|
}
|
|
115
|
-
if (!warnings.length) {
|
|
216
|
+
if (!warnings.length && !errors.length) {
|
|
116
217
|
console.log(`${dir}: nothing to report`);
|
|
117
218
|
continue;
|
|
118
219
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
220
|
+
if (warnings.length) {
|
|
221
|
+
console.log(`${dir}: ${warnings.length} thing(s) worth a look`);
|
|
222
|
+
for (const w of warnings) console.log(` ${w}`);
|
|
223
|
+
}
|
|
224
|
+
if (errors.length) {
|
|
225
|
+
console.log(`${dir}: ${errors.length} thing(s) that cannot be right`);
|
|
226
|
+
for (const e of errors) console.log(` ERROR ${e}`);
|
|
227
|
+
}
|
|
228
|
+
totalWarnings += warnings.length;
|
|
229
|
+
totalErrors += errors.length;
|
|
122
230
|
}
|
|
123
231
|
|
|
124
|
-
if (
|
|
125
|
-
console.log(`\n${
|
|
232
|
+
if (totalWarnings) {
|
|
233
|
+
console.log(`\n${totalWarnings} warning(s) - these are for you to decide about, nothing has been changed.`);
|
|
234
|
+
}
|
|
235
|
+
if (totalErrors) {
|
|
236
|
+
console.log(`${totalErrors} error(s) - a card will silently lose a row until these are fixed.`);
|
|
126
237
|
}
|
|
127
|
-
process.exit(unreadable ? 1 : 0);
|
|
238
|
+
process.exit((unreadable || totalErrors) ? 1 : 0);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* List, and optionally delete, retained MQTT messages.
|
|
4
|
+
*
|
|
5
|
+
* Nodes publish nearly everything retained, so the broker keeps the last value of every topic and
|
|
6
|
+
* hands it to each new subscriber - which is how a dashboard can show a reading straight away
|
|
7
|
+
* instead of waiting for the next one. The cost is that a topic published by mistake outlives the
|
|
8
|
+
* mistake: rename a module, misspell a field, test a node with the wrong id, and that topic stays
|
|
9
|
+
* on the broker for ever, appearing on every dashboard. Restarting the broker does not clear it and
|
|
10
|
+
* neither does fixing the node. The only way to remove one is to publish an empty message to that
|
|
11
|
+
* exact topic, which is what this does.
|
|
12
|
+
*
|
|
13
|
+
* Run from the server's own directory, the one holding frugal-iot.db, so that the organization's
|
|
14
|
+
* broker password can be read from config.d/organizations/.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* npx --no frugal-iot-clearretained '<topic-pattern>' # list what is retained
|
|
18
|
+
* npx --no frugal-iot-clearretained '<topic-pattern>' delete # delete it
|
|
19
|
+
*
|
|
20
|
+
* Quote the pattern, or the shell expands the wildcards before this ever sees them. In MQTT
|
|
21
|
+
* patterns "+" matches exactly one level and "#" matches the rest:
|
|
22
|
+
* 'myfarm/#' everything the organization holds - look before deleting
|
|
23
|
+
* 'myfarm/lotus/esp8266-fb94bb/#' one node
|
|
24
|
+
* 'myfarm/lotus/+/sht/temperture/#' a misspelled field, on every node in the project
|
|
25
|
+
*
|
|
26
|
+
* "delete" is a bare word rather than "--delete" because npm parses the whole command line of an
|
|
27
|
+
* "npx" invocation itself: it treats any --flag it does not recognise as an unknown config setting
|
|
28
|
+
* and passes only the values on, so "--delete" would silently never reach this script and it would
|
|
29
|
+
* quietly list instead of deleting. "--delete" is still accepted for running it directly.
|
|
30
|
+
*
|
|
31
|
+
* This talks to the broker through the "mqtt" library rather than mosquitto_sub and mosquitto_pub,
|
|
32
|
+
* so it works anywhere the server itself runs. On the production server those commands cannot be
|
|
33
|
+
* installed at all: its broker came from the mosquitto PPA built for an older Ubuntu, and the
|
|
34
|
+
* mosquitto-clients in the distribution demand an exactly-matching libmosquitto1, so apt would have
|
|
35
|
+
* to downgrade the library out from under the running broker.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync } from 'node:fs';
|
|
39
|
+
import yaml from 'js-yaml';
|
|
40
|
+
import mqtt from 'mqtt';
|
|
41
|
+
|
|
42
|
+
const SCRIPT = 'frugal-iot-clearretained';
|
|
43
|
+
|
|
44
|
+
function usage(message) {
|
|
45
|
+
if (message) console.error(`Error: ${message}`);
|
|
46
|
+
console.error(`Usage: ${SCRIPT} '<topic-pattern>' [delete] [--host H] [--port N] [--user U] [--password P] [--wait S]`);
|
|
47
|
+
console.error(`Example: ${SCRIPT} 'myfarm/lotus/+/sht/temperture/#'`);
|
|
48
|
+
console.error(` ${SCRIPT} 'myfarm/lotus/+/sht/temperture/#' delete`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- arguments ----
|
|
53
|
+
const opts = { host: 'localhost', port: 1883, user: null, password: null, wait: 3 };
|
|
54
|
+
let pattern = null;
|
|
55
|
+
let doDelete = false;
|
|
56
|
+
|
|
57
|
+
const argv = process.argv.slice(2);
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
switch (a) {
|
|
61
|
+
case '--delete': doDelete = true; break;
|
|
62
|
+
case '--host': opts.host = argv[++i]; break;
|
|
63
|
+
case '--port': opts.port = Number(argv[++i]); break;
|
|
64
|
+
case '--user': opts.user = argv[++i]; break;
|
|
65
|
+
case '--password': opts.password = argv[++i]; break;
|
|
66
|
+
case '--wait': opts.wait = Number(argv[++i]); break;
|
|
67
|
+
default:
|
|
68
|
+
if (a.startsWith('-')) usage(`unknown option '${a}'`);
|
|
69
|
+
else if (pattern === null) pattern = a;
|
|
70
|
+
else if (a === 'delete') doDelete = true;
|
|
71
|
+
else usage(`unexpected argument '${a}'`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!pattern) usage('no topic pattern given');
|
|
75
|
+
|
|
76
|
+
// A bare "#" would be every topic on the broker, across every organization. Almost always a
|
|
77
|
+
// mistake, and the one case where a slip is unrecoverable, so it is not accepted at all.
|
|
78
|
+
if (pattern === '#' || pattern === '+/#') {
|
|
79
|
+
console.error(`Error: refusing the pattern '${pattern}' - that is every topic on the broker.`);
|
|
80
|
+
console.error("Name the organization at least, as in 'myfarm/#'.");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- credentials ----
|
|
85
|
+
// The broker requires them, and the organization's are in its config file - the same place the
|
|
86
|
+
// server reads them from. The first element of the topic pattern is the organization.
|
|
87
|
+
if (!opts.user) {
|
|
88
|
+
opts.user = pattern.split('/')[0];
|
|
89
|
+
const configFile = `config.d/organizations/${opts.user}.yaml`;
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = yaml.load(readFileSync(configFile, 'utf8'));
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error(`Error: cannot read ${configFile} (${e.code || e.message}), so the broker password`);
|
|
95
|
+
console.error(`for '${opts.user}' cannot be found. Run this from the server's own directory, or`);
|
|
96
|
+
console.error('give credentials with --user and --password.');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
opts.password = parsed && parsed.mqtt_password;
|
|
100
|
+
if (!opts.password) {
|
|
101
|
+
console.error(`Error: no mqtt_password in ${configFile}`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- collect what is retained ----
|
|
107
|
+
console.log(`Looking for retained messages matching '${pattern}' on ${opts.host}:${opts.port} as ${opts.user} ...`);
|
|
108
|
+
|
|
109
|
+
const client = mqtt.connect(`mqtt://${opts.host}:${opts.port}`, {
|
|
110
|
+
username: opts.user,
|
|
111
|
+
password: opts.password,
|
|
112
|
+
connectTimeout: 5000,
|
|
113
|
+
// A fresh session every time: we want the retained messages delivered on subscribe, not a
|
|
114
|
+
// continuation of some earlier one.
|
|
115
|
+
clean: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const topics = new Set();
|
|
119
|
+
|
|
120
|
+
client.on('error', (err) => {
|
|
121
|
+
// ECONNREFUSED arrives with an empty message, so fall back to the code - otherwise this prints
|
|
122
|
+
// "Error:" and nothing, which says neither "wrong password" nor "nothing listening there".
|
|
123
|
+
console.error(`Error: could not talk to the broker: ${err.message || err.code || err}`);
|
|
124
|
+
client.end(true);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
client.on('message', (topic, _payload, packet) => {
|
|
129
|
+
// Only messages the broker had already kept. A node reporting normally while we listen arrives
|
|
130
|
+
// with the retain flag clear, and clearing that would be removing something nobody asked us to.
|
|
131
|
+
if (packet.retain) topics.add(topic);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
client.on('connect', () => {
|
|
135
|
+
client.subscribe(pattern, { qos: 0 }, (err) => {
|
|
136
|
+
if (err) {
|
|
137
|
+
console.error(`Error: could not subscribe to '${pattern}': ${err.message}`);
|
|
138
|
+
client.end(true);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
// Retained messages arrive immediately on subscribing, so this only has to cover the round
|
|
142
|
+
// trip; a busy broker on a slow Pi may want more, hence --wait.
|
|
143
|
+
setTimeout(finish, opts.wait * 1000);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
async function finish() {
|
|
148
|
+
const list = [...topics].sort();
|
|
149
|
+
if (list.length === 0) {
|
|
150
|
+
console.log('Nothing retained matches that pattern.');
|
|
151
|
+
client.end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const t of list) console.log(` ${t}`);
|
|
155
|
+
console.log(`${list.length} retained topic(s).`);
|
|
156
|
+
|
|
157
|
+
if (!doDelete) {
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(`Nothing has been changed. To delete these, run the same command again with "delete"`);
|
|
160
|
+
console.log('as the last word.');
|
|
161
|
+
client.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Deleting one means publishing a zero-length retained message to it, which is how MQTT spells
|
|
166
|
+
// "forget this". At QoS 1 so the broker acknowledges each rather than dropping it under load.
|
|
167
|
+
console.log(`Deleting ${list.length} retained topic(s) ...`);
|
|
168
|
+
let failed = 0;
|
|
169
|
+
for (const t of list) {
|
|
170
|
+
try {
|
|
171
|
+
await new Promise((resolve, reject) => {
|
|
172
|
+
client.publish(t, '', { retain: true, qos: 1 }, (err) => (err ? reject(err) : resolve()));
|
|
173
|
+
});
|
|
174
|
+
console.log(` cleared ${t}`);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error(` FAILED ${t}: ${e.message}`);
|
|
177
|
+
failed++;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
console.log('');
|
|
181
|
+
console.log('Done. Run the same command without "delete" - it should report nothing.');
|
|
182
|
+
console.log('If a topic comes back, a node is still publishing it: fix or restart the node, then re-run.');
|
|
183
|
+
// A bridged broker forwards these deletions to the other end, so clearing here clears there too.
|
|
184
|
+
client.end(false, {}, () => process.exit(failed ? 1 : 0));
|
|
185
|
+
}
|
|
@@ -32,13 +32,17 @@ if (( ! ${#DIRS} )); then
|
|
|
32
32
|
exit 0
|
|
33
33
|
fi
|
|
34
34
|
|
|
35
|
+
# Every yaml in the schema directory, rather than a list to keep in step - devices.yaml was added
|
|
36
|
+
# and left behind once already.
|
|
37
|
+
FILES=("${SCHEMA}"/*.yaml(N:t))
|
|
38
|
+
if (( ! ${#FILES} )); then
|
|
39
|
+
echo " No yaml files in ${SCHEMA}"
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
35
43
|
COPIED=0
|
|
36
44
|
for d in $DIRS; do
|
|
37
|
-
for f in
|
|
38
|
-
if [[ ! -f "${SCHEMA}/${f}" ]]; then
|
|
39
|
-
echo " ${SCHEMA}/${f} does not exist - skipped"
|
|
40
|
-
continue
|
|
41
|
-
fi
|
|
45
|
+
for f in $FILES; do
|
|
42
46
|
if cmp -s "${SCHEMA}/${f}" "${d}/${f}"; then
|
|
43
47
|
echo " same ${d#${LOGGER}/}/${f}"
|
|
44
48
|
else
|
package/scripts/diagnostic.zsh
CHANGED
|
@@ -266,6 +266,53 @@ else
|
|
|
266
266
|
item "mosquitto_sub not installed - cannot test broker logins (sudo apt install mosquitto-clients)"
|
|
267
267
|
fi
|
|
268
268
|
|
|
269
|
+
section "Bridge to a production server (optional)"
|
|
270
|
+
# A bridge relays this Pi's readings to a production server - see
|
|
271
|
+
# extras/mosquitto-bridge.conf.example. It is configured by a "connection" line in one of
|
|
272
|
+
# mosquitto's own config files, so most installations have none, which is not a problem.
|
|
273
|
+
BRIDGECONFS=(/etc/mosquitto/mosquitto.conf(N) /etc/mosquitto/conf.d/*.conf(N))
|
|
274
|
+
BRIDGENAMES=""
|
|
275
|
+
(( ${#BRIDGECONFS} )) && BRIDGENAMES=$(grep -hE '^[[:space:]]*connection[[:space:]]' $BRIDGECONFS 2>/dev/null | awk '{print $2}')
|
|
276
|
+
if [[ -z "$BRIDGENAMES" ]]; then
|
|
277
|
+
item "no bridge configured - readings stay on this Pi"
|
|
278
|
+
else
|
|
279
|
+
for b in ${(f)BRIDGENAMES}; do item "configured bridge: $b"; done
|
|
280
|
+
# What it is set to relay and where. remote_password is deliberately not among these.
|
|
281
|
+
item "its settings:"
|
|
282
|
+
grep -hE '^[[:space:]]*(address|remote_username|remote_clientid|topic|cleansession|restart_timeout|notifications)[[:space:]]' $BRIDGECONFS 2>/dev/null | sed 's/^/ /'
|
|
283
|
+
# Whether it is actually up, rather than what the configuration hoped for. With "notifications"
|
|
284
|
+
# left on, the bridge publishes a retained 1 or 0 here each time it connects or drops.
|
|
285
|
+
if have mosquitto_sub; then
|
|
286
|
+
# Reading $SYS needs a broker login, so borrow the first organization that has one
|
|
287
|
+
STATEORG=""; STATEPW=""
|
|
288
|
+
for f in config.d/organizations/*.yaml(N); do
|
|
289
|
+
STATEPW=$(sed -n 's/^mqtt_password:[[:space:]]*//p' "$f" 2>/dev/null | head -1 | tr -d '"'"'"'')
|
|
290
|
+
if [[ -n "$STATEPW" ]]; then STATEORG=${f:t:r}; break; fi
|
|
291
|
+
done
|
|
292
|
+
if [[ -z "$STATEORG" ]]; then
|
|
293
|
+
item "live state: no organization credentials here to read it with"
|
|
294
|
+
else
|
|
295
|
+
STATEOUT=$(mosquitto_sub -h localhost -u "$STATEORG" -P "$STATEPW" -v -t '$SYS/broker/connection/+/state' -W 2 2>&1 | grep -E '/state ')
|
|
296
|
+
if [[ -z "$STATEOUT" ]]; then
|
|
297
|
+
item "live state: the broker reported nothing - the bridge has never connected since it started"
|
|
298
|
+
problem "A bridge is configured but has not reported its state - check 'address' is reachable and see the mosquitto log above"
|
|
299
|
+
else
|
|
300
|
+
for line in ${(f)STATEOUT}; do
|
|
301
|
+
BNAME=${${(s:/:)${line%% *}}[4]}
|
|
302
|
+
if [[ "${line##* }" == "1" ]]; then
|
|
303
|
+
item "live state: ${BNAME} is CONNECTED to production"
|
|
304
|
+
else
|
|
305
|
+
item "live state: ${BNAME} is DOWN - this Pi is recording locally only"
|
|
306
|
+
problem "Bridge '${BNAME}' is not connected to production - readings since it dropped will not appear there"
|
|
307
|
+
fi
|
|
308
|
+
done
|
|
309
|
+
fi
|
|
310
|
+
fi
|
|
311
|
+
else
|
|
312
|
+
item "mosquitto_sub not installed - cannot check whether the bridge is up"
|
|
313
|
+
fi
|
|
314
|
+
fi
|
|
315
|
+
|
|
269
316
|
section "Web server (step 7)"
|
|
270
317
|
WEBPORT=$(grep -h '^port:' config.d/server.yaml 2>/dev/null | awk '{print $2}')
|
|
271
318
|
[[ -z "$WEBPORT" ]] && WEBPORT=8080
|
package/scripts/init.zsh
CHANGED
|
@@ -56,7 +56,7 @@ copy_if_missing() {
|
|
|
56
56
|
|
|
57
57
|
echo "Configuration:"
|
|
58
58
|
copy_if_missing "${PKG}/config.yaml" "./config.yaml"
|
|
59
|
-
for f in logger.yaml mqtt.yaml server.yaml; do
|
|
59
|
+
for f in logger.yaml mqtt.yaml server.yaml email.yaml; do
|
|
60
60
|
copy_if_missing "${PKG}/config.d/${f}" "./config.d/${f}"
|
|
61
61
|
done
|
|
62
62
|
# The schema describes the sensor types the software understands, so a release changing it matters
|