iobroker.nut2 0.5.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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/lib/message-router.ts"],
4
+ "sourcesContent": ["import type { NutClient } from \"./nut-client\";\nimport { coerceCommandTimeoutMs, coerceHost, coercePort, errText, localAddressOf } from \"./coerce\";\nimport type { AdapterConfig, NutClientOptions, NutLogger } from \"./types\";\n\n/**\n * Dependencies the message router needs. Extracted as a pure interface\n * so `dispatchMessage` is testable without an adapter instance.\n */\nexport interface MessageRouterDeps {\n /** Adapter logger. */\n log: {\n debug(msg: string): void;\n warn(msg: string): void;\n };\n /** ioBroker sendTo bound against the adapter instance. */\n sendTo: (\n from: string,\n command: string,\n response: unknown,\n callback: ioBroker.MessageCallbackInfo | undefined,\n ) => void;\n /** Factory for throwaway NutClient used by checkConnection (options carry localAddress/timeout/TLS). */\n createTestClient: (host: string, port: number, options?: NutClientOptions) => NutClient;\n /** Called right after createTestClient returns. */\n onTestClientCreated?: (client: NutClient) => void;\n /** Called after checkConnection settles (success or fail). */\n onTestClientDone?: (client: NutClient) => void;\n}\n\n/** NutClient constructor shape for dependency injection. */\ntype NutClientConstructor = new (host: string, port: number, options?: NutClientOptions) => NutClient;\n\n/**\n * Build the standard test-client factory used in production.\n *\n * @param NutClientClass NutClient constructor\n * @param logger Logger to forward into the NutClient\n */\nexport function makeTestClientFactory(\n NutClientClass: NutClientConstructor,\n logger: NutLogger,\n): MessageRouterDeps[\"createTestClient\"] {\n return (host, port, options) => new NutClientClass(host, port, { ...options, logger });\n}\n\n/**\n * Dispatch a single ioBroker message. Handles `checkConnection` from the\n * admin UI and provides the default-branch contract so unknown commands\n * get `{ error: \"Unknown command\" }` instead of leaving the callback hanging.\n *\n * @param obj The incoming message payload\n * @param deps Test-injectable dependencies\n */\nexport async function dispatchMessage(obj: ioBroker.Message, deps: MessageRouterDeps): Promise<void> {\n deps.log.debug(`onMessage: command='${obj?.command}' from='${obj?.from}' has-callback=${!!obj?.callback}`);\n if (!obj.callback) {\n return;\n }\n try {\n switch (obj.command) {\n case \"checkConnection\": {\n const raw = obj.message;\n const config =\n typeof raw === \"object\" && raw !== null && !Array.isArray(raw)\n ? (raw as Partial<AdapterConfig>)\n : ({} as Partial<AdapterConfig>);\n const host = coerceHost(config.host);\n\n if (!host) {\n deps.log.debug(\"checkConnection: missing host in message\");\n deps.sendTo(obj.from, obj.command, { error: \"Host is required\" }, obj.callback);\n return;\n }\n\n const port = coercePort(config.port);\n const username = typeof config.username === \"string\" ? config.username : \"\";\n const password = typeof config.password === \"string\" ? config.password : \"\";\n\n // Mirror the production client so the test exercises the real path (multi-homed bind, TLS).\n const localAddress = localAddressOf(config.networkInterface);\n const options: NutClientOptions = {\n localAddress,\n commandTimeout: coerceCommandTimeoutMs(config.commandTimeout),\n useTls: !!config.useTls,\n tlsRejectUnauthorized: !!config.tlsRejectUnauthorized,\n };\n\n const testClient = deps.createTestClient(host, port, options);\n deps.onTestClientCreated?.(testClient);\n try {\n await testClient.connect();\n const upsList = await testClient.listUps();\n const names = upsList.map(u => u.name).join(\", \");\n deps.log.debug(`checkConnection: found ${upsList.length} UPS(es): ${names}`);\n\n if (username && password) {\n // No per-UPS LOGIN (see onConnected in main.ts): NUT allows one LOGIN per connection\n // and it is upsmon-only; USERNAME/PASSWORD is the real credential check.\n await testClient.authenticate(username, password);\n deps.sendTo(\n obj.from,\n obj.command,\n { result: `Connected and authenticated \u2014 ${upsList.length} UPS(es): ${names}` },\n obj.callback,\n );\n } else {\n deps.sendTo(\n obj.from,\n obj.command,\n { result: `Connected \u2014 ${upsList.length} UPS(es): ${names}` },\n obj.callback,\n );\n }\n } finally {\n testClient.destroy();\n deps.onTestClientDone?.(testClient);\n }\n break;\n }\n default:\n deps.log.debug(`onMessage: unknown command '${obj.command}'`);\n deps.sendTo(obj.from, obj.command, { error: \"Unknown command\" }, obj.callback);\n }\n } catch (err) {\n deps.log.debug(`onMessage: '${obj.command}' failed: ${errText(err)}`);\n deps.sendTo(obj.from, obj.command, { error: errText(err) }, obj.callback);\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,oBAAwF;AAqCjF,SAAS,sBACd,gBACA,QACuC;AACvC,SAAO,CAAC,MAAM,MAAM,YAAY,IAAI,eAAe,MAAM,MAAM,EAAE,GAAG,SAAS,OAAO,CAAC;AACvF;AAUA,eAAsB,gBAAgB,KAAuB,MAAwC;AArDrG;AAsDE,OAAK,IAAI,MAAM,uBAAuB,2BAAK,OAAO,WAAW,2BAAK,IAAI,kBAAkB,CAAC,EAAC,2BAAK,SAAQ,EAAE;AACzG,MAAI,CAAC,IAAI,UAAU;AACjB;AAAA,EACF;AACA,MAAI;AACF,YAAQ,IAAI,SAAS;AAAA,MACnB,KAAK,mBAAmB;AACtB,cAAM,MAAM,IAAI;AAChB,cAAM,SACJ,OAAO,QAAQ,YAAY,QAAQ,QAAQ,CAAC,MAAM,QAAQ,GAAG,IACxD,MACA,CAAC;AACR,cAAM,WAAO,0BAAW,OAAO,IAAI;AAEnC,YAAI,CAAC,MAAM;AACT,eAAK,IAAI,MAAM,0CAA0C;AACzD,eAAK,OAAO,IAAI,MAAM,IAAI,SAAS,EAAE,OAAO,mBAAmB,GAAG,IAAI,QAAQ;AAC9E;AAAA,QACF;AAEA,cAAM,WAAO,0BAAW,OAAO,IAAI;AACnC,cAAM,WAAW,OAAO,OAAO,aAAa,WAAW,OAAO,WAAW;AACzE,cAAM,WAAW,OAAO,OAAO,aAAa,WAAW,OAAO,WAAW;AAGzE,cAAM,mBAAe,8BAAe,OAAO,gBAAgB;AAC3D,cAAM,UAA4B;AAAA,UAChC;AAAA,UACA,oBAAgB,sCAAuB,OAAO,cAAc;AAAA,UAC5D,QAAQ,CAAC,CAAC,OAAO;AAAA,UACjB,uBAAuB,CAAC,CAAC,OAAO;AAAA,QAClC;AAEA,cAAM,aAAa,KAAK,iBAAiB,MAAM,MAAM,OAAO;AAC5D,mBAAK,wBAAL,8BAA2B;AAC3B,YAAI;AACF,gBAAM,WAAW,QAAQ;AACzB,gBAAM,UAAU,MAAM,WAAW,QAAQ;AACzC,gBAAM,QAAQ,QAAQ,IAAI,OAAK,EAAE,IAAI,EAAE,KAAK,IAAI;AAChD,eAAK,IAAI,MAAM,0BAA0B,QAAQ,MAAM,aAAa,KAAK,EAAE;AAE3E,cAAI,YAAY,UAAU;AAGxB,kBAAM,WAAW,aAAa,UAAU,QAAQ;AAChD,iBAAK;AAAA,cACH,IAAI;AAAA,cACJ,IAAI;AAAA,cACJ,EAAE,QAAQ,sCAAiC,QAAQ,MAAM,aAAa,KAAK,GAAG;AAAA,cAC9E,IAAI;AAAA,YACN;AAAA,UACF,OAAO;AACL,iBAAK;AAAA,cACH,IAAI;AAAA,cACJ,IAAI;AAAA,cACJ,EAAE,QAAQ,oBAAe,QAAQ,MAAM,aAAa,KAAK,GAAG;AAAA,cAC5D,IAAI;AAAA,YACN;AAAA,UACF;AAAA,QACF,UAAE;AACA,qBAAW,QAAQ;AACnB,qBAAK,qBAAL,8BAAwB;AAAA,QAC1B;AACA;AAAA,MACF;AAAA,MACA;AACE,aAAK,IAAI,MAAM,+BAA+B,IAAI,OAAO,GAAG;AAC5D,aAAK,OAAO,IAAI,MAAM,IAAI,SAAS,EAAE,OAAO,kBAAkB,GAAG,IAAI,QAAQ;AAAA,IACjF;AAAA,EACF,SAAS,KAAK;AACZ,SAAK,IAAI,MAAM,eAAe,IAAI,OAAO,iBAAa,uBAAQ,GAAG,CAAC,EAAE;AACpE,SAAK,OAAO,IAAI,MAAM,IAAI,SAAS,EAAE,WAAO,uBAAQ,GAAG,EAAE,GAAG,IAAI,QAAQ;AAAA,EAC1E;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,633 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var nut_client_exports = {};
30
+ __export(nut_client_exports, {
31
+ NutClient: () => NutClient,
32
+ NutError: () => NutError,
33
+ NutTimeoutError: () => NutTimeoutError,
34
+ isTlsConfigError: () => isTlsConfigError
35
+ });
36
+ module.exports = __toCommonJS(nut_client_exports);
37
+ var net = __toESM(require("node:net"));
38
+ var tls = __toESM(require("node:tls"));
39
+ var import_coerce = require("./coerce");
40
+ var import_types = require("./types");
41
+ const RECONNECT_BASE_MS = 1e3;
42
+ const RECONNECT_MAX_MS = 6e4;
43
+ class NutError extends Error {
44
+ /**
45
+ * @param code NUT error code (a server may send codes outside the documented set, so this is `string`)
46
+ * @param message Optional custom message
47
+ */
48
+ constructor(code, message) {
49
+ super(message != null ? message : `NUT error: ${code}`);
50
+ this.code = code;
51
+ this.name = "NutError";
52
+ }
53
+ }
54
+ class NutTimeoutError extends Error {
55
+ /**
56
+ * @param command The command that timed out
57
+ */
58
+ constructor(command) {
59
+ super(`NUT command timed out: ${command}`);
60
+ this.command = command;
61
+ this.name = "NutTimeoutError";
62
+ }
63
+ }
64
+ const TLS_FATAL_ERROR_CODES = /* @__PURE__ */ new Set([
65
+ // NUT-level: the server cannot/does not start TLS
66
+ "FEATURE-NOT-CONFIGURED",
67
+ "FEATURE-NOT-SUPPORTED",
68
+ "ALREADY-SSL-MODE",
69
+ // Node certificate-verification failures (only reachable with tlsRejectUnauthorized=true)
70
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
71
+ "SELF_SIGNED_CERT_IN_CHAIN",
72
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
73
+ "CERT_HAS_EXPIRED",
74
+ "ERR_TLS_CERT_ALTNAME_INVALID"
75
+ ]);
76
+ function isTlsConfigError(err) {
77
+ if (err instanceof NutError) {
78
+ return TLS_FATAL_ERROR_CODES.has(err.code);
79
+ }
80
+ const code = err == null ? void 0 : err.code;
81
+ if (typeof code !== "string") {
82
+ return false;
83
+ }
84
+ return TLS_FATAL_ERROR_CODES.has(code) || code.startsWith("ERR_TLS_") || code.startsWith("ERR_SSL_");
85
+ }
86
+ class NutClient {
87
+ socket = null;
88
+ buffer = "";
89
+ queue = [];
90
+ active = null;
91
+ multiLineBuffer = [];
92
+ multiLineExpectedEnd = "";
93
+ connected = false;
94
+ destroyed = false;
95
+ tlsActive = false;
96
+ host;
97
+ port;
98
+ localAddress;
99
+ commandTimeout;
100
+ useTls;
101
+ tlsRejectUnauthorized;
102
+ log;
103
+ // Injected managed timers (adapter.setTimeout/clearTimeout in production → auto-cleared on
104
+ // unload; global timers as fallback for standalone use/tests).
105
+ setTimer;
106
+ clearTimer;
107
+ reconnectAttempt = 0;
108
+ reconnectTimer = null;
109
+ // Persistent mode (set by start()) owns the unified retry loop: it retries the initial
110
+ // connect, reconnects on drops, and stops yellow on a fatal TLS-config error. A plain
111
+ // connect() (e.g. the connection test) leaves this false — one-shot, never retries.
112
+ persistent = false;
113
+ onConnectHandler = null;
114
+ onFatalHandler = null;
115
+ /**
116
+ * @param host NUT server hostname or IP
117
+ * @param port NUT server port
118
+ * @param options Connection options
119
+ */
120
+ constructor(host, port, options) {
121
+ var _a, _b, _c, _d, _e;
122
+ this.host = host;
123
+ this.port = port;
124
+ this.localAddress = options == null ? void 0 : options.localAddress;
125
+ this.commandTimeout = (_a = options == null ? void 0 : options.commandTimeout) != null ? _a : import_types.NUT_DEFAULT_COMMAND_TIMEOUT;
126
+ this.useTls = (_b = options == null ? void 0 : options.useTls) != null ? _b : false;
127
+ this.tlsRejectUnauthorized = (_c = options == null ? void 0 : options.tlsRejectUnauthorized) != null ? _c : false;
128
+ this.log = options == null ? void 0 : options.logger;
129
+ this.setTimer = (_d = options == null ? void 0 : options.setTimer) != null ? _d : ((cb, ms) => globalThis.setTimeout(cb, ms));
130
+ this.clearTimer = (_e = options == null ? void 0 : options.clearTimer) != null ? _e : ((h) => globalThis.clearTimeout(h));
131
+ }
132
+ /**
133
+ * Register a callback invoked after every successful (re)connection in persistent mode.
134
+ * Runs the post-connect setup (discover/auth/poll); must be idempotent.
135
+ *
136
+ * @param handler Connect callback
137
+ */
138
+ setOnConnect(handler) {
139
+ this.onConnectHandler = handler;
140
+ }
141
+ /**
142
+ * Register a callback invoked when the persistent connection fails fatally (TLS
143
+ * misconfiguration) — the retry loop stops and the caller should go yellow.
144
+ *
145
+ * @param handler Fatal-error callback
146
+ */
147
+ setOnFatal(handler) {
148
+ this.onFatalHandler = handler;
149
+ }
150
+ /**
151
+ * Start the persistent runtime connection: connect now and keep retrying with exponential
152
+ * backoff, reconnecting automatically on later drops. A fatal TLS-config error stops the
153
+ * loop (onFatal). Use connect() directly for a one-shot (e.g. the connection test).
154
+ */
155
+ start() {
156
+ this.persistent = true;
157
+ this.reconnectAttempt = 0;
158
+ this.attemptConnect();
159
+ }
160
+ /** One iteration of the persistent loop: connect, then fire onConnect or handle the failure. */
161
+ attemptConnect() {
162
+ if (this.destroyed) {
163
+ return;
164
+ }
165
+ this.connect().then(() => {
166
+ var _a;
167
+ this.reconnectAttempt = 0;
168
+ (_a = this.onConnectHandler) == null ? void 0 : _a.call(this);
169
+ }).catch((err) => this.handleConnectFailure(err));
170
+ }
171
+ /**
172
+ * Decide a failed persistent connect: a TLS-config error stops the loop (onFatal, yellow);
173
+ * any other error schedules a backed-off retry.
174
+ *
175
+ * @param err The connect/STARTTLS failure
176
+ */
177
+ handleConnectFailure(err) {
178
+ var _a, _b, _c;
179
+ if (this.destroyed) {
180
+ return;
181
+ }
182
+ this.connected = false;
183
+ const sock = this.socket;
184
+ this.socket = null;
185
+ sock == null ? void 0 : sock.destroy();
186
+ const msg = err instanceof Error ? err.message : String(err);
187
+ if (this.useTls && isTlsConfigError(err)) {
188
+ (_a = this.log) == null ? void 0 : _a.warn(`TLS connection to NUT server ${this.host}:${this.port} failed \u2014 not retrying: ${msg}`);
189
+ (_b = this.onFatalHandler) == null ? void 0 : _b.call(this, err);
190
+ return;
191
+ }
192
+ (_c = this.log) == null ? void 0 : _c.debug(`Connect attempt failed: ${msg}`);
193
+ this.scheduleReconnect();
194
+ }
195
+ /** Establish TCP connection (and STARTTLS upgrade if configured) to the NUT server. */
196
+ connect() {
197
+ return new Promise((resolve, reject) => {
198
+ if (this.destroyed) {
199
+ reject(new Error("Client has been destroyed"));
200
+ return;
201
+ }
202
+ let settled = false;
203
+ const deadline = this.setTimer(() => {
204
+ if (settled) {
205
+ return;
206
+ }
207
+ settled = true;
208
+ const sock = this.socket;
209
+ this.socket = null;
210
+ this.connected = false;
211
+ sock == null ? void 0 : sock.destroy();
212
+ reject(new Error(`Connect to NUT server ${this.host}:${this.port} timed out`));
213
+ }, this.commandTimeout);
214
+ const settle = (err) => {
215
+ if (settled) {
216
+ return;
217
+ }
218
+ settled = true;
219
+ this.clearTimer(deadline);
220
+ if (err) {
221
+ reject(err);
222
+ } else {
223
+ resolve();
224
+ }
225
+ };
226
+ const opts = { host: this.host, port: this.port };
227
+ if (this.localAddress) {
228
+ opts.localAddress = this.localAddress;
229
+ }
230
+ const socket = net.createConnection(opts, () => {
231
+ var _a;
232
+ this.connected = true;
233
+ this.tlsActive = false;
234
+ this.buffer = "";
235
+ (_a = this.log) == null ? void 0 : _a.debug(`Connected to NUT server ${this.host}:${this.port}`);
236
+ if (this.useTls) {
237
+ this.startTls().then(() => settle()).catch(settle);
238
+ } else {
239
+ settle();
240
+ }
241
+ });
242
+ this.socket = socket;
243
+ socket.setKeepAlive(true, 3e4);
244
+ this.wireSocket(socket, settle);
245
+ });
246
+ }
247
+ /**
248
+ * Attach data/error/close handlers to the current socket.
249
+ *
250
+ * @param socket The socket (plaintext or TLS) to wire up
251
+ * @param rejectConnect Optional connect() rejector, called if the socket errors before connecting
252
+ */
253
+ wireSocket(socket, rejectConnect) {
254
+ socket.setEncoding("utf8");
255
+ socket.on("data", (data) => this.onData(data));
256
+ socket.on("error", (err) => {
257
+ var _a;
258
+ (_a = this.log) == null ? void 0 : _a.debug(`Socket error: ${err.message}`);
259
+ if (!this.connected && rejectConnect) {
260
+ rejectConnect(err);
261
+ }
262
+ });
263
+ socket.on("close", () => {
264
+ var _a;
265
+ const wasConnected = this.connected;
266
+ this.connected = false;
267
+ this.rejectAll(new Error("Connection closed"));
268
+ if (wasConnected && !this.destroyed && this.persistent) {
269
+ (_a = this.log) == null ? void 0 : _a.warn(`Connection to NUT server ${this.host}:${this.port} lost`);
270
+ this.scheduleReconnect();
271
+ }
272
+ });
273
+ }
274
+ /** Upgrade the plaintext socket to TLS via STARTTLS. */
275
+ async startTls() {
276
+ const plain = this.socket;
277
+ await this.sendCommand("STARTTLS", false);
278
+ plain.removeAllListeners("data");
279
+ plain.removeAllListeners("error");
280
+ plain.removeAllListeners("close");
281
+ this.buffer = "";
282
+ const servername = net.isIP(this.host) === 0 ? this.host : void 0;
283
+ await new Promise((resolve, reject) => {
284
+ const tlsSocket = tls.connect(
285
+ { socket: plain, rejectUnauthorized: this.tlsRejectUnauthorized, servername },
286
+ () => {
287
+ var _a;
288
+ this.tlsActive = true;
289
+ (_a = this.log) == null ? void 0 : _a.debug(`STARTTLS established with ${this.host}:${this.port}`);
290
+ resolve();
291
+ }
292
+ );
293
+ tlsSocket.once("error", (err) => reject(err));
294
+ this.socket = tlsSocket;
295
+ this.wireSocket(tlsSocket);
296
+ });
297
+ }
298
+ /** Synchronous teardown — destroys socket, no LOGOUT sent. */
299
+ destroy() {
300
+ this.destroyed = true;
301
+ if (this.reconnectTimer) {
302
+ this.clearTimer(this.reconnectTimer);
303
+ this.reconnectTimer = null;
304
+ }
305
+ this.cancelAll();
306
+ if (this.socket) {
307
+ this.socket.destroy();
308
+ this.socket = null;
309
+ }
310
+ this.connected = false;
311
+ }
312
+ /**
313
+ * Synchronous graceful teardown for onUnload — sends a best-effort LOGOUT and half-closes
314
+ * so the write flushes (vs. destroy()'s hard reset). Any server reply is ignored.
315
+ */
316
+ shutdown() {
317
+ this.destroyed = true;
318
+ if (this.reconnectTimer) {
319
+ this.clearTimer(this.reconnectTimer);
320
+ this.reconnectTimer = null;
321
+ }
322
+ this.cancelAll();
323
+ const sock = this.socket;
324
+ this.socket = null;
325
+ this.connected = false;
326
+ if (sock) {
327
+ try {
328
+ sock.end("LOGOUT\n");
329
+ } catch {
330
+ sock.destroy();
331
+ }
332
+ }
333
+ }
334
+ /** Reject all pending and queued commands. */
335
+ cancelAll() {
336
+ this.rejectAll(new Error("Client cancelled"));
337
+ }
338
+ /**
339
+ * Reject the active command AND every queued command (clearing their timers), then reset the
340
+ * multi-line parse state. Used by cancelAll (destroy/resync) and by the socket-close handler —
341
+ * a queued entry left behind with a live timer would fire later and tear down a
342
+ * subsequently-reconnected socket.
343
+ *
344
+ * @param err Rejection reason handed to every pending command
345
+ */
346
+ rejectAll(err) {
347
+ if (this.active) {
348
+ this.clearTimer(this.active.timer);
349
+ this.active.reject(err);
350
+ this.active = null;
351
+ }
352
+ for (const entry of this.queue) {
353
+ this.clearTimer(entry.timer);
354
+ entry.reject(err);
355
+ }
356
+ this.queue = [];
357
+ this.multiLineBuffer = [];
358
+ this.multiLineExpectedEnd = "";
359
+ }
360
+ /** Whether the TCP connection is currently established. */
361
+ get isConnected() {
362
+ return this.connected;
363
+ }
364
+ /** Whether the connection is TLS-encrypted. */
365
+ get isTls() {
366
+ return this.tlsActive;
367
+ }
368
+ /** Discover all UPS devices on the NUT server. */
369
+ listUps() {
370
+ return this.parseList("LIST UPS", /^UPS\s+(\S+)\s+"((?:[^"\\]|\\.)*)"/, (m) => ({
371
+ name: m[1],
372
+ description: unescapeNut(m[2])
373
+ }));
374
+ }
375
+ /**
376
+ * List all variables for a UPS.
377
+ *
378
+ * @param ups UPS name
379
+ */
380
+ listVar(ups) {
381
+ return this.parseList(`LIST VAR ${ups}`, /^VAR\s+\S+\s+(\S+)\s+"((?:[^"\\]|\\.)*)"/, (m) => ({
382
+ name: m[1],
383
+ value: unescapeNut(m[2])
384
+ }));
385
+ }
386
+ /**
387
+ * List writable variables for a UPS.
388
+ *
389
+ * @param ups UPS name
390
+ */
391
+ listRw(ups) {
392
+ return this.parseList(`LIST RW ${ups}`, /^RW\s+\S+\s+(\S+)\s+"((?:[^"\\]|\\.)*)"/, (m) => ({
393
+ name: m[1],
394
+ value: unescapeNut(m[2])
395
+ }));
396
+ }
397
+ /**
398
+ * List available instant commands for a UPS.
399
+ *
400
+ * @param ups UPS name
401
+ */
402
+ listCmd(ups) {
403
+ return this.parseList(`LIST CMD ${ups}`, /^CMD\s+\S+\s+(\S+)/, (m) => ({ name: m[1] }));
404
+ }
405
+ /**
406
+ * List enum values for a variable.
407
+ *
408
+ * @param ups UPS name
409
+ * @param varName Variable name
410
+ */
411
+ listEnum(ups, varName) {
412
+ return this.parseList(
413
+ `LIST ENUM ${ups} ${varName}`,
414
+ /^ENUM\s+\S+\s+\S+\s+"((?:[^"\\]|\\.)*)"/,
415
+ (m) => unescapeNut(m[1])
416
+ );
417
+ }
418
+ /**
419
+ * List range constraints for a variable.
420
+ *
421
+ * @param ups UPS name
422
+ * @param varName Variable name
423
+ */
424
+ listRange(ups, varName) {
425
+ return this.parseList(
426
+ `LIST RANGE ${ups} ${varName}`,
427
+ /^RANGE\s+\S+\s+\S+\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"/,
428
+ (m) => ({ min: unescapeNut(m[1]), max: unescapeNut(m[2]) })
429
+ );
430
+ }
431
+ /**
432
+ * Generic LIST/multi-line parser — runs a regex per response line and maps matches.
433
+ *
434
+ * @param command The LIST command to send
435
+ * @param lineRegex Regex applied to each response line
436
+ * @param map Maps a matched line to a result item
437
+ */
438
+ async parseList(command, lineRegex, map) {
439
+ const lines = await this.sendCommand(command, true);
440
+ const result = [];
441
+ for (const line of lines) {
442
+ const match = lineRegex.exec(line);
443
+ if (match) {
444
+ result.push(map(match));
445
+ }
446
+ }
447
+ return result;
448
+ }
449
+ /**
450
+ * Get a single variable value.
451
+ *
452
+ * @param ups UPS name
453
+ * @param varName Variable name
454
+ */
455
+ async getVar(ups, varName) {
456
+ const lines = await this.sendCommand(`GET VAR ${ups} ${varName}`, false);
457
+ const match = /^VAR\s+\S+\s+\S+\s+"((?:[^"\\]|\\.)*)"/.exec(lines[0]);
458
+ if (!match) {
459
+ throw new Error(`Unexpected GET VAR response: ${lines[0]}`);
460
+ }
461
+ return unescapeNut(match[1]);
462
+ }
463
+ /**
464
+ * Set a writable variable.
465
+ *
466
+ * @param ups UPS name
467
+ * @param varName Variable name
468
+ * @param value New value
469
+ */
470
+ async setVar(ups, varName, value) {
471
+ await this.sendCommand(`SET VAR ${ups} ${varName} "${escapeNut(value)}"`, false);
472
+ }
473
+ /**
474
+ * Execute an instant command.
475
+ *
476
+ * @param ups UPS name
477
+ * @param cmd Command name
478
+ */
479
+ async instCmd(ups, cmd) {
480
+ await this.sendCommand(`INSTCMD ${ups} ${cmd}`, false);
481
+ }
482
+ /**
483
+ * Authenticate with the NUT server.
484
+ *
485
+ * @param username NUT username
486
+ * @param password NUT password
487
+ */
488
+ async authenticate(username, password) {
489
+ await this.sendCommand(`USERNAME ${username}`, false);
490
+ await this.sendCommand(`PASSWORD ${password}`, false);
491
+ }
492
+ /**
493
+ * Register as monitoring client for a UPS.
494
+ *
495
+ * @param ups UPS name
496
+ */
497
+ async login(ups) {
498
+ await this.sendCommand(`LOGIN ${ups}`, false);
499
+ }
500
+ /** Best-effort LOGOUT (graceful lifecycle; ignores errors). */
501
+ async logout() {
502
+ try {
503
+ await this.sendCommand("LOGOUT", false);
504
+ } catch {
505
+ }
506
+ }
507
+ sendCommand(command, multiLine) {
508
+ return new Promise((resolve, reject) => {
509
+ if (!this.connected || !this.socket) {
510
+ reject(new Error("Not connected"));
511
+ return;
512
+ }
513
+ const entry = { command, resolve, reject, timer: null, multiLine };
514
+ this.queue.push(entry);
515
+ if (!this.active) {
516
+ this.processQueue();
517
+ }
518
+ });
519
+ }
520
+ /**
521
+ * Drop the desynced connection and reconnect on a clean stream (resync).
522
+ *
523
+ * @param command The command that timed out
524
+ */
525
+ resyncAfterTimeout(command) {
526
+ var _a, _b;
527
+ if (((_a = this.active) == null ? void 0 : _a.command) === command) {
528
+ this.active = null;
529
+ }
530
+ this.connected = false;
531
+ this.cancelAll();
532
+ (_b = this.socket) == null ? void 0 : _b.destroy();
533
+ this.scheduleReconnect();
534
+ }
535
+ processQueue() {
536
+ var _a, _b;
537
+ if (this.active || this.queue.length === 0) {
538
+ return;
539
+ }
540
+ const entry = this.queue.shift();
541
+ this.active = entry;
542
+ entry.timer = this.setTimer(() => {
543
+ entry.reject(new NutTimeoutError(entry.command));
544
+ this.resyncAfterTimeout(entry.command);
545
+ }, this.commandTimeout);
546
+ (_a = this.log) == null ? void 0 : _a.debug(`>> ${entry.command}`);
547
+ if (entry.multiLine) {
548
+ this.multiLineBuffer = [];
549
+ const query = entry.command.replace(/^LIST\s+/, "");
550
+ this.multiLineExpectedEnd = `END LIST ${query}`;
551
+ }
552
+ (_b = this.socket) == null ? void 0 : _b.write(`${entry.command}
553
+ `);
554
+ }
555
+ onData(data) {
556
+ this.buffer += data;
557
+ const lines = this.buffer.split("\n");
558
+ this.buffer = lines.pop();
559
+ for (const line of lines) {
560
+ this.processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
561
+ }
562
+ }
563
+ processLine(line) {
564
+ var _a;
565
+ if (!this.active) {
566
+ (_a = this.log) == null ? void 0 : _a.debug(`<< (no active command) ${line}`);
567
+ return;
568
+ }
569
+ if (line.startsWith("ERR ")) {
570
+ const code = line.slice(4).trim();
571
+ this.clearTimer(this.active.timer);
572
+ const entry2 = this.active;
573
+ this.active = null;
574
+ this.multiLineBuffer = [];
575
+ this.multiLineExpectedEnd = "";
576
+ entry2.reject(new NutError(code));
577
+ this.processQueue();
578
+ return;
579
+ }
580
+ if (this.active.multiLine) {
581
+ if (line.startsWith("BEGIN LIST ")) {
582
+ return;
583
+ }
584
+ if (line === this.multiLineExpectedEnd) {
585
+ this.clearTimer(this.active.timer);
586
+ const entry2 = this.active;
587
+ const result = [...this.multiLineBuffer];
588
+ this.active = null;
589
+ this.multiLineBuffer = [];
590
+ this.multiLineExpectedEnd = "";
591
+ entry2.resolve(result);
592
+ this.processQueue();
593
+ return;
594
+ }
595
+ this.multiLineBuffer.push(line);
596
+ return;
597
+ }
598
+ this.clearTimer(this.active.timer);
599
+ const entry = this.active;
600
+ this.active = null;
601
+ entry.resolve([line]);
602
+ this.processQueue();
603
+ }
604
+ scheduleReconnect() {
605
+ var _a;
606
+ if (this.destroyed || this.reconnectTimer || !this.persistent) {
607
+ return;
608
+ }
609
+ this.reconnectAttempt += 1;
610
+ const delay = (0, import_coerce.computeReconnectDelay)(this.reconnectAttempt, RECONNECT_BASE_MS, RECONNECT_MAX_MS);
611
+ (_a = this.log) == null ? void 0 : _a.debug(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
612
+ this.reconnectTimer = this.setTimer(() => {
613
+ var _a2;
614
+ this.reconnectTimer = null;
615
+ (_a2 = this.log) == null ? void 0 : _a2.debug(`Attempting reconnect to ${this.host}:${this.port}`);
616
+ this.attemptConnect();
617
+ }, delay);
618
+ }
619
+ }
620
+ function unescapeNut(s) {
621
+ return s.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
622
+ }
623
+ function escapeNut(s) {
624
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
625
+ }
626
+ // Annotate the CommonJS export names for ESM import in node:
627
+ 0 && (module.exports = {
628
+ NutClient,
629
+ NutError,
630
+ NutTimeoutError,
631
+ isTlsConfigError
632
+ });
633
+ //# sourceMappingURL=nut-client.js.map