frugal-iot-server 0.3.4 → 0.3.6
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 +380 -123
- package/README.md +6 -6
- package/config.d/logger.yaml +16 -0
- package/config.d/schema/modules.yaml +66 -1
- package/config.d/schema/topics.yaml +184 -2
- package/config.d/server.yaml +28 -4
- package/extras/mosquitto.conf +13 -1
- package/frugal-iot-server.js +71 -5
- package/lib/api-routes.js +3 -0
- package/lib/data-loader.js +11 -4
- package/lib/housekeeping.js +251 -0
- package/lib/logger-client.js +16 -0
- package/package.json +10 -5
- package/public/service-worker.js +1 -1
- package/scripts/addorganization.zsh +59 -15
- package/scripts/check-schema.js +127 -0
- package/scripts/copy-schema-to-examples.zsh +55 -0
- package/scripts/diagnostic.zsh +382 -0
- package/scripts/init.zsh +37 -10
- package/scripts/prerelease.zsh +125 -0
- package/scripts/setpassword.zsh +3 -3
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Warn about things in a sensor schema that are easy to get wrong and expensive to get wrong.
|
|
4
|
+
*
|
|
5
|
+
* It only warns - it never changes anything. Each of these needs somebody to decide what the right
|
|
6
|
+
* value is for that particular sensor, and a script guessing would be worse than a script asking.
|
|
7
|
+
*
|
|
8
|
+
* What it looks for:
|
|
9
|
+
*
|
|
10
|
+
* - A topic that does not say whether it is logged. With no "log" setting, any float, int or bool
|
|
11
|
+
* with "rw: r" is recorded and anything else is not, which is easy to be surprised by in either
|
|
12
|
+
* direction - a sensor silently not recorded, or one recorded that nobody wanted.
|
|
13
|
+
*
|
|
14
|
+
* - A logged topic with no "duplicates" rule. Without one, every reading that arrives is written
|
|
15
|
+
* to disk as it arrives. On a server running from an SD card that wears the card out, and it
|
|
16
|
+
* fills the disk with readings that mostly repeat the one before.
|
|
17
|
+
*
|
|
18
|
+
* - A "duplicates" rule that cannot do anything, because it sets neither significantvalue nor
|
|
19
|
+
* significantdate.
|
|
20
|
+
*
|
|
21
|
+
* - A module topic overriding a field that its topic does not define, which is usually a
|
|
22
|
+
* misspelling.
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* node scripts/check-schema.js # checks ./config.d/schema
|
|
26
|
+
* node scripts/check-schema.js <dir> [<dir> ...] # each dir holding topics.yaml and modules.yaml
|
|
27
|
+
*
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import yaml from 'js-yaml';
|
|
33
|
+
import { readFileSync, existsSync } from 'fs';
|
|
34
|
+
import path from 'path';
|
|
35
|
+
|
|
36
|
+
// The types the logger records when a topic does not say - keep in step with shouldLog() in
|
|
37
|
+
// frugal-iot-logger/index.js
|
|
38
|
+
const TYPES_LOGGED_BY_DEFAULT = ['float', 'int', 'bool'];
|
|
39
|
+
|
|
40
|
+
function loadYaml(file) {
|
|
41
|
+
if (!existsSync(file)) return null;
|
|
42
|
+
return yaml.load(readFileSync(file, 'utf8')) || {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* Check one directory holding topics.yaml and (optionally) modules.yaml.
|
|
47
|
+
* Returns an array of warning strings.
|
|
48
|
+
*/
|
|
49
|
+
function checkSchemaDir(dir) {
|
|
50
|
+
const topics = loadYaml(path.join(dir, 'topics.yaml'));
|
|
51
|
+
if (topics === null) return [`${dir}/topics.yaml does not exist`];
|
|
52
|
+
const modules = loadYaml(path.join(dir, 'modules.yaml')) || {};
|
|
53
|
+
const warnings = [];
|
|
54
|
+
|
|
55
|
+
for (const [name, topic] of Object.entries(topics)) {
|
|
56
|
+
if (!topic || (typeof topic !== 'object')) {
|
|
57
|
+
warnings.push(`topic "${name}" is not a set of settings`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Whether it is logged, and whether that was actually stated
|
|
61
|
+
const wouldBeLogged = TYPES_LOGGED_BY_DEFAULT.includes(topic.type) && (topic.rw === 'r');
|
|
62
|
+
if (topic.log === undefined) {
|
|
63
|
+
warnings.push(`topic "${name}" does not say whether it is logged`
|
|
64
|
+
+ ` - with type "${topic.type}" and rw "${topic.rw}" it ${wouldBeLogged ? 'IS' : 'is not'} recorded.`
|
|
65
|
+
+ ` Add "log: ${wouldBeLogged}" if that is what you want.`);
|
|
66
|
+
}
|
|
67
|
+
const logged = (topic.log === undefined) ? wouldBeLogged : topic.log;
|
|
68
|
+
if (logged && !topic.duplicates) {
|
|
69
|
+
warnings.push(`topic "${name}" is logged but has no "duplicates" rule, so every reading is`
|
|
70
|
+
+ ` written to disk as it arrives. Add significantvalue (how far it has to move) and`
|
|
71
|
+
+ ` significantdate (how long since the last one, in milliseconds).`);
|
|
72
|
+
}
|
|
73
|
+
if (topic.duplicates
|
|
74
|
+
&& (topic.duplicates.significantvalue === undefined)
|
|
75
|
+
&& (topic.duplicates.significantdate === undefined)) {
|
|
76
|
+
warnings.push(`topic "${name}" has a "duplicates" rule setting neither significantvalue nor`
|
|
77
|
+
+ ` significantdate, so it does nothing`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A module topic can override any field of the topic it is built from. A field the topic does not
|
|
82
|
+
// have is usually a spelling mistake, and one that quietly does nothing.
|
|
83
|
+
for (const [moduleName, module] of Object.entries(modules)) {
|
|
84
|
+
for (const topic of ((module && module.topics) || [])) {
|
|
85
|
+
const from = topic.leaf_from || topic.leaf;
|
|
86
|
+
const base = topics[from];
|
|
87
|
+
if (!base) {
|
|
88
|
+
warnings.push(`module "${moduleName}" has topic "${topic.leaf}" built from "${from}",`
|
|
89
|
+
+ ` which is not in topics.yaml`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
for (const field of Object.keys(topic)) {
|
|
93
|
+
if (['leaf', 'leaf_from', 'name'].includes(field)) continue; // Always local to the module
|
|
94
|
+
if (base[field] === undefined) {
|
|
95
|
+
warnings.push(`module "${moduleName}" topic "${topic.leaf}" sets "${field}", which`
|
|
96
|
+
+ ` "${from}" in topics.yaml does not have - a misspelling?`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return warnings;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const dirs = (process.argv.length > 2) ? process.argv.slice(2) : ['config.d/schema'];
|
|
105
|
+
let unreadable = 0;
|
|
106
|
+
let total = 0;
|
|
107
|
+
|
|
108
|
+
for (const dir of dirs) {
|
|
109
|
+
const warnings = checkSchemaDir(dir);
|
|
110
|
+
if ((warnings.length === 1) && warnings[0].endsWith('does not exist')) {
|
|
111
|
+
console.error(`${dir}: ${warnings[0]}`);
|
|
112
|
+
unreadable++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!warnings.length) {
|
|
116
|
+
console.log(`${dir}: nothing to report`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
console.log(`${dir}: ${warnings.length} thing(s) worth a look`);
|
|
120
|
+
for (const w of warnings) console.log(` ${w}`);
|
|
121
|
+
total += warnings.length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (total) {
|
|
125
|
+
console.log(`\n${total} warning(s) - these are for you to decide about, nothing has been changed.`);
|
|
126
|
+
}
|
|
127
|
+
process.exit(unreadable ? 1 : 0);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env zsh
|
|
2
|
+
#
|
|
3
|
+
# Copy this server's sensor schema into the examples that ship with frugal-iot-logger, so that they
|
|
4
|
+
# do not drift away from the schema actually in use.
|
|
5
|
+
#
|
|
6
|
+
# Called by the prerelease script of both packages - whichever one you are about to publish, the
|
|
7
|
+
# examples end up current. It is here rather than in frugal-iot-logger because this is where the
|
|
8
|
+
# schema is maintained.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# scripts/copy-schema-to-examples.zsh [<frugal-iot-logger directory>]
|
|
12
|
+
#
|
|
13
|
+
# With no argument it looks for frugal-iot-logger beside this checkout, which is how these are
|
|
14
|
+
# developed. Says what it did and changes nothing else; committing is up to you.
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
HERE="${0:A:h:h}" # The frugal-iot-server checkout this script is in
|
|
19
|
+
SCHEMA="${HERE}/config.d/schema"
|
|
20
|
+
LOGGER="${1:-${HERE:h}/frugal-iot-logger}"
|
|
21
|
+
|
|
22
|
+
if [[ ! -d "$LOGGER" ]]; then
|
|
23
|
+
echo " No frugal-iot-logger checkout at ${LOGGER}, so nothing copied."
|
|
24
|
+
echo " The examples in that package keep their own copy of the schema and will drift."
|
|
25
|
+
echo " Clone it beside this one and run this again if you want them updated."
|
|
26
|
+
exit 0 # Not an error - you may simply not have it checked out
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
DIRS=("${LOGGER}"/examples/*/config.d/schema(N/))
|
|
30
|
+
if (( ! ${#DIRS} )); then
|
|
31
|
+
echo " No examples/*/config.d/schema directories under ${LOGGER}"
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
COPIED=0
|
|
36
|
+
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
|
|
42
|
+
if cmp -s "${SCHEMA}/${f}" "${d}/${f}"; then
|
|
43
|
+
echo " same ${d#${LOGGER}/}/${f}"
|
|
44
|
+
else
|
|
45
|
+
cp "${SCHEMA}/${f}" "${d}/${f}"
|
|
46
|
+
echo " updated ${d#${LOGGER}/}/${f}"
|
|
47
|
+
COPIED=$((COPIED+1))
|
|
48
|
+
fi
|
|
49
|
+
done
|
|
50
|
+
done
|
|
51
|
+
|
|
52
|
+
if (( COPIED )); then
|
|
53
|
+
echo
|
|
54
|
+
echo " ${COPIED} file(s) changed in ${LOGGER} - commit them there before publishing the logger."
|
|
55
|
+
fi
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env zsh
|
|
2
|
+
#
|
|
3
|
+
# Report the state of a Frugal IoT installation, for working out why something in INSTALLATION.md
|
|
4
|
+
# did not do what it said it would. It only looks - it changes nothing.
|
|
5
|
+
#
|
|
6
|
+
# Run it from the directory the server is installed in (the one holding frugal-iot.db):
|
|
7
|
+
# zsh diagnostic.zsh
|
|
8
|
+
# or, once installed from npm:
|
|
9
|
+
# npx --no frugal-iot-diagnostic
|
|
10
|
+
#
|
|
11
|
+
# Paste the whole output into a bug report. Passwords are deliberately not printed: the password
|
|
12
|
+
# file is reported by account name only, and organization configs by name only.
|
|
13
|
+
|
|
14
|
+
# No "set -e": a failing check should report and carry on, not stop the script.
|
|
15
|
+
setopt no_unset 2>/dev/null
|
|
16
|
+
|
|
17
|
+
section() { print -r -- ""; print -r -- "===== $* ====="; }
|
|
18
|
+
item() { print -r -- " $*"; }
|
|
19
|
+
have() { command -v "$1" >/dev/null 2>&1; }
|
|
20
|
+
# Ownership and mode of a file, spelled for either Linux or BSD/macOS stat
|
|
21
|
+
fileinfo() {
|
|
22
|
+
local f=$1
|
|
23
|
+
if [[ ! -e "$f" ]]; then print -r -- "missing"; return; fi
|
|
24
|
+
stat -c '%U:%G %a %s bytes' "$f" 2>/dev/null || stat -f '%Su:%Sg %Lp %z bytes' "$f" 2>/dev/null || print -r -- "(cannot stat)"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
PROBLEMS=()
|
|
28
|
+
problem() { PROBLEMS+=("$1"); }
|
|
29
|
+
|
|
30
|
+
section "When and where"
|
|
31
|
+
item "date: $(date 2>/dev/null)"
|
|
32
|
+
item "host: $(hostname 2>/dev/null)"
|
|
33
|
+
item "user: $(id -un 2>/dev/null) (groups: $(id -Gn 2>/dev/null))"
|
|
34
|
+
item "directory: $PWD"
|
|
35
|
+
|
|
36
|
+
section "The machine"
|
|
37
|
+
have uname && item "kernel: $(uname -srm)"
|
|
38
|
+
[[ -r /etc/os-release ]] && item "os: $(grep PRETTY_NAME /etc/os-release | cut -d'"' -f2)"
|
|
39
|
+
[[ -r /proc/device-tree/model ]] && item "board: $(tr -d '\0' < /proc/device-tree/model)"
|
|
40
|
+
have free && item "memory: $(free -h | awk '/^Mem:/ {print $3 " used of " $2 ", " $7 " available"}')"
|
|
41
|
+
have df && item "disk (/): $(df -h / | awk 'NR==2 {print $3 " used of " $2 ", " $4 " free"}')"
|
|
42
|
+
if have timedatectl; then
|
|
43
|
+
item "clock: $(timedatectl show -p NTPSynchronized --value 2>/dev/null | sed 's/^yes$/synchronized with a time server/; s/^no$/NOT synchronized - timestamps may be wrong/')"
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
section "Software versions"
|
|
47
|
+
for c in node npm sqlite3 zsh mosquitto mosquitto_passwd mosquitto_sub; do
|
|
48
|
+
if have $c; then
|
|
49
|
+
case $c in
|
|
50
|
+
node|npm) item "$c: $($c -v 2>&1 | head -1)" ;;
|
|
51
|
+
sqlite3) item "$c: $(sqlite3 --version 2>&1 | awk '{print $1}')" ;;
|
|
52
|
+
zsh) item "$c: $ZSH_VERSION" ;;
|
|
53
|
+
mosquitto) item "$c: $(mosquitto -h 2>&1 | head -1)" ;;
|
|
54
|
+
*) item "$c: present" ;;
|
|
55
|
+
esac
|
|
56
|
+
else
|
|
57
|
+
item "$c: NOT INSTALLED"
|
|
58
|
+
[[ $c == (node|npm|sqlite3) ]] && problem "$c is not installed - see INSTALLATION.md step 3"
|
|
59
|
+
fi
|
|
60
|
+
done
|
|
61
|
+
|
|
62
|
+
section "This installation"
|
|
63
|
+
for f in config.yaml config.d frugal-iot.db data ota node_modules; do
|
|
64
|
+
if [[ -e $f ]]; then item "$f: present"; else item "$f: MISSING"; fi
|
|
65
|
+
done
|
|
66
|
+
[[ ! -e frugal-iot.db ]] && problem "No frugal-iot.db here - either the wrong directory, or 'npx --no frugal-iot-init' has not been run"
|
|
67
|
+
if [[ -d node_modules/frugal-iot-server ]]; then
|
|
68
|
+
item "frugal-iot-server: $(node -e 'console.log(require("./node_modules/frugal-iot-server/package.json").version)' 2>/dev/null)"
|
|
69
|
+
item "frugal-iot-client: $(node -e 'console.log(require("./node_modules/frugal-iot-client/package.json").version)' 2>/dev/null)"
|
|
70
|
+
item "frugal-iot-logger: $(node -e 'console.log(require("./node_modules/frugal-iot-logger/package.json").version)' 2>/dev/null)"
|
|
71
|
+
item "commands: $(ls node_modules/.bin 2>/dev/null | grep frugal | tr '\n' ' ')"
|
|
72
|
+
else
|
|
73
|
+
item "frugal-iot-server is not installed in this directory"
|
|
74
|
+
fi
|
|
75
|
+
# sqlite3 is the one dependency with compiled code in it. Where no ready-made binary exists for the
|
|
76
|
+
# platform (32-bit ARM, so any Pi Zero W or Pi 1) npm compiles it during install - and if that fails,
|
|
77
|
+
# npm can still report success, leaving a package directory with nothing usable inside it. Nothing
|
|
78
|
+
# else here looks wrong when that happens, and the server simply refuses to start.
|
|
79
|
+
if [[ -d node_modules/sqlite3 ]]; then
|
|
80
|
+
if node -e "require('sqlite3')" >/dev/null 2>&1; then
|
|
81
|
+
item "sqlite3 native module: builds and loads OK"
|
|
82
|
+
else
|
|
83
|
+
item "sqlite3 native module: PRESENT BUT NOT BUILT"
|
|
84
|
+
problem "node_modules/sqlite3 has no compiled binding, so the server cannot start. Build it with:
|
|
85
|
+
npm rebuild sqlite3 --foreground-scripts
|
|
86
|
+
(about 40 minutes on a Pi Zero W; needs build-essential python3-dev python3-setuptools)"
|
|
87
|
+
fi
|
|
88
|
+
fi
|
|
89
|
+
if [[ -f config.d/mqtt.yaml ]]; then item "broker configured as: $(grep -h '^broker:' config.d/mqtt.yaml 2>/dev/null)"; fi
|
|
90
|
+
if [[ -d config.d/organizations ]]; then
|
|
91
|
+
# Names only - these files contain the broker password.
|
|
92
|
+
# (N) makes a non-matching glob expand to nothing rather than erroring - and the list has to be
|
|
93
|
+
# tested before use, because "ls" with no arguments would list the current directory instead.
|
|
94
|
+
ORGS=(config.d/organizations/*.yaml(N:t:r))
|
|
95
|
+
if (( ${#ORGS} )); then
|
|
96
|
+
item "organizations: ${ORGS}"
|
|
97
|
+
else
|
|
98
|
+
item "organizations: none defined yet"
|
|
99
|
+
fi
|
|
100
|
+
fi
|
|
101
|
+
if [[ -f frugal-iot.db ]] && have sqlite3; then
|
|
102
|
+
item "accounts: $(sqlite3 frugal-iot.db 'SELECT group_concat(username, ", ") FROM users;' 2>/dev/null)"
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
section "Mosquitto configuration"
|
|
106
|
+
if [[ -f /etc/mosquitto/mosquitto.conf ]]; then
|
|
107
|
+
item "/etc/mosquitto/mosquitto.conf: $(fileinfo /etc/mosquitto/mosquitto.conf)"
|
|
108
|
+
else
|
|
109
|
+
item "/etc/mosquitto/mosquitto.conf: missing - is mosquitto installed?"
|
|
110
|
+
fi
|
|
111
|
+
item "files in /etc/mosquitto/conf.d:"
|
|
112
|
+
for f in /etc/mosquitto/conf.d/*(N); do
|
|
113
|
+
item " ${f}: $(fileinfo $f)"
|
|
114
|
+
grep -nE '^[[:space:]]*(listener|protocol|password_file|allow_anonymous)' $f 2>/dev/null | sed 's/^/ /'
|
|
115
|
+
done
|
|
116
|
+
[[ -z "$(print -r -- /etc/mosquitto/conf.d/*(N))" ]] && problem "Nothing in /etc/mosquitto/conf.d - the Frugal IoT config was never copied there (step 5)"
|
|
117
|
+
|
|
118
|
+
# Which password file does the running configuration actually name?
|
|
119
|
+
# mosquitto's include_dir only loads files ending in .conf, so look at exactly those, last one wins
|
|
120
|
+
CONFIGURED_PWFILE=$(grep -hE '^[[:space:]]*password_file[[:space:]]' /etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*.conf(N) 2>/dev/null | tail -1 | awk '{print $2}')
|
|
121
|
+
section "Mosquitto password file"
|
|
122
|
+
if [[ -n "$CONFIGURED_PWFILE" ]]; then
|
|
123
|
+
item "configuration names: $CONFIGURED_PWFILE"
|
|
124
|
+
item " that file: $(fileinfo $CONFIGURED_PWFILE)"
|
|
125
|
+
if [[ ! -e "$CONFIGURED_PWFILE" ]]; then
|
|
126
|
+
problem "Mosquitto is configured to use $CONFIGURED_PWFILE but that file does not exist - it will refuse to start"
|
|
127
|
+
else
|
|
128
|
+
# Account names only, never the hashes
|
|
129
|
+
item " accounts in it: $(cut -d: -f1 "$CONFIGURED_PWFILE" 2>/dev/null | tr '\n' ' ')"
|
|
130
|
+
[[ ! -s "$CONFIGURED_PWFILE" ]] && item " (the file is empty - no accounts yet)"
|
|
131
|
+
fi
|
|
132
|
+
else
|
|
133
|
+
item "no password_file line found in any mosquitto configuration"
|
|
134
|
+
problem "No password_file configured - the broker would allow anonymous access or reject everything"
|
|
135
|
+
fi
|
|
136
|
+
# The copy in this installation is what step 5 tells you to put into /etc - if the two disagree,
|
|
137
|
+
# either the copy never happened or the installed package is older than the instructions.
|
|
138
|
+
if [[ -f extras/mosquitto.conf ]]; then
|
|
139
|
+
item "this installation's extras/mosquitto.conf names: $(grep -hE '^[[:space:]]*password_file' extras/mosquitto.conf 2>/dev/null | awk '{print $2}')"
|
|
140
|
+
if [[ -n "$CONFIGURED_PWFILE" ]] && ! diff -q extras/mosquitto.conf /etc/mosquitto/conf.d/frugal-iot.conf >/dev/null 2>&1; then
|
|
141
|
+
problem "extras/mosquitto.conf here differs from /etc/mosquitto/conf.d/frugal-iot.conf - if you followed step 5 with an older installed package, copy it again after upgrading"
|
|
142
|
+
fi
|
|
143
|
+
fi
|
|
144
|
+
item "candidate locations, whether or not configured:"
|
|
145
|
+
for f in /var/lib/mosquitto/passwords /etc/mosquitto/mosquitto_passwords; do
|
|
146
|
+
item " ${f}: $(fileinfo $f)"
|
|
147
|
+
done
|
|
148
|
+
item "directories they live in:"
|
|
149
|
+
for d in /var/lib/mosquitto /etc/mosquitto; do
|
|
150
|
+
item " ${d}: $(fileinfo $d)"
|
|
151
|
+
done
|
|
152
|
+
|
|
153
|
+
section "Mosquitto service"
|
|
154
|
+
if have systemctl; then
|
|
155
|
+
item "enabled: $(systemctl is-enabled mosquitto 2>&1)"
|
|
156
|
+
item "active: $(systemctl is-active mosquitto 2>&1)"
|
|
157
|
+
[[ "$(systemctl is-active mosquitto 2>/dev/null)" != "active" ]] && problem "Mosquitto is not running - see its own log, quoted above under 'Mosquitto service'"
|
|
158
|
+
print -r -- " --- last 12 journal lines (systemd's view: usually only an exit code) ---"
|
|
159
|
+
journalctl -u mosquitto -n 12 --no-pager 2>&1 | sed 's/^/ /'
|
|
160
|
+
else
|
|
161
|
+
item "systemctl not available on this machine"
|
|
162
|
+
fi
|
|
163
|
+
# Debian's mosquitto logs to a file rather than the journal, so this is where the real reason is.
|
|
164
|
+
MOSQUITTO_LOG=$(grep -hE '^[[:space:]]*log_dest[[:space:]]+file' /etc/mosquitto/mosquitto.conf /etc/mosquitto/conf.d/*(N) 2>/dev/null | tail -1 | awk '{print $3}')
|
|
165
|
+
[[ -z "$MOSQUITTO_LOG" ]] && MOSQUITTO_LOG=/var/log/mosquitto/mosquitto.log
|
|
166
|
+
item "its own log file: ${MOSQUITTO_LOG} ($(fileinfo $MOSQUITTO_LOG))"
|
|
167
|
+
if [[ -r "$MOSQUITTO_LOG" ]]; then
|
|
168
|
+
print -r -- " --- last 12 lines of that log (this is where startup errors appear) ---"
|
|
169
|
+
tail -12 "$MOSQUITTO_LOG" 2>&1 | sed 's/^/ /'
|
|
170
|
+
elif [[ -e "$MOSQUITTO_LOG" ]]; then
|
|
171
|
+
# The log belongs to the mosquitto user, so reading it needs privilege. Try without prompting -
|
|
172
|
+
# on Raspberry Pi OS the first user has passwordless sudo, so this usually just works.
|
|
173
|
+
if have sudo && MOSQUITTO_LOG_TAIL=$(sudo -n tail -12 "$MOSQUITTO_LOG" 2>/dev/null); then
|
|
174
|
+
print -r -- " --- last 12 lines of that log, read with sudo (startup errors appear here) ---"
|
|
175
|
+
print -r -- "$MOSQUITTO_LOG_TAIL" | sed 's/^/ /'
|
|
176
|
+
else
|
|
177
|
+
item " it belongs to $(fileinfo $MOSQUITTO_LOG | awk '{print $1}') so it cannot be read as $(id -un)."
|
|
178
|
+
item " Re-run this whole script with sudo to include it: sudo zsh $0"
|
|
179
|
+
problem "Could not read ${MOSQUITTO_LOG}, which is where Mosquitto explains itself - re-run with sudo"
|
|
180
|
+
fi
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
section "Listening ports"
|
|
184
|
+
PORTS=""
|
|
185
|
+
if have ss; then
|
|
186
|
+
PORTS=$(ss -tln 2>/dev/null | grep -E ':(1883|9012|8080)\b')
|
|
187
|
+
elif have netstat; then
|
|
188
|
+
PORTS=$(netstat -an 2>/dev/null | grep -E '[.:](1883|9012|8080) ')
|
|
189
|
+
else
|
|
190
|
+
item "neither ss nor netstat available"
|
|
191
|
+
fi
|
|
192
|
+
if [[ -n "$PORTS" ]]; then
|
|
193
|
+
print -r -- "$PORTS" | sed 's/^/ /'
|
|
194
|
+
else
|
|
195
|
+
item "none of 1883 (mqtt), 9012 (websockets), 8080 (web) are listening"
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
section "Frugal IoT server service"
|
|
199
|
+
if have systemctl; then
|
|
200
|
+
if systemctl list-unit-files 2>/dev/null | grep -q '^frugaliot.service'; then
|
|
201
|
+
item "enabled: $(systemctl is-enabled frugaliot 2>&1)"
|
|
202
|
+
item "active: $(systemctl is-active frugaliot 2>&1)"
|
|
203
|
+
print -r -- " --- last 20 journal lines ---"
|
|
204
|
+
journalctl -u frugaliot -n 20 --no-pager 2>&1 | sed 's/^/ /'
|
|
205
|
+
else
|
|
206
|
+
item "no frugaliot service installed yet (step 8 not reached)"
|
|
207
|
+
fi
|
|
208
|
+
else
|
|
209
|
+
item "systemctl not available on this machine"
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
section "Name resolution (steps 3, 4)"
|
|
213
|
+
# The broker URL in config.d/mqtt.yaml has to resolve from every machine that uses it - this one,
|
|
214
|
+
# and whatever browser or node talks to it.
|
|
215
|
+
BROKER_URL=$(grep -h '^broker:' config.d/mqtt.yaml 2>/dev/null | awk '{print $2}')
|
|
216
|
+
# Strip scheme, then any path (wss://host/wss), then any port - leaving just the hostname
|
|
217
|
+
BROKER_HOST=${${${BROKER_URL#*://}%%/*}%%:*}
|
|
218
|
+
if [[ -n "$BROKER_HOST" ]]; then
|
|
219
|
+
item "broker host: $BROKER_HOST"
|
|
220
|
+
if have getent && BROKER_IP=$(getent hosts "$BROKER_HOST" 2>/dev/null | head -1 | awk '{print $1}') && [[ -n "$BROKER_IP" ]]; then
|
|
221
|
+
item " resolves to $BROKER_IP from this machine"
|
|
222
|
+
elif have ping && ping -c1 -W2 "$BROKER_HOST" >/dev/null 2>&1; then
|
|
223
|
+
item " answers ping from this machine"
|
|
224
|
+
else
|
|
225
|
+
item " DOES NOT RESOLVE from this machine"
|
|
226
|
+
problem "The broker host '$BROKER_HOST' does not resolve here - the server's own logger cannot connect either"
|
|
227
|
+
fi
|
|
228
|
+
[[ "$BROKER_HOST" == *.local ]] && item " note: .local names do not resolve on most Android phones - use an IP address there"
|
|
229
|
+
fi
|
|
230
|
+
MYADDRS=$( (have ip && ip -4 -o addr show scope global | awk '{print $2"="$4}') 2>/dev/null | tr '\n' ' ')
|
|
231
|
+
item "this machine's addresses: ${MYADDRS:-(could not determine)}"
|
|
232
|
+
|
|
233
|
+
section "Broker authentication (steps 5, 6)"
|
|
234
|
+
if have mosquitto_sub; then
|
|
235
|
+
# A wrong password must be refused - that is the check in step 5
|
|
236
|
+
WRONGOUT=$(mosquitto_sub -h localhost -u nobody -P wrong -t '#' -W 2 2>&1)
|
|
237
|
+
if [[ "$WRONGOUT" == *"not authorised"* ]]; then
|
|
238
|
+
item "wrong password: correctly refused"
|
|
239
|
+
elif [[ "$WRONGOUT" == *"Connection refused"* || "$WRONGOUT" == *"Error"* ]]; then
|
|
240
|
+
item "wrong password: broker did not answer - $WRONGOUT"
|
|
241
|
+
problem "The broker is not answering on localhost:1883"
|
|
242
|
+
else
|
|
243
|
+
item "wrong password: ACCEPTED - the broker is not requiring credentials"
|
|
244
|
+
problem "A wrong password was not refused - check password_file is set in the mosquitto config"
|
|
245
|
+
fi
|
|
246
|
+
# Then each configured organization should be able to connect with its own credentials.
|
|
247
|
+
# The password is read from the config and used, never printed.
|
|
248
|
+
for f in config.d/organizations/*.yaml(N); do
|
|
249
|
+
ORG=${f:t:r}
|
|
250
|
+
ORG_PW=$(sed -n 's/^mqtt_password:[[:space:]]*//p' "$f" 2>/dev/null | head -1 | tr -d '"'"'"'')
|
|
251
|
+
if [[ -z "$ORG_PW" ]]; then
|
|
252
|
+
item "organization $ORG: no mqtt_password in $f"
|
|
253
|
+
continue
|
|
254
|
+
fi
|
|
255
|
+
ORGOUT=$(mosquitto_sub -h localhost -u "$ORG" -P "$ORG_PW" -t '#' -W 2 2>&1)
|
|
256
|
+
if [[ "$ORGOUT" == *"not authorised"* ]]; then
|
|
257
|
+
item "organization $ORG: REFUSED by the broker"
|
|
258
|
+
problem "Organization '$ORG' cannot log in to the broker - its password in $f does not match the broker's password file (step 6)"
|
|
259
|
+
elif [[ "$ORGOUT" == *"Connection refused"* ]]; then
|
|
260
|
+
item "organization $ORG: broker not answering"
|
|
261
|
+
else
|
|
262
|
+
item "organization $ORG: authenticates, and saw $(print -r -- "$ORGOUT" | grep -c . ) message(s) in a 2 second sample"
|
|
263
|
+
fi
|
|
264
|
+
done
|
|
265
|
+
else
|
|
266
|
+
item "mosquitto_sub not installed - cannot test broker logins (sudo apt install mosquitto-clients)"
|
|
267
|
+
fi
|
|
268
|
+
|
|
269
|
+
section "Web server (step 7)"
|
|
270
|
+
WEBPORT=$(grep -h '^port:' config.d/server.yaml 2>/dev/null | awk '{print $2}')
|
|
271
|
+
[[ -z "$WEBPORT" ]] && WEBPORT=8080
|
|
272
|
+
if have curl; then
|
|
273
|
+
HOMECODE=$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:${WEBPORT}/ 2>/dev/null)
|
|
274
|
+
if [[ "$HOMECODE" == "000" ]]; then
|
|
275
|
+
item "nothing is answering on port ${WEBPORT} - the server is not running"
|
|
276
|
+
item " (expected if you have not reached step 7 yet; otherwise start it, or see the frugaliot service below)"
|
|
277
|
+
else
|
|
278
|
+
item "GET / -> ${HOMECODE} (expect 200)"
|
|
279
|
+
item "GET /config.json -> $(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:${WEBPORT}/config.json 2>/dev/null) (expect 401 when not logged in)"
|
|
280
|
+
[[ "$HOMECODE" != "200" ]] && problem "The web server answered ${HOMECODE} rather than 200 on port ${WEBPORT}"
|
|
281
|
+
fi
|
|
282
|
+
else
|
|
283
|
+
item "curl not installed - cannot test the web server"
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
section "Logged data (step 9)"
|
|
287
|
+
if [[ -d data ]]; then
|
|
288
|
+
DATADIRS=(data/*(N/))
|
|
289
|
+
if (( ${#DATADIRS} )); then
|
|
290
|
+
for d in $DATADIRS; do
|
|
291
|
+
item "${d}: $(find $d -type f 2>/dev/null | wc -l | tr -d ' ') files, newest $(ls -t $d/**/*(N.om[1]) 2>/dev/null | head -1)"
|
|
292
|
+
done
|
|
293
|
+
else
|
|
294
|
+
item "data/ is empty - no readings logged yet, which is expected until a node reports (step 9)"
|
|
295
|
+
fi
|
|
296
|
+
fi
|
|
297
|
+
|
|
298
|
+
section "Wear on the SD card"
|
|
299
|
+
# An SD card wears out from being written to, and a server logging sensor readings writes constantly
|
|
300
|
+
# unless told not to. This reports how much is actually being written and whether the settings that
|
|
301
|
+
# reduce it are in force. There is no threshold to compare against - run it twice a few days apart
|
|
302
|
+
# and look at the rate.
|
|
303
|
+
if [[ -r /proc/diskstats && -r /proc/uptime ]]; then
|
|
304
|
+
# Field 10 of each line is sectors written, and a sector is 512 bytes. Take the whole card rather
|
|
305
|
+
# than a partition (mmcblk0, not mmcblk0p2) so the boot partition and swap are included too.
|
|
306
|
+
ROOTDEV=$( (have findmnt && findmnt -no SOURCE / 2>/dev/null) || print -r -- "" )
|
|
307
|
+
ROOTDISK=${${ROOTDEV:t}%p[0-9]*} # /dev/mmcblk0p2 -> mmcblk0
|
|
308
|
+
[[ -z "$ROOTDISK" ]] && ROOTDISK=mmcblk0
|
|
309
|
+
WRITTEN=$(awk -v d="$ROOTDISK" '$3 == d {print $10}' /proc/diskstats 2>/dev/null)
|
|
310
|
+
UPSECS=$(awk '{print int($1)}' /proc/uptime 2>/dev/null)
|
|
311
|
+
if [[ -n "$WRITTEN" && -n "$UPSECS" && "$UPSECS" -gt 0 ]]; then
|
|
312
|
+
item "device: /dev/${ROOTDISK}"
|
|
313
|
+
item "written: $(awk -v s="$WRITTEN" 'BEGIN {printf "%.1f MB", s*512/1048576}') since boot, over $(awk -v u="$UPSECS" 'BEGIN {printf "%.1f", u/3600}') hours"
|
|
314
|
+
item " that is: $(awk -v s="$WRITTEN" -v u="$UPSECS" 'BEGIN {printf "%.1f MB/hour", s*512/1048576/(u/3600)}')"
|
|
315
|
+
else
|
|
316
|
+
item "could not read write counters for /dev/${ROOTDISK} from /proc/diskstats"
|
|
317
|
+
fi
|
|
318
|
+
else
|
|
319
|
+
item "no /proc/diskstats on this machine - write counters are Linux only"
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
# Swapping to an SD card wears it fast. On a Pi 4 there should be little or none; a Pi Zero W may
|
|
323
|
+
# need some. See INSTALLATION.md, "Wear and tear on the SD card".
|
|
324
|
+
if have free; then
|
|
325
|
+
item "swap: $(free -h | awk '/^Swap:/ {print $3 " used of " $2}')"
|
|
326
|
+
fi
|
|
327
|
+
if [[ -r /proc/sys/vm/swappiness ]]; then
|
|
328
|
+
SWAPPINESS=$(< /proc/sys/vm/swappiness)
|
|
329
|
+
item "swappiness: ${SWAPPINESS} (1 means swap only when there is no alternative)"
|
|
330
|
+
fi
|
|
331
|
+
have swapon && item "swap areas: $(swapon --show=NAME,TYPE,SIZE,USED --noheadings 2>/dev/null | tr '\n' ';' || print -r -- '(none)')"
|
|
332
|
+
|
|
333
|
+
# The journal is the other thing that writes on every event, if it is kept on disk at all
|
|
334
|
+
if [[ -d /var/log/journal ]]; then
|
|
335
|
+
item "journal: stored on disk in /var/log/journal - every logged line is a write"
|
|
336
|
+
have journalctl && item " using: $(journalctl --disk-usage 2>/dev/null | sed 's/^Archived and active journals take up //')"
|
|
337
|
+
else
|
|
338
|
+
item "journal: kept in RAM only (no /var/log/journal), so it costs no writes"
|
|
339
|
+
fi
|
|
340
|
+
|
|
341
|
+
# The settings that decide how much gets logged in the first place
|
|
342
|
+
if [[ -f config.d/logger.yaml ]]; then
|
|
343
|
+
VERBOSE=$(sed -n 's/^verbose:[[:space:]]*//p' config.d/logger.yaml 2>/dev/null | head -1)
|
|
344
|
+
FLUSHSECS=$(sed -n 's/^flushseconds:[[:space:]]*//p' config.d/logger.yaml 2>/dev/null | head -1)
|
|
345
|
+
item "logger verbose: ${VERBOSE:-not set, so on - a line logged per message received}"
|
|
346
|
+
item "logger flushseconds: ${FLUSHSECS:-not set, so readings are written as they arrive}"
|
|
347
|
+
fi
|
|
348
|
+
if [[ -f config.d/server.yaml ]]; then
|
|
349
|
+
MORGANSET=$(sed -n 's/^morgan:[[:space:]]*//p' config.d/server.yaml 2>/dev/null | head -1)
|
|
350
|
+
item "server morgan: ${MORGANSET:-not set, so on - a line logged per HTTP request}"
|
|
351
|
+
fi
|
|
352
|
+
if [[ -f /etc/mosquitto/conf.d/frugal-iot.conf ]]; then
|
|
353
|
+
if grep -qE '^[[:space:]]*connection_messages[[:space:]]+false' /etc/mosquitto/conf.d/frugal-iot.conf 2>/dev/null; then
|
|
354
|
+
item "mosquitto connections: not logged"
|
|
355
|
+
else
|
|
356
|
+
item "mosquitto connections: logged - every connect and disconnect is a write"
|
|
357
|
+
fi
|
|
358
|
+
fi
|
|
359
|
+
if [[ -e "$MOSQUITTO_LOG" ]]; then
|
|
360
|
+
item "mosquitto log size: $(fileinfo $MOSQUITTO_LOG | awk '{print $3, $4}')"
|
|
361
|
+
else
|
|
362
|
+
item "mosquitto log size: no log file at ${MOSQUITTO_LOG}"
|
|
363
|
+
fi
|
|
364
|
+
|
|
365
|
+
# How much the readings themselves are taking up, and whether the old ones have been compressed
|
|
366
|
+
if [[ -d data ]]; then
|
|
367
|
+
CSVCOUNT=$(find data -name '*.csv' 2>/dev/null | wc -l | tr -d ' ')
|
|
368
|
+
GZCOUNT=$(find data -name '*.csv.gz' 2>/dev/null | wc -l | tr -d ' ')
|
|
369
|
+
item "readings: ${CSVCOUNT} csv files and ${GZCOUNT} compressed, $(du -sh data 2>/dev/null | awk '{print $1}') in total"
|
|
370
|
+
# Filenames are the date, so the earliest name is the oldest day held. Both suffixes stripped
|
|
371
|
+
# separately, because BSD sed (macOS) has no "\?" for an optional group.
|
|
372
|
+
OLDEST=$(find data \( -name '*.csv' -o -name '*.csv.gz' \) 2>/dev/null | sed 's|.*/||; s|\.gz$||; s|\.csv$||' | sort | head -1)
|
|
373
|
+
[[ -n "$OLDEST" ]] && item " oldest day: ${OLDEST}"
|
|
374
|
+
fi
|
|
375
|
+
|
|
376
|
+
section "Summary"
|
|
377
|
+
if (( ${#PROBLEMS} == 0 )); then
|
|
378
|
+
item "No problems detected by these checks."
|
|
379
|
+
else
|
|
380
|
+
for p in $PROBLEMS; do item "PROBLEM: $p"; done
|
|
381
|
+
fi
|
|
382
|
+
print -r -- ""
|
package/scripts/init.zsh
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# in node_modules (or which sits in a git clone).
|
|
6
6
|
#
|
|
7
7
|
# Usage:
|
|
8
|
-
# npx frugal-iot-init # after: npm install frugal-iot-server
|
|
8
|
+
# npx --no frugal-iot-init # after: npm install frugal-iot-server
|
|
9
9
|
# scripts/init.zsh # in a git clone, where the config files are already present
|
|
10
10
|
#
|
|
11
11
|
# Safe to run again: existing files are left exactly as they are, only missing ones are added,
|
|
@@ -28,11 +28,24 @@ if [[ ! -f "${PKG}/frugal-iot-createdb.sql" ]]; then
|
|
|
28
28
|
fi
|
|
29
29
|
|
|
30
30
|
# ---- 1. Configuration files, copied only if not already here ----
|
|
31
|
-
COPIED=()
|
|
31
|
+
COPIED=() # which files this run actually created, so existing ones are never rewritten below
|
|
32
|
+
DIFFER_TO=() # files kept that no longer match what this release ships, and where to compare them
|
|
33
|
+
DIFFER_FROM=()
|
|
34
|
+
# copy_if_missing <packaged file> <local file> [compare]
|
|
35
|
+
# "compare" asks for a warning when the file exists but differs from the one this release ships.
|
|
36
|
+
# Only pass it for files nobody is expected to edit - config.yaml, mqtt.yaml and server.yaml are
|
|
37
|
+
# this installation's own settings, so of course they differ, and warning about them every time
|
|
38
|
+
# would train you to ignore the warning that matters.
|
|
32
39
|
copy_if_missing() {
|
|
33
|
-
local from=$1 to=$2
|
|
40
|
+
local from=$1 to=$2 compare=${3:-}
|
|
34
41
|
if [[ -e "$to" ]]; then
|
|
35
|
-
|
|
42
|
+
if [[ "$compare" == compare && -e "$from" ]] && ! cmp -s "$from" "$to"; then
|
|
43
|
+
DIFFER_TO+=("$to")
|
|
44
|
+
DIFFER_FROM+=("$from")
|
|
45
|
+
echo " kept ${to} (DIFFERS from this release - see below)"
|
|
46
|
+
else
|
|
47
|
+
echo " kept ${to} (already present)"
|
|
48
|
+
fi
|
|
36
49
|
elif [[ -e "$from" ]]; then
|
|
37
50
|
mkdir -p "${to:h}"
|
|
38
51
|
cp "$from" "$to"
|
|
@@ -46,12 +59,14 @@ copy_if_missing "${PKG}/config.yaml" "./config.yaml"
|
|
|
46
59
|
for f in logger.yaml mqtt.yaml server.yaml; do
|
|
47
60
|
copy_if_missing "${PKG}/config.d/${f}" "./config.d/${f}"
|
|
48
61
|
done
|
|
62
|
+
# The schema describes the sensor types the software understands, so a release changing it matters
|
|
49
63
|
for f in "${PKG}"/config.d/schema/*.yaml(N); do
|
|
50
|
-
copy_if_missing "$f" "./config.d/schema/${f:t}"
|
|
64
|
+
copy_if_missing "$f" "./config.d/schema/${f:t}" compare
|
|
51
65
|
done
|
|
52
|
-
# Copied so that mosquitto.conf and frugaliot.service can be edited and installed from here
|
|
66
|
+
# Copied so that mosquitto.conf and frugaliot.service can be edited and installed from here. Worth
|
|
67
|
+
# comparing: these get copied on somewhere else (/etc/...), where an old version lingers unnoticed.
|
|
53
68
|
for f in "${PKG}"/extras/*(N); do
|
|
54
|
-
copy_if_missing "$f" "./extras/${f:t}"
|
|
69
|
+
copy_if_missing "$f" "./extras/${f:t}" compare
|
|
55
70
|
done
|
|
56
71
|
|
|
57
72
|
# ---- 1a. Point server.yaml at the web client this instance actually has ----
|
|
@@ -104,8 +119,20 @@ else
|
|
|
104
119
|
echo " created ${DB}"
|
|
105
120
|
fi
|
|
106
121
|
|
|
122
|
+
if (( ${#DIFFER_TO} )); then
|
|
123
|
+
echo
|
|
124
|
+
echo "These files were left as you have them, but this release ships a different version:"
|
|
125
|
+
for i in {1..${#DIFFER_TO}}; do
|
|
126
|
+
echo " ${DIFFER_TO[$i]}"
|
|
127
|
+
echo " compare with: diff ${DIFFER_TO[$i]} ${DIFFER_FROM[$i]}"
|
|
128
|
+
done
|
|
129
|
+
echo "Usually that just means you edited it, and there is nothing to do. But a release can also"
|
|
130
|
+
echo "change one of these files - and anything installed elsewhere from it, such as"
|
|
131
|
+
echo "/etc/mosquitto/conf.d/frugal-iot.conf, keeps the old content until you copy it again."
|
|
132
|
+
fi
|
|
133
|
+
|
|
107
134
|
echo
|
|
108
135
|
echo "Ready. Next:"
|
|
109
|
-
echo " npx frugal-iot-setpassword superuser <password> # so you can log in as the administrator"
|
|
110
|
-
echo " npx frugal-iot-addorganization <org-id> <name> <email> <phone> <broker-password>"
|
|
111
|
-
echo " npx frugal-iot-server # start it"
|
|
136
|
+
echo " npx --no frugal-iot-setpassword superuser <password> # so you can log in as the administrator"
|
|
137
|
+
echo " npx --no frugal-iot-addorganization <org-id> <name> <email> <phone> <broker-password>"
|
|
138
|
+
echo " npx --no frugal-iot-server # start it"
|