@yachteye/signalk-engineroom-plugin
Version:
Get EngineRoom data from the source (database or other) and add it to the SignalK graph.
346 lines (345 loc) • 17 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const _1 = require(".");
const mssql_1 = __importDefault(require("mssql"));
const utils = require('@signalk/nmea0183-utilities');
const cloudwatch_1 = require("./cloudwatch");
/**
* Access to the BDE database with Engine Data.
*/
class SqlBDE {
constructor(app, settings, definitions) {
this.pool = null;
this.retryTimeOutSeconds = 60;
this.heartbeatTimer = null;
this.reconnecting = false;
this.lastHeartbeat = null;
this.lastHeartbeatError = null;
this.lastConnected = null;
this.lastDisconnect = null;
this.lastError = null;
this.reconnectCount = 0;
this.app = app;
this.dataDefinitions = definitions;
this.config = {
server: settings.sqlServer,
database: settings.sqlDb,
user: settings.sqlUser,
password: settings.sqlPassword,
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000,
},
options: {
trustServerCertificate: settings.trustServerCertificate,
},
};
if (settings.sqlPort > 0) {
this.config.port = settings.sqlPort;
}
this.connect();
}
/**
* Connect to the SQL database and get a Connection Pool. Retry when the connect fails.
*/
connect() {
var _a;
this.app.debug(`[SqlBDE] connect(): connect-status=${(_a = this.pool) === null || _a === void 0 ? void 0 : _a.connected}`);
mssql_1.default.connect(this.config)
.then((pool) => {
this.app.setPluginStatus(`[SqlBDE]: connection pool created, connected-status=${pool.connected}.`);
pool.on('error', (err) => {
this.app.debug(`[SqlBDE] pool error: ${err}`);
this.app.setPluginError(`[SqlBDE] pool error: ${err === null || err === void 0 ? void 0 : err.message}`);
this.lastError = (err === null || err === void 0 ? void 0 : err.message) || String(err);
this.lastDisconnect = new Date();
this.reconnect();
(0, cloudwatch_1.log)('warn', 'SQL disconnected', { error: (err === null || err === void 0 ? void 0 : err.message) || String(err) });
});
pool.on('warn', (warn) => {
console.error(`[SqlBDE] pool warn: ${warn}`);
});
pool.on('info', (info) => {
console.error(`[SqlBDE] pool info: ${info}`);
});
this.app.debug(`[SqlBDE] connect(): connecting-status=${pool.connecting}, connected-status=${pool.connected}.`);
this.pool = pool;
this.retryTimeOutSeconds = 60;
this.reconnecting = false;
this.lastConnected = new Date();
this.lastError = null;
this.lastHeartbeatError = null;
this.startHeartbeat();
(0, cloudwatch_1.log)('info', 'SQL connected', { server: this.config.server, database: this.config.database });
this.getIds();
})
.catch((err) => {
this.app.debug(`[SqlBDE] connect(): Error creating connection pool ${err}`);
this.app.setPluginError(`[SqlBDE]: ${err === null || err === void 0 ? void 0 : err.message}`);
this.lastError = (err === null || err === void 0 ? void 0 : err.message) || String(err);
this.reconnecting = false;
(0, cloudwatch_1.log)('error', 'SQL connection failed', { error: (err === null || err === void 0 ? void 0 : err.message) || String(err), retryInSeconds: this.retryTimeOutSeconds });
this.retryTimeOutSeconds = this.retryTimeOutSeconds > 10 * 60 ? 10 * 60 : this.retryTimeOutSeconds * 2;
setTimeout(() => {
this.connect();
}, this.retryTimeOutSeconds * 1000);
});
}
/**
* Start a periodic heartbeat that checks the SQL connection health.
* If the connection is lost, triggers a reconnect.
*/
startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => __awaiter(this, void 0, void 0, function* () {
if (!this.pool || !this.pool.connected) {
this.app.debug(`[SqlBDE] heartbeat: pool not connected, triggering reconnect.`);
this.lastHeartbeatError = 'Pool not connected';
this.lastDisconnect = new Date();
this.reconnect();
(0, cloudwatch_1.log)('warn', 'SQL heartbeat failed', { error: 'Pool not connected' });
return;
}
try {
yield this.pool.query('SELECT 1');
this.lastHeartbeat = new Date();
this.lastHeartbeatError = null;
}
catch (err) {
this.app.debug(`[SqlBDE] heartbeat: query failed (${err === null || err === void 0 ? void 0 : err.message}), triggering reconnect.`);
this.lastHeartbeatError = (err === null || err === void 0 ? void 0 : err.message) || String(err);
this.lastDisconnect = new Date();
this.reconnect();
(0, cloudwatch_1.log)('warn', 'SQL heartbeat failed', { error: (err === null || err === void 0 ? void 0 : err.message) || String(err) });
}
}), SqlBDE.HEARTBEAT_INTERVAL_MS);
}
/**
* Stop the heartbeat timer.
*/
stopHeartbeat() {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* Close the current pool (if any) and reconnect. Guards against concurrent reconnect attempts.
*/
reconnect() {
if (this.reconnecting) {
return;
}
this.reconnecting = true;
this.reconnectCount++;
this.stopHeartbeat();
this.app.debug(`[SqlBDE] reconnect(): closing current pool and reconnecting (attempt #${this.reconnectCount}).`);
this.app.setPluginStatus(`[SqlBDE]: reconnecting...`);
(0, cloudwatch_1.log)('warn', 'SQL reconnecting', { attempt: this.reconnectCount });
const closeAndReconnect = () => __awaiter(this, void 0, void 0, function* () {
if (this.pool) {
try {
this.pool.removeAllListeners();
yield this.pool.close();
}
catch (err) {
this.app.debug(`[SqlBDE] reconnect(): error closing pool: ${err === null || err === void 0 ? void 0 : err.message}`);
}
this.pool = null;
}
this.connect();
});
closeAndReconnect();
}
/**
* Get the current status of the SQL connection for diagnostics.
*/
getStatus() {
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
return {
connected: (_b = (_a = this.pool) === null || _a === void 0 ? void 0 : _a.connected) !== null && _b !== void 0 ? _b : false,
reconnecting: this.reconnecting,
server: this.config.server,
database: (_c = this.config.database) !== null && _c !== void 0 ? _c : '',
port: this.config.port,
lastHeartbeat: (_e = (_d = this.lastHeartbeat) === null || _d === void 0 ? void 0 : _d.toISOString()) !== null && _e !== void 0 ? _e : null,
lastHeartbeatError: this.lastHeartbeatError,
lastConnected: (_g = (_f = this.lastConnected) === null || _f === void 0 ? void 0 : _f.toISOString()) !== null && _g !== void 0 ? _g : null,
lastDisconnect: (_j = (_h = this.lastDisconnect) === null || _h === void 0 ? void 0 : _h.toISOString()) !== null && _j !== void 0 ? _j : null,
lastError: this.lastError,
reconnectCount: this.reconnectCount,
retryTimeOutSeconds: this.retryTimeOutSeconds,
};
}
/**
* Disconnect and stop the heartbeat. Called when the plugin is stopped.
*/
disconnect() {
return __awaiter(this, void 0, void 0, function* () {
var _a;
this.app.debug(`[SqlBDE] disconnect(): connected-status=${(_a = this.pool) === null || _a === void 0 ? void 0 : _a.connected}`);
this.stopHeartbeat();
if (this.pool) {
try {
this.pool.removeAllListeners();
yield this.pool.close();
this.pool = null;
}
catch (err) {
this.app.setPluginError(`[SqlBDE] disconnect() error: ${err}`);
}
}
});
}
/**
* Get the database [Id] and [Scale] values for the data we need to retrieve.
*/
getIds() {
console.log(`[SqlBDE] getIds()`);
this.dataDefinitions.forEach((dataDef) => {
if (this.pool) {
this.pool
.query(`SELECT [Id], [Scale] from [BDE].[dbo].[T_Tag] WHERE [DisplayName] LIKE '${dataDef.displayNameDb}'`)
.then((r) => {
if (r.recordset.length === 0) {
if (!dataDef.displayNameDb.startsWith('N.A. ')) {
console.warn(`[SqlBDE] getIds() No data found for '${dataDef.displayNameDb}' path='${dataDef.path}' `);
}
}
else {
dataDef.Id = r.recordset[0].Id;
if (r.recordset[0].Scale) {
dataDef.scaleDb = r.recordset[0].Scale;
}
}
})
.catch((reason) => {
this.app.debug(`[SqlBDE] getIds() error for ${dataDef.displayNameDb}: ${reason}`);
});
}
});
}
/**
* Get the specified value from the database table [T_Data]. The SQL Value is specified as bigint or null.
* @param id The database Id to query.
* @returns A Promise.
*/
queryDataById(id) {
return __awaiter(this, void 0, void 0, function* () {
// console.log(`[SqlBDE] queryDataById( ${id} )`);
if (this.pool) {
try {
// const result = await this.pool.request()
// .input('input_parameter', sql.Int, id)
// .output('Value', sql.BigInt)
// .output('Time',sql.DateTime2(7))
// .query('SELECT TOP(1) * FROM [BDE].[dbo].[T_Data] WHERE [Id] = @input_parameter ORDER BY [Time] DESC');
const result = yield this.pool.query(`SELECT TOP(1) [Time], [Value] FROM [BDE].[dbo].[T_Data] WHERE [Id] = ${id} ORDER BY [Time] DESC`);
// console.log('Q', id, JSON.stringify(result));
// {"recordsets":[[{"Time":"2024-06-25T05:44:02.096Z","Value":"0"}]],"recordset":[{"Time":"2024-06-25T05:44:02.096Z","Value":"0"}],"output":{},"rowsAffected":[1]}
const v = result.recordset[0].Value === null ? null : parseInt(result.recordset[0].Value, 10);
return { sqlValue: v, timestamp: result.recordset[0].Time };
}
catch (err) {
console.error(`[SqlBDE] queryDataById( ${id} ) error: ${err}`);
}
}
else {
// console.error(`[SqlBDE] queryDataById( ${id} ) No SQL connection pool`);
}
return null;
});
}
/**
* Get all the required data from the database.
* @returns A Promise with a dictionary with the data (keyed by path).
*/
getData() {
return __awaiter(this, void 0, void 0, function* () {
var _a, _b;
if (this.pool === null || this.pool.connected === false) {
this.app.setPluginError(`[SqlBDE] getData(): no pool or not connected, connected-status=${(_a = this.pool) === null || _a === void 0 ? void 0 : _a.connected}`);
(0, cloudwatch_1.log)('warn', 'SQL getData called while disconnected');
}
else {
this.app.setPluginStatus(`[SqlBDE] getData(): connected-status=${(_b = this.pool) === null || _b === void 0 ? void 0 : _b.connected} `);
}
const data = {};
const raw = {};
for (const dataDef of this.dataDefinitions) {
if (dataDef.Id) {
const value = yield this.queryDataById(dataDef.Id);
let dataValue = (value === null) ? null : value.sqlValue;
// console.warn('[SqlBDE] Value for', dataDef.Id, dataDef.displayNameDb, dataValue);
if (dataValue !== null) {
if (dataDef.scaleDb !== undefined && dataDef.scaleDb !== 1) {
dataValue = dataValue / dataDef.scaleDb;
}
if (dataDef.type === _1.IDataType.Ratio) {
if (dataValue > 100 || dataValue < -100) {
console.warn(`[SqlBDE] getData(): unexpected percentage value ${dataValue} for ${JSON.stringify(dataDef)} .`);
}
dataValue /= 100;
}
else if (dataDef.type === 'running_off') {
// Values are not just 0 or 1 as one would expect, so check for 0 and treat everything else as 'running'.
dataValue = dataValue === 0 ? 'off' : 'running';
}
if (dataDef.unit === 'W' && typeof dataValue === 'number') {
dataValue *= 1000; // kW to W.
}
else if (dataDef.unit === 'K' && typeof dataValue === 'number') {
dataValue = utils.transform(dataValue, 'C', 'K');
}
else if (dataDef.unit === 'Pa' && typeof dataValue === 'number') {
dataValue *= 100000; // bar to Pa.
}
else if (dataDef.unit === 'degrees' && dataDef.type === _1.IDataType.Number && typeof dataValue === 'number') {
dataValue = utils.transform(dataValue, 'deg', 'rad');
}
if (dataDef.type === _1.IDataType.Number && typeof dataValue === 'number') {
if (dataDef.min !== undefined && dataValue < dataDef.min) {
console.log(`[SqlBDE] getData(): unexpected value ${dataValue} for ${JSON.stringify(dataDef)} .`);
}
if (dataDef.max !== undefined && dataValue > dataDef.max) {
console.log(`[SqlBDE] getData(): unexpected value ${dataValue} for ${JSON.stringify(dataDef)} .`);
}
}
}
raw[dataDef.path] = {
id: String(dataDef.Id),
value: (value === null) ? null : value.sqlValue,
parsed: dataValue,
path: dataDef.path,
displayNameDb: dataDef.displayNameDb,
lastUpdate: (value === null) ? null : value.timestamp,
};
data[dataDef.path] = dataValue;
}
else if (dataDef.type === _1.IDataType.Fixed) {
data[dataDef.path] = dataDef.fixed === undefined ? 0 : dataDef.fixed;
}
else {
// console.warn('[SqlBDE] getData() no ID for', dataDef.displayNameDb, dataDef.Id);
data[dataDef.path] = null;
}
}
return { data, raw };
});
}
}
SqlBDE.HEARTBEAT_INTERVAL_MS = 60 * 1000;
exports.default = SqlBDE;