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.
- package/config.d/mapping.yaml +56 -0
- package/config.d/schema/modules.yaml +1 -0
- package/config.d/schema/topics.yaml +10 -0
- 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 +4 -3
- package/public/service-worker.js +1 -1
- package/scripts/init.zsh +1 -1
- package/scripts/install-openwrt.sh +3 -0
- package/scripts/openwrt-preflight.sh +2 -1
|
@@ -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/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
|
+
}
|
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: '© <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.
|
|
16
|
-
"frugal-iot-logger": "^2.0.
|
|
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.
|
|
98
|
+
"version": "2.0.11",
|
|
98
99
|
"overrides": {
|
|
99
100
|
"uuid": "^11.1.1"
|
|
100
101
|
}
|
package/public/service-worker.js
CHANGED
|
@@ -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.
|
|
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 -
|
|
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
|
|