UNPKG

signalk-racer

Version:

Signalk plugin to calculate values of interest to sail racers: such as Distance to Line; Next leg TWA.

777 lines (692 loc) 33 kB
module.exports = (app) => { const state = {}; const geolib = require('geolib') const racerSchema = { title: 'Signalk Racer Configuration', type: 'object', properties: { startLineStb: { type: 'string', title: 'The start line starboard end (boat) waypoint name', default: 'startBoat' }, startLinePort: { type: 'string', title: 'The start line port end (pin) waypoint name', default: 'startPin' }, period: { type: 'number', title: 'The subscription period in microseconds used for position and wind deltas', default: 1000 }, timer: { type: 'number', title: 'Start Timer default initial period in seconds', default: 300 }, } } const unsubscribes = []; // Update meta data for non standard paths app.handleMessage('vessels.self', { context: "vessels.self", updates: [ { timestamp: new Date().toISOString(), "meta": [ { "path": "navigation.racing.startLineLength", "value": { "units": "m", "description": "Length of the start line", "displayName": "Length of the start line", "shortName": "SLL" } }, { "path": "navigation.racing.stbLineBias", "value": { "units": "m", "description": "Bias of the start line for the starboard end", "displayName": "Bias of the start line for the starboard end", "shortName": "Bias" } }, { "path": "navigation.racing.nextLegHeading", "value": { "units": "rad", "description": "Heading of the next leg of the course", "displayName": "Heading of the next leg of the course", "shortName": "NextHDG", } }, { "path": "navigation.racing.nextLegTrueWindAngle", "value": { "units": "rad", "description": "TWA on the next leg of the course", "displayName": "TWA on the next leg of the course", "shortName": "NextTWA", } }, ] } ] }); // send multiple deltas, each in the form of { path, value, units?} function sendDeltas(updatesArray) { const timestamp = new Date().toISOString(); const delta = { context: 'vessels.self', updates: [ { source: {label: 'signalk-racer'}, timestamp: timestamp, values: updatesArray.map(({path, value}) => ({ path, value })) } ] }; app.debug('Sending deltas:', JSON.stringify(delta)); app.handleMessage('vessels.self', delta); } function waypointToPosition(waypoint) { if ( waypoint && waypoint.feature && waypoint.feature.geometry && waypoint.feature.geometry.type === 'Point' && Array.isArray(waypoint.feature.geometry.coordinates) && waypoint.feature.geometry.coordinates.length === 2 ) { const [longitude, latitude] = waypoint.feature.geometry.coordinates; return {latitude, longitude}; } else { return null; } } function toDegrees(rad) { return rad ? rad * (180 / Math.PI) : null; } function toRadians(degrees) { return degrees ? degrees * (Math.PI / 180) : null; } function bowPosition(position) { let headingTrue = app.getSelfPath("navigation.headingTrue"); if (!headingTrue) headingTrue = app.getSelfPath("navigation.courseOverGroundTrue"); if (!headingTrue || (!state.gpsFromBow && !state.gpsFromCenter)) return position headingTrue = toDegrees(headingTrue.value); app.debug('headingTrue:' + headingTrue); app.debug('position:' + JSON.stringify(position)); app.debug('gpsFromBow:' + JSON.stringify(state.gpsFromBow)); app.debug('gpsFromCenter:' + JSON.stringify(state.gpsFromCenter)); let bow = position; if (state.gpsFromBow) bow = geolib.computeDestinationPoint(bow, state.gpsFromBow, headingTrue); if (state.gpsFromCenter) bow = geolib.computeDestinationPoint(bow, state.gpsFromCenter, headingTrue + 90); app.debug('bowPosition:' + JSON.stringify(bow)); return bow; } function camelCase(name) { return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); } async function setStartLine(context, path, value, cb) { try { app.debug('setStartLine:', JSON.stringify(value)); if (!value || !value.end || typeof value.end !== 'string') { app.error('Failed to set start line: !value.end'); cb(null, {state: 'FAILED'}); } const end = value.end.toLowerCase(); if (end !== 'port' && end !== 'stb') { app.error('Failed to set start line: unknown end'); cb(null, {state: 'FAILED'}); } // Get the position either as arg or of the current bow postion let position = value.position; if (position === 'bow') { position = app.getSelfPath('navigation.position'); if (position) position = bowPosition(position.value); } app.debug(`setStartLine[${end}] = ${JSON.stringify(position)} before translation/rotation`); const startLine = state.startLine; // Do any rotations or length changes if (startLine && (value.delta || value.rotate)) { app.debug(`setStartLine[${end}] translate ${value.delta} and rotate ${value.rotate} of ${JSON.stringify(startLine)}`); const fixedEnd = startLine[end === 'port' ? 'stb' : 'port']; const movingEnd = position ? position : startLine[end]; app.debug(`fixedEnd: ${JSON.stringify(fixedEnd)} movingEnd: ${JSON.stringify(movingEnd)}`); const initialBearing = geolib.getRhumbLineBearing(fixedEnd, movingEnd); const currentDistance = geolib.getDistance(fixedEnd, movingEnd); app.debug(`initialBearing: ${initialBearing} currentDistance: ${currentDistance}`); const newDistance = value.delta && typeof value.delta === 'number' ? (currentDistance + value.delta) : currentDistance; const newBearing = value.rotate && typeof value.rotate === 'number'? (initialBearing + toDegrees(value.rotate)) : initialBearing; app.debug(`newDistance: ${newDistance} newBearing: ${newBearing}`); const newPosition = geolib.computeDestinationPoint(fixedEnd, newDistance, newBearing); app.debug(`Moved/Rotated ${end} from ${JSON.stringify(position)} to ${JSON.stringify(newPosition)}`); position = newPosition; } if (position) { app.debug('position:' + JSON.stringify(position)); const waypointConfig =`startLine${camelCase(end)}`; app.debug(`waypointConfig: ${waypointConfig}`); const waypointName = state.options[waypointConfig]; app.debug(`waypointName: ${waypointName}`); let waypointId = startLine ? startLine[end + 'Id'] : null; app.debug(`waypointId: ${waypointId}`); if (!waypointId) { // Get the ID of the last existing waypoint with the given name. const waypoints = await app.resourcesApi.listResources('waypoints', {}); for (const [id, resource] of Object.entries(waypoints)) { if (resource.name === waypointName) { waypointId = id; // don't break here as we want the last waypoint with the given name } } app.debug(`waypointId: ${waypointId}`); } app.debug(`POSITION to set ${end}(${waypointName}/${waypointId}): ${JSON.stringify(position)}`); const waypoint = { name: waypointName, feature: { type: 'Feature', geometry: { type: 'Point', coordinates: [position.longitude, position.latitude] }, properties: {} } } app.debug(`waypoint: ${waypointId} -> ${end}/${waypointName} : ${JSON.stringify(waypoint)}`); if (waypointId) await app.resourcesApi.setResource('waypoints', waypointId, waypoint); else await app.resourcesApi.createResource('waypoints', waypoint); } cb(null, { state: 'COMPLETED', result: { distanceToStart: state.distanceToLine ? state.distanceToLine : null, } }); if (startLine) { const newStartLine = {...state.startLine}; newStartLine[end + 'Id'] = waypointId; newStartLine[end] = position; startLine.length = geolib.getPreciseDistance(newStartLine.port, newStartLine.stb, 0.1); startLine.bearing = geolib.getRhumbLineBearing(newStartLine.stb, newStartLine.port); state.startLine = newStartLine; sendDeltas([ {path: `navigation.racing.startLine${end.charAt(0).toUpperCase() + end.slice(1)}`, value: position}, {path: 'navigation.racing.startLineLength', value: newStartLine.length}, ]); processPosition(null); } else { findLineAndThenProcess(null, true); } } catch (err) { app.error('Failed to set start line end:', JSON.stringify(err)); cb(null, {state: 'FAILED'}); throw err; } } async function startTimeCommand(context, path, value, cb) { // start / reset / sync try { app.debug('startTimerCommand:', JSON.stringify(value)); if (!value || !value.command || typeof value.command !== 'string') { app.error('Failed to command start timer: ' + JSON.stringify(value)); cb(null, {state: 'FAILED'}); } const command = value.command.toLowerCase(); switch (command) { case 'start': { const now = new Date(); const timeToStart = state.timeToStart ?? state.options.timer ?? 300; const startTimestamp = new Date(now.getTime() + timeToStart * 1000).toISOString(); state.timerRunning = true; state.startTime = startTimestamp; state.timeToStart = timeToStart; sendDeltas([ {path: 'navigation.racing.startTime', value: startTimestamp}, {path: 'navigation.racing.timeToStart', value: timeToStart} ]); if (state.timerInterval) clearInterval(state.timerInterval); state.timerInterval = setInterval(() => { if (!state.timerRunning || state.timeToStart <= 0) { clearInterval(state.timerInterval); return; } state.timeToStart--; sendDeltas([{path: 'navigation.racing.timeToStart', value: state.timeToStart}]); }, 1000); cb(null, {state: 'COMPLETED'}); break; } case 'reset': { state.timerRunning = false; state.timeToStart = state.options.timer ?? 300; if (state.timerInterval) clearInterval(state.timerInterval); sendDeltas([ {path: 'navigation.racing.timeToStart', value: state.timeToStart}, {path: 'navigation.racing.startTime', value: null} ]); cb(null, {state: 'COMPLETED'}); break; } case 'sync': { if (!state.timerRunning || state.timeToStart == null) { cb(null, { state: 'FAILED', message: 'Timer not running' }); return; } const rounded = Math.round(state.timeToStart / 60) * 60; state.timeToStart = rounded; state.startTime = new Date(Date.now() + rounded * 1000).toISOString(); sendDeltas([ { path: 'navigation.racing.timeToStart', value: state.timeToStart }, { path: 'navigation.racing.startTime', value: state.startTime } ]); cb(null, { state: 'COMPLETED' }); break; } case 'set': { const input = value.startTime; if (!input) { cb(null, { state: 'FAILED', message: 'Missing startTime' }); return; } const inputTime = new Date(input); if (isNaN(inputTime)) { cb(null, { state: 'FAILED', message: 'Invalid startTime format' }); return; } const now = new Date(); const diff = Math.floor((inputTime - now) / 1000); if (diff <= 0) { state.timerRunning = false; state.timeToStart = 0; state.startTime = null; sendDeltas([ { path: 'navigation.racing.timeToStart', value: 0 }, { path: 'navigation.racing.startTime', value: null } ]); cb(null, { state: 'COMPLETED' }); return; } state.timerRunning = true; state.timeToStart = diff; state.startTime = inputTime.toISOString(); sendDeltas([ { path: 'navigation.racing.timeToStart', value: state.timeToStart }, { path: 'navigation.racing.startTime', value: state.startTime } ]); if (state.timerInterval) clearInterval(state.timerInterval); state.timerInterval = setInterval(() => { if (!state.timerRunning || state.timeToStart <= 0) { clearInterval(state.timerInterval); return; } state.timeToStart--; sendDeltas([{ path: 'navigation.racing.timeToStart', value: state.timeToStart }]); }, 1000); cb(null, { state: 'COMPLETED' }); break; } case 'adjust': { const delta = Number(value.delta); if (isNaN(delta) || state.timeToStart == null || !state.startTime) { cb(null, { state: 'FAILED', message: 'Cannot adjust: invalid state or delta' }); return; } state.timeToStart += delta; if (state.timeToStart < 0) state.timeToStart = 0; let now = Date.now(); const adjustedStart = new Date(now - (now % 1000) + state.timeToStart * 1000).toISOString(); state.startTime = adjustedStart; sendDeltas([ { path: 'navigation.racing.timeToStart', value: state.timeToStart }, { path: 'navigation.racing.startTime', value: adjustedStart } ]); cb(null, { state: 'COMPLETED' }); break; } default : { app.error('Unknown command to start timer: ' + command); cb(null, {state: 'FAILED'}); return; } } } catch (err) { app.error('Failed to set start line end:', JSON.stringify(err)); cb(null, {state: 'FAILED'}); throw err; } } function findLineAndThenProcess(position, alwaysFindLine = false) { if (!state.startLine || alwaysFindLine) { app.resourcesApi.listResources( 'waypoints', {} ).then(data => { state.wayPointsScanned = true; const startLine = { stb: null, port: null, length: null, bearing: null, } for (const [key, value] of Object.entries(data)) { app.debug(`WAYPOINT: ${key}, Value:`, JSON.stringify(value)); if (value.name === state.options.startLinePort) { let pos = waypointToPosition(value); if (pos) { startLine.portId = key; startLine.port = pos; } } if (value.name === state.options.startLineStb) { let pos = waypointToPosition(value); if (pos) { startLine.stbId = key; startLine.stb = pos; } } } if (startLine.port && startLine.stb) { startLine.length = geolib.getPreciseDistance(startLine.port, startLine.stb, 0.1); startLine.bearing = geolib.getRhumbLineBearing(startLine.stb, startLine.port); app.debug(`STARTLINE: startLinePort: ${JSON.stringify(startLine)}`); state.startLine = startLine; } else { app.debug(`STARTLINE: undefined`); state.startLine = null; } sendDeltas([ {path: 'navigation.racing.startLinePort', value: startLine.port}, {path: 'navigation.racing.startLineStb', value: startLine.stb}, {path: 'navigation.racing.startLineLength', value: startLine.length}, ]); processPosition(position); processWind() }).catch(err => { app.error(err); }); } else { processPosition(position); } } function processPosition(position) { if (!position) { position = app.getSelfPath('navigation.position'); app.debug('POSITION:' + JSON.stringify(position)); position = position ? position.value : null; } const startLine = state.startLine; app.debug(`processPosition ${JSON.stringify(position)} to ${JSON.stringify(startLine)}`); if (position && startLine && (!state.activeRoute || state.activeRoute.pointIndex < 2)) { // handle the bow offset const bow = bowPosition(position); // Get the distance to each end of the line const toPort = geolib.getPreciseDistance(bow, startLine.port, 0.1); const toStb = geolib.getPreciseDistance(bow, startLine.stb, 0.1); const closest = toPort < toStb ? startLine.port : startLine.stb; let toEnd; let ocs; let angle; if (closest === state.startLine.port) { toEnd = toPort; app.debug('closest to Port(pin):' + toPort); const toBowBearing = geolib.getRhumbLineBearing(startLine.port, bow); app.debug('toBowBearing:' + toBowBearing); angle = toBowBearing - (startLine.bearing + 180) % 360; } else { toEnd = toStb; app.debug('closest to Stb(boat):' + toStb); const toBowBearing = geolib.getRhumbLineBearing(startLine.stb, bow); app.debug('toBowBearing:' + toBowBearing); angle = startLine.bearing - toBowBearing; } angle = ((angle + 180) % 360 + 360) % 360 - 180; app.debug('angle:' + angle); app.debug('toEnd:' + toEnd); ocs = angle < 0; app.debug('ocs:' + ocs); let absAngle = Math.abs(angle); const farFromLine = absAngle > 135; app.debug('farFromLine:' + farFromLine); const perpendicularToLine = toEnd * Math.sin(toRadians(absAngle)); app.debug('perpendicularToLine:' + perpendicularToLine); let toLine = farFromLine ? Math.sqrt(toEnd * toEnd - perpendicularToLine * perpendicularToLine) : perpendicularToLine; toLine = Math.round(10 * toLine) / 10; app.debug('toLine:' + toLine); const distanceToLine = ocs ? -toLine : toLine; app.debug('distanceToLine:' + distanceToLine); state.distanceToLine = distanceToLine; sendDeltas([ {path: 'navigation.racing.distanceStartline', value: distanceToLine}, ]); } else if (state.distanceToLine) { state.distanceToLine = null; sendDeltas([ {path: 'navigation.racing.distanceStartline', value: null}, ]); } } function processWind(twd) { if (!twd) { twd = app.getSelfPath('environment.wind.directionTrue'); app.debug('TWD:' + JSON.stringify(twd)); twd = twd ? twd.value : null; } if (!twd) return; let nextTwa = null; const activeRoute = state.activeRoute; if (activeRoute) { const nextLegHeading = activeRoute.nextLegHeading; if (nextLegHeading) { nextTwa = (540 + toDegrees(twd) - nextLegHeading) % 360 - 180; app.debug(`nextTwa: ${nextTwa}`); } } let bias = null; const startLine = state.startLine; if (startLine && (!activeRoute || activeRoute.pointIndex < 2)) { app.debug(`processWind ${twd} to ${JSON.stringify(startLine)}`); bias = startLine.length * Math.cos(toRadians(startLine.bearing) - (twd + Math.PI)); } sendDeltas([ {path: 'navigation.racing.stbLineBias', value: bias}, {path: 'navigation.racing.nextLegTrueWindAngle', value: toRadians(nextTwa)}, ]); } function processRoute(activeRoute) { if (activeRoute && (activeRoute.pointIndex + 1) < activeRoute.pointTotal) { // e.g. {"href":"/resources/routes/a12eefe7-8fd3-49f7-81af-ce1f3440d3ff","name":"Course 1","reverse":false,"pointIndex":1,"pointTotal":5} const routeId = activeRoute.href.split('/').pop(); // e.g. "a12eefe7-8fd3-49f7-81af-ce1f3440d3ff" app.debug('processRoute:' + routeId); app.resourcesApi.getResource('routes', routeId).then(route => { app.debug('found active route:', JSON.stringify(route)); // e.g. {name:"Course 1",description:"Test race course",distance:3027,feature:{type:"Feature",geometry:{type:"LineString",coordinates:[[151.2789194160523,-33.80427227074185],[151.28230238234357,-33.813458268981435],[151.2758044350174,-33.80201284252691],[151.27864400784992,-33.801915364141514],[151.27982384226053,-33.80429179893497]]},properties:{coordinatesMeta:[{href:"/resources/waypoints/bb35d9d0-c04e-4721-89f2-a1d8e697ab81"},{href:"/resources/waypoints/65082ddd-6fa6-4538-9a01-49d5d795b0bb"},{href:"/resources/waypoints/0bbce508-67c6-4f03-8de0-fb0532f88917"},{href:"/resources/waypoints/602ee562-c6dc-42c3-a775-512597063ca4"},{href:"/resources/waypoints/2e3cf194-8835-498d-abaa-a5d2104550b3"}]},id:""},timestamp:"2025-06-09T22:22:54.739Z",$source:"resources-provider"} const coordinates = route.feature.geometry.coordinates; const [fromLongitude, fromLatitude] = coordinates[activeRoute.pointIndex]; const [toLongitude, toLatitude] = coordinates[activeRoute.pointIndex + 1]; app.debug(`from: {longitude:${fromLongitude}, latitude:${fromLatitude}}, to: {longitude:${toLongitude}, latitude:${toLatitude}}`); const nextLegHeading = geolib.getRhumbLineBearing({ latitude: fromLatitude, longitude: fromLongitude }, {latitude: toLatitude, longitude: toLongitude}); app.debug(`nextLegHeading: ${nextLegHeading}`); activeRoute.nextLegHeading = nextLegHeading; state.activeRoute = activeRoute; sendDeltas([ {path: 'navigation.racing.nextLegHeading', value: toRadians(nextLegHeading)} ]); }).catch(err => { app.error(err); }); } else { state.activeRoute = null; sendDeltas([ {path: 'navigation.racing.nextLegHeading', value: null} ]); } } // Return the plugin return { id: 'signalk-racer', name: 'SignalK Racing Plugin', start: (options) => { if (!options.startLinePort || !options.startLineStb) { app.error('Missing waypoint names: startLinePort and/or startLineStb not configured.'); return; } app.debug('startLinePort:' + options.startLinePort); app.debug('startLineStb:' + options.startLineStb); app.debug('app.selfId:' + app.selfId); let fromBow = app.getSelfPath('sensors.gps.fromBow'); let fromCenter = app.getSelfPath('sensors.gps.fromCenter'); state.options = options; state.wayPointsScanned = false; state.gpsFromBow = fromBow ? fromBow.value : null; state.gpsFromCenter = fromCenter ? fromCenter.value : null; state.timeToStart = options.timer; sendDeltas([{path: 'navigation.racing.timeToStart', value: state.timeToStart}]); // Subscribe to position updates. app.subscriptionmanager.subscribe( { context: 'vessels.' + app.selfId, subscribe: [ { path: 'navigation.position', period: options.period, } ] }, unsubscribes, (subscriptionError) => { app.error('Error:' + subscriptionError) }, (delta) => { app.debug('DELTA POSITIONS ' + JSON.stringify(delta)); position = null; delta.updates.forEach((u) => { u.values.forEach((v) => { app.debug('DELTA POSITION ' + v.path + ' = ' + JSON.stringify(v.value)); position = v.value; }) }) if (state.startLine) processPosition(position); else findLineAndThenProcess(position, false); if (!state.initActiveRoute) { state.initActiveRoute = true; const activeRoute = app.getSelfPath('navigation.course.activeRoute'); if (activeRoute) processRoute(activeRoute.value); } } ) // Subscribe to waypoint updates. app.subscriptionmanager.subscribe( { context: 'vessels.' + app.selfId, subscribe: [ { path: 'resources.waypoints.*', policy: 'instant' } ] }, unsubscribes, (subscriptionError) => { app.error('Error:' + subscriptionError) }, (delta) => { app.debug('DELTAS WAYPOINTS ' + JSON.stringify(delta)); delta.updates.forEach((u) => { u.values.forEach((v) => { app.debug("DELTA WAYPOINT: " + v.path + ' = ' + JSON.stringify(v.value)) }) }); state.wayPointsScanned = false; findLineAndThenProcess(undefined, true); } ) // Subscribe to TWD updates. app.subscriptionmanager.subscribe( { context: 'vessels.' + app.selfId, subscribe: [ { path: 'environment.wind.directionTrue', period: options.period, } ] }, unsubscribes, (subscriptionError) => { app.error('Error:' + subscriptionError) }, (delta) => { let twd; app.debug('DELTAS TWD ' + JSON.stringify(delta)); delta.updates.forEach((u) => { u.values.forEach((v) => { twd = v.value; app.debug("DELTA TWD: " + v.path + ' = ' + twd) }) }); processWind(twd); } ) // Subscribe to Active route. app.subscriptionmanager.subscribe( { context: 'vessels.' + app.selfId, subscribe: [ { path: 'navigation.course.activeRoute', policy: 'instant' } ] }, unsubscribes, (subscriptionError) => { app.error('Error:' + subscriptionError) }, (delta) => { let route; app.debug('DELTAS ACTIVE ROUTE ' + JSON.stringify(delta)); delta.updates.forEach((u) => { u.values.forEach((v) => { route = v.value; app.debug("DELTA ACTIVE ROUTE: " + v.path + ' = ' + JSON.stringify(route)); }) }); if (route) processRoute(route); } ); app.registerPutHandler('vessels.self', 'navigation.racing.setStartLine', setStartLine); app.registerPutHandler('vessels.self', 'navigation.racing.setStartTime', startTimeCommand); }, stop: () => { unsubscribes.forEach((f) => f()); unsubscribes.length = 0; }, schema: () => racerSchema, getOpenApi: () => require('./openapi.json'), }; };