signalk-garmin-keypad-plugin 1.0.2
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/.codex +0 -0
- package/.github/workflows/publish.yml +42 -0
- package/README.md +69 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +395 -0
- package/dist/n2k.d.ts +19 -0
- package/dist/n2k.js +143 -0
- package/dist/pgns.d.ts +169 -0
- package/dist/pgns.js +454 -0
- package/dist/protocol.d.ts +22 -0
- package/dist/protocol.js +55 -0
- package/index.js +1 -0
- package/package.json +32 -0
- package/public/183.js +1 -0
- package/public/main.js +1 -0
- package/public/remoteEntry.js +1 -0
- package/resources/demo.png +0 -0
- package/tsconfig.json +13 -0
package/.codex
ADDED
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
|
|
15
|
+
- uses: actions/setup-node@v4
|
|
16
|
+
with:
|
|
17
|
+
node-version: 18
|
|
18
|
+
registry-url: https://registry.npmjs.org
|
|
19
|
+
|
|
20
|
+
- name: Set version from release tag
|
|
21
|
+
run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version
|
|
22
|
+
env:
|
|
23
|
+
GITHUB_REF_NAME: ${{ github.event.release.tag_name }}
|
|
24
|
+
|
|
25
|
+
- run: npm ci
|
|
26
|
+
- run: cd webapp && npm ci
|
|
27
|
+
- run: npm run build
|
|
28
|
+
- run: npm test
|
|
29
|
+
|
|
30
|
+
- run: npm publish
|
|
31
|
+
env:
|
|
32
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
33
|
+
|
|
34
|
+
- name: Commit version bump
|
|
35
|
+
run: |
|
|
36
|
+
git config user.name "github-actions[bot]"
|
|
37
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
38
|
+
git add package.json
|
|
39
|
+
git commit -m "v${GITHUB_REF_NAME#v}"
|
|
40
|
+
git push origin HEAD:main
|
|
41
|
+
env:
|
|
42
|
+
GITHUB_REF_NAME: ${{ github.event.release.tag_name }}
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# signalk-garmin-keypad-plugin
|
|
2
|
+
|
|
3
|
+
Signal K server plugin that acts as a Garmin GNX Keypad on NMEA 2000, allowing control of GNX instrument displays via a web UI or REST API.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Preset selection** (1-4) — short press to recall, long press to save
|
|
10
|
+
- **Page navigation** — up/down on the active display
|
|
11
|
+
- **Display selection** — cycle between GNX displays in the group
|
|
12
|
+
- **Power control** — sleep/wake
|
|
13
|
+
- **Auto-discovery** — group ID, display count, keypad fingerprint, and per-property counters are all discovered automatically from bus traffic
|
|
14
|
+
- **Embeddable webapp** — dark-themed UI matching the physical keypad, embedded in the Signal K admin UI via Module Federation
|
|
15
|
+
|
|
16
|
+
## Protocol
|
|
17
|
+
|
|
18
|
+
Sends Garmin proprietary NMEA 2000 messages:
|
|
19
|
+
|
|
20
|
+
- **PGN 61184** — single-frame button events (preset select/save, page navigation)
|
|
21
|
+
- **PGN 126720** — fast-packet property commands (display selection, sleep/wake)
|
|
22
|
+
|
|
23
|
+
Property commands use a per-property sequence counter and a keypad fingerprint that must match what the displays have stored. On first use, the plugin sends a command that the display rejects (NACK), discovers the correct counter and fingerprint from the rejection, then retries with corrected values.
|
|
24
|
+
|
|
25
|
+
## Prerequisites
|
|
26
|
+
|
|
27
|
+
- Signal K server with an NMEA 2000 gateway (e.g. Actisense NGT-1, Yacht Devices YDWG-02)
|
|
28
|
+
- One or more Garmin GNX displays configured in a group
|
|
29
|
+
|
|
30
|
+
> **Note:** The displays may need to have been grouped with a real Garmin GNX Keypad at least once before this plugin can control them. The group binding token and fingerprint are persisted in display NVM during initial pairing — without a prior pairing, these values may not exist for the plugin to discover. This is speculative and has not been confirmed.
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
| Option | Description | Default |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| Source Address | NMEA 2000 source address for the keypad | 0 |
|
|
37
|
+
| GNX Group ID | 4-byte group binding token (8 hex digits). Leave blank to auto-discover. | auto |
|
|
38
|
+
| Display Count | Number of displays in the group. Set to 0 to auto-discover. | 0 |
|
|
39
|
+
| Keypad Fingerprint | 2-byte keypad fingerprint (4 hex digits). Leave blank to auto-discover. | auto |
|
|
40
|
+
|
|
41
|
+
## REST API
|
|
42
|
+
|
|
43
|
+
All endpoints at `/plugins/signalk-garmin-keypad/`:
|
|
44
|
+
|
|
45
|
+
| Method | Path | Body |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| GET | `/state` | — |
|
|
48
|
+
| POST | `/preset/select` | `{ "index": 0-3 }` |
|
|
49
|
+
| POST | `/preset/save` | `{ "index": 0-3 }` |
|
|
50
|
+
| POST | `/page` | `{ "direction": "next" \| "previous" }` |
|
|
51
|
+
| POST | `/display/cycle` | `{ "direction": "up" \| "down" }` |
|
|
52
|
+
| POST | `/power` | `{ "action": "sleep" \| "wake" }` |
|
|
53
|
+
|
|
54
|
+
## Development
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm install
|
|
58
|
+
npm run build:plugin # compile TypeScript
|
|
59
|
+
cd webapp && npm install && npm run build # build React webapp
|
|
60
|
+
npm test # run tests
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Disclaimer
|
|
64
|
+
|
|
65
|
+
This project is an independent demo and is not affiliated with, endorsed by, or connected to Garmin or its subsidiaries. "Garmin" and "GNX" are trademarks of Garmin. Use at your own risk. The authors assume no liability for any damage to equipment or loss of functionality resulting from the use of this software.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
Apache-2.0
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
interface PluginOptions {
|
|
2
|
+
sourceAddress: number;
|
|
3
|
+
groupId?: string;
|
|
4
|
+
displayCount?: number;
|
|
5
|
+
fingerprint?: string;
|
|
6
|
+
}
|
|
7
|
+
export default function (app: any): {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
schema: {
|
|
12
|
+
type: "object";
|
|
13
|
+
title: string;
|
|
14
|
+
properties: {
|
|
15
|
+
sourceAddress: {
|
|
16
|
+
type: "number";
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
default: number;
|
|
20
|
+
};
|
|
21
|
+
groupId: {
|
|
22
|
+
type: "string";
|
|
23
|
+
title: string;
|
|
24
|
+
description: string;
|
|
25
|
+
default: string;
|
|
26
|
+
};
|
|
27
|
+
displayCount: {
|
|
28
|
+
type: "number";
|
|
29
|
+
title: string;
|
|
30
|
+
description: string;
|
|
31
|
+
default: number;
|
|
32
|
+
};
|
|
33
|
+
fingerprint: {
|
|
34
|
+
type: "string";
|
|
35
|
+
title: string;
|
|
36
|
+
description: string;
|
|
37
|
+
default: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
start: (props: PluginOptions) => void;
|
|
42
|
+
stop: () => void;
|
|
43
|
+
registerWithRouter: (router: any) => void;
|
|
44
|
+
};
|
|
45
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const protocol_1 = require("./protocol");
|
|
4
|
+
const n2k_1 = require("./n2k");
|
|
5
|
+
const pgnDefinitions = require('./pgns');
|
|
6
|
+
function default_1(app) {
|
|
7
|
+
const debug = (...args) => app.debug(...args);
|
|
8
|
+
let options = { sourceAddress: protocol_1.DEFAULT_SRC };
|
|
9
|
+
let sleeping = false;
|
|
10
|
+
let displayCount = 0;
|
|
11
|
+
let activeDisplay = 0;
|
|
12
|
+
let n2kDiscoveryListener = null;
|
|
13
|
+
let propertyListener = null;
|
|
14
|
+
let rawInputListener = null;
|
|
15
|
+
let retryTimers = [];
|
|
16
|
+
const displayAddresses = new Set();
|
|
17
|
+
const syncedProperties = new Set();
|
|
18
|
+
function src() {
|
|
19
|
+
var _a;
|
|
20
|
+
return (_a = options.sourceAddress) !== null && _a !== void 0 ? _a : protocol_1.DEFAULT_SRC;
|
|
21
|
+
}
|
|
22
|
+
function effectiveDisplayCount() {
|
|
23
|
+
return displayCount > 0 ? displayCount : displayAddresses.size;
|
|
24
|
+
}
|
|
25
|
+
function parsePayload(raw) {
|
|
26
|
+
if (!raw)
|
|
27
|
+
return null;
|
|
28
|
+
if (Buffer.isBuffer(raw))
|
|
29
|
+
return raw;
|
|
30
|
+
if (typeof raw === "string" && raw.includes(" ")) {
|
|
31
|
+
return Buffer.from(raw.split(" ").map((b) => parseInt(b, 16)));
|
|
32
|
+
}
|
|
33
|
+
if (typeof raw === "string") {
|
|
34
|
+
return Buffer.from(raw, "hex");
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function emit(pgn) {
|
|
39
|
+
debug("Sending PGN %d: %j", pgn.pgn, pgn);
|
|
40
|
+
app.emit("nmea2000JsonOut", pgn);
|
|
41
|
+
}
|
|
42
|
+
// Send a property command with lazy discovery retry.
|
|
43
|
+
// On first use of each property, the display will NACK with stored
|
|
44
|
+
// counter/fingerprint. rawInputHandler syncs from the NACK, then
|
|
45
|
+
// the retry (250ms later) sends with corrected values.
|
|
46
|
+
function emitWithRetry(buildFn, property) {
|
|
47
|
+
emit(buildFn());
|
|
48
|
+
if (!syncedProperties.has(property)) {
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
debug("Retry %s after discovery sync", property);
|
|
51
|
+
emit(buildFn());
|
|
52
|
+
}, 250);
|
|
53
|
+
retryTimers.push(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const plugin = {
|
|
57
|
+
id: protocol_1.PLUGIN_ID,
|
|
58
|
+
name: "Garmin GNX Keypad",
|
|
59
|
+
description: "Garmin GNX Keypad on NMEA 2000 to control GNX instrument displays",
|
|
60
|
+
schema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
title: "Garmin GNX Keypad",
|
|
63
|
+
properties: {
|
|
64
|
+
sourceAddress: {
|
|
65
|
+
type: "number",
|
|
66
|
+
title: "Source Address",
|
|
67
|
+
description: "NMEA 2000 source address for the keypad (note: the CAN gateway may override this)",
|
|
68
|
+
default: 0,
|
|
69
|
+
},
|
|
70
|
+
groupId: {
|
|
71
|
+
type: "string",
|
|
72
|
+
title: "GNX Group ID (optional)",
|
|
73
|
+
description: 'Manual override for the 4-byte GNX group binding token (8 hex digits, e.g. "80d99efc"). ' +
|
|
74
|
+
"Leave blank to auto-discover from the first heartbeat or property message on the bus. " +
|
|
75
|
+
"Find it in bytes 10-13 of any PGN 126720 0xe5/0xe7 message on your GNX bus.",
|
|
76
|
+
default: "",
|
|
77
|
+
},
|
|
78
|
+
displayCount: {
|
|
79
|
+
type: "number",
|
|
80
|
+
title: "Display Count (optional)",
|
|
81
|
+
description: "Number of GNX displays in the group. Set to 0 to auto-discover from bus traffic. " +
|
|
82
|
+
"Set manually if auto-discovery fails (displays only broadcast count during startup).",
|
|
83
|
+
default: 0,
|
|
84
|
+
},
|
|
85
|
+
fingerprint: {
|
|
86
|
+
type: "string",
|
|
87
|
+
title: "Keypad Fingerprint (optional)",
|
|
88
|
+
description: 'Manual override for the 2-byte keypad fingerprint (4 hex digits, e.g. "f9a9"). ' +
|
|
89
|
+
"Leave blank to auto-discover from the first property response on the bus. " +
|
|
90
|
+
"Displays reject property commands from a fingerprint that doesn't match their stored value.",
|
|
91
|
+
default: "",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
start: function (props) {
|
|
96
|
+
var _a, _b, _c;
|
|
97
|
+
options = {
|
|
98
|
+
sourceAddress: (_a = props.sourceAddress) !== null && _a !== void 0 ? _a : protocol_1.DEFAULT_SRC,
|
|
99
|
+
};
|
|
100
|
+
sleeping = false;
|
|
101
|
+
displayCount = 0;
|
|
102
|
+
activeDisplay = 0;
|
|
103
|
+
retryTimers = [];
|
|
104
|
+
displayAddresses.clear();
|
|
105
|
+
syncedProperties.clear();
|
|
106
|
+
(0, n2k_1.resetCounters)();
|
|
107
|
+
app.emitPropertyValue("canboat-custom-pgns", pgnDefinitions);
|
|
108
|
+
debug("Registered custom PGN definitions");
|
|
109
|
+
if (props.displayCount && props.displayCount > 0) {
|
|
110
|
+
displayCount = props.displayCount;
|
|
111
|
+
debug("Display count set from config: %d", displayCount);
|
|
112
|
+
}
|
|
113
|
+
let fingerprintDiscovered = false;
|
|
114
|
+
const manualFingerprintHex = (_b = props.fingerprint) === null || _b === void 0 ? void 0 : _b.trim();
|
|
115
|
+
if (manualFingerprintHex && manualFingerprintHex.length === 4) {
|
|
116
|
+
const b1 = parseInt(manualFingerprintHex.slice(0, 2), 16);
|
|
117
|
+
const b2 = parseInt(manualFingerprintHex.slice(2, 4), 16);
|
|
118
|
+
if (!isNaN(b1) && !isNaN(b2)) {
|
|
119
|
+
(0, n2k_1.setFingerprint)([b1, b2]);
|
|
120
|
+
fingerprintDiscovered = true;
|
|
121
|
+
debug("Keypad fingerprint set from config: %s", manualFingerprintHex);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const manualGroupId = (_c = props.groupId) === null || _c === void 0 ? void 0 : _c.trim();
|
|
125
|
+
let groupIdDiscovered = false;
|
|
126
|
+
if (manualGroupId) {
|
|
127
|
+
try {
|
|
128
|
+
(0, n2k_1.setGroupId)((0, protocol_1.parseGroupId)(manualGroupId));
|
|
129
|
+
groupIdDiscovered = true;
|
|
130
|
+
debug("Group ID set from config: %s", manualGroupId);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
debug("Invalid groupId in config, will auto-discover: %s", err);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Read raw actisense input BEFORE canboatjs parsing to get full trailing
|
|
137
|
+
// bytes (fingerprint + counter) that canboatjs truncates from the Payload.
|
|
138
|
+
const rawInputHandler = (msg) => {
|
|
139
|
+
if (typeof msg !== "string")
|
|
140
|
+
return;
|
|
141
|
+
const parts = msg.split(",");
|
|
142
|
+
if (parts.length < 9)
|
|
143
|
+
return;
|
|
144
|
+
if (parseInt(parts[2]) !== protocol_1.PGN_FAST)
|
|
145
|
+
return;
|
|
146
|
+
const msgSrc = parseInt(parts[3]);
|
|
147
|
+
if (!displayAddresses.has(msgSrc))
|
|
148
|
+
return;
|
|
149
|
+
if (parts[6] !== "e5" || parts[8] !== "e5")
|
|
150
|
+
return;
|
|
151
|
+
if (parseInt(parts[5]) < 40)
|
|
152
|
+
return;
|
|
153
|
+
const dataBytes = parts.slice(6).map((h) => parseInt(h, 16));
|
|
154
|
+
const payload = Buffer.from(dataBytes.slice(3));
|
|
155
|
+
if (payload.length < 20)
|
|
156
|
+
return;
|
|
157
|
+
const strLen = payload[13];
|
|
158
|
+
if (payload.length < 14 + strLen)
|
|
159
|
+
return;
|
|
160
|
+
const propName = payload.toString("ascii", 14, 14 + strLen - 1);
|
|
161
|
+
const valueOffset = 14 + strLen + 3;
|
|
162
|
+
if (valueOffset >= payload.length)
|
|
163
|
+
return;
|
|
164
|
+
const trailingStart = valueOffset + 1;
|
|
165
|
+
if (payload.length < trailingStart + 7)
|
|
166
|
+
return;
|
|
167
|
+
const t3 = payload[trailingStart + 3];
|
|
168
|
+
const t4 = payload[trailingStart + 4];
|
|
169
|
+
const t5 = payload[trailingStart + 5];
|
|
170
|
+
const t6 = payload[trailingStart + 6];
|
|
171
|
+
const storedSeq = (0, n2k_1.decodeCounter)(t5, t6);
|
|
172
|
+
(0, n2k_1.ensureCounterAbove)(propName, storedSeq);
|
|
173
|
+
syncedProperties.add(propName);
|
|
174
|
+
debug("Raw counter sync: %s src=%d seq=%d", propName, msgSrc, storedSeq);
|
|
175
|
+
if (!fingerprintDiscovered && (t3 !== 0 || t4 !== 0)) {
|
|
176
|
+
fingerprintDiscovered = true;
|
|
177
|
+
(0, n2k_1.setFingerprint)([t3, t4]);
|
|
178
|
+
debug("Fingerprint auto-discovered (raw): %s", t3.toString(16).padStart(2, "0") + t4.toString(16).padStart(2, "0"));
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
app.on("canboatjs:rawoutput", rawInputHandler);
|
|
182
|
+
rawInputListener = rawInputHandler;
|
|
183
|
+
const discoveryHandler = (msg) => {
|
|
184
|
+
if (msg.pgn !== protocol_1.PGN_FAST)
|
|
185
|
+
return;
|
|
186
|
+
const fields = msg.fields;
|
|
187
|
+
const mfr = fields === null || fields === void 0 ? void 0 : fields["Manufacturer Code"];
|
|
188
|
+
if (mfr !== 229 && mfr !== "Garmin")
|
|
189
|
+
return;
|
|
190
|
+
const cmd = fields === null || fields === void 0 ? void 0 : fields["Command"];
|
|
191
|
+
if (cmd !== 0xe5 && cmd !== 0xe7)
|
|
192
|
+
return;
|
|
193
|
+
const payload = parsePayload(fields === null || fields === void 0 ? void 0 : fields["Payload"]);
|
|
194
|
+
if (!groupIdDiscovered && payload) {
|
|
195
|
+
const groupId = (0, n2k_1.extractGroupIdFromPayload)(payload);
|
|
196
|
+
if (groupId) {
|
|
197
|
+
groupIdDiscovered = true;
|
|
198
|
+
(0, n2k_1.setGroupId)(groupId);
|
|
199
|
+
const hex = groupId.toString("hex");
|
|
200
|
+
debug("Group ID auto-discovered: %s", hex);
|
|
201
|
+
app.setPluginStatus(`Running — group ${hex}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Heartbeat payload[13]: 0x01 = display, 0x00 = keypad
|
|
205
|
+
if (cmd === 0xe7 && payload && payload.length >= 14 && payload[13] === 0x01) {
|
|
206
|
+
if (!displayAddresses.has(msg.src)) {
|
|
207
|
+
displayAddresses.add(msg.src);
|
|
208
|
+
debug("Display discovered at src=%d (total: %d)", msg.src, displayAddresses.size);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
app.on("N2KAnalyzerOut", discoveryHandler);
|
|
213
|
+
n2kDiscoveryListener = discoveryHandler;
|
|
214
|
+
const propHandler = (msg) => {
|
|
215
|
+
if (msg.pgn !== protocol_1.PGN_FAST)
|
|
216
|
+
return;
|
|
217
|
+
const fields = msg.fields;
|
|
218
|
+
if ((fields === null || fields === void 0 ? void 0 : fields["Manufacturer Code"]) !== 229 && (fields === null || fields === void 0 ? void 0 : fields["Manufacturer Code"]) !== "Garmin")
|
|
219
|
+
return;
|
|
220
|
+
if ((fields === null || fields === void 0 ? void 0 : fields["Command"]) !== 0xe5)
|
|
221
|
+
return;
|
|
222
|
+
const payload = parsePayload(fields === null || fields === void 0 ? void 0 : fields["Payload"]);
|
|
223
|
+
if (!payload || payload.length < 20)
|
|
224
|
+
return;
|
|
225
|
+
const strLen = payload[13];
|
|
226
|
+
if (payload.length < 14 + strLen)
|
|
227
|
+
return;
|
|
228
|
+
const propName = payload.toString("ascii", 14, 14 + strLen - 1);
|
|
229
|
+
const valueOffset = 14 + strLen + 3;
|
|
230
|
+
if (valueOffset >= payload.length)
|
|
231
|
+
return;
|
|
232
|
+
const value = payload[valueOffset];
|
|
233
|
+
// Extract stored counter and fingerprint from trailing bytes.
|
|
234
|
+
// Trailing 7 bytes: [2e T1 T2 T3(fp1) T4(fp2) T5 T6] — T3/T4 = fingerprint, T5/T6 = counter.
|
|
235
|
+
if (payload.length >= valueOffset + 1 + 7) {
|
|
236
|
+
const trailingStart = valueOffset + 1;
|
|
237
|
+
const t3 = payload[trailingStart + 3];
|
|
238
|
+
const t4 = payload[trailingStart + 4];
|
|
239
|
+
const t5 = payload[payload.length - 2];
|
|
240
|
+
const t6 = payload[payload.length - 1];
|
|
241
|
+
const storedSeq = (0, n2k_1.decodeCounter)(t5, t6);
|
|
242
|
+
debug("Counter discovery: %s src=%d stored=%d t5=0x%s t6=0x%s", propName, msg.src, storedSeq, t5.toString(16), t6.toString(16));
|
|
243
|
+
(0, n2k_1.ensureCounterAbove)(propName, storedSeq);
|
|
244
|
+
syncedProperties.add(propName);
|
|
245
|
+
if (!fingerprintDiscovered && (t3 !== 0 || t4 !== 0)) {
|
|
246
|
+
fingerprintDiscovered = true;
|
|
247
|
+
(0, n2k_1.setFingerprint)([t3, t4]);
|
|
248
|
+
debug("Fingerprint auto-discovered: %s", t3.toString(16).padStart(2, "0") + t4.toString(16).padStart(2, "0"));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (propName === protocol_1.PROP_DISP_CNT && value > 0 && displayCount === 0) {
|
|
252
|
+
displayCount = value;
|
|
253
|
+
debug("Display count discovered: %d", displayCount);
|
|
254
|
+
}
|
|
255
|
+
if (propName === protocol_1.PROP_DISPLAY) {
|
|
256
|
+
activeDisplay = value;
|
|
257
|
+
debug("Active display updated: %d", activeDisplay);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
app.on("N2KAnalyzerOut", propHandler);
|
|
261
|
+
propertyListener = propHandler;
|
|
262
|
+
app.setPluginStatus("Started");
|
|
263
|
+
debug("Plugin started, src=%d", src());
|
|
264
|
+
},
|
|
265
|
+
stop: function () {
|
|
266
|
+
retryTimers.forEach((t) => clearTimeout(t));
|
|
267
|
+
retryTimers = [];
|
|
268
|
+
if (n2kDiscoveryListener) {
|
|
269
|
+
app.removeListener("N2KAnalyzerOut", n2kDiscoveryListener);
|
|
270
|
+
n2kDiscoveryListener = null;
|
|
271
|
+
}
|
|
272
|
+
if (propertyListener) {
|
|
273
|
+
app.removeListener("N2KAnalyzerOut", propertyListener);
|
|
274
|
+
propertyListener = null;
|
|
275
|
+
}
|
|
276
|
+
if (rawInputListener) {
|
|
277
|
+
app.removeListener("canboatjs:rawoutput", rawInputListener);
|
|
278
|
+
rawInputListener = null;
|
|
279
|
+
}
|
|
280
|
+
displayAddresses.clear();
|
|
281
|
+
syncedProperties.clear();
|
|
282
|
+
debug("Plugin stopped");
|
|
283
|
+
},
|
|
284
|
+
registerWithRouter: function (router) {
|
|
285
|
+
router.use((_req, _res, next) => {
|
|
286
|
+
var _a;
|
|
287
|
+
if (_req.method === "POST") {
|
|
288
|
+
// express.json() may not be available, parse manually if needed
|
|
289
|
+
if (!_req.body && ((_a = _req.headers["content-type"]) === null || _a === void 0 ? void 0 : _a.includes("application/json"))) {
|
|
290
|
+
let data = "";
|
|
291
|
+
_req.on("data", (chunk) => {
|
|
292
|
+
data += chunk;
|
|
293
|
+
});
|
|
294
|
+
_req.on("end", () => {
|
|
295
|
+
try {
|
|
296
|
+
_req.body = JSON.parse(data);
|
|
297
|
+
}
|
|
298
|
+
catch (_a) {
|
|
299
|
+
_req.body = {};
|
|
300
|
+
}
|
|
301
|
+
next();
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
next();
|
|
307
|
+
});
|
|
308
|
+
router.get("/state", (_req, res) => {
|
|
309
|
+
const state = {
|
|
310
|
+
sleeping,
|
|
311
|
+
displayCount: effectiveDisplayCount(),
|
|
312
|
+
activeDisplay,
|
|
313
|
+
};
|
|
314
|
+
res.json(state);
|
|
315
|
+
});
|
|
316
|
+
router.post("/preset/select", (req, res) => {
|
|
317
|
+
var _a;
|
|
318
|
+
const index = (_a = req.body) === null || _a === void 0 ? void 0 : _a.index;
|
|
319
|
+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index > 3) {
|
|
320
|
+
return res.status(400).json({ error: "index must be an integer 0-3" });
|
|
321
|
+
}
|
|
322
|
+
emit((0, n2k_1.buildSelectPreset)(index, src()));
|
|
323
|
+
res.json({ ok: true });
|
|
324
|
+
});
|
|
325
|
+
router.post("/preset/save", (req, res) => {
|
|
326
|
+
var _a;
|
|
327
|
+
const index = (_a = req.body) === null || _a === void 0 ? void 0 : _a.index;
|
|
328
|
+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index > 3) {
|
|
329
|
+
return res.status(400).json({ error: "index must be an integer 0-3" });
|
|
330
|
+
}
|
|
331
|
+
emit((0, n2k_1.buildSavePreset)(index, src()));
|
|
332
|
+
res.json({ ok: true });
|
|
333
|
+
});
|
|
334
|
+
router.post("/page", (req, res) => {
|
|
335
|
+
var _a;
|
|
336
|
+
const direction = (_a = req.body) === null || _a === void 0 ? void 0 : _a.direction;
|
|
337
|
+
if (direction !== "next" && direction !== "previous") {
|
|
338
|
+
return res.status(400).json({ error: 'direction must be "next" or "previous"' });
|
|
339
|
+
}
|
|
340
|
+
emit((0, n2k_1.buildPageNav)(direction, src()));
|
|
341
|
+
res.json({ ok: true });
|
|
342
|
+
});
|
|
343
|
+
router.post("/display/select", (req, res) => {
|
|
344
|
+
var _a;
|
|
345
|
+
const index = (_a = req.body) === null || _a === void 0 ? void 0 : _a.index;
|
|
346
|
+
if (typeof index !== "number" || !Number.isInteger(index)) {
|
|
347
|
+
return res.status(400).json({ error: "index must be an integer" });
|
|
348
|
+
}
|
|
349
|
+
let target = index;
|
|
350
|
+
const count = effectiveDisplayCount();
|
|
351
|
+
if (count > 0) {
|
|
352
|
+
target = ((index % count) + count) % count;
|
|
353
|
+
}
|
|
354
|
+
else if (index < 0) {
|
|
355
|
+
target = 0;
|
|
356
|
+
}
|
|
357
|
+
emitWithRetry(() => (0, n2k_1.buildDisplaySelect)(target, src()), protocol_1.PROP_DISPLAY);
|
|
358
|
+
activeDisplay = target;
|
|
359
|
+
res.json({ ok: true, displayIndex: target });
|
|
360
|
+
});
|
|
361
|
+
router.post("/display/cycle", (req, res) => {
|
|
362
|
+
var _a;
|
|
363
|
+
const direction = (_a = req.body) === null || _a === void 0 ? void 0 : _a.direction;
|
|
364
|
+
if (direction !== "up" && direction !== "down") {
|
|
365
|
+
return res.status(400).json({ error: 'direction must be "up" or "down"' });
|
|
366
|
+
}
|
|
367
|
+
let next;
|
|
368
|
+
const count = effectiveDisplayCount();
|
|
369
|
+
if (count > 0) {
|
|
370
|
+
const delta = direction === "down" ? 1 : -1;
|
|
371
|
+
next = (((activeDisplay + delta) % count) + count) % count;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
next = direction === "down" ? activeDisplay + 1 : Math.max(0, activeDisplay - 1);
|
|
375
|
+
}
|
|
376
|
+
emitWithRetry(() => (0, n2k_1.buildDisplaySelect)(next, src()), protocol_1.PROP_DISPLAY);
|
|
377
|
+
activeDisplay = next;
|
|
378
|
+
res.json({ ok: true, displayIndex: next });
|
|
379
|
+
});
|
|
380
|
+
router.post("/power", (req, res) => {
|
|
381
|
+
var _a;
|
|
382
|
+
const action = (_a = req.body) === null || _a === void 0 ? void 0 : _a.action;
|
|
383
|
+
if (action !== "sleep" && action !== "wake") {
|
|
384
|
+
return res.status(400).json({ error: 'action must be "sleep" or "wake"' });
|
|
385
|
+
}
|
|
386
|
+
const goToSleep = action === "sleep";
|
|
387
|
+
emitWithRetry(() => (0, n2k_1.buildSleepWake)(goToSleep, src()), protocol_1.PROP_SLEEP);
|
|
388
|
+
sleeping = goToSleep;
|
|
389
|
+
res.json({ ok: true });
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
return plugin;
|
|
394
|
+
}
|
|
395
|
+
exports.default = default_1;
|
package/dist/n2k.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
export interface PgnMessage {
|
|
3
|
+
pgn: number;
|
|
4
|
+
dst: number;
|
|
5
|
+
prio: number;
|
|
6
|
+
src: number;
|
|
7
|
+
[key: string]: any;
|
|
8
|
+
}
|
|
9
|
+
export declare function setGroupId(groupId: Buffer): void;
|
|
10
|
+
export declare function extractGroupIdFromPayload(payload: Buffer | null): Buffer | null;
|
|
11
|
+
export declare function buildSelectPreset(index: number, src?: number): PgnMessage;
|
|
12
|
+
export declare function buildSavePreset(index: number, src?: number): PgnMessage;
|
|
13
|
+
export declare function buildPageNav(direction: 'next' | 'previous', src?: number): PgnMessage;
|
|
14
|
+
export declare function resetCounters(): void;
|
|
15
|
+
export declare function decodeCounter(t5: number, t6: number): number;
|
|
16
|
+
export declare function ensureCounterAbove(property: string, minSeq: number): void;
|
|
17
|
+
export declare function setFingerprint(fp: [number, number]): void;
|
|
18
|
+
export declare function buildSleepWake(sleep: boolean, src?: number): PgnMessage;
|
|
19
|
+
export declare function buildDisplaySelect(index: number, src?: number): PgnMessage;
|