frugal-iot-server 2.0.9 → 2.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/config.d/email.yaml +1 -1
- package/config.d/mapping.yaml +56 -0
- package/config.d/opensearch.yaml +2 -2
- package/config.d/schema/modules.yaml +14 -0
- package/config.d/schema/topics.yaml +60 -0
- package/docs/OPENWRT.md +208 -0
- package/extras/frugaliot.service +1 -1
- package/extras/mosquitto.conf +1 -1
- package/frugal-iot-createdb.sql +10 -1
- package/frugal-iot-server.js +51 -12
- package/lib/config-for-user.js +6 -0
- package/lib/grafana.js +2 -2
- package/lib/language-requests.js +50 -0
- package/lib/tile-prefetch.js +158 -0
- package/lib/tiles.js +271 -0
- package/package.json +8 -6
- package/public/service-worker.js +10 -2
- package/scripts/addbridge-pi.zsh +1 -1
- package/scripts/addbridge-prod.zsh +2 -2
- package/scripts/addorganization.zsh +11 -2
- package/scripts/diagnostic.zsh +4 -4
- package/scripts/grafana-token.js +1 -1
- package/scripts/init.zsh +6 -2
- package/scripts/install-openwrt.sh +712 -0
- package/scripts/install-pi.sh +46 -12
- package/scripts/openwrt-preflight.sh +364 -0
- /package/{GRAFANA.md → docs/GRAFANA.md} +0 -0
- /package/{INSTALL-OPENSEARCH.md → docs/INSTALL-OPENSEARCH.md} +0 -0
- /package/{INSTALLATION.md → docs/INSTALLATION.md} +0 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which languages people's browsers ask for, counted at each sign-in, so the next language to
|
|
3
|
+
* translate is the one most asked for (frugal-iot-client docs/LANGUAGES_PLAN.md, Phase 3). Codes
|
|
4
|
+
* only - nothing records who asked.
|
|
5
|
+
*
|
|
6
|
+
* To read it: SELECT * FROM language_requests ORDER BY supported, count DESC;
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
// A browser sends a handful; anything longer is not a browser
|
|
12
|
+
const MAX_CODES = 10;
|
|
13
|
+
const CODE = /^[a-z]{2,3}(-[a-z0-9]{1,8}){0,3}$/;
|
|
14
|
+
// As languageAliases in the client's core.js: browsers call Tagalog "fil", and its file is tl.yaml
|
|
15
|
+
const ALIASES = { fil: 'tl' };
|
|
16
|
+
|
|
17
|
+
// The form field is untrusted: keep only what looks like a language tag, lower-cased, once each
|
|
18
|
+
export function parseBrowserLanguages(value) {
|
|
19
|
+
if (typeof value !== 'string') return [];
|
|
20
|
+
const codes = value.split(',').map((c) => c.trim().toLowerCase()).filter((c) => CODE.test(c));
|
|
21
|
+
return [...new Set(codes)].slice(0, MAX_CODES);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The client has a file per language it supports, so whether one exists is the answer, and the
|
|
25
|
+
// server keeps no list of its own. fr-CA is served by fr, as the client matches it; English is the
|
|
26
|
+
// keys themselves, so it has no file.
|
|
27
|
+
export function isSupported(htmldir, code) {
|
|
28
|
+
const primary = ALIASES[code.split('-')[0]] || code.split('-')[0];
|
|
29
|
+
if (primary === 'en') return true;
|
|
30
|
+
return [code, primary].some((c) => existsSync(join(htmldir, 'languages', `${c}.yaml`)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One upsert per code. cb(err) - a failure to count must never stand in the way of a login
|
|
34
|
+
export function countLanguages(db, htmldir, codes, cb = () => {}) {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
let pending = codes.length;
|
|
37
|
+
let failed = null;
|
|
38
|
+
if (!pending) { cb(null); return; }
|
|
39
|
+
codes.forEach((code) => {
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO language_requests (code, count, last_seen, supported) VALUES (?, 1, ?, ?)
|
|
42
|
+
ON CONFLICT(code) DO UPDATE SET count = count + 1, last_seen = excluded.last_seen,
|
|
43
|
+
supported = excluded.supported`,
|
|
44
|
+
[code, now, isSupported(htmldir, code) ? 1 : 0],
|
|
45
|
+
(err) => {
|
|
46
|
+
failed = failed || err;
|
|
47
|
+
if (--pending === 0) cb(failed);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -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",
|
|
@@ -64,9 +65,10 @@
|
|
|
64
65
|
"extras/frugaliot-ap-watchdog.service",
|
|
65
66
|
"extras/frugaliot-ap-watchdog.timer",
|
|
66
67
|
"API.md",
|
|
67
|
-
"GRAFANA.md",
|
|
68
|
-
"INSTALL-OPENSEARCH.md",
|
|
69
|
-
"INSTALLATION.md"
|
|
68
|
+
"docs/GRAFANA.md",
|
|
69
|
+
"docs/INSTALL-OPENSEARCH.md",
|
|
70
|
+
"docs/INSTALLATION.md",
|
|
71
|
+
"docs/OPENWRT.md"
|
|
70
72
|
],
|
|
71
73
|
"devDependencies": {
|
|
72
74
|
"supertest": "^7.2.2",
|
|
@@ -93,7 +95,7 @@
|
|
|
93
95
|
"check-files": "node scripts/check-published-files.js"
|
|
94
96
|
},
|
|
95
97
|
"type": "module",
|
|
96
|
-
"version": "2.0.
|
|
98
|
+
"version": "2.0.11",
|
|
97
99
|
"overrides": {
|
|
98
100
|
"uuid": "^11.1.1"
|
|
99
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
|
*
|
|
@@ -100,6 +100,14 @@ self.addEventListener('fetch', event => {
|
|
|
100
100
|
// Scoped to CACHE_NAME: a bare caches.match searches every cache in the origin, so a
|
|
101
101
|
// leftover older cache answers first and the version bump achieves nothing
|
|
102
102
|
caches.match(event.request, {cacheName: CACHE_NAME})
|
|
103
|
-
.then(response => response || fetch(event.request)
|
|
103
|
+
.then(response => response || fetch(event.request).then(fetched => {
|
|
104
|
+
// A language is cached when first used, not at install: there are many, and a
|
|
105
|
+
// phone wants one
|
|
106
|
+
if (fetched.ok && new URL(event.request.url).pathname.startsWith('/dashboard/languages/')) {
|
|
107
|
+
const copy = fetched.clone();
|
|
108
|
+
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
|
|
109
|
+
}
|
|
110
|
+
return fetched;
|
|
111
|
+
}))
|
|
104
112
|
);
|
|
105
113
|
});
|
package/scripts/addbridge-pi.zsh
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# After this, the Pi's readings also appear on the production server, and its nodes can be
|
|
6
6
|
# controlled from there - while the Pi carries on recording and serving on its own whenever the
|
|
7
7
|
# link is down. Readings taken during an outage stay on the Pi: production gets a gap rather than
|
|
8
|
-
# a backlog delivered later with the wrong timestamps. INSTALLATION.md step 11 explains why.
|
|
8
|
+
# a backlog delivered later with the wrong timestamps. docs/INSTALLATION.md step 11 explains why.
|
|
9
9
|
#
|
|
10
10
|
# The relaying is done by the broker, so the server, the logger and the nodes are untouched.
|
|
11
11
|
#
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
# printed - it is needed once, to type into the Pi.
|
|
25
25
|
#
|
|
26
26
|
# This assumes the server has already been set up to accept bridges at all: a TLS listener on 8883
|
|
27
|
-
# with a certificate. That is a one-off, and INSTALLATION.md step 11 covers it.
|
|
27
|
+
# with a certificate. That is a one-off, and docs/INSTALLATION.md step 11 covers it.
|
|
28
28
|
|
|
29
29
|
set -euo pipefail
|
|
30
30
|
|
|
@@ -255,7 +255,7 @@ else
|
|
|
255
255
|
echo ""
|
|
256
256
|
echo "NOTE: this broker has no acl_file, so ${ACCOUNT} can reach every topic on it, not just"
|
|
257
257
|
echo " ${ORG_ID}/#. That is how the broker already treats every other account, so this is no"
|
|
258
|
-
echo " worse than what is there - but see INSTALLATION.md step 11 for restricting it."
|
|
258
|
+
echo " worse than what is there - but see docs/INSTALLATION.md step 11 for restricting it."
|
|
259
259
|
fi
|
|
260
260
|
|
|
261
261
|
# password_file and acl_file are both re-read on a reload, so nothing needs to be disconnected.
|
|
@@ -133,11 +133,20 @@ if [[ ! -w "$MOSQUITTO_PASSWD_DIR" || ( -e "$MOSQUITTO_PASSWD_FILE" && ! -w "$MO
|
|
|
133
133
|
fi
|
|
134
134
|
fi
|
|
135
135
|
fi
|
|
136
|
+
# Run as root there is no sudo to go through (OpenWrt has none, and runs everything as root), and
|
|
137
|
+
# mosquitto_passwd then rewrites the file as root - which the broker, running as its own account,
|
|
138
|
+
# cannot read. So when root is doing it and somebody else owns the file, hand it back afterwards.
|
|
139
|
+
if (( EUID == 0 )) && [[ -n "$MOSQUITTO_PASSWD_OWNER" && "$MOSQUITTO_PASSWD_OWNER" != "root" ]]; then
|
|
140
|
+
MOSQUITTO_RESTORE_OWNER=1
|
|
141
|
+
fi
|
|
136
142
|
# Called after any successful mosquitto_passwd run below
|
|
137
143
|
restore_passwd_owner() {
|
|
138
144
|
(( MOSQUITTO_RESTORE_OWNER )) || return 0
|
|
139
|
-
sudo
|
|
140
|
-
|
|
145
|
+
local SUDO=(sudo)
|
|
146
|
+
(( EUID == 0 )) && SUDO=()
|
|
147
|
+
"${SUDO[@]}" chown "${MOSQUITTO_PASSWD_OWNER}:${MOSQUITTO_PASSWD_GROUP}" "$MOSQUITTO_PASSWD_FILE"
|
|
148
|
+
[[ -n "$MOSQUITTO_PASSWD_MODE" ]] && "${SUDO[@]}" chmod "$MOSQUITTO_PASSWD_MODE" "$MOSQUITTO_PASSWD_FILE"
|
|
149
|
+
return 0
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
# ---- Escaping helpers ----
|
package/scripts/diagnostic.zsh
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env zsh
|
|
2
2
|
#
|
|
3
|
-
# Report the state of a Frugal IoT installation, for working out why something in INSTALLATION.md
|
|
3
|
+
# Report the state of a Frugal IoT installation, for working out why something in docs/INSTALLATION.md
|
|
4
4
|
# did not do what it said it would. It only looks - it changes nothing.
|
|
5
5
|
#
|
|
6
6
|
# Run it from the directory the server is installed in (the one holding frugal-iot.db):
|
|
@@ -85,7 +85,7 @@ for c in node npm sqlite3 zsh mosquitto mosquitto_passwd mosquitto_sub; do
|
|
|
85
85
|
esac
|
|
86
86
|
else
|
|
87
87
|
item "$c: NOT INSTALLED"
|
|
88
|
-
[[ $c == (node|npm|sqlite3) ]] && problem "$c is not installed - see INSTALLATION.md step 3"
|
|
88
|
+
[[ $c == (node|npm|sqlite3) ]] && problem "$c is not installed - see docs/INSTALLATION.md step 3"
|
|
89
89
|
fi
|
|
90
90
|
done
|
|
91
91
|
|
|
@@ -355,7 +355,7 @@ broker_setting() { # name -> value from the broker's own configuration, sudo on
|
|
|
355
355
|
broker_setting acl_file; ACLFILE="$BROKER_SETTING"
|
|
356
356
|
if [[ -z "$ACLFILE" ]]; then
|
|
357
357
|
item "acl_file: NOT SET - every account that can log in can reach every topic, across all organizations"
|
|
358
|
-
problem "The broker has no acl_file. Re-run the install script, or see INSTALLATION.md 'Turn on access control'"
|
|
358
|
+
problem "The broker has no acl_file. Re-run the install script, or see docs/INSTALLATION.md 'Turn on access control'"
|
|
359
359
|
else
|
|
360
360
|
item "acl_file: $ACLFILE"
|
|
361
361
|
if [[ -r "$ACLFILE" ]] || { have sudo && sudo -n test -r "$ACLFILE" 2>/dev/null; }; then
|
|
@@ -608,7 +608,7 @@ else
|
|
|
608
608
|
fi
|
|
609
609
|
|
|
610
610
|
# Swapping to an SD card wears it fast. On a Pi 4 there should be little or none; a Pi Zero W may
|
|
611
|
-
# need some. See INSTALLATION.md, "Wear and tear on the SD card".
|
|
611
|
+
# need some. See docs/INSTALLATION.md, "Wear and tear on the SD card".
|
|
612
612
|
if have free; then
|
|
613
613
|
item "swap: $(free -h | awk '/^Swap:/ {print $3 " used of " $2}')"
|
|
614
614
|
fi
|
package/scripts/grafana-token.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
|
-
* Issue, list or revoke the tokens Grafana uses to read an organization's history (GRAFANA.md).
|
|
3
|
+
* Issue, list or revoke the tokens Grafana uses to read an organization's history (docs/GRAFANA.md).
|
|
4
4
|
*
|
|
5
5
|
* npx --no frugal-iot-grafana-token add <org> <username> <name> prints the token - once, it is not kept
|
|
6
6
|
* npx --no frugal-iot-grafana-token list <org>
|
package/scripts/init.zsh
CHANGED
|
@@ -48,7 +48,11 @@ copy_if_missing() {
|
|
|
48
48
|
fi
|
|
49
49
|
elif [[ -e "$from" ]]; then
|
|
50
50
|
mkdir -p "${to:h}"
|
|
51
|
-
cp
|
|
51
|
+
# extras/ holds a directory (grafana/) as well as files, and a plain cp refuses a directory -
|
|
52
|
+
# which under "set -e" ended the whole init, after server.yaml had been created but before it
|
|
53
|
+
# was pointed at node_modules. The re-run then saw an existing server.yaml and left it alone, so
|
|
54
|
+
# "/" 404ed. -R for a directory, so a failure here cannot strand a half-initialised install.
|
|
55
|
+
if [[ -d "$from" ]]; then cp -R "$from" "$to"; else cp "$from" "$to"; fi
|
|
52
56
|
COPIED+=("$to")
|
|
53
57
|
echo " created ${to}"
|
|
54
58
|
fi
|
|
@@ -56,7 +60,7 @@ copy_if_missing() {
|
|
|
56
60
|
|
|
57
61
|
echo "Configuration:"
|
|
58
62
|
copy_if_missing "${PKG}/config.yaml" "./config.yaml"
|
|
59
|
-
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
|
|
60
64
|
copy_if_missing "${PKG}/config.d/${f}" "./config.d/${f}"
|
|
61
65
|
done
|
|
62
66
|
# The schema describes the sensor types the software understands, so a release changing it matters
|