frugal-iot-server 2.0.10 → 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.
@@ -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
@@ -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
+ }
package/lib/tiles.js ADDED
@@ -0,0 +1,271 @@
1
+ /*
2
+ * Map tiles kept on this server (frugal-iot-client docs/MAPPING_PLAN.md, M-19 to M-30).
3
+ *
4
+ * The map asks for /tiles/<source>/z/x/y. A tile kept here is served from disk; one that is not is
5
+ * fetched from that source's upstream while it is reachable, and is a 404 at once while it is not.
6
+ * So a Pi that goes offline still shows whatever was looked at while it was online.
7
+ *
8
+ * A file's mtime is when the tile was last known to be current, so a check that finds it unchanged
9
+ * costs one metadata write, not a rewrite. Last use is kept in memory, never read from atime: a Pi
10
+ * is often mounted noatime to spare its card, and where it is not, atime is a write per view.
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { priorityQueue } from 'async';
16
+
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ // Waiting on a timeout for every tile would freeze the map, so one failure stands for a minute
19
+ const OFFLINE_RETRY_MS = 60 * 1000;
20
+ const FETCH_TIMEOUT_MS = 15 * 1000;
21
+ const CONCURRENT_FETCHES = 2; // OSM's tile usage policy
22
+ const VIEW = 0; // someone is looking - ahead of any prefetch
23
+ const PREFETCH = 1;
24
+ const MIN_REFRESH_DAYS = 7; // OSM's tile usage policy: never re-ask sooner than this
25
+ const EXTENSIONS = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp' };
26
+ const { version } = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
27
+
28
+ export const CACHE_DEFAULTS = {
29
+ dir: './tiles',
30
+ refreshdays: 30,
31
+ maxmb: 200,
32
+ userAgent: `frugal-iot-server/${version} (+https://github.com/mitra42/frugal-iot-server)`,
33
+ };
34
+ // What a mapping.yaml with no sources gets: the map as it always was
35
+ export const DEFAULT_SOURCES = {
36
+ osm: {
37
+ name: 'Map',
38
+ enabled: true,
39
+ upstream: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
40
+ attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
41
+ maxzoom: 19,
42
+ },
43
+ };
44
+
45
+ // The enabled sources, by id. The id becomes a directory and a URL segment, so it is held to letters
46
+ // and digits.
47
+ export function enabledSources(mapping) {
48
+ const sources = (mapping && mapping.sources) || DEFAULT_SOURCES;
49
+ const out = {};
50
+ Object.entries(sources).forEach(([id, src]) => {
51
+ if (!src || (src.enabled === false) || !/^[a-z0-9_-]{1,32}$/i.test(id) || !src.upstream) return;
52
+ out[id] = { id, name: src.name || id, minzoom: 0, maxzoom: 19, ...src };
53
+ });
54
+ return out;
55
+ }
56
+
57
+ // What the map is told about each source (M-27): through this server while it keeps tiles,
58
+ // straight from upstream while it does not
59
+ export function sourcesForBrowser(mapping) {
60
+ const cached = !!(mapping && mapping.cache && mapping.cache.enabled);
61
+ return Object.values(enabledSources(mapping)).map((s) => ({
62
+ id: s.id, name: s.name, attribution: s.attribution, minzoom: s.minzoom, maxzoom: s.maxzoom,
63
+ url: cached ? `/tiles/${s.id}/{z}/{x}/{y}` : s.upstream,
64
+ ...(s.bounds ? { bounds: s.bounds } : {}),
65
+ }));
66
+ }
67
+
68
+ const clamp = (v, z) => Math.min(Math.max(v, 0), (2 ** z) - 1);
69
+ export function tileX(lon, z) {
70
+ return clamp(Math.floor(((lon + 180) / 360) * (2 ** z)), z);
71
+ }
72
+ export function tileY(lat, z) {
73
+ const r = (lat * Math.PI) / 180;
74
+ return clamp(Math.floor(((1 - (Math.log(Math.tan(r) + (1 / Math.cos(r))) / Math.PI)) / 2) * (2 ** z)), z);
75
+ }
76
+
77
+ // Within the source's zooms, and touching its bounds (bounds: [[south, west], [north, east]])
78
+ export function tileInSource(t, src) {
79
+ if ((t.z < src.minzoom) || (t.z > src.maxzoom)) return false;
80
+ if (!src.bounds) return true;
81
+ const [[south, west], [north, east]] = src.bounds;
82
+ return (t.x >= tileX(west, t.z)) && (t.x <= tileX(east, t.z))
83
+ && (t.y >= tileY(north, t.z)) && (t.y <= tileY(south, t.z));
84
+ }
85
+
86
+ // z, x and y straight from the URL. Digits only, so none of it can name a path elsewhere.
87
+ export function parseTile(z, x, y, maxzoom = 22) {
88
+ const m = /^(\d{1,7})(\.(png|jpg|jpeg|webp))?$/.exec(String(y));
89
+ if (!m || !/^\d{1,2}$/.test(String(z)) || !/^\d{1,7}$/.test(String(x))) return null;
90
+ const t = { z: Number(z), x: Number(x), y: Number(m[1]) };
91
+ const n = 2 ** t.z;
92
+ return ((t.z <= maxzoom) && (t.x < n) && (t.y < n)) ? t : null;
93
+ }
94
+
95
+ export const tileKey = (id, t) => `${id}/${t.z}/${t.x}/${t.y}`;
96
+
97
+ // The network edge, replaced in tests. cb(err) only when upstream could not be reached at all.
98
+ function fetchUpstream(url, { since, userAgent }, cb) {
99
+ const headers = { 'User-Agent': userAgent };
100
+ if (since) headers['If-Modified-Since'] = since.toUTCString();
101
+ fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
102
+ .then((r) => (r.ok
103
+ ? r.arrayBuffer().then((b) => ({ status: r.status, type: r.headers.get('content-type'), body: Buffer.from(b) }))
104
+ : { status: r.status }))
105
+ .then((res) => cb(null, res), (err) => cb(err));
106
+ }
107
+
108
+ export function createTileCache(options = {}) {
109
+ const o = { ...CACHE_DEFAULTS, ...options };
110
+ const sources = o.sources ? enabledSources({ sources: o.sources }) : enabledSources(null);
111
+ const fetchTile = o.fetchTile || fetchUpstream;
112
+ const now = o.now || Date.now;
113
+ const refreshMs = Math.max(o.refreshdays, MIN_REFRESH_DAYS) * DAY_MS;
114
+ let keep = o.keep || (() => false); // keys the prefetch still wants are never evicted
115
+ const lastUsed = new Map(); // file -> ms
116
+ const waiting = new Map(); // key -> [cb], so two views of one tile fetch it once
117
+ const offlineUntil = {}; // by source: NSW being down does not stop OSM
118
+ const lastExt = {}; // by source, so a kept tile is usually found in one stat
119
+
120
+ const base = (id, t) => path.join(o.dir, id, String(t.z), String(t.x), String(t.y));
121
+ const urlOf = (src, t) => src.upstream.replace('{z}', t.z).replace('{x}', t.x).replace('{y}', t.y);
122
+ const online = (id) => now() >= (offlineUntil[id] || 0);
123
+
124
+ const fetches = priorityQueue((job, cb) => fetchTile(job.url, { since: job.since, userAgent: o.userAgent }, cb),
125
+ CONCURRENT_FETCHES);
126
+
127
+ // cb(null, {file, st}) for the kept file, whichever type it was stored as, or cb(null, null)
128
+ function find(id, t, cb) {
129
+ const exts = [...new Set([lastExt[id] || '.png', ...Object.values(EXTENSIONS)])];
130
+ let i = 0;
131
+ const next = () => {
132
+ if (i >= exts.length) return cb(null, null);
133
+ const file = base(id, t) + exts[i++];
134
+ fs.stat(file, (err, st) => {
135
+ if (err) return next();
136
+ lastExt[id] = path.extname(file);
137
+ cb(null, { file, st });
138
+ });
139
+ };
140
+ next();
141
+ }
142
+
143
+ function store(id, t, res, cb) {
144
+ const ext = EXTENSIONS[String(res.type).split(';')[0].trim()] || '.png';
145
+ const file = base(id, t) + ext;
146
+ const tmp = `${file}.tmp`; // renamed into place, so a half-written tile is never served
147
+ fs.mkdir(path.dirname(file), { recursive: true }, (err) => {
148
+ if (err) return cb(err);
149
+ fs.writeFile(tmp, res.body, (err1) => (err1 ? cb(err1) : fs.rename(tmp, file, (err2) => {
150
+ if (!err2) lastExt[id] = ext;
151
+ cb(err2, file);
152
+ })));
153
+ });
154
+ }
155
+
156
+ // cb(null, file) when the tile is now current, cb(null, null) when it is not
157
+ function refresh(src, t, kept, priority, cb) {
158
+ const key = tileKey(src.id, t);
159
+ if (!online(src.id)) return cb(null, null);
160
+ if (waiting.has(key)) { waiting.get(key).push(cb); return; }
161
+ waiting.set(key, [cb]);
162
+ const done = (err, file) => {
163
+ const cbs = waiting.get(key);
164
+ waiting.delete(key);
165
+ if (err) console.log('Tile', key, 'not kept:', err.message);
166
+ cbs.forEach((c) => c(null, err ? null : file));
167
+ };
168
+ fetches.push({ url: urlOf(src, t), since: kept && kept.st.mtime }, priority, (err, res) => {
169
+ if (err || (res.status === 429) || (res.status >= 500)) {
170
+ offlineUntil[src.id] = now() + OFFLINE_RETRY_MS;
171
+ return done(null, null);
172
+ }
173
+ if ((res.status === 304) && kept) {
174
+ const d = new Date(now());
175
+ return fs.utimes(kept.file, d, d, (err1) => done(err1, kept.file));
176
+ }
177
+ if (res.status === 200) return store(src.id, t, res, done);
178
+ done(null, null); // e.g. 404: upstream has no such tile
179
+ });
180
+ }
181
+
182
+ // cb(null, file) to serve, or cb(null, null) for a 404. A kept tile is served at once even when
183
+ // due a check - the check runs behind it, and offline a stale tile beats none.
184
+ function get(id, t, cb) {
185
+ const src = sources[id];
186
+ if (!src || !tileInSource(t, src)) return cb(null, null);
187
+ find(id, t, (err, kept) => {
188
+ if (kept) {
189
+ lastUsed.set(kept.file, now());
190
+ if ((now() - kept.st.mtimeMs) > refreshMs) refresh(src, t, kept, VIEW, () => {});
191
+ return cb(null, kept.file);
192
+ }
193
+ refresh(src, t, null, VIEW, (err1, file) => {
194
+ if (file) lastUsed.set(file, now());
195
+ cb(null, file);
196
+ });
197
+ });
198
+ }
199
+
200
+ // Fetch a tile nobody is looking at yet, if it is missing or due. cb(null, 'kept' | 'fetched' | 'failed')
201
+ function prefetch(id, t, cb) {
202
+ const src = sources[id];
203
+ if (!src || !tileInSource(t, src)) return cb(null, 'failed');
204
+ find(id, t, (err, kept) => {
205
+ if (kept && ((now() - kept.st.mtimeMs) <= refreshMs)) return cb(null, 'kept');
206
+ refresh(src, t, kept, PREFETCH, (err1, file) => cb(null, file ? 'fetched' : 'failed'));
207
+ });
208
+ }
209
+
210
+ function listFiles(cb) {
211
+ const found = [];
212
+ const walk = (d, cb1) => fs.readdir(d, { withFileTypes: true }, (err, entries) => {
213
+ if (err) return cb1(err.code === 'ENOENT' ? null : err);
214
+ let i = 0;
215
+ const next = (err1) => {
216
+ if (err1 || (i >= entries.length)) return cb1(err1);
217
+ const e = entries[i++];
218
+ const full = path.join(d, e.name);
219
+ if (e.isDirectory()) return walk(full, next);
220
+ fs.stat(full, (err2, st) => {
221
+ if (!err2) found.push({ file: full, size: st.size, used: lastUsed.get(full) || st.mtimeMs });
222
+ next();
223
+ });
224
+ };
225
+ next();
226
+ });
227
+ walk(o.dir, (err) => cb(err, found));
228
+ }
229
+ // dir/osm/15/26000/16900.png -> osm/15/26000/16900
230
+ const keyOfFile = (file) => path.relative(o.dir, file).split(path.sep).join('/').replace(/\.[a-z]+$/, '');
231
+
232
+ // Least recently used first, until under maxmb, across every source. After a restart, a tile not
233
+ // yet viewed counts as last used when it was last known current.
234
+ function evict(cb) {
235
+ listFiles((err, files) => {
236
+ if (err) return cb(err);
237
+ let total = files.reduce((s, f) => s + f.size, 0);
238
+ const limit = o.maxmb * 1024 * 1024;
239
+ const victims = [];
240
+ files.sort((a, b) => a.used - b.used);
241
+ for (const f of files) {
242
+ if (total <= limit) break;
243
+ if (keep(keyOfFile(f.file))) continue;
244
+ victims.push(f.file);
245
+ total -= f.size;
246
+ }
247
+ let i = 0;
248
+ const next = () => {
249
+ if (i >= victims.length) return cb(null, { removed: victims.length, bytes: total });
250
+ const file = victims[i++];
251
+ lastUsed.delete(file);
252
+ fs.unlink(file, () => next());
253
+ };
254
+ next();
255
+ });
256
+ }
257
+
258
+ return { get, prefetch, evict, online, sources, setKeep: (fn) => { keep = fn; }, dir: o.dir };
259
+ }
260
+
261
+ // Once a day, like the readings' housekeeping - first pass delayed past the busy startup
262
+ export function startTileHousekeeping(cache) {
263
+ const run = () => cache.evict((err, r) => {
264
+ if (err) console.error('Tile housekeeping:', err.message);
265
+ else if (r.removed) console.log('Tile housekeeping:', r.removed, 'tiles dropped');
266
+ });
267
+ setTimeout(run, 5 * 60 * 1000).unref();
268
+ const timer = setInterval(run, DAY_MS);
269
+ timer.unref();
270
+ return timer;
271
+ }
package/package.json CHANGED
@@ -12,8 +12,8 @@
12
12
  "cookie-parser": "^1.4.7",
13
13
  "express": "^5.2.1",
14
14
  "express-session": "^1.19.0",
15
- "frugal-iot-client": "^2.0.10",
16
- "frugal-iot-logger": "^2.0.10",
15
+ "frugal-iot-client": "^2.0.11",
16
+ "frugal-iot-logger": "^2.0.11",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
@@ -47,6 +47,7 @@
47
47
  "config.yaml",
48
48
  "config.d/email.yaml",
49
49
  "config.d/logger.yaml",
50
+ "config.d/mapping.yaml",
50
51
  "config.d/mqtt.yaml",
51
52
  "config.d/opensearch.yaml",
52
53
  "config.d/server.yaml",
@@ -94,7 +95,7 @@
94
95
  "check-files": "node scripts/check-published-files.js"
95
96
  },
96
97
  "type": "module",
97
- "version": "2.0.10",
98
+ "version": "2.0.11",
98
99
  "overrides": {
99
100
  "uuid": "^11.1.1"
100
101
  }
@@ -6,7 +6,7 @@
6
6
  // it, an installed browser keeps serving the files it already cached - and since the list now covers
7
7
  // the dashboard's own code, that means old JS behind freshly fetched HTML. Any edit to the list
8
8
  // needs this bumped, or it does not reach anyone who already has the old one.
9
- const CACHE_NAME = 'frugal-iot-cache-2.0.10';
9
+ const CACHE_NAME = 'frugal-iot-cache-2.0.11';
10
10
  /*
11
11
  * Everything the app needs, so that a phone on a poor or expensive link fetches it once.
12
12
  *
package/scripts/init.zsh CHANGED
@@ -60,7 +60,7 @@ copy_if_missing() {
60
60
 
61
61
  echo "Configuration:"
62
62
  copy_if_missing "${PKG}/config.yaml" "./config.yaml"
63
- for f in logger.yaml mqtt.yaml server.yaml email.yaml; do
63
+ for f in logger.yaml mqtt.yaml server.yaml email.yaml mapping.yaml; do
64
64
  copy_if_missing "${PKG}/config.d/${f}" "./config.d/${f}"
65
65
  done
66
66
  # The schema describes the sensor types the software understands, so a release changing it matters
@@ -635,6 +635,9 @@ CODE=""; i=0
635
635
  while [ "$i" -lt 20 ]; do
636
636
  CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://localhost:${PORT}/" || true)
637
637
  [ "$CODE" = 200 ] && break
638
+ # "/" is the static front page; the login page is the thing that proves the dashboard is served
639
+ CODE2=$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://localhost:${PORT}/dashboard/login.html" || true)
640
+ [ "$CODE2" = 200 ] && { CODE=200; break; }
638
641
  sleep 2; i=$((i+1))
639
642
  done
640
643
  if [ "$CODE" = 200 ]; then
@@ -344,7 +344,8 @@ fi
344
344
  # ---------------------------------------------------------------- ports
345
345
  head_ "Ports"
346
346
  for port in 1883 9012 8080; do
347
- if netstat -ln 2>/dev/null | grep -q "[:.]${port} "; then warn "port ${port} is already in use here - something else is listening on it"
347
+ if netstat -lnp 2>/dev/null | grep "[:.]${port} " | grep -q mosquitto; then info "port ${port} is in use by mosquitto itself (fine)"
348
+ elif netstat -ln 2>/dev/null | grep -q "[:.]${port} "; then warn "port ${port} is already in use here - something else is listening on it"
348
349
  else info "port ${port} is free"; fi
349
350
  done
350
351