Major additions accumulated over 9 days — single commit per request. Flow editor (new): - Generic visual editor for step trees, usable by project wizard + agent flows - PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain - Drag-and-drop reorder via vuedraggable with scope isolation per peer group - Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved) - Variable picker with per-applies_to catalog (Customer / Quotation / Service Contract / Issue / Subscription), insert + copy-clipboard modes - trigger_condition helper with domain-specific JSONLogic examples - Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern - Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js - ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates - depends_on chips resolve to step labels instead of opaque "s4" ids QR/OCR scanner (field app): - Camera capture → Gemini Vision via targo-hub with 8s timeout - IndexedDB offline queue retries photos when signal returns - Watcher merges late-arriving scan results into the live UI Dispatch: - Planning mode (draft → publish) with offer pool for unassigned jobs - Shared presets, recurrence selector, suggested-slots dialog - PublishScheduleModal, unassign confirmation Ops app: - ClientDetailPage composables extraction (useClientData, useDeviceStatus, useWifiDiagnostic, useModemDiagnostic) - Project wizard: shared detail sections, wizard catalog/publish composables - Address pricing composable + pricing-mock data - Settings redesign hosting flow templates Targo-hub: - Contract acceptance (JWT residential + DocuSeal commercial tracks) - Referral system - Modem-bridge diagnostic normalizer - Device extractors consolidated Migration scripts: - Invoice/quote print format setup, Jinja rendering - Additional import + fix scripts (reversals, dates, customers, payments) Docs: - Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS, FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT, APP_DESIGN_GUIDELINES - Archived legacy wizard PHP for reference - STATUS snapshots for 2026-04-18/19 Cleanup: - Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*) - .gitignore now covers invoice preview output + nested .DS_Store Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
552 lines
20 KiB
JavaScript
552 lines
20 KiB
JavaScript
'use strict'
|
|
/**
|
|
* Oktopus TR-369/USP Integration
|
|
*
|
|
* Handles device pre-authorization and service provisioning via the
|
|
* Oktopus controller API at oss.gigafibre.ca.
|
|
*
|
|
* Device identity in Oktopus = MAC address (no colons, uppercase).
|
|
* ERPNext Service Equipment stores mac_address (with colons) and serial_number.
|
|
*
|
|
* Flow:
|
|
* 1. On equipment scan (field app) or bulk sync, create device auth in Oktopus
|
|
* 2. Attach a "registration" service instance so the CPE auto-provisions on BOOTSTRAP
|
|
* 3. Optionally push OLT/fibre config variables for ZTP
|
|
*/
|
|
const { log, httpRequest, erpFetch } = require('./helpers')
|
|
const cfg = require('./config')
|
|
|
|
// Internal Docker network URL (bypasses Authentik SSO proxy)
|
|
// Internal Docker network URL (bypasses Authentik SSO proxy)
|
|
const OKTOPUS_URL = cfg.OKTOPUS_URL || 'http://oktopus-controller-1:8000'
|
|
const OKTOPUS_USER = cfg.OKTOPUS_USER || ''
|
|
const OKTOPUS_PASS = cfg.OKTOPUS_PASS || ''
|
|
const OKTOPUS_MONGO_URL = cfg.OKTOPUS_MONGO_URL || 'mongodb://oktopus-mongo-1:27017'
|
|
|
|
let authToken = null
|
|
let tokenExpiry = 0
|
|
let mongoClient = null
|
|
let mongoDb = null
|
|
|
|
/* ── Auth ─────────────────────────────────────────────────────────── */
|
|
|
|
async function authenticate () {
|
|
if (authToken && Date.now() < tokenExpiry) return authToken
|
|
try {
|
|
const res = await httpRequest(OKTOPUS_URL, '/api/auth/login', {
|
|
method: 'PUT',
|
|
body: { email: OKTOPUS_USER, password: OKTOPUS_PASS },
|
|
})
|
|
// Oktopus returns JWT as a plain JSON string (with quotes)
|
|
const token = typeof res.data === 'string' ? res.data.replace(/^"|"$/g, '') : res.data?.token
|
|
if (res.status === 200 && token) {
|
|
authToken = token
|
|
// Refresh 5 min before expiry (assume 1h token)
|
|
tokenExpiry = Date.now() + 55 * 60 * 1000
|
|
log('[oktopus] Authenticated')
|
|
return authToken
|
|
}
|
|
log('[oktopus] Auth failed:', res.status, JSON.stringify(res.data))
|
|
return null
|
|
} catch (e) {
|
|
log('[oktopus] Auth error:', e.message)
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function oktopusRequest (method, path, body = null) {
|
|
const token = await authenticate()
|
|
if (!token) throw new Error('Oktopus authentication failed')
|
|
return httpRequest(OKTOPUS_URL, path, {
|
|
method,
|
|
body,
|
|
// Oktopus expects raw JWT token (no "Bearer " prefix)
|
|
headers: { Authorization: token },
|
|
timeout: 15000,
|
|
})
|
|
}
|
|
|
|
/* ── MongoDB (Oktopus adapter DB — device records) ────────────────── */
|
|
|
|
async function getMongoDb () {
|
|
if (mongoDb) return mongoDb
|
|
try {
|
|
const { MongoClient } = require('mongodb')
|
|
mongoClient = new MongoClient(OKTOPUS_MONGO_URL)
|
|
await mongoClient.connect()
|
|
mongoDb = mongoClient.db('adapter')
|
|
log('[oktopus] Connected to Oktopus MongoDB')
|
|
return mongoDb
|
|
} catch (e) {
|
|
log('[oktopus] MongoDB connect error:', e.message)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure a device record exists in Oktopus MongoDB (adapter.devices).
|
|
* The adapter requires the device to exist before it can process status updates.
|
|
*/
|
|
async function ensureDeviceRecord (endpointId, opts = {}) {
|
|
const db = await getMongoDb()
|
|
if (!db) return { ok: false, error: 'MongoDB unavailable' }
|
|
|
|
try {
|
|
const col = db.collection('devices')
|
|
const existing = await col.findOne({ sn: endpointId })
|
|
if (existing) {
|
|
// Update if we have new info
|
|
if (opts.vendor || opts.model || opts.customer || opts.alias) {
|
|
const update = {}
|
|
if (opts.vendor) update.vendor = opts.vendor
|
|
if (opts.model) update.model = opts.model
|
|
if (opts.customer) update.customer = opts.customer
|
|
if (opts.alias) update.alias = opts.alias
|
|
await col.updateOne({ sn: endpointId }, { $set: update })
|
|
}
|
|
return { ok: true, existing: true }
|
|
}
|
|
|
|
await col.insertOne({
|
|
sn: endpointId,
|
|
model: opts.model || '',
|
|
customer: opts.customer || '',
|
|
vendor: opts.vendor || '',
|
|
version: '',
|
|
product_class: opts.product_class || 'ONT',
|
|
alias: opts.alias || '',
|
|
status: 0,
|
|
mqtt: true,
|
|
stomp: false,
|
|
websockets: false,
|
|
cwmp: false,
|
|
})
|
|
log(`[oktopus] Device record created: ${endpointId}`)
|
|
return { ok: true, created: true }
|
|
} catch (e) {
|
|
log(`[oktopus] MongoDB error for ${endpointId}:`, e.message)
|
|
return { ok: false, error: e.message }
|
|
}
|
|
}
|
|
|
|
/* ── MAC helpers ──────────────────────────────────────────────────── */
|
|
|
|
/** Normalize MAC to uppercase hex without separators: E4FAC4160688 */
|
|
function normalizeMac (mac) {
|
|
if (!mac) return null
|
|
return mac.replace(/[:\-. ]/g, '').toUpperCase()
|
|
}
|
|
|
|
/** Format as USP Agent Endpoint ID: ops::OUI-ProductClass-SerialNumber (TR-369 ops authority) */
|
|
function generateEndpointId (mac, serial, productClass = 'Device2') {
|
|
const cleanMac = normalizeMac(mac)
|
|
if (!cleanMac) return null
|
|
const oui = cleanMac.substring(0, 6)
|
|
if (serial) {
|
|
return `ops::${oui}-${productClass}-${serial}`
|
|
}
|
|
// Fallback if serial is missing
|
|
return `USP::${cleanMac}`
|
|
}
|
|
|
|
/** Generate a random password (alphanumeric, 16 chars) */
|
|
function generatePassword (len = 16) {
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
|
|
let result = ''
|
|
for (let i = 0; i < len; i++) result += chars[Math.floor(Math.random() * chars.length)]
|
|
return result
|
|
}
|
|
|
|
/* ── Device Auth (pre-authorization) ──────────────────────────────── */
|
|
|
|
/**
|
|
* Create device credentials in Oktopus so the CPE can authenticate via MQTT.
|
|
* @param {string} mac - MAC address (any format)
|
|
* @returns {{ ok: boolean, deviceId: string, password?: string, error?: string }}
|
|
*/
|
|
async function preAuthorizeDevice (mac) {
|
|
const deviceId = normalizeMac(mac)
|
|
if (!deviceId) return { ok: false, error: 'Invalid MAC address' }
|
|
|
|
const password = generatePassword()
|
|
try {
|
|
const res = await oktopusRequest('POST', '/api/device/auth', {
|
|
id: deviceId,
|
|
password,
|
|
})
|
|
if (res.status === 200 || res.status === 201) {
|
|
log(`[oktopus] Device pre-authorized: ${deviceId}`)
|
|
return { ok: true, deviceId, password }
|
|
}
|
|
if (res.status === 409) {
|
|
// Already exists — that's fine
|
|
log(`[oktopus] Device already registered: ${deviceId}`)
|
|
return { ok: true, deviceId, password: null, existing: true }
|
|
}
|
|
log(`[oktopus] Pre-auth failed: ${res.status}`, JSON.stringify(res.data))
|
|
return { ok: false, deviceId, error: `HTTP ${res.status}: ${JSON.stringify(res.data)}` }
|
|
} catch (e) {
|
|
log(`[oktopus] Pre-auth error for ${deviceId}:`, e.message)
|
|
return { ok: false, deviceId, error: e.message }
|
|
}
|
|
}
|
|
|
|
/* ── Full provisioning: pre-auth + fibre context ─────────────────── */
|
|
|
|
/**
|
|
* Pre-authorize a device and enrich with fibre/OLT context from ERPNext.
|
|
* Called during field scan (on-scan) or bulk sync.
|
|
*
|
|
* @param {object} opts
|
|
* @param {string} opts.mac - MAC address
|
|
* @param {string} opts.serial - ERPNext serial number
|
|
* @param {string} [opts.service_location] - ERPNext Service Location name
|
|
* @param {string} [opts.equipment_name] - ERPNext Service Equipment name
|
|
* @param {string} [opts.customer] - ERPNext Customer name
|
|
* @returns {object} Result with pre-auth status and fibre context
|
|
*/
|
|
async function provisionDevice (opts) {
|
|
const { mac, serial, service_location, equipment_name, customer } = opts
|
|
const result = {
|
|
serial,
|
|
mac,
|
|
deviceId: normalizeMac(mac),
|
|
endpointId: generateEndpointId(mac, serial),
|
|
actions: [],
|
|
}
|
|
|
|
// Step 1: Pre-authorize in Oktopus
|
|
const authResult = await preAuthorizeDevice(mac)
|
|
result.oktopus_auth = authResult
|
|
if (authResult.ok) {
|
|
result.actions.push({
|
|
action: authResult.existing ? 'oktopus_already_registered' : 'oktopus_pre_authorized',
|
|
deviceId: authResult.deviceId,
|
|
})
|
|
} else {
|
|
result.actions.push({ action: 'oktopus_auth_failed', error: authResult.error })
|
|
}
|
|
|
|
// Step 1b: Ensure device record exists in Oktopus MongoDB
|
|
if (result.endpointId) {
|
|
const mongoResult = await ensureDeviceRecord(result.endpointId, {
|
|
vendor: opts.vendor || 'TP-Link',
|
|
model: opts.model || '',
|
|
customer: customer || '',
|
|
alias: opts.alias || serial || '',
|
|
product_class: opts.equipment_type || 'ONT',
|
|
})
|
|
if (mongoResult.ok) {
|
|
result.actions.push({
|
|
action: mongoResult.created ? 'device_record_created' : 'device_record_exists',
|
|
endpointId: result.endpointId,
|
|
})
|
|
} else {
|
|
result.actions.push({ action: 'device_record_failed', error: mongoResult.error })
|
|
}
|
|
}
|
|
|
|
// Step 2: Store Oktopus credentials back in ERPNext (if new)
|
|
if (authResult.ok && authResult.password && equipment_name) {
|
|
try {
|
|
await erpFetch(`/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
oktopus_device_id: authResult.deviceId,
|
|
oktopus_password: authResult.password,
|
|
}),
|
|
})
|
|
result.actions.push({ action: 'erp_credentials_stored', equipment: equipment_name })
|
|
} catch (e) {
|
|
result.actions.push({ action: 'erp_update_failed', error: e.message })
|
|
}
|
|
}
|
|
|
|
// Step 3: Fetch fibre/OLT context from Service Location
|
|
if (service_location) {
|
|
try {
|
|
const locRes = await erpFetch(
|
|
`/api/resource/Service Location/${encodeURIComponent(service_location)}` +
|
|
'?fields=["name","address_line1","city","olt_ip","olt_port","olt_name","ont_id","ont_serial",' +
|
|
'"vlan_internet","vlan_manage","vlan_telephone","vlan_tv","connection_type","network_id"]'
|
|
)
|
|
const loc = locRes.data?.data || locRes.data || {}
|
|
result.fibre_context = {
|
|
location: loc.name,
|
|
address: `${loc.address_line1 || ''}, ${loc.city || ''}`.trim().replace(/^,\s*/, ''),
|
|
olt_ip: loc.olt_ip,
|
|
olt_port: loc.olt_port,
|
|
olt_name: loc.olt_name,
|
|
ont_id: loc.ont_id,
|
|
ont_serial: loc.ont_serial,
|
|
vlans: {
|
|
internet: loc.vlan_internet,
|
|
manage: loc.vlan_manage,
|
|
telephone: loc.vlan_telephone,
|
|
tv: loc.vlan_tv,
|
|
},
|
|
connection_type: loc.connection_type,
|
|
network_id: loc.network_id,
|
|
}
|
|
result.actions.push({ action: 'fibre_context_loaded', location: loc.name })
|
|
} catch (e) {
|
|
result.actions.push({ action: 'fibre_context_failed', error: e.message })
|
|
}
|
|
}
|
|
|
|
// Step 4: Generate TR-369 MQTT config summary for the CPE
|
|
if (result.deviceId) {
|
|
result.tr369_config = {
|
|
connection_type: 'MQTT',
|
|
mqtt_server: 'oss.gigafibre.ca',
|
|
mqtt_port: 1883,
|
|
agent_endpoint_id: result.endpointId,
|
|
agent_mqtt_topic: `oktopus/usp/v1/agent/${result.deviceId}`,
|
|
controller_endpoint_id: 'oktopusController',
|
|
controller_mqtt_topic: `oktopus/usp/v1/controller/${result.deviceId}`,
|
|
}
|
|
}
|
|
|
|
log(`[oktopus] Provision: ${serial} (${result.deviceId}) → ${result.actions.length} actions`)
|
|
return result
|
|
}
|
|
|
|
/* ── Bulk sync: ERPNext → Oktopus ─────────────────────────────────── */
|
|
|
|
/**
|
|
* Sync all active ONT equipment from ERPNext into Oktopus.
|
|
* Fetches Service Equipment where equipment_type=ONT and mac_address is set,
|
|
* then pre-authorizes each in Oktopus.
|
|
*
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.status] - Filter by status (default: 'Actif')
|
|
* @param {number} [opts.limit] - Max devices to sync (default: all)
|
|
* @returns {{ total: number, authorized: number, existing: number, failed: number, errors: string[] }}
|
|
*/
|
|
async function bulkSync (opts = {}) {
|
|
const status = opts.status || 'Actif'
|
|
const limit = opts.limit || 0
|
|
const PAGE = 500
|
|
let offset = 0
|
|
let allEquip = []
|
|
|
|
log(`[oktopus] Bulk sync starting (status=${status}, limit=${limit || 'all'})`)
|
|
|
|
// Fetch all ONT equipment with MAC addresses from ERPNext
|
|
while (true) {
|
|
const filters = JSON.stringify([
|
|
['equipment_type', '=', 'ONT'],
|
|
['mac_address', 'is', 'set'],
|
|
['status', '=', status],
|
|
])
|
|
const fields = JSON.stringify(['name', 'serial_number', 'mac_address', 'service_location', 'customer'])
|
|
const path = `/api/resource/Service Equipment?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(fields)}&limit_page_length=${PAGE}&limit_start=${offset}&order_by=name asc`
|
|
|
|
const res = await erpFetch(path)
|
|
const rows = res.data?.data || []
|
|
allEquip.push(...rows)
|
|
|
|
if (rows.length < PAGE) break
|
|
offset += PAGE
|
|
if (limit && allEquip.length >= limit) break
|
|
}
|
|
|
|
if (limit) allEquip = allEquip.slice(0, limit)
|
|
log(`[oktopus] Found ${allEquip.length} ONT devices to sync`)
|
|
|
|
const result = { total: allEquip.length, authorized: 0, existing: 0, failed: 0, errors: [] }
|
|
|
|
for (const equip of allEquip) {
|
|
const authRes = await preAuthorizeDevice(equip.mac_address)
|
|
if (authRes.ok) {
|
|
// Ensure MongoDB record exists
|
|
const endpointId = generateEndpointId(equip.mac_address, equip.serial_number)
|
|
await ensureDeviceRecord(endpointId, {
|
|
vendor: equip.brand || '',
|
|
model: equip.model || '',
|
|
customer: equip.customer || '',
|
|
alias: equip.serial_number || '',
|
|
})
|
|
|
|
if (authRes.existing) {
|
|
result.existing++
|
|
} else {
|
|
result.authorized++
|
|
// Store credentials in ERPNext
|
|
try {
|
|
await erpFetch(`/api/resource/Service Equipment/${encodeURIComponent(equip.name)}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
oktopus_device_id: authRes.deviceId,
|
|
oktopus_password: authRes.password,
|
|
}),
|
|
})
|
|
} catch (e) {
|
|
log(`[oktopus] Failed to store creds for ${equip.name}:`, e.message)
|
|
}
|
|
}
|
|
} else {
|
|
result.failed++
|
|
result.errors.push(`${equip.serial_number}: ${authRes.error}`)
|
|
}
|
|
|
|
// Small delay to avoid hammering Oktopus API
|
|
if (result.total > 50) await new Promise(r => setTimeout(r, 50))
|
|
}
|
|
|
|
log(`[oktopus] Bulk sync done: ${result.authorized} new, ${result.existing} existing, ${result.failed} failed`)
|
|
return result
|
|
}
|
|
|
|
/* ── HTTP handler for /oktopus/* routes ───────────────────────────── */
|
|
|
|
async function handle (req, res, method, path) {
|
|
const { json: sendJson, parseBody: parse } = require('./helpers')
|
|
|
|
try {
|
|
const action = path.replace('/oktopus/', '').split('/').filter(Boolean)[0]
|
|
|
|
// POST /oktopus/provision — provision single device
|
|
if (action === 'provision' && method === 'POST') {
|
|
const body = await parse(req)
|
|
if (!body.mac) return sendJson(res, 400, { error: 'Missing mac address' })
|
|
const result = await provisionDevice(body)
|
|
return sendJson(res, 200, result)
|
|
}
|
|
|
|
// POST /oktopus/pre-authorize — just create device auth
|
|
if (action === 'pre-authorize' && method === 'POST') {
|
|
const body = await parse(req)
|
|
if (!body.mac) return sendJson(res, 400, { error: 'Missing mac address' })
|
|
const result = await preAuthorizeDevice(body.mac)
|
|
return sendJson(res, 200, result)
|
|
}
|
|
|
|
// POST /oktopus/bulk-sync — sync all ONT devices from ERPNext
|
|
if (action === 'bulk-sync' && method === 'POST') {
|
|
const body = await parse(req)
|
|
const result = await bulkSync(body)
|
|
return sendJson(res, 200, result)
|
|
}
|
|
|
|
// GET /oktopus/status — check Oktopus connectivity
|
|
if (action === 'status' && method === 'GET') {
|
|
try {
|
|
const token = await authenticate()
|
|
return sendJson(res, 200, {
|
|
ok: !!token,
|
|
url: OKTOPUS_URL,
|
|
authenticated: !!token,
|
|
})
|
|
} catch (e) {
|
|
return sendJson(res, 200, { ok: false, url: OKTOPUS_URL, error: e.message })
|
|
}
|
|
}
|
|
|
|
// PUT /oktopus/walk — USP GetSupportedParams (like snmpwalk)
|
|
if (action === 'walk' && method === 'PUT') {
|
|
const body = await parse(req)
|
|
const sn = body.sn || body.serial
|
|
const rootPath = body.path || 'Device.'
|
|
if (!sn) return sendJson(res, 400, { error: 'Missing sn (e.g. USP::E4FAC4160688)' })
|
|
try {
|
|
const result = await oktopusRequest('PUT', `/api/device/${encodeURIComponent(sn)}/mqtt/parameters`, {
|
|
paths: [rootPath],
|
|
})
|
|
return sendJson(res, result.status, result.data)
|
|
} catch (e) {
|
|
return sendJson(res, 504, { error: e.message })
|
|
}
|
|
}
|
|
|
|
// PUT /oktopus/get — USP Get (read parameter values)
|
|
if (action === 'get' && method === 'PUT') {
|
|
const body = await parse(req)
|
|
const sn = body.sn || body.serial
|
|
const paths = body.paths || [body.path || 'Device.']
|
|
if (!sn) return sendJson(res, 400, { error: 'Missing sn' })
|
|
try {
|
|
const result = await oktopusRequest('PUT', `/api/device/${encodeURIComponent(sn)}/mqtt/get`, { paths })
|
|
return sendJson(res, result.status, result.data)
|
|
} catch (e) {
|
|
return sendJson(res, 504, { error: e.message })
|
|
}
|
|
}
|
|
|
|
// PUT /oktopus/set — USP Set (write parameter values)
|
|
if (action === 'set' && method === 'PUT') {
|
|
const body = await parse(req)
|
|
const sn = body.sn || body.serial
|
|
if (!sn || !body.params) return sendJson(res, 400, { error: 'Missing sn and params' })
|
|
try {
|
|
const result = await oktopusRequest('PUT', `/api/device/${encodeURIComponent(sn)}/mqtt/set`, body.params)
|
|
return sendJson(res, result.status, result.data)
|
|
} catch (e) {
|
|
return sendJson(res, 504, { error: e.message })
|
|
}
|
|
}
|
|
|
|
// GET /oktopus/mqtt-status — MQTT monitor status
|
|
if (action === 'mqtt-status' && method === 'GET') {
|
|
try {
|
|
const mqttMon = require('./oktopus-mqtt')
|
|
return sendJson(res, 200, mqttMon.getStatus())
|
|
} catch (e) {
|
|
return sendJson(res, 200, { ok: false, error: 'MQTT monitor not loaded' })
|
|
}
|
|
}
|
|
|
|
// POST /oktopus/mark-online — manually mark device online
|
|
if (action === 'mark-online' && method === 'POST') {
|
|
const body = await parse(req)
|
|
const sn = body.sn || body.endpointId
|
|
if (!sn) return sendJson(res, 400, { error: 'Missing sn (e.g. USP::E4FAC4160688)' })
|
|
try {
|
|
const mqttMon = require('./oktopus-mqtt')
|
|
const result = await mqttMon.markOnline(sn)
|
|
return sendJson(res, 200, result)
|
|
} catch (e) {
|
|
return sendJson(res, 500, { error: e.message })
|
|
}
|
|
}
|
|
|
|
// POST /oktopus/probe — probe device to check if online
|
|
if (action === 'probe' && method === 'POST') {
|
|
const body = await parse(req)
|
|
const sn = body.sn || body.endpointId
|
|
if (!sn) return sendJson(res, 400, { error: 'Missing sn' })
|
|
try {
|
|
const mqttMon = require('./oktopus-mqtt')
|
|
const result = await mqttMon.probeDevice(sn)
|
|
return sendJson(res, 200, result)
|
|
} catch (e) {
|
|
return sendJson(res, 504, { error: e.message })
|
|
}
|
|
}
|
|
|
|
// GET /oktopus/devices — list all devices from Oktopus
|
|
if (action === 'devices' && method === 'GET') {
|
|
try {
|
|
const result = await oktopusRequest('GET', '/api/device')
|
|
return sendJson(res, result.status, result.data)
|
|
} catch (e) {
|
|
return sendJson(res, 502, { error: e.message })
|
|
}
|
|
}
|
|
|
|
return sendJson(res, 404, { error: 'Unknown oktopus endpoint: ' + action })
|
|
} catch (e) {
|
|
log('[oktopus] Handler error:', e.message)
|
|
return require('./helpers').json(res, 500, { error: 'Oktopus error: ' + e.message })
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
handle,
|
|
preAuthorizeDevice,
|
|
provisionDevice,
|
|
bulkSync,
|
|
normalizeMac,
|
|
generateEndpointId,
|
|
}
|