frugal-iot-server 2.0.9 → 2.0.11
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/mapping.yaml +56 -0
- package/config.d/opensearch.yaml +2 -2
- package/config.d/schema/modules.yaml +14 -0
- package/config.d/schema/topics.yaml +60 -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 +51 -12
- package/lib/config-for-user.js +6 -0
- package/lib/grafana.js +2 -2
- package/lib/language-requests.js +50 -0
- package/lib/tile-prefetch.js +158 -0
- package/lib/tiles.js +271 -0
- package/package.json +8 -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 +6 -2
- package/scripts/install-openwrt.sh +712 -0
- package/scripts/install-pi.sh +46 -12
- package/scripts/openwrt-preflight.sh +364 -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
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# The map: where its tiles come from, and whether this server keeps them.
|
|
2
|
+
|
|
3
|
+
# Each source is a set of tiles the map can draw. Sources named "Map" are drawn everywhere; any other
|
|
4
|
+
# name (e.g. "Imagery") is drawn over the map where the source has tiles, and the map offers a switch
|
|
5
|
+
# between them. Sources sharing a name switch together. bounds ([[south, west], [north, east]]) and
|
|
6
|
+
# minzoom are optional - outside them nothing is asked for.
|
|
7
|
+
sources:
|
|
8
|
+
osm:
|
|
9
|
+
name: Map
|
|
10
|
+
enabled: true
|
|
11
|
+
upstream: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
|
12
|
+
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
13
|
+
maxzoom: 19
|
|
14
|
+
nsw:
|
|
15
|
+
name: Imagery
|
|
16
|
+
# Aerial imagery of New South Wales from NSW Spatial Services, free and with no key.
|
|
17
|
+
# Licence: Creative Commons Attribution 4.0, though their own information sheet links the
|
|
18
|
+
# no-derivatives version - https://www.spatial.nsw.gov.au/__data/assets/pdf_file/0005/202559/NSW_Imagery_rebranded.pdf
|
|
19
|
+
# Showing it with the attribution is fine either way. It is unclear whether caching it here
|
|
20
|
+
# (cache and prefetch, below) constitutes redistribution, or is just good behaviour.
|
|
21
|
+
enabled: false
|
|
22
|
+
upstream: https://maps.six.nsw.gov.au/arcgis/rest/services/public/NSW_Imagery/MapServer/tile/{z}/{y}/{x}
|
|
23
|
+
attribution: '© State Government of NSW and Spatial Services (DCS)'
|
|
24
|
+
bounds: [[-38.4, 138.4], [-26.5, 159.3]]
|
|
25
|
+
minzoom: 15
|
|
26
|
+
maxzoom: 21
|
|
27
|
+
|
|
28
|
+
# Keeping tiles on this server. Off, the browser fetches them from each source itself.
|
|
29
|
+
# On, this server keeps every tile anyone looks at and serves it to the map from then on, so a Pi
|
|
30
|
+
# that goes offline still has a map. On a server that is always online it means one fetch from a
|
|
31
|
+
# source per tile rather than one per browser.
|
|
32
|
+
cache:
|
|
33
|
+
enabled: false
|
|
34
|
+
# Not under datadir: that is served at /data, and its housekeeping walks every file in it
|
|
35
|
+
dir: ./tiles
|
|
36
|
+
# A kept tile is checked with its source after this many days. Never less than 7 - OSM asks that.
|
|
37
|
+
# A check that finds it unchanged costs no rewrite, and offline the old tile is served regardless.
|
|
38
|
+
refreshdays: 30
|
|
39
|
+
# Tiles looked at beyond this are dropped once a day, least recently used first. A tile is 5-50 KB.
|
|
40
|
+
maxmb: 200
|
|
41
|
+
|
|
42
|
+
# Fetching, while online, the tiles nobody has looked at yet - for a Pi that will go offline. Needs
|
|
43
|
+
# the cache on. Not needed on a server that is always online. Tiles it fetches are never dropped
|
|
44
|
+
# while still wanted.
|
|
45
|
+
prefetch:
|
|
46
|
+
enabled: false
|
|
47
|
+
# Each organization's area, at every zoom while it fits in this many tiles - zoom 3 for the
|
|
48
|
+
# whole world, about 12 for a district. So zooming out always finds a map. Not for sources with
|
|
49
|
+
# bounds, which get only the squares around devices inside them.
|
|
50
|
+
organizationtiles: 64
|
|
51
|
+
# The same for each project's area
|
|
52
|
+
projecttiles: 256
|
|
53
|
+
# A square this many metres around each device, down to devicezoom (19 is OSM's deepest).
|
|
54
|
+
# About 100 tiles, 1-2 MB, per device and source. Each zoom deeper is four times as many.
|
|
55
|
+
deviceradius: 250
|
|
56
|
+
devicezoom: 19
|
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
|
|
@@ -339,6 +339,7 @@ frugal_iot:
|
|
|
339
339
|
- leaf: id
|
|
340
340
|
- leaf: name
|
|
341
341
|
- leaf: description
|
|
342
|
+
- leaf: location
|
|
342
343
|
- leaf: lastseen
|
|
343
344
|
gps:
|
|
344
345
|
name: GPS
|
|
@@ -383,6 +384,13 @@ heatsink:
|
|
|
383
384
|
name: Heatsink Temperature
|
|
384
385
|
min: 0
|
|
385
386
|
max: 120
|
|
387
|
+
# Seeed HM3301 particulate sensor ("Grove - Laser PM2.5 Sensor") - Sensor_HM3301.
|
|
388
|
+
hm3301:
|
|
389
|
+
name: "HM3301"
|
|
390
|
+
topics:
|
|
391
|
+
- leaf: pm1
|
|
392
|
+
- leaf: pm25
|
|
393
|
+
- leaf: pm10
|
|
386
394
|
ht:
|
|
387
395
|
name: HT
|
|
388
396
|
topics:
|
|
@@ -617,6 +625,12 @@ sector:
|
|
|
617
625
|
- leaf: valve
|
|
618
626
|
leaf_from: controlouttoggle
|
|
619
627
|
name: Valve
|
|
628
|
+
# Nova Fitness SDS011 particulate sensor, as in sensor.community's airRohr - Sensor_SDS011.
|
|
629
|
+
sds011:
|
|
630
|
+
name: "SDS011"
|
|
631
|
+
topics:
|
|
632
|
+
- leaf: pm25
|
|
633
|
+
- leaf: pm10
|
|
620
634
|
sht:
|
|
621
635
|
name: SHT
|
|
622
636
|
topics:
|
|
@@ -411,6 +411,16 @@ loadcell:
|
|
|
411
411
|
rw: r
|
|
412
412
|
calibrate: true
|
|
413
413
|
graphable: true
|
|
414
|
+
# "lat,lon" in one payload, so a pin is never moved half-way; set from the UI, held by the node
|
|
415
|
+
location:
|
|
416
|
+
leaf: location
|
|
417
|
+
slot: location
|
|
418
|
+
name: Location
|
|
419
|
+
type: text
|
|
420
|
+
log: false
|
|
421
|
+
display: text
|
|
422
|
+
rw: w
|
|
423
|
+
retain: true
|
|
414
424
|
longitude:
|
|
415
425
|
leaf: longitude
|
|
416
426
|
name: Longitude
|
|
@@ -549,6 +559,56 @@ percent:
|
|
|
549
559
|
significantvalue: 1
|
|
550
560
|
graphable: true
|
|
551
561
|
units: "%"
|
|
562
|
+
# Particulate matter, from the SDS011 or HM3301 - see sensor/pm.h in Frugal-IoT. Both are noisy,
|
|
563
|
+
# and unreliable above ~70% humidity (they count droplets as particles). Only the HM3301 has pm1.
|
|
564
|
+
pm1:
|
|
565
|
+
leaf: pm1
|
|
566
|
+
name: PM1.0
|
|
567
|
+
type: float
|
|
568
|
+
display: bar
|
|
569
|
+
min: 0
|
|
570
|
+
max: 100
|
|
571
|
+
width: 5
|
|
572
|
+
color: "#d2691e"
|
|
573
|
+
units: ug/m3
|
|
574
|
+
rw: r
|
|
575
|
+
graphable: true
|
|
576
|
+
log: true
|
|
577
|
+
duplicates:
|
|
578
|
+
significantdate: 900000
|
|
579
|
+
significantvalue: 1
|
|
580
|
+
pm10:
|
|
581
|
+
leaf: pm10
|
|
582
|
+
name: PM10
|
|
583
|
+
type: float
|
|
584
|
+
display: bar
|
|
585
|
+
min: 0
|
|
586
|
+
max: 200
|
|
587
|
+
width: 5
|
|
588
|
+
color: "#696969"
|
|
589
|
+
units: ug/m3
|
|
590
|
+
rw: r
|
|
591
|
+
graphable: true
|
|
592
|
+
log: true
|
|
593
|
+
duplicates:
|
|
594
|
+
significantdate: 900000
|
|
595
|
+
significantvalue: 1
|
|
596
|
+
pm25:
|
|
597
|
+
leaf: pm25
|
|
598
|
+
name: PM2.5
|
|
599
|
+
type: float
|
|
600
|
+
display: bar
|
|
601
|
+
min: 0
|
|
602
|
+
max: 100
|
|
603
|
+
width: 5
|
|
604
|
+
color: "#a0522d"
|
|
605
|
+
units: ug/m3
|
|
606
|
+
rw: r
|
|
607
|
+
graphable: true
|
|
608
|
+
log: true
|
|
609
|
+
duplicates:
|
|
610
|
+
significantdate: 900000
|
|
611
|
+
significantvalue: 1
|
|
552
612
|
position:
|
|
553
613
|
leaf: position
|
|
554
614
|
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
|
@@ -125,6 +125,8 @@ import { APIError } from './lib/api-errors.js';
|
|
|
125
125
|
import { access, constants, createReadStream, mkdir, readdir, readFile, rm } from 'fs'; // https://nodejs.org/api/fs.html
|
|
126
126
|
import { createGunzip } from 'zlib'; // https://nodejs.org/api/zlib.html - for serving compressed readings
|
|
127
127
|
import { startHousekeeping } from './lib/housekeeping.js';
|
|
128
|
+
import { createTileCache, parseTile, startTileHousekeeping } from './lib/tiles.js';
|
|
129
|
+
import { loggerLocations, startPrefetch } from './lib/tile-prefetch.js';
|
|
128
130
|
import { detectSeries } from 'async'; // https://caolan.github.io/async/v3/docs.html
|
|
129
131
|
import { createMD5 } from 'hash-wasm';
|
|
130
132
|
import multer from 'multer'; // https://www.npmjs.com/package/multer
|
|
@@ -140,6 +142,7 @@ import crypto from 'crypto'; /* https://nodejs.org/api/crypto.html */
|
|
|
140
142
|
import {waterfall, each} from 'async';
|
|
141
143
|
import { mailInit, mailConfigured, sendMail } from './lib/mailer.js';
|
|
142
144
|
import { resetCodeMake, resetCodeCheck, resetRateOk } from './lib/resetcode.js';
|
|
145
|
+
import { countLanguages, parseBrowserLanguages } from './lib/language-requests.js';
|
|
143
146
|
// import { openDB } from 'sqlite-express-package'; /* appContent, appSelect, validateId, validateAlias, tagCloud, atom, rss,*/
|
|
144
147
|
|
|
145
148
|
export let config; // Live binding - lib/api-routes.js reads the current value at request time
|
|
@@ -155,8 +158,16 @@ function loginRedirect(res, { mode = 'signin', message, messagetype, url }) {
|
|
|
155
158
|
if (message) { q.set('message', message); }
|
|
156
159
|
if (messagetype) { q.set('messagetype', messagetype); }
|
|
157
160
|
if (url) { q.set('url', url); } // Encoded here: a return url usually has a query string of its own
|
|
161
|
+
const lang = langOf(url);
|
|
162
|
+
if (lang) { q.set('lang', lang); }
|
|
158
163
|
res.redirect(`${loginUrl}?${q}`);
|
|
159
164
|
}
|
|
165
|
+
// The language of the page being returned to. login.html reads only its own ?lang=, and without it
|
|
166
|
+
// falls back to the browser's language rather than the one picked before signing in.
|
|
167
|
+
function langOf(url) {
|
|
168
|
+
if (!url) return null;
|
|
169
|
+
try { return new URL(url, 'http://localhost').searchParams.get('lang'); } catch (e) { return null; }
|
|
170
|
+
}
|
|
160
171
|
// A password reset link carries its token in the query string, and everything that logs a URL goes
|
|
161
172
|
// to the journal, which outlives the ten minutes the token is good for.
|
|
162
173
|
function redactUrl(url) {
|
|
@@ -936,12 +947,8 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
936
947
|
"Content-Length", "0"]
|
|
937
948
|
*/
|
|
938
949
|
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
app.use('/', (req, res, next) => {
|
|
942
|
-
console.log(redactUrl(req.url));
|
|
943
|
-
next();
|
|
944
|
-
})
|
|
950
|
+
// No request logging of its own: morgan, above, is the one switch for it, and on an SD card
|
|
951
|
+
// every line is a write.
|
|
945
952
|
|
|
946
953
|
// Nodes enrol here to get their own broker credential. Deliberately not behind a session: a node
|
|
947
954
|
// has no session, and this is what replaces the shared password compiled into its firmware.
|
|
@@ -1241,7 +1248,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1241
1248
|
app.use('/api', apiRouter);
|
|
1242
1249
|
console.log("Mounted API routes at /api");
|
|
1243
1250
|
|
|
1244
|
-
// History for Grafana, by bearer token - see lib/grafana.js and GRAFANA.md
|
|
1251
|
+
// History for Grafana, by bearer token - see lib/grafana.js and docs/GRAFANA.md
|
|
1245
1252
|
const grafana = createGrafanaRouters(db, config.server.datadir, loggerClient, { loggedInOrFail, can_READ });
|
|
1246
1253
|
app.use('/grafana', grafana.data);
|
|
1247
1254
|
app.use('/grafana_token', grafana.admin);
|
|
@@ -1273,21 +1280,33 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1273
1280
|
(req,res,next) => {
|
|
1274
1281
|
console.log("Trying to login with redirect to",req.body.url);
|
|
1275
1282
|
// 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.
|
|
1283
|
+
// Default the destination rather than trusting every caller to supply one - a missing
|
|
1284
|
+
// form field should still land somewhere, here and in the success handler below.
|
|
1279
1285
|
const returnTo = req.body.url || '/dashboard';
|
|
1280
1286
|
const failureQuery = new URLSearchParams({
|
|
1281
1287
|
mode: 'signin', messagetype: 'error',
|
|
1282
1288
|
message: 'Incorrect username or password', url: returnTo });
|
|
1289
|
+
const lang = langOf(returnTo);
|
|
1290
|
+
if (lang) { failureQuery.set('lang', lang); }
|
|
1283
1291
|
passport.authenticate('local', {
|
|
1284
1292
|
session: true,
|
|
1285
1293
|
//failWithError: true,
|
|
1286
1294
|
failureRedirect: `${loginUrl}?${failureQuery}`,
|
|
1287
|
-
successRedirect:
|
|
1295
|
+
// No successRedirect: on success passport calls next(), which counts and then redirects
|
|
1288
1296
|
// In failure case will also be messages in the session which need clearing out TODO-N89
|
|
1289
1297
|
//failureRedirect: `${loginUrl}?register=false&message=Incorrect+username+or+password&url=${req.body.url}`,
|
|
1290
1298
|
})(req, res, next);
|
|
1299
|
+
},
|
|
1300
|
+
// Counted only once signed in, so failed attempts cannot pad the numbers. Not waited for.
|
|
1301
|
+
(req, res) => {
|
|
1302
|
+
const codes = parseBrowserLanguages(req.body.browserlangs);
|
|
1303
|
+
if (codes.length) {
|
|
1304
|
+
console.log("Browser languages", codes.join(','));
|
|
1305
|
+
countLanguages(db, config.server.htmldir, codes, (err) => {
|
|
1306
|
+
if (err) console.error("Could not count browser languages:", err.message);
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
res.redirect(req.body.url || '/dashboard');
|
|
1291
1310
|
}
|
|
1292
1311
|
);
|
|
1293
1312
|
app.post('/register', (req, res) => {
|
|
@@ -1826,7 +1845,6 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1826
1845
|
const routerDashboard = express.Router();
|
|
1827
1846
|
app.use('/dashboard', routerDashboard);
|
|
1828
1847
|
routerDashboard.use(
|
|
1829
|
-
(req,res,next) => {console.log("/dashboard handler for", req.url); next(); }, // Log attempt
|
|
1830
1848
|
shouldIBeLoggedIn, // redirect to ./login.html if not logged in then back here
|
|
1831
1849
|
//(req,res,next) => {console.log("XXX back to /dashboard handler for", req.url); next(); }, // Log attempt
|
|
1832
1850
|
//(req,res,next) => {console.log("XXX", config.server.htmldir); next(); }, // Log attempt
|
|
@@ -1835,6 +1853,27 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1835
1853
|
express.static(config.server.htmldir, revalidate) // Serve static
|
|
1836
1854
|
);
|
|
1837
1855
|
|
|
1856
|
+
// Map tiles, kept here so the map works offline. Behind the login, or every server would be a
|
|
1857
|
+
// free proxy to OpenStreetMap. Off unless config.d/mapping.yaml turns it on.
|
|
1858
|
+
const mapping = config.mapping || {};
|
|
1859
|
+
if (mapping.cache && mapping.cache.enabled) {
|
|
1860
|
+
const tiles = createTileCache({ ...mapping.cache, sources: mapping.sources });
|
|
1861
|
+
console.log("Serving /tiles from", tiles.dir, "for", Object.keys(tiles.sources).join(", "));
|
|
1862
|
+
startTileHousekeeping(tiles);
|
|
1863
|
+
if (mapping.prefetch && mapping.prefetch.enabled) {
|
|
1864
|
+
startPrefetch(tiles, mapping.prefetch, () => loggerLocations(mqttLogger));
|
|
1865
|
+
}
|
|
1866
|
+
app.get('/tiles/:source/:z/:x/:y', loggedInOrFail, (req, res) => {
|
|
1867
|
+
const t = parseTile(req.params.z, req.params.x, req.params.y);
|
|
1868
|
+
if (!t) { res.sendStatus(404); return; }
|
|
1869
|
+
tiles.get(req.params.source, t, (err, file) => {
|
|
1870
|
+
if (!file) { res.sendStatus(404); return; }
|
|
1871
|
+
res.set('Cache-Control', 'private, max-age=86400');
|
|
1872
|
+
res.sendFile(path.resolve(file));
|
|
1873
|
+
});
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1838
1877
|
// Serve frugal-iot-logger data at /data but configure where to get them.
|
|
1839
1878
|
console.log("Serving /data from", config.server.datadir);
|
|
1840
1879
|
// Nothing ever removed old readings, so a server left running filled its disk - which on a
|
package/lib/config-for-user.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* possible.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { sourcesForBrowser } from './tiles.js';
|
|
12
|
+
|
|
11
13
|
// Sections of the configuration that are never sent to a browser, whatever permissions the user has.
|
|
12
14
|
// config.d itself is not served - the static mounts are publicdir, htmldir, datadir, privatedir and
|
|
13
15
|
// node_modules, so config.d/schema/devices.yaml is a 404 - and buildConfigFor is the only route by
|
|
@@ -92,6 +94,8 @@ export function buildConfigFor(config, user) {
|
|
|
92
94
|
Object.entries(config).forEach(([key, value]) => {
|
|
93
95
|
if (configSectionsNeverServed.includes(key)) {
|
|
94
96
|
// Deliberately absent from the result
|
|
97
|
+
} else if (key === 'mapping') {
|
|
98
|
+
// The server's own settings - the browser's version is built below
|
|
95
99
|
} else if (key === 'organizations') {
|
|
96
100
|
Object.entries(value).forEach(([orgid, org]) => {
|
|
97
101
|
if (hasPermissions(user, orgid, 'READ')) {
|
|
@@ -102,6 +106,8 @@ export function buildConfigFor(config, user) {
|
|
|
102
106
|
oo[key] = value;
|
|
103
107
|
}
|
|
104
108
|
});
|
|
109
|
+
// The browser gets only what the map draws (M-27), and gets it whether or not mapping.yaml exists
|
|
110
|
+
oo.mapping = { sources: sourcesForBrowser(config.mapping) };
|
|
105
111
|
return oo;
|
|
106
112
|
}
|
|
107
113
|
|
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 || ''));
|