frugal-iot-server 0.3.7 → 0.3.8

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.
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env zsh
2
+ #
3
+ # Bridge this Pi's broker to a production Frugal IoT server. RUN THIS ON THE PI.
4
+ #
5
+ # After this, the Pi's readings also appear on the production server, and its nodes can be
6
+ # controlled from there - while the Pi carries on recording and serving on its own whenever the
7
+ # link is down. Readings taken during an outage stay on the Pi: production gets a gap rather than
8
+ # a backlog delivered later with the wrong timestamps. INSTALLATION.md step 11 explains why.
9
+ #
10
+ # The relaying is done by the broker, so the server, the logger and the nodes are untouched.
11
+ #
12
+ # Run the companion script on the production server first - "frugal-iot-addbridge-prod" - which
13
+ # creates the account this needs and tells you the password.
14
+ #
15
+ # Run from the server's own directory, the one holding frugal-iot.db.
16
+ #
17
+ # Usage:
18
+ # npx --no frugal-iot-addbridge-pi --org <org-id> --host <prod-host> --account <account> [--replace]
19
+ # scripts/addbridge-pi.zsh --org myfarm --host prod.example.org --account bridge-northfield
20
+ #
21
+ # The password is asked for rather than passed on the command line, so it does not end up in your
22
+ # shell history. Give it with --password only for an unattended run.
23
+
24
+ set -euo pipefail
25
+
26
+ SCRIPT_NAME=$0
27
+
28
+ usage() {
29
+ echo "Usage: ${SCRIPT_NAME} --org <org-id> --host <prod-host> --account <account> [--port N] [--password PW] [--replace]" >&2
30
+ echo "Example: ${SCRIPT_NAME} --org myfarm --host prod.example.org --account bridge-northfield" >&2
31
+ exit 1
32
+ }
33
+
34
+ ORG_ID=""; PROD_HOST=""; ACCOUNT=""; PASSWORD=""; PROD_PORT=8883; REPLACE=false
35
+
36
+ while [[ $# -gt 0 ]]; do
37
+ case "$1" in
38
+ --org) ORG_ID=${2:-}; shift 2 ;;
39
+ --host) PROD_HOST=${2:-}; shift 2 ;;
40
+ --account) ACCOUNT=${2:-}; shift 2 ;;
41
+ --password) PASSWORD=${2:-}; shift 2 ;;
42
+ --port) PROD_PORT=${2:-}; shift 2 ;;
43
+ --replace) REPLACE=true; shift ;;
44
+ *) echo "Error: unexpected argument '$1'" >&2; usage ;;
45
+ esac
46
+ done
47
+
48
+ [[ -z "$ORG_ID" || -z "$PROD_HOST" || -z "$ACCOUNT" ]] && usage
49
+
50
+ BRIDGE_CONF=/etc/mosquitto/conf.d/frugal-iot-bridge.conf
51
+
52
+ # This Pi has to know the organization too, or its own logger will not be recording what it relays.
53
+ if [[ ! -f "config.d/organizations/${ORG_ID}.yaml" ]]; then
54
+ echo "Error: no config.d/organizations/${ORG_ID}.yaml here." >&2
55
+ echo "Run this from the server's own directory, and check the organization id matches the one" >&2
56
+ echo "used on the production server - it is the first element of every topic, so the two ends" >&2
57
+ echo "must agree exactly." >&2
58
+ exit 1
59
+ fi
60
+
61
+ if [[ -e "$BRIDGE_CONF" && "$REPLACE" != true ]]; then
62
+ echo "Error: ${BRIDGE_CONF} already exists - this Pi is already bridged." >&2
63
+ echo "Add --replace to overwrite it, or edit it by hand." >&2
64
+ exit 1
65
+ fi
66
+
67
+ # The example file ships with the server, so look beside this script rather than guessing a path -
68
+ # that works both for an npm install and for a git checkout.
69
+ EXAMPLE="${SCRIPT_NAME:A:h}/../extras/mosquitto-bridge.conf.example"
70
+ if [[ ! -f "$EXAMPLE" ]]; then
71
+ echo "Error: cannot find mosquitto-bridge.conf.example (looked at ${EXAMPLE})" >&2
72
+ exit 1
73
+ fi
74
+
75
+ if [[ -z "$PASSWORD" ]]; then
76
+ echo -n "Password for ${ACCOUNT} on ${PROD_HOST} (not echoed): "
77
+ read -rs PASSWORD
78
+ echo ""
79
+ if [[ -z "$PASSWORD" ]]; then
80
+ echo "Error: no password given" >&2
81
+ exit 1
82
+ fi
83
+ fi
84
+
85
+ # Check the far end is reachable and its certificate checks out, before writing anything. Getting
86
+ # this wrong is the usual reason a bridge silently fails to connect, and the broker's own log is
87
+ # the only place it says so.
88
+ if command -v openssl >/dev/null; then
89
+ echo "Checking ${PROD_HOST}:${PROD_PORT} ..."
90
+ if echo | openssl s_client -connect "${PROD_HOST}:${PROD_PORT}" -servername "$PROD_HOST" \
91
+ -verify_hostname "$PROD_HOST" -verify_return_error >/dev/null 2>&1; then
92
+ echo " reachable, and the certificate is valid for that name"
93
+ else
94
+ echo "Warning: could not complete a TLS handshake with ${PROD_HOST}:${PROD_PORT}." >&2
95
+ echo " Either the port is not open, or the certificate is not valid for that name." >&2
96
+ echo " Carrying on - the bridge will keep retrying - but it will not connect until that is fixed." >&2
97
+ fi
98
+ fi
99
+
100
+ # Fill in the example. Everything the bridge needs, including why each setting is what it is, is in
101
+ # the comments of that file, so it is copied whole rather than reduced to the active lines.
102
+ TMP=$(mktemp)
103
+ trap 'rm -f "$TMP"' EXIT
104
+ sed -e "s|<prod-host>:8883|${PROD_HOST}:${PROD_PORT}|" \
105
+ -e "s|<bridge-account>|${ACCOUNT}|g" \
106
+ -e "s|<bridge-password>|${PASSWORD}|" \
107
+ -e "s|<org>/|${ORG_ID}/|g" \
108
+ -e "s|^# Replace <org> in all four lines with the organization id - the first element of every topic, and|# The organization here is \"${ORG_ID}\" - the first element of every topic, and|" \
109
+ "$EXAMPLE" > "$TMP"
110
+
111
+ if grep -qE '<(prod-host|bridge-account|bridge-password)>' "$TMP"; then
112
+ echo "Error: some placeholders were not filled in - not installing. This is a bug in this script" >&2
113
+ echo " or in extras/mosquitto-bridge.conf.example." >&2
114
+ exit 1
115
+ fi
116
+
117
+ # Mode 600 because it holds the password. Mosquitto reads its configuration as root, before
118
+ # dropping privileges, so it can still read it - unlike the TLS key, which it reads afterwards.
119
+ echo "Writing ${BRIDGE_CONF} ..."
120
+ sudo install -o root -g root -m 600 "$TMP" "$BRIDGE_CONF"
121
+
122
+ # A restart, not a reload: mosquitto does not pick up bridges on a reload signal. Every node
123
+ # reconnects on its own afterwards.
124
+ echo "Restarting mosquitto (nodes will reconnect on their own) ..."
125
+ sudo systemctl restart mosquitto
126
+ sleep 4
127
+
128
+ if [[ "$(systemctl is-active mosquitto 2>/dev/null)" != "active" ]]; then
129
+ echo "Error: mosquitto did not come back up. Its own log says why:" >&2
130
+ echo " sudo tail -20 /var/log/mosquitto/mosquitto.log" >&2
131
+ echo "To undo this: sudo rm ${BRIDGE_CONF} && sudo systemctl restart mosquitto" >&2
132
+ exit 1
133
+ fi
134
+
135
+ # Did it actually connect? The bridge publishes a retained 1 or 0 here whenever that changes, so
136
+ # this is the real answer rather than the absence of an error.
137
+ STATE=""
138
+ if command -v mosquitto_sub >/dev/null; then
139
+ ORG_PW=$(sed -n 's/^mqtt_password:[[:space:]]*//p' "config.d/organizations/${ORG_ID}.yaml" | head -1 | tr -d '"'"'"'') || true
140
+ for attempt in 1 2 3 4 5; do
141
+ # "|| true" because mosquitto_sub always exits non-zero when -W times out, and under "set -e"
142
+ # that ends this script here - silently, after the restart, without ever printing the verdict
143
+ # below. Likewise "if" rather than "&& break", whose false case is a non-zero last command.
144
+ STATE=$(mosquitto_sub -h localhost -u "$ORG_ID" -P "$ORG_PW" \
145
+ -t "\$SYS/broker/connection/${ACCOUNT}/state" -W 3 2>/dev/null | tail -1) || true
146
+ if [[ "$STATE" == "1" ]]; then break; fi
147
+ sleep 3
148
+ done
149
+ fi
150
+
151
+ echo ""
152
+ if [[ "$STATE" == "1" ]]; then
153
+ echo "Connected. This Pi is now relaying ${ORG_ID} to ${PROD_HOST}."
154
+ elif [[ -z "$STATE" ]]; then
155
+ echo "Installed, but this script could not tell whether it connected."
156
+ echo "Check with: npx --no frugal-iot-diagnostic"
157
+ else
158
+ echo "Installed, but not connected yet (state=${STATE})."
159
+ echo "It keeps retrying, so a wrong password or an unreachable port will show up in the log:"
160
+ echo " sudo tail -20 /var/log/mosquitto/mosquitto.log"
161
+ echo "and the state is reported by: npx --no frugal-iot-diagnostic"
162
+ fi
163
+ echo ""
164
+ echo "The readings this Pi has already recorded stay here - only new ones are relayed."
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env zsh
2
+ #
3
+ # Authorize a Raspberry Pi to bridge into this server. RUN THIS ON THE PRODUCTION SERVER.
4
+ #
5
+ # A bridge relays a Pi's readings to this server, so the same nodes appear on a dashboard here and
6
+ # can be controlled from here, while the Pi carries on working on its own when the link is down.
7
+ # This end of it is one broker account and one access-control rule. The Pi end is a configuration
8
+ # file, written by the companion script - "frugal-iot-addbridge-pi", run on the Pi.
9
+ #
10
+ # Each Pi gets its own account rather than sharing the organization's, for two reasons: the
11
+ # organization's broker password is handed to every browser that logs in, so it is not a secret;
12
+ # and a per-Pi account can be confined to one site's topics and revoked without disturbing anyone.
13
+ #
14
+ # Run from the server's own directory, the one holding frugal-iot.db.
15
+ #
16
+ # Usage:
17
+ # npx --no frugal-iot-addbridge-prod <org-id> <site-name> [password]
18
+ # scripts/addbridge-prod.zsh <org-id> <site-name> [password]
19
+ # Example:
20
+ # npx --no frugal-iot-addbridge-prod myfarm northfield
21
+ #
22
+ # The site name only distinguishes one Pi from another within an organization, and becomes part of
23
+ # the account name ("bridge-northfield"). With no password given, a strong one is generated and
24
+ # printed - it is needed once, to type into the Pi.
25
+ #
26
+ # This assumes the server has already been set up to accept bridges at all: a TLS listener on 8883
27
+ # with a certificate. That is a one-off, and INSTALLATION.md step 11 covers it.
28
+
29
+ set -euo pipefail
30
+
31
+ SCRIPT_NAME=$0
32
+
33
+ usage() {
34
+ echo "Usage: ${SCRIPT_NAME} <org-id> <site-name> [password]" >&2
35
+ echo "Example: ${SCRIPT_NAME} myfarm northfield" >&2
36
+ exit 1
37
+ }
38
+
39
+ if [[ $# -lt 2 || $# -gt 3 ]]; then
40
+ usage
41
+ fi
42
+
43
+ ORG_ID=$1
44
+ SITE=$2
45
+ PASSWORD=${3:-}
46
+
47
+ if [[ ! "$ORG_ID" =~ ^[a-z0-9]{1,10}$ ]]; then
48
+ echo "Error: org id must be 1-10 lower-case letters/digits, got '${ORG_ID}'" >&2
49
+ exit 1
50
+ fi
51
+ # The site name ends up in an account name and in an ACL file, so keep it to something that cannot
52
+ # be mistaken for syntax in either
53
+ if [[ ! "$SITE" =~ ^[a-z0-9-]{1,20}$ ]]; then
54
+ echo "Error: site name must be 1-20 lower-case letters, digits or hyphens, got '${SITE}'" >&2
55
+ exit 1
56
+ fi
57
+
58
+ ACCOUNT="bridge-${SITE}"
59
+ CONFIG_FILE="config.d/organizations/${ORG_ID}.yaml"
60
+
61
+ # The organization has to exist here, or the readings will arrive and nothing will record them:
62
+ # this server's logger subscribes per organization, driven by these files.
63
+ if [[ ! -f "$CONFIG_FILE" ]]; then
64
+ echo "Error: no ${CONFIG_FILE} - organization '${ORG_ID}' is not configured on this server." >&2
65
+ echo "Its readings would arrive at the broker and nothing would record them. Add it first:" >&2
66
+ echo " npx --no frugal-iot-addorganization ${ORG_ID} \"<name>\" <email> <phone> \"<password>\"" >&2
67
+ exit 1
68
+ fi
69
+
70
+ if ! command -v mosquitto_passwd >/dev/null; then
71
+ echo "Error: mosquitto_passwd not found - is mosquitto installed on this machine?" >&2
72
+ exit 1
73
+ fi
74
+
75
+ # Which files the running broker actually uses, rather than where they usually are. Only ".conf"
76
+ # files in conf.d are read, and the last setting wins.
77
+ MOSQ_CONFS=(/etc/mosquitto/mosquitto.conf(N) /etc/mosquitto/conf.d/*.conf(N))
78
+ if (( ${#MOSQ_CONFS} == 0 )); then
79
+ echo "Error: no mosquitto configuration found in /etc/mosquitto" >&2
80
+ exit 1
81
+ fi
82
+ # Read one setting out of the broker's configuration. Two things to be careful of: a conf.d file
83
+ # can be root-only (a bridge configuration holds a password, so it should be), which makes grep
84
+ # fail rather than return nothing - and under "set -e" a failing command substitution would end
85
+ # this script silently, before it had printed anything at all. Hence "|| true" and the sudo retry.
86
+ mosq_setting() {
87
+ local out
88
+ out=$(grep -hE "^[[:space:]]*$1[[:space:]]" "${MOSQ_CONFS[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
89
+ if [[ -z "$out" ]] && command -v sudo >/dev/null; then
90
+ out=$(sudo grep -hE "^[[:space:]]*$1[[:space:]]" "${MOSQ_CONFS[@]}" 2>/dev/null | tail -1 | awk '{print $2}') || true
91
+ fi
92
+ print -r -- "$out"
93
+ }
94
+ PWFILE=$(mosq_setting password_file)
95
+ ACLFILE=$(mosq_setting acl_file)
96
+
97
+ if [[ -z "$PWFILE" ]]; then
98
+ echo "Error: no password_file in the mosquitto configuration - this broker is not using accounts." >&2
99
+ exit 1
100
+ fi
101
+ if [[ ! -e "$PWFILE" ]]; then
102
+ echo "Error: mosquitto names ${PWFILE} as its password file but it does not exist." >&2
103
+ exit 1
104
+ fi
105
+
106
+ # The account names in the password file. Needs the same sudo retry as the settings above: the
107
+ # password file is normally mode 600 owned by the broker's own user, so reading it as anyone else
108
+ # silently returns nothing - which would look exactly like "this account does not exist yet".
109
+ account_exists() {
110
+ local names
111
+ names=$(cut -d: -f1 "$PWFILE" 2>/dev/null) || true
112
+ if [[ -z "$names" ]] && command -v sudo >/dev/null; then
113
+ names=$(sudo cut -d: -f1 "$PWFILE" 2>/dev/null) || true
114
+ fi
115
+ print -r -- "$names" | grep -qx "$1"
116
+ }
117
+
118
+ # Refuse to rotate the password of a bridge that is presumably working: the Pi holds the old one,
119
+ # and it would stop relaying the moment this changed without anyone touching the Pi.
120
+ if account_exists "$ACCOUNT"; then
121
+ echo "Error: account '${ACCOUNT}' already exists in ${PWFILE}." >&2
122
+ echo "If a Pi is already using it, leave it alone. To give it a new password, run" >&2
123
+ echo "mosquitto_passwd as the owner of that file - as root it rewrites the file owned by root," >&2
124
+ echo "and the broker, which reads it as the mosquitto user, then will not start:" >&2
125
+ echo " sudo -u \"\$(stat -c '%U' ${PWFILE})\" mosquitto_passwd -b ${PWFILE} ${ACCOUNT} '<new password>'" >&2
126
+ echo "and then update /etc/mosquitto/conf.d/frugal-iot-bridge.conf on that Pi to match." >&2
127
+ exit 1
128
+ fi
129
+
130
+ if [[ -z "$PASSWORD" ]]; then
131
+ # Unlike the organization's broker password, this one is never given to a browser - it lives only
132
+ # in a root-owned file on the Pi - so it can and should be a real one.
133
+ # The subshell turns pipefail off: head closes the pipe after 24 characters, tr is killed by
134
+ # SIGPIPE, and with pipefail on that failure would end this script before it printed anything.
135
+ # install-pi.sh's randpw does the same for the same reason.
136
+ PASSWORD=$( set +o pipefail; LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24 )
137
+ GENERATED=true
138
+ else
139
+ GENERATED=false
140
+ fi
141
+
142
+ # Run mosquitto_passwd as whoever owns the password file, the same way addorganization.zsh does.
143
+ # Running it as root instead rewrites the file owned by root, and mosquitto drops to the mosquitto
144
+ # user *before* reading it - so the broker then cannot read its own password file and refuses to
145
+ # start, with an error that says nothing about ownership. mosquitto_passwd also writes a temporary
146
+ # backup beside the file, so it needs the directory and not only the file.
147
+ MOSQ_PASSWD_CMD=(mosquitto_passwd)
148
+ if [[ ! -w "${PWFILE:h}" || ( -e "$PWFILE" && ! -w "$PWFILE" ) ]]; then
149
+ if command -v sudo >/dev/null; then
150
+ # stat's spelling differs between Linux (-c) and BSD/macOS (-f)
151
+ PWFILE_OWNER=$(stat -c '%U' "$PWFILE" 2>/dev/null || stat -f '%Su' "$PWFILE" 2>/dev/null || true)
152
+ if [[ -n "$PWFILE_OWNER" && "$PWFILE_OWNER" != "$(id -un)" && "$PWFILE_OWNER" != "root" ]]; then
153
+ MOSQ_PASSWD_CMD=(sudo -u "$PWFILE_OWNER" mosquitto_passwd)
154
+ else
155
+ MOSQ_PASSWD_CMD=(sudo mosquitto_passwd)
156
+ fi
157
+ fi
158
+ fi
159
+
160
+ echo "Creating broker account ${ACCOUNT} in ${PWFILE} ..."
161
+ if ! $MOSQ_PASSWD_CMD -b "$PWFILE" "$ACCOUNT" "$PASSWORD"; then
162
+ echo "Error: mosquitto_passwd failed - nothing has been changed." >&2
163
+ exit 1
164
+ fi
165
+
166
+ # The access control rule. Without one the account can connect and reach nothing at all, because a
167
+ # mosquitto acl_file is deny-by-default.
168
+ if [[ -n "$ACLFILE" ]]; then
169
+ if [[ ! -e "$ACLFILE" ]]; then
170
+ echo "Error: mosquitto names ${ACLFILE} as its ACL file but it does not exist." >&2
171
+ echo "The account has been created but cannot reach anything until that is sorted out." >&2
172
+ exit 1
173
+ fi
174
+ if sudo grep -qE "^[[:space:]]*user[[:space:]]+${ACCOUNT}[[:space:]]*$" "$ACLFILE"; then
175
+ echo "ACL rule for ${ACCOUNT} is already in ${ACLFILE} - leaving it alone."
176
+ else
177
+ echo "Adding ACL rule to ${ACLFILE} ..."
178
+ sudo tee -a "$ACLFILE" >/dev/null <<EOF
179
+
180
+ # Pi bridge for site "${SITE}", confined to its organization's topics
181
+ user ${ACCOUNT}
182
+ topic readwrite ${ORG_ID}/#
183
+ EOF
184
+ fi
185
+ else
186
+ echo ""
187
+ echo "NOTE: this broker has no acl_file, so ${ACCOUNT} can reach every topic on it, not just"
188
+ echo " ${ORG_ID}/#. That is how the broker already treats every other account, so this is no"
189
+ echo " worse than what is there - but see INSTALLATION.md step 11 for restricting it."
190
+ fi
191
+
192
+ # password_file and acl_file are both re-read on a reload, so nothing needs to be disconnected.
193
+ echo "Reloading mosquitto ..."
194
+ sudo systemctl reload mosquitto || {
195
+ echo "Warning: reload failed - the account exists but the broker has not re-read the files." >&2
196
+ echo " Try: sudo systemctl restart mosquitto" >&2
197
+ }
198
+
199
+ echo ""
200
+ echo "Done. On the Pi at site '${SITE}', run:"
201
+ echo ""
202
+ echo " npx --no frugal-iot-addbridge-pi --org ${ORG_ID} --host $(hostname -f 2>/dev/null || hostname) --account ${ACCOUNT}"
203
+ echo ""
204
+ echo "It will ask for this password:"
205
+ echo ""
206
+ echo " ${PASSWORD}"
207
+ echo ""
208
+ if [[ "$GENERATED" == true ]]; then
209
+ echo "That was generated just now and is not stored anywhere you can read it back - copy it before"
210
+ echo "you lose this output. If it does get lost, set a new one with:"
211
+ echo " sudo -u \"\$(stat -c '%U' ${PWFILE})\" mosquitto_passwd -b ${PWFILE} ${ACCOUNT} '<new password>'"
212
+ echo "and use that on the Pi instead."
213
+ fi
214
+ echo "Check the host name above is one the Pi can reach, and that it is the name on the"
215
+ echo "certificate - the Pi verifies it."
Binary file
@@ -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);