signalk-chiplog 1.0.0
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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/index.js +327 -0
- package/lib/api.js +427 -0
- package/lib/database.js +264 -0
- package/lib/detection.js +407 -0
- package/lib/entries.js +345 -0
- package/lib/errors.js +22 -0
- package/lib/event-watcher.js +332 -0
- package/lib/events.js +287 -0
- package/lib/export.js +220 -0
- package/lib/formats.js +215 -0
- package/lib/manoeuvre-types.js +84 -0
- package/lib/observation-recorder.js +152 -0
- package/lib/place-names.js +208 -0
- package/lib/places.js +128 -0
- package/lib/propulsion-detector.js +156 -0
- package/lib/propulsion.js +75 -0
- package/lib/rows.js +15 -0
- package/lib/track-recorder.js +149 -0
- package/lib/track.js +58 -0
- package/lib/usb-scheduler.js +131 -0
- package/lib/validation.js +105 -0
- package/package.json +67 -0
- package/public/app.css +603 -0
- package/public/entry/entry.css +528 -0
- package/public/entry/icons/apple-touch-icon.png +0 -0
- package/public/entry/icons/icon-192.png +0 -0
- package/public/entry/icons/icon-512.png +0 -0
- package/public/entry/index.html +22 -0
- package/public/entry/js/access.mjs +116 -0
- package/public/entry/js/clock.mjs +29 -0
- package/public/entry/js/components/AccessGate.mjs +70 -0
- package/public/entry/js/components/Dialogs.mjs +64 -0
- package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
- package/public/entry/js/components/NotePanel.mjs +34 -0
- package/public/entry/js/components/RecentList.mjs +113 -0
- package/public/entry/js/components/SketchPanel.mjs +181 -0
- package/public/entry/js/components/StatusHeader.mjs +69 -0
- package/public/entry/js/journal.mjs +139 -0
- package/public/entry/js/main.mjs +360 -0
- package/public/entry/js/outbox.mjs +124 -0
- package/public/entry/js/strokes.mjs +81 -0
- package/public/entry/manifest.webmanifest +26 -0
- package/public/entry/sw.js +75 -0
- package/public/icon.svg +7 -0
- package/public/index.html +18 -0
- package/public/js/api.mjs +85 -0
- package/public/js/auth.mjs +41 -0
- package/public/js/components/ExportView.mjs +182 -0
- package/public/js/components/LogView.mjs +132 -0
- package/public/js/components/PassageView.mjs +343 -0
- package/public/js/components/PropulsionStrip.mjs +60 -0
- package/public/js/components/StatusBar.mjs +55 -0
- package/public/js/components/Timeline.mjs +226 -0
- package/public/js/components/TrackMap.mjs +85 -0
- package/public/js/components/common.mjs +70 -0
- package/public/js/context.mjs +56 -0
- package/public/js/days.mjs +56 -0
- package/public/js/format.mjs +84 -0
- package/public/js/i18n.mjs +480 -0
- package/public/js/ids.mjs +10 -0
- package/public/js/main.mjs +48 -0
- package/public/js/status.mjs +21 -0
- package/public/vendor/htm-LICENSE +202 -0
- package/public/vendor/leaflet/LICENSE +26 -0
- package/public/vendor/leaflet/images/layers-2x.png +0 -0
- package/public/vendor/leaflet/images/layers.png +0 -0
- package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
- package/public/vendor/leaflet/images/marker-icon.png +0 -0
- package/public/vendor/leaflet/images/marker-shadow.png +0 -0
- package/public/vendor/leaflet/leaflet.css +661 -0
- package/public/vendor/leaflet/leaflet.js +6 -0
- package/public/vendor/preact-LICENSE +21 -0
- package/public/vendor/preact-htm.mjs +1 -0
package/lib/formats.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Rendering for human-facing exports. This is the presentation layer the data
|
|
2
|
+
// model defers unit conversion to: the database stays in Signal K SI units.
|
|
3
|
+
|
|
4
|
+
const KNOTS_PER_MPS = 3600 / 1852;
|
|
5
|
+
const METRES_PER_NM = 1852;
|
|
6
|
+
|
|
7
|
+
function convert(value, fn, digits) {
|
|
8
|
+
return value === null || value === undefined ? null : Number(fn(value).toFixed(digits));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const radiansToDegrees = (rad) => (rad * 180) / Math.PI;
|
|
12
|
+
const wrap360 = (degrees) => ((degrees % 360) + 360) % 360;
|
|
13
|
+
|
|
14
|
+
const toKnots = (value) => convert(value, (mps) => mps * KNOTS_PER_MPS, 1);
|
|
15
|
+
|
|
16
|
+
function toBearing(value) {
|
|
17
|
+
const degrees = convert(value, (rad) => wrap360(radiansToDegrees(rad)), 0);
|
|
18
|
+
return degrees === 360 ? 0 : degrees;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const toSignedAngle = (value) =>
|
|
22
|
+
convert(value, (rad) => wrap360(radiansToDegrees(rad) + 180) - 180, 0);
|
|
23
|
+
const toHectopascals = (value) => convert(value, (pa) => pa / 100, 1);
|
|
24
|
+
const toCelsius = (value) => convert(value, (k) => k - 273.15, 1);
|
|
25
|
+
const toNauticalMiles = (value) => convert(value, (m) => m / METRES_PER_NM, 1);
|
|
26
|
+
const toHours = (value) => convert(value, (s) => s / 3600, 1);
|
|
27
|
+
const toMetres = (value) => convert(value, (m) => m, 1);
|
|
28
|
+
const toCoordinate = (value) => convert(value, (deg) => deg, 6);
|
|
29
|
+
|
|
30
|
+
function toGeoJson(entry, trackPoints) {
|
|
31
|
+
const coordinates = trackPoints.map((point) => [point.lon, point.lat]);
|
|
32
|
+
let geometry = null;
|
|
33
|
+
if (coordinates.length === 1) {
|
|
34
|
+
geometry = { type: 'Point', coordinates: coordinates[0] };
|
|
35
|
+
} else if (coordinates.length > 1) {
|
|
36
|
+
geometry = { type: 'LineString', coordinates };
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
type: 'Feature',
|
|
40
|
+
geometry,
|
|
41
|
+
properties: {
|
|
42
|
+
entryId: entry.id,
|
|
43
|
+
startTime: entry.startTime,
|
|
44
|
+
endTime: entry.endTime,
|
|
45
|
+
startPlaceName: entry.startPlaceName,
|
|
46
|
+
endPlaceName: entry.endPlaceName,
|
|
47
|
+
coordTimes: trackPoints.map((point) => point.time)
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const XML_ESCAPES = { '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' };
|
|
53
|
+
|
|
54
|
+
function escapeXml(value) {
|
|
55
|
+
return String(value).replace(/[<>&'"]/g, (char) => XML_ESCAPES[char]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function trackName(entry) {
|
|
59
|
+
const places = [entry.startPlaceName, entry.endPlaceName].filter(Boolean).join(' - ');
|
|
60
|
+
return places || `Passage of ${entry.startTime.slice(0, 10)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toGpx(bundles) {
|
|
64
|
+
const tracks = bundles.map(({ entry, trackPoints }) => {
|
|
65
|
+
const points = trackPoints
|
|
66
|
+
.map(
|
|
67
|
+
(point) =>
|
|
68
|
+
` <trkpt lat="${point.lat}" lon="${point.lon}"><time>${point.time}</time></trkpt>`
|
|
69
|
+
)
|
|
70
|
+
.join('\n');
|
|
71
|
+
return [
|
|
72
|
+
' <trk>',
|
|
73
|
+
` <name>${escapeXml(trackName(entry))}</name>`,
|
|
74
|
+
' <trkseg>',
|
|
75
|
+
points,
|
|
76
|
+
' </trkseg>',
|
|
77
|
+
' </trk>'
|
|
78
|
+
]
|
|
79
|
+
.filter((line) => line !== '')
|
|
80
|
+
.join('\n');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return [
|
|
84
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
85
|
+
'<gpx version="1.1" creator="signalk-chiplog" xmlns="http://www.topografix.com/GPX/1/1">',
|
|
86
|
+
...tracks,
|
|
87
|
+
'</gpx>',
|
|
88
|
+
''
|
|
89
|
+
].join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const CSV_COLUMNS = [
|
|
93
|
+
'entry_id',
|
|
94
|
+
'time_utc',
|
|
95
|
+
'kind',
|
|
96
|
+
'type',
|
|
97
|
+
'subtype',
|
|
98
|
+
'place',
|
|
99
|
+
'lat',
|
|
100
|
+
'lon',
|
|
101
|
+
'sog_kn',
|
|
102
|
+
'cog_deg',
|
|
103
|
+
'heading_deg',
|
|
104
|
+
'stw_kn',
|
|
105
|
+
'tws_kn',
|
|
106
|
+
'twd_deg',
|
|
107
|
+
'aws_kn',
|
|
108
|
+
'awa_deg',
|
|
109
|
+
'depth_m',
|
|
110
|
+
'pressure_hpa',
|
|
111
|
+
'air_temp_c',
|
|
112
|
+
'water_temp_c',
|
|
113
|
+
'trip_log_nm',
|
|
114
|
+
'engine_runtime_h',
|
|
115
|
+
'comment'
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const KIND_ORDER = { departure: 0, observation: 1, event: 2, arrival: 3 };
|
|
119
|
+
|
|
120
|
+
function csvCell(value) {
|
|
121
|
+
if (value === null || value === undefined) {
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
if (typeof value !== 'string') {
|
|
125
|
+
return String(value);
|
|
126
|
+
}
|
|
127
|
+
// Spreadsheets execute cells starting with these characters as formulas;
|
|
128
|
+
// comments and place names are free text typed by the crew.
|
|
129
|
+
const text = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
|
|
130
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function positionColumns(position) {
|
|
134
|
+
return {
|
|
135
|
+
lat: toCoordinate(position?.lat),
|
|
136
|
+
lon: toCoordinate(position?.lon)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// One chronological line per departure, observation, event and arrival: the
|
|
141
|
+
// shape of a paper logbook, readable without the plugin.
|
|
142
|
+
function toCsv(bundles) {
|
|
143
|
+
const rows = [];
|
|
144
|
+
|
|
145
|
+
for (const { entry, observations, events } of bundles) {
|
|
146
|
+
rows.push({
|
|
147
|
+
entry_id: entry.id,
|
|
148
|
+
time_utc: entry.startTime,
|
|
149
|
+
kind: 'departure',
|
|
150
|
+
place: entry.startPlaceName,
|
|
151
|
+
...positionColumns(entry.startPosition)
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
for (const observation of observations) {
|
|
155
|
+
rows.push({
|
|
156
|
+
entry_id: entry.id,
|
|
157
|
+
time_utc: observation.time,
|
|
158
|
+
kind: 'observation',
|
|
159
|
+
type: observation.reason,
|
|
160
|
+
...positionColumns(observation.position),
|
|
161
|
+
sog_kn: toKnots(observation.sog),
|
|
162
|
+
cog_deg: toBearing(observation.cog),
|
|
163
|
+
heading_deg: toBearing(observation.heading),
|
|
164
|
+
stw_kn: toKnots(observation.stw),
|
|
165
|
+
tws_kn: toKnots(observation.tws),
|
|
166
|
+
twd_deg: toBearing(observation.twd),
|
|
167
|
+
aws_kn: toKnots(observation.aws),
|
|
168
|
+
awa_deg: toSignedAngle(observation.awa),
|
|
169
|
+
depth_m: toMetres(observation.depth),
|
|
170
|
+
pressure_hpa: toHectopascals(observation.pressure),
|
|
171
|
+
air_temp_c: toCelsius(observation.airTemp),
|
|
172
|
+
water_temp_c: toCelsius(observation.waterTemp),
|
|
173
|
+
trip_log_nm: toNauticalMiles(observation.tripLog),
|
|
174
|
+
engine_runtime_h: toHours(observation.engineRuntime)
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const event of events) {
|
|
179
|
+
rows.push({
|
|
180
|
+
entry_id: entry.id,
|
|
181
|
+
time_utc: event.time,
|
|
182
|
+
kind: 'event',
|
|
183
|
+
type: event.type,
|
|
184
|
+
subtype: event.subtype,
|
|
185
|
+
...positionColumns(event.position),
|
|
186
|
+
comment: event.comment
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (entry.endTime) {
|
|
191
|
+
rows.push({
|
|
192
|
+
entry_id: entry.id,
|
|
193
|
+
time_utc: entry.endTime,
|
|
194
|
+
kind: 'arrival',
|
|
195
|
+
place: entry.endPlaceName,
|
|
196
|
+
...positionColumns(entry.endPosition)
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
rows.sort(
|
|
202
|
+
(a, b) =>
|
|
203
|
+
a.time_utc.localeCompare(b.time_utc) ||
|
|
204
|
+
a.entry_id - b.entry_id ||
|
|
205
|
+
KIND_ORDER[a.kind] - KIND_ORDER[b.kind]
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const lines = [CSV_COLUMNS.join(',')];
|
|
209
|
+
for (const row of rows) {
|
|
210
|
+
lines.push(CSV_COLUMNS.map((column) => csvCell(row[column])).join(','));
|
|
211
|
+
}
|
|
212
|
+
return `${lines.join('\r\n')}\r\n`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { toGeoJson, toGpx, toCsv };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const { badRequest, conflict, notFound } = require('./errors');
|
|
2
|
+
const { paginate } = require('./rows');
|
|
3
|
+
|
|
4
|
+
const KEY_PATTERN = /^[a-z0-9_]{1,40}$/;
|
|
5
|
+
|
|
6
|
+
function toManoeuvreType(row) {
|
|
7
|
+
return {
|
|
8
|
+
key: row.key,
|
|
9
|
+
label: row.label,
|
|
10
|
+
icon: row.icon,
|
|
11
|
+
sortOrder: row.sort_order,
|
|
12
|
+
builtin: row.builtin === 1,
|
|
13
|
+
enabled: row.enabled === 1
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function requireTypeRow(db, key) {
|
|
18
|
+
const row = db.prepare('SELECT * FROM manoeuvre_types WHERE key = ?').get(key);
|
|
19
|
+
if (!row) {
|
|
20
|
+
throw notFound('manoeuvre_type', key);
|
|
21
|
+
}
|
|
22
|
+
return row;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function listManoeuvreTypes(db, { limit, offset }) {
|
|
26
|
+
return paginate(db, {
|
|
27
|
+
table: 'manoeuvre_types',
|
|
28
|
+
orderBy: 'sort_order, key',
|
|
29
|
+
limit,
|
|
30
|
+
offset,
|
|
31
|
+
map: toManoeuvreType
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createManoeuvreType(db, { key, label, icon, sortOrder, enabled }) {
|
|
36
|
+
if (!KEY_PATTERN.test(key)) {
|
|
37
|
+
throw badRequest('key must be 1-40 characters of lowercase letters, digits or underscores');
|
|
38
|
+
}
|
|
39
|
+
if (db.prepare('SELECT 1 FROM manoeuvre_types WHERE key = ?').get(key)) {
|
|
40
|
+
throw conflict('manoeuvre_type_exists', `A manoeuvre type with key ${key} already exists`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const order =
|
|
44
|
+
sortOrder ??
|
|
45
|
+
db.prepare('SELECT COALESCE(MAX(sort_order), 0) + 10 AS next FROM manoeuvre_types').get().next;
|
|
46
|
+
db.prepare(
|
|
47
|
+
`INSERT INTO manoeuvre_types (key, label, icon, sort_order, builtin, enabled)
|
|
48
|
+
VALUES (?, ?, ?, ?, 0, ?)`
|
|
49
|
+
).run(key, label, icon ?? null, order, enabled === false ? 0 : 1);
|
|
50
|
+
|
|
51
|
+
return toManoeuvreType(requireTypeRow(db, key));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function updateManoeuvreType(db, key, patch) {
|
|
55
|
+
const row = requireTypeRow(db, key);
|
|
56
|
+
db.prepare(
|
|
57
|
+
'UPDATE manoeuvre_types SET label = ?, icon = ?, sort_order = ?, enabled = ? WHERE key = ?'
|
|
58
|
+
).run(
|
|
59
|
+
patch.label ?? row.label,
|
|
60
|
+
patch.icon !== undefined ? patch.icon : row.icon,
|
|
61
|
+
patch.sortOrder ?? row.sort_order,
|
|
62
|
+
patch.enabled === undefined ? row.enabled : Number(patch.enabled),
|
|
63
|
+
key
|
|
64
|
+
);
|
|
65
|
+
return toManoeuvreType(requireTypeRow(db, key));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function deleteManoeuvreType(db, key) {
|
|
69
|
+
const row = requireTypeRow(db, key);
|
|
70
|
+
if (row.builtin === 1) {
|
|
71
|
+
throw conflict(
|
|
72
|
+
'builtin_manoeuvre_type',
|
|
73
|
+
`${key} is built in and cannot be deleted; disable it instead`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
db.prepare('DELETE FROM manoeuvre_types WHERE key = ?').run(key);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
listManoeuvreTypes,
|
|
81
|
+
createManoeuvreType,
|
|
82
|
+
updateManoeuvreType,
|
|
83
|
+
deleteManoeuvreType
|
|
84
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const OBSERVATION_DEFAULTS = { observationIntervalMinutes: 60 };
|
|
2
|
+
|
|
3
|
+
// Navigation, wind and depth arrive several times a second; barometer and
|
|
4
|
+
// temperatures can be minutes apart.
|
|
5
|
+
const FAST_MAX_AGE_MS = 2 * 60 * 1000;
|
|
6
|
+
const SLOW_MAX_AGE_MS = 15 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
const COLUMNS = [
|
|
9
|
+
'lat',
|
|
10
|
+
'lon',
|
|
11
|
+
'sog',
|
|
12
|
+
'cog',
|
|
13
|
+
'heading',
|
|
14
|
+
'stw',
|
|
15
|
+
'twd',
|
|
16
|
+
'tws',
|
|
17
|
+
'awa',
|
|
18
|
+
'aws',
|
|
19
|
+
'depth',
|
|
20
|
+
'pressure',
|
|
21
|
+
'air_temp',
|
|
22
|
+
'water_temp',
|
|
23
|
+
'trip_log',
|
|
24
|
+
'engine_runtime'
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function iso(ms) {
|
|
28
|
+
return new Date(ms).toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function finiteOrNull(value) {
|
|
32
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeAngle(radians) {
|
|
36
|
+
return ((radians % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createObservationRecorder({ db, readSelfPath, readFresh, settings }) {
|
|
40
|
+
const intervalMs = settings.observationIntervalMinutes * 60 * 1000;
|
|
41
|
+
let latest = {};
|
|
42
|
+
|
|
43
|
+
const fast = (path, now) => finiteOrNull(readFresh(path, now, FAST_MAX_AGE_MS));
|
|
44
|
+
const slow = (path, now) => finiteOrNull(readFresh(path, now, SLOW_MAX_AGE_MS));
|
|
45
|
+
// A counter's last value stays a true reading while it does not move: engine
|
|
46
|
+
// hours with the engine off, the trip log at anchor.
|
|
47
|
+
const counter = (path) => finiteOrNull(readSelfPath(path)?.value);
|
|
48
|
+
|
|
49
|
+
function engineRuntime() {
|
|
50
|
+
const engines = Object.keys(readSelfPath('propulsion') ?? {});
|
|
51
|
+
const ordered = engines.includes('main') ? ['main', ...engines] : engines;
|
|
52
|
+
for (const id of ordered) {
|
|
53
|
+
const runtime = counter(`propulsion.${id}.runTime`);
|
|
54
|
+
if (runtime !== null) {
|
|
55
|
+
return runtime;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Called every detection cycle, not only when a snapshot is due: freshness is
|
|
62
|
+
// judged by timestamps changing between reads, so a sensor read once an hour
|
|
63
|
+
// would look current as long as it had changed at any point in that hour.
|
|
64
|
+
function sense(now) {
|
|
65
|
+
const position = readFresh('navigation.position', now, FAST_MAX_AGE_MS);
|
|
66
|
+
const headingTrue = fast('navigation.headingTrue', now);
|
|
67
|
+
const headingMagnetic = fast('navigation.headingMagnetic', now);
|
|
68
|
+
const variation = slow('navigation.magneticVariation', now);
|
|
69
|
+
const belowSurface = fast('environment.depth.belowSurface', now);
|
|
70
|
+
const belowTransducer = fast('environment.depth.belowTransducer', now);
|
|
71
|
+
|
|
72
|
+
const hasPosition =
|
|
73
|
+
position && Number.isFinite(position.latitude) && Number.isFinite(position.longitude);
|
|
74
|
+
|
|
75
|
+
latest = {
|
|
76
|
+
lat: hasPosition ? position.latitude : null,
|
|
77
|
+
lon: hasPosition ? position.longitude : null,
|
|
78
|
+
sog: fast('navigation.speedOverGround', now),
|
|
79
|
+
cog: fast('navigation.courseOverGroundTrue', now),
|
|
80
|
+
heading:
|
|
81
|
+
headingTrue ??
|
|
82
|
+
(headingMagnetic !== null && variation !== null
|
|
83
|
+
? normalizeAngle(headingMagnetic + variation)
|
|
84
|
+
: null),
|
|
85
|
+
stw: fast('navigation.speedThroughWater', now),
|
|
86
|
+
twd: fast('environment.wind.directionTrue', now),
|
|
87
|
+
tws: fast('environment.wind.speedTrue', now),
|
|
88
|
+
awa: fast('environment.wind.angleApparent', now),
|
|
89
|
+
aws: fast('environment.wind.speedApparent', now),
|
|
90
|
+
depth: belowSurface ?? belowTransducer,
|
|
91
|
+
pressure: slow('environment.outside.pressure', now),
|
|
92
|
+
air_temp: slow('environment.outside.temperature', now),
|
|
93
|
+
water_temp: slow('environment.water.temperature', now),
|
|
94
|
+
trip_log: counter('navigation.trip.log'),
|
|
95
|
+
engine_runtime: engineRuntime()
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function record(entryId, reason, time) {
|
|
100
|
+
if (COLUMNS.every((column) => latest[column] === null)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const { lastInsertRowid } = db
|
|
104
|
+
.prepare(
|
|
105
|
+
`INSERT INTO observations (entry_id, time, reason, ${COLUMNS.join(', ')})
|
|
106
|
+
VALUES (?, ?, ?, ${COLUMNS.map(() => '?').join(', ')})`
|
|
107
|
+
)
|
|
108
|
+
.run(entryId, time, reason, ...COLUMNS.map((column) => latest[column]));
|
|
109
|
+
return Number(lastInsertRowid);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Periodic snapshots fall on clock boundaries — on the hour by default — as
|
|
113
|
+
// on a paper log. Any snapshot already in the slot, such as one taken for an
|
|
114
|
+
// event, stands in for the periodic one.
|
|
115
|
+
function recordPeriodic(entry, now) {
|
|
116
|
+
const { lastTime } = db
|
|
117
|
+
.prepare('SELECT MAX(time) AS lastTime FROM observations WHERE entry_id = ?')
|
|
118
|
+
.get(entry.id);
|
|
119
|
+
const lastSlot = Math.floor(Date.parse(lastTime ?? entry.start_time) / intervalMs);
|
|
120
|
+
if (Math.floor(now / intervalMs) > lastSlot) {
|
|
121
|
+
record(entry.id, 'periodic', iso(now));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
sense,
|
|
127
|
+
|
|
128
|
+
// Runs inside detection's transaction, with what this cycle did to passages.
|
|
129
|
+
afterDetection(now, { opened, closed }) {
|
|
130
|
+
if (closed && !closed.late) {
|
|
131
|
+
record(closed.id, 'entry_end', iso(now));
|
|
132
|
+
}
|
|
133
|
+
if (opened) {
|
|
134
|
+
record(opened, 'entry_start', iso(now));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const entry = db
|
|
138
|
+
.prepare("SELECT id, start_time FROM log_entries WHERE state = 'active'")
|
|
139
|
+
.get();
|
|
140
|
+
if (entry) {
|
|
141
|
+
recordPeriodic(entry, now);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
recordEvent(entryId, time, now) {
|
|
146
|
+
sense(now);
|
|
147
|
+
return record(entryId, 'event', time);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { createObservationRecorder, OBSERVATION_DEFAULTS };
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const { withTransaction } = require('./database');
|
|
2
|
+
const { findNearestPlace } = require('./places');
|
|
3
|
+
|
|
4
|
+
const GEOCODING_DEFAULTS = {
|
|
5
|
+
geocodingEnabled: true,
|
|
6
|
+
geocodingUrl: 'https://nominatim.openstreetmap.org'
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 10 * 1000;
|
|
10
|
+
// Nominatim's usage policy allows at most one request per second.
|
|
11
|
+
const NEXT_LOOKUP_MS = 2 * 1000;
|
|
12
|
+
const IDLE_MS = 60 * 1000;
|
|
13
|
+
// Offline is the normal state at sea; retry patiently rather than hammering.
|
|
14
|
+
const FIRST_RETRY_MS = 5 * 60 * 1000;
|
|
15
|
+
const MAX_RETRY_MS = 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
const HARBOUR_TYPES = new Set(['marina', 'harbour', 'port']);
|
|
18
|
+
const SETTLEMENT_KEYS = [
|
|
19
|
+
'city',
|
|
20
|
+
'town',
|
|
21
|
+
'village',
|
|
22
|
+
'hamlet',
|
|
23
|
+
'isolated_dwelling',
|
|
24
|
+
'locality',
|
|
25
|
+
'island',
|
|
26
|
+
'municipality'
|
|
27
|
+
];
|
|
28
|
+
const DISTRICT_KEYS = ['quarter', 'suburb', 'neighbourhood', 'city_district'];
|
|
29
|
+
|
|
30
|
+
function coordinatesName({ lat, lon }) {
|
|
31
|
+
const latitude = `${Math.abs(lat).toFixed(4)}${lat >= 0 ? 'N' : 'S'}`;
|
|
32
|
+
const longitude = `${Math.abs(lon).toFixed(4)}${lon >= 0 ? 'E' : 'W'}`;
|
|
33
|
+
return `${latitude} ${longitude}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Name for a departure or arrival as it happens: a known place, or failing
|
|
37
|
+
// that a name from coordinates left pending until geocoding answers.
|
|
38
|
+
function initialPlaceName(db, position, radius) {
|
|
39
|
+
if (!position) {
|
|
40
|
+
return { id: null, name: null, pending: 0 };
|
|
41
|
+
}
|
|
42
|
+
const place = findNearestPlace(db, position, radius);
|
|
43
|
+
return place
|
|
44
|
+
? { id: place.id, name: place.name, pending: 0 }
|
|
45
|
+
: { id: null, name: coordinatesName(position), pending: 1 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Near a marina Nominatim tends to answer with the quay's road, and at sea with
|
|
49
|
+
// a bare administrative boundary: neither is a logbook place name. The
|
|
50
|
+
// settlement, with its district when there is one, is.
|
|
51
|
+
function pickName(result) {
|
|
52
|
+
if (result.name && HARBOUR_TYPES.has(result.type)) {
|
|
53
|
+
return result.name;
|
|
54
|
+
}
|
|
55
|
+
const address = result.address ?? {};
|
|
56
|
+
const settlement = SETTLEMENT_KEYS.map((key) => address[key]).find(Boolean);
|
|
57
|
+
if (!settlement) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const district = DISTRICT_KEYS.map((key) => address[key]).find(Boolean);
|
|
61
|
+
return district && district !== settlement ? `${settlement} (${district})` : settlement;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createPlaceNamer({ db, settings, userAgent, fetch = globalThis.fetch }) {
|
|
65
|
+
const stopping = new AbortController();
|
|
66
|
+
let failures = 0;
|
|
67
|
+
|
|
68
|
+
function nextPending() {
|
|
69
|
+
return db
|
|
70
|
+
.prepare(
|
|
71
|
+
`SELECT id, 'start' AS side, start_lat AS lat, start_lon AS lon FROM log_entries
|
|
72
|
+
WHERE start_place_pending = 1 AND start_lat IS NOT NULL
|
|
73
|
+
UNION ALL
|
|
74
|
+
SELECT id, 'end' AS side, end_lat AS lat, end_lon AS lon FROM log_entries
|
|
75
|
+
WHERE end_place_pending = 1 AND end_lat IS NOT NULL
|
|
76
|
+
ORDER BY id DESC, side DESC
|
|
77
|
+
LIMIT 1`
|
|
78
|
+
)
|
|
79
|
+
.get();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Only a name still pending for the position that was looked up is replaced:
|
|
83
|
+
// a name typed, or a position corrected, while the lookup was out stays.
|
|
84
|
+
function settle({ id, side, lat, lon }, place) {
|
|
85
|
+
const prefix = side === 'start' ? 'start' : 'end';
|
|
86
|
+
const stillAsLookedUp = `id = ? AND ${prefix}_place_pending = 1 AND ${prefix}_lat = ? AND ${prefix}_lon = ?`;
|
|
87
|
+
if (place) {
|
|
88
|
+
db.prepare(
|
|
89
|
+
`UPDATE log_entries
|
|
90
|
+
SET ${prefix}_place_name = ?, ${prefix}_place_id = ?, ${prefix}_place_pending = 0
|
|
91
|
+
WHERE ${stillAsLookedUp}`
|
|
92
|
+
).run(place.name, place.id, id, lat, lon);
|
|
93
|
+
} else {
|
|
94
|
+
db.prepare(`UPDATE log_entries SET ${prefix}_place_pending = 0 WHERE ${stillAsLookedUp}`).run(
|
|
95
|
+
id,
|
|
96
|
+
lat,
|
|
97
|
+
lon
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The places table is the geocoding cache: the next departure or arrival
|
|
103
|
+
// within the radius is named from it, with no request.
|
|
104
|
+
function rememberGeocoded(name, { lat, lon }) {
|
|
105
|
+
const now = new Date().toISOString();
|
|
106
|
+
const { lastInsertRowid } = db
|
|
107
|
+
.prepare(
|
|
108
|
+
`INSERT INTO places (name, lat, lon, source, created_at, updated_at)
|
|
109
|
+
VALUES (?, ?, ?, 'geocoding', ?, ?)`
|
|
110
|
+
)
|
|
111
|
+
.run(name, lat, lon, now, now);
|
|
112
|
+
return { id: Number(lastInsertRowid), name };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function lookup({ lat, lon }) {
|
|
116
|
+
const base = settings.geocodingUrl.endsWith('/')
|
|
117
|
+
? settings.geocodingUrl
|
|
118
|
+
: `${settings.geocodingUrl}/`;
|
|
119
|
+
const url = new URL('reverse', base);
|
|
120
|
+
url.searchParams.set('format', 'jsonv2');
|
|
121
|
+
url.searchParams.set('lat', String(lat));
|
|
122
|
+
url.searchParams.set('lon', String(lon));
|
|
123
|
+
url.searchParams.set('zoom', '17');
|
|
124
|
+
url.searchParams.set('addressdetails', '1');
|
|
125
|
+
|
|
126
|
+
const response = await fetch(url, {
|
|
127
|
+
headers: { 'User-Agent': userAgent, Accept: 'application/json' },
|
|
128
|
+
signal: AbortSignal.any([stopping.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
|
|
129
|
+
});
|
|
130
|
+
// Rate limiting, server trouble or a refused client are worth retrying
|
|
131
|
+
// later; anything else about this position is a final answer.
|
|
132
|
+
if (
|
|
133
|
+
response.status === 401 ||
|
|
134
|
+
response.status === 403 ||
|
|
135
|
+
response.status === 429 ||
|
|
136
|
+
response.status >= 500
|
|
137
|
+
) {
|
|
138
|
+
throw new Error(`Geocoding service answered HTTP ${response.status}`);
|
|
139
|
+
}
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const result = await response.json();
|
|
144
|
+
return result.error ? null : pickName(result);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
async resolveNext() {
|
|
149
|
+
if (!settings.geocodingEnabled) {
|
|
150
|
+
return { outcome: 'disabled', retryInMs: IDLE_MS };
|
|
151
|
+
}
|
|
152
|
+
const pending = nextPending();
|
|
153
|
+
if (!pending) {
|
|
154
|
+
return { outcome: 'idle', retryInMs: IDLE_MS };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A lookup for a nearby departure or arrival may already have named it.
|
|
158
|
+
const known = findNearestPlace(db, pending, settings.placeMatchRadius);
|
|
159
|
+
if (known) {
|
|
160
|
+
settle(pending, known);
|
|
161
|
+
return { outcome: 'known', retryInMs: 0 };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let name;
|
|
165
|
+
try {
|
|
166
|
+
name = await lookup(pending);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (stopping.signal.aborted) {
|
|
169
|
+
return { outcome: 'stopped' };
|
|
170
|
+
}
|
|
171
|
+
failures += 1;
|
|
172
|
+
return {
|
|
173
|
+
outcome: 'failed',
|
|
174
|
+
error,
|
|
175
|
+
retryInMs: Math.min(FIRST_RETRY_MS * 2 ** (failures - 1), MAX_RETRY_MS)
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (stopping.signal.aborted) {
|
|
179
|
+
return { outcome: 'stopped' };
|
|
180
|
+
}
|
|
181
|
+
failures = 0;
|
|
182
|
+
|
|
183
|
+
if (!name) {
|
|
184
|
+
settle(pending, null);
|
|
185
|
+
return { outcome: 'no_result', retryInMs: NEXT_LOOKUP_MS };
|
|
186
|
+
}
|
|
187
|
+
withTransaction(db, () => {
|
|
188
|
+
const place =
|
|
189
|
+
findNearestPlace(db, pending, settings.placeMatchRadius) ??
|
|
190
|
+
rememberGeocoded(name, pending);
|
|
191
|
+
settle(pending, place);
|
|
192
|
+
});
|
|
193
|
+
return { outcome: 'resolved', name, retryInMs: NEXT_LOOKUP_MS };
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
stop() {
|
|
197
|
+
stopping.abort();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
coordinatesName,
|
|
204
|
+
initialPlaceName,
|
|
205
|
+
pickName,
|
|
206
|
+
createPlaceNamer,
|
|
207
|
+
GEOCODING_DEFAULTS
|
|
208
|
+
};
|