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.
@@ -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 whatever it finds, so it can run from a prerelease script without blocking a release for
29
- * something that may well be deliberate. Exits 1 only if it could not read a schema at all.
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
- return warnings;
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 dirs = (process.argv.length > 2) ? process.argv.slice(2) : ['config.d/schema'];
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 total = 0;
206
+ let totalWarnings = 0;
207
+ let totalErrors = 0;
107
208
 
108
209
  for (const dir of dirs) {
109
- const warnings = checkSchemaDir(dir);
110
- if ((warnings.length === 1) && warnings[0].endsWith('does not exist')) {
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
- console.log(`${dir}: ${warnings.length} thing(s) worth a look`);
120
- for (const w of warnings) console.log(` ${w}`);
121
- total += warnings.length;
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 (total) {
125
- console.log(`\n${total} warning(s) - these are for you to decide about, nothing has been changed.`);
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 topics.yaml modules.yaml; do
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
@@ -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