signalk-siparu 0.1.39 → 0.1.41

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-siparu",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Kept aboard, proven ashore. An impartial, timestamped record of every voyage: position, wind, depth and logbook, written on the boat and readable from anywhere. A read-only Signal K plugin with a built-in dashboard.",
5
5
  "keywords": [
6
6
  "signalk-node-server-plugin",
@@ -29,12 +29,37 @@ export declare const MAX_ACTIVE_ALARMS = 64;
29
29
  export declare const MAX_NOTE_MESSAGE = 160;
30
30
  /** The prefix that makes a path a notification rather than a gauge. */
31
31
  export declare const NOTIFICATIONS_PREFIX = "notifications.";
32
+ /**
33
+ * How many distinct notification paths are remembered for the sake of offering them.
34
+ *
35
+ * The same misbehaving bus member that could flood {@link MAX_ACTIVE_ALARMS} can invent paths
36
+ * that clear immediately, and those would grow this set without ever standing. A screen
37
+ * offering a list to choose from stops being usable long before this, so the ceiling costs an
38
+ * honest boat nothing.
39
+ */
40
+ export declare const MAX_KNOWN_PATHS = 256;
41
+ /**
42
+ * Whether one standing condition is allowed to raise the flag.
43
+ *
44
+ * Supplied by the rule store; the default here is what a boat with no rules does, which is
45
+ * ring for everything. Passing nothing must therefore mean the loud answer: this file is
46
+ * about being heard, and a silence chosen by nobody is the one outcome not worth risking.
47
+ */
48
+ export type Audible = (path: string, state: Exclude<AlertLevel, 'normal'>) => boolean;
32
49
  export declare class AlarmWatch {
33
50
  private readonly debug;
34
51
  /** Path to condition, for the ones standing right now. Normal is absence, not an entry. */
35
52
  private readonly standing;
36
53
  /** States that arrived in no vocabulary we know, so a boat reporting one can be diagnosed. */
37
54
  private readonly unknown;
55
+ /**
56
+ * Every notification path seen since start, standing or since cleared.
57
+ *
58
+ * Kept because a screen that offers the owner conditions to mute cannot offer him what is
59
+ * standing at that moment: a bilge alarm that comes and goes is exactly the one he wants to
60
+ * silence, and it is rarely raised while he is looking for it.
61
+ */
62
+ private readonly seen;
38
63
  constructor(debug?: (msg: string) => void);
39
64
  /**
40
65
  * Take one path/value pair from the delta stream.
@@ -43,8 +68,20 @@ export declare class AlarmWatch {
43
68
  * notice of notifications only.
44
69
  */
45
70
  ingest(path: string, value: unknown, now?: number): void;
46
- /** The loudest condition standing, which is what travels beside the sealed body. */
47
- level(): AlertLevel;
71
+ /**
72
+ * The loudest condition standing that the owner asked to hear about, which is what travels
73
+ * beside the sealed body.
74
+ *
75
+ * The filter belongs here rather than on the list: a muted condition stays in the body and
76
+ * on the screen of anybody who opens the app, and only this word is withheld. That is the
77
+ * whole difference between not being woken and being kept in the dark.
78
+ *
79
+ * What withholding the word buys is that the carrier cannot read which condition was muted
80
+ * and cannot decide that it was not. It does not hide that a condition is standing - an
81
+ * unpadded body grows by the length of one, and sizes are measurable. See the wiring in
82
+ * index.ts, where that limit is written down rather than assumed away.
83
+ */
84
+ level(audible?: Audible): AlertLevel;
48
85
  /**
49
86
  * What is actually wrong, for the sealed body.
50
87
  *
@@ -57,6 +94,13 @@ export declare class AlarmWatch {
57
94
  active(): number;
58
95
  /** States seen that are in no vocabulary we know, in the order they first arrived. */
59
96
  unknownStates(): string[];
97
+ /**
98
+ * Every notification path this boat has raised since start, in the order first seen.
99
+ *
100
+ * What a screen offers the owner to choose from. Sent only inside a sealed answer: the list
101
+ * of notifications a vessel is wired for says a good deal about what is aboard her.
102
+ */
103
+ knownPaths(): string[];
60
104
  /**
61
105
  * The one line a notification can show, or nothing when she is quiet.
62
106
  *
@@ -68,6 +112,6 @@ export declare class AlarmWatch {
68
112
  * on a path with a hard size limit, and a gateway is free to put a paragraph in a
69
113
  * notification.
70
114
  */
71
- note(): AlertNote | undefined;
115
+ note(audible?: Audible): AlertNote | undefined;
72
116
  private quietest;
73
117
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AlarmWatch = exports.NOTIFICATIONS_PREFIX = exports.MAX_NOTE_MESSAGE = exports.MAX_ACTIVE_ALARMS = void 0;
3
+ exports.AlarmWatch = exports.MAX_KNOWN_PATHS = exports.NOTIFICATIONS_PREFIX = exports.MAX_NOTE_MESSAGE = exports.MAX_ACTIVE_ALARMS = void 0;
4
4
  /**
5
5
  * The ceiling on simultaneously standing conditions.
6
6
  *
@@ -30,12 +30,30 @@ const LEVEL_OF = {
30
30
  const RANK = { normal: 0, warning: 1, alarm: 2 };
31
31
  /** The prefix that makes a path a notification rather than a gauge. */
32
32
  exports.NOTIFICATIONS_PREFIX = 'notifications.';
33
+ /**
34
+ * How many distinct notification paths are remembered for the sake of offering them.
35
+ *
36
+ * The same misbehaving bus member that could flood {@link MAX_ACTIVE_ALARMS} can invent paths
37
+ * that clear immediately, and those would grow this set without ever standing. A screen
38
+ * offering a list to choose from stops being usable long before this, so the ceiling costs an
39
+ * honest boat nothing.
40
+ */
41
+ exports.MAX_KNOWN_PATHS = 256;
42
+ const ALWAYS = () => true;
33
43
  class AlarmWatch {
34
44
  debug;
35
45
  /** Path to condition, for the ones standing right now. Normal is absence, not an entry. */
36
46
  standing = new Map();
37
47
  /** States that arrived in no vocabulary we know, so a boat reporting one can be diagnosed. */
38
48
  unknown = new Set();
49
+ /**
50
+ * Every notification path seen since start, standing or since cleared.
51
+ *
52
+ * Kept because a screen that offers the owner conditions to mute cannot offer him what is
53
+ * standing at that moment: a bilge alarm that comes and goes is exactly the one he wants to
54
+ * silence, and it is rarely raised while he is looking for it.
55
+ */
56
+ seen = new Set();
39
57
  constructor(debug = () => { }) {
40
58
  this.debug = debug;
41
59
  }
@@ -71,6 +89,12 @@ class AlarmWatch {
71
89
  }
72
90
  return;
73
91
  }
92
+ // Remembered from here rather than at the top of the function: this is the point at which
93
+ // what arrived is known to be a real Signal K notification and not any object that happened
94
+ // to be published under the tree. Quiet ones are remembered too, because a path standing at
95
+ // normal is still one its owner may want silenced before it next goes off.
96
+ if (this.seen.size < exports.MAX_KNOWN_PATHS)
97
+ this.seen.add(path);
74
98
  if (level === 'normal') {
75
99
  this.standing.delete(path);
76
100
  return;
@@ -97,10 +121,24 @@ class AlarmWatch {
97
121
  const since = before !== undefined && before.state === level ? before.since : now;
98
122
  this.standing.set(path, { state: level, message, since });
99
123
  }
100
- /** The loudest condition standing, which is what travels beside the sealed body. */
101
- level() {
124
+ /**
125
+ * The loudest condition standing that the owner asked to hear about, which is what travels
126
+ * beside the sealed body.
127
+ *
128
+ * The filter belongs here rather than on the list: a muted condition stays in the body and
129
+ * on the screen of anybody who opens the app, and only this word is withheld. That is the
130
+ * whole difference between not being woken and being kept in the dark.
131
+ *
132
+ * What withholding the word buys is that the carrier cannot read which condition was muted
133
+ * and cannot decide that it was not. It does not hide that a condition is standing - an
134
+ * unpadded body grows by the length of one, and sizes are measurable. See the wiring in
135
+ * index.ts, where that limit is written down rather than assumed away.
136
+ */
137
+ level(audible = ALWAYS) {
102
138
  let loudest = 'normal';
103
- for (const held of this.standing.values()) {
139
+ for (const [path, held] of this.standing) {
140
+ if (!audible(path, held.state))
141
+ continue;
104
142
  if (RANK[held.state] > RANK[loudest])
105
143
  loudest = held.state;
106
144
  }
@@ -126,6 +164,15 @@ class AlarmWatch {
126
164
  unknownStates() {
127
165
  return [...this.unknown];
128
166
  }
167
+ /**
168
+ * Every notification path this boat has raised since start, in the order first seen.
169
+ *
170
+ * What a screen offers the owner to choose from. Sent only inside a sealed answer: the list
171
+ * of notifications a vessel is wired for says a good deal about what is aboard her.
172
+ */
173
+ knownPaths() {
174
+ return [...this.seen];
175
+ }
129
176
  /**
130
177
  * The one line a notification can show, or nothing when she is quiet.
131
178
  *
@@ -137,8 +184,11 @@ class AlarmWatch {
137
184
  * on a path with a hard size limit, and a gateway is free to put a paragraph in a
138
185
  * notification.
139
186
  */
140
- note() {
141
- const first = this.conditions()[0];
187
+ note(audible = ALWAYS) {
188
+ // Filtered by the same rule as the flag, and it has to be: the note is the sentence on the
189
+ // lock screen of the bell the flag rang. Taking it from the whole list would put a muted
190
+ // condition's words on a phone that rang for a different one.
191
+ const first = this.conditions().find((c) => audible(c.path, c.state));
142
192
  if (first === undefined)
143
193
  return undefined;
144
194
  return {
@@ -0,0 +1,177 @@
1
+ import type { AlertLevel, AlertRing, AlertRule } from './contract';
2
+ export declare const RULES_VERSION = 1;
3
+ /**
4
+ * The most rules one boat will hold.
5
+ *
6
+ * A vessel raising more distinct notification paths than this is not a vessel whose owner is
7
+ * curating them one by one, and the list is written to a flash card that has other work to do.
8
+ * Well past the tank, engine and generator notifications a real boat carries.
9
+ */
10
+ export declare const MAX_ALERT_RULES = 128;
11
+ /** The longest notification path a rule may name. Signal K's own are far shorter. */
12
+ export declare const MAX_RULE_PATH = 256;
13
+ /**
14
+ * How far ahead of the boat's own clock a list may be stamped.
15
+ *
16
+ * A phone and a boat disagree by seconds, not minutes, and the window exists so a device with
17
+ * a badly set clock cannot stamp a list in the next century and lock its owner out of every
18
+ * edit after it.
19
+ */
20
+ export declare const MAX_CLOCK_SKEW_MS: number;
21
+ /**
22
+ * What a boat with no rules does, and therefore what she does about a path nobody has named:
23
+ * everything rings. A default of anything else would mean a feature nobody switched on could
24
+ * silence an alarm, which is the one mistake this file must not make.
25
+ */
26
+ export declare const DEFAULT_RING: AlertRing;
27
+ /**
28
+ * A rule list as it arrives, parsed only as far as the proof needs it.
29
+ *
30
+ * Deliberately not validated here beyond "these are the right kinds of thing". The proof is
31
+ * computed over raw fields, so whether `ring_from` is a word this build knows is a question
32
+ * for after the sender has been established - a device sending a list this build cannot use
33
+ * deserves an answer saying so, and a stranger deserves nothing at all.
34
+ */
35
+ export interface ParsedRuleWrite {
36
+ v: number;
37
+ id: string;
38
+ kid: string;
39
+ ts: number;
40
+ rules: {
41
+ path: string;
42
+ ring_from: string;
43
+ }[];
44
+ proof: string;
45
+ }
46
+ /**
47
+ * The exact bytes both implementations compute the proof over.
48
+ *
49
+ * Built from raw fields with explicit lengths rather than from re-serialised JSON, exactly as
50
+ * the frame signing input is, and for the same reason: the two implementations that have to
51
+ * agree on these bytes are written in different languages, and any disagreement between their
52
+ * JSON encoders about key order or spacing would refuse every write in the fleet at once.
53
+ *
54
+ * The rules are covered in the order they were sent rather than sorted, so a carrier that
55
+ * reordered them fails the check. That order is also the order the boat stores them in, so
56
+ * nothing has to be re-derived to verify the same list twice.
57
+ */
58
+ export declare function proofInput(req: {
59
+ v: number;
60
+ boat: string;
61
+ kid: string;
62
+ id: string;
63
+ ts: number;
64
+ rules: readonly {
65
+ path: string;
66
+ ring_from: string;
67
+ }[];
68
+ }): Buffer;
69
+ /**
70
+ * Make the proof for one rule list. The device's side of the same agreement.
71
+ *
72
+ * Kept beside the check for the same reason `sealRequest` is kept beside `openRequest`: this
73
+ * file is where the format lives, and both ends have to agree on it byte for byte. The
74
+ * shipping client is the phone, in another language, held to the same committed vectors.
75
+ */
76
+ export declare function makeRuleProof(opts: {
77
+ req: Omit<ParsedRuleWrite, 'proof'>;
78
+ boat: string;
79
+ /** The device's raw 32-byte X25519 private half, and the public one it implies. */
80
+ devicePriv: Buffer;
81
+ devicePub: Buffer;
82
+ /** The boat's raw 32-byte X25519 inbox public key. */
83
+ inboxPub: Buffer;
84
+ }): string;
85
+ /**
86
+ * Whether this write was made by the device it claims to be from.
87
+ *
88
+ * Returns false rather than throwing on anything malformed. This runs on whatever arrives over
89
+ * a socket, and "this is not a write from a device I know" is an answer rather than a fault:
90
+ * the two are the same thing to the caller, and only one of them is safe inside a live link.
91
+ */
92
+ export declare function verifyRuleProof(opts: {
93
+ req: ParsedRuleWrite;
94
+ boat: string;
95
+ /** The boat's raw 32-byte X25519 inbox private half, and the public one it implies. */
96
+ inboxPriv: Buffer;
97
+ inboxPub: Buffer;
98
+ /** The raw 32-byte X25519 public key of the device named by `req.kid`. */
99
+ devicePub: Buffer;
100
+ }): boolean;
101
+ /**
102
+ * A rule write off the wire, parsed no further than the proof needs.
103
+ *
104
+ * Anything that is not shaped like one at all yields nothing, and the caller drops it in
105
+ * silence, exactly as it drops every other message that is not its own request.
106
+ */
107
+ export declare function parseRuleWrite(msg: unknown): ParsedRuleWrite | undefined;
108
+ /** Why a list this boat could not use was refused. Only ever told to a proven device. */
109
+ export type RuleRefusal = {
110
+ code: 'UNSUPPORTED_VERSION';
111
+ message: string;
112
+ } | {
113
+ code: 'BAD_RULES';
114
+ message: string;
115
+ } | {
116
+ code: 'STALE';
117
+ message: string;
118
+ } | {
119
+ code: 'WRITE_FAILED';
120
+ message: string;
121
+ };
122
+ /**
123
+ * The rules this boat is going by, and the file they survive a restart in.
124
+ *
125
+ * Held in the plugin's data directory rather than in its options, for the same reason the
126
+ * keys are: Signal K serves plugin options wholesale over GET /plugins/<id>/config, and with
127
+ * security off - the default install - that answers anyone on the boat's network. A list of
128
+ * what an owner has chosen not to be told about is not something to hand out on a marina wifi.
129
+ */
130
+ export declare class AlertRuleStore {
131
+ private readonly debug;
132
+ private readonly file;
133
+ private byPath;
134
+ private order;
135
+ private appliedTs;
136
+ /** One write at a time. See {@link apply} - this is the replay floor, not tidiness. */
137
+ private queue;
138
+ constructor(dataDir: string, debug?: (msg: string) => void);
139
+ /**
140
+ * Read the stored list, if there is one.
141
+ *
142
+ * A file that cannot be read or understood leaves the boat with no rules, which is the loud
143
+ * side: every condition rings. The quiet side would have been a boat silently muted by a
144
+ * corrupt file, and nobody would notice until the night it mattered.
145
+ */
146
+ load(): void;
147
+ /** The list in force, in the order it was written. */
148
+ rules(): AlertRule[];
149
+ /** Epoch ms of the list in force. Zero on a boat that has never been given one. */
150
+ ts(): number;
151
+ /**
152
+ * Whether one standing condition is allowed to raise the flag.
153
+ *
154
+ * A path nobody has named rings, and so does a boat with no list at all: the flag is what
155
+ * reaches a pocket, and a silence chosen by nobody is the one outcome worth protecting
156
+ * against.
157
+ */
158
+ rings(conditionPath: string, state: Exclude<AlertLevel, 'normal'>): boolean;
159
+ /**
160
+ * Take a list from a device whose proof has already been checked.
161
+ *
162
+ * Runs one at a time, and the queue is not housekeeping - it is what makes the replay floor
163
+ * mean anything. The floor is a check against `appliedTs` followed by a disk write, and a
164
+ * disk write yields. Two writes arriving together would both read the floor before either
165
+ * raised it, both pass, and land in whatever order the filesystem finished them: a carrier
166
+ * holding an old sealed write only has to deliver it alongside a new one to have the old
167
+ * list win AND drag `appliedTs` backwards, which reopens every write it captured since.
168
+ * Serialising here is the whole of the fix; nothing below may assume it runs alone.
169
+ */
170
+ apply(req: ParsedRuleWrite, now: number): Promise<RuleRefusal | undefined>;
171
+ private applyAlone;
172
+ /** Wait for a queued write to land, so a stop does not return with a rename in flight. */
173
+ flush(): Promise<void>;
174
+ private adopt;
175
+ /** Callers reach this through the queue above, so it needs no chain of its own. */
176
+ private persist;
177
+ }