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 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
@@ -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: '&copy; <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: '&copy; 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
@@ -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
@@ -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
+ );
@@ -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
- // Just log the request for now
940
- // noinspection JSCheckFunctionSignatures
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: 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.
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: returnTo,
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
@@ -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 || ''));