PageSourceSearch

https://warriorlifepodcast.com/demo/radio-setup-wizard.js?v=afe02c85a924

js warriorlifepodcast.com collected 2026-10-09 01:09:50 UTC 38,507 bytes, 751 lines download raw bytes

1/* DCC_RADIO_SETUP_WIZARD */
2(() => {
3  "use strict";
4
5  const stepNames = ["Hardware", "Radio", "CAT / PTT", "Audio", "Check"];
6  const state = {
7    step: 0,
8    catalog: null,
9    detection: null,
10    selectedModelId: null,
11    connectionType: "serial",
12    rigPort: "",
13    baud: "auto",
14    pttType: "RIG",
15    pttPort: "",
16    playbackDevice: "",
17    backendSettings: {},
18    details: null,
19    modelSearch: "",
20    manufacturerFilter: "",
21    detailsRequest: 0,
22    civAddress: "",
23    audioDevice: "",
24    guidance: null,
25    probe: null,
26    message: "",
27    messageKind: "",
28    busy: false
29  };
30
31  function escapeHtml(value) {
32    return String(value ?? "")
33      .replaceAll("&", "&")
34      .replaceAll("<", "&lt;")
35      .replaceAll(">", "&gt;")
36      .replaceAll('"', "&quot;")
37      .replaceAll("'", "&#039;");
38  }
39
40  async function fetchJson(url, options = {}) {
41    if (window.dccDemoMode) {
42      if (!window.dccDemoRadioApi) throw new Error("Radio setup is unavailable in this demo");
43      return window.dccDemoRadioApi(url, options);
44    }
45    const response = await fetch(url, {cache: "no-store", ...options});
46    let payload = {};
47    try {
48      payload = await response.json();
49    } catch {
50      payload = {};
51    }
52    if (!response.ok) {
53      const detail = Array.isArray(payload.detail) ? payload.detail.map(item => `${item.loc?.at(-1) || "Setting"}: ${item.msg}`).join("; ") : payload.detail;
54      throw new Error(detail || payload.message || `Request failed (${response.status})`);
55    }
56    return payload;
57  }
58
59  function modelById(modelId = state.selectedModelId) {
60    return state.catalog?.models?.find(
61      model => Number(model.model_id) === Number(modelId)
62    ) || null;
63  }
64
65  function selectedAudio() {
66    return state.catalog?.audio_candidates?.find(
67      item => item.capture_device === state.audioDevice
68    ) || null;
69  }
70
71  function requestPayload() {
72    const model = modelById();
73    if (!model) throw new Error("Select the exact radio model");
74    return {
75      model_id: Number(model.model_id),
76      manufacturer: model.manufacturer,
77      model_name: model.model,
78      connection_type: state.connectionType,
79      rig_port: state.rigPort,
80      baud: state.baud,
81      ptt_type: state.pttType,
82      ptt_port: state.pttPort,
83      hamlib_settings: state.backendSettings,
84      civ_address: state.civAddress || null,
85      audio_device: state.audioDevice,
86      playback_device: state.playbackDevice || state.audioDevice
87    };
88  }
89
90  function ensureOverlay() {
91    let overlay = document.querySelector(".dcc-wizard-overlay");
92    if (overlay) return overlay;
93
94    overlay = document.createElement("div");
95    overlay.className = "dcc-wizard-overlay";
96    overlay.hidden = true;
97    overlay.innerHTML = `
98      <section class="dcc-wizard" role="dialog" aria-modal="true" aria-labelledby="dcc-wizard-title">
99        <header class="dcc-wizard-header">
100          <div>
101            <small>${window.dccDemoMode ? "DEMO RADIO SETUP · SIMULATED CONNECTION" : "RECEIVE-ONLY RADIO SETUP"}</small>
102            <h2 id="dcc-wizard-title">Get my radio connected</h2>
103            <p>${window.dccDemoMode ? "Explore Hamlib settings. Saved settings are remembered in this browser." : "Guided Hamlib CAT and audio setup. This wizard never sends a PTT-on command."}</p>
104          </div>
105          <button type="button" class="dcc-wizard-close" aria-label="Close radio setup">×</button>
106        </header>
107        <nav class="dcc-wizard-steps" aria-label="Radio setup steps"></nav>
108        <main class="dcc-wizard-body"></main>
109        <footer class="dcc-wizard-footer"></footer>
110      </section>`;
111
112    document.body.appendChild(overlay);
113    overlay.querySelector(".dcc-wizard-close")?.addEventListener("click", closeWizard);
114    overlay.addEventListener("click", event => {
115      if (event.target === overlay && !state.busy) closeWizard();
116    });
117    return overlay;
118  }
119
120  function setMessage(message = "", kind = "") {
121    state.message = message;
122    state.messageKind = kind;
123  }
124
125  function renderSteps() {
126    const steps = ensureOverlay().querySelector(".dcc-wizard-steps");
127    steps.innerHTML = stepNames.map((name, index) => {
128      const className = index < state.step
129        ? "complete"
130        : index === state.step ? "active" : "";
131      return `<div class="dcc-wizard-step ${className}"><i>${index < state.step ? "✓" : index + 1}</i><span>${name}</span></div>`;
132    }).join("");
133  }
134
135  function renderMessage() {
136    if (!state.message) return "";
137    return `<div class="dcc-wizard-alert ${escapeHtml(state.messageKind)}">${escapeHtml(state.message)}</div>`;
138  }
139
140  function deviceRows(items, type) {
141    if (!items?.length) {
142      return `<div class="dcc-wizard-alert">${type === "serial" ? "No serial device was detected. You can enter a device path or select network CAT in the next steps." : "No audio device was detected. Connect the radio's USB audio or an external sound interface to the radio host, then reopen setup."}</div>`;
143    }
144    return `<div class="dcc-device-list">${items.map((item, index) => {
145      const value = type === "serial" ? item.path : item.capture_device;
146      const checked = value === (type === "serial" ? state.rigPort : state.audioDevice);
147      const title = type === "serial"
148        ? item.likely_model
149        : `${item.device_id} — ${item.label}`;
150      const detail = type === "serial"
151        ? `${item.path}${item.stable_path ? " • stable path" : " • temporary path"}`
152        : item.capture_device;
153      return `<label class="dcc-device">
154        <input type="radio" name="dcc-${type}-device" value="${escapeHtml(value)}" ${checked ? "checked" : ""}>
155        <span><strong>${escapeHtml(title || `Device ${index + 1}`)}</strong><small>${escapeHtml(detail)}</small></span>
156      </label>`;
157    }).join("")}</div>`;
158  }
159
160  function renderHardware() {
161    const suggestions = state.catalog.suggestions || [];
162    const suggestion = suggestions[0];
163    const summary = suggestion
164      ? `Best match: ${suggestion.display_name} (${suggestion.confidence} confidence). You will confirm it on the next page.`
165      : "No exact model name was available from USB. Select the radio from the complete Hamlib list on the next page.";
166    return `
167      <h3>${window.dccDemoMode ? "Try a sample radio connection" : "What did the Pi find?"}</h3>
168      <p>${window.dccDemoMode ? "These are sample CAT and audio devices. Browse the full offline Hamlib catalog in the next step." : "The wizard checks stable USB serial names and ALSA audio cards without opening transmit audio or keying the radio."}</p>
169      <div class="dcc-wizard-alert success">${escapeHtml(summary)}</div>
170      <div class="dcc-wizard-grid">
171        <section class="dcc-wizard-panel">
172          <h4>CAT / serial devices</h4>
173          ${deviceRows(state.catalog.serial_candidates, "serial")}
174        </section>
175        <section class="dcc-wizard-panel">
176          <h4>Receive / transmit audio devices</h4>
177          ${deviceRows(state.catalog.audio_candidates, "audio")}
178        </section>
179      </div>
180      ${renderMessage()}`;
181  }
182
183  function manufacturerOptions() {
184    const names = [...new Set(
185      (state.catalog.models || []).map(model => model.manufacturer).filter(Boolean)
186    )].sort((a, b) => a.localeCompare(b));
187    return [`<option value="">All manufacturers</option>`]
188      .concat(names.map(name => `<option value="${escapeHtml(name)}" ${state.manufacturerFilter === name ? "selected" : ""}>${escapeHtml(name)}</option>`))
189      .join("");
190  }
191
192  function filteredModels(search = "", manufacturer = "") {
193    const normalize = value => String(value).toUpperCase().replace(/[^A-Z0-9]/g, "");
194    const needle = normalize(search);
195    return (state.catalog.models || []).filter(model => {
196      if (manufacturer && model.manufacturer !== manufacturer) return false;
197      if (!needle) return true;
198      return normalize(`${model.manufacturer} ${model.model} ${model.model_id}`).includes(needle);
199    });
200  }
201
202  function modelOptions(models) {
203    return models.map(model => {
204      const selected = Number(model.model_id) === Number(state.selectedModelId);
205      return `<option value="${model.model_id}" ${selected ? "selected" : ""}>${escapeHtml(model.display_name)} — #${model.model_id} · ${escapeHtml(model.status || "Status not reported")}</option>`;
206    }).join("");
207  }
208
209  function guidanceHtml() {
210    if (!state.guidance) return "";
211    return `<section class="dcc-guidance">
212      <h4>Radio-side instructions: ${escapeHtml(state.guidance.family)}</h4>
213      <ol>${(state.guidance.steps || []).map(step => `<li>${escapeHtml(step)}</li>`).join("")}</ol>
214    </section>`;
215  }
216
217  function renderRadio() {
218    const models = filteredModels(state.modelSearch, state.manufacturerFilter);
219    return `
220      <h3>Select the exact radio</h3>
221      <p>All radio backends from the ${window.dccDemoMode ? "offline demo" : "installed"} Hamlib catalog, including receivers and experimental drivers. Available controls depen
221d on the selected backend.</p>
222      <div class="dcc-wizard-form">
223        <label>Search radios
224          <input type="search" id="dcc-model-search" value="${escapeHtml(state.modelSearch)}" placeholder="Model, manufacturer, or Hamlib ID">
225        </label>
226        <label>Manufacturer
227          <select id="dcc-manufacturer-filter">${manufacturerOptions()}</select>
228        </label>
229        <label class="wide">Radio model
230          <select id="dcc-model-select" size="10">${modelOptions(models)}</select>
231        </label>
232      </div>
233      <p class="dcc-radio-count" id="dcc-model-count">${models.length} of ${state.catalog.model_count} Hamlib backends · ${escapeHtml(state.catalog.hamlib_version || "Version unavailable")}</p>
234      <div id="dcc-model-details">${modelDetailsHtml()}</div>
235      ${guidanceHtml()}
236      ${renderMessage()}`;
237  }
238
239  function serialOptions() {
240    const paths = state.catalog.serial_candidates || [];
241    return paths.map(item => {
242      const selected = item.path === state.rigPort;
243      return `<option value="${escapeHtml(item.path)}" ${selected ? "selected" : ""}>${escapeHtml(item.likely_model)}
243 — ${escapeHtml(item.path)}</option>`;
244    }).join("");
245  }
246
247  function modelDetailsHtml() {
248    if (!modelById()) return `<p>No matching radios. Change the search or manufacturer filter.</p>`;
249    if (!state.details) return `<p>Loading this radio's settings…</p>`;
250    return `<p class="dcc-wizard-copy">${escapeHtml(state.details.note)}</p>
251      ${state.details.capabilities?.text ? `<details class="dcc-backend-details"><summary>Backend capabilities, modes, ranges, and meters</summary><pre>${escapeHtml(state.details.capabilities.text)}</pre></details>` : ""}`;
252  }
253
254  function backendSettingsHtml() {
255    const options = state.details?.settings || [];
256    const editable = options.filter(item => item.editable);
257    return `<details class="dcc-backend-details"><summary>Hamlib settings (${editable.length} configurable)</summary>
258      <p>Leave a field at “Backend default” to use this radio's own defaults. Radio menu settings must match.</p>
259      <div class="dcc-wizard-form">${editable.map(item => {
260        const value = state.backendSettings[item.name] ?? "";
261        const defaultLabel = `Backend default${item.default ? ` — ${item.default}` : ""}`;
262        const control = item.type === "combo"
263          ? `<select data-hamlib-setting="${escapeHtml(item.name)}"><option value="">${escapeHtml(defaultLabel)}</option>${item.choices.map(choice => `<option value="${escapeHtml(choice)}" ${value === choice ? "selected" : ""}>${escapeHtml(choice)}</option>`).join("")}</select>`
264          : `<input type="${item.type === "number" ? "number" : "text"}" data-hamlib-setting="${escapeHtml(item.name)}" value="${escapeHtml(value)}" placeholder="${escapeHtml(defaultLabel)}" ${item.type === "number" ? `min="${escapeHtml(item.minimum)}" max="${escapeHtml(item.maximum)}" step="${Number(item.step) > 0 ? escapeHtml(item.step) : "any"}"` : ""}>`;
265        return `<label>${escapeHtml(item.name.replaceAll("_", " "))}${control}<small>${escapeHtml(item.description)}${item.type === "number" ? ` (${escapeHtml(item.minimum)}–${escapeHtml(item.maximum)})` : ""}</small></label>`;
266      }).join("")}</div>
267      ${!editable.length ? `<p>${escapeHtml(state.details?.note || "No backend settings were returned.")}</p>` : ""}
268      <p>Port, baud, CI-V and PTT use the fields above. DTR/RTS force-on controls and action buttons are excluded from receive-only setup.</p>
269    </details>`;
270  }
271
272  function invalidateProbe() {
273    state.probe = null;
274  }
275
276  async function selectModel(modelId) {
277    if (Number(state.selectedModelId) === Number(modelId)) return;
278    state.selectedModelId = modelId;
279    state.details = null;
280    state.guidance = null;
281    state.backendSettings = {};
282    state.baud = "auto";
283    state.pttType = "RIG";
284    state.pttPort = "";
285    state.civAddress = "";
286    if (Number(modelId) === 2) { state.connectionType = "rigctld"; state.rigPort = ""; }
287    invalidateProbe();
288    renderFooter();
289    await loadGuidance();
290    // Preserve search focus and scroll while asynchronous metadata arrives.
291    const details = ensureOverlay().querySelector("#dcc-model-details");
292    if (details) details.innerHTML = modelDetailsHtml();
293    const guidance = ensureOverlay().querySelector(".dcc-guidance");
294    if (guidance) guidance.outerHTML = guidanceHtml();
295  }
296
297  function renderCat() {
298    const isNetwork = ["network", "rigctld"].includes(state.connectionType);
299    const isRemote = state.connectionType === "rigctld" || Number(state.selectedModelId) === 2;
300    const isIcom = `${modelById()?.manufacturer || ""} ${modelById()?.model || ""}`.toUpperCase().includes("ICOM");
301    return `
302      <h3>CAT connection and PTT method</h3>
303      <p>${window.dccDemoMode ? "Explore connection options and each radio's Hamlib settings. No device will be opened." : "Digital Command Center will c
303onfigure a local rigctld connection, but the main application remains transmit-locked."}</p>
304      <div class="dcc-wizard-form">
305        <label>Connection type
306          <select id="dcc-connection-type">
307            <option value="serial" ${state.connectionType === "serial" ? "selected" : ""}>USB or serial cable</option>
308            <option value="network" ${state.connectionType === "network" ? "selected" : ""}>Direct network CAT (selected radio backend)</option>
309            <option value="rigctld" ${state.connectionType === "rigctld" ? "selected" : ""}>Remote rigctld server (NET rigctl)</option>
310            <option value="device" ${state.connectionType === "device" ? "selected" : ""}>Other Hamlib device path</option>
311          </select>
312        </label>
313        ${isNetwork ? `
314          <label>Network CAT address
315            <input type="text" id="dcc-rig-port" value="${escapeHtml(state.rigPort)}" placeholder="192.168.1.50:4532">
316          </label>` : `
317          <label>CAT device path
318            <input type="text" id="dcc-rig-port" list="dcc-serial-paths" value="${escapeHtml(state.rigPort)}" placeholder="/dev/ttyUSB0">
319            <datalist id="dcc-serial-paths">${serialOptions()}</datalist>
320          </label>`}
321        <label>CAT baud rate
322          <select id="dcc-baud" ${state.connectionType !== "serial" ? "disabled" : ""}>${(state.catalog.baud_rates || []).map(value => `<option value="${value}" ${value === state.baud ? "selected" : ""}>${value === "auto" ? "Auto — Hamlib backend default" : `${value} baud`}</option>`).join("")}</select>
323        </label>
324        <label>PTT method
325          <select id="dcc-ptt-type">${(isRemote ? ["RIG", "NONE"] : state.catalog.ptt_types).map(value => `<option value="${value}" ${state.pttType === value ? "selected" : ""}>${escapeHtml(({RIG: "CAT command", NONE: "None — receive only", GPION: "GPIO (inverted)"})[value] || value)}</option>`).join("")}</select>
326        </label>
327        ${!isRemote && !["RIG", "NONE"].includes(state.pttType) ? `<label>PTT device / GPIO pin
328          <input type="text" id="dcc-ptt-port" value="${escapeHtml(state.pttPort)}" placeholder="Blank uses CAT port for DTR / RTS">
329        </label>` : ""}
330        ${isIcom && !isRemote ? `<label>CI-V address (optional)
331          <input type="text" id="dcc-civ-address" value="${escapeHtml(state.civAddress)}" placeholder="Example: 0xA4">
332        </label>` : ""}
333      </div>
334      ${isRemote ? `<p class="dcc-wizard-copy">The remote rigctld server owns the radio's serial and PTT settings. Audio must be routed separately to an audio interface on this host.</p>` : backendSettingsHtml()}
335      <div class="dcc-wizard-alert">${window.dccDemoMode ? "Transmit and tune are simulated on the dashboard with a matched antenna. These settings never connect to physical hardware." : "The PTT method is saved for future transmit setup. The receive-only service runs with PTT disabled; this wizard never keys the radio."}</div>
336      ${guidanceHtml()}
337      ${renderMessage()}`;
338  }
339
340  function audioOptions(playback = false) {
341    return (state.catalog.audio_candidates || []).map(item => {
342      const device = playback ? item.playback_device : item.capture_device;
343      const selected = device === (playback ? state.playbackDevice : state.audioDevice);
344      return `<option value="${escapeHtml(device)}" ${selected ? "selected" : ""}>${escapeHtml(item.device_id)} — ${escapeHtml(item.label)}</option>`;
345    }).join("");
346  }
347
348  function renderAudio() {
349    const audio = selectedAudio();
350    return `
351      <h3>Select the radio audio interface</h3>
352      <p>The same USB codec is normally used for receive and transmit audio. Only receive audio is opened during setup.</p>
353      <div class="dcc-wizard-form">
354        <label class="wide">Receive audio device
355          <select id="dcc-audio-device">${audioOptions()}</select>
356        </label>
357        <label class="wide">Playback audio device (saved for transmit setup)
358          <select id="dcc-playback-device">${audioOptions(true)}</select>
359        </label>
360      </div>
361      <section class="dcc-guidance">
362        <h4>Selected ALSA paths</h4>
363        <ol>
364          <li>Receive: ${escapeHtml(audio?.capture_device || "No device selected")}</li>
365          <li>Transmit: ${escapeHtml(audio?.playback_device || "No device selected")} — not opened by this wizard</li>
366          <li>Set the radio DATA input to USB when it has a built-in USB codec.</li>
367        </ol>
368      </section>
369      <h3 style="margin-top:18px">Common setup questions</h3>
370      <div class="dcc-help-list">
371        <details><summary>Which serial port should I choose?</summary><p>Prefer a path under /dev/serial/by-id. It follows the same radio after a reboot or USB reconnect. ttyUSB and ttyACM names can change.</p></details>
372        <details><summary>What baud rate should I use?</summary><p>The radio menu and Hamlib must agree. Auto uses the backend default; modern USB radios often work best at 38400 or 115200.</p></details>
373        <details><summary>Should I use CAT or VOX for PTT?</summary><p>Use CAT whenever the radio supports it. VOX is intentionally not configured because background computer audio could key the transmitter.</p></details>
374        <details><summary>${window.dccDemoMode ? "Can I try transmitting?" : "Why is transmit still locked?"}</summary><p>${window.dccDemoMode ? "Yes. Enable TX or Tune on the demo dashboard to try the simulated radio and matched antenna." : "Radio setup proves CAT and receive audio first. Transmit requires separate antenna, power, waveform, and safety approval."}</p></details>
375        <details><summary>What if no USB audio appears?</summary><p>Confirm the cable carries data, try another USB port, power-cycle the radio, and check that the radio exposes its USB audio codec.</p></details>
376      </div>
377      ${renderMessage()}`;
378  }
379
380  function checkRow(label, value, detail = "") {
381    const stateClass = value ? "ok" : "bad";
382    return `<div class="dcc-check"><span>${escapeHtml(label)}${detail ? `<small style="display:block;color:#78909a;margin-top:3px">${escapeHtml(detail)}</small>` : ""}</span><strong class="${stateClass}">${value ? "PASS" : "CHECK"}</strong></div>`;
383  }
384
385  function renderCheck() {
386    const model = modelById();
387    const probe = state.probe;
388    return `
389      <h3>${window.dccDemoMode ? "Review demo radio settings" : "Receive-only connection check"}</h3>
390      <p>${window.dccDemoMode ? "Validate and save these settings for your next visit. No hardware connection or transmission is tested." : "Review the selection, run the checks, then save it. Applying the setup briefly restarts radio control and the dashboard."}</p>
391      <div class="dcc-wizard-grid">
392        <section class="dcc-wizard-panel">
393          <h4>Selected configuration</h4>
394          <div class="dcc-check-list">
395            <div class="dcc-check"><span>Radio</span><strong>${escapeHtml(model?.display_name || "Not selected")}</strong></div>
396            <div class="dcc-check"><span>CAT</span><strong>${escapeHtml(state.rigPort || "Not selected")}</strong></div>
397            <div class="dcc-check"><span>Baud / PTT</span><strong>${escapeHtml(`${state.baud} / ${state.pttType}`)}</strong></div>
398            <div class="dcc-check"><span>Audio</span><strong>${escapeHtml(state.audioDevice || "Not selected")}</strong></div>
399            <div class="dcc-check"><span>Playback</span><strong>${escapeHtml(state.playbackDevice || state.audioDevice)}</strong></div>
400            ${state.pttPort ? `<div class="dcc-check"><span>PTT device (saved)</span><strong>${escapeHtml(state.pttPort)}</strong></div>` : ""}
401            ${Object.entries(state.backendSettings).map(([key, value]) => `<div class="dcc-check"><span>${escapeHtml(key)}</span><strong>${escapeHtml(value)}</strong></div>`).join("")}
402            <div class="dcc-check"><span>Transmit state</span><strong class="ok">LOCKED</strong></div>
403          </div>
404        </section>
405        <section class="dcc-wizard-panel">
406          <h4>${window.dccDemoMode ? "Demo validation" : "Hardware checks"}</h4>
407          ${probe ? `<div class="dcc-check-list">
408            ${checkRow("Hamlib model installed", probe.checks.model_available)}
409            ${checkRow("Hamlib settings valid", probe.checks.settings_valid !== false)}
410            ${checkRow("Playback device detected", probe.checks.playback_device_detected !== false)}
411            ${checkRow("CAT connection present", probe.checks.connection_present, probe.connection_detail)}
412            ${checkRow("Audio device detected", probe.checks.audio_device_detected)}
413            ${window.dccDemoMode ? "" : checkRow("Current CAT responding", probe.checks.current_cat_connected, probe.current_frequency_hz ? `${(probe.current_frequency_hz / 1000000).toFixed(6)} MHz ${probe.current_mode || ""}` : "A new configuration may need to be applied first")}
414          </div>` : `<div class="dcc-wizard-alert">Run the checks to confirm the selected hardware before saving.</div>`}
415        </section>
416      </div>
417      <div class="dcc-wizard-actions" style="margin-top:15px">
418        <button type="button" id="dcc-run-probe" ${state.busy ? "disabled" : ""}>${state.busy ? "Checking…" : window.dccDemoMode ? "Validate demo settings" : "Run receive-only checks"}</button>
419        <button type="button" class="primary" id="dcc-apply-radio" ${!probe?.ready_to_apply || state.busy ? "disabled" : ""}>${window.dccDemoMode ? "Save in demo" : "Save and connect receive-only"}</button>
420      </div>
421      ${renderMessage()}`;
422  }
423
424  function renderBody() {
425    const body = ensureOverlay().querySelector(".dcc-wizard-body");
426    if (!state.catalog) {
427      body.innerHTML = `<h3>Scanning the Pi…</h3><p>Reading Hamlib models, USB serial devices, and audio cards.</p>${renderMessage()}`;
428      return;
429    }
430    body.innerHTML = [renderHardware, renderRadio, renderCat, renderAudio, renderCheck][state.step]();
431    bindStepControls();
432  }
433
434  function canContinue() {
435    if (!state.catalog) return false;
436    if (state.step === 1) return Boolean(modelById()) && Boolean(state.details);
437    if (state.step === 2) return Boolean(state.rigPort);
438    if (state.step === 3) return Boolean(state.audioDevice);
439    return true;
440  }
441
442  function renderFooter() {
443    const footer = ensureOverlay().querySelector(".dcc-wizard-footer");
444    footer.innerHTML = `
445      <span>${window.dccDemoMode ? "SIMULATED TX / RX · MATCHED ANTENNA" : "TX LOCKED • NO PTT TEST"}</span>
446      <div class="dcc-wizard-actions">
447        <button type="button" id="dcc-wizard-back" ${state.step === 0 || state.busy ? "disabled" : ""}>Back</button>
448        ${state.step < stepNames.length - 1
449          ? `<button type="button" class="primary" id="dcc-wizard-next" ${!canContinue() || state.busy ? "disabled" : ""}>Next</button>`
450          : `<button type="button" id="dcc-wizard-finish" ${state.busy ? "disabled" : ""}>Close</button>`}
451      </div>`;
452    footer.querySelector("#dcc-wizard-back")?.addEventListener("click", () => {
453      state.step = Math.max(0, state.step - 1);
454      setMessage();
455      renderAll();
456    });
457    footer.querySelector("#dcc-wizard-next")?.addEventListener("click", async () => {
458      state.step = Math.min(stepNames.length - 1, state.step + 1);
459      setMessage();
460      if (state.step === 2) await loadGuidance();
461      renderAll();
462    });
463    footer.querySelector("#dcc-wizard-finish")?.addEventListener("click", closeWizard);
464  }
465
466  function renderAll() {
467    renderSteps();
468    renderBody();
469    renderFooter();
470  }
471
472  async function loadGuidance() {
473    const model = modelById();
474    if (!model) return;
475    if (state.details?.model_id === Number(model.model_id)) return;
476    const revision = ++state.detailsRequest;
477    try {
478      const details = await fetchJson(
479        `/api/v1/radio/setup/models/${Number(model.model_id)}`
480      );
481      if (revision !== state.detailsRequest || Number(model.model_id) !== Number(state.selectedModelId)) return;
482      state.details = details;
483      state.guidance = details.guidance;
484    } catch (error) {
485      if (revision === state.detailsRequest) setMessage(error.message, "error");
486    }
487    renderFooter();
488  }
489
490  function bindStepControls() {
491    const body = ensureOverlay().querySelector(".dcc-wizard-body");
492    body.addEventListener("input", invalidateProbe, {once: true});
493    body.addEventListener("change", invalidateProbe, {once: true});
494    body.querySelectorAll('input[name="dcc-serial-device"]').forEach(input => {
495      input.addEventListener("change", () => { state.rigPort = input.value; renderFooter(); });
496    });
497    body.querySelectorAll('input[name="dcc-audio-device"]').forEach(input => {
498      input.addEventListener("change", () => { state.audioDevice = input.value; renderFooter(); });
499    });
500
501    const search = body.querySelector("#dcc-model-search");
502    const manufacturer = body.querySelector("#dcc-manufacturer-filter");
503    const select = body.querySelector("#dcc-model-select");
504    function refreshModelOptions() {
505      state.modelSearch = search?.value || "";
506      state.manufacturerFilter = manufacturer?.value || "";
507      const models = filteredModels(search?.value || "", manufacturer?.value || "");
508      select.innerHTML = modelOptions(models);
509      if (!models.some(item => Number(item.model_id) === Number(state.selectedModelId))) {
510        void selectModel(models[0]?.model_id || null);
511        if (select && state.selectedModelId) select.value = String(state.selectedModelId);
512      }
513      body.querySelector("#dcc-model-count").textContent = `${models.length} of ${state.catalog.model_count} Hamlib backends`;
514      body.querySelector("#dcc-model-details").innerHTML = modelDetailsHtml();
515      renderFooter();
516    }
517    search?.addEventListener("input", refreshModelOptions);
518    manufacturer?.addEventListener("change", refreshModelOptions);
519    select?.addEventListener("change", async () => {
520      await selectModel(Number(select.value));
521    });
522
523    body.querySelector("#dcc-connection-type")?.addEventListener("change", event => {
524      state.connectionType = event.target.value;
525      if (["network", "rigctld"].includes(state.connectionType) && state.rigPort.startsWith("/dev/")) {
526        state.rigPort = "";
527      }
528      if (state.connectionType !== "serial") state.baud = "auto";
529      if (state.connectionType === "rigctld") {
530        state.backendSettings = {}; state.pttType = "RIG"; state.pttPort = ""; state.civAddress = "";
531      }
532      if (state.connectionType === "serial" && !state.rigPort.startsWith("/dev/")) {
533        state.rigPort = state.catalog.serial_candidates?.[0]?.path || "";
534      }
535      renderAll();
536    });
537    body.querySelector("#dcc-rig-port")?.addEventListener("change", event => {
538      state.rigPort = event.target.value.trim();
539      renderFooter();
540    });
541    body.querySelector("#dcc-rig-port")?.addEventListener("input", event => {
542      state.rigPort = event.target.value.trim();
543      renderFooter();
544    });
545    body.querySelector("#dcc-baud")?.addEventListener("change", event => { state.baud = event.target.value; });
546    body.querySelector("#dcc-ptt-type")?.addEventListener("change", event => { state.pttType = event.target.value; state.pttPort = ""; renderAll(); });
547    body.querySelector("#dcc-ptt-port")?.addEventListener("input", event => { state.pttPort = event.target.value.trim(); });
548    body.querySelectorAll("[data-hamlib-setting]").forEach(input => {
549      input.addEventListener("input", () => {
550        if (input.value === "") delete state.backendSettings[input.dataset.hamlibSetting];
551        else state.backendSettings[input.dataset.hamlibSetting] = input.value;
552        invalidateProbe();
553      });
554    });
555    body.querySelector("#dcc-playback-device")?.addEventListener("change", event => { state.playbackDevice = event.target.value; });
556    body.querySelector("#dcc-civ-address")?.addEventListener("input", event => { state.civAddress = event.target.value.trim(); });
557    body.querySelector("#dcc-audio-device")?.addEventListener("change", event => {
558      state.audioDevice = event.target.value;
559      renderAll();
560    });
561    body.querySelector("#dcc-run-probe")?.addEventListener("click", runProbe);
562    body.querySelector("#dcc-apply-radio")?.addEventListener("click", applyRadio);
563  }
564
565  async function runProbe() {
566    state.busy = true;
567    setMessage("Running receive-only hardware checks…");
568    renderAll();
569    try {
570      state.probe = await fetchJson("/api/v1/radio/setup/probe", {
571        method: "POST",
572        headers: {"Content-Type": "application/json"},
573        body: JSON.stringify(requestPayload())
574      });
575      setMessage(
576        state.probe.ready_to_apply
577          ? window.dccDemoMode ? "Demo settings are valid. No hardware was checked." : "Hardware checks passed. The configuration is ready to save."
578          : state.probe.settings_error || "One or more hardware checks need attention before saving.",
579        state.probe.ready_to_apply ? "success" : "error"
580      );
581    } catch (error) {
582      state.probe = null;
583      setMessage(error.message, "error");
584    } finally {
585      state.busy = false;
586      renderAll();
587    }
588  }
589
590  async function waitForApply(requestedAt) {
591    for (let attempt = 0; attempt < 45; attempt += 1) {
592      await new Promise(resolve => setTimeout(resolve, 2000));
593      let status;
594      try {
595        status = await fetchJson("/api/v1/radio/setup/status");
596      } catch (error) {
597        if (attempt > 35) throw error;
598        continue;
599      }
600      if (requestedAt && status.requested_at_utc !== requestedAt) continue;
601      if (status.state === "success") {
602        if (status.schema_version !== 2) throw new Error("Update the radio setup worker to support the new Hamlib settings. This worker did not confirm that they were applied.");
603        return status;
604      }
605      if (status.state === "failed") {
606        throw new Error(status.message || "Radio setup failed and was rolled back");
607      }
608    }
609    throw new Error("The dashboard did not confirm the new radio connection in time");
610  }
611
612  async function applyRadio() {
613    state.busy = true;
614    setMessage("Saving the receive-only configuration. The dashboard may reconnect briefly.");
615    renderAll();
616    try {
617      const queued = await fetchJson("/api/v1/radio/setup/apply", {
618        method: "POST",
619        headers: {"Content-Type": "application/json"},
620        body: JSON.stringify(requestPayload())
621      });
622      const status = await waitForApply(queued.requested_at_utc);
623      const mhz = Number(status.frequency_hz) / 1000000;
624      setMessage(
625        status.simulated ? `${status.radio_name} connected in the simulator. Transmit and tune are ready to try.` : `${status.radio_name || "Radio"} connected receive-only${Number.isFinite(mhz) ? ` at ${mhz.toFixed(6)} MHz` : ""}. Transmit remains locked.`,
626        "success"
627      );
628      state.probe = {
629        ready_to_apply: true,
630        connection_detail: status.simulated ? "Saved in this browser's demo settings" : "New CAT configuration is responding",
631        current_frequency_hz: status.frequency_hz,
632        current_mode: status.mode,
633        checks: {
634          model_available: true,
635          connection_present: true,
636          audio_device_detected: true,
637          current_cat_connected: true
638        }
639      };
640      window.dispatchEvent(new CustomEvent("dcc-radio-setup-complete", {detail: status}));
641    } catch (error) {
642      setMessage(error.message, "error");
643    } finally {
644      state.busy = false;
645      renderAll();
646    }
647  }
648
649  async function loadCatalog() {
650    const [catalog, detection] = await Promise.all([
651      fetchJson("/api/v1/radio/setup/catalog"),
652      fetchJson("/api/v1/radio/detect")
653    ]);
654    if (!catalog.models?.length) {
655      throw new Error("Hamlib did not return any radio models. Confirm rigctl is installed.");
656    }
657    state.catalog = catalog;
658    state.detection = detection;
659
660    const configured = detection.configured || {};
661    const suggestion = catalog.suggestions?.[0];
662    const configuredExists = catalog.models.some(
663      model => Number(model.model_id) === Number(configured.model_id)
664    );
665    state.selectedModelId = configuredExists ? configured.model_id : suggestion?.model_id || catalog.models[0].model_id;
666    state.connectionType = configured.connection_type || "serial";
667    state.rigPort = configured.rig_port || suggestion?.serial_path || catalog.serial_candidates?.[0]?.path || "";
668    state.audioDevice = (
669      catalog.audio_candidates?.find(item => item.capture_device === configured.audio_device)?.capture_device ||
670      catalog.audio_candidates?.find(item => item.likely_radio_audio)?.capture_device ||
671      catalog.audio_candidates?.[0]?.capture_device || ""
672    );
673    state.baud = configured.baud || "auto";
674    state.pttType = configured.ptt || "RIG";
675    state.civAddress = configured.civ_address || "";
676    state.backendSettings = {...(configured.hamlib_settings || {})};
677    state.pttPort = configured.ptt_port || "";
678    state.playbackDevice = configured.playback_device || state.audioDevice;
679    state.details = null;
680    state.modelSearch = "";
681    state.manufacturerFilter = "";
682    await loadGuidance();
683  }
684
685  async function openWizard() {
686    const overlay = ensureOverlay();
687    const settingsDialog = document.querySelector(".settings-dialog");
688    const settingsDone = Array.from(
689      settingsDialog?.querySelectorAll("button") || []
690    ).find(button => button.textContent.trim().toUpperCase() === "DONE");
691    if (settingsDone) {
692      settingsDone.click();
693      await new Promise(resolve => setTimeout(resolve, 120));
694    }
695    overlay.hidden = false;
696    state.step = 0;
697    state.catalog = null;
698    state.probe = null;
699    state.busy = true;
700    setMessage();
701    renderAll();
702    try {
703      await loadCatalog();
704    } catch (error) {
705      setMessage(error.message, "error");
706    } finally {
707      state.busy = false;
708      renderAll();
709      overlay.querySelector(".dcc-wizard-close")?.focus();
710    }
711  }
712
713  function closeWizard() {
714    if (state.busy) return;
715    ensureOverlay().hidden = true;
716  }
717
718  function addSettingsCard() {
719    const dialog = document.querySelector(".settings-dialog");
720    if (!dialog) return;
721    const activeTab = dialog.querySelector('[role="tab"][aria-selected="true"]');
722    if (!activeTab || activeTab.textContent.trim().toUpperCase() !== "RADIO") return;
723    const panel = dialog.querySelector('[role="tabpanel"][data-state="active"]');
724    if (!panel) return;
725
726    const section = panel.querySelector(".settings-section");
727    const heading = section?.querySelector("h3");
728    if (heading) heading.textContent = "Hamlib radio setup";
729    const description = section?.querySelector("p");
730    if (description) description.textContent = "Choose any installed Hamlib radio backend and configure its CAT connection, audio interface, and model-specific settings.";
731    const oldForm = panel.querySelector(".settings-form");
732    const oldTest = panel.querySelector(".test-strip");
733    if (oldForm) oldForm.style.display = "none";
734    if (oldTest) oldTest.style.display = "none";
735    section?.querySelectorAll("button").forEach(button => {
736      if (button.textContent.toUpperCase().includes("AUTO DETECT")) button.style.display = "none";
737    });
738
739    if (panel.querySelector(".dcc-radio-setup-card")) return;
740    const card = document.createElement("div");
741    card.className = "dcc-radio-setup-card";
742    card.innerHTML = `<div><strong>Connect any Hamlib-supported radio</strong><span>Automatic hardware clues, the complete WSJT-X/Hamlib radio list, model-specific help, CAT/PTT questions, audio selection, and a receive-only connection test.</span></div><button type="button">Get my radio connected</button>`;
743    card.querySelector("button")?.addEventListener("click", openWizard);
744    section?.insertAdjacentElement("afterend", card);
745  }
746
747  window.dccRadioSetupOpen = openWizard;
748  ensureOverlay();
749  addSettingsCard();
750  setInterval(addSettingsCard, 400);
751})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.