import {existsSync, mkdirSync} from "node:fs"; import {createServer} from "node:http"; import {parse} from "node:querystring"; import {findAllDevices} from "zigbee-herdsman/dist/adapter/adapterDiscovery"; import data from "./data"; import * as settings from "./settings"; import {YAMLFileException} from "./yaml"; type OnboardSettings = { mqtt_base_topic?: string; mqtt_server?: string; mqtt_user?: string; mqtt_password?: string; serial_port?: string; serial_adapter?: Settings["serial"]["adapter"]; serial_baudrate?: string; serial_rtscts?: "on"; network_channel?: string; network_key?: string; network_pan_id?: string; network_ext_pan_id?: string; frontend_enabled?: "on"; frontend_port?: string; homeassistant_enabled?: "on"; log_level?: Settings["advanced"]["log_level"]; }; function escapeHtml(s: string): string { return s.replace(/[^0-9A-Za-z \-_.]/g, (c) => `&#${c.charCodeAt(0)};`); } function generateHtmlDone(frontendUrl: string | undefined): string { return ` Zigbee2MQTT Onboarding

Zigbee2MQTT Onboarding

Settings saved.

Zigbee2MQTT is now starting...

${frontendUrl ? `Redirecting to Zigbee2MQTT frontend at ${frontendUrl} in 30 seconds.` : "You can close this page."}
${frontendUrl ? `` : ""} `; } function generateHtmlForm(currentSettings: RecursivePartial, devices: Awaited>): string { let devicesSelect = ""; if (devices.length > 0) { devicesSelect += '"; devicesSelect += "Optionally allows to configure coordinator port and type (if known) automatically."; } else { devicesSelect = "No device found"; } let generateCheckbox = ""; if ( Array.isArray(currentSettings.advanced?.network_key) || typeof currentSettings.advanced?.pan_id === "number" || Array.isArray(currentSettings.advanced?.ext_pan_id) ) { generateCheckbox = ` `; } /* v8 ignore start */ return ` Zigbee2MQTT Onboarding

Zigbee2MQTT Onboarding

Set the base configuration to start Zigbee2MQTT.

Optional fields will either be ignored or fallback to defaults if not set (see appropriate documentation page for more details).

If a field is disabled, it means environment variables are being used to override specific values (for example, through the Home Assistant add-on configuration page).


${devicesSelect}
Can be ignored for networked coordinators (TCP). style="margin-bottom: 1rem;"> Can be ignored for networked coordinators (TCP).
https://www.zigbee2mqtt.io/guide/configuration/adapter-settings.html
Optionally set to your closest WiFi channel to pick the best value for "Network channel" below.
${generateCheckbox}
https://www.zigbee2mqtt.io/guide/configuration/zigbee-network.html
Optional. Set only if using authentication. Optional. Set only if using authentication.
https://www.zigbee2mqtt.io/guide/configuration/mqtt.html
https://www.zigbee2mqtt.io/guide/configuration/frontend.html
https://www.zigbee2mqtt.io/guide/configuration/homeassistant.html
https://www.zigbee2mqtt.io/guide/configuration/logging.html
`; /* v8 ignore stop */ } function generateHtmlError(errors: string): string { return ` Zigbee2MQTT Onboarding

Zigbee2MQTT configuration is not valid

Found the following errors:

${errors}

If you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration

`; } function getServerUrl(): URL { return new URL(process.env.Z2M_ONBOARD_URL ?? "http://0.0.0.0:8080"); } async function startOnboardingServer(): Promise { const currentSettings = settings.get(); const serverUrl = getServerUrl(); let server: ReturnType | undefined; let failed = false; const success = await new Promise((resolve) => { server = createServer(async (req, res) => { if (req.method === "POST") { if (failed) { res.end(() => { resolve(false); }); } else { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { const result = parse(body) as unknown as OnboardSettings; const frontendEnabled = result.frontend_enabled === "on"; const updatedSettings: RecursivePartial = { mqtt: { base_topic: result.mqtt_base_topic, server: result.mqtt_server, user: result.mqtt_user || undefined, // empty string => removed password: result.mqtt_password || undefined, // empty string => removed }, serial: { port: result.serial_port, adapter: result.serial_adapter, baudrate: result.serial_baudrate ? Number.parseInt(result.serial_baudrate, 10) : undefined, rtscts: result.serial_rtscts === "on", }, advanced: { log_level: result.log_level, channel: result.network_channel ? Number.parseInt(result.network_channel, 10) : undefined, network_key: result.network_key ? result.network_key === "GENERATE" ? result.network_key : result.network_key.split(",").map((v) => Number.parseInt(v, 10)) : undefined, pan_id: result.network_pan_id ? result.network_pan_id === "GENERATE" ? result.network_pan_id : Number.parseInt(result.network_pan_id, 10) : undefined, ext_pan_id: result.network_ext_pan_id ? result.network_ext_pan_id === "GENERATE" ? result.network_ext_pan_id : result.network_ext_pan_id.split(",").map((v) => Number.parseInt(v, 10)) : undefined, }, frontend: { enabled: frontendEnabled, port: result.frontend_port ? Number.parseInt(result.frontend_port, 10) : undefined, }, homeassistant: { enabled: result.homeassistant_enabled === "on", }, }; try { settings.apply(updatedSettings); // to redirect, make sure frontend "will be" enabled, and host isn't socket const redirect = !process.env.Z2M_ONBOARD_NO_REDIRECT && frontendEnabled && (!currentSettings.frontend?.host || !currentSettings.frontend.host.startsWith("/")); const protocol = currentSettings.frontend?.ssl_cert && currentSettings.frontend.ssl_key ? "https" : "http"; res.setHeader("Content-Type", "text/html"); res.writeHead(200); res.end( generateHtmlDone( redirect ? /* v8 ignore next */ `${protocol}://${currentSettings.frontend?.host ?? "localhost"}:${currentSettings.frontend?.port ?? "8080"}${currentSettings.frontend?.base_url ?? "/"}` : undefined, ), () => { resolve(true); }, ); } catch (error) { console.error(`Failed to apply configuration: ${(error as Error).message}`); failed = true; if (process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) { res.end(() => { resolve(false); }); } else { res.setHeader("Content-Type", "text/html"); res.writeHead(406); res.end(generateHtmlError(`

${escapeHtml((error as Error).message)}

`)); } } }); } } else { res.setHeader("Content-Type", "text/html"); res.writeHead(200); res.end(generateHtmlForm(currentSettings, await findAllDevices())); } }); server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => { console.log(`Onboarding page is available at ${serverUrl.href}`); }); }); await new Promise((resolve) => server?.close(resolve)); return success; } async function startFailureServer(errors: string): Promise { const serverUrl = getServerUrl(); let server: ReturnType | undefined; await new Promise((resolve) => { server = createServer((req, res) => { if (req.method === "POST") { res.end(() => { resolve(); }); } else { res.setHeader("Content-Type", "text/html"); res.writeHead(406); res.end(generateHtmlError(errors)); } }); server.listen(Number.parseInt(serverUrl.port), serverUrl.hostname, () => { console.error(`Failure page is available at ${serverUrl.href}`); }); }); await new Promise((resolve) => server?.close(resolve)); } async function onSettingsErrors(errors: string[]): Promise { let pErrors = ""; console.error("\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); console.error(" READ THIS CAREFULLY\n"); console.error("Refusing to start because configuration is not valid, found the following errors:"); for (const error of errors) { console.error(`- ${error}`); pErrors += `

- ${escapeHtml(error)}

`; } console.error("\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/guide/configuration"); console.error("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n"); if (!process.env.Z2M_ONBOARD_NO_SERVER && !process.env.Z2M_ONBOARD_NO_FAILURE_PAGE) { await startFailureServer(pErrors); } } export async function onboard(): Promise { if (!existsSync(data.getPath())) { mkdirSync(data.getPath(), {recursive: true}); } const confExists = existsSync(data.joinPath("configuration.yaml")); if (confExists) { // initial caching, ensure file is valid yaml first try { settings.getPersistedSettings(); } catch (error) { await onSettingsErrors( error instanceof YAMLFileException ? [`Your configuration file: '${error.file}' is invalid (use https://jsonformatter.org/yaml-validator to find and fix the issue)`] : [`${error}`], ); return false; } // migrate first const {migrateIfNecessary} = await import("./settingsMigration.js"); migrateIfNecessary(); // make sure existing settings are valid before applying envs const errors = settings.validateNonRequired(); if (errors.length > 0) { await onSettingsErrors(errors); return false; } // trigger initial writing of `ZIGBEE2MQTT_CONFIG_*` ENVs settings.write(); } else { settings.writeMinimalDefaults(); } // use `configuration.yaml` file to detect "brand new install" // env allows to re-run onboard even with existing install if (!process.env.Z2M_ONBOARD_NO_SERVER && (process.env.Z2M_ONBOARD_FORCE_RUN || !confExists || settings.get().onboarding)) { settings.setOnboarding(true); const success = await startOnboardingServer(); if (!success) { return false; } } settings.reRead(); const errors = settings.validate(); if (errors.length > 0) { await onSettingsErrors(errors); return false; } return true; }