@slaplacian/tsups 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +82 -2
- package/package.json +42 -4
- package/src/args.js +51 -0
- package/src/cli.js +313 -0
- package/src/protocol.js +197 -0
- package/src/serial.js +147 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Léo Victor Cruz Vasconcelos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tsups
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cross-platform (macOS / Linux / Windows) command-line monitor for TS Shara UPS
|
|
4
|
+
units that speak the Megatec **Q1** serial protocol over USB.
|
|
5
|
+
|
|
6
|
+
The UPS speaks the Megatec **Q1** protocol over a USB-serial port at 2400 baud:
|
|
7
|
+
|
|
8
|
+
| Command | Meaning |
|
|
9
|
+
|---------|------------------------------------------|
|
|
10
|
+
| `I` | Identify (reply must contain `TS`) |
|
|
11
|
+
| `F` | Rating info |
|
|
12
|
+
| `Q1` | Status (polled every 2 s) |
|
|
13
|
+
| `T` / `TL` / `Tnn` | 10 s test / test until low / nn-minute test |
|
|
14
|
+
| `Q` | Toggle beeper |
|
|
15
|
+
| `Sn` | Turn output off after n minutes |
|
|
16
|
+
| `C`, `CT` | Cancel shutdown / cancel test |
|
|
17
|
+
|
|
18
|
+
### Hardware quirks (TS Shara USB interface, STM32 `0483:5740`)
|
|
19
|
+
|
|
20
|
+
Measured on a real unit; handled in `src/serial.js`:
|
|
21
|
+
|
|
22
|
+
- A reply is held until the host writes again. `tsups` sends a bare `\r`
|
|
23
|
+
every 300 ms after a command to release it.
|
|
24
|
+
- A command sent right after a reply is silently dropped. `tsups` waits
|
|
25
|
+
500 ms after the last reply before sending the next command.
|
|
26
|
+
- `F` reports the input voltage as `Univ.` (universal / bivolt).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install -g @slaplacian/tsups
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
On Linux, add yourself to the `dialout` group (or `uucp` on Arch) to access
|
|
35
|
+
`/dev/ttyUSB*`.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
tsups detect # find the UPS
|
|
41
|
+
tsups status # one reading
|
|
42
|
+
tsups status --json
|
|
43
|
+
tsups monitor # continuous; Ctrl+C to stop
|
|
44
|
+
tsups monitor --events-only --log ups.jsonl \
|
|
45
|
+
--on-event 'osascript -e "display notification \"$UPS_MESSAGE\" with title \"UPS\""'
|
|
46
|
+
tsups test quick | low | 5
|
|
47
|
+
tsups beep
|
|
48
|
+
tsups shutdown 3 --yes # UPS output OFF in 3 minutes
|
|
49
|
+
tsups cancel-shutdown
|
|
50
|
+
tsups raw Q1 # send anything, print reply
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Events: `UTILITY_FAIL`, `UTILITY_RESTORED`, `LOW_BATTERY`, `OK_BATTERY`,
|
|
54
|
+
`BYPASS_ENABLED/DISABLED`, `UPS_FAULT`, `BATTERY_TEST_STARTED/FINISHED`,
|
|
55
|
+
`SHUTDOWN_PENDING/CANCELLED`, `BEEP_ENABLED/DISABLED`, `COMM_OK`, `COMM_FAIL`.
|
|
56
|
+
|
|
57
|
+
To shut down the computer on power loss, use `--on-event` with a script that
|
|
58
|
+
checks `$UPS_EVENT` (e.g. `LOW_BATTERY`) and runs `shutdown -h +1`.
|
|
59
|
+
|
|
60
|
+
## Notes
|
|
61
|
+
|
|
62
|
+
- Status bit 6 is reported per the Megatec spec (1 = shutdown pending).
|
|
63
|
+
- Q1 replies with a malformed status field are rejected instead of parsed.
|
|
64
|
+
- `tsups monitor --json` output can be sent to InfluxDB with
|
|
65
|
+
[`upsflux`](https://github.com/slaplacian/upsflux).
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
npm install
|
|
71
|
+
npm test # parsing, battery %, event detection, command formatting
|
|
72
|
+
npm link # puts your working copy of `tsups` on your PATH
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Disclaimer
|
|
76
|
+
|
|
77
|
+
Independent project, not affiliated with or endorsed by TS Shara. Product
|
|
78
|
+
names are trademarks of their respective owners. Sending `S`/`T` commands
|
|
79
|
+
switches the UPS output or runs battery tests; use at your own risk.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@slaplacian/tsups",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cross-platform CLI monitor for TS Shara UPS units (Megatec/Q1 serial protocol)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ups",
|
|
7
|
+
"megatec",
|
|
8
|
+
"q1",
|
|
9
|
+
"ts-shara",
|
|
10
|
+
"nobreak",
|
|
11
|
+
"serial",
|
|
12
|
+
"cli",
|
|
13
|
+
"monitor"
|
|
14
|
+
],
|
|
15
|
+
"author": "Léo Victor Cruz Vasconcelos",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/slaplacian/tsups.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/slaplacian/tsups#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/slaplacian/tsups/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"tsups": "src/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"serialport": "^13.0.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/args.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
|
|
3
|
+
// Levenshtein distance, used to suggest the option the user probably meant.
|
|
4
|
+
function distance(a, b) {
|
|
5
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
6
|
+
for (let i = 1; i <= a.length; i++) {
|
|
7
|
+
const row = [i];
|
|
8
|
+
for (let j = 1; j <= b.length; j++) {
|
|
9
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
10
|
+
}
|
|
11
|
+
prev = row;
|
|
12
|
+
}
|
|
13
|
+
return prev[b.length];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function suggestOption(given, names) {
|
|
17
|
+
const name = given.replace(/^-+/, '').split('=')[0];
|
|
18
|
+
let best = null;
|
|
19
|
+
for (const n of names) {
|
|
20
|
+
const d = distance(name, n);
|
|
21
|
+
if (d <= Math.max(2, Math.floor(n.length / 3)) && (!best || d < best.d)) best = { n, d };
|
|
22
|
+
}
|
|
23
|
+
return best && `--${best.n}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// parseArgs without the stack trace: prints a one-line error, a suggestion for
|
|
27
|
+
// mistyped options, and exits with status 2 (usage error).
|
|
28
|
+
export function parseCli(config, prog) {
|
|
29
|
+
try {
|
|
30
|
+
return parseArgs(config);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (!err.code?.startsWith('ERR_PARSE_ARGS')) throw err;
|
|
33
|
+
let msg = `${prog}: ${err.message.split('. ')[0].replace(/\.$/, '')}`;
|
|
34
|
+
if (err.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
|
|
35
|
+
const given = err.message.match(/'([^']+)'/)?.[1] ?? '';
|
|
36
|
+
const hint = suggestOption(given, Object.keys(config.options));
|
|
37
|
+
if (hint) msg += `; did you mean ${hint}?`;
|
|
38
|
+
}
|
|
39
|
+
console.error(`${msg}\nRun "${prog} --help" for the list of options.`);
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// When the reading end of a pipe goes away (`| head`, or the next program in
|
|
45
|
+
// the pipeline exited), stop quietly instead of crashing with EPIPE.
|
|
46
|
+
export function exitOnClosedPipe(onClose = () => process.exit(0)) {
|
|
47
|
+
process.stdout.on('error', (err) => {
|
|
48
|
+
if (err.code === 'EPIPE') onClose();
|
|
49
|
+
else throw err;
|
|
50
|
+
});
|
|
51
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { appendFileSync } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
CMD,
|
|
6
|
+
parseQ1,
|
|
7
|
+
parseRating,
|
|
8
|
+
parseInfo,
|
|
9
|
+
isQ1Reply,
|
|
10
|
+
isRatingReply,
|
|
11
|
+
isInfoReply,
|
|
12
|
+
diffStatus,
|
|
13
|
+
testCommand,
|
|
14
|
+
shutdownCommand,
|
|
15
|
+
} from './protocol.js';
|
|
16
|
+
import { UpsConnection, detectPort, listPorts } from './serial.js';
|
|
17
|
+
import { parseCli, exitOnClosedPipe } from './args.js';
|
|
18
|
+
|
|
19
|
+
const HELP = `tsups - TS Shara UPS monitor (Megatec Q1 protocol, 2400 baud)
|
|
20
|
+
|
|
21
|
+
Usage: tsups <command> [options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
ports List serial ports
|
|
25
|
+
detect Probe ports for a TS UPS (sends "I", expects "TS")
|
|
26
|
+
info Show identification (I) and rating (F) replies
|
|
27
|
+
status Read one Q1 status sample
|
|
28
|
+
monitor Poll continuously and report events
|
|
29
|
+
test [quick|low|<min>] Battery test: 10 s (default), until low, or N minutes
|
|
30
|
+
cancel-test Cancel a running battery test
|
|
31
|
+
beep Toggle the UPS beeper
|
|
32
|
+
shutdown <min> --yes Turn UPS output OFF after <min> (0.2-0.9 or 1-10)
|
|
33
|
+
cancel-shutdown Cancel a pending shutdown
|
|
34
|
+
raw <cmd> Send a raw command (CR appended) and print the reply
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
-p, --port <path> Serial port (default: auto-detect)
|
|
38
|
+
-j, --json Machine-readable JSON output
|
|
39
|
+
-i, --interval <sec> Monitor poll interval (default 2)
|
|
40
|
+
--events-only Monitor: print events only, not every sample
|
|
41
|
+
--log <file> Monitor: append samples and events as JSON lines
|
|
42
|
+
--on-event <cmd> Monitor: run a shell command on each event; gets
|
|
43
|
+
UPS_EVENT, UPS_MESSAGE, UPS_SEVERITY, UPS_JSON env vars
|
|
44
|
+
--shutdown-on-fail <min> Monitor: send S<min> to the UPS on utility failure
|
|
45
|
+
--shutdown-on-low <min> Monitor: send S<min> to the UPS on low battery
|
|
46
|
+
--match <text> Detect: identification substring (default "TS")
|
|
47
|
+
-y, --yes Confirm commands that cut power
|
|
48
|
+
-v, --verbose Print probe details
|
|
49
|
+
-h, --help Show this help
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const { values: opt, positionals } = parseCli({
|
|
53
|
+
allowPositionals: true,
|
|
54
|
+
options: {
|
|
55
|
+
port: { type: 'string', short: 'p' },
|
|
56
|
+
json: { type: 'boolean', short: 'j' },
|
|
57
|
+
interval: { type: 'string', short: 'i', default: '2' },
|
|
58
|
+
'events-only': { type: 'boolean' },
|
|
59
|
+
log: { type: 'string' },
|
|
60
|
+
'on-event': { type: 'string' },
|
|
61
|
+
'shutdown-on-fail': { type: 'string' },
|
|
62
|
+
'shutdown-on-low': { type: 'string' },
|
|
63
|
+
match: { type: 'string', default: 'TS' },
|
|
64
|
+
yes: { type: 'boolean', short: 'y' },
|
|
65
|
+
verbose: { type: 'boolean', short: 'v' },
|
|
66
|
+
help: { type: 'boolean', short: 'h' },
|
|
67
|
+
},
|
|
68
|
+
}, 'tsups');
|
|
69
|
+
|
|
70
|
+
exitOnClosedPipe();
|
|
71
|
+
|
|
72
|
+
const [command, ...args] = positionals;
|
|
73
|
+
const verbose = (msg) => opt.verbose && console.error(`[probe] ${msg}`);
|
|
74
|
+
const trace = (msg) => opt.verbose && console.error(`[serial] ${msg}`);
|
|
75
|
+
const out = (human, data) => console.log(opt.json ? JSON.stringify(data) : human);
|
|
76
|
+
|
|
77
|
+
async function resolvePort() {
|
|
78
|
+
if (opt.port) return opt.port;
|
|
79
|
+
const found = await detectPort({ match: opt.match, log: verbose });
|
|
80
|
+
if (!found) throw new Error('no UPS found; check the USB cable or pass --port');
|
|
81
|
+
verbose(`using ${found.path}`);
|
|
82
|
+
return found.path;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function withConnection(fn) {
|
|
86
|
+
const conn = new UpsConnection(await resolvePort(), { trace });
|
|
87
|
+
await conn.open();
|
|
88
|
+
try {
|
|
89
|
+
return await fn(conn);
|
|
90
|
+
} finally {
|
|
91
|
+
await conn.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readRating(conn) {
|
|
96
|
+
try {
|
|
97
|
+
return parseRating(await conn.query(CMD.rating, { accept: isRatingReply }));
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatSample(s) {
|
|
104
|
+
const f = s.flags;
|
|
105
|
+
const mode = f.utilityFail ? 'ON BATTERY' : 'ONLINE';
|
|
106
|
+
const extras = [
|
|
107
|
+
f.batteryLow && 'BATTERY LOW',
|
|
108
|
+
f.bypass && 'BYPASS',
|
|
109
|
+
f.upsFailed && 'FAULT',
|
|
110
|
+
f.testInProgress && 'TESTING',
|
|
111
|
+
f.shutdownActive && 'SHUTDOWN PENDING',
|
|
112
|
+
].filter(Boolean);
|
|
113
|
+
return (
|
|
114
|
+
`${mode}${extras.length ? ' [' + extras.join(', ') + ']' : ''} ` +
|
|
115
|
+
`in ${s.inputVoltage}V out ${s.outputVoltage}V load ${s.loadPercent}% ` +
|
|
116
|
+
`${s.frequency}Hz batt ${s.batteryVoltage}V (${s.batterySOC}%) ` +
|
|
117
|
+
`${s.temperature}°C beep ${f.beeperOn ? 'on' : 'off'}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const commands = {
|
|
122
|
+
async ports() {
|
|
123
|
+
const ports = await listPorts();
|
|
124
|
+
out(
|
|
125
|
+
ports.map((p) => `${p.path}\t${p.manufacturer ?? ''}\t${p.vendorId ?? ''}:${p.productId ?? ''}`).join('\n') ||
|
|
126
|
+
'no serial ports found',
|
|
127
|
+
ports,
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async detect() {
|
|
132
|
+
const found = await detectPort({ match: opt.match, log: verbose });
|
|
133
|
+
if (!found) {
|
|
134
|
+
out('no UPS found', { found: false });
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
out(`${found.path}\t${found.info}`, { found: true, ...found });
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async info() {
|
|
142
|
+
await withConnection(async (conn) => {
|
|
143
|
+
const info = parseInfo(await conn.query(CMD.identify, { accept: isInfoReply }));
|
|
144
|
+
const rating = await readRating(conn);
|
|
145
|
+
out(
|
|
146
|
+
`Company: ${info.company}\nModel: ${info.model}\nVersion: ${info.version}\nRaw: ${info.raw}\n` +
|
|
147
|
+
(rating
|
|
148
|
+
? `Rating: ${typeof rating.voltage === 'number' ? rating.voltage + 'V' : rating.voltage} ${rating.current}A, battery ${rating.batteryVoltage}V, ${rating.frequency}Hz`
|
|
149
|
+
: 'Rating: (no reply to F)'),
|
|
150
|
+
{ port: conn.path, info, rating },
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async status() {
|
|
156
|
+
await withConnection(async (conn) => {
|
|
157
|
+
const rating = await readRating(conn);
|
|
158
|
+
const line = await conn.query(CMD.status, { accept: isQ1Reply });
|
|
159
|
+
const sample = parseQ1(line, rating);
|
|
160
|
+
if (!sample) throw new Error(`unrecognised Q1 reply: ${JSON.stringify(line)}`);
|
|
161
|
+
out(formatSample(sample), { port: conn.path, ts: Date.now(), ...sample });
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async test() {
|
|
166
|
+
const kind = args[0] ?? 'quick';
|
|
167
|
+
const cmd = kind === 'quick' ? CMD.quickTest : kind === 'low' ? CMD.lowTest : testCommand(kind);
|
|
168
|
+
await withConnection((conn) => conn.send(cmd));
|
|
169
|
+
out(`sent ${JSON.stringify(cmd)}`, { sent: cmd });
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async 'cancel-test'() {
|
|
173
|
+
await withConnection((conn) => conn.send(CMD.cancelTest));
|
|
174
|
+
out('battery test cancelled', { sent: CMD.cancelTest });
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async beep() {
|
|
178
|
+
await withConnection((conn) => conn.send(CMD.toggleBeep));
|
|
179
|
+
out('beeper toggled', { sent: CMD.toggleBeep });
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async shutdown() {
|
|
183
|
+
if (args[0] === undefined) throw new Error('usage: tsups shutdown <minutes> --yes');
|
|
184
|
+
const cmd = shutdownCommand(args[0]);
|
|
185
|
+
if (!opt.yes) throw new Error(`this turns UPS output OFF after ${args[0]} min; re-run with --yes`);
|
|
186
|
+
await withConnection((conn) => conn.send(cmd));
|
|
187
|
+
out(`sent ${JSON.stringify(cmd)}; cancel with "tsups cancel-shutdown"`, { sent: cmd });
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async 'cancel-shutdown'() {
|
|
191
|
+
await withConnection((conn) => conn.send(CMD.cancelShutdown));
|
|
192
|
+
out('shutdown cancelled', { sent: CMD.cancelShutdown });
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async raw() {
|
|
196
|
+
if (!args[0]) throw new Error('usage: tsups raw <command>');
|
|
197
|
+
await withConnection(async (conn) => {
|
|
198
|
+
const reply = await conn.query(`${args[0]}\r`).catch((e) => `(${e.message})`);
|
|
199
|
+
out(reply, { sent: args[0], reply });
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async monitor() {
|
|
204
|
+
const intervalMs = Math.max(1, Number(opt.interval)) * 1000;
|
|
205
|
+
const failCmd = opt['shutdown-on-fail'] && shutdownCommand(opt['shutdown-on-fail']);
|
|
206
|
+
const lowCmd = opt['shutdown-on-low'] && shutdownCommand(opt['shutdown-on-low']);
|
|
207
|
+
let last = null;
|
|
208
|
+
let conn = null;
|
|
209
|
+
|
|
210
|
+
const record = (obj) => {
|
|
211
|
+
if (opt.log) appendFileSync(opt.log, JSON.stringify(obj) + '\n');
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const emit = async (ev, sample) => {
|
|
215
|
+
const event = { type: 'event', ts: Date.now(), ...ev, sample };
|
|
216
|
+
record(event);
|
|
217
|
+
const stamp = new Date(event.ts).toISOString();
|
|
218
|
+
out(`${stamp} ${ev.severity.toUpperCase().padEnd(8)} ${ev.code}: ${ev.message}`, event);
|
|
219
|
+
if (opt['on-event']) {
|
|
220
|
+
spawn(opt['on-event'], {
|
|
221
|
+
shell: true,
|
|
222
|
+
stdio: 'inherit',
|
|
223
|
+
env: {
|
|
224
|
+
...process.env,
|
|
225
|
+
UPS_EVENT: ev.code,
|
|
226
|
+
UPS_MESSAGE: ev.message,
|
|
227
|
+
UPS_SEVERITY: ev.severity,
|
|
228
|
+
UPS_JSON: JSON.stringify(event),
|
|
229
|
+
},
|
|
230
|
+
}).on('error', (e) => console.error(`[on-event] ${e.message}`));
|
|
231
|
+
}
|
|
232
|
+
const shutdown = ev.code === 'UTILITY_FAIL' ? failCmd : ev.code === 'LOW_BATTERY' ? lowCmd : null;
|
|
233
|
+
if (shutdown && conn?.isOpen) {
|
|
234
|
+
await conn.send(shutdown);
|
|
235
|
+
console.error(`[monitor] sent UPS shutdown ${JSON.stringify(shutdown)}`);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const connect = async () => {
|
|
240
|
+
const c = new UpsConnection(await resolvePort(), { trace });
|
|
241
|
+
await c.open();
|
|
242
|
+
c.rating = await readRating(c);
|
|
243
|
+
return c;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
let stopping = false;
|
|
247
|
+
const stop = async () => {
|
|
248
|
+
stopping = true;
|
|
249
|
+
await conn?.close();
|
|
250
|
+
process.exit(0);
|
|
251
|
+
};
|
|
252
|
+
process.on('SIGINT', stop);
|
|
253
|
+
process.on('SIGTERM', stop);
|
|
254
|
+
|
|
255
|
+
let misses = 0;
|
|
256
|
+
while (!stopping) {
|
|
257
|
+
if (!conn?.isOpen) {
|
|
258
|
+
try {
|
|
259
|
+
conn = await connect();
|
|
260
|
+
misses = 0;
|
|
261
|
+
await emit({ code: 'COMM_OK', message: `Connected on ${conn.path}`, severity: 'success' });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (last !== 'offline') {
|
|
264
|
+
await emit({ code: 'COMM_FAIL', message: err.message, severity: 'warning' });
|
|
265
|
+
last = 'offline';
|
|
266
|
+
}
|
|
267
|
+
await new Promise((r) => setTimeout(r, 8000)); // rescan period
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const sample = parseQ1(await conn.query(CMD.status, { accept: isQ1Reply }), conn.rating);
|
|
274
|
+
if (sample) {
|
|
275
|
+
misses = 0;
|
|
276
|
+
const prev = typeof last === 'string' && last !== 'offline' ? last : null;
|
|
277
|
+
for (const ev of diffStatus(sample.status, prev)) await emit(ev, sample);
|
|
278
|
+
last = sample.status;
|
|
279
|
+
record({ type: 'sample', ts: Date.now(), ...sample });
|
|
280
|
+
if (!opt['events-only']) {
|
|
281
|
+
out(`${new Date().toISOString()} ${formatSample(sample)}`, { type: 'sample', ts: Date.now(), ...sample });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
if (++misses >= 3) {
|
|
286
|
+
await emit({ code: 'COMM_FAIL', message: `Lost contact with UPS: ${err.message}`, severity: 'warning' });
|
|
287
|
+
last = 'offline';
|
|
288
|
+
await conn.close();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
async function main() {
|
|
297
|
+
if (opt.help || !command) {
|
|
298
|
+
console.log(HELP);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const run = commands[command];
|
|
302
|
+
if (!run) {
|
|
303
|
+
console.error(`unknown command: ${command}\n\n${HELP}`);
|
|
304
|
+
process.exitCode = 2;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
await run();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
main().catch((err) => {
|
|
311
|
+
console.error(`tsups: ${err.message}`);
|
|
312
|
+
process.exitCode = 1;
|
|
313
|
+
});
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Megatec / Voltronic "Q1" serial protocol, as used by TS Shara UPS units.
|
|
2
|
+
|
|
3
|
+
export const BAUD_RATE = 2400;
|
|
4
|
+
|
|
5
|
+
// Exact byte sequences sent to the UPS.
|
|
6
|
+
export const CMD = {
|
|
7
|
+
identify: 'I\r\n',
|
|
8
|
+
status: 'Q1\r\n',
|
|
9
|
+
rating: 'F\r',
|
|
10
|
+
quickTest: 'T\r', // 10-second battery test
|
|
11
|
+
lowTest: 'TL\r', // test until battery low
|
|
12
|
+
toggleBeep: 'Q\r',
|
|
13
|
+
// Other standard Megatec commands.
|
|
14
|
+
cancelShutdown: 'C\r',
|
|
15
|
+
cancelTest: 'CT\r',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Bit order of the 8-char status field in a Q1 reply (b7..b0, left to right).
|
|
19
|
+
export const STATUS_BITS = [
|
|
20
|
+
'utilityFail',
|
|
21
|
+
'batteryLow',
|
|
22
|
+
'bypass',
|
|
23
|
+
'upsFailed',
|
|
24
|
+
'standby',
|
|
25
|
+
'testInProgress',
|
|
26
|
+
'shutdownActive',
|
|
27
|
+
'beeperOn',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const toNum = (s) => {
|
|
31
|
+
const n = parseFloat(s);
|
|
32
|
+
return Number.isNaN(n) ? 0 : n;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const stripPrefix = (line) => line.trim().replace(/^[#(]/, '').trim();
|
|
36
|
+
|
|
37
|
+
// Lead-acid resting voltage -> state-of-charge lookup for 12 V and 24 V banks.
|
|
38
|
+
const SOC_POINTS = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
|
|
39
|
+
const SOC_VOLTS = {
|
|
40
|
+
12: [12.2, 12.09, 11.98, 11.87, 11.76, 11.64, 11.53, 11.42, 11.31, 10.91, 10.5],
|
|
41
|
+
24: [24.4, 24.18, 23.96, 23.74, 23.52, 23.28, 23.06, 22.84, 22.62, 21.82, 21.0],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function calcSOC(volts, nominal) {
|
|
45
|
+
const table = SOC_VOLTS[nominal === 24 ? 24 : 12];
|
|
46
|
+
if (volts > table[0]) return 100;
|
|
47
|
+
if (volts <= table[table.length - 1]) return 0;
|
|
48
|
+
for (let i = 0; i < table.length - 1; i++) {
|
|
49
|
+
if (volts <= table[i] && volts > table[i + 1]) {
|
|
50
|
+
const slope = (SOC_POINTS[i] - SOC_POINTS[i + 1]) / (table[i] - table[i + 1]);
|
|
51
|
+
return Math.round(Math.max(0, Math.min(100, SOC_POINTS[i] - slope * (table[i] - volts))));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Without a rating reply, guess the bank voltage from the live reading (> 18 V => 24 V).
|
|
58
|
+
export function nominalBatteryVoltage(batteryVolts, rating) {
|
|
59
|
+
if (rating?.batteryVoltage) {
|
|
60
|
+
const n = Math.round(rating.batteryVoltage / 12) * 12;
|
|
61
|
+
if (n === 12 || n === 24) return n;
|
|
62
|
+
}
|
|
63
|
+
return batteryVolts > 18 ? 24 : 12;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function decodeStatus(bits) {
|
|
67
|
+
const out = {};
|
|
68
|
+
STATUS_BITS.forEach((name, i) => {
|
|
69
|
+
out[name] = bits[i] === '1';
|
|
70
|
+
});
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// "(127.0 127.0 127.0 000 60.0 13.5 30.0 00000001" -> reading object, or null.
|
|
75
|
+
export function parseQ1(line, rating) {
|
|
76
|
+
const parts = stripPrefix(line).split(/\s+/);
|
|
77
|
+
if (parts.length < 8) return null;
|
|
78
|
+
const [input, inputFault, output, load, freq, battery, temp, status] = parts;
|
|
79
|
+
if (!/^[01]{8}$/.test(status)) return null;
|
|
80
|
+
const batteryVoltage = toNum(battery);
|
|
81
|
+
const nominal = nominalBatteryVoltage(batteryVoltage, rating);
|
|
82
|
+
return {
|
|
83
|
+
inputVoltage: toNum(input),
|
|
84
|
+
inputFaultVoltage: toNum(inputFault),
|
|
85
|
+
outputVoltage: toNum(output),
|
|
86
|
+
loadPercent: toNum(load),
|
|
87
|
+
frequency: toNum(freq),
|
|
88
|
+
batteryVoltage,
|
|
89
|
+
batteryNominal: nominal,
|
|
90
|
+
batterySOC: calcSOC(batteryVoltage, nominal),
|
|
91
|
+
temperature: toNum(temp),
|
|
92
|
+
status,
|
|
93
|
+
flags: decodeStatus(status),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const isQ1Reply = (line) => line.trim().startsWith('(') && parseQ1(line) !== null;
|
|
98
|
+
|
|
99
|
+
// "#Univ. 100 24.00 60.0": four fields, the last two numeric.
|
|
100
|
+
export const isRatingReply = (line) => {
|
|
101
|
+
if (!line.trim().startsWith('#')) return false;
|
|
102
|
+
const parts = stripPrefix(line).split(/\s+/);
|
|
103
|
+
return parts.length === 4 && parts.slice(2).every((p) => /^\d+(\.\d+)?$/.test(p));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const isInfoReply = (line) => line.trim().startsWith('#') && !isRatingReply(line);
|
|
107
|
+
|
|
108
|
+
// "#220.0 000 024.0 50.0" -> rated voltage, current, battery voltage, frequency.
|
|
109
|
+
// TS Shara bivolt units report the voltage as "Univ." (universal input).
|
|
110
|
+
export function parseRating(line) {
|
|
111
|
+
if (!isRatingReply(line)) return null;
|
|
112
|
+
const [voltage, current, batteryVoltage, frequency] = stripPrefix(line).split(/\s+/);
|
|
113
|
+
return {
|
|
114
|
+
voltage: Number.isNaN(parseFloat(voltage)) ? voltage : toNum(voltage),
|
|
115
|
+
current: toNum(current),
|
|
116
|
+
batteryVoltage: toNum(batteryVoltage),
|
|
117
|
+
frequency: toNum(frequency),
|
|
118
|
+
raw: line.trim(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// "#TS -222 250514 Senoid 15 V010207057": Megatec fixed columns of
|
|
123
|
+
// 15/10/10 chars separated by single spaces.
|
|
124
|
+
export function parseInfo(line) {
|
|
125
|
+
const body = line.trim().replace(/^#/, '');
|
|
126
|
+
return {
|
|
127
|
+
company: body.slice(0, 15).trim(),
|
|
128
|
+
model: body.slice(16, 26).trim(),
|
|
129
|
+
version: body.slice(27, 37).trim(),
|
|
130
|
+
raw: line.trim(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Events emitted when a status bit flips.
|
|
135
|
+
const EVENT_DEFS = {
|
|
136
|
+
utilityFail: [
|
|
137
|
+
{ code: 'UTILITY_RESTORED', message: 'Utility power restored', severity: 'success' },
|
|
138
|
+
{ code: 'UTILITY_FAIL', message: 'Utility power failure, running on battery', severity: 'warning' },
|
|
139
|
+
],
|
|
140
|
+
batteryLow: [
|
|
141
|
+
{ code: 'OK_BATTERY', message: 'Battery OK', severity: 'info' },
|
|
142
|
+
{ code: 'LOW_BATTERY', message: 'Battery low', severity: 'critical' },
|
|
143
|
+
],
|
|
144
|
+
bypass: [
|
|
145
|
+
{ code: 'BYPASS_DISABLED', message: 'Bypass inactive', severity: 'info' },
|
|
146
|
+
{ code: 'BYPASS_ENABLED', message: 'Bypass active', severity: 'info' },
|
|
147
|
+
],
|
|
148
|
+
upsFailed: [
|
|
149
|
+
{ code: 'UPS_FAULT_CLEARED', message: 'UPS fault cleared', severity: 'success' },
|
|
150
|
+
{ code: 'UPS_FAULT', message: 'UPS reports internal fault', severity: 'critical' },
|
|
151
|
+
],
|
|
152
|
+
testInProgress: [
|
|
153
|
+
{ code: 'BATTERY_TEST_FINISHED', message: 'Battery test finished', severity: 'success' },
|
|
154
|
+
{ code: 'BATTERY_TEST_STARTED', message: 'Battery test started', severity: 'success' },
|
|
155
|
+
],
|
|
156
|
+
// Per the Megatec spec, bit 6 = 1 means a shutdown countdown is active.
|
|
157
|
+
shutdownActive: [
|
|
158
|
+
{ code: 'SHUTDOWN_CANCELLED', message: 'UPS shutdown inactive', severity: 'info' },
|
|
159
|
+
{ code: 'SHUTDOWN_PENDING', message: 'UPS shutdown countdown active', severity: 'warning' },
|
|
160
|
+
],
|
|
161
|
+
beeperOn: [
|
|
162
|
+
{ code: 'BEEP_DISABLED', message: 'Beeper disabled', severity: 'info' },
|
|
163
|
+
{ code: 'BEEP_ENABLED', message: 'Beeper enabled', severity: 'info' },
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export function diffStatus(current, previous) {
|
|
168
|
+
if (!previous) return [];
|
|
169
|
+
const events = [];
|
|
170
|
+
STATUS_BITS.forEach((name, i) => {
|
|
171
|
+
const defs = EVENT_DEFS[name];
|
|
172
|
+
if (!defs || current[i] === previous[i]) return;
|
|
173
|
+
events.push({ ...defs[current[i] === '1' ? 1 : 0], bit: name });
|
|
174
|
+
});
|
|
175
|
+
return events;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// T<nn>: battery test for nn minutes (01..99).
|
|
179
|
+
export function testCommand(minutes) {
|
|
180
|
+
const n = Number(minutes);
|
|
181
|
+
if (!Number.isInteger(n) || n < 1 || n > 99) {
|
|
182
|
+
throw new Error('test minutes must be an integer from 1 to 99');
|
|
183
|
+
}
|
|
184
|
+
return `T${String(n).padStart(2, '0')}\r`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// S<n>: shut UPS output down after n minutes; .2-.9 or 01-10 per Megatec spec.
|
|
188
|
+
export function shutdownCommand(minutes) {
|
|
189
|
+
const n = Number(minutes);
|
|
190
|
+
if (n >= 0.2 && n < 1 && Math.round(n * 10) === n * 10) {
|
|
191
|
+
return `S.${Math.round(n * 10)}\r`;
|
|
192
|
+
}
|
|
193
|
+
if (Number.isInteger(n) && n >= 1 && n <= 10) {
|
|
194
|
+
return `S${String(n).padStart(2, '0')}\r`;
|
|
195
|
+
}
|
|
196
|
+
throw new Error('shutdown delay must be 0.2-0.9 or an integer 1-10 (minutes)');
|
|
197
|
+
}
|
package/src/serial.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { SerialPort } from 'serialport';
|
|
2
|
+
import { BAUD_RATE, CMD, isInfoReply } from './protocol.js';
|
|
3
|
+
|
|
4
|
+
export async function listPorts() {
|
|
5
|
+
return SerialPort.list();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// The TS Shara USB interface (STM32 CDC, 0483:5740) holds each reply until the
|
|
9
|
+
// host writes again. A bare CR is ignored by the UPS but releases the reply.
|
|
10
|
+
const KICK = '\r';
|
|
11
|
+
const KICK_EVERY_MS = 300;
|
|
12
|
+
// A command written right after a reply is silently dropped by the UPS;
|
|
13
|
+
// measured on hardware: 0 ms gap fails, 500 ms works.
|
|
14
|
+
const MIN_GAP_AFTER_RX_MS = 500;
|
|
15
|
+
|
|
16
|
+
// Line-oriented connection: replies are CR-terminated, one reply per query.
|
|
17
|
+
export class UpsConnection {
|
|
18
|
+
constructor(path, { baudRate = BAUD_RATE, trace = () => {} } = {}) {
|
|
19
|
+
this.path = path;
|
|
20
|
+
this.trace = trace;
|
|
21
|
+
this.port = new SerialPort({ path, baudRate, autoOpen: false });
|
|
22
|
+
this.buffer = '';
|
|
23
|
+
this.waiter = null;
|
|
24
|
+
this.chain = Promise.resolve();
|
|
25
|
+
this.lastRxAt = 0;
|
|
26
|
+
this.port.on('data', (chunk) => this.#onData(chunk));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async open() {
|
|
30
|
+
await new Promise((resolve, reject) => {
|
|
31
|
+
this.port.open((err) => (err ? reject(err) : resolve()));
|
|
32
|
+
});
|
|
33
|
+
// Release and discard any reply left pending by a previous session.
|
|
34
|
+
await this.#write(KICK);
|
|
35
|
+
await new Promise((r) => setTimeout(r, KICK_EVERY_MS));
|
|
36
|
+
this.buffer = '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
close() {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
if (!this.port.isOpen) return resolve();
|
|
42
|
+
this.port.close(() => resolve());
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get isOpen() {
|
|
47
|
+
return this.port.isOpen;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
onClose(fn) {
|
|
51
|
+
this.port.on('close', fn);
|
|
52
|
+
this.port.on('error', fn);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#onData(chunk) {
|
|
56
|
+
this.buffer += chunk.toString('latin1');
|
|
57
|
+
let idx;
|
|
58
|
+
while ((idx = this.buffer.indexOf('\r')) !== -1) {
|
|
59
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
60
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
61
|
+
if (!line) continue;
|
|
62
|
+
this.lastRxAt = Date.now();
|
|
63
|
+
this.trace(`RX ${JSON.stringify(line)}`);
|
|
64
|
+
if (this.waiter?.accept(line)) {
|
|
65
|
+
const { resolve } = this.waiter;
|
|
66
|
+
this.waiter = null;
|
|
67
|
+
resolve(line);
|
|
68
|
+
} else if (this.waiter) {
|
|
69
|
+
this.trace(' (ignored: not the expected reply)');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#write(cmd) {
|
|
75
|
+
if (cmd !== KICK) this.trace(`TX ${JSON.stringify(cmd)}`);
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
this.port.write(cmd, (err) => {
|
|
78
|
+
if (err) return reject(err);
|
|
79
|
+
this.port.drain((e) => (e ? reject(e) : resolve()));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Serialised so concurrent callers never interleave on the wire.
|
|
85
|
+
#enqueue(fn) {
|
|
86
|
+
const run = this.chain.then(fn, fn);
|
|
87
|
+
this.chain = run.catch(() => {});
|
|
88
|
+
return run;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async #settle() {
|
|
92
|
+
const wait = this.lastRxAt + MIN_GAP_AFTER_RX_MS - Date.now();
|
|
93
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
send(cmd) {
|
|
97
|
+
return this.#enqueue(async () => {
|
|
98
|
+
await this.#settle();
|
|
99
|
+
await this.#write(cmd);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Resolves with the first reply line that passes `accept`.
|
|
104
|
+
query(cmd, { timeout = 3000, accept = () => true } = {}) {
|
|
105
|
+
return this.#enqueue(async () => {
|
|
106
|
+
await this.#settle();
|
|
107
|
+
this.buffer = '';
|
|
108
|
+
let kicker;
|
|
109
|
+
const reply = new Promise((resolve, reject) => {
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
this.waiter = null;
|
|
112
|
+
reject(new Error(`no reply to ${JSON.stringify(cmd)} within ${timeout} ms`));
|
|
113
|
+
}, timeout);
|
|
114
|
+
this.waiter = {
|
|
115
|
+
accept,
|
|
116
|
+
resolve: (line) => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
resolve(line);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}).finally(() => clearInterval(kicker));
|
|
122
|
+
await this.#write(cmd);
|
|
123
|
+
kicker = setInterval(() => this.#write(KICK).catch(() => {}), KICK_EVERY_MS);
|
|
124
|
+
return reply;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Probe: send "I", accept the port if the reply contains "TS".
|
|
130
|
+
export async function detectPort({ match = 'TS', timeout = 3000, log = () => {} } = {}) {
|
|
131
|
+
const ports = await listPorts();
|
|
132
|
+
for (const info of ports) {
|
|
133
|
+
const lower = info.path.toLowerCase();
|
|
134
|
+
if (lower.includes('bluetooth') || lower.includes('blth')) continue;
|
|
135
|
+
const conn = new UpsConnection(info.path, { trace: (m) => log(`${info.path}: ${m}`) });
|
|
136
|
+
try {
|
|
137
|
+
await conn.open();
|
|
138
|
+
const reply = await conn.query(CMD.identify, { timeout, accept: isInfoReply });
|
|
139
|
+
if (reply.includes(match)) return { path: info.path, info: reply };
|
|
140
|
+
} catch (err) {
|
|
141
|
+
log(`${info.path}: ${err.message}`);
|
|
142
|
+
} finally {
|
|
143
|
+
await conn.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|