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.
@@ -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: '&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.9",
16
- "frugal-iot-logger": "^2.0.9",
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.9",
98
+ "version": "2.0.11",
97
99
  "overrides": {
98
100
  "uuid": "^11.1.1"
99
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.9';
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
  });
@@ -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 chown "${MOSQUITTO_PASSWD_OWNER}:${MOSQUITTO_PASSWD_GROUP}" "$MOSQUITTO_PASSWD_FILE"
140
- [[ -n "$MOSQUITTO_PASSWD_MODE" ]] && sudo chmod "$MOSQUITTO_PASSWD_MODE" "$MOSQUITTO_PASSWD_FILE"
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 ----
@@ -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
@@ -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 "$from" "$to"
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