Wifiv2 / src /services /omada.js
Mbonea's picture
fix(sessions): reconcile paid tokens via Omada voucherCode
9a533f3
Raw
History Blame Contribute Delete
49.6 kB
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&currentPage=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&currentPageSize=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&currentPageSize=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&currentPageSize=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&currentPageSize=10`,
);
return Array.isArray(res.result) ? res.result : res.result?.data || [];
}
async function getControllerPortals(siteId) {
const pathPart = `/sites/${siteId}/setting/portals?currentPage=1&currentPageSize=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&currentPageSize=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&currentPageSize=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&currentPageSize=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&currentPageSize=200&filters.active=true`,
);
return res.result?.data || [];
}
const res = await omadaRequest(
"GET",
`/sites/${siteId}/clients?currentPage=1&currentPageSize=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&currentPage=1&currentPageSize=50`,
);
return res.result?.data || [];
}
const res = await omadaRequest(
"GET",
`/sites/${siteId}/logs/alerts` +
`?filters.timeStart=${thirtyDaysAgo}&filters.timeEnd=${now}` +
`&filters.resolved=false&currentPage=1&currentPageSize=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}&currentPageSize=${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}&currentPageSize=${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,
};