@pinecall/zenitel-client 0.0.1-beta.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/dist/cli.js ADDED
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * zenitel — CLI for testing Zenitel intercom interaction
4
+ *
5
+ * Usage:
6
+ * zenitel scan Discover Zenitel devices on the network
7
+ * zenitel info -h 192.168.1.143 Get device info
8
+ * zenitel relay -h 192.168.1.143 Activate relay1 for 3 seconds
9
+ * zenitel relay -h ... --id gpio1 --timer 5
10
+ * zenitel call 122 -h 192.168.1.143 Place a call
11
+ * zenitel stop -h 192.168.1.143 Stop current call
12
+ * zenitel status -h 192.168.1.143 Get relay + call status
13
+ */
14
+ import { ZenitelClient } from './client.js';
15
+ import { scanNetwork } from './scanner.js';
16
+ const args = process.argv.slice(2);
17
+ const command = args[0];
18
+ function flag(name, short) {
19
+ for (let i = 0; i < args.length; i++) {
20
+ if (args[i] === `--${name}` || (short && args[i] === `-${short}`)) {
21
+ return args[i + 1];
22
+ }
23
+ }
24
+ return undefined;
25
+ }
26
+ function hasFlag(name) {
27
+ return args.includes(`--${name}`);
28
+ }
29
+ function getClient() {
30
+ const host = flag('host', 'h');
31
+ if (!host) {
32
+ console.error('Error: --host (-h) is required');
33
+ process.exit(1);
34
+ }
35
+ return new ZenitelClient({
36
+ host,
37
+ user: flag('user', 'u') ?? 'admin',
38
+ password: flag('pass', 'p') ?? 'alphaadmin',
39
+ });
40
+ }
41
+ async function main() {
42
+ switch (command) {
43
+ case 'scan': {
44
+ console.log('🔍 Scanning network for Zenitel devices...\n');
45
+ const devices = await scanNetwork({
46
+ timeout: Number(flag('timeout') ?? 5000),
47
+ });
48
+ if (devices.length === 0) {
49
+ console.log('No Zenitel devices found.');
50
+ break;
51
+ }
52
+ for (const dev of devices) {
53
+ console.log(` 🟢 ${dev.ip}`);
54
+ if (dev.mac)
55
+ console.log(` MAC: ${dev.mac}`);
56
+ if (dev.model)
57
+ console.log(` Model: ${dev.model}`);
58
+ if (dev.firmware)
59
+ console.log(` Firmware: ${dev.firmware}`);
60
+ if (dev.hostname)
61
+ console.log(` Hostname: ${dev.hostname}`);
62
+ if (dev.mode)
63
+ console.log(` Mode: ${dev.mode}`);
64
+ console.log(` Camera: ${dev.hasCamera ? 'Yes' : 'No'}`);
65
+ console.log();
66
+ }
67
+ console.log(`Found ${devices.length} device(s).`);
68
+ break;
69
+ }
70
+ case 'info': {
71
+ const client = getClient();
72
+ console.log(`📋 Getting device info from ${flag('host', 'h')}...\n`);
73
+ const info = await client.getDeviceInfo();
74
+ console.log(` Model: ${info.model}`);
75
+ console.log(` System: ${info.systemModelName}`);
76
+ console.log(` Firmware: ${info.firmware}`);
77
+ console.log(` MAC: ${info.mac}`);
78
+ console.log(` Serial: ${info.serialNumber}`);
79
+ console.log(` Hostname: ${info.hostname}`);
80
+ console.log(` Mode: ${info.mode}`);
81
+ console.log(` Camera: ${info.hasCamera ? 'Yes' : 'No'}`);
82
+ console.log(` Platform: ${info.platform}`);
83
+ console.log(` HW Type: ${info.hardwareType}`);
84
+ console.log(` Uptime: ${info.uptime}`);
85
+ console.log(` Webcall: ${info.webcallEnabled ? 'Enabled' : 'Disabled'}`);
86
+ console.log(` SIP Domain: ${info.sipDomain ?? '—'}`);
87
+ console.log(` SIP Registered: ${info.sipRegistered ? 'Yes' : 'No'}`);
88
+ console.log(` SIP Number: ${info.sipNumber ?? '—'}`);
89
+ console.log(` Outbound Proxy: ${info.outboundProxy ?? '—'}`);
90
+ break;
91
+ }
92
+ case 'relay': {
93
+ const client = getClient();
94
+ const relayId = flag('id') ?? 'relay1';
95
+ const timer = Number(flag('timer') ?? 3);
96
+ const deactivate = hasFlag('off');
97
+ if (deactivate) {
98
+ console.log(`🔒 Deactivating ${relayId}...`);
99
+ await client.deactivateRelay(relayId);
100
+ }
101
+ else {
102
+ console.log(`🚪 Activating ${relayId} for ${timer}s...`);
103
+ await client.activateRelay({ relayId, timer });
104
+ }
105
+ console.log('✅ Done.');
106
+ break;
107
+ }
108
+ case 'call': {
109
+ const client = getClient();
110
+ const number = args[1];
111
+ if (!number) {
112
+ console.error('Usage: zenitel call <number> -h <host>');
113
+ process.exit(1);
114
+ }
115
+ console.log(`📞 Calling ${number}...`);
116
+ await client.placeCall(number);
117
+ console.log('✅ Call placed.');
118
+ break;
119
+ }
120
+ case 'stop': {
121
+ const client = getClient();
122
+ console.log('⏹ Stopping call...');
123
+ await client.stopCall();
124
+ console.log('✅ Done.');
125
+ break;
126
+ }
127
+ case 'status': {
128
+ const client = getClient();
129
+ console.log(`📊 Status of ${flag('host', 'h')}:\n`);
130
+ const callStatus = await client.getCallStatus();
131
+ console.log(` Call: ${callStatus}`);
132
+ const relays = await client.getRelayStatus();
133
+ for (const [key, val] of Object.entries(relays)) {
134
+ console.log(` ${key}: ${val}`);
135
+ }
136
+ break;
137
+ }
138
+ case 'sip': {
139
+ const client = getClient();
140
+ const subCmd = args[1]; // get | set
141
+ if (subCmd === 'set') {
142
+ const config = {};
143
+ if (flag('domain'))
144
+ config.domain = flag('domain');
145
+ if (flag('user'))
146
+ config.authUsername = flag('user');
147
+ if (flag('password'))
148
+ config.authPassword = flag('password');
149
+ if (flag('number'))
150
+ config.directoryNumber = flag('number');
151
+ if (flag('name'))
152
+ config.displayName = flag('name');
153
+ if (flag('proxy'))
154
+ config.outboundProxy = flag('proxy');
155
+ if (flag('transport'))
156
+ config.transport = flag('transport');
157
+ console.log('📝 Writing SIP config...');
158
+ await client.setSIPConfig(config);
159
+ console.log('✅ SIP config saved. Reboot the device for changes to take effect.');
160
+ }
161
+ else {
162
+ console.log(`📡 SIP config from ${flag('host', 'h')}:\n`);
163
+ const sip = await client.getSIPConfig();
164
+ console.log(` Name: ${sip.displayName}`);
165
+ console.log(` Number: ${sip.directoryNumber}`);
166
+ console.log(` Domain: ${sip.domain}`);
167
+ console.log(` Auth User: ${sip.authUsername}`);
168
+ console.log(` Auth Pass: ${sip.authPassword}`);
169
+ console.log(` Proxy: ${sip.outboundProxy}`);
170
+ console.log(` Transport: ${sip.transport}`);
171
+ }
172
+ break;
173
+ }
174
+ case 'webcall': {
175
+ const client = getClient();
176
+ const action = args[1]; // enable | disable
177
+ if (action === 'enable') {
178
+ console.log('🔓 Enabling webcall + relay API...');
179
+ await client.enableWebcall();
180
+ console.log('✅ Webcall enabled.');
181
+ }
182
+ else if (action === 'disable') {
183
+ console.log('🔒 Disabling webcall + relay API...');
184
+ await client.disableWebcall();
185
+ console.log('✅ Webcall disabled.');
186
+ }
187
+ else {
188
+ const info = await client.getDeviceInfo();
189
+ console.log(`Webcall: ${info.webcallEnabled ? '✅ Enabled' : '❌ Disabled'}`);
190
+ console.log('\nUsage: zenitel webcall enable|disable -h <host>');
191
+ }
192
+ break;
193
+ }
194
+ case 'backup': {
195
+ const client = getClient();
196
+ const { writeFileSync } = await import('node:fs');
197
+ const outFile = flag('out', 'o') || 'ipst_config.tar.gz';
198
+ console.log(`💾 Downloading config backup...`);
199
+ const buf = await client.downloadConfig();
200
+ writeFileSync(outFile, buf);
201
+ console.log(`✅ Saved to ${outFile} (${buf.length} bytes)`);
202
+ break;
203
+ }
204
+ case 'restore': {
205
+ const client = getClient();
206
+ const { readFileSync } = await import('node:fs');
207
+ const inFile = args[1];
208
+ if (!inFile) {
209
+ console.error('Usage: zenitel restore <file.tar.gz> -h <host>');
210
+ process.exit(1);
211
+ }
212
+ console.log(`📤 Uploading config from ${inFile}...`);
213
+ const buf = readFileSync(inFile);
214
+ await client.uploadConfig(buf);
215
+ console.log('✅ Config uploaded. Reboot the device for changes to take effect.');
216
+ break;
217
+ }
218
+ case 'reboot': {
219
+ const client = getClient();
220
+ console.log('🔄 Rebooting device...');
221
+ await client.reboot();
222
+ console.log('✅ Reboot command sent. Device will be offline for ~30 seconds.');
223
+ break;
224
+ }
225
+ case 'video': {
226
+ const client = getClient();
227
+ console.log(`📷 Video URLs for ${flag('host', 'h')}:\n`);
228
+ console.log(` MJPG: ${client.getMJPGUrl()}`);
229
+ console.log(` RTSP: ${client.getRTSPUrl()}`);
230
+ break;
231
+ }
232
+ case 'dak': {
233
+ const client = getClient();
234
+ const subCmd = args[1]; // get | set
235
+ if (subCmd === 'set') {
236
+ const number = flag('number') || args[2];
237
+ if (!number) {
238
+ console.error('Usage: zenitel dak set --number <sip-number> -h <host>');
239
+ process.exit(1);
240
+ }
241
+ const domain = flag('domain');
242
+ const noReboot = hasFlag('no-reboot');
243
+ console.log(`🔧 Configuring call button to dial: ${number}${domain ? '@' + domain : ' (auto-detect domain)'}...`);
244
+ await client.configureCallButton(number, domain, !noReboot);
245
+ console.log('✅ Call button configured.');
246
+ if (!noReboot) {
247
+ console.log('🔄 Rebooting device... (~30s downtime)');
248
+ }
249
+ else {
250
+ console.log('⚠️ Reboot required for changes to take effect.');
251
+ }
252
+ }
253
+ else {
254
+ console.log(`📞 Call button (DAK1) config from ${flag('host', 'h')}:\n`);
255
+ const dak = await client.readDAK();
256
+ console.log(` Dial Number: ${dak.number || '(not set)'}`);
257
+ console.log(` SIP Domain: ${dak.domain || '(none)'}`);
258
+ console.log(` Full URI: ${dak.raw || '(empty)'}`);
259
+ }
260
+ break;
261
+ }
262
+ case 'provision': {
263
+ const client = getClient();
264
+ const sipDomain = flag('domain');
265
+ const sipAuthUser = flag('sip-user');
266
+ const sipAuthPassword = flag('sip-pass');
267
+ const agentNumber = flag('number') || flag('agent');
268
+ if (!sipDomain || !sipAuthUser || !sipAuthPassword || !agentNumber) {
269
+ console.error(`Usage: zenitel provision -h <host> \\
270
+ --domain <sip-domain> \\
271
+ --sip-user <auth-username> \\
272
+ --sip-pass <auth-password> \\
273
+ --number <agent-sip-number>
274
+
275
+ Optional:
276
+ --name <station-name> Display name (default: agent number)
277
+ --proxy <outbound-proxy> Outbound proxy (default: sip domain)
278
+ --transport udp|tcp|tls SIP transport (default: UDP)`);
279
+ process.exit(1);
280
+ }
281
+ console.log(`\n🔧 Provisioning ${flag('host', 'h')} from factory reset...\n`);
282
+ console.log(` SIP Domain: ${sipDomain}`);
283
+ console.log(` SIP Auth User: ${sipAuthUser}`);
284
+ console.log(` Call Button →: ${agentNumber}@${sipDomain}`);
285
+ console.log(` Webcall: Enabled`);
286
+ console.log(` Auto-answer: Enabled\n`);
287
+ await client.provisionDevice({
288
+ sipDomain,
289
+ sipAuthUser,
290
+ sipAuthPassword,
291
+ sipProxy: flag('proxy') || undefined,
292
+ sipTransport: flag('transport')?.toUpperCase() || undefined,
293
+ stationName: flag('name') || undefined,
294
+ agentNumber,
295
+ });
296
+ console.log('✅ Provisioned! Device is rebooting (~30s).');
297
+ console.log(` After reboot, call button will dial: ${agentNumber}`);
298
+ break;
299
+ }
300
+ default:
301
+ console.log(`zenitel — Zenitel intercom CLI
302
+
303
+ Commands:
304
+ scan Discover devices on the network
305
+ info -h <host> Device information
306
+ relay -h <host> Activate relay (--id relay1 --timer 3)
307
+ relay -h <host> --off Deactivate relay
308
+ call <N> -h <host> Place a call
309
+ stop -h <host> Stop current call
310
+ status -h <host> Call + relay status
311
+ sip get -h <host> Read SIP configuration
312
+ sip set -h <host> Write SIP config (--domain --number --proxy ...)
313
+ webcall enable -h <host> Enable webcall + relay HTTP API
314
+ webcall disable -h <host> Disable webcall + relay HTTP API
315
+ backup [file] -h <host> Download config as tar.gz
316
+ restore <file> -h <host> Upload config tar.gz
317
+ reboot -h <host> Reboot the device
318
+ video -h <host> Show video stream URLs
319
+
320
+ Options:
321
+ -h, --host IP address of the Zenitel
322
+ -u, --user Username (default: admin)
323
+ -p, --pass Password (default: alphaadmin)
324
+ --id Relay ID (relay1, gpio1-gpio6)
325
+ --timer Relay timer in seconds (default: 3)
326
+ --timeout Scan timeout in ms (default: 5000)
327
+ --off Deactivate relay instead of activating
328
+ --domain SIP domain
329
+ --number SIP directory number
330
+ --proxy Outbound proxy
331
+ --transport SIP transport (udp/tcp/tls)
332
+ --name Display name`);
333
+ }
334
+ }
335
+ main().catch((err) => {
336
+ console.error('❌ Error:', err.message);
337
+ process.exit(1);
338
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * ZenitelClient — HTTP scraper for Zenitel intercom web UI
3
+ *
4
+ * All methods use the goform endpoints documented in the Zenitel wiki
5
+ * and confirmed against a real TCIV-2+ (FW 9.2.3.0) at 192.168.1.143.
6
+ *
7
+ * Auth: HTTP Basic Auth (admin/alphaadmin by default).
8
+ * All goform endpoints use POST with form-urlencoded bodies.
9
+ */
10
+ import type { ZenitelClientOptions, DeviceInfo, RelayOptions, RelayStatus, CallStatus, SIPConfig, ProvisionConfig } from './types.js';
11
+ export declare class ZenitelClient {
12
+ private opts;
13
+ private readonly baseUrl;
14
+ private readonly authHeader;
15
+ private readonly timeout;
16
+ constructor(opts: ZenitelClientOptions);
17
+ /** Check if the Zenitel is reachable */
18
+ isReachable(): Promise<boolean>;
19
+ /** Scrape station info + header for full device data */
20
+ getDeviceInfo(): Promise<DeviceInfo>;
21
+ /** Place a call to a number/SIP URI */
22
+ placeCall(number: string): Promise<void>;
23
+ /** Stop the current call */
24
+ stopCall(): Promise<void>;
25
+ /** Answer an incoming call */
26
+ answerCall(): Promise<void>;
27
+ /** Get current call status by scraping the webcall page */
28
+ getCallStatus(): Promise<CallStatus>;
29
+ /** Activate a relay (default: relay1, 3 seconds) */
30
+ activateRelay(opts?: RelayOptions): Promise<void>;
31
+ /** Deactivate a relay */
32
+ deactivateRelay(relayId?: string): Promise<void>;
33
+ /** Get status of all relays by scraping the webcall page */
34
+ getRelayStatus(): Promise<RelayStatus>;
35
+ /** Get the MJPG stream URL (port 80, same as web UI — confirmed on TCIV-2+) */
36
+ getMJPGUrl(): string;
37
+ /** Get RTSP stream URL */
38
+ getRTSPUrl(): string;
39
+ /** Get auth credentials for MJPG (for Electron protocol handler) */
40
+ getVideoAuth(): {
41
+ user: string;
42
+ password: string;
43
+ };
44
+ /** Read current SIP config by scraping the form fields */
45
+ getSIPConfig(): Promise<SIPConfig>;
46
+ /** Write SIP config via POST to zForm_save_changes */
47
+ setSIPConfig(config: SIPConfig): Promise<void>;
48
+ /** Enable webcall + relay HTTP API (required for FW ≥4.11.3.1) */
49
+ enableWebcall(): Promise<void>;
50
+ /** Disable webcall + relay HTTP API */
51
+ disableWebcall(): Promise<void>;
52
+ /**
53
+ * Set the DAK (call button) to dial a specific SIP address.
54
+ * Button index 0 = physical button 1 on the intercom.
55
+ * @param sipAddress - Full SIP address, e.g. "portia-xxxx@sip.twilio.com"
56
+ * @param buttonIndex - DAK button index (default 0 = Button 1)
57
+ */
58
+ setDAK(sipAddress: string, buttonIndex?: number): Promise<void>;
59
+ /** Read the current DAK value for a button */
60
+ getDAK(buttonIndex?: number): Promise<string>;
61
+ /** Download complete config as tar.gz binary */
62
+ downloadConfig(): Promise<Buffer>;
63
+ /** Upload config tar.gz (triggers restore on reboot) */
64
+ uploadConfig(tarGz: Buffer): Promise<void>;
65
+ /** Read current DAK1 value from the config backup */
66
+ readDAK(): Promise<{
67
+ number: string;
68
+ domain: string;
69
+ raw: string;
70
+ }>;
71
+ /**
72
+ * Configure the call button (DAK1) to dial a specific number.
73
+ * Downloads backup, modifies XML, re-uploads, optionally reboots.
74
+ *
75
+ * @param number - The SIP number to dial (e.g. "portia-ae3c")
76
+ * @param domain - SIP domain (auto-detected from current config if omitted)
77
+ * @param autoReboot - Reboot after upload (default: true)
78
+ */
79
+ configureCallButton(number: string, domain?: string, autoReboot?: boolean): Promise<void>;
80
+ /**
81
+ * Provision a factory-reset Zenitel in one operation.
82
+ * Downloads config, modifies all SIP + DAK + webcall fields in XML,
83
+ * uploads, and reboots. Device will be fully configured after ~30s.
84
+ */
85
+ provisionDevice(config: ProvisionConfig): Promise<void>;
86
+ private _extractXmlFromTarGz;
87
+ private _modifyTarGzXml;
88
+ /**
89
+ * Minimal tar file finder — tar format:
90
+ * Each file: 512-byte header + data padded to 512 bytes
91
+ * Header: name at offset 0 (100 bytes), size at offset 124 (12 bytes, octal)
92
+ */
93
+ private _findFileInTar;
94
+ /**
95
+ * Replace a file's content inside a tar buffer.
96
+ * If the new content is a different size, we rebuild the entry.
97
+ */
98
+ private _replaceFileInTar;
99
+ /** Recalculate tar header checksum (sum of all header bytes with checksum field as spaces) */
100
+ private _updateTarChecksum;
101
+ /** Reboot the Zenitel (required after config changes) */
102
+ reboot(): Promise<void>;
103
+ private _fetch;
104
+ private _html;
105
+ private _post;
106
+ /** Extract value from <input type=hidden id='name' value='val'> */
107
+ private _hidden;
108
+ }