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 +3 -3
- package/config.d/email.yaml +1 -1
- package/config.d/opensearch.yaml +2 -2
- package/config.d/schema/modules.yaml +13 -0
- package/config.d/schema/topics.yaml +50 -0
- package/docs/OPENWRT.md +208 -0
- package/extras/frugaliot.service +1 -1
- package/extras/mosquitto.conf +1 -1
- package/frugal-iot-createdb.sql +10 -1
- package/frugal-iot-server.js +26 -5
- package/lib/grafana.js +2 -2
- package/lib/language-requests.js +50 -0
- package/package.json +7 -6
- package/public/service-worker.js +10 -2
- package/scripts/addbridge-pi.zsh +1 -1
- package/scripts/addbridge-prod.zsh +2 -2
- package/scripts/addorganization.zsh +11 -2
- package/scripts/diagnostic.zsh +4 -4
- package/scripts/grafana-token.js +1 -1
- package/scripts/init.zsh +5 -1
- package/scripts/install-openwrt.sh +709 -0
- package/scripts/install-pi.sh +46 -12
- package/scripts/openwrt-preflight.sh +363 -0
- /package/{GRAFANA.md → docs/GRAFANA.md} +0 -0
- /package/{INSTALL-OPENSEARCH.md → docs/INSTALL-OPENSEARCH.md} +0 -0
- /package/{INSTALLATION.md → docs/INSTALLATION.md} +0 -0
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
|
```
|
package/config.d/email.yaml
CHANGED
|
@@ -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
|
package/config.d/opensearch.yaml
CHANGED
|
@@ -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
|
package/docs/OPENWRT.md
ADDED
|
@@ -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. |
|
package/extras/frugaliot.service
CHANGED
|
@@ -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
|
package/extras/mosquitto.conf
CHANGED
|
@@ -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
|
package/frugal-iot-createdb.sql
CHANGED
|
@@ -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
|
+
);
|
package/frugal-iot-server.js
CHANGED
|
@@ -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
|
|
1277
|
-
//
|
|
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:
|
|
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.
|
|
16
|
-
"frugal-iot-logger": "^2.0.
|
|
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.
|
|
97
|
+
"version": "2.0.10",
|
|
97
98
|
"overrides": {
|
|
98
99
|
"uuid": "^11.1.1"
|
|
99
100
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -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
|
+
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
|
});
|
package/scripts/addbridge-pi.zsh
CHANGED
|
@@ -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
|
|
140
|
-
|
|
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 ----
|