frugal-iot-server 2.0.10 → 2.0.12
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/config.d/mapping.yaml +56 -0
- package/config.d/schema/modules.yaml +1 -0
- package/config.d/schema/topics.yaml +10 -0
- package/docs/INSTALLATION.md +128 -31
- package/docs/OPENWRT.md +2 -2
- package/frugal-iot-server.js +25 -7
- package/lib/config-for-user.js +6 -0
- package/lib/tile-prefetch.js +158 -0
- package/lib/tiles.js +271 -0
- package/package.json +6 -3
- package/public/service-worker.js +1 -1
- package/scripts/addbridge-prod.zsh +15 -19
- package/scripts/addorganization.zsh +9 -3
- package/scripts/backup.zsh +361 -0
- package/scripts/broker-config.zsh +185 -0
- package/scripts/diagnostic.zsh +122 -83
- package/scripts/init.zsh +162 -85
- package/scripts/install-openwrt.sh +3 -0
- package/scripts/not-published.txt +1 -0
- package/scripts/openwrt-preflight.sh +16 -3
- package/scripts/restore.zsh +375 -0
|
@@ -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
|
|
@@ -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
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -1164,8 +1164,8 @@ but close enough for sensor data.
|
|
|
1164
1164
|
### Wear and tear on the SD card
|
|
1165
1165
|
|
|
1166
1166
|
SD cards wear out from being written to, and a server recording sensor readings writes all the time.
|
|
1167
|
-
We have not yet seen a card fail, but plan for one eventually:
|
|
1168
|
-
|
|
1167
|
+
We have not yet seen a card fail, but plan for one eventually: see [Backups](#backups), and keep
|
|
1168
|
+
the snapshots somewhere other than the card.
|
|
1169
1169
|
|
|
1170
1170
|
A new installation is set up to write as little as it reasonably can, and the settings are worth
|
|
1171
1171
|
knowing about, because turning one of them back on for debugging and forgetting is easy to do:
|
|
@@ -1199,6 +1199,90 @@ and read the **Wear on the SD card** section, which reports how much has been wr
|
|
|
1199
1199
|
whether the machine is swapping, and whether each of the settings above is in force. Run it twice a
|
|
1200
1200
|
few days apart — the interesting number is the rate, not the total.
|
|
1201
1201
|
|
|
1202
|
+
## Backups
|
|
1203
|
+
|
|
1204
|
+
`frugal-iot-backup` takes a snapshot of everything on this machine that cannot be rebuilt: the
|
|
1205
|
+
database, `config.d/` (including `secrets.yaml`), the broker's configuration, password file, ACL
|
|
1206
|
+
file and TLS certificate, and the server's systemd unit. It finds the broker's files by asking the
|
|
1207
|
+
broker's own configuration, so it works however this machine is laid out.
|
|
1208
|
+
|
|
1209
|
+
Not in it: `data/` (large, and copied separately - see below), `ota/` (rebuilt at each release),
|
|
1210
|
+
and anything the broker rebuilds or is running on.
|
|
1211
|
+
|
|
1212
|
+
First see what it would take, and from where - this writes nothing:
|
|
1213
|
+
|
|
1214
|
+
```
|
|
1215
|
+
cd ~/frugal-iot
|
|
1216
|
+
npx --no frugal-iot-backup check
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
Then take one now, and install a timer that takes one every night:
|
|
1220
|
+
|
|
1221
|
+
```
|
|
1222
|
+
npx --no frugal-iot-backup
|
|
1223
|
+
npx --no frugal-iot-backup install
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
Both ask for `sudo`, because the broker's files belong to root and to `mosquitto`. Snapshots go to
|
|
1227
|
+
`/var/backups/frugal-iot/`, keeping one a day for a week, one a week for a month, one a month for
|
|
1228
|
+
three months, one a quarter for a year, and one a year for ever - about 20 small files.
|
|
1229
|
+
|
|
1230
|
+
**A snapshot holds every credential this server has** - with it, someone can log in as any node or
|
|
1231
|
+
user on the broker. So each one is mode 600, owned by the account the server runs as, in a directory
|
|
1232
|
+
nobody else can open. Copy them off the machine the same way: by `rsync` or `scp` as that account,
|
|
1233
|
+
to a machine you trust as much as this one.
|
|
1234
|
+
|
|
1235
|
+
A snapshot on the same SD card is lost with the card, so it only protects against mistakes. To
|
|
1236
|
+
protect against losing the machine, copy `/var/backups/frugal-iot/` and `data/` somewhere else
|
|
1237
|
+
regularly - for example from a laptop:
|
|
1238
|
+
|
|
1239
|
+
```
|
|
1240
|
+
rsync -a pi@frugaliot.local:/var/backups/frugal-iot/ ~/frugal-iot-backups/frugaliot/state/
|
|
1241
|
+
rsync -a pi@frugaliot.local:frugal-iot/data/ ~/frugal-iot-backups/frugaliot/data/
|
|
1242
|
+
```
|
|
1243
|
+
|
|
1244
|
+
Leave out `--delete` on the second: the server may delete old readings to save space, and this way
|
|
1245
|
+
the copy keeps them.
|
|
1246
|
+
|
|
1247
|
+
`npx --no frugal-iot-diagnostic` reports how old the newest snapshot is, and calls it a problem if
|
|
1248
|
+
the timer is installed and nothing has been written for two days.
|
|
1249
|
+
|
|
1250
|
+
### Restoring
|
|
1251
|
+
|
|
1252
|
+
Restore onto a machine where the server is already installed - after re-flashing a Pi, run the
|
|
1253
|
+
installer first, then restore over what it set up. Copy the snapshot onto the machine, then from
|
|
1254
|
+
the server's directory see what would happen - this changes nothing:
|
|
1255
|
+
|
|
1256
|
+
```
|
|
1257
|
+
npx --no frugal-iot-restore <snapshot>
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
`latest` in place of a file name means the newest one in `/var/backups/frugal-iot/`. Then do it,
|
|
1261
|
+
in one of two ways:
|
|
1262
|
+
|
|
1263
|
+
* **`state`** - onto a machine its own installer set up, laid out however that is. It restores the
|
|
1264
|
+
database, `config.d/` (including `secrets.yaml`) and the broker's password file, ACL file and
|
|
1265
|
+
account state, each to wherever *this* broker keeps them. It keeps this machine's own broker
|
|
1266
|
+
configuration, TLS certificate, systemd unit, `mqtt.yaml` and `server.yaml`.
|
|
1267
|
+
* **`all`** - onto the same machine, or one laid out exactly like it: everything goes back where it
|
|
1268
|
+
came from. Broker configuration files the snapshot does not have are renamed to
|
|
1269
|
+
`<name>.pre-restore`, so the broker does not read both.
|
|
1270
|
+
|
|
1271
|
+
```
|
|
1272
|
+
npx --no frugal-iot-restore <snapshot> state
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
If the machine already has logins of its own, add `force` as the last word - it says so rather
|
|
1276
|
+
than replacing them silently.
|
|
1277
|
+
|
|
1278
|
+
Before changing anything it takes a snapshot of what is there into
|
|
1279
|
+
`/var/backups/frugal-iot/pre-restore/`, so a restore can be undone with
|
|
1280
|
+
`npx --no frugal-iot-restore <that snapshot> all force`. It stops the server and the broker while it
|
|
1281
|
+
works - every node disconnects for a few seconds - and finishes by checking the broker's accounts
|
|
1282
|
+
against the database and running the diagnostic.
|
|
1283
|
+
|
|
1284
|
+
`data/` is not in a snapshot: copy it back from wherever it is mirrored.
|
|
1285
|
+
|
|
1202
1286
|
## Moving the Pi to another WiFi network
|
|
1203
1287
|
|
|
1204
1288
|
This is how to give the Pi a new WiFi network when you cannot log in to it, because it no longer
|
|
@@ -1317,7 +1401,8 @@ Two things to know:
|
|
|
1317
1401
|
|
|
1318
1402
|
If you have not reached step 4 yet, the command does not exist. Copy
|
|
1319
1403
|
[scripts/diagnostic.zsh](https://github.com/mitra42/frugal-iot-server/blob/main/scripts/diagnostic.zsh)
|
|
1320
|
-
|
|
1404
|
+
and [scripts/broker-config.zsh](https://github.com/mitra42/frugal-iot-server/blob/main/scripts/broker-config.zsh),
|
|
1405
|
+
which it needs, into the same directory on the Pi and run `zsh diagnostic.zsh` instead.
|
|
1321
1406
|
|
|
1322
1407
|
### A topic that will not go away
|
|
1323
1408
|
|
|
@@ -1363,8 +1448,11 @@ sudo systemctl restart frugaliot
|
|
|
1363
1448
|
Taking those in turn:
|
|
1364
1449
|
|
|
1365
1450
|
* `npm update` fetches the new version of the server, and of the client and logger it depends on.
|
|
1451
|
+
It may report vulnerabilities, typically in `uuid`. `frugal-iot-init`, next, is what fixes those,
|
|
1452
|
+
so ignore that report and do not run `npm audit fix`.
|
|
1366
1453
|
* `frugal-iot-init` adds any configuration file or directory the new version expects and you do not
|
|
1367
|
-
have yet. It never changes
|
|
1454
|
+
have yet. It never changes your settings, database or data. It also copies the release's
|
|
1455
|
+
dependency `overrides` into your `package.json` and re-runs `npm install` - see below.
|
|
1368
1456
|
* `systemctl restart frugaliot` is what actually puts the new version into service — without it the
|
|
1369
1457
|
old one keeps running from memory, and nothing appears to have changed. (Skip this if you are
|
|
1370
1458
|
running the server by hand rather than as a service; stop it with `Ctrl-C` and start it again.)
|
|
@@ -1377,17 +1465,37 @@ journalctl -u frugaliot -n 30
|
|
|
1377
1465
|
npm ls frugal-iot-server
|
|
1378
1466
|
```
|
|
1379
1467
|
|
|
1380
|
-
`frugal-iot-init` leaves your own files alone
|
|
1468
|
+
`frugal-iot-init` leaves your own files alone, with two exceptions, and tells you what differs.
|
|
1381
1469
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
the
|
|
1470
|
+
**`package.json`** in the install directory gets the `overrides` section from frugal-iot-server's
|
|
1471
|
+
own. These pin transitive dependencies (such as `uuid`, deep below the logger) to versions without
|
|
1472
|
+
known vulnerabilities. npm only reads `overrides` from the top-level project, which here is your
|
|
1473
|
+
`package.json`, not the package's, so without this step `npm audit` reports problems that a checkout
|
|
1474
|
+
of the same release does not. When it adds or changes one it prints `set ./package.json override ...`
|
|
1475
|
+
and runs `npm install` to apply it; `npm audit` should then come back clean.
|
|
1386
1476
|
|
|
1387
|
-
**
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1477
|
+
**The sensor schema** under `config.d/schema/` is the other. If every line of your copy is still
|
|
1478
|
+
in this release's version, all the release did was add to it, so it installs the new one and says
|
|
1479
|
+
`updated ... (this release only adds to it)`. If your copy has lines the release does not, it keeps
|
|
1480
|
+
yours and shows just those lines: if you added them, that is exactly right; if you did not, this
|
|
1481
|
+
release changed or removed them, and it prints the `cp` that takes its version.
|
|
1482
|
+
|
|
1483
|
+
**The broker's configuration** is compared setting by setting across every file the broker reads,
|
|
1484
|
+
wherever they are - one `frugal-iot.conf` from the installer, or several files in
|
|
1485
|
+
`/etc/mosquitto/conf.d/` assembled by hand. Each difference is one line:
|
|
1486
|
+
|
|
1487
|
+
```
|
|
1488
|
+
missing: connection_messages false - not set (mosquitto's default applies)
|
|
1489
|
+
(fine) password_file - here /etc/mosquitto/mosquitto_passwords (this release's default is /var/lib/mosquitto/passwords)
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
A file kept at a different path is fine. A `missing:` or `different:` line is something this release
|
|
1493
|
+
sets that your broker does not. Add it to any file in `/etc/mosquitto/conf.d/`, then
|
|
1494
|
+
`sudo systemctl restart mosquitto` - or, on a Pi set up by `install-pi.sh`, re-run that.
|
|
1495
|
+
|
|
1496
|
+
**"Installed elsewhere from extras/, and different from this release"** covers the ACL file and the
|
|
1497
|
+
systemd units, found where the broker and systemd say they are. It lists the differing lines,
|
|
1498
|
+
marked `here only` and `release only`. To bring them up to date on a Pi, re-run the installer:
|
|
1391
1499
|
|
|
1392
1500
|
```
|
|
1393
1501
|
bash node_modules/frugal-iot-server/scripts/install-pi.sh
|
|
@@ -1403,29 +1511,18 @@ install these three, because none of them can simply be copied into place:
|
|
|
1403
1511
|
| `extras/frugaliot.service` | written for user `pi` in `/home/pi/frugal-iot`; the installer rewrites `User`, `WorkingDirectory` and `ExecStart` for wherever this actually went. |
|
|
1404
1512
|
|
|
1405
1513
|
The setup network's watchdog, `extras/frugaliot-ap-watchdog` and its `.service` and `.timer`, *can* be
|
|
1406
|
-
copied as they are; step 8a has the commands.
|
|
1407
|
-
re-running the installer adds it, and asks once for the
|
|
1514
|
+
copied as they are; step 8a has the commands. It is only checked where it is installed. On a Pi
|
|
1515
|
+
installed before the setup network existed, re-running the installer adds it, and asks once for the
|
|
1516
|
+
`pi` login's password to give it.
|
|
1408
1517
|
|
|
1409
1518
|
The installer will not overwrite an ACL file that has per-bridge rules in it. If this release adds a
|
|
1410
1519
|
rule to a broker that has them, it says which lines are missing and leaves the file alone for you to
|
|
1411
1520
|
add them by hand, then `sudo systemctl reload mosquitto`.
|
|
1412
1521
|
|
|
1413
1522
|
> **If your broker's configuration was assembled by hand** — several small files in
|
|
1414
|
-
> `/etc/mosquitto/conf.d/` rather than the single `frugal-iot.conf` the installer writes —
|
|
1415
|
-
>
|
|
1416
|
-
>
|
|
1417
|
-
> alongside what you already have, declaring the same listeners and the same plugin twice. Compare
|
|
1418
|
-
> by hand instead, and add only what is missing:
|
|
1419
|
-
>
|
|
1420
|
-
> ```
|
|
1421
|
-
> sudo grep -hvE '^\s*(#|$)' /etc/mosquitto/conf.d/*.conf | sort -u > /tmp/running.txt
|
|
1422
|
-
> grep -hvE '^\s*(#|$)' node_modules/frugal-iot-server/extras/mosquitto.conf | sort -u > /tmp/shipped.txt
|
|
1423
|
-
> diff /tmp/running.txt /tmp/shipped.txt
|
|
1424
|
-
> ```
|
|
1425
|
-
>
|
|
1426
|
-
> Lines only in the running config are usually this machine's own — TLS `certfile`/`keyfile`, a
|
|
1427
|
-
> bridge `listener`, a `password_file` in a different place. Lines only in the shipped one are what
|
|
1428
|
-
> this release added.
|
|
1523
|
+
> `/etc/mosquitto/conf.d/` rather than the single `frugal-iot.conf` the installer writes — do not
|
|
1524
|
+
> re-run the installer: it would add `frugal-iot.conf` alongside what you already have, declaring the
|
|
1525
|
+
> same listeners and the same plugin twice. Add the `missing:` and `different:` settings by hand.
|
|
1429
1526
|
|
|
1430
1527
|
> It does not compare `config.yaml`, `config.d/mqtt.yaml`, `config.d/logger.yaml` or
|
|
1431
1528
|
> `config.d/server.yaml`, because those hold your own settings and would differ every time. If a
|
|
@@ -1437,7 +1534,7 @@ which for the settings under [Wear and tear on the SD card](#wear-and-tear-on-th
|
|
|
1437
1534
|
goes on writing as often as it did. A server that has been running since before those settings
|
|
1438
1535
|
existed keeps logging every message and every web request, and writing every reading as it arrives —
|
|
1439
1536
|
deliberately, since quietening somebody's server without being asked is not a thing an upgrade should
|
|
1440
|
-
do. On a Pi, compare the
|
|
1537
|
+
do. On a Pi, compare the files named above and copy across the settings you want.
|
|
1441
1538
|
|
|
1442
1539
|
Your organizations, accounts, database and logged data are untouched by an upgrade — they live in
|
|
1443
1540
|
this directory, not in `node_modules`.
|
package/docs/OPENWRT.md
CHANGED
|
@@ -24,8 +24,8 @@ Run the preflight script (below) and it checks every line of this table for you.
|
|
|
24
24
|
|
|
25
25
|
| | Needed | Why |
|
|
26
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
|
|
27
|
+
| **CPU** | **aarch64 or x86_64.** 32-bit ARM might work. **MIPS will not** (Node.js has no MIPS build at all, so older and cheaper routers - the GL.iNet AR300M, for one - are out). | 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** | The server alone uses about 250 MB once running, on top of the router's own software. A router with 256 MB cannot do it (a Cudy with 233 MB usable was refused). |
|
|
29
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
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
31
|
| **mosquitto** | A build with **websockets** | The browser dashboard connects over websockets. If the router's mosquitto has none, the dashboard cannot work. |
|
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
|
|
@@ -945,12 +947,8 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
945
947
|
"Content-Length", "0"]
|
|
946
948
|
*/
|
|
947
949
|
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
app.use('/', (req, res, next) => {
|
|
951
|
-
console.log(redactUrl(req.url));
|
|
952
|
-
next();
|
|
953
|
-
})
|
|
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.
|
|
954
952
|
|
|
955
953
|
// Nodes enrol here to get their own broker credential. Deliberately not behind a session: a node
|
|
956
954
|
// has no session, and this is what replaces the shared password compiled into its firmware.
|
|
@@ -1847,7 +1845,6 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1847
1845
|
const routerDashboard = express.Router();
|
|
1848
1846
|
app.use('/dashboard', routerDashboard);
|
|
1849
1847
|
routerDashboard.use(
|
|
1850
|
-
(req,res,next) => {console.log("/dashboard handler for", req.url); next(); }, // Log attempt
|
|
1851
1848
|
shouldIBeLoggedIn, // redirect to ./login.html if not logged in then back here
|
|
1852
1849
|
//(req,res,next) => {console.log("XXX back to /dashboard handler for", req.url); next(); }, // Log attempt
|
|
1853
1850
|
//(req,res,next) => {console.log("XXX", config.server.htmldir); next(); }, // Log attempt
|
|
@@ -1856,6 +1853,27 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
|
|
|
1856
1853
|
express.static(config.server.htmldir, revalidate) // Serve static
|
|
1857
1854
|
);
|
|
1858
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
|
+
|
|
1859
1877
|
// Serve frugal-iot-logger data at /data but configure where to get them.
|
|
1860
1878
|
console.log("Serving /data from", config.server.datadir);
|
|
1861
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
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Fetching, while online, the tiles a Pi will need once it is not (frugal-iot-client
|
|
3
|
+
* docs/MAPPING_PLAN.md M-21, M-30). For each source without bounds, three layers:
|
|
4
|
+
* - each organization's devices' bounding box, at every zoom while it needs no more than
|
|
5
|
+
* organizationtiles - so zooming out always finds a map;
|
|
6
|
+
* - the same for each project, with projecttiles;
|
|
7
|
+
* - a square deviceradius metres around each device, down to devicezoom.
|
|
8
|
+
* An organization, or even a project, can span the world, so the boxes are bounded by tiles, not
|
|
9
|
+
* zoom: the whole world stops at zoom 3, a district goes to about 12.
|
|
10
|
+
*
|
|
11
|
+
* A source with bounds - imagery for one state, say - gets only the device squares inside them, from
|
|
12
|
+
* its minzoom: the box layers are what an unbounded map is for.
|
|
13
|
+
*
|
|
14
|
+
* Locations are what the logger last heard - it keeps the current value of every topic, retained
|
|
15
|
+
* ones included - so this needs no subscription of its own.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { eachLimit } from 'async';
|
|
19
|
+
import { tileInSource, tileKey, tileX, tileY } from './tiles.js';
|
|
20
|
+
|
|
21
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
const CHECK_MS = 10 * 60 * 1000; // how soon a moved device's area is fetched
|
|
23
|
+
const FIRST_CHECK_MS = 2 * 60 * 1000; // retained locations arrive just after the logger connects
|
|
24
|
+
const BOX_MAX_ZOOM = 16; // OSM's policy: deep tiles only in the small device squares
|
|
25
|
+
const METRES_PER_DEGREE = 111320;
|
|
26
|
+
|
|
27
|
+
export const PREFETCH_DEFAULTS = {
|
|
28
|
+
organizationtiles: 64,
|
|
29
|
+
projecttiles: 256,
|
|
30
|
+
deviceradius: 250,
|
|
31
|
+
devicezoom: 19,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function boxRange(box, z) {
|
|
35
|
+
return { z, x0: tileX(box.west, z), x1: tileX(box.east, z), y0: tileY(box.north, z), y1: tileY(box.south, z) };
|
|
36
|
+
}
|
|
37
|
+
const rangeCount = (r) => ((r.x1 - r.x0) + 1) * ((r.y1 - r.y0) + 1);
|
|
38
|
+
|
|
39
|
+
function addRange(tiles, src, r) {
|
|
40
|
+
for (let x = r.x0; x <= r.x1; x++) {
|
|
41
|
+
for (let y = r.y0; y <= r.y1; y++) {
|
|
42
|
+
const t = { z: r.z, x, y };
|
|
43
|
+
if (tileInSource(t, src)) tiles.set(tileKey(src.id, t), { source: src.id, t });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Each zoom from 0 while the box fits in budget tiles
|
|
49
|
+
export function boxRanges(box, budget, maxzoom = BOX_MAX_ZOOM) {
|
|
50
|
+
const ranges = [];
|
|
51
|
+
for (let z = 0; z <= maxzoom; z++) {
|
|
52
|
+
const r = boxRange(box, z);
|
|
53
|
+
if (rangeCount(r) > budget) break;
|
|
54
|
+
ranges.push(r);
|
|
55
|
+
}
|
|
56
|
+
return ranges;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function deviceSquare({ lat, lon }, radius) {
|
|
60
|
+
const dLat = radius / METRES_PER_DEGREE;
|
|
61
|
+
const dLon = radius / (METRES_PER_DEGREE * Math.max(Math.cos((lat * Math.PI) / 180), 0.01));
|
|
62
|
+
return { south: lat - dLat, north: lat + dLat, west: lon - dLon, east: lon + dLon };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bounds(locs) {
|
|
66
|
+
return {
|
|
67
|
+
south: Math.min(...locs.map((l) => l.lat)), north: Math.max(...locs.map((l) => l.lat)),
|
|
68
|
+
west: Math.min(...locs.map((l) => l.lon)), east: Math.max(...locs.map((l) => l.lon)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const insideBounds = (l, b) => !b || ((l.lat >= b[0][0]) && (l.lat <= b[1][0]) && (l.lon >= b[0][1]) && (l.lon <= b[1][1]));
|
|
73
|
+
|
|
74
|
+
// locations: [{ org, project, node, lat, lon }], sources: enabledSources() ->
|
|
75
|
+
// Map 'source/z/x/y' -> { source, t: { z, x, y } }
|
|
76
|
+
export function wantedTiles(locations, settings, sources) {
|
|
77
|
+
const s = { ...PREFETCH_DEFAULTS, ...settings };
|
|
78
|
+
const tiles = new Map();
|
|
79
|
+
const groups = (keyOf) => locations.reduce((g, l) => { (g[keyOf(l)] = g[keyOf(l)] || []).push(l); return g; }, {});
|
|
80
|
+
Object.values(sources).forEach((src) => {
|
|
81
|
+
if (!src.bounds) {
|
|
82
|
+
Object.values(groups((l) => l.org)).forEach((locs) =>
|
|
83
|
+
boxRanges(bounds(locs), s.organizationtiles).forEach((r) => addRange(tiles, src, r)));
|
|
84
|
+
Object.values(groups((l) => `${l.org}/${l.project}`)).forEach((locs) =>
|
|
85
|
+
boxRanges(bounds(locs), s.projecttiles).forEach((r) => addRange(tiles, src, r)));
|
|
86
|
+
}
|
|
87
|
+
locations.filter((l) => insideBounds(l, src.bounds)).forEach((l) => {
|
|
88
|
+
const square = deviceSquare(l, s.deviceradius);
|
|
89
|
+
for (let z = src.minzoom; z <= Math.min(s.devicezoom, src.maxzoom); z++) addRange(tiles, src, boxRange(square, z));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return tiles;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// As the client decides it (core.js MqttTopicNode.location): a GPS with a fix wins over a set location
|
|
96
|
+
export function locationOf({ location, latitude, longitude, satellites }) {
|
|
97
|
+
let lat; let lon;
|
|
98
|
+
if ((Number(satellites) > 0) && Number.isFinite(Number(latitude)) && Number.isFinite(Number(longitude))) {
|
|
99
|
+
lat = Number(latitude); lon = Number(longitude);
|
|
100
|
+
} else if (typeof location === 'string') {
|
|
101
|
+
[lat, lon] = location.split(',').map(Number);
|
|
102
|
+
}
|
|
103
|
+
return (Number.isFinite(lat) && Number.isFinite(lon) && (Math.abs(lat) <= 90) && (Math.abs(lon) <= 180))
|
|
104
|
+
? { lat, lon } : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function loggerLocations(mqttLogger) {
|
|
108
|
+
const out = [];
|
|
109
|
+
Object.entries(mqttLogger.reportNodes()).forEach(([org, projects]) => {
|
|
110
|
+
Object.entries(projects).forEach(([project, nodes]) => {
|
|
111
|
+
Object.keys(nodes).forEach((node) => {
|
|
112
|
+
const v = (field) => mqttLogger.getPropertyValue(org, project, node, field);
|
|
113
|
+
const loc = locationOf({ location: v('frugal_iot/location'), latitude: v('gps/latitude'),
|
|
114
|
+
longitude: v('gps/longitude'), satellites: v('gps/satellites') });
|
|
115
|
+
if (loc) out.push({ org, project, node, ...loc });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// locations() is asked at each check. Runs when the locations change, and daily to refresh; a run
|
|
123
|
+
// with failures (offline, most likely) is tried again at the next check.
|
|
124
|
+
export function createPrefetch(cache, settings, locations, now = Date.now) {
|
|
125
|
+
const s = { ...PREFETCH_DEFAULTS, ...settings };
|
|
126
|
+
let wanted = new Map();
|
|
127
|
+
let done = { signature: null, at: 0 };
|
|
128
|
+
let running = false;
|
|
129
|
+
cache.setKeep((key) => wanted.has(key));
|
|
130
|
+
|
|
131
|
+
function check(cb = () => {}) {
|
|
132
|
+
if (running) return cb(null, null);
|
|
133
|
+
const locs = locations();
|
|
134
|
+
const tiles = wanted = wantedTiles(locs, s, cache.sources);
|
|
135
|
+
const signature = locs.map((l) => `${l.org}/${l.project}/${l.node}=${l.lat},${l.lon}`).sort().join(';');
|
|
136
|
+
if ((signature === done.signature) && ((now() - done.at) < DAY_MS)) return cb(null, null);
|
|
137
|
+
if (!Object.keys(cache.sources).some((id) => cache.online(id))) return cb(null, null);
|
|
138
|
+
running = true;
|
|
139
|
+
const counts = { kept: 0, fetched: 0, failed: 0 };
|
|
140
|
+
// Two at a time, so the stat calls stay few and a view never queues behind a thousand tiles
|
|
141
|
+
eachLimit([...tiles.values()], 2, (w, cb1) => cache.prefetch(w.source, w.t, (err, result) => { counts[result] += 1; cb1(); }), () => {
|
|
142
|
+
running = false;
|
|
143
|
+
if (!counts.failed) done = { signature, at: now() };
|
|
144
|
+
if (counts.fetched || counts.failed) {
|
|
145
|
+
console.log('Tile prefetch:', tiles.size, 'wanted,', counts.fetched, 'fetched,', counts.failed, 'failed');
|
|
146
|
+
}
|
|
147
|
+
cb(null, counts);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return { check };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function startPrefetch(cache, settings, locations) {
|
|
154
|
+
const p = createPrefetch(cache, settings, locations);
|
|
155
|
+
setTimeout(() => p.check(), FIRST_CHECK_MS).unref();
|
|
156
|
+
setInterval(() => p.check(), CHECK_MS).unref();
|
|
157
|
+
return p;
|
|
158
|
+
}
|