@slaplacian/tsups 0.0.0-stage → 0.1.1

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 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
- # Temporary Holding Version
1
+ # tsups
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ });
@@ -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,174 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { SerialPort } from 'serialport';
3
+ import { BAUD_RATE, CMD, isInfoReply } from './protocol.js';
4
+
5
+ // USB serial devices by name. Used on Linux when SerialPort.list() cannot run:
6
+ // it shells out to udevadm, which minimal containers do not have.
7
+ export function scanDevPorts(dir = '/dev', names = readdirSync(dir)) {
8
+ return names
9
+ .filter((n) => /^tty(ACM|USB)\d+$/.test(n))
10
+ .sort()
11
+ .map((n) => ({ path: `${dir}/${n}` }));
12
+ }
13
+
14
+ export async function listPorts() {
15
+ try {
16
+ return await SerialPort.list();
17
+ } catch (err) {
18
+ if (process.platform !== 'linux') throw err;
19
+ return scanDevPorts();
20
+ }
21
+ }
22
+
23
+ export const isPermissionError = (err) => /permission denied|EACCES/i.test(`${err?.code} ${err?.message}`);
24
+
25
+ // The TS Shara USB interface (STM32 CDC, 0483:5740) holds each reply until the
26
+ // host writes again. A bare CR is ignored by the UPS but releases the reply.
27
+ const KICK = '\r';
28
+ const KICK_EVERY_MS = 300;
29
+ // A command written right after a reply is silently dropped by the UPS;
30
+ // measured on hardware: 0 ms gap fails, 500 ms works.
31
+ const MIN_GAP_AFTER_RX_MS = 500;
32
+
33
+ // Line-oriented connection: replies are CR-terminated, one reply per query.
34
+ export class UpsConnection {
35
+ constructor(path, { baudRate = BAUD_RATE, trace = () => {} } = {}) {
36
+ this.path = path;
37
+ this.trace = trace;
38
+ this.port = new SerialPort({ path, baudRate, autoOpen: false });
39
+ this.buffer = '';
40
+ this.waiter = null;
41
+ this.chain = Promise.resolve();
42
+ this.lastRxAt = 0;
43
+ this.port.on('data', (chunk) => this.#onData(chunk));
44
+ }
45
+
46
+ async open() {
47
+ await new Promise((resolve, reject) => {
48
+ this.port.open((err) => (err ? reject(err) : resolve()));
49
+ });
50
+ // Release and discard any reply left pending by a previous session.
51
+ await this.#write(KICK);
52
+ await new Promise((r) => setTimeout(r, KICK_EVERY_MS));
53
+ this.buffer = '';
54
+ }
55
+
56
+ close() {
57
+ return new Promise((resolve) => {
58
+ if (!this.port.isOpen) return resolve();
59
+ this.port.close(() => resolve());
60
+ });
61
+ }
62
+
63
+ get isOpen() {
64
+ return this.port.isOpen;
65
+ }
66
+
67
+ onClose(fn) {
68
+ this.port.on('close', fn);
69
+ this.port.on('error', fn);
70
+ }
71
+
72
+ #onData(chunk) {
73
+ this.buffer += chunk.toString('latin1');
74
+ let idx;
75
+ while ((idx = this.buffer.indexOf('\r')) !== -1) {
76
+ const line = this.buffer.slice(0, idx).trim();
77
+ this.buffer = this.buffer.slice(idx + 1);
78
+ if (!line) continue;
79
+ this.lastRxAt = Date.now();
80
+ this.trace(`RX ${JSON.stringify(line)}`);
81
+ if (this.waiter?.accept(line)) {
82
+ const { resolve } = this.waiter;
83
+ this.waiter = null;
84
+ resolve(line);
85
+ } else if (this.waiter) {
86
+ this.trace(' (ignored: not the expected reply)');
87
+ }
88
+ }
89
+ }
90
+
91
+ #write(cmd) {
92
+ if (cmd !== KICK) this.trace(`TX ${JSON.stringify(cmd)}`);
93
+ return new Promise((resolve, reject) => {
94
+ this.port.write(cmd, (err) => {
95
+ if (err) return reject(err);
96
+ this.port.drain((e) => (e ? reject(e) : resolve()));
97
+ });
98
+ });
99
+ }
100
+
101
+ // Serialised so concurrent callers never interleave on the wire.
102
+ #enqueue(fn) {
103
+ const run = this.chain.then(fn, fn);
104
+ this.chain = run.catch(() => {});
105
+ return run;
106
+ }
107
+
108
+ async #settle() {
109
+ const wait = this.lastRxAt + MIN_GAP_AFTER_RX_MS - Date.now();
110
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
111
+ }
112
+
113
+ send(cmd) {
114
+ return this.#enqueue(async () => {
115
+ await this.#settle();
116
+ await this.#write(cmd);
117
+ });
118
+ }
119
+
120
+ // Resolves with the first reply line that passes `accept`.
121
+ query(cmd, { timeout = 3000, accept = () => true } = {}) {
122
+ return this.#enqueue(async () => {
123
+ await this.#settle();
124
+ this.buffer = '';
125
+ let kicker;
126
+ const reply = new Promise((resolve, reject) => {
127
+ const timer = setTimeout(() => {
128
+ this.waiter = null;
129
+ reject(new Error(`no reply to ${JSON.stringify(cmd)} within ${timeout} ms`));
130
+ }, timeout);
131
+ this.waiter = {
132
+ accept,
133
+ resolve: (line) => {
134
+ clearTimeout(timer);
135
+ resolve(line);
136
+ },
137
+ };
138
+ }).finally(() => clearInterval(kicker));
139
+ await this.#write(cmd);
140
+ kicker = setInterval(() => this.#write(KICK).catch(() => {}), KICK_EVERY_MS);
141
+ return reply;
142
+ });
143
+ }
144
+ }
145
+
146
+ // Probe: send "I", accept the port if the reply contains "TS".
147
+ // Throws when no UPS was found but some port could not be opened for lack of
148
+ // permission, since "not found" would hide the real cause.
149
+ export async function detectPort({ match = 'TS', timeout = 3000, log = () => {} } = {}) {
150
+ const ports = await listPorts();
151
+ const denied = [];
152
+ for (const info of ports) {
153
+ const lower = info.path.toLowerCase();
154
+ if (lower.includes('bluetooth') || lower.includes('blth')) continue;
155
+ const conn = new UpsConnection(info.path, { trace: (m) => log(`${info.path}: ${m}`) });
156
+ try {
157
+ await conn.open();
158
+ const reply = await conn.query(CMD.identify, { timeout, accept: isInfoReply });
159
+ if (reply.includes(match)) return { path: info.path, info: reply };
160
+ } catch (err) {
161
+ log(`${info.path}: ${err.message}`);
162
+ if (isPermissionError(err)) denied.push(info.path);
163
+ } finally {
164
+ await conn.close();
165
+ }
166
+ }
167
+ if (denied.length) {
168
+ throw new Error(
169
+ `permission denied on ${denied.join(', ')}; add your user to the "dialout" group ` +
170
+ '(or "uucp" on Arch) and log in again, or run with sudo',
171
+ );
172
+ }
173
+ return null;
174
+ }