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.
@@ -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
@@ -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
@@ -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
@@ -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: keep a copy of `config.d/` and
1168
- `frugal-iot.db` somewhere else, and treat the readings in `data/` as valuable but not irreplaceable.
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
- to the Pi and run `zsh diagnostic.zsh` instead.
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 files you already have, so your settings, database and data are safe.
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. Two sections of its output are worth reading.
1468
+ `frugal-iot-init` leaves your own files alone, with two exceptions, and tells you what differs.
1381
1469
 
1382
- **"These files were left as you have them, but this release ships a different version"** covers the
1383
- sensor schema under `config.d/schema/`, and gives you a `diff` command for each. If you added a
1384
- sensor type of your own, that is exactly what it should say; if you did not, this release changed
1385
- the schema and the difference is worth reading.
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
- **"Installed elsewhere from extras/, and older than this release"** covers the files that live
1388
- somewhere else — the broker configuration, its ACL, the systemd unit. It compares what is actually
1389
- in `/etc`, not the copy in `extras/`, because that copy is never overwritten and so differs for ever
1390
- once a release changes it. To bring anything it names up to date, re-run the installer:
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. On a Pi installed before the setup network existed,
1407
- re-running the installer adds it, and asks once for the `pi` login's password to give it.
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 — then
1415
- > `frugal-iot-init` reports those files under "Nothing at the usual place, so these were not
1416
- > compared", and re-running the installer is *not* what you want: it would add `frugal-iot.conf`
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 two files above and copy across the settings you want.
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 (256 MB at the very least) | The server uses about 250 MB once running. |
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. |
@@ -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
- // Just log the request for now
949
- // noinspection JSCheckFunctionSignatures
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
@@ -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
+ }