initial commit

This commit is contained in:
Jon Vanvik 2026-05-26 11:16:18 +02:00
commit 0eb9b55717
11 changed files with 3969 additions and 0 deletions

102
src/bank-strategy.js Normal file
View file

@ -0,0 +1,102 @@
// Bank-selection helpers for MCMS ONU firmware upgrades.
//
// MCMS stores firmware in two slots on the ONU:
// ONU.FW Bank Files: [slot 0 filename, slot 1 filename]
// ONU.FW Bank Versions: [slot 0 version, slot 1 version]
// ONU.FW Bank Ptr: 0 | 1 | 65535 (unset)
//
// FW Bank Ptr identifies the *active* bank. To avoid forcing a reboot mid-campaign,
// we stage new firmware to the inactive bank. MCMS still flips the pointer after
// a successful download, which triggers one reboot per ONU — but because we're
// only committing to the *other* bank, the ONU boots into the new image on its
// next natural reboot window. If you want to stage-only (no activation), clear
// the new file from the target slot *after* the download completes; that mode
// is out of scope for the initial tool and called out in the UI.
const UNSET_PTR = 65535;
/**
* Given a current FW Bank Ptr, decide which slot we should write to so we're
* not overwriting the active image.
*
* @param {number | undefined | null} activePtr Current ONU.FW Bank Ptr
* @returns {0 | 1}
*/
function inactiveBank(activePtr) {
if (activePtr === 0) return 1;
if (activePtr === 1) return 0;
// 65535 / null / undefined — ONU has never been upgraded via MCMS.
// Slot 0 is the factory image location; writing to slot 1 keeps slot 0 as
// a safety fallback, which matches Procedure 7's example in the dev guide.
return 1;
}
/**
* Read the version string in a given slot, tolerating sparse arrays that
* MCMS returns when only one slot is populated.
*/
function versionInSlot(onuCfg, slot) {
const versions = onuCfg?.ONU?.['FW Bank Versions'] || [];
return versions[slot] || '';
}
function filenameInSlot(onuCfg, slot) {
const files = onuCfg?.ONU?.['FW Bank Files'] || [];
return files[slot] || '';
}
function activeBankPtr(onuCfg) {
const ptr = onuCfg?.ONU?.['FW Bank Ptr'];
return typeof ptr === 'number' ? ptr : UNSET_PTR;
}
/**
* Compute the mutation we'll apply to an ONU config to stage `targetFile`
* (with `targetVersion`) into the inactive bank. Returns a new ONU-CFG
* object with only the FW fields changed caller should merge into the
* existing document fetched via GET /v1/onus/configs/<id>/ before PUTting.
*/
function planUpgrade(onuCfg, { targetFile, targetVersion }) {
const activePtr = activeBankPtr(onuCfg);
const writeSlot = inactiveBank(activePtr);
// Preserve existing slot contents; only overwrite the slot we're targeting.
const existingFiles = onuCfg?.ONU?.['FW Bank Files'] || ['', ''];
const existingVersions = onuCfg?.ONU?.['FW Bank Versions'] || ['', ''];
const newFiles = [existingFiles[0] || '', existingFiles[1] || ''];
const newVersions = [existingVersions[0] || '', existingVersions[1] || ''];
newFiles[writeSlot] = targetFile;
newVersions[writeSlot] = targetVersion;
return {
writeSlot,
activePtr,
// MCMS triggers a download by writing the target slot's Files/Versions AND
// flipping FW Bank Ptr to that slot. Per Procedure 7 in the dev guide,
// setting FW Bank Ptr to the new slot is what initiates the upgrade.
fwFields: {
'FW Bank Files': newFiles,
'FW Bank Versions': newVersions,
'FW Bank Ptr': writeSlot,
},
// Human-readable summary for the dry-run preview.
summary: {
willWriteSlot: writeSlot,
previousActiveSlot: activePtr === UNSET_PTR ? 'unset' : activePtr,
currentVersionInSlot0: existingVersions[0] || '(empty)',
currentVersionInSlot1: existingVersions[1] || '(empty)',
targetVersion,
targetFile,
},
};
}
module.exports = {
UNSET_PTR,
inactiveBank,
versionInSlot,
filenameInSlot,
activeBankPtr,
planUpgrade,
};

648
src/mcms-api.js Normal file
View file

@ -0,0 +1,648 @@
// MCMS 6.2 REST API client.
//
// Sequence (from 323-1961-306, Chapter 3 "Request Sequence"):
// 1. POST /api/v1/users/authenticate/ -> Set-Cookie: sessionid + csrftoken
// 2. (optional) PUT /api/v1/databases/selection/
// 3. GETs/PUTs with Cookie + X-CSRFToken + Referer headers
// 4. GET /api/v1/users/logout/
//
// Notes:
// - All HTTP paths are prefixed with `/api`. The dev guide's sequence
// diagrams write "/v1/users/authenticate/" as shorthand, but the actual
// HTTP route (see the curl examples in the same guide) is
// "/api/v1/users/authenticate/". Paths below include the prefix.
// - MCMS deployments commonly use self-signed certs on an internal IP. We
// expose `rejectUnauthorized` as a per-session toggle rather than globally
// neutering TLS.
// - All PUT/POST bodies wrap the payload in a `data` envelope: { "data": {...} }.
// - Response envelope is { "status": "success"|"fail"|..., "data"?: ..., "details"?: ... }.
const https = require('https');
const { URL } = require('url');
const { CookieJar } = require('tough-cookie');
// ---------- Time helpers ----------------------------------------------
// MCMS emits and accepts timestamps as "YYYY-MM-DD HH:MM:SS[.ffffff]" in UTC.
// (Example: ONU-STATE.Time, OLT alarm timestamps, AUTO-TASK Scheduled Start.)
// We use the same format on outgoing query params to /onus/stats/.
function formatMcmsTime(d) {
const pad = (n) => String(n).padStart(2, '0');
return (
`${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`
);
}
function parseMcmsTime(s) {
if (!s) return 0;
// Accept "YYYY-MM-DD HH:MM:SS[.ffffff]" (UTC). Date() treats the space-form
// as local in some Node versions, so coerce to ISO with a Z suffix.
const iso = String(s).replace(' ', 'T') + (String(s).endsWith('Z') ? '' : 'Z');
const t = Date.parse(iso);
return Number.isFinite(t) ? t : 0;
}
// ---------- ONU link-health reduction ---------------------------------
// Reduce an ONU-STATE document to one of:
// 'ok' | 'pre' | 'post' | 'buggy' | 'nodata'
//
// Field locations are based on observed traffic from MCMS 6.2 +
// Interos Everest ONUs. The ONU summary endpoint (and the bulk
// /v3/onus/states/ list) returns:
//
// STATE.STATS["ONU-PON"]["RX Pre-FEC BER"]
// STATE.STATS["ONU-PON"]["RX Post-FEC BER"]
// STATE.STATS["ONU-PON"]["RX Optical Level"] (dBm)
// STATE.STATS["ONU-PON"]["TX Optical Level"] (dBm)
// STATE.STATS["OLT-PON"]["RX Pre-FEC BER"] (upstream view)
// STATE.STATS["OLT-PON"]["RX Post-FEC BER"]
//
// On at least one MCMS UI helper the document is wrapped as
// `{ state_collection: { STATS: ... } }` — accept that shape too.
//
// Health rules (matching the user-guide green-LED criteria on p149
// plus a sanity check for ONU firmware bugs):
// ONU pre>0 AND post>0 AND post/pre >= 0.95 -> 'buggy'
// Real FEC essentially always reduces errors. A working ONU
// shows post=0 (or near-0) even with pre>0; if post is within
// 5% of pre there's no correction happening. Jon observed an
// example pair like 22.388e9/22.379e9 (post/pre = 0.9996) on
// a GNXS Everest ONU, which is the firmware bug we want to
// surface separately from a genuinely broken link.
// ONU or OLT post-FEC > 0 -> 'post' (uncorrected errors)
// ONU or OLT pre-FEC > 0 -> 'pre' (marginal link, FEC working)
// All four counters present and zero -> 'ok'
// No FEC counters found -> 'nodata'
//
// Returns:
// { flag, counters: [{tail, value, kind, scope}], optical: {rx, tx},
// detail }
const NEAR_IDENTICAL_RATIO = 0.95;
function extractOnuHealth(stateDoc) {
if (!stateDoc || typeof stateDoc !== 'object') {
return { flag: 'nodata', counters: [], optical: {} };
}
// Accept both the bare state doc and the `{ state_collection: {...} }`
// wrapper exposed by /api/onu/summary.
const stats =
stateDoc.STATS ||
stateDoc?.state_collection?.STATS ||
null;
if (!stats || typeof stats !== 'object') {
return { flag: 'nodata', counters: [], optical: {} };
}
const onuPon = stats['ONU-PON'] || {};
const oltPon = stats['OLT-PON'] || {};
const onuPre = numOrNull(onuPon['RX Pre-FEC BER']);
const onuPost = numOrNull(onuPon['RX Post-FEC BER']);
const oltPre = numOrNull(oltPon['RX Pre-FEC BER']);
const oltPost = numOrNull(oltPon['RX Post-FEC BER']);
const rxOpt = numOrNull(onuPon['RX Optical Level']);
const txOpt = numOrNull(onuPon['TX Optical Level']);
/** @type {{tail:string, value:number, kind:string, scope:string}[]} */
const counters = [];
if (onuPre !== null) counters.push({ tail: 'ONU RX Pre-FEC BER', value: onuPre, kind: 'pre', scope: 'onu' });
if (onuPost !== null) counters.push({ tail: 'ONU RX Post-FEC BER', value: onuPost, kind: 'post', scope: 'onu' });
if (oltPre !== null) counters.push({ tail: 'OLT RX Pre-FEC BER', value: oltPre, kind: 'pre', scope: 'olt' });
if (oltPost !== null) counters.push({ tail: 'OLT RX Post-FEC BER', value: oltPost, kind: 'post', scope: 'olt' });
if (counters.length === 0) {
return { flag: 'nodata', counters: [], optical: { rx: rxOpt, tx: txOpt } };
}
// Buggy detector: ONU side only — that's the side that's known to
// mis-report on Interos Everest ONUs. The OLT side is computed by
// the OLT firmware which we trust.
const buggy =
onuPre !== null && onuPost !== null &&
onuPre > 0 && onuPost > 0 &&
onuPost / onuPre >= NEAR_IDENTICAL_RATIO;
const anyPostNonzero = (onuPost ?? 0) > 0 || (oltPost ?? 0) > 0;
const anyPreNonzero = (onuPre ?? 0) > 0 || (oltPre ?? 0) > 0;
let flag;
if (buggy) flag = 'buggy';
else if (anyPostNonzero) flag = 'post';
else if (anyPreNonzero) flag = 'pre';
else flag = 'ok';
// Compact tooltip / CSV detail. Show all four counters with the
// exact field names from the API so an operator can correlate
// with the PON Manager UI by eye.
const detail = counters
.map((c) => `${c.tail}=${formatFecNumber(c.value)}`)
.join('; ');
return { flag, counters, optical: { rx: rxOpt, tx: txOpt }, detail };
}
// Backwards-compatible alias for any older callers / tests that still
// reach for extractFecHealth. The new code path runs through
// extractOnuHealth, but calling extractFecHealth on a raw STATS sub-
// document still works because we look for a `STATS` wrapper first.
function extractFecHealth(doc) {
if (doc && typeof doc === 'object' && (doc.STATS || doc.state_collection)) {
return extractOnuHealth(doc);
}
// Old callers passed a STATS sub-doc directly — wrap it.
return extractOnuHealth({ STATS: doc });
}
function numOrNull(v) {
if (v === null || v === undefined) return null;
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
if (typeof v === 'string' && /^-?[\d.]+(?:[eE][+-]?\d+)?$/.test(v)) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
return null;
}
// Format a number for the FEC display: keep integers as-is, render very
// small or very large floats in scientific notation, otherwise show 3
// significant digits. BER values are typically in the 1e-12 .. 1e-3 range
// while error *counts* are integers — both should look readable.
function formatFecNumber(n) {
if (n === 0) return '0';
const abs = Math.abs(n);
if (Number.isInteger(n)) return String(n);
if (abs < 0.01 || abs >= 1e6) return n.toExponential(2);
return Number(n.toPrecision(3)).toString();
}
class McmsApiError extends Error {
constructor(message, { status, body } = {}) {
super(message);
this.name = 'McmsApiError';
this.status = status;
this.body = body;
}
}
class McmsClient {
/**
* @param {object} opts
* @param {string} opts.baseUrl e.g. "https://mcms.example.internal"
* @param {boolean} [opts.rejectUnauthorized=true] set false for self-signed
* @param {boolean} [opts.verbose=false] console.log each request + response
*/
constructor({ baseUrl, rejectUnauthorized = true, verbose = false }) {
if (!baseUrl) throw new Error('baseUrl is required');
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.rejectUnauthorized = rejectUnauthorized;
this.verbose = verbose;
this.jar = new CookieJar();
this.csrfToken = null;
}
// --- Low-level request ------------------------------------------------
_resolvePath(path) {
// MCMS exposes the REST API under /api/. The guide writes paths as
// /v1/... but the actual HTTP route is /api/v1/... . Accept either and
// normalize here so callers don't have to care.
if (path.startsWith('/api/')) return path;
if (path.startsWith('/')) return '/api' + path;
return '/api/' + path;
}
/**
* Build a query string using percent-encoding that MCMS accepts. Node's
* URLSearchParams encodes spaces as `+` (form-urlencoded), but MCMS's
* URL parser requires `%20`. encodeURIComponent gives us `%20` for
* spaces in both keys and values.
*/
_encodeQuery(params) {
const parts = [];
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
const val = typeof v === 'string' ? v : String(v);
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(val)}`);
}
return parts.join('&');
}
async _request(method, path, { body, contentType = 'application/json', query } = {}) {
const resolvedPath = this._resolvePath(path);
const qs = query ? this._encodeQuery(query) : '';
const fullUrl = new URL(this.baseUrl + resolvedPath + (qs ? '?' + qs : ''));
const cookieHeader = await this.jar.getCookieString(fullUrl.toString());
const headers = {
'Accept': 'application/json',
'Referer': this.baseUrl + '/',
// Node's https.request sends no User-Agent by default. Some MCMS
// middlewares (and third-party WAF layers) deref HTTP_USER_AGENT
// without a guard and 500 when it's absent. Set an explicit UA so
// we look like a normal HTTP client.
'User-Agent': 'pon-fleet-upgrader/0.1.0',
};
if (cookieHeader) headers['Cookie'] = cookieHeader;
if (this.csrfToken) headers['X-CSRFToken'] = this.csrfToken;
let payload;
if (body !== undefined) {
headers['Content-Type'] = contentType;
payload = typeof body === 'string' ? body : JSON.stringify(body);
headers['Content-Length'] = Buffer.byteLength(payload).toString();
}
if (this.verbose) {
const cookieNames = cookieHeader
? cookieHeader.split(';').map((c) => c.split('=')[0].trim()).join(',')
: '(none)';
console.log(
`[mcms] -> ${method} ${fullUrl.pathname}${fullUrl.search} ` +
`cookies=[${cookieNames}] csrf=${this.csrfToken ? 'yes' : 'no'}`,
);
// Dump all outgoing headers so we can diff against a working curl.
// Cookie values are redacted — only names are interesting here.
const safeHeaders = { ...headers };
if (safeHeaders.Cookie) safeHeaders.Cookie = `<${cookieNames}>`;
if (safeHeaders['X-CSRFToken']) safeHeaders['X-CSRFToken'] = '<redacted>';
console.log('[mcms] headers:', safeHeaders);
}
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const req = https.request(
{
method,
hostname: fullUrl.hostname,
port: fullUrl.port || 443,
path: fullUrl.pathname + fullUrl.search,
headers,
rejectUnauthorized: this.rejectUnauthorized,
timeout: 60000, // 60s — MCMS lists can be slow over a big fleet
},
async (res) => {
// Record Set-Cookie headers in our jar.
const setCookie = res.headers['set-cookie'] || [];
for (const c of setCookie) {
try {
await this.jar.setCookie(c, fullUrl.toString());
} catch (e) {
// non-fatal
}
}
// Capture CSRF token when it's refreshed. MCMS 6.2 sets the
// cookie as `__Host-csrftoken` (RFC 6265bis __Host- prefix for
// Secure + Path=/ cookies); older builds may use bare `csrftoken`.
const jarCookies = await this.jar.getCookies(fullUrl.toString());
const csrf = jarCookies.find(
(c) => c.key === 'csrftoken' || c.key === '__Host-csrftoken',
);
if (csrf) this.csrfToken = csrf.value;
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
let parsed = null;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch {
parsed = raw;
}
if (this.verbose) {
const ms = Date.now() - startedAt;
const size = raw ? raw.length : 0;
console.log(
`[mcms] <- ${res.statusCode} ${method} ${fullUrl.pathname} ` +
`(${ms}ms, ${size}B)`,
);
// On any non-2xx, dump both the headers and the raw body so
// we can see what openresty/Apache/Django actually said. We
// want the WHOLE body, not a 200-char truncation, because
// proxy errors are often short HTML snippets that tell you
// exactly which middleware rejected the request.
if (res.statusCode < 200 || res.statusCode >= 300) {
console.log('[mcms] response headers:', res.headers);
console.log('[mcms] response body:', raw);
}
}
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: res.statusCode, body: parsed, headers: res.headers });
} else {
// Surface whatever the server put in the response — often
// details.message or details.error describes the real cause
// (bad credentials, stale CSRF, missing header, etc.).
let detail = '';
if (parsed && typeof parsed === 'object') {
const d = parsed.details || parsed;
if (typeof d === 'string') detail = d;
else if (d.message) detail = d.message;
else if (d.detail) detail = d.detail;
else if (d.error) detail = typeof d.error === 'string' ? d.error : JSON.stringify(d.error);
else detail = JSON.stringify(d).slice(0, 200);
} else if (typeof parsed === 'string' && parsed) {
detail = parsed.slice(0, 200);
}
const suffix = detail ? `${detail}` : '';
reject(
new McmsApiError(
`MCMS ${method} ${fullUrl.pathname} failed: HTTP ${res.statusCode}${suffix}`,
{ status: res.statusCode, body: parsed },
),
);
}
});
},
);
req.on('error', (err) => {
if (this.verbose) console.log(`[mcms] xx ${method} ${fullUrl.pathname} error:`, err?.message || err);
reject(err);
});
req.on('timeout', () => {
if (this.verbose) console.log(`[mcms] xx ${method} ${fullUrl.pathname} TIMEOUT after 60s`);
req.destroy(new Error('Request timeout (60s)'));
});
if (payload) req.write(payload);
req.end();
});
}
// --- Auth -------------------------------------------------------------
async login(username, password) {
// The PON Manager web app POSTs { "data": { "email":..., "password":... } }
// directly — no CSRF prime GET needed, and this is the body shape that
// actually establishes a session on observed MCMS builds. If the server
// rejects it with 400, try the dev-guide-documented unwrapped shape.
const credentials = { email: username, password };
try {
const res = await this._request('POST', '/v1/users/authenticate/', {
body: { data: credentials },
});
return res.body;
} catch (e) {
if (e instanceof McmsApiError && e.status === 400) {
const res = await this._request('POST', '/v1/users/authenticate/', {
body: credentials,
});
return res.body;
}
throw e;
}
}
async logout() {
try {
await this._request('GET', '/v1/users/logout/');
} catch (_) {
// best-effort
}
}
async selectDatabase(databaseId) {
return this._request('PUT', '/v1/databases/selection/', {
body: { data: databaseId },
});
}
// --- ONUs -------------------------------------------------------------
/**
* List ONU configurations with an optional server-side Mongo-style filter.
* MCMS supports query/projection/sort/limit URL params; we pass them
* through as-is (the server JSON-parses them).
*/
async listOnuConfigs({ query, projection, sort, limit, skip } = {}) {
const res = await this._request('GET', '/v1/onus/configs/', {
query: { query, projection, sort, limit, skip },
});
return res.body?.data || [];
}
async getOnuConfig(onuId) {
const res = await this._request('GET', `/v1/onus/configs/${encodeURIComponent(onuId)}/`);
return res.body?.data;
}
async listOnuStates({ query, projection, limit } = {}) {
const res = await this._request('GET', '/v1/onus/states/', {
query: { query, projection, limit },
});
return res.body?.data || [];
}
/**
* Paginate a list endpoint using the `next` cursor (not `skip` `skip`
* has been observed 500'ing on this MCMS build). Each page passes the
* last document's _id as `next`, which excludes all IDs up to and
* including that value.
*/
async _listPaginatedByNext(path, { pageSize = 1000, maxTotal = 100000 } = {}) {
const all = [];
let next;
for (;;) {
const query = { limit: pageSize };
if (next) query.next = next;
const res = await this._request('GET', path, { query });
const batch = Array.isArray(res.body?.data) ? res.body.data : [];
if (batch.length === 0) break;
all.push(...batch);
if (batch.length < pageSize) break;
next = batch[batch.length - 1]?._id;
if (!next) break;
if (all.length >= maxTotal) break;
}
return all;
}
/**
* Pull ALL ONU-CFG documents using `next`-cursor pagination at a small
* page size. Large single-page fetches have been observed timing out
* at Apache (before Django logs the request) on ~1500-ONU fleets
* because full ONU-CFG docs are ~30KB each. 100/page keeps each
* response under ~3MB and well under the proxy timeout.
*/
async listAllOnuConfigs({ pageSize = 100 } = {}) {
return this._listPaginatedByNext('/v3/onus/configs/', { pageSize });
}
/**
* Fetch a single ONU-STATE document. Uses v3 (the only path observed
* working on this MCMS build for per-ONU state).
*/
async getOnuState(onuId) {
try {
const res = await this._request('GET', `/v3/onus/states/${encodeURIComponent(onuId)}/`);
return res.body?.data;
} catch (e) {
if (e instanceof McmsApiError && e.status === 404) return null;
throw e;
}
}
/**
* Bulk-fetch ALL ONU-STATE documents. Same pagination strategy as
* configs page size 100 via `next` cursor keeps each response
* small enough for the proxy not to time out.
*/
async listAllOnuStates({ pageSize = 100 } = {}) {
return this._listPaginatedByNext('/v3/onus/states/', { pageSize });
}
/**
* Replace an ONU-CFG document. The caller supplies the full document
* (typically fetched, mutated, then passed here) MCMS does not accept
* partial updates on this endpoint.
*/
async putOnuConfig(onuId, fullDoc) {
const res = await this._request('PUT', `/v1/onus/configs/${encodeURIComponent(onuId)}/`, {
body: { data: fullDoc },
});
return res.body;
}
/**
* Delete an ONU-CFG document. This is what Procedure 43 "Deleting an ONU"
* in the PON Manager User Guide does. The STATE document may linger until
* its own TTL call `deleteOnuState` too if you want a clean sweep.
*/
async deleteOnuConfig(onuId) {
const res = await this._request('DELETE', `/v1/onus/configs/${encodeURIComponent(onuId)}/`);
return res.body;
}
/**
* Delete the ONU-STATE document. Best-effort the OLT will rewrite it
* if the ONU comes back online, so this is only useful in combination
* with deleting the CFG (or if you know the ONU is physically gone).
*/
async deleteOnuState(onuId) {
const res = await this._request('DELETE', `/v1/onus/states/${encodeURIComponent(onuId)}/`);
return res.body;
}
// --- OLTs (for ONU registration status) ------------------------------
/**
* OLT-STATE documents carry an `ONU States` field that buckets every
* known ONU by its current registration bucket:
* Registered | Deregistered | Dying Gasp | Disabled |
* Disallowed Admin | Disallowed Error | Disallowed Reg ID |
* Unspecified | Unprovisioned
* See 323-1961-306 Procedure 2 ("Determining the registration status of
* an ONU"). The PON Manager web UI uses this same data to label ONUs.
*/
async listAllOltStates({ pageSize = 100 } = {}) {
return this._listPaginatedByNext('/v3/olts/states/', { pageSize });
}
// --- Firmware inventory ----------------------------------------------
async listOnuFirmware() {
const res = await this._request('GET', '/v1/files/onu-firmware/');
return res.body?.data || [];
}
async uploadOnuFirmware(filename, base64Contents, metadata) {
const res = await this._request(
'POST',
`/v1/files/onu-firmware/${encodeURIComponent(filename)}/`,
{
body: {
data: {
file: base64Contents,
metadata: metadata || {},
},
},
},
);
return res.body;
}
// --- Bulk upgrade task (Procedure 8) ---------------------------------
/**
* Create or replace an AUTO-TASK-CFG that schedules a firmware upgrade
* across many ONUs. MCMS owns retries/staging once this is written.
*
* @param {object} opts
* @param {string} opts.taskId Unique task ID (caller-generated).
* @param {string[]} opts.serials ONU serial numbers.
* @param {string} opts.scheduledStart UTC, "YYYY-MM-DD HH:MM:SS"
* @param {number} opts.fwBankPtr 0 | 1 target (inactive) bank.
* @param {string} opts.targetFile Filename as it appears in onu-firmware.
* @param {string} opts.targetVersion Matching version string.
* @param {string} [opts.companionFile] Existing file name for the *other* slot
* (so we don't blank it by sending []).
* @param {string} [opts.companionVersion]
*/
async createFirmwareTask({
taskId,
serials,
scheduledStart,
fwBankPtr,
targetFile,
targetVersion,
companionFile = '',
companionVersion = '',
}) {
const files = ['', ''];
const versions = ['', ''];
files[fwBankPtr] = targetFile;
versions[fwBankPtr] = targetVersion;
const otherSlot = fwBankPtr === 0 ? 1 : 0;
files[otherSlot] = companionFile;
versions[otherSlot] = companionVersion;
const doc = {
_id: taskId,
Task: {
'Device Type': 'ONU',
'Operation': 'Firmware Upgrade',
'Scheduled Start Time': scheduledStart,
},
'Task Details': {
Devices: serials,
ONU: {
'FW Bank Ptr': fwBankPtr,
'FW Bank Files': { 0: files[0], 1: files[1] },
'FW Bank Versions': { 0: versions[0], 1: versions[1] },
},
},
};
const res = await this._request('PUT', `/v3/tasks/configs/${encodeURIComponent(taskId)}/`, {
body: { data: doc },
});
return res.body;
}
async getTaskConfig(taskId) {
const res = await this._request('GET', `/v3/tasks/configs/${encodeURIComponent(taskId)}/`);
return res.body?.data;
}
// --- Per-ONU upgrade status ------------------------------------------
/**
* The status endpoint the user pasted in the original spec. Path differs
* from /v1/... it's a helper MCMS exposes for polling an in-flight
* download. If the deployment doesn't expose this shape, prefer polling
* the ONU-CFG document to watch FW Bank Versions/Ptr flip.
*/
async getOnuUpgradeStatus(onuId) {
const res = await this._request('GET', `/v1/onus/${encodeURIComponent(onuId)}/upgrade/status/`);
return res.body?.data || res.body;
}
}
module.exports = {
McmsClient,
McmsApiError,
formatMcmsTime,
parseMcmsTime,
extractOnuHealth,
extractFecHealth, // legacy alias — calls extractOnuHealth
formatFecNumber,
};