frugal-iot-server 2.0.9 → 2.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  ## Installation
4
4
 
5
5
  For a self-contained server on a Raspberry Pi - including the MQTT broker, and starting from a
6
- blank SD card - see [INSTALLATION.md](INSTALLATION.md), which also has a script that does the whole
6
+ blank SD card - see [docs/INSTALLATION.md](docs/INSTALLATION.md), which also has a script that does the whole
7
7
  thing in one go once you can ssh in.
8
8
 
9
9
  On a unix box that is already running ...
@@ -91,7 +91,7 @@ verbose: true
91
91
  and each message reappears as `Received dev/lotus/esp8266-85ea2b/humidity 71.8`. `morgan: true`
92
92
  in `config.d/server.yaml` does the same for web requests. Turn both off again afterwards.
93
93
  There is more on what gets written, and how to see how much, under
94
- [Wear and tear on the SD card](INSTALLATION.md#wear-and-tear-on-the-sd-card).
94
+ [Wear and tear on the SD card](docs/INSTALLATION.md#wear-and-tear-on-the-sd-card).
95
95
 
96
96
  Open a browser pointing at for example `localhost:8080` and you should see the UI.
97
97
 
@@ -161,7 +161,7 @@ expensive on an SD card.
161
161
  To set it up as a service that runs at startup (and instructions vary between flavors of Linux)
162
162
 
163
163
  copy and edit `extras/frugaliot.service` to `/etc/systemd/system/frugaliot.service`. As shipped it
164
- matches the Raspberry Pi install in [INSTALLATION.md](INSTALLATION.md) - user `pi`, installed in
164
+ matches the Raspberry Pi install in [docs/INSTALLATION.md](docs/INSTALLATION.md) - user `pi`, installed in
165
165
  `/home/pi/frugal-iot` - so on any other machine change `User`, `WorkingDirectory` and `ExecStart`
166
166
  to the account and directory you installed into.
167
167
  ```
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # Leave it commented out on a server that should not send mail.
5
5
  #
6
- # For Gmail - the common case, written out step by step in INSTALLATION.md section 9a:
6
+ # For Gmail - the common case, written out step by step in docs/INSTALLATION.md section 9a:
7
7
  # 1. Turn on 2-Step Verification: https://myaccount.google.com/signinoptions/twosv
8
8
  # App passwords do not exist until you do, which is where most people get stuck.
9
9
  # 2. Create an app password: https://myaccount.google.com/apppasswords
@@ -1,4 +1,4 @@
1
- # This server's own OpenSearch - the one INSTALL-OPENSEARCH.md sets up on the server itself.
1
+ # This server's own OpenSearch - the one docs/INSTALL-OPENSEARCH.md sets up on the server itself.
2
2
  #
3
3
  # Leave it all commented out on a server without one. With it filled in, an organization's
4
4
  # administrator can choose "OpenSearch on this server" on the dashboard's Forwarders card, and make a
@@ -23,5 +23,5 @@
23
23
  # password: change-me
24
24
  #
25
25
  # # The address Dashboards on someone's own computer uses to reach OpenSearch: https, through
26
- # # Apache (INSTALL-OPENSEARCH.md), never the port 9200 above.
26
+ # # Apache (docs/INSTALL-OPENSEARCH.md), never the port 9200 above.
27
27
  # public: https://search.example.org
@@ -383,6 +383,13 @@ heatsink:
383
383
  name: Heatsink Temperature
384
384
  min: 0
385
385
  max: 120
386
+ # Seeed HM3301 particulate sensor ("Grove - Laser PM2.5 Sensor") - Sensor_HM3301.
387
+ hm3301:
388
+ name: "HM3301"
389
+ topics:
390
+ - leaf: pm1
391
+ - leaf: pm25
392
+ - leaf: pm10
386
393
  ht:
387
394
  name: HT
388
395
  topics:
@@ -617,6 +624,12 @@ sector:
617
624
  - leaf: valve
618
625
  leaf_from: controlouttoggle
619
626
  name: Valve
627
+ # Nova Fitness SDS011 particulate sensor, as in sensor.community's airRohr - Sensor_SDS011.
628
+ sds011:
629
+ name: "SDS011"
630
+ topics:
631
+ - leaf: pm25
632
+ - leaf: pm10
620
633
  sht:
621
634
  name: SHT
622
635
  topics:
@@ -549,6 +549,56 @@ percent:
549
549
  significantvalue: 1
550
550
  graphable: true
551
551
  units: "%"
552
+ # Particulate matter, from the SDS011 or HM3301 - see sensor/pm.h in Frugal-IoT. Both are noisy,
553
+ # and unreliable above ~70% humidity (they count droplets as particles). Only the HM3301 has pm1.
554
+ pm1:
555
+ leaf: pm1
556
+ name: PM1.0
557
+ type: float
558
+ display: bar
559
+ min: 0
560
+ max: 100
561
+ width: 5
562
+ color: "#d2691e"
563
+ units: ug/m3
564
+ rw: r
565
+ graphable: true
566
+ log: true
567
+ duplicates:
568
+ significantdate: 900000
569
+ significantvalue: 1
570
+ pm10:
571
+ leaf: pm10
572
+ name: PM10
573
+ type: float
574
+ display: bar
575
+ min: 0
576
+ max: 200
577
+ width: 5
578
+ color: "#696969"
579
+ units: ug/m3
580
+ rw: r
581
+ graphable: true
582
+ log: true
583
+ duplicates:
584
+ significantdate: 900000
585
+ significantvalue: 1
586
+ pm25:
587
+ leaf: pm25
588
+ name: PM2.5
589
+ type: float
590
+ display: bar
591
+ min: 0
592
+ max: 100
593
+ width: 5
594
+ color: "#a0522d"
595
+ units: ug/m3
596
+ rw: r
597
+ graphable: true
598
+ log: true
599
+ duplicates:
600
+ significantdate: 900000
601
+ significantvalue: 1
552
602
  position:
553
603
  leaf: position
554
604
  name: Position
@@ -0,0 +1,208 @@
1
+ # Running a Frugal IoT server on an OpenWrt router
2
+
3
+ **Status: worked once, on one router, after fixes.** The Raspberry Pi installation
4
+ ([INSTALLATION.md](INSTALLATION.md)) is proven; this is the same thing re-done for OpenWrt. The first
5
+ real run - an OpenWrt One, OpenWrt 25.12.5, see [Tested on](#tested-on) - needed five attempts and
6
+ found ten problems, which are fixed or described below. Expect the next router to find more. This
7
+ document says what a router needs, how to prepare it, gives two scripts, and says what to send back.
8
+
9
+ ## What you are installing
10
+
11
+ A Frugal IoT server is three programs on one machine:
12
+
13
+ * **mosquitto**, an MQTT broker. Small sensor boards (ESP32s) publish readings to it, and the
14
+ dashboard in your browser listens to it.
15
+ * **the Frugal IoT server**, a Node.js program. It serves the dashboard on port 8080, keeps the
16
+ user logins and settings in an SQLite database, and writes every reading to disk.
17
+ * nothing else. No cloud, no internet needed once it is installed.
18
+
19
+ The router is simply where they run. It does not stop being a router.
20
+
21
+ ## What the router needs
22
+
23
+ Run the preflight script (below) and it checks every line of this table for you.
24
+
25
+ | | Needed | Why |
26
+ | --- | --- | --- |
27
+ | **CPU** | **aarch64 or x86_64.** 32-bit ARM might work. MIPS will not. | OpenWrt's `node` package is only built for some CPUs, and the database module (`sqlite3`) is a compiled binary that has to exist for that CPU and for OpenWrt's C library (musl). Both are most likely on 64-bit. |
28
+ | **Memory** | 512 MB or more (256 MB at the very least) | The server uses about 250 MB once running. |
29
+ | **Storage** | **About 400 MB free** on ext4, f2fs or btrfs | The software is ~100 MB; npm needs room to work; readings accumulate. **Most routers' own flash is far too small**, so this means a USB stick or SSD, set up as extroot or just mounted, and `--dir` pointing at it. Not FAT, exFAT or NTFS: the install needs symlinks and Unix permissions. The image may also lack the USB storage driver - see [Preparing the router](#preparing-the-router). |
30
+ | **Node.js** | **20.17 or later**, and npm | From OpenWrt's feed if it has one - **OpenWrt 25.12 has none for aarch64**, in which case it is unpacked by hand from a musl build ([how](#if-the-feed-has-no-node)). The preflight and the installer accept either. |
31
+ | **mosquitto** | A build with **websockets** | The browser dashboard connects over websockets. If the router's mosquitto has none, the dashboard cannot work. |
32
+ | | Ideally the **dynamic security plugin** | It gives every node and every user its own broker password. Without it the server falls back to one shared password per organization: it works, but it is the older and weaker scheme. |
33
+ | **Network** | Internet access while installing | opkg and npm download from their own servers. |
34
+ | **Free ports** | 1883 (nodes), 9012 (dashboard websocket), 8080 (dashboard) | Nothing on OpenWrt uses these by default; LuCI is on 80/443. |
35
+
36
+ Better to have, not required: a fixed LAN address (a router has one already), and a drive that
37
+ tolerates constant small writes - the server logs readings all day, which is hard on a cheap USB stick.
38
+
39
+ ## Preparing the router
40
+
41
+ Two things a router often lacks, both found on the first real run. Commands are shown for `apk`
42
+ (OpenWrt 25.12 and later); on older releases use `opkg install` instead of `apk add`.
43
+
44
+ ### A drive the router can see
45
+
46
+ The preflight tells you when none is visible. A USB drive formatted ext4 was invisible on the test
47
+ router because the image carried no USB storage driver:
48
+
49
+ ```sh
50
+ apk add kmod-usb-storage kmod-fs-ext4 block-mount
51
+ block info # is the drive listed now? what is it called?
52
+ block detect > /etc/config/fstab # NOTE: replaces your mount configuration - if you already have
53
+ block mount # mounts there, add the new one by hand (LuCI: System > Mount Points)
54
+ df -h /mnt/sda1
55
+ ```
56
+
57
+ Check that the drive is mounted again after a reboot (`uci show fstab`, or LuCI's Mount Points), or
58
+ the server will not find its files and will not start.
59
+
60
+ ### If the feed has no node
61
+
62
+ The preflight's feed check says `no node package in this feed` when this applies. Node.js publishes
63
+ musl builds, which is what OpenWrt uses, at <https://unofficial-builds.nodejs.org/download/release/>.
64
+ Pick a version of 20.17 or later (24.9.0 worked) and the file for your CPU: `linux-arm64-musl` for
65
+ aarch64, `linux-x64-musl` for x86_64. **Take the `.tar.gz`, not the `.tar.xz`** - BusyBox `tar` cannot
66
+ unpack xz. Put it on the drive, not the router's flash:
67
+
68
+ ```sh
69
+ cd /mnt/sda1
70
+ wget https://unofficial-builds.nodejs.org/download/release/v24.9.0/node-v24.9.0-linux-arm64-musl.tar.gz
71
+ tar xzf node-v24.9.0-linux-arm64-musl.tar.gz
72
+ for t in node npm npx; do ln -sf /mnt/sda1/node-v24.9.0-linux-arm64-musl/bin/$t /usr/bin/$t; done
73
+ apk add libstdcpp6 libatomic1 # the musl build needs both
74
+ node -v; npm -v
75
+ ```
76
+
77
+ If `node -v` says a library is missing, that is the `apk add` line. The preflight and installer then
78
+ see node on the router and do not look for a package.
79
+
80
+ ## Doing it
81
+
82
+ You need `ssh` access to the router as root. On the router, fetch the two scripts from the `main` branch:
83
+
84
+ ```sh
85
+ B=https://raw.githubusercontent.com/mitra42/frugal-iot-server/main/scripts
86
+ wget -O openwrt-preflight.sh $B/openwrt-preflight.sh
87
+ wget -O install-openwrt.sh $B/install-openwrt.sh
88
+ ```
89
+
90
+ If `wget` complains about https, the router has no TLS support for it yet
91
+ (`apk add ca-bundle libustream-mbedtls`, or `opkg update && opkg install ...` on older releases), or download the files on your computer
92
+ and copy them across with `scp`.
93
+
94
+ ### 1. Preflight - on each candidate router
95
+
96
+ ```sh
97
+ sh openwrt-preflight.sh --dir /mnt/sda1/frugal-iot --install 2>&1 | tee preflight.txt
98
+ ```
99
+
100
+ * `--dir` is where you intend to put the server: a path on the drive. The default is `/opt/frugal-iot`.
101
+ * Without `--install` it only reads things, and refreshes the package list. **With `--install` it
102
+ installs node, mosquitto and the other packages**, after asking, so that it can try mosquitto and
103
+ sqlite3 for real rather than guess. That is the same set the installer would put there anyway, but it
104
+ does use the router's own flash: it prints how much is free first. If the flash is nearly full, run
105
+ it without `--install` first.
106
+ * It ends with `PASS`/`WARN`/`FAIL` counts and a verdict. **Please send the whole of `preflight.txt`
107
+ for every router you try**, including the ones that fail - which ones fail, and why, is the most
108
+ useful thing this stage can tell us.
109
+
110
+ Which router to choose is then mostly: one with no `FAIL`, a USB port with a decent drive in it, and
111
+ 512 MB or more.
112
+
113
+ ### 2. Install - on the one you pick
114
+
115
+ ```sh
116
+ sh install-openwrt.sh --org myfarm --name "My Farm" --email you@example.com \
117
+ --phone +61123456789 --dir /mnt/sda1/frugal-iot
118
+ ```
119
+
120
+ * `--org` is 1-10 lower-case letters or digits. It becomes the first part of every MQTT topic, and
121
+ the sensor boards must be flashed with the same one. Pick something you will keep.
122
+ * Passwords you do not give are generated and printed at the end. The whole run is also logged to
123
+ `/root/frugal-iot-install-<date>.log` (readable only by root; the generated passwords are in it).
124
+ * `--dry-run` checks the arguments and the router and says what it would do, changing nothing.
125
+ * It is safe to run again after a failure: steps already done are skipped. Give it the **same**
126
+ `--org` and `--dir` as before - a different `--org` creates a second organization. Passwords already
127
+ set are left alone, and the summary says "unchanged" rather than printing a made-up one.
128
+ * It runs the server as root, as most OpenWrt services do. `--user frugal` creates and uses a
129
+ separate account instead, which is tidier but is another thing that has not been tried.
130
+
131
+ When it finishes it prints the dashboard address and the settings the sensor boards need.
132
+
133
+ ### 3. If something goes wrong
134
+
135
+ Send back:
136
+
137
+ 1. `preflight.txt`
138
+ 2. the install log (`/root/frugal-iot-install-*.log`) - or just the end of the terminal output
139
+ 3. `logread -e frugaliot | tail -50` and `logread -e mosquitto | tail -50`
140
+ 4. the output of `apk list -I | grep -E 'mosquitto|node|sqlite|zsh'` (`opkg list-installed | grep ...`
141
+ on older releases)
142
+
143
+ ## What is different from a Raspberry Pi, and why it might fail
144
+
145
+ The installer is the Pi one, redone, so these are the places it can be wrong:
146
+
147
+ * **No systemd.** The service is a procd init script, `/etc/init.d/frugaliot`. Control it with
148
+ `service frugaliot restart`. Logs go to the system log: `logread -e frugaliot`.
149
+ * **`/var` is RAM.** On OpenWrt `/var` is `/tmp`, emptied at every reboot. The broker's password file
150
+ and account database would normally live in `/var/lib/mosquitto`, which would lose every account on
151
+ restart, so the installer keeps them in `/etc/mosquitto/state` and points the configuration there.
152
+ It also moves mosquitto's own persistence (the retained values) there if it was set to a
153
+ RAM path; the original configuration is kept as `/etc/mosquitto/mosquitto.conf.pre-frugal`.
154
+ * **No bash, no zsh, no sudo, no apt.** The installer is plain `sh` and installs zsh itself (the
155
+ server's own commands are zsh scripts). BusyBox lacks `install(1)` (it is in `coreutils-install`),
156
+ which stopped the first run, so the installer places files with `cp`, `chown`, `chmod` and `mv`
157
+ instead; and its `tar` cannot unpack `.xz`. `stat`, `sed`, `grep` and `su` worked.
158
+ * **The sqlite3 module.** The most likely single failure. If a binary does not exist for your CPU
159
+ and musl, `npm install` will try to compile it, and there is no compiler. The installer stops there
160
+ with the error. A fix exists (compile it on another machine, or use a build environment) but it
161
+ is a different project.
162
+ * **The mosquitto package.** Which parts OpenWrt builds in - websockets, the dynamic security plugin,
163
+ `mosquitto_ctrl`, `mosquitto_passwd` - is the main thing nobody here knows. The preflight tries each
164
+ one, and the installer lists every mosquitto package the feed offers into the log.
165
+ * **No WiFi fallback network.** On the Pi, a setup network is raised if its WiFi is lost. A router is
166
+ the access point, so this is skipped.
167
+ * **Time.** Routers without a battery clock start at an old date until NTP runs. The server stamps
168
+ readings when they arrive, so check the router has the right time.
169
+
170
+ ## After it is running
171
+
172
+ * **Do not open the ports to the internet.** OpenWrt accepts LAN connections and refuses WAN ones by
173
+ default; leave 1883, 9012 and 8080 that way. HTTPS (needed for over-the-air firmware updates to the
174
+ boards) is not covered here.
175
+ * **Upgrading:**
176
+ ```sh
177
+ cd /mnt/sda1/frugal-iot && npm update frugal-iot-server && service frugaliot restart
178
+ ```
179
+ (and re-run `install-openwrt.sh` with the same arguments afterwards, which brings the broker
180
+ configuration up to date.)
181
+ * **If `http://<router>:8080/` answers 404** (the dashboard at `/dashboard` may still work), the
182
+ server cannot find its own web pages. An install whose `frugal-iot-init` failed part-way and was
183
+ re-run - as on the first OpenWrt run, before `init` was fixed - keeps a `config.d/server.yaml`
184
+ with `publicdir: ./public`. Set these in `<install dir>/config.d/server.yaml`, then
185
+ `service frugaliot restart`:
186
+ ```yaml
187
+ htmldir: ./node_modules/frugal-iot-client
188
+ nodemodulesdir: ./node_modules
189
+ publicdir: ./node_modules/frugal-iot-server/public
190
+ privatedir: ./node_modules/frugal-iot-server/private
191
+ ```
192
+ Without `publicdir` the home page, `/about.html` and the PWA's `service-worker.js` are missing too.
193
+ * **`npx --no frugal-iot-diagnostic`** was written for systemd and will report some things that do
194
+ not apply here. Treat it as a hint, not a verdict.
195
+ * **Removing it:**
196
+ ```sh
197
+ service frugaliot stop; /etc/init.d/frugaliot disable; rm /etc/init.d/frugaliot
198
+ rm -rf /mnt/sda1/frugal-iot # your install directory: this deletes the readings too
199
+ rm /etc/mosquitto/conf.d/frugal-iot.conf /etc/mosquitto/aclfile; rm -rf /etc/mosquitto/state
200
+ cp /etc/mosquitto/mosquitto.conf.pre-frugal /etc/mosquitto/mosquitto.conf # if it exists
201
+ /etc/init.d/mosquitto restart
202
+ ```
203
+
204
+ ## Tested on
205
+
206
+ | Date | Router | OpenWrt | Node | Server | Result |
207
+ | --- | --- | --- | --- | --- | --- |
208
+ | 2026-11 | OpenWrt One (Banana Pi), MediaTek Filogic, aarch64_cortex-a53, 986 MB; USB drive, ext4, mounted at `/mnt/sda1` | 25.12.5 with LibreMesh, `apk`, kernel 6.12.94 | v24.9.0 and npm 11.6.0, unpacked by hand (no node in the aarch64 feed) | 2.0.8 | Installed and running: broker (websockets and dynamic security both present), organization, procd service, dashboard. Took five runs: no USB storage driver; no node package; no `install` command; `frugal-iot-init` failing on `extras/grafana`, which also left `server.yaml` unconfigured and made `/` answer 404. All now handled by the scripts or described above, except the `init` fix, which needs a server release to reach a router. |
@@ -1,6 +1,6 @@
1
1
  # Systemd unit for the Frugal IoT server.
2
2
  #
3
- # As shipped this matches the Raspberry Pi installation in INSTALLATION.md: user "pi", installed
3
+ # As shipped this matches the Raspberry Pi installation in docs/INSTALLATION.md: user "pi", installed
4
4
  # with npm into /home/pi/frugal-iot. If that is what you have, install it unedited:
5
5
  # sudo cp extras/frugaliot.service /etc/systemd/system/frugaliot.service
6
6
  # sudo systemctl daemon-reload
@@ -6,7 +6,7 @@ protocol websockets
6
6
  # Kept under /var/lib/mosquitto, which belongs to the mosquitto user, rather than /etc/mosquitto,
7
7
  # which belongs to root. Both the broker and mosquitto_passwd warn unless the file belongs to
8
8
  # whoever opened it, and mosquitto_passwd also writes a temporary backup beside it, so the
9
- # directory has to be writable by that same user. See INSTALLATION.md step A5.
9
+ # directory has to be writable by that same user. See docs/INSTALLATION.md step A5.
10
10
  password_file /var/lib/mosquitto/passwords
11
11
 
12
12
  # Which topics each account may reach. Without this, every account that can log in can publish and
@@ -108,7 +108,7 @@ CREATE TABLE IF NOT EXISTS `forwarders` (
108
108
  CREATE INDEX IF NOT EXISTS `forwarders_org` ON `forwarders` (`org`);
109
109
 
110
110
  -- A credential for Grafana (or anything else that reads history over HTTP), see lib/grafana.js and
111
- -- GRAFANA.md. Read-only, and scoped to one organization: it can fetch that organization's readings
111
+ -- docs/GRAFANA.md. Read-only, and scoped to one organization: it can fetch that organization's readings
112
112
  -- through /grafana/:org/ and nothing else.
113
113
  --
114
114
  -- Only a SHA-256 of the token is kept, so a copy of this database does not hand out working
@@ -269,3 +269,12 @@ CREATE TABLE IF NOT EXISTS `node_attempts` (
269
269
  `count` INTEGER NOT NULL DEFAULT 1,
270
270
  UNIQUE(`org`, `nodeid`)
271
271
  );
272
+
273
+ -- Which languages browsers ask for, counted at each sign-in, to choose what to translate next - see
274
+ -- lib/language-requests.js. Codes only: nothing records who asked.
275
+ CREATE TABLE IF NOT EXISTS `language_requests` (
276
+ `code` TEXT PRIMARY KEY, -- lower-case, as the browser sent it: hi-in, pt-br
277
+ `count` INTEGER NOT NULL,
278
+ `last_seen` INTEGER NOT NULL,
279
+ `supported` INTEGER NOT NULL -- 1 if the client had a file for it at the last count
280
+ );
@@ -140,6 +140,7 @@ import crypto from 'crypto'; /* https://nodejs.org/api/crypto.html */
140
140
  import {waterfall, each} from 'async';
141
141
  import { mailInit, mailConfigured, sendMail } from './lib/mailer.js';
142
142
  import { resetCodeMake, resetCodeCheck, resetRateOk } from './lib/resetcode.js';
143
+ import { countLanguages, parseBrowserLanguages } from './lib/language-requests.js';
143
144
  // import { openDB } from 'sqlite-express-package'; /* appContent, appSelect, validateId, validateAlias, tagCloud, atom, rss,*/
144
145
 
145
146
  export let config; // Live binding - lib/api-routes.js reads the current value at request time
@@ -155,8 +156,16 @@ function loginRedirect(res, { mode = 'signin', message, messagetype, url }) {
155
156
  if (message) { q.set('message', message); }
156
157
  if (messagetype) { q.set('messagetype', messagetype); }
157
158
  if (url) { q.set('url', url); } // Encoded here: a return url usually has a query string of its own
159
+ const lang = langOf(url);
160
+ if (lang) { q.set('lang', lang); }
158
161
  res.redirect(`${loginUrl}?${q}`);
159
162
  }
163
+ // The language of the page being returned to. login.html reads only its own ?lang=, and without it
164
+ // falls back to the browser's language rather than the one picked before signing in.
165
+ function langOf(url) {
166
+ if (!url) return null;
167
+ try { return new URL(url, 'http://localhost').searchParams.get('lang'); } catch (e) { return null; }
168
+ }
160
169
  // A password reset link carries its token in the query string, and everything that logs a URL goes
161
170
  // to the journal, which outlives the ten minutes the token is good for.
162
171
  function redactUrl(url) {
@@ -1241,7 +1250,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1241
1250
  app.use('/api', apiRouter);
1242
1251
  console.log("Mounted API routes at /api");
1243
1252
 
1244
- // History for Grafana, by bearer token - see lib/grafana.js and GRAFANA.md
1253
+ // History for Grafana, by bearer token - see lib/grafana.js and docs/GRAFANA.md
1245
1254
  const grafana = createGrafanaRouters(db, config.server.datadir, loggerClient, { loggedInOrFail, can_READ });
1246
1255
  app.use('/grafana', grafana.data);
1247
1256
  app.use('/grafana_token', grafana.admin);
@@ -1273,21 +1282,33 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1273
1282
  (req,res,next) => {
1274
1283
  console.log("Trying to login with redirect to",req.body.url);
1275
1284
  // This is ugly, but I cannot see how to pass the URL to passport.authenticate options
1276
- // Default the destination rather than trusting every caller to supply one: with no
1277
- // successRedirect, passport calls next() on success and the request 404s as
1278
- // "Cannot POST /login" - a confusing way to report a missing form field.
1285
+ // Default the destination rather than trusting every caller to supply one - a missing
1286
+ // form field should still land somewhere, here and in the success handler below.
1279
1287
  const returnTo = req.body.url || '/dashboard';
1280
1288
  const failureQuery = new URLSearchParams({
1281
1289
  mode: 'signin', messagetype: 'error',
1282
1290
  message: 'Incorrect username or password', url: returnTo });
1291
+ const lang = langOf(returnTo);
1292
+ if (lang) { failureQuery.set('lang', lang); }
1283
1293
  passport.authenticate('local', {
1284
1294
  session: true,
1285
1295
  //failWithError: true,
1286
1296
  failureRedirect: `${loginUrl}?${failureQuery}`,
1287
- successRedirect: returnTo,
1297
+ // No successRedirect: on success passport calls next(), which counts and then redirects
1288
1298
  // In failure case will also be messages in the session which need clearing out TODO-N89
1289
1299
  //failureRedirect: `${loginUrl}?register=false&message=Incorrect+username+or+password&url=${req.body.url}`,
1290
1300
  })(req, res, next);
1301
+ },
1302
+ // Counted only once signed in, so failed attempts cannot pad the numbers. Not waited for.
1303
+ (req, res) => {
1304
+ const codes = parseBrowserLanguages(req.body.browserlangs);
1305
+ if (codes.length) {
1306
+ console.log("Browser languages", codes.join(','));
1307
+ countLanguages(db, config.server.htmldir, codes, (err) => {
1308
+ if (err) console.error("Could not count browser languages:", err.message);
1309
+ });
1310
+ }
1311
+ res.redirect(req.body.url || '/dashboard');
1291
1312
  }
1292
1313
  );
1293
1314
  app.post('/register', (req, res) => {
package/lib/grafana.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * GET /grafana_token/:org list names and when last used (yours; all, for an org-wide administrator)
22
22
  * POST /grafana_token/:org/revoke {name} yours; any, for an org-wide administrator
23
23
  *
24
- * See GRAFANA.md for setting up Grafana to use these.
24
+ * See docs/GRAFANA.md for setting up Grafana to use these.
25
25
  */
26
26
 
27
27
  import crypto from 'crypto';
@@ -398,7 +398,7 @@ export function createGrafanaRouters(db, dataDir, loggerClient, auth) {
398
398
  res.json(rows.map(({ user_id, ...t }) => ({ ...t, mine: user_id === Number(req.user.id) })));
399
399
  } catch (err) { next(err); }
400
400
  });
401
- // The starter dashboard, to save and import into Grafana (GRAFANA.md). Nothing in it is secret.
401
+ // The starter dashboard, to save and import into Grafana (docs/GRAFANA.md). Nothing in it is secret.
402
402
  admin.get('/:org/dashboard', auth.loggedInOrFail, auth.can_READ, async (req, res, next) => {
403
403
  try {
404
404
  const dash = await dashboardFor(req.params.org, String(req.query.server || ''));
@@ -0,0 +1,50 @@
1
+ /*
2
+ * Which languages people's browsers ask for, counted at each sign-in, so the next language to
3
+ * translate is the one most asked for (frugal-iot-client docs/LANGUAGES_PLAN.md, Phase 3). Codes
4
+ * only - nothing records who asked.
5
+ *
6
+ * To read it: SELECT * FROM language_requests ORDER BY supported, count DESC;
7
+ */
8
+ import { existsSync } from 'fs';
9
+ import { join } from 'path';
10
+
11
+ // A browser sends a handful; anything longer is not a browser
12
+ const MAX_CODES = 10;
13
+ const CODE = /^[a-z]{2,3}(-[a-z0-9]{1,8}){0,3}$/;
14
+ // As languageAliases in the client's core.js: browsers call Tagalog "fil", and its file is tl.yaml
15
+ const ALIASES = { fil: 'tl' };
16
+
17
+ // The form field is untrusted: keep only what looks like a language tag, lower-cased, once each
18
+ export function parseBrowserLanguages(value) {
19
+ if (typeof value !== 'string') return [];
20
+ const codes = value.split(',').map((c) => c.trim().toLowerCase()).filter((c) => CODE.test(c));
21
+ return [...new Set(codes)].slice(0, MAX_CODES);
22
+ }
23
+
24
+ // The client has a file per language it supports, so whether one exists is the answer, and the
25
+ // server keeps no list of its own. fr-CA is served by fr, as the client matches it; English is the
26
+ // keys themselves, so it has no file.
27
+ export function isSupported(htmldir, code) {
28
+ const primary = ALIASES[code.split('-')[0]] || code.split('-')[0];
29
+ if (primary === 'en') return true;
30
+ return [code, primary].some((c) => existsSync(join(htmldir, 'languages', `${c}.yaml`)));
31
+ }
32
+
33
+ // One upsert per code. cb(err) - a failure to count must never stand in the way of a login
34
+ export function countLanguages(db, htmldir, codes, cb = () => {}) {
35
+ const now = Date.now();
36
+ let pending = codes.length;
37
+ let failed = null;
38
+ if (!pending) { cb(null); return; }
39
+ codes.forEach((code) => {
40
+ db.run(
41
+ `INSERT INTO language_requests (code, count, last_seen, supported) VALUES (?, 1, ?, ?)
42
+ ON CONFLICT(code) DO UPDATE SET count = count + 1, last_seen = excluded.last_seen,
43
+ supported = excluded.supported`,
44
+ [code, now, isSupported(htmldir, code) ? 1 : 0],
45
+ (err) => {
46
+ failed = failed || err;
47
+ if (--pending === 0) cb(failed);
48
+ });
49
+ });
50
+ }
package/package.json CHANGED
@@ -12,8 +12,8 @@
12
12
  "cookie-parser": "^1.4.7",
13
13
  "express": "^5.2.1",
14
14
  "express-session": "^1.19.0",
15
- "frugal-iot-client": "^2.0.9",
16
- "frugal-iot-logger": "^2.0.9",
15
+ "frugal-iot-client": "^2.0.10",
16
+ "frugal-iot-logger": "^2.0.10",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
@@ -64,9 +64,10 @@
64
64
  "extras/frugaliot-ap-watchdog.service",
65
65
  "extras/frugaliot-ap-watchdog.timer",
66
66
  "API.md",
67
- "GRAFANA.md",
68
- "INSTALL-OPENSEARCH.md",
69
- "INSTALLATION.md"
67
+ "docs/GRAFANA.md",
68
+ "docs/INSTALL-OPENSEARCH.md",
69
+ "docs/INSTALLATION.md",
70
+ "docs/OPENWRT.md"
70
71
  ],
71
72
  "devDependencies": {
72
73
  "supertest": "^7.2.2",
@@ -93,7 +94,7 @@
93
94
  "check-files": "node scripts/check-published-files.js"
94
95
  },
95
96
  "type": "module",
96
- "version": "2.0.9",
97
+ "version": "2.0.10",
97
98
  "overrides": {
98
99
  "uuid": "^11.1.1"
99
100
  }
@@ -6,7 +6,7 @@
6
6
  // it, an installed browser keeps serving the files it already cached - and since the list now covers
7
7
  // the dashboard's own code, that means old JS behind freshly fetched HTML. Any edit to the list
8
8
  // needs this bumped, or it does not reach anyone who already has the old one.
9
- const CACHE_NAME = 'frugal-iot-cache-2.0.9';
9
+ const CACHE_NAME = 'frugal-iot-cache-2.0.10';
10
10
  /*
11
11
  * Everything the app needs, so that a phone on a poor or expensive link fetches it once.
12
12
  *
@@ -100,6 +100,14 @@ self.addEventListener('fetch', event => {
100
100
  // Scoped to CACHE_NAME: a bare caches.match searches every cache in the origin, so a
101
101
  // leftover older cache answers first and the version bump achieves nothing
102
102
  caches.match(event.request, {cacheName: CACHE_NAME})
103
- .then(response => response || fetch(event.request))
103
+ .then(response => response || fetch(event.request).then(fetched => {
104
+ // A language is cached when first used, not at install: there are many, and a
105
+ // phone wants one
106
+ if (fetched.ok && new URL(event.request.url).pathname.startsWith('/dashboard/languages/')) {
107
+ const copy = fetched.clone();
108
+ caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
109
+ }
110
+ return fetched;
111
+ }))
104
112
  );
105
113
  });
@@ -5,7 +5,7 @@
5
5
  # After this, the Pi's readings also appear on the production server, and its nodes can be
6
6
  # controlled from there - while the Pi carries on recording and serving on its own whenever the
7
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.
8
+ # a backlog delivered later with the wrong timestamps. docs/INSTALLATION.md step 11 explains why.
9
9
  #
10
10
  # The relaying is done by the broker, so the server, the logger and the nodes are untouched.
11
11
  #
@@ -24,7 +24,7 @@
24
24
  # printed - it is needed once, to type into the Pi.
25
25
  #
26
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.
27
+ # with a certificate. That is a one-off, and docs/INSTALLATION.md step 11 covers it.
28
28
 
29
29
  set -euo pipefail
30
30
 
@@ -255,7 +255,7 @@ else
255
255
  echo ""
256
256
  echo "NOTE: this broker has no acl_file, so ${ACCOUNT} can reach every topic on it, not just"
257
257
  echo " ${ORG_ID}/#. That is how the broker already treats every other account, so this is no"
258
- echo " worse than what is there - but see INSTALLATION.md step 11 for restricting it."
258
+ echo " worse than what is there - but see docs/INSTALLATION.md step 11 for restricting it."
259
259
  fi
260
260
 
261
261
  # password_file and acl_file are both re-read on a reload, so nothing needs to be disconnected.
@@ -133,11 +133,20 @@ if [[ ! -w "$MOSQUITTO_PASSWD_DIR" || ( -e "$MOSQUITTO_PASSWD_FILE" && ! -w "$MO
133
133
  fi
134
134
  fi
135
135
  fi
136
+ # Run as root there is no sudo to go through (OpenWrt has none, and runs everything as root), and
137
+ # mosquitto_passwd then rewrites the file as root - which the broker, running as its own account,
138
+ # cannot read. So when root is doing it and somebody else owns the file, hand it back afterwards.
139
+ if (( EUID == 0 )) && [[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]]; then
140
+ MOSQUITTO_RESTORE_OWNER=1
141
+ fi
136
142
  # Called after any successful mosquitto_passwd run below
137
143
  restore_passwd_owner() {
138
144
  (( MOSQUITTO_RESTORE_OWNER )) || return 0
139
- sudo chown "${MOSQUITTO_PASSWD_OWNER}:${MOSQUITTO_PASSWD_GROUP}" "$MOSQUITTO_PASSWD_FILE"
140
- [[ -n "$MOSQUITTO_PASSWD_MODE" ]] && sudo chmod "$MOSQUITTO_PASSWD_MODE" "$MOSQUITTO_PASSWD_FILE"
145
+ local SUDO=(sudo)
146
+ (( EUID == 0 )) && SUDO=()
147
+ "${SUDO[@]}" chown "${MOSQUITTO_PASSWD_OWNER}:${MOSQUITTO_PASSWD_GROUP}" "$MOSQUITTO_PASSWD_FILE"
148
+ [[ -n "$MOSQUITTO_PASSWD_MODE" ]] && "${SUDO[@]}" chmod "$MOSQUITTO_PASSWD_MODE" "$MOSQUITTO_PASSWD_FILE"
149
+ return 0
141
150
  }
142
151
 
143
152
  # ---- Escaping helpers ----