polar-recorder
Version:
A SignalK plugin to record boat polars based on sailing performance
453 lines (365 loc) • 15.5 kB
JavaScript
const fs = require('fs');
const path = require('path');
const apiRoutes = require('./apiRoutes');
const recorder = require('./recorder');
const polarStore = require('./polarStore');
const WebSocket = require('ws');
const {
isStableCourse,
isStableHdg,
isStableTWD,
passesVmgRatioFilter,
passesAvgSpeedFilter,
passesAvgTwaFilter,
passesAvgTwsFilter
} = require('./filters');
let unsubscribes = [];
let state = null;
let onUpgradeListener = null;
module.exports = function (app) {
const plugin = {
id: 'polar-recorder',
name: 'SignalK Polar Recorder',
description: 'A SignalK plugin to record boat polars based on sailing performance',
schema: require('../schema.json'),
uiSchema: require('../uiSchema.js'),
start(options) {
app.debug("Plugin start() called with options:", options);
let localSubscription = {
context: 'vessels.self',
subscribe: [{
path: 'propulsion.*',
period: 1000
}]
};
state = {
app,
interval: null,
polarData: {},
recording: {},
recordingActive: false,
recordingMode: 'manual',
propulsionInstances: [],
polarDataFile: '',
automaticRecordingFile: '',
liveTWA: undefined,
liveTWS: undefined,
liveSTW: undefined,
motoring: false,
filePath: undefined,
wss: undefined,
connectedClients: undefined
};
let courseHistory = [];
let headingHistory = [];
let stwHistory = [];
let twaHistory = [];
let twsHistory = [];
let twdHistory = [];
function changeRecordingStatus(status) {
if (status != state.recordingActive) {
state.recordingActive = status;
state.notifyClients({ event: 'changeRecordStatus', status: status });
if (status) {
state.filePath = state.recordingMode === 'auto' ? state.automaticRecordingFile : state.polarDataFile;
state.notifyClients({ event: 'polarUpdated', filePath: state.filePath });
}
app.debug(">>>>>>>>>>>>>>>>>>>> Recording", status);
}
}
function evaluateRecordingConditions(validData) {
if (state.motoring) {
changeRecordingStatus(false);
return;
}
if (validData) {
if (state.recordingMode === 'auto') {
changeRecordingStatus(true);
return;
}
if (state.recordingMode === 'manual' && state.recordingActive) {
changeRecordingStatus(true);
return;
}
}
else {
changeRecordingStatus(false);
return;
}
}
function getValidValue(pathObj, expectedSource, now, maxAgeMs) {
//app.debug(`Get valid data with: pathObj: : ${JSON.stringify(pathObj)}; expectedSource: ${expectedSource}; now: ${now}; maxAgeMS: ${maxAgeMs}`);
if (!pathObj) return undefined;
if (!expectedSource) {
const ts = new Date(pathObj.timestamp).getTime();
return now - ts <= maxAgeMs ? pathObj.value : undefined;
}
// Buscar en .values[expectedSource]
const entry = pathObj.values?.[expectedSource];
if (entry) {
const ts = new Date(entry.timestamp).getTime();
return now - ts <= maxAgeMs ? entry.value : undefined;
}
// Si no está en .values, comprobar si el source principal coincide
const mainSource = pathObj.$source?.replace(/^\$/, '');
if (mainSource === expectedSource) {
const ts = new Date(pathObj.timestamp).getTime();
return now - ts <= maxAgeMs ? pathObj.value : undefined;
}
// No hay valor válido del source esperado
return undefined;
}
function getReadings(app, options, sampleInterval, twaHistory, twsHistory, stwHistory, courseHistory, headingHistory, twdHistory) {
const now = Date.now();
const maxAgeMs = sampleInterval * 5;
const twaPath = app.getSelfPath(options.pathSources.anglePath);
const twa = getValidValue(twaPath, options.pathSources.angleSource, now, maxAgeMs);
const twsPath = app.getSelfPath(options.pathSources.speedPath);
const tws = getValidValue(twsPath, options.pathSources.speedSource, now, maxAgeMs);
const twdPath = app.getSelfPath('environment.wind.directionTrue');
const twd = getValidValue(twdPath, options.pathSources.twdSource, now, maxAgeMs);
const stwPath = app.getSelfPath('navigation.speedThroughWater');
const stw = getValidValue(stwPath, options.pathSources.stwSource, now, maxAgeMs);
const cogPath = app.getSelfPath('navigation.courseOverGroundTrue');
const cog = getValidValue(cogPath, options.pathSources.cogSource, now, maxAgeMs);
const hdgPath = app.getSelfPath('navigation.headingTrue');
const hdg = getValidValue(hdgPath, options.pathSources.hdgSource, now, maxAgeMs);
if (stw !== undefined) {
const windowMs = options.avgSpeedTimeWindow * 1000;
stwHistory.push({ time: now, value: stw });
const filtered = stwHistory.filter(entry => entry.time >= now - windowMs);
stwHistory.splice(0, stwHistory.length, ...filtered);
}
if (cog !== undefined) {
const minAge = now - (options.minLenghtValidData * 1000);
courseHistory.push({ time: now, value: cog });
const filtered = courseHistory.filter(entry => entry.time >= minAge);
courseHistory.splice(0, courseHistory.length, ...filtered);
}
if (hdg !== undefined) {
const minAge = now - (options.minLenghtValidData * 1000);
headingHistory.push({ time: now, value: hdg });
const filtered = headingHistory.filter(entry => entry.time >= minAge);
headingHistory.splice(0, headingHistory.length, ...filtered);
}
if (twa !== undefined) {
const windowMs = options.avgTwaTimeWindow * 1000;
twaHistory.push({ time: now, value: twa });
const filtered = twaHistory.filter(entry => entry.time >= now - windowMs);
twaHistory.splice(0, twaHistory.length, ...filtered);
}
if (tws !== undefined) {
const windowMs = options.avgTwsTimeWindow * 1000;
twsHistory.push({ time: now, value: tws });
const filtered = twsHistory.filter(entry => entry.time >= now - windowMs);
twsHistory.splice(0, twsHistory.length, ...filtered);
}
if (twd !== undefined) {
const windowMs = options.minStableTwdTime * 1000;
twdHistory.push({ time: now, value: twd });
const filtered = twdHistory.filter(entry => entry.time >= now - windowMs);
twdHistory.splice(0, twdHistory.length, ...filtered);
}
return {
twa,
tws,
stw,
cog,
twd,
twaHistory,
twsHistory,
stwHistory,
courseHistory,
headingHistory,
twdHistory
};
}
state.wss = new WebSocket.Server({ noServer: true });
state.connectedClients = new Set();
state.notifyClients = function (message) {
const payload = JSON.stringify(message);
state.connectedClients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
});
};
onUpgradeListener = (request, socket, head) => {
if (request.url === '/plugins/polar-recorder/ws') {
state.wss.handleUpgrade(request, socket, head, ws => {
state.connectedClients.add(ws);
const initMessages = [
{ event: 'changeMotoringStatus', engineOn: state.motoring },
{ event: 'changeRecordStatus', status: state.recordingActive },
{
event: 'updateLivePerformance',
twa: state.liveTWA,
tws: state.liveTWS,
stw: state.liveSTW
},
{ event: 'polarUpdated', filePath: state.filePath }
];
initMessages.forEach(msg => {
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
} catch (err) {
app.error("Error sending initial WS message:", err);
}
});
ws.on('close', () => state.connectedClients.delete(ws));
});
}
};
app.server.on('upgrade', onUpgradeListener);
app.subscriptionmanager.subscribe(
localSubscription,
unsubscribes,
subscriptionError => {
app.error('Error:' + subscriptionError);
},
delta => {
delta.updates.forEach(() => {
const selfProp = app.getSelfPath('propulsion');
const instances = Object.keys(selfProp || {});
let engineOn;
if (options.motoringFilter.useAutostate ?? true) {
// ✅ Comportamiento actual
engineOn = instances.some(name => {
const stateVal = app.getSelfPath(`propulsion.${name}.state`)?.value;
const rev = app.getSelfPath(`propulsion.${name}.revolutions`)?.value;
return stateVal !== 'stopped' || (typeof rev === 'number' && rev > 0);
});
} else {
// ⚙️ Evaluación alternativa basada solo en revoluciones
const maxRev = options.motoringFilter.maxRevForEngine ?? 0;
const maxRevHz = maxRev / 60;
engineOn = instances.some(name => {
const rev = app.getSelfPath(`propulsion.${name}.revolutions`)?.value;
return typeof rev === 'number' && rev > maxRevHz;
});
}
//app.debug(`Engine ON=${engineOn} (maxRevsForEngine=${options.motoringFilter.maxRevForEngine}) (useAutoState=${options.motoringFilter.useAutostate})`);
if (engineOn !== state.motoring) {
state.motoring = engineOn;
app.debug(`Motoring: ${state.motoring}`);
evaluateRecordingConditions(true);
state.notifyClients({ event: 'changeMotoringStatus', engineOn });
}
});
}
);
app.debug("Polar Recorder plugin started");
const sampleInterval = options.sampleInterval || 1000;
const dataDir = app.getDataDirPath();
const polarDataFile = path.join(dataDir, 'polar-data.json');
const rawPath = options.automaticRecordingFile ?? 'auto-recording-polar.json';
let automaticRecordingFile;
if (path.isAbsolute(rawPath)) {
// Si no termina en .json, lo añadimos
automaticRecordingFile = rawPath.endsWith('.json') ? rawPath : `${rawPath}.json`;
} else {
automaticRecordingFile = path.join(dataDir, rawPath);
}
app.debug("Polar Recorder plugin data dir:", dataDir);
const selfProp = app.getSelfPath('propulsion');
const propulsionPaths = Object.keys(selfProp || {});
state.polarData = polarStore.load(polarDataFile);
state.polarDataFile = polarDataFile;
state.automaticRecordingFile = automaticRecordingFile;
state.propulsionInstances = propulsionPaths;
if (options.automaticRecording) {
state.recordingMode = 'auto';
changeRecordingStatus(true);
}
state.interval = setInterval(() => {
const readings = getReadings(app, options, sampleInterval, twaHistory, twsHistory, stwHistory, courseHistory, headingHistory, twdHistory);
const { twa, tws, stw, cog, twd } = readings;
//*********** Filters
app.debug(`>>> Applying filters <<<`);
const stableCourse = isStableCourse(app, courseHistory, options);
const stableHdg = isStableHdg(app, headingHistory, options);
const stableTwd = isStableTWD(app, twdHistory, options);
const vmgOk = passesVmgRatioFilter(app, stw, twa, tws, state.polarData, options);
const avgSpeedOk = passesAvgSpeedFilter(app, stw, stwHistory, options);
const avgTwaFilterOk = passesAvgTwaFilter(app, twa, twaHistory, options);
const avgTwsFilterOk = passesAvgTwsFilter(app, tws, twsHistory, options);
// ********* Check if valid data
const reasons = [];
if (twa === undefined) reasons.push('twa undefined');
if (tws === undefined) reasons.push('tws undefined');
if (stw === undefined) reasons.push('stw undefined');
if (cog === undefined) reasons.push('cog undefined');
if (twd === undefined) reasons.push('twd undefined');
if (!stableCourse) reasons.push('unstable course');
if (!stableHdg) reasons.push('unstable heading');
if (!stableTwd) reasons.push('unstable TWD');
if (!vmgOk) reasons.push('VMG ratio filter failed');
if (!avgSpeedOk) reasons.push('average speed filter failed');
if (!avgTwaFilterOk) reasons.push('average TWA filter failed');
if (!avgTwsFilterOk) reasons.push('average TWS filter failed');
const validData = reasons.length === 0;
if (!validData) {
app.debug(`Invalid data due to: ${reasons.join(', ')}`);
return;
}
app.debug(`>>> Valid data ${validData} <<<`);
evaluateRecordingConditions(validData);
if (validData) {
state.liveTWA = twa ? twa * 180 / Math.PI : undefined;
state.liveTWS = tws ? tws * 1.94384 : undefined;
state.liveSTW = stw ? stw * 1.94384 : undefined;
state.notifyClients({ event: 'updateLivePerformance', twa: state.liveTWA, tws: state.liveTWS, stw: state.liveSTW });
if (!state.motoring && state.recordingActive) {
app.debug('>>> Should update <<<');
state.filePath = state.recordingMode === 'auto' ? state.automaticRecordingFile : state.polarDataFile;
const updated = recorder.update(state, state.filePath);
if (updated) {
state.notifyClients({ event: 'polarUpdated', filePath: state.filePath });
}
}
}
}, sampleInterval);
apiRoutes(app, state);
},
stop() {
unsubscribes.forEach(f => f());
unsubscribes = [];
if (state?.interval) {
clearInterval(state.interval);
state.interval = null;
console.log(">>>>>>>>> Polar Recorder interval cleared <<<<<<<<<<<<<");
}
// Cerrar conexiones WebSocket activas
if (state?.connectedClients) {
state.connectedClients.forEach(ws => {
try {
if (ws.readyState === ws.OPEN) {
ws.close();
}
} catch (err) {
app.error("Error closing WS connection:", err);
}
});
state.connectedClients.clear?.();
}
// Cerrar WebSocket server si está activo
if (state?.wss) {
try {
state.wss.close();
console.log(">>>>>>>>> WebSocket server closed <<<<<<<<<<<<<");
} catch (err) {
app.error("Error closing WebSocket server:", err);
}
}
if (onUpgradeListener) {
app.server.off('upgrade', onUpgradeListener);
onUpgradeListener = null;
}
state = null;
console.log("Polar Recorder plugin stopped");
}
};
return plugin;
};