diverter-device-emulator/src/thingsboard.js
2026-09-22 22:26:58 +01:00

263 lines
7.3 KiB
JavaScript

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,
};