const axios = require("axios"); const fs = require("fs"); const https = require("https"); const path = require("path"); const { OmadaClient } = require("./omadaClient"); // Omada uses self-signed certs on the controller const httpsAgent = new https.Agent({ rejectUnauthorized: false }); let controllerId = null; let csrfToken = null; let sessionCookies = ""; const client = axios.create({ httpsAgent, timeout: 20000, }); let v2Client = null; function isOpenApiConfigured() { return Boolean( process.env.OMADA_URL && process.env.OMADA_OPENAPI_OMADAC_ID && process.env.OMADA_OPENAPI_CLIENT_ID && process.env.OMADA_OPENAPI_CLIENT_SECRET, ); } function getV2Client() { if (!v2Client) { v2Client = new OmadaClient({ controllerUrl: process.env.OMADA_URL, omadacId: process.env.OMADA_OPENAPI_OMADAC_ID, clientId: process.env.OMADA_OPENAPI_CLIENT_ID, clientSecret: process.env.OMADA_OPENAPI_CLIENT_SECRET, controllerUsername: process.env.OMADA_USER, controllerPassword: process.env.OMADA_PASS, rejectUnauthorized: process.env.OMADA_REJECT_UNAUTHORIZED === "true", }); } return v2Client; } function unwrapRows(result) { const rows = result?.result?.data || result?.result || []; return Array.isArray(rows) ? rows : []; } function unwrapControllerRows(result) { const rows = result?.result?.data || result?.result || []; return Array.isArray(rows) ? rows : []; } function omadaResultId(result) { if (!result) return null; if (typeof result === "string") return result; return result.id || result.fileId || result.pageId || result.importedPortalPageId || null; } function findPortalById(portals, portalId) { return portals.find((p) => (p.id ?? p.portalId) === portalId) || null; } function toOmadaMbps(value, unit) { const n = Number(value || 0); if (!Number.isFinite(n) || n <= 0) return 0; return Number(unit || 2) === 1 ? n / 1024 : n; } function durationToVoucher(durationSeconds) { const seconds = Number(durationSeconds || 0); if (seconds > 0 && seconds % 86400 === 0) { return { duration: seconds / 86400, durationUnit: 3 }; } if (seconds > 0 && seconds % 3600 === 0) { return { duration: seconds / 3600, durationUnit: 2 }; } return { duration: Math.max(1, Math.ceil(seconds / 60)), durationUnit: 1 }; } function stripNullValues(value) { return Object.fromEntries( Object.entries(value).filter(([, entry]) => entry !== null && entry !== undefined), ); } // ── Axios Logging Interceptors — commented out to reduce noise ──────────────── client.interceptors.request.use( (config) => config, (error) => { console.error("[Omada] Request Setup Error:", error.message); return Promise.reject(error); }, ); client.interceptors.response.use( (response) => response, (error) => { // Log only the essential error details if (error.response) { console.error( `[Omada] ${error.config.method.toUpperCase()} ${error.config.url} → ${error.response.status}`, JSON.stringify(error.response.data), ); } else { console.error("[Omada] Network error:", error.message); } return Promise.reject(error); }, ); // ── Init ────────────────────────────────────────────────────────────────────── async function initOmada() { if (isOpenApiConfigured()) { const openApi = getV2Client(); await openApi.authorize(); console.log(`[Omada] Ready via OpenAPI. omadacId=${process.env.OMADA_OPENAPI_OMADAC_ID}`); return; } // 1. Fetch controller ID const infoRes = await client.get(`${process.env.OMADA_URL}/api/info`); controllerId = infoRes.data.result.omadacId; // 2. Login await omadaLogin(); // 3. 25-min keepalive setInterval(keepAlive, 25 * 60 * 1000); console.log(`[Omada] Ready. controllerId=${controllerId}`); } async function omadaLogin() { const res = await client.post( `${process.env.OMADA_URL}/${controllerId}/api/v2/login`, { username: process.env.OMADA_USER, password: process.env.OMADA_PASS }, ); csrfToken = res.data.result.token; const setCookie = res.headers["set-cookie"] || []; sessionCookies = setCookie.map((c) => c.split(";")[0]).join("; "); } async function keepAlive() { try { await omadaRequest("GET", "/current/users"); } catch (err) { console.error("[Omada] Keepalive failed:", err.message); } } // ── Core request helper ─────────────────────────────────────────────────────── async function omadaRequest(method, path, data = null, retried = false) { if (!controllerId) await initOmada(); if (!csrfToken) await omadaLogin(); try { const res = await client({ method, url: `${process.env.OMADA_URL}/${controllerId}/api/v2${path}`, headers: { "Csrf-Token": csrfToken, Cookie: sessionCookies, "Content-Type": "application/json", }, data: data ?? undefined, }); // Omada returns errorCode 0 on success; -1000 = session expired if (res.data.errorCode === -1000 && !retried) { await omadaLogin(); return omadaRequest(method, path, data, true); } // Verbose per-request log — uncomment to debug Omada responses // console.log(`[omada] ${method} ${path} → errorCode=${res.data.errorCode}`); if (res.data.errorCode !== 0) { const err = new Error( res.data.msg || `Omada error ${res.data.errorCode}`, ); err.errorCode = res.data.errorCode; err.result = res.data.result; throw err; } return res.data; } catch (err) { if (err.response?.status === 401 && !retried) { await omadaLogin(); return omadaRequest(method, path, data, true); } throw err; } } async function authenticatePortalVoucher(payload) { const url = `${String(process.env.OMADA_URL || "").replace(/\/+$/, "")}/portal/auth`; const startedAt = Date.now(); const safePayload = { ...payload, voucherCode: payload?.voucherCode ? "***" : undefined, }; try { const res = await client.post(url, payload, { headers: { "Content-Type": "application/json" }, }); console.log( `[Omada Portal HTTP] POST /portal/auth -> ${res.status} errorCode=${res.data?.errorCode} msg=${res.data?.msg || ""} ms=${Date.now() - startedAt}`, safePayload, ); if (res.data?.errorCode !== 0) { const err = new Error(res.data?.msg || `Omada portal auth error ${res.data?.errorCode}`); err.errorCode = res.data?.errorCode; err.result = res.data?.result; err.responseData = res.data; throw err; } return res.data; } catch (err) { if (err.response) { console.error( `[Omada Portal HTTP] POST /portal/auth -> ${err.response.status} errorCode=${err.response.data?.errorCode} msg=${err.response.data?.msg || ""} ms=${Date.now() - startedAt}`, safePayload, ); err.errorCode = err.response.data?.errorCode; err.result = err.response.data?.result; err.responseData = err.response.data; } else { console.error(`[Omada Portal HTTP] POST /portal/auth network error: ${err.message}`, safePayload); } throw err; } } // ── Sites ───────────────────────────────────────────────────────────────────── async function createSite(name, scenario = "Hotel") { if (isOpenApiConfigured()) { const res = await getV2Client().openApi("POST", "/sites", { body: { name, scenario, region: process.env.OMADA_SITE_REGION || "Tanzania", timeZone: process.env.OMADA_SITE_TIMEZONE || "Africa/Dar_es_Salaam", deviceAccountSetting: { username: process.env.OMADA_DEVICE_DEFAULT_USER || "admin", password: process.env.OMADA_DEVICE_DEFAULT_PASS || "Admin@12345", }, tagIds: [], supportES: false, supportL2: true, }, }); return res.result?.siteId ?? res.result?.id ?? res.result; } const res = await omadaRequest("POST", "/sites", { name, scenario, region: "Tanzania", timeZone: "Africa/Dar_es_Salaam", }); // Omada returns either a plain string or {siteId: "..."} depending on version return res.result?.siteId ?? res.result; } async function getSites() { if (isOpenApiConfigured()) { const res = await getV2Client().openApi("GET", "/sites", { query: { page: 1, pageSize: 200 }, }); return unwrapRows(res); } const res = await omadaRequest( "GET", "/sites?currentPageSize=200¤tPage=1", ); return res.result?.data || []; } async function getSiteById(siteId) { const sites = await getSites(); return sites.find((s) => (s.id ?? s.siteId) === siteId) || null; } // ── Portal ──────────────────────────────────────────────────────────────────── async function createPortal(siteId, portalName, ssidList = []) { if (isOpenApiConfigured()) { const ssids = ssidList.filter(Boolean); const res = await getV2Client().openApi("POST", `/sites/${siteId}/portal`, { body: { name: portalName || "Voucher Portal", enable: true, ssidList: ssids, networkList: [], authType: 11, authTimeout: { customTimeout: Number(process.env.OMADA_PORTAL_AUTH_TIMEOUT || 1440), customTimeoutUnit: 1, }, httpsRedirectEnable: true, landingPage: 1, hotspot: { enabledTypes: [3] }, pageType: 2, portalCustomize: { defaultLanguage: 1, logoDisplay: false, welcomeEnable: false, termsOfServiceEnable: false, copyrightEnable: false, redirectionCountDownEnable: false, buttonText: "Log In", }, }, }); return res.result?.portalId ?? res.result?.id ?? res.result; } const scheme = process.env.PORTAL_SCHEME || "http"; let serverUrl = `${process.env.PORTAL_HOST}/portal/${siteId}`; if (!serverUrl.endsWith("?")) serverUrl += ""; //the server url should not have http:// or https:// serverUrl = serverUrl.replace(/^https?:\/\//, ""); const res = await omadaRequest("POST", `/sites/${siteId}/setting/portals`, { name: portalName, enable: true, httpsRedirectEnable: true, landingPage: 1, authType: 4, externalPortal: { hostType: 2, serverUrlScheme: scheme, serverUrl, logout: false, }, ssidList, networkList: [], pageType: 1, }); return res.result; // portalId string } // ── WLANs & SSIDs ──────────────────────────────────────────────────────────── async function getWlans(siteId) { if (isOpenApiConfigured()) { const res = await getV2Client().openApi("GET", `/sites/${siteId}/wireless-network/wlans`, { query: { page: 1, pageSize: 50 }, }); return unwrapRows(res); } const res = await omadaRequest( "GET", `/sites/${siteId}/setting/wlans?currentPage=1¤tPageSize=10`, ); const data = res.result?.data || res.result || []; // console.log(`[omada] getWlans site=${siteId} count=${Array.isArray(data) ? data.length : 0}`); return Array.isArray(data) ? data : []; } async function getOrCreateWlan(siteId) { const wlans = await getWlans(siteId); if (wlans.length > 0) { const id = wlans[0].id ?? wlans[0].wlanId ?? wlans[0].groupId; return id; } if (isOpenApiConfigured()) { const res = await getV2Client().openApi("POST", `/sites/${siteId}/wireless-network/wlans`, { body: { name: "Default" }, }); return res.result?.wlanId ?? res.result?.id ?? res.result; } // No WLAN group exists — create a default one const res = await omadaRequest("POST", `/sites/${siteId}/setting/wlans`, { name: "Default", }); const id = res.result?.id ?? res.result; return id; } async function getSSIDs(siteId, wlanId) { if (isOpenApiConfigured()) { const res = await getV2Client().openApi("GET", `/sites/${siteId}/wireless-network/wlans/${wlanId}/ssids`, { query: { page: 1, pageSize: 100 }, }); return unwrapRows(res); } const res = await omadaRequest( "GET", `/sites/${siteId}/setting/wlans/${wlanId}/ssids?currentPage=1¤tPageSize=10`, ); const data = res.result?.data || res.result || []; return Array.isArray(data) ? data : []; } async function getRateLimits(siteId) { const paths = [ `/sites/${siteId}/setting/profiles/rateLimits`, // confirmed working path `/sites/${siteId}/setting/profiles/ratelimit`, `/sites/${siteId}/setting/profiles/bandwidth`, `/sites/${siteId}/setting/profiles/transmission`, ]; for (const path of paths) { try { const res = await omadaRequest( "GET", `${path}?currentPage=1¤tPageSize=50`, ); // result may be a direct array or a paginated object with a data property const data = Array.isArray(res.result) ? res.result : res.result?.data || []; if (data.length) return data; } catch { /* try next */ } } return []; } async function resolveRateLimitId(siteId) { // 1. Try dedicated profiles endpoints const profiles = await getRateLimits(siteId); if (profiles.length) return profiles[0].id ?? profiles[0].profileId ?? profiles[0].rateLimitId; // 2. Scrape from any existing SSID across all wlans try { const wlans = await getWlans(siteId); for (const w of wlans) { const wid = w.id ?? w.wlanId ?? w.groupId; const ssids = await getSSIDs(siteId, wid); for (const s of ssids) { const id = s.rateLimit?.rateLimitId ?? s.ssidRateLimit?.rateLimitId; if (id) return id; } // 3. Try single wlan GET — may include default profile references try { const res = await omadaRequest( "GET", `/sites/${siteId}/setting/wlans/${wid}`, ); const wlan = res.result; // console.log(`[omada] resolveRateLimitId wlan detail=`, JSON.stringify(wlan).slice(0,200)); const id = wlan?.rateLimit?.rateLimitId ?? wlan?.defaultRateLimitId ?? wlan?.rateLimitId; if (id) return id; } catch { /* ignore */ } } } catch { /* ignore */ } // 4. Try wireless settings — may reference default profiles try { const res = await omadaRequest("GET", `/sites/${siteId}/setting/wireless`); // console.log(`[omada] resolveRateLimitId wireless=`, JSON.stringify(res.result).slice(0,200)); const id = res.result?.defaultRateLimitId ?? res.result?.rateLimit?.rateLimitId; if (id) return id; } catch { /* ignore */ } console.warn( `[omada] resolveRateLimitId site=${siteId} — could not find any rateLimitId`, ); return null; } async function createSSID(siteId, wlanId, { ssid, password }) { if (!wlanId) throw new Error(`Cannot create SSID — wlanId is ${wlanId}`); // Reuse existing SSID if one already exists in this WLAN const existing = await getSSIDs(siteId, wlanId); if (existing.length > 0) { const id = existing[0].id ?? existing[0].ssidId; // reusing existing SSID — no log needed return id; } if (isOpenApiConfigured()) { const body = { name: ssid, deviceType: 1, band: 1, guestNetEnable: false, security: password ? 3 : 0, broadcast: true, vlanEnable: false, mloEnable: false, pmfMode: 3, enable11r: false, hidePwd: false, greEnable: false, vlanSetting: { mode: 0 }, prohibitWifiShare: false, }; if (password) { body.pskSetting = { securityKey: password, versionPsk: 2, encryptionPsk: 3, gikRekeyPskEnable: false, }; } else { body.oweEnable = false; } const res = await getV2Client().openApi( "POST", `/sites/${siteId}/wireless-network/wlans/${wlanId}/ssids`, { body }, ); return res.result?.ssidId ?? res.result?.id ?? res.result; } const rateLimitId = await resolveRateLimitId(siteId); const security = password ? { mode: 3, encryptAlg: 2, password } : 0; const payload = { name: ssid, band: 1, guestNetEnable: false, security, broadcast: true, vlanSetting: { mode: 0 }, ...(rateLimitId ? { rateLimit: { rateLimitId }, ssidRateLimit: { rateLimitId } } : {}), wlanScheduleEnable: false, macFilterEnable: false, wlanId: "", enable11r: false, oweEnable: false, deviceType: 1, mloEnable: false, rateAndBeaconCtrl: { rate2gCtrlEnable: false, rate5gCtrlEnable: false, rate6gCtrlEnable: false, }, multiCastSetting: { multiCastEnable: true, arpCastEnable: true, filterEnable: false, ipv6CastEnable: true, channelUtil: 100, }, }; const res = await omadaRequest( "POST", `/sites/${siteId}/setting/wlans/${wlanId}/ssids`, payload, ); // Omada returns {ssidId: "..."} or {id: "..."} depending on version const id = res.result?.ssidId ?? res.result?.id ?? res.result; return id; } async function getPortals(siteId) { if (isOpenApiConfigured()) { const res = await getV2Client().openApi("GET", `/sites/${siteId}/portal`, { query: { page: 1, pageSize: 50 }, }); return unwrapRows(res); } const res = await omadaRequest( "GET", `/sites/${siteId}/setting/portals?currentPage=1¤tPageSize=10`, ); return Array.isArray(res.result) ? res.result : res.result?.data || []; } async function getControllerPortals(siteId) { const pathPart = `/sites/${siteId}/setting/portals?currentPage=1¤tPageSize=50`; if (isOpenApiConfigured()) { const res = await getV2Client().controllerRequest("GET", pathPart); return unwrapControllerRows(res); } const res = await omadaRequest("GET", pathPart); return unwrapControllerRows(res); } async function updatePortalSSID(siteId, portalId, ssidList) { // Omada PATCH replaces the whole resource — fetch current state first const portals = await getPortals(siteId); const current = portals.find((p) => (p.id ?? p.portalId) === portalId); if (!current) throw new Error(`Portal ${portalId} not found in site ${siteId}`); console.log("[omada] current portal before PATCH:", current); if (isOpenApiConfigured()) { return getV2Client().controllerRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, { ...current, id: portalId, enable: true, authType: 11, hotspot: { ...(current.hotspot || {}), enabledTypes: [3] }, pageType: current.pageType || 2, ssidList, networkList: current.networkList || [], }); } return omadaRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, { ...current, pageType: 1, ssidList, }); } async function updatePortalUrl(siteId, portalId) { const scheme = process.env.PORTAL_SCHEME || "http"; let serverUrl = `${process.env.PORTAL_HOST}/portal/${siteId}`; // Omada PATCH replaces the whole resource — fetch current state first const portals = await getPortals(siteId); const current = portals.find((p) => (p.id ?? p.portalId) === portalId); if (!current) throw new Error(`Portal ${portalId} not found in site ${siteId}`); console.log("[omada] current portal before PATCH:", current); if (isOpenApiConfigured()) { return getV2Client().controllerRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, { ...current, id: portalId, enable: true, pageType: current.pageType || 2, httpsRedirectEnable: true, landingPage: 1, authType: 11, hotspot: { ...(current.hotspot || {}), enabledTypes: [3] }, portalCustomize: current.portalCustomize || { defaultLanguage: 1, logoDisplay: false, welcomeEnable: false, termsOfServiceEnable: false, copyrightEnable: false, redirectionCountDownEnable: false, buttonText: "Log In", }, ssidList: current.ssidList || [], networkList: current.networkList || [], }); } serverUrl = serverUrl.replace(/^https?:\/\//, ""); return omadaRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, { ...current, enable: true, pageType: 1, httpsRedirectEnable: true, landingPage: 1, portalCustomize: current.portalCustomize, authType: 4, externalPortal: { hostType: 2, serverUrlScheme: scheme, serverUrl, logout: false, }, }); } // ── Devices ─────────────────────────────────────────────────────────────────── async function getSiteDevices(siteId) { if (isOpenApiConfigured()) { const client = getV2Client(); try { const res = await client.controllerRequest( "GET", `/sites/${siteId}/grid/devices?currentPage=1¤tPageSize=100`, ); const rows = unwrapControllerRows(res); if (rows.length > 0) return rows; } catch (err) { console.warn(`[Omada] controller grid devices failed for site=${siteId}:`, err.message); } const candidates = [ `/sites/${siteId}/devices`, `/sites/${siteId}/devices/adopted`, `/sites/${siteId}/devices/known-devices`, ]; let lastError = null; for (const candidate of candidates) { try { const res = await client.openApi("GET", candidate, { query: { page: 1, pageSize: 100 }, }); return unwrapRows(res); } catch (err) { lastError = err; } } throw lastError; } const res = await omadaRequest( "GET", `/sites/${siteId}/grid/devices?currentPage=1¤tPageSize=50`, ); return res.result?.data || []; } async function getAllAdoptedDevices() { if (isOpenApiConfigured()) { const res = await getV2Client().openApi("GET", "/devices/known-devices", { query: { page: 1, pageSize: 200 }, }); return unwrapRows(res); } const res = await omadaRequest( "GET", "/grid/devices/adopted?currentPage=1¤tPageSize=200", ); return res.result?.data || []; } async function adoptDevice(siteId, mac, username, password) { if (isOpenApiConfigured()) { return getV2Client().openApi("POST", `/sites/${siteId}/devices/${mac}/start-adopt`, { body: { username, password }, }); } return omadaRequest("POST", `/sites/${siteId}/cmd/devices/adopt`, { mac, username, password, }); } async function rebootDevice(siteId, mac) { if (isOpenApiConfigured()) { return getV2Client().controllerRequest("POST", `/sites/${siteId}/cmd/devices/${mac}/reboot`, { mac }); } return omadaRequest("POST", `/sites/${siteId}/cmd/devices/${mac}/reboot`, { mac, }); } async function forgetDevice(siteId, mac) { if (isOpenApiConfigured()) { return getV2Client().controllerRequest("POST", `/sites/${siteId}/cmd/devices/${mac}/forget`, { mac }); } return omadaRequest("POST", `/sites/${siteId}/cmd/devices/${mac}/forget`, { mac, }); } async function updateSSID(siteId, wlanId, ssidId, { ssid, password }) { const existing = await getSSIDs(siteId, wlanId); const current = existing.find((s) => (s.id ?? s.ssidId) === ssidId); if (!current) throw new Error(`SSID ${ssidId} not found in wlan ${wlanId}`); console.log("[omada] current SSID before update:", current); if (isOpenApiConfigured()) { const body = { name: ssid, band: Number(current.band || 1), guestNetEnable: current.guestNetEnable ?? false, security: password ? 3 : 0, broadcast: current.broadcast ?? true, vlanEnable: current.vlanEnable ?? false, mloEnable: false, pmfMode: current.pmfMode ?? 3, enable11r: current.enable11r ?? false, hidePwd: current.hidePwd ?? false, greEnable: current.greEnable ?? false, vlanSetting: current.vlanSetting || { mode: 0 }, prohibitWifiShare: current.prohibitWifiShare ?? false, }; if (password) { body.pskSetting = { securityKey: password, versionPsk: 2, encryptionPsk: 3, gikRekeyPskEnable: false, }; } else { body.oweEnable = false; } return getV2Client().openApi( "PATCH", `/sites/${siteId}/wireless-network/wlans/${wlanId}/ssids/${ssidId}/update-basic-config`, { body }, ); } const body = { name: ssid, band: current.band, guestNetEnable: current.guestNetEnable ?? false, security: password ? 3 : 0, broadcast: current.broadcast ?? true, vlanSetting: { mode: 0 }, ...(password ? { pskSetting: { securityKey: password, encryptionPsk: 3, versionPsk: 2, gikRekeyPskEnable: false, }, } : {}), rateLimit: { rateLimitId: current.rateLimit?.rateLimitId ?? "" }, ssidRateLimit: { rateLimitId: current.ssidRateLimit?.rateLimitId ?? "" }, wlanScheduleEnable: false, macFilterEnable: false, wlanId: "", enable11r: false, pmfMode: current.pmfMode ?? 3, multiCastSetting: { multiCastEnable: true, arpCastEnable: true, filterEnable: false, ipv6CastEnable: true, channelUtil: 100, }, mloEnable: false, rateAndBeaconCtrl: { rate2gCtrlEnable: false, rate5gCtrlEnable: false, rate6gCtrlEnable: false, }, }; return omadaRequest( "PATCH", `/sites/${siteId}/setting/wlans/${wlanId}/ssids/${ssidId}`, body, ); } async function deleteSSID(siteId, wlanId, ssidId) { if (isOpenApiConfigured()) { return getV2Client().openApi( "DELETE", `/sites/${siteId}/wireless-network/wlans/${wlanId}/ssids/${ssidId}`, ); } return omadaRequest( "DELETE", `/sites/${siteId}/setting/wlans/${wlanId}/ssids/${ssidId}`, ); } // ── Clients (guests) ────────────────────────────────────────────────────────── async function getClient(siteId, mac) { if (isOpenApiConfigured()) { const res = await getV2Client().controllerRequest("GET", `/sites/${siteId}/clients/${mac}`); return res.result; } const res = await omadaRequest("GET", `/sites/${siteId}/clients/${mac}`); return res.result; } async function getSiteClients(siteId) { if (isOpenApiConfigured()) { const res = await getV2Client().controllerRequest( "GET", `/sites/${siteId}/clients?currentPage=1¤tPageSize=200&filters.active=true`, ); return res.result?.data || []; } const res = await omadaRequest( "GET", `/sites/${siteId}/clients?currentPage=1¤tPageSize=200&filters.active=true`, ); return res.result?.data || []; } async function getHotspotClients(siteId, { page = 1, size = 200 } = {}) { if (!isOpenApiConfigured()) { throw new Error("Omada OpenAPI config is required for hotspot clients"); } const query = new URLSearchParams({ currentPage: String(page), currentPageSize: String(size), _: String(Date.now()), }); const res = await getV2Client().controllerRequest( "GET", `/hotspot/sites/${siteId}/clients?${query}`, ); return res.result?.data || []; } async function authorizeClient(siteId, mac) { if (isOpenApiConfigured()) { return getV2Client().controllerRequest("POST", `/sites/${siteId}/cmd/clients/${mac}/auth`, {}); } return omadaRequest("POST", `/sites/${siteId}/cmd/clients/${mac}/auth`, {}); } async function deauthorizeClient(siteId, mac) { if (isOpenApiConfigured()) { return getV2Client().controllerRequest("POST", `/sites/${siteId}/cmd/clients/${mac}/unauth`, {}); } return omadaRequest( "POST", `/sites/${siteId}/cmd/clients/${mac}/unauth`, {}, ); } async function applyRateLimit( siteId, mac, { downLimit, downUnit, upLimit, upUnit, name = "Guest" }, ) { if (isOpenApiConfigured()) { return getV2Client().controllerRequest("PATCH", `/sites/${siteId}/clients/${mac}`, { name, rateLimit: { enable: true, downEnable: true, downLimit, downUnit, upEnable: true, upLimit, upUnit, }, clientLockToApSetting: { enable: false }, ipSetting: { useFixedAddr: false }, }); } return omadaRequest("PATCH", `/sites/${siteId}/clients/${mac}`, { name, rateLimit: { enable: true, downEnable: true, downLimit, downUnit, upEnable: true, upLimit, upUnit, }, clientLockToApSetting: { enable: false }, ipSetting: { useFixedAddr: false }, }); } // ── Alerts ──────────────────────────────────────────────────────────────────── async function getSiteAlerts(siteId) { const now = Date.now(); const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; if (isOpenApiConfigured()) { const res = await getV2Client().controllerRequest( "GET", `/sites/${siteId}/logs/alerts` + `?filters.timeStart=${thirtyDaysAgo}&filters.timeEnd=${now}` + `&filters.resolved=false¤tPage=1¤tPageSize=50`, ); return res.result?.data || []; } const res = await omadaRequest( "GET", `/sites/${siteId}/logs/alerts` + `?filters.timeStart=${thirtyDaysAgo}&filters.timeEnd=${now}` + `&filters.resolved=false¤tPage=1¤tPageSize=50`, ); return res.result?.data || []; } function voucherGroupBodyFromPlan(plan, overrides = {}) { const duration = durationToVoucher(plan.duration_seconds); const amount = Number(overrides.amount || process.env.OMADA_VOUCHER_BATCH_SIZE || 100); const durationMinutes = duration.durationUnit === 1 ? duration.duration : duration.durationUnit === 2 ? duration.duration * 60 : duration.duration * 1440; return stripNullValues({ codeLength: Number(overrides.codeLength || process.env.OMADA_VOUCHER_CODE_LENGTH || 6), codeForm: [0, 1], amount, name: overrides.name || `${plan.name} (${plan.id})`, type: 0, logout: true, downLimitEnable: false, downLimit: null, downLimitUnit: null, upLimitEnable: false, upLimit: null, upLimitUnit: null, trafficLimitEnable: false, trafficLimit: null, voucherValidityEnable: false, upTimeLimitEnable: false, durationType: 1, duration: durationMinutes, description: overrides.description || `WiFiBiz plan ${plan.id}`, maxUsers: 1, applyToAllPortals: true, printComments: null, }); } // Omada rejects voucher group names longer than 32 UTF-8 characters with // errorCode -1001 ("Parameter [name] should be 1~32 UTF-8 characters"). Count by // code points (Array.from) so multi-byte characters aren't over-counted. const OMADA_VOUCHER_NAME_MAX = 32; function clampVoucherGroupName(name) { const chars = Array.from(String(name || "")); if (chars.length <= OMADA_VOUCHER_NAME_MAX) return chars.join(""); return chars.slice(0, OMADA_VOUCHER_NAME_MAX).join(""); } function defaultVoucherGroupName(plan) { return clampVoucherGroupName(`${plan.name} (${plan.id})`); } function correctedVoucherGroupName(plan, existingGroups = []) { const base = `${defaultVoucherGroupName(plan)} - single-user`; const existingNames = new Set(existingGroups.map((group) => String(group?.name || ""))); const clampedBase = clampVoucherGroupName(base); if (!existingNames.has(clampedBase)) return clampedBase; for (let i = 2; i <= 50; i += 1) { const candidate = clampVoucherGroupName(`${base} ${i}`); if (!existingNames.has(candidate)) return candidate; } return clampVoucherGroupName(`${base} ${Date.now()}`); } function expectedVoucherGroupSettings(plan) { const body = voucherGroupBodyFromPlan(plan, { amount: 1 }); return { type: body.type, maxUsers: body.maxUsers, durationType: body.durationType, duration: body.duration, }; } function voucherGroupIdFromResult(res) { return res.result?.id || res.result?.voucherGroupId || res.result?.groupId || res.result; } function voucherRowsFromResult(res) { const rows = res.result?.data || res.result || res.data || []; return Array.isArray(rows) ? rows : []; } function voucherGroupId(group) { return group?.id || group?.voucherGroupId || group?.groupId || null; } function isVoucherGroupUnused(group) { if (!group) return false; const hasUsageCounters = ["usedCount", "inUseCount", "expiredCount", "unusedCount", "totalCount", "totalRows"] .some((key) => Object.prototype.hasOwnProperty.call(group, key)); if (!hasUsageCounters) return false; const used = Number(group.usedCount || 0); const inUse = Number(group.inUseCount || 0); const expired = Number(group.expiredCount || 0); const unused = Number(group.unusedCount || 0); const total = Number(group.totalCount || group.totalRows || 0); if (used > 0 || inUse > 0 || expired > 0) return false; return total === 0 || unused === 0 || unused === total; } function isTruthyOmadaFlag(value) { return value === true || value === 1 || value === "1" || value === "true"; } function voucherGroupHasNoSpeedLimit(group) { if (!group) return false; return !isTruthyOmadaFlag(group.downLimitEnable) && !isTruthyOmadaFlag(group.upLimitEnable); } function hasOwnValue(object, key) { return Object.prototype.hasOwnProperty.call(object || {}, key) && object[key] !== null && object[key] !== undefined && object[key] !== ""; } function voucherGroupCompatibility(group, plan) { const expected = expectedVoucherGroupSettings(plan); const reasons = []; for (const key of ["type", "maxUsers", "durationType", "duration"]) { if (!hasOwnValue(group, key)) { reasons.push(`missing_${key}`); continue; } if (Number(group[key]) !== Number(expected[key])) { reasons.push(`${key}_expected_${expected[key]}_got_${group[key]}`); } } if (!voucherGroupHasNoSpeedLimit(group)) { reasons.push("speed_limit_enabled"); } return { compatible: reasons.length === 0, reasons, expected, }; } async function getVoucherGroup(siteId, groupId) { const { groups } = await listVoucherGroups(siteId, { page: 1, size: 200 }); return groups.find((item) => String(voucherGroupId(item)) === String(groupId)) || null; } function findCompatibleVoucherGroup(groups, plan) { const base = defaultVoucherGroupName(plan); const candidates = groups.filter((group) => { const name = String(group?.name || ""); return name === base || name.startsWith(`${base} - single-user`); }); return candidates.find((group) => voucherGroupCompatibility(group, plan).compatible) || null; } async function createVoucherGroup(siteId, plan, overrides = {}) { if (!isOpenApiConfigured()) { throw new Error("Omada OpenAPI config is required for voucher groups"); } const client = getV2Client(); const controllerBody = voucherGroupBodyFromPlan(plan, overrides); const res = await client.controllerRequest( "POST", `/hotspot/sites/${siteId}/voucherGroups`, controllerBody, ); return { voucherGroupId: voucherGroupIdFromResult(res), request: controllerBody, response: res, path: `/hotspot/sites/${siteId}/voucherGroups`, }; } async function deleteVoucherGroup(siteId, groupId) { if (!isOpenApiConfigured()) { throw new Error("Omada OpenAPI config is required for voucher groups"); } if (!siteId || !groupId) { throw new Error("siteId and groupId are required to delete an Omada voucher group"); } const client = getV2Client(); return client.controllerRequest("DELETE", `/hotspot/sites/${siteId}/voucherGroups/${groupId}`); } async function deleteVoucher(siteId, voucherId) { if (!isOpenApiConfigured()) { throw new Error("Omada OpenAPI config is required for vouchers"); } if (!siteId || !voucherId) { throw new Error("siteId and voucherId are required to delete an Omada voucher"); } const client = getV2Client(); return client.controllerRequest("DELETE", `/hotspot/sites/${siteId}/vouchers/${voucherId}`); } async function extendVoucherClient(siteId, clientId, periodMs) { if (!isOpenApiConfigured()) { throw new Error("Omada OpenAPI config is required for voucher extension"); } if (!siteId || !clientId || !periodMs) { throw new Error("siteId, clientId, and periodMs are required to extend an Omada voucher client"); } const client = getV2Client(); return client.controllerRequest( "POST", `/hotspot/sites/${siteId}/cmd/clients/${clientId}/extend`, { period: Number(periodMs) }, ); } async function disconnectHotspotClient(siteId, clientId) { if (!isOpenApiConfigured()) { throw new Error("Omada OpenAPI config is required for hotspot client disconnect"); } if (!siteId || !clientId) { throw new Error("siteId and clientId are required to disconnect an Omada hotspot client"); } const client = getV2Client(); return client.controllerRequest( "POST", `/hotspot/sites/${siteId}/cmd/clients/${clientId}/disconnect`, {}, ); } async function reconcileVoucherGroup(siteId, plan) { const groupId = plan.omada_voucher_group_id; const { groups } = await listVoucherGroups(siteId, { page: 1, size: 200 }); if (!groupId) { const compatible = findCompatibleVoucherGroup(groups, plan); if (compatible) { return { action: "reused_compatible", voucherGroupId: voucherGroupId(compatible), group: compatible, compatibility: voucherGroupCompatibility(compatible, plan), }; } const name = groups.some((group) => String(group?.name || "") === defaultVoucherGroupName(plan)) ? correctedVoucherGroupName(plan, groups) : undefined; const created = await createVoucherGroup(siteId, plan, { name }); return { action: "created", ...created }; } const group = groups.find((item) => String(voucherGroupId(item)) === String(groupId)) || null; if (!group) { const compatible = findCompatibleVoucherGroup(groups, plan); if (compatible) { return { action: "reused_compatible", oldVoucherGroupId: groupId, voucherGroupId: voucherGroupId(compatible), group: compatible, compatibility: voucherGroupCompatibility(compatible, plan), }; } const name = groups.some((item) => String(item?.name || "") === defaultVoucherGroupName(plan)) ? correctedVoucherGroupName(plan, groups) : undefined; const created = await createVoucherGroup(siteId, plan, { name }); return { action: "created_missing", oldVoucherGroupId: groupId, ...created }; } const compatibility = voucherGroupCompatibility(group, plan); if (compatibility.compatible) { return { action: "unchanged", voucherGroupId: groupId, group, compatibility }; } const compatible = findCompatibleVoucherGroup(groups, plan); if (compatible) { return { action: "reused_compatible", oldVoucherGroupId: groupId, voucherGroupId: voucherGroupId(compatible), group: compatible, oldGroup: group, compatibility, }; } const created = await createVoucherGroup(siteId, { ...plan, omada_voucher_group_id: null, }, { name: correctedVoucherGroupName(plan, groups), }); return { action: "replaced_incompatible", oldVoucherGroupId: groupId, group, compatibility, ...created, }; } async function listVoucherGroups(siteId, { page = 1, size = 50, name } = {}) { if (!isOpenApiConfigured()) { throw new Error("Omada OpenAPI config is required for voucher groups"); } const client = getV2Client(); const query = `currentPage=${page}¤tPageSize=${size}&searchField=name%2Ccode&_=${Date.now()}`; const response = await client.controllerRequest("GET", `/hotspot/sites/${siteId}/voucherGroups?${query}`); const groups = voucherRowsFromResult(response); return { response, groups, matched: name ? groups.find((item) => item.name === name) : groups[0], }; } async function listVouchers(siteId, groupId, { page = 1, size = 100 } = {}) { if (!isOpenApiConfigured()) { throw new Error("Omada OpenAPI config is required for vouchers"); } const client = getV2Client(); const query = `currentPage=${page}¤tPageSize=${size}&_=${Date.now()}`; const res = await client.controllerRequest( "GET", `/hotspot/sites/${siteId}/voucherGroups/${groupId}?${query}`, ); const vouchers = voucherRowsFromResult(res); return { response: res, vouchers }; } async function issueVoucherFromGroup(siteId, groupId, { excludeCodes = [], excludeVoucherIds = [] } = {}) { const excludedCodes = new Set(excludeCodes.filter(Boolean).map((code) => String(code).toUpperCase())); const excludedIds = new Set(excludeVoucherIds.filter(Boolean).map(String)); const result = await listVouchers(siteId, groupId, { page: 1, size: 200 }); const voucher = result.vouchers.find((item) => { const status = Number(item.status); const code = String(item.code || "").toUpperCase(); const id = String(item.id || item.voucherId || ""); return status === 0 && code && !excludedCodes.has(code) && !excludedIds.has(id); }); if (!voucher) { const err = new Error("No unused vouchers available for this group"); err.code = "NO_UNUSED_OMADA_VOUCHERS"; throw err; } return { voucher, voucherId: voucher.id || voucher.voucherId || null, code: voucher.code, stats: result.response?.result || null, }; } async function findVoucherByCode(siteId, code, { pageSize = 200 } = {}) { const normalizedCode = String(code || "").trim().toUpperCase(); if (!normalizedCode) return null; const { groups } = await listVoucherGroups(siteId, { page: 1, size: pageSize }); for (const group of groups) { const groupId = voucherGroupId(group); if (!groupId) continue; const { vouchers } = await listVouchers(siteId, groupId, { page: 1, size: pageSize }); const voucher = vouchers.find((item) => String(item.code || "").trim().toUpperCase() === normalizedCode); if (voucher) { return { group, groupId, voucher, voucherId: voucher.id || voucher.voucherId || null, code: voucher.code, }; } } return null; } async function importAndAttachPortalPage(siteId, portalId, ssidId, filePath = null) { if (!isOpenApiConfigured()) { throw new Error("Omada OpenAPI config is required for portal page import"); } const client = getV2Client(); const resolvedPath = path.resolve( filePath || process.env.OMADA_PORTAL_TEMPLATE_PATH || path.join(__dirname, "..", "..", "v2 Test", "portal-redirect.html"), ); if (!fs.existsSync(resolvedPath)) { throw new Error(`Portal template not found: ${resolvedPath}`); } const portalBase = `${process.env.PORTAL_SCHEME || "http"}://${String(process.env.PORTAL_HOST || "").replace(/^https?:\/\//, "").replace(/\/+$/, "")}`; const templateHtml = fs.readFileSync(resolvedPath, "utf8") .replace(/__WIFIBIZ_SITE_ID__/g, siteId) .replace(/__WIFIBIZ_PORTAL_BASE__/g, portalBase); const preparedPath = path.join("/tmp", `wifibiz-portal-${siteId}-${Date.now()}.html`); fs.writeFileSync(preparedPath, templateHtml); const md5 = client.fileMd5(preparedPath); console.log("[portal-template] prepared upload:", { siteId, portalId, ssidId, portalBase, fileName: "portal-redirect.html", md5, containsSiteId: templateHtml.includes(siteId), }); const upload = await client.controllerUploadFileWithData( `/files/sites/${siteId}/portal/page`, preparedPath, { md5 }, "portal-redirect.html", ); const uploadedPortalPageId = omadaResultId(upload.result); console.log("[portal-template] upload result:", { siteId, portalId, ssidId, uploadedPortalPageId, result: upload.result || null, }); const portals = await getControllerPortals(siteId); const current = findPortalById(portals, portalId) || {}; const importedPortalPageId = uploadedPortalPageId || current.importedPortalPage?.id || null; const saveBody = { id: portalId, name: current.name || "Voucher Portal", enable: true, httpsRedirectEnable: true, landingPage: 1, authType: 11, hotspot: { enabledTypes: [3] }, ...(importedPortalPageId ? { importedPortalPage: { id: importedPortalPageId } } : {}), resource: 0, ssidList: ssidId ? [ssidId] : (current.ssidList || []), networkList: current.networkList || [], pageType: 2, }; console.log(`[portal-template] attaching imported page site=${siteId} portal=${portalId} page=${importedPortalPageId || 'existing/unknown'} ssid=${ssidId || 'none'} name=${saveBody.name}`); let saved; try { saved = await client.controllerRequest("PATCH", `/sites/${siteId}/setting/portals/${portalId}`, saveBody); } catch (err) { console.error("[portal-template] attach failed:", { siteId, portalId, ssidId, importedPortalPageId, uploadedFileName: upload.result?.fileName || null, errorCode: err.errorCode ?? null, message: err.message, result: err.result ?? null, request: saveBody, }); throw err; } const verifiedPortals = await getControllerPortals(siteId); const verified = findPortalById(verifiedPortals, portalId); const expectedServerUrl = `${String(process.env.PORTAL_HOST || "").replace(/^https?:\/\//, "").replace(/\/+$/, "")}/portal/${siteId}`; const verification = { siteId, portalId, expectedServerUrl, pageType: verified?.pageType ?? null, importedPortalPageId: verified?.importedPortalPage?.id || null, importedPortalPageFileName: verified?.importedPortalPage?.fileName || null, serverUrlScheme: verified?.externalPortal?.serverUrlScheme || verified?.landingUrlScheme || null, serverUrl: verified?.externalPortal?.serverUrl || null, ssidList: verified?.ssidList || [], hotspotEnabledTypes: verified?.hotspot?.enabledTypes || [], }; const mismatches = []; if (!verified) mismatches.push("portal_missing_after_patch"); if (verified && verified.pageType !== 2) mismatches.push(`pageType=${verified.pageType}`); if (verified && !verified.importedPortalPage?.id) mismatches.push("importedPortalPage.id_missing"); if (verified && verified.importedPortalPage?.fileName !== "portal-redirect.html") { mismatches.push(`importedPortalPage.fileName=${verified.importedPortalPage?.fileName || "missing"}`); } if (verified && verified.externalPortal?.serverUrlScheme !== "http") { mismatches.push(`externalPortal.serverUrlScheme=${verified.externalPortal?.serverUrlScheme || "missing"}`); } if (verified && verified.externalPortal?.serverUrl !== expectedServerUrl) { mismatches.push(`externalPortal.serverUrl=${verified.externalPortal?.serverUrl || "missing"}`); } if (verified && ssidId && !(verified.ssidList || []).includes(ssidId)) { mismatches.push(`ssidList_missing_${ssidId}`); } if (verified && !(verified.hotspot?.enabledTypes || []).includes(3)) { mismatches.push("hotspot.enabledTypes_missing_3"); } if (mismatches.length) { console.warn("[portal-template] verification failed:", { ...verification, mismatches, }); } else { console.log("[portal-template] verification ok:", verification); } try { fs.unlinkSync(preparedPath); } catch (_) {} return { importedPortalPageId: verification.importedPortalPageId, filePath: resolvedPath, preparedPath, md5, upload, saved, verification, verificationMismatches: mismatches, }; } async function getPortalId(siteId, ssidId = null) { const portals = await getPortals(siteId); const matched = ssidId ? portals.find((portal) => (portal.ssidList || []).includes(ssidId)) : portals[0]; return matched ? (matched.id ?? matched.portalId) : null; } module.exports = { initOmada, createSite, getSites, getSiteById, createPortal, getPortals, getPortalId, updatePortalUrl, importAndAttachPortalPage, getWlans, getOrCreateWlan, getSSIDs, getRateLimits, resolveRateLimitId, createSSID, updatePortalSSID, getSiteDevices, getAllAdoptedDevices, adoptDevice, rebootDevice, forgetDevice, updateSSID, deleteSSID, getClient, getSiteClients, getHotspotClients, authenticatePortalVoucher, authorizeClient, deauthorizeClient, applyRateLimit, getSiteAlerts, isOpenApiConfigured, createVoucherGroup, deleteVoucherGroup, deleteVoucher, extendVoucherClient, disconnectHotspotClient, reconcileVoucherGroup, isVoucherGroupUnused, voucherGroupHasNoSpeedLimit, voucherGroupCompatibility, listVoucherGroups, listVouchers, issueVoucherFromGroup, findVoucherByCode, };