zenitel-client 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/dist/client.js ADDED
@@ -0,0 +1,709 @@
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
+ export class ZenitelClient {
11
+ opts;
12
+ baseUrl;
13
+ authHeader;
14
+ timeout;
15
+ constructor(opts) {
16
+ this.opts = opts;
17
+ const proto = opts.protocol ?? 'http';
18
+ this.baseUrl = `${proto}://${opts.host}`;
19
+ const user = opts.user ?? 'admin';
20
+ const pass = opts.password ?? 'alphaadmin';
21
+ this.authHeader = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
22
+ this.timeout = opts.timeout ?? 5000;
23
+ }
24
+ // ── Connectivity ────────────────────────────────────────────────────────
25
+ /** Check if the Zenitel is reachable */
26
+ async isReachable() {
27
+ try {
28
+ const res = await this._fetch('/', 'GET', 2000);
29
+ return res.ok || res.status === 302;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ // ── Device Info ─────────────────────────────────────────────────────────
36
+ /** Scrape station info + header for full device data */
37
+ async getDeviceInfo() {
38
+ // 1. Get hidden fields from header (sigmode, frontboard, platform, hwtype)
39
+ const headerHtml = await this._html('/goform/zForm_header');
40
+ const sigmode = this._hidden(headerHtml, 'global_sigmode') || 'sip';
41
+ const frontboard = this._hidden(headerHtml, 'global_frontboard') || '';
42
+ const platform = this._hidden(headerHtml, 'global_platform') || '';
43
+ const hwtype = this._hidden(headerHtml, 'global_hwtype') || '';
44
+ // 2. Get station info table
45
+ const infoHtml = await this._html('/goform/zForm_stn_info');
46
+ // Parse the info table — rows are <td class='header_left'>Label:</td><td>Value</td>
47
+ const field = (label) => {
48
+ const re = new RegExp(`header_left'>\\s*${label}[:\\s]*</td>\\s*<td[^>]*>([^<]+)`, 'i');
49
+ return infoHtml.match(re)?.[1]?.trim() ?? '';
50
+ };
51
+ // Model is in the <h2> tag: "TCIV-2+ Information"
52
+ const modelMatch = infoHtml.match(/<h2>([^<]+)\s+Information/i);
53
+ const model = modelMatch?.[1]?.trim() ?? '';
54
+ // SIP domain includes registration status: "domain.com, Registered - ..."
55
+ const sipDomainRaw = field('Server Domain \\(SIP\\)');
56
+ const sipDomain = sipDomainRaw.split(',')[0]?.trim();
57
+ const sipRegistered = sipDomainRaw.toLowerCase().includes('registered');
58
+ // 3. Check if webcall is enabled
59
+ const webcallHtml = await this._html('/goform/zForm_webcall');
60
+ const webcallEnabled = webcallHtml.includes('checked');
61
+ return {
62
+ model,
63
+ firmware: field('Software Version'),
64
+ mac: field('MAC Address'),
65
+ ip: field('IP Address') || this.opts.host,
66
+ hostname: field('Name'),
67
+ serialNumber: field('Serial Number'),
68
+ hardwareType: hwtype,
69
+ mode: sigmode,
70
+ hasCamera: frontboard === 'video',
71
+ sipDomain,
72
+ sipRegistered,
73
+ sipNumber: field('Number \\(SIP ID\\)'),
74
+ outboundProxy: field('Outbound Proxy'),
75
+ uptime: field('Uptime'),
76
+ webcallEnabled,
77
+ platform,
78
+ systemModelName: field('System Model Name'),
79
+ };
80
+ }
81
+ // ── Webcall API ─────────────────────────────────────────────────────────
82
+ /** Place a call to a number/SIP URI */
83
+ async placeCall(number) {
84
+ await this._post('/goform/zForm_webcall', {
85
+ webcall: number,
86
+ message: 'PLACE CALL',
87
+ });
88
+ }
89
+ /** Stop the current call */
90
+ async stopCall() {
91
+ await this._post('/goform/zForm_webcall', { message: 'STOP' });
92
+ }
93
+ /** Answer an incoming call */
94
+ async answerCall() {
95
+ await this._post('/goform/zForm_webcall', { message: 'ANSWER' });
96
+ }
97
+ /** Get current call status by scraping the webcall page */
98
+ async getCallStatus() {
99
+ const html = await this._html('/goform/zForm_webcall');
100
+ const match = html.match(/Call status:<\/td>\s*<td>(\w+)/i);
101
+ return match?.[1] ?? 'Idle';
102
+ }
103
+ // ── Relay / Door Control ────────────────────────────────────────────────
104
+ /** Activate a relay (default: relay1, 3 seconds) */
105
+ async activateRelay(opts) {
106
+ const body = {
107
+ activate: 'ACTIVATE',
108
+ relayId: opts?.relayId ?? 'relay1',
109
+ };
110
+ if (opts?.timer !== undefined) {
111
+ body.relaytimer = String(opts.timer);
112
+ }
113
+ await this._post('/goform/zForm_webcall', body);
114
+ }
115
+ /** Deactivate a relay */
116
+ async deactivateRelay(relayId = 'relay1') {
117
+ await this._post('/goform/zForm_webcall', {
118
+ deactivate: 'DEACTIVATE',
119
+ relayId,
120
+ });
121
+ }
122
+ /** Get status of all relays by scraping the webcall page */
123
+ async getRelayStatus() {
124
+ const html = await this._html('/goform/zForm_webcall');
125
+ const parse = (label) => {
126
+ const re = new RegExp(`${label} status:</td>\\s*<td>(\\w+)`, 'i');
127
+ const m = html.match(re);
128
+ return m?.[1] === 'Activated' ? 'Activated' : 'Deactivated';
129
+ };
130
+ return {
131
+ relay1: parse('Relay 1'),
132
+ gpio1: parse('Output 1'),
133
+ gpio2: parse('Output 2'),
134
+ gpio3: parse('Output 3'),
135
+ gpio4: parse('Output 4'),
136
+ gpio5: parse('Output 5'),
137
+ gpio6: parse('Output 6'),
138
+ };
139
+ }
140
+ // ── Video ───────────────────────────────────────────────────────────────
141
+ /** Get the MJPG stream URL (port 80, same as web UI — confirmed on TCIV-2+) */
142
+ getMJPGUrl() {
143
+ return `http://${this.opts.host}/mjpg/video.mjpg`;
144
+ }
145
+ /** Get RTSP stream URL */
146
+ getRTSPUrl() {
147
+ return `rtsp://${this.opts.host}:554/1/RTSP`;
148
+ }
149
+ /** Get auth credentials for MJPG (for Electron protocol handler) */
150
+ getVideoAuth() {
151
+ return {
152
+ user: this.opts.user ?? 'admin',
153
+ password: this.opts.password ?? 'alphaadmin',
154
+ };
155
+ }
156
+ // ── SIP Configuration ──────────────────────────────────────────────────
157
+ // All fields from POST /goform/zForm_save_changes (confirmed on FW 9.2.3.0)
158
+ //
159
+ // Field map (HTML name → SIPConfig key):
160
+ // sip_nick → displayName
161
+ // sip_id → directoryNumber
162
+ // sip_domain → domain
163
+ // sip_auth_user → authUsername
164
+ // sip_auth_pwd → authPassword
165
+ // sip_ppa → outboundProxy (address)
166
+ // sip_ppp → outboundProxy (port)
167
+ // sip_outbound_transport → transport
168
+ /** Read current SIP config by scraping the form fields */
169
+ async getSIPConfig() {
170
+ const html = await this._html('/goform/zForm_sip_configuration');
171
+ const input = (name) => {
172
+ const re = new RegExp(`name=${name}\\s+value="([^"]*)"`, 'i');
173
+ return html.match(re)?.[1] ?? '';
174
+ };
175
+ const select = (name) => {
176
+ const re = new RegExp(`name=${name}[^>]*>([\\s\\S]*?)</select>`, 'i');
177
+ const block = html.match(re)?.[1] ?? '';
178
+ const sel = block.match(/SELECTED\s+value='([^']*)'/i);
179
+ return sel?.[1] ?? '';
180
+ };
181
+ return {
182
+ displayName: input('sip_nick'),
183
+ directoryNumber: input('sip_id'),
184
+ domain: input('sip_domain'),
185
+ authUsername: input('sip_auth_user'),
186
+ authPassword: input('sip_auth_pwd'),
187
+ outboundProxy: input('sip_ppa'),
188
+ transport: select('sip_outbound_transport'),
189
+ };
190
+ }
191
+ /** Write SIP config via POST to zForm_save_changes */
192
+ async setSIPConfig(config) {
193
+ const fields = {};
194
+ if (config.displayName !== undefined)
195
+ fields.sip_nick = config.displayName;
196
+ if (config.directoryNumber !== undefined)
197
+ fields.sip_id = config.directoryNumber;
198
+ if (config.domain !== undefined)
199
+ fields.sip_domain = config.domain;
200
+ if (config.authUsername !== undefined)
201
+ fields.sip_auth_user = config.authUsername;
202
+ if (config.authPassword !== undefined)
203
+ fields.sip_auth_pwd = config.authPassword;
204
+ if (config.outboundProxy !== undefined)
205
+ fields.sip_ppa = config.outboundProxy;
206
+ if (config.transport !== undefined)
207
+ fields.sip_outbound_transport = config.transport.toUpperCase();
208
+ fields.save_changes = 'Save';
209
+ await this._post('/goform/zForm_save_changes', fields);
210
+ }
211
+ // ── Webcall Enable/Disable ─────────────────────────────────────────────
212
+ /** Enable webcall + relay HTTP API (required for FW ≥4.11.3.1) */
213
+ async enableWebcall() {
214
+ await this._post('/goform/zForm_webcall', {
215
+ enable_wc_r_node: 'on',
216
+ savewcr: 'Save',
217
+ });
218
+ }
219
+ /** Disable webcall + relay HTTP API */
220
+ async disableWebcall() {
221
+ await this._post('/goform/zForm_webcall', {
222
+ savewcr: 'Save',
223
+ // checkbox not sent = disabled
224
+ });
225
+ }
226
+ // ── Direct Access Key (DAK) — call button config ──────────────────────
227
+ /**
228
+ * Set the DAK (call button) to dial a specific SIP address.
229
+ * Button index 0 = physical button 1 on the intercom.
230
+ * @param sipAddress - Full SIP address, e.g. "portia-xxxx@sip.twilio.com"
231
+ * @param buttonIndex - DAK button index (default 0 = Button 1)
232
+ */
233
+ async setDAK(sipAddress, buttonIndex = 0) {
234
+ await this._post('/goform/zForm_speeddial_configuration_basic_auth', {
235
+ [`dak_fun${buttonIndex}`]: '0', // 0 = normal call
236
+ [`dak_value${buttonIndex}`]: sipAddress,
237
+ [`dak_in_call_func${buttonIndex}`]: '5', // 5 = End Call on button press during call
238
+ message: 'SAVE',
239
+ });
240
+ }
241
+ /** Read the current DAK value for a button */
242
+ async getDAK(buttonIndex = 0) {
243
+ const res = await this._fetch('/goform/zForm_speeddial_configuration_basic_auth');
244
+ const html = await res.text();
245
+ const match = html.match(new RegExp(`name='dak_value${buttonIndex}'[^>]*value='([^']*)'`));
246
+ return match?.[1] || '';
247
+ }
248
+ // ── Config Backup / Restore ────────────────────────────────────────────
249
+ /** Download complete config as tar.gz binary */
250
+ async downloadConfig() {
251
+ const res = await this._fetch('/ipst_config.tar.gz');
252
+ if (!res.ok)
253
+ throw new Error(`Download config failed: HTTP ${res.status}`);
254
+ const ab = await res.arrayBuffer();
255
+ return Buffer.from(ab);
256
+ }
257
+ /** Upload config tar.gz (triggers restore on reboot) */
258
+ async uploadConfig(tarGz) {
259
+ // The form uses multipart/form-data with field name "binary"
260
+ const boundary = '----PortiaUpload' + Date.now();
261
+ const header = `--${boundary}\r\nContent-Disposition: form-data; name="binary"; filename="ipst_config.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n`;
262
+ const submit = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="WebRestore"\r\n\r\nUPLOAD\r\n--${boundary}--\r\n`;
263
+ const body = Buffer.concat([
264
+ Buffer.from(header),
265
+ tarGz,
266
+ Buffer.from(submit),
267
+ ]);
268
+ await this._fetch('/goform/zForm_config_backup', 'POST', 30000, body.toString('binary'), `multipart/form-data; boundary=${boundary}`);
269
+ }
270
+ // ── DAK (Call Button) Configuration ─────────────────────────────────────
271
+ // The DAK (Direct Access Key) controls what number the intercom calls
272
+ // when someone presses the call button. Stored in ipst_config.xml.
273
+ //
274
+ // Flow: download backup → modify XML → re-upload → reboot
275
+ //
276
+ // XML structure:
277
+ // <dak1><fun>0</fun><val>222@domain.sip.twilio.com</val>...</dak1>
278
+ /** Read current DAK1 value from the config backup */
279
+ async readDAK() {
280
+ const backup = await this.downloadConfig();
281
+ const xml = await this._extractXmlFromTarGz(backup);
282
+ const match = xml.match(/<dak1>[\s\S]*?<val>([^<]*)<\/val>/);
283
+ const raw = match?.[1] ?? '';
284
+ const [number, domain] = raw.includes('@') ? raw.split('@') : [raw, ''];
285
+ return { number, domain, raw };
286
+ }
287
+ /**
288
+ * Configure the call button (DAK1) to dial a specific number.
289
+ * Downloads backup, modifies XML, re-uploads, optionally reboots.
290
+ *
291
+ * @param number - The SIP number to dial (e.g. "portia-ae3c")
292
+ * @param domain - SIP domain (auto-detected from current config if omitted)
293
+ * @param autoReboot - Reboot after upload (default: true)
294
+ */
295
+ async configureCallButton(number, domain, autoReboot = true) {
296
+ // 1. Get current SIP domain if not provided
297
+ if (!domain) {
298
+ const sip = await this.getSIPConfig();
299
+ domain = sip.domain;
300
+ }
301
+ const dialValue = domain ? `${number}@${domain}` : number;
302
+ // 2. Download current config
303
+ const backup = await this.downloadConfig();
304
+ // 3. Extract, modify, re-pack
305
+ const modified = await this._modifyTarGzXml(backup, (xml) => {
306
+ // Replace DAK1 value
307
+ return xml.replace(/(<dak1>[\s\S]*?<val>)[^<]*(<\/val>)/, `$1${dialValue}$2`);
308
+ });
309
+ // 4. Upload modified config
310
+ await this.uploadConfig(modified);
311
+ // 5. Reboot to apply
312
+ if (autoReboot) {
313
+ await this.reboot();
314
+ }
315
+ }
316
+ // ── Full Provisioning ──────────────────────────────────────────────────
317
+ // One-shot setup for a factory-reset Zenitel. Configures everything
318
+ // in a single backup → modify XML → upload → reboot cycle.
319
+ //
320
+ // XML fields modified:
321
+ // <sip_nick> → stationName
322
+ // <sip_id> → stationName
323
+ // <sip_domain> → sipDomain
324
+ // <sip_auth_user> → sipAuthUser
325
+ // <sip_auth_password> → sipAuthPassword
326
+ // <sip_outbound_transport> → sipTransport
327
+ // <dak1><val> → agentNumber@sipDomain
328
+ // <enable_wc_r> → 1 (webcall enabled)
329
+ // <auto_answer_mode> → 1 (auto-answer for AI)
330
+ /**
331
+ * Provision a factory-reset Zenitel in one operation.
332
+ * Downloads config, modifies all SIP + DAK + webcall fields in XML,
333
+ * uploads, and reboots. Device will be fully configured after ~30s.
334
+ */
335
+ async provisionDevice(config) {
336
+ const proxy = config.sipProxy ?? config.sipDomain;
337
+ const name = config.stationName ?? config.agentNumber;
338
+ const transport = config.sipTransport ?? 'UDP';
339
+ const webcall = config.enableWebcall !== false ? '1' : '0';
340
+ const autoAnswer = config.autoAnswer !== false ? '1' : '0';
341
+ const dakValue = `${config.agentNumber}@${config.sipDomain}`;
342
+ // 1. Download current config backup
343
+ const backup = await this.downloadConfig();
344
+ // 2. Modify all fields in ipst_config.xml
345
+ const modified = await this._modifyTarGzXml(backup, (xml) => {
346
+ return xml
347
+ // SIP identity
348
+ .replace(/(<sip_nick>)[^<]*(<\/sip_nick>)/, `$1${name}$2`)
349
+ .replace(/(<sip_id>)[^<]*(<\/sip_id>)/, `$1${name}$2`)
350
+ // SIP server
351
+ .replace(/(<sip_domain>)[^<]*(<\/sip_domain>)/, `$1${config.sipDomain}$2`)
352
+ .replace(/(<sip_auth_user>)[^<]*(<\/sip_auth_user>)/, `$1${config.sipAuthUser}$2`)
353
+ .replace(/(<sip_auth_password>)[^<]*(<\/sip_auth_password>)/, `$1${config.sipAuthPassword}$2`)
354
+ .replace(/(<sip_outbound_transport>)[^<]*(<\/sip_outbound_transport>)/, `$1${transport}$2`)
355
+ // Outbound proxy (match first occurrence)
356
+ .replace(/(<sip_outbound_proxy_address>)[^<]*/, `$1${proxy}`)
357
+ // Call button → agent number
358
+ .replace(/(\<dak1\>[\s\S]*?\<val\>)[^<]*(\<\/val\>)/, `$1${dakValue}$2`)
359
+ // Enable webcall + relay API
360
+ .replace(/(<enable_wc_r>)[^<]*(<\/enable_wc_r>)/, `$1${webcall}$2`)
361
+ // Auto-answer (AI agent needs this)
362
+ .replace(/(<auto_answer_mode>)[^<]*(<\/auto_answer_mode>)/, `$1${autoAnswer}$2`);
363
+ });
364
+ // 3. Upload modified config
365
+ await this.uploadConfig(modified);
366
+ // 4. Reboot to apply
367
+ await this.reboot();
368
+ }
369
+ // ── Audio Settings ─────────────────────────────────────────────────────
370
+ // Full audio config via /goform/zForm_auto_config (AngularJS JSON endpoint).
371
+ // The POST expects the complete JSON — partial updates are not supported.
372
+ // Read → merge → write pattern required.
373
+ /** Get current audio settings (parsed from goform JSON) */
374
+ async getAudioSettings() {
375
+ const raw = await this.getAudioSettingsRaw();
376
+ return this._parseAudioJson(raw);
377
+ }
378
+ /**
379
+ * Update audio settings. Reads current config, deep-merges with the
380
+ * provided partial, and writes the complete JSON back.
381
+ *
382
+ * @example
383
+ * // Set speaker to +3 dB
384
+ * await z.setAudioSettings({ speaker: { gain: 3 } })
385
+ *
386
+ * // Enable DRC with 8 dBA gain
387
+ * await z.setAudioSettings({ drc: { enabled: true, gain: 8 } })
388
+ */
389
+ async setAudioSettings(partial) {
390
+ // 1. Read current raw JSON
391
+ const raw = await this.getAudioSettingsRaw();
392
+ // 2. Apply changes
393
+ const audio = raw.audio;
394
+ const outputs = audio.io.output_devices.output_device;
395
+ const speaker = outputs.find((d) => d.kid === 'internal_speaker');
396
+ const lineOut = outputs.find((d) => d.kid === 'line_out');
397
+ const mic = audio.io.input_devices.input_device[0];
398
+ const dsp = audio.lines.line[0].dsp;
399
+ if (partial.speaker && speaker) {
400
+ if (partial.speaker.gain !== undefined)
401
+ speaker.gain = partial.speaker.gain;
402
+ if (partial.speaker.overrideGain !== undefined)
403
+ speaker.override_gain = partial.speaker.overrideGain;
404
+ }
405
+ if (partial.lineOut && lineOut) {
406
+ if (partial.lineOut.gain !== undefined)
407
+ lineOut.gain = partial.lineOut.gain;
408
+ if (partial.lineOut.overrideGain !== undefined)
409
+ lineOut.override_gain = partial.lineOut.overrideGain;
410
+ }
411
+ if (partial.mic && mic) {
412
+ if (partial.mic.gain !== undefined)
413
+ mic.gain = partial.mic.gain;
414
+ }
415
+ if (partial.aec) {
416
+ if (partial.aec.enabled !== undefined)
417
+ dsp.aec.enable = partial.aec.enabled;
418
+ if (partial.aec.mode !== undefined)
419
+ dsp.aec.aec_mode = partial.aec.mode;
420
+ }
421
+ if (partial.anc) {
422
+ if (partial.anc.enabled !== undefined)
423
+ dsp.anc.enable = partial.anc.enabled;
424
+ if (partial.anc.mode !== undefined)
425
+ dsp.anc.anc_mode = partial.anc.mode;
426
+ }
427
+ if (partial.fess) {
428
+ if (partial.fess.enabled !== undefined)
429
+ dsp.fess.enable = partial.fess.enabled;
430
+ if (partial.fess.threshold !== undefined)
431
+ dsp.fess.gain = partial.fess.threshold;
432
+ if (partial.fess.delay !== undefined)
433
+ dsp.fess.delay = partial.fess.delay;
434
+ }
435
+ if (partial.drc) {
436
+ if (partial.drc.enabled !== undefined)
437
+ dsp.drc.enable = partial.drc.enabled;
438
+ if (partial.drc.gain !== undefined)
439
+ dsp.drc.gain = partial.drc.gain;
440
+ }
441
+ if (partial.avc) {
442
+ const avc = audio.avc;
443
+ const algo = avc.algorithm_avc || avc.algorithm;
444
+ if (partial.avc.enabled !== undefined)
445
+ avc.enable_avc = partial.avc.enabled;
446
+ if (partial.avc.digitalEnabled !== undefined)
447
+ avc.enable_davc = partial.avc.digitalEnabled;
448
+ if (algo) {
449
+ if (partial.avc.threshold !== undefined)
450
+ algo.threshold = partial.avc.threshold;
451
+ if (partial.avc.upperThreshold !== undefined)
452
+ algo.upper_threshold = partial.avc.upperThreshold;
453
+ if (partial.avc.attackRate !== undefined)
454
+ algo.attack_rate = partial.avc.attackRate;
455
+ if (partial.avc.decayRate !== undefined)
456
+ algo.decay_rate = partial.avc.decayRate;
457
+ if (partial.avc.hysteresis !== undefined)
458
+ algo.hysteresis = partial.avc.hysteresis;
459
+ if (partial.avc.farEndLockoutTime !== undefined)
460
+ algo.far_end_lockout_time = partial.avc.farEndLockoutTime;
461
+ }
462
+ }
463
+ if (partial.mode !== undefined)
464
+ audio.mode = partial.mode;
465
+ // 3. POST full JSON
466
+ const body = new URLSearchParams({ audio: JSON.stringify(raw) }).toString();
467
+ await this._fetch('/goform/zForm_auto_config', 'POST', undefined, body, 'application/x-www-form-urlencoded');
468
+ }
469
+ /** Get raw audio config JSON from the device (for backup/debug) */
470
+ async getAudioSettingsRaw() {
471
+ // The audio config page embeds JSON in an AngularJS scope.
472
+ // We fetch the page and extract the JSON from the script block.
473
+ const html = await this._html('/goform/zForm_audio_configuration');
474
+ // Strategy 1: Look for ng-init or inline JSON assignment
475
+ // The page typically has: $scope.audio = {...} or data in a form field
476
+ let jsonStr = '';
477
+ // Try to extract from a script block: audio = {...}
478
+ const scriptMatch = html.match(/audio\s*=\s*(\{[\s\S]*?\});\s*(?:\n|<\/script>)/)
479
+ || html.match(/ng-init=['"]\s*init\(([\s\S]*?)\)['"]/);
480
+ if (scriptMatch) {
481
+ jsonStr = scriptMatch[1];
482
+ }
483
+ else {
484
+ // Strategy 2: POST to get the JSON directly (some FW versions)
485
+ const res = await this._fetch('/goform/zForm_auto_config', 'GET');
486
+ const text = await res.text();
487
+ // The response may be JSON directly
488
+ try {
489
+ const parsed = JSON.parse(text);
490
+ if (parsed.audio)
491
+ return parsed;
492
+ }
493
+ catch { /* not JSON */ }
494
+ // Strategy 3: Look for hidden input or textarea with JSON
495
+ const inputMatch = html.match(/value='(\{&quot;audio&quot;[^']*)'/);
496
+ if (inputMatch) {
497
+ jsonStr = inputMatch[1]
498
+ .replace(/&quot;/g, '"')
499
+ .replace(/&amp;/g, '&');
500
+ }
501
+ }
502
+ if (!jsonStr) {
503
+ throw new Error('Could not extract audio config JSON from device. Firmware may not support this endpoint.');
504
+ }
505
+ return JSON.parse(jsonStr);
506
+ }
507
+ /** Parse the raw goform JSON into our typed AudioSettings */
508
+ _parseAudioJson(raw) {
509
+ const audio = raw.audio;
510
+ const outputs = audio.io.output_devices.output_device;
511
+ const speaker = outputs.find((d) => d.kid === 'internal_speaker') || outputs[1];
512
+ const lineOut = outputs.find((d) => d.kid === 'line_out') || outputs[0];
513
+ const mic = audio.io.input_devices.input_device[0];
514
+ const dsp = audio.lines.line[0].dsp;
515
+ const avc = audio.avc;
516
+ const algo = avc.algorithm_avc || avc.algorithm || {};
517
+ return {
518
+ speaker: {
519
+ kid: speaker.kid,
520
+ gain: speaker.gain,
521
+ overrideGain: speaker.override_gain,
522
+ signalSource: speaker.signal_source,
523
+ outputType: speaker.output_type,
524
+ },
525
+ lineOut: {
526
+ kid: lineOut.kid,
527
+ gain: lineOut.gain,
528
+ overrideGain: lineOut.override_gain,
529
+ signalSource: lineOut.signal_source,
530
+ outputType: lineOut.output_type,
531
+ },
532
+ mic: {
533
+ kid: mic.kid,
534
+ gain: mic.gain,
535
+ inputType: mic.input_type,
536
+ },
537
+ aec: {
538
+ enabled: dsp.aec.enable,
539
+ mode: dsp.aec.aec_mode,
540
+ },
541
+ anc: {
542
+ enabled: dsp.anc.enable,
543
+ mode: dsp.anc.anc_mode,
544
+ },
545
+ fess: {
546
+ enabled: dsp.fess.enable,
547
+ threshold: dsp.fess.gain,
548
+ delay: dsp.fess.delay,
549
+ },
550
+ drc: {
551
+ enabled: dsp.drc.enable,
552
+ gain: dsp.drc.gain,
553
+ },
554
+ avc: {
555
+ enabled: avc.enable_avc,
556
+ digitalEnabled: avc.enable_davc,
557
+ threshold: algo.threshold ?? 55,
558
+ upperThreshold: algo.upper_threshold ?? 100,
559
+ attackRate: algo.attack_rate ?? 10,
560
+ decayRate: algo.decay_rate ?? 10,
561
+ hysteresis: algo.hysteresis ?? 3,
562
+ farEndLockoutTime: algo.far_end_lockout_time ?? 1,
563
+ },
564
+ mode: audio.mode,
565
+ };
566
+ }
567
+ // ── Tar.gz helpers (Node built-in zlib, no deps) ──────────────────────
568
+ async _extractXmlFromTarGz(tarGz) {
569
+ const { gunzipSync } = await import('node:zlib');
570
+ const tar = gunzipSync(tarGz);
571
+ // Simple tar parser — find ipst_config.xml
572
+ return this._findFileInTar(tar, 'ipst_config.xml');
573
+ }
574
+ async _modifyTarGzXml(tarGz, modify) {
575
+ const { gunzipSync, gzipSync } = await import('node:zlib');
576
+ const tar = gunzipSync(tarGz);
577
+ // Find and modify ipst_config.xml in the tar
578
+ const modified = this._replaceFileInTar(tar, 'ipst_config.xml', modify);
579
+ return gzipSync(modified);
580
+ }
581
+ /**
582
+ * Minimal tar file finder — tar format:
583
+ * Each file: 512-byte header + data padded to 512 bytes
584
+ * Header: name at offset 0 (100 bytes), size at offset 124 (12 bytes, octal)
585
+ */
586
+ _findFileInTar(tar, filename) {
587
+ let offset = 0;
588
+ while (offset < tar.length - 512) {
589
+ const header = tar.subarray(offset, offset + 512);
590
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0/g, '');
591
+ if (!name)
592
+ break; // End of tar
593
+ const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0/g, '').trim();
594
+ const size = parseInt(sizeStr, 8) || 0;
595
+ if (name.includes(filename)) {
596
+ return tar.subarray(offset + 512, offset + 512 + size).toString('utf8');
597
+ }
598
+ // Next entry: header (512) + data padded to 512
599
+ offset += 512 + Math.ceil(size / 512) * 512;
600
+ }
601
+ throw new Error(`File ${filename} not found in tar`);
602
+ }
603
+ /**
604
+ * Replace a file's content inside a tar buffer.
605
+ * If the new content is a different size, we rebuild the entry.
606
+ */
607
+ _replaceFileInTar(tar, filename, modify) {
608
+ const chunks = [];
609
+ let offset = 0;
610
+ let replaced = false;
611
+ while (offset < tar.length - 512) {
612
+ const header = Buffer.from(tar.subarray(offset, offset + 512));
613
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0/g, '');
614
+ if (!name) {
615
+ // Copy remaining (end-of-archive markers)
616
+ chunks.push(Buffer.from(tar.subarray(offset)));
617
+ break;
618
+ }
619
+ const sizeStr = header.subarray(124, 136).toString('utf8').replace(/\0/g, '').trim();
620
+ const size = parseInt(sizeStr, 8) || 0;
621
+ const dataStart = offset + 512;
622
+ const paddedSize = Math.ceil(size / 512) * 512;
623
+ if (name.includes(filename) && !replaced) {
624
+ const original = tar.subarray(dataStart, dataStart + size).toString('utf8');
625
+ const modified = modify(original);
626
+ const newData = Buffer.from(modified, 'utf8');
627
+ const newPaddedSize = Math.ceil(newData.length / 512) * 512;
628
+ // Update size in header (octal, 11 chars + null)
629
+ const newSizeStr = newData.length.toString(8).padStart(11, '0') + '\0';
630
+ Buffer.from(newSizeStr).copy(header, 124);
631
+ // Recalculate header checksum
632
+ this._updateTarChecksum(header);
633
+ chunks.push(header);
634
+ chunks.push(newData);
635
+ // Pad to 512 boundary
636
+ if (newPaddedSize > newData.length) {
637
+ chunks.push(Buffer.alloc(newPaddedSize - newData.length));
638
+ }
639
+ replaced = true;
640
+ }
641
+ else {
642
+ chunks.push(Buffer.from(tar.subarray(offset, dataStart + paddedSize)));
643
+ }
644
+ offset = dataStart + paddedSize;
645
+ }
646
+ return Buffer.concat(chunks);
647
+ }
648
+ /** Recalculate tar header checksum (sum of all header bytes with checksum field as spaces) */
649
+ _updateTarChecksum(header) {
650
+ // Clear checksum field (offset 148, 8 bytes) with spaces
651
+ for (let i = 148; i < 156; i++)
652
+ header[i] = 0x20;
653
+ // Sum all bytes
654
+ let sum = 0;
655
+ for (let i = 0; i < 512; i++)
656
+ sum += header[i];
657
+ // Write checksum (6 octal digits + null + space)
658
+ const checksumStr = sum.toString(8).padStart(6, '0') + '\0 ';
659
+ Buffer.from(checksumStr).copy(header, 148);
660
+ }
661
+ // ── Reboot ─────────────────────────────────────────────────────────────
662
+ /** Reboot the Zenitel (required after config changes) */
663
+ async reboot() {
664
+ try {
665
+ await this._post('/goform/zForm_system_prefs', { reboot: 'Reboot' });
666
+ }
667
+ catch {
668
+ // May disconnect before response — that's expected
669
+ }
670
+ }
671
+ // ── Internal helpers ────────────────────────────────────────────────────
672
+ async _fetch(path, method = 'GET', timeout, body, contentType) {
673
+ const controller = new AbortController();
674
+ const timer = setTimeout(() => controller.abort(), timeout ?? this.timeout);
675
+ try {
676
+ const headers = {
677
+ Authorization: this.authHeader,
678
+ };
679
+ if (contentType)
680
+ headers['Content-Type'] = contentType;
681
+ return await fetch(`${this.baseUrl}${path}`, {
682
+ method,
683
+ headers,
684
+ body,
685
+ signal: controller.signal,
686
+ redirect: 'follow',
687
+ });
688
+ }
689
+ finally {
690
+ clearTimeout(timer);
691
+ }
692
+ }
693
+ async _html(path) {
694
+ const res = await this._fetch(path);
695
+ if (!res.ok)
696
+ throw new Error(`HTTP ${res.status} from ${path}`);
697
+ return res.text();
698
+ }
699
+ async _post(path, fields) {
700
+ const body = new URLSearchParams(fields).toString();
701
+ const res = await this._fetch(path, 'POST', undefined, body, 'application/x-www-form-urlencoded');
702
+ return res.text();
703
+ }
704
+ /** Extract value from <input type=hidden id='name' value='val'> */
705
+ _hidden(html, id) {
706
+ const re = new RegExp(`id='${id}'\\s+value='([^']*)'`, 'i');
707
+ return html.match(re)?.[1] ?? '';
708
+ }
709
+ }