const axios = require("axios"); const config = require("./config"); const http = axios.create({ baseURL: config.tbHttpUrl, timeout: 15000, }); let cachedToken = null; let tokenExpiresAt = 0; function requireTbCredentials() { if (!config.tbUsername || !config.tbPassword) { const error = new Error("TB_USERNAME and TB_PASSWORD must be set in .env"); error.status = 500; throw error; } } async function login(force = false) { requireTbCredentials(); if (!force && cachedToken && Date.now() < tokenExpiresAt - 60_000) { return cachedToken; } const { data } = await http.post("/api/auth/login", { username: config.tbUsername, password: config.tbPassword, }); cachedToken = data.token; tokenExpiresAt = Date.now() + 2 * 60 * 60 * 1000; return cachedToken; } async function withAuth(request) { const token = await login(); try { return await request(token); } catch (error) { if (error.response?.status === 401) { const retryToken = await login(true); return request(retryToken); } throw error; } } function authHeaders(token) { return { "X-Authorization": `Bearer ${token}` }; } function isTimeoutError(error) { if (!error) return false; if (error.timedOut) return true; const code = error.code || error.cause?.code; if (code === "ECONNABORTED" || code === "ETIMEDOUT" || code === "ERR_CANCELED") return true; return /timeout/i.test(String(error.message || "")); } function tbError(error, fallback) { const message = error.response?.data?.message || error.response?.data?.error || error.message || fallback; const wrapped = new Error(message); wrapped.status = error.response?.status || error.status || 502; wrapped.cause = error; if (isTimeoutError(error)) { wrapped.status = 504; wrapped.timedOut = true; wrapped.message = `${fallback}: ThingsBoard timed out. The record may still have been created.`; } return wrapped; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function getCredentials(token, deviceId) { const { data: credentials } = await http.get(`/api/device/${deviceId}/credentials`, { headers: authHeaders(token), }); if (!credentials?.credentialsId) { throw new Error("ThingsBoard did not return device credentials"); } return credentials.credentialsId; } async function findDeviceByName(name) { try { return await withAuth(async (token) => { try { const { data: device } = await http.get("/api/tenant/devices", { headers: authHeaders(token), params: { deviceName: name }, }); return device || null; } catch (error) { if (error.response?.status === 404) return null; throw error; } }); } catch (error) { throw tbError(error, "Failed to look up ThingsBoard device"); } } async function getDeviceCredentials(deviceId) { try { return await withAuth(async (token) => getCredentials(token, deviceId)); } catch (error) { throw tbError(error, "Failed to fetch ThingsBoard device credentials"); } } async function adoptDeviceByName(name, { attempts = 4, delayMs = 1500 } = {}) { let lastError = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const device = await findDeviceByName(name); if (!device?.id?.id) { lastError = new Error(`ThingsBoard has no device named "${name}" yet`); lastError.status = 404; if (attempt < attempts) await sleep(delayMs); continue; } const accessToken = await getDeviceCredentials(device.id.id); return { id: device.id.id, name: device.name || name, label: device.label || name, type: device.type || config.tbDeviceType, accessToken, }; } catch (error) { lastError = error; if (isTimeoutError(error) && attempt >= 2) break; if (attempt < attempts) await sleep(delayMs); } } throw lastError || tbError(new Error("lookup failed"), "Failed to adopt ThingsBoard device"); } async function createDevice({ name, label, type, deviceProfileId }) { try { return await withAuth(async (token) => { const body = { name, label: label || name, type: type || config.tbDeviceType, }; const profileId = deviceProfileId || config.tbDeviceProfileId; if (profileId) { body.deviceProfileId = { entityType: "DEVICE_PROFILE", id: profileId }; } const { data: device } = await http.post("/api/device", body, { headers: authHeaders(token), }); const deviceId = device.id?.id; if (!deviceId) { throw new Error("ThingsBoard did not return a device id"); } const accessToken = await getCredentials(token, deviceId); return { id: deviceId, name: device.name, label: device.label || label || name, type: device.type || body.type, accessToken, }; }); } catch (error) { throw tbError(error, "Failed to create ThingsBoard device"); } } async function deleteDevice(deviceId) { try { await withAuth(async (token) => { await http.delete(`/api/device/${deviceId}`, { headers: authHeaders(token), }); }); } catch (error) { const status = error.response?.status; const message = String(error.response?.data?.message || error.message || ""); if (status === 404 || status === 400 || /not found/i.test(message)) return; throw tbError(error, "Failed to delete ThingsBoard device"); } } const DIVERTER_PROFILE_NAME = /diverter|dvgo/i; function preferredProfileId(profiles) { const configured = config.tbDeviceProfileId; if (configured && profiles.some((profile) => profile.id === configured)) { return configured; } const named = profiles.find((profile) => DIVERTER_PROFILE_NAME.test(profile.name || "")); if (named) return named.id; return configured || null; } async function listDeviceProfiles() { try { return await withAuth(async (token) => { const { data } = await http.get("/api/deviceProfiles", { headers: authHeaders(token), params: { pageSize: 100, page: 0 }, }); const profiles = (data.data || []).map((profile) => ({ id: profile.id?.id, name: profile.name, default: Boolean(profile.default), })); const preferredId = preferredProfileId(profiles); return profiles.map((profile) => ({ ...profile, preferred: profile.id === preferredId, })); }); } catch (error) { throw tbError(error, "Failed to list device profiles"); } } async function ping() { try { await login(); return { ok: true, host: config.tbHttpUrl }; } catch (error) { return { ok: false, host: config.tbHttpUrl, error: error.message }; } } async function writeSharedAttributes(deviceId, attrs) { try { await withAuth(async (token) => { await http.post(`/api/plugins/telemetry/${deviceId}/SHARED_SCOPE`, attrs, { headers: { ...authHeaders(token), "Content-Type": "application/json" }, }); }); } catch (error) { throw tbError(error, "Failed to write ThingsBoard shared attributes"); } } module.exports = { createDevice, deleteDevice, listDeviceProfiles, findDeviceByName, getDeviceCredentials, adoptDeviceByName, writeSharedAttributes, ping, };