263 lines
7.3 KiB
JavaScript
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,
|
|
};
|