UNPKG

signalk-vlm

Version:

Plugin for SignalK to retrieve your Virtual Boat datas from Virtual Loup-de-Mer (https://www.v-l-m.org)

462 lines (429 loc) 15.4 kB
/* * Copyright 2024 Jean-Laurent Girod <jeanlaurent.girod@icloud.com> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ module.exports = function(app) { const version = '0.7.0' var plugin = {}; var dataGet, dataPublish, dataGetOther, dataPublishOther; var MMSI, LAT, LON, SOG, COG, TWS, TWD, TWA, LOG, AWA, AWS, PIM, PIT, WPLON, WPLAT, NUP, VAC, timestamp; var RAC,IDU=0; let unsubscribes = []; let otherBoats = []; plugin.id = "signalk-vlm"; plugin.name = "VLM"; plugin.description = "Plugin to gather virtual boat informations from https://www.v-l-m.org"; plugin.schema = { type: 'object', required: ['login', 'password'], description: 'Warning! In order not to overload the servers of https://www.v-l-m.org, only activate this plugin when you use it.', properties: { login: { type: "string", title: "Login" }, password: { type: "string", title: "Password" }, setwp: { type: "boolean", title: "Set received waypoints to VLM", default: false }, ais: { type: "boolean", title: "Show other boats in race as AIS target", default: false } } } plugin.start = async function(options) { if ((!options.login) || (!options.password)) { app.error('Login, password and boat# are required') return; } // Fonction pour calculer la distance entre deux positions GPS function haversineDistance(lat1, lon1, lat2, lon2) { const dLat = degToRad(lat2 - lat1); const dLon = degToRad(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return 6371000 * c; // distance en mètres } // Fonction pour calculer le COG (Course Over Ground) function calculateCOG(lat1, lon1, lat2, lon2) { const dLon = degToRad(lon2 - lon1); const y = Math.sin(dLon) * Math.cos(degToRad(lat2)); const x = Math.cos(degToRad(lat1)) * Math.sin(degToRad(lat2)) - Math.sin(degToRad(lat1)) * Math.cos(degToRad(lat2)) * Math.cos(dLon); let cog = Math.atan2(y, x); // COG en radians if (cog < 0) { cog += 2 * Math.PI ; } return cog; // COG en rad } // Fonction pour calculer le SOG (Speed Over Ground) function calculateSOG(distance, timeInSeconds) { const sog = distance / timeInSeconds * 1000; // SOG en m/s return sog ; } const sleep = ms => new Promise(r => setTimeout(r, ms)); const basicauth = btoa(options.login + ':' + options.password); const degToRad = deg => (deg * Math.PI) / 180.0; const knToMs = kn => (kn * 0.51444); const nMToM = nm => (nm * 1852); const registerWpPath = () => { app.debug("Registering to paths to set WP in VLM"); let localSubscription = { context: '*', subscribe: [{ path: 'navigation.course*.nextPoint.position', period: 1000 }] }; app.subscriptionmanager.subscribe( localSubscription, unsubscribes, subscriptionError => { app.error('Error:' + subscriptionError); }, delta => { delta.updates.forEach(u => { if ( u.$source != "signalk-vlm" ) { let wptarget=u["values"][0]["value"]; let sWPLAT = wptarget["latitude"].toFixed(7); let sWPLON = wptarget["longitude"].toFixed(7); setBoatWP(sWPLAT, sWPLON); } }); } ); } const setBoatWP = async ( sWPLAT, sWPLON) => { app.debug("New WP Target received from SignalK: " + sWPLAT + "," + sWPLON); if ( ( sWPLAT == WPLAT ) && ( sWPLON == WPLON ) ) { app.debug('Same waypoint already set'); } else if ( ( PIM == undefined ) || ( PIM < 3 ) ) { app.debug('Pilote mode should be Ortho, VMG or VBMG to set waypoint') } else { let parms = "{\"pip\":{\"targetlat\":" + sWPLAT + ",\"targetlong\":" + sWPLON + ",\"targetandhdg\":-1},\"idu\":\"" + IDU + "\"}"; app.debug( 'New waypoint to VLM: ' + parms ); const res = await fetch('https://www.v-l-m.org/ws/boatsetup/target_set.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': 'Basic ' + basicauth, 'User-Agent': 'signalk-vlm/' + version, }, body: new URLSearchParams({ forcefmt: 'json', select_idu: IDU, parms: parms, }).toString() }); if (!res.ok) { app.error(`Failed to set boat WP to VLM: HTTP ${res.status} ${res.statusText}`); } else { const resBody = await res.json(); app.debug(`Received: ${JSON.stringify(resBody)}`); if ( resBody.success == true ) { WPLON = sWPLON; WPLAT = sWPLAT; sWPLON = undefined; sWPLAT = undefined; getBoatInfo(); } else { app.error(`Error setting waypoint: ${JSON.stringify(resBody)}`); } } } } const getBoatInfo = async () => { app.debug('Start Get vBoat informations routine'); const res = await fetch('https://www.v-l-m.org/ws/boatinfo.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': 'Basic ' + basicauth, 'User-Agent': 'signalk-vlm/' + version, }, body: new URLSearchParams({ forcefmt: 'json', select_idu: IDU, }).toString() }); if (!res.ok) { app.error(`Failed to get boat info from VLM: HTTP ${res.status} ${res.statusText}`); } else { const resBody = await res.json(); app.debug(`Received: ${JSON.stringify(resBody)}`); timestamp = Date.now(); IDU = resBody.IDU; MMSI = (999999999 - IDU).toString(); RAC = resBody.RAC; LAT = resBody.LAT / 1000; LON = resBody.LON / 1000; SOG = knToMs(resBody.BSP); COG = degToRad(resBody.HDG); TWS = knToMs(resBody.TWS); TWD = degToRad(resBody.TWD); // Correct the TWA Bug //TWA = degToRad( resBody.TWA ); TWA = degToRad((180 + resBody.TWD - resBody.HDG) % 360 - 180); LOG = nMToM(resBody.LOC); AWA = Math.atan(TWS * Math.sin(TWA) / (SOG + TWS * Math.cos(TWA))); AWS = Math.sqrt(Math.pow(TWS * Math.sin(TWA), 2) + Math.pow(SOG + TWS * Math.cos(TWA), 2)); PIM = resBody.PIM; if ((PIM == 1) || (PIM == 2)) { PIT = degToRad(resBody.PIP); } else { WPLAT = resBody.PIP.split("@")[0].split(",")[0]; WPLON = resBody.PIP.split("@")[0].split(",")[1]; } NUP = resBody.NUP; VAC = resBody.VAC; app.handleMessage(plugin.id, { updates: [{ values: [{ path: '', value: {name: resBody.IDB} }] }] }); } } const getOtherBoats = async () => { app.debug('Start Get Other vBoat informations routine'); const res = await fetch('https://www.v-l-m.org/ws/raceinfo/ranking.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': 'Basic ' + basicauth, 'User-Agent': 'signalk-vlm/' + version, }, body: new URLSearchParams({ forcefmt: 'json', idr: RAC, }).toString() }); if (!res.ok) { app.error(`Failed to get other boat info from VLM: HTTP ${res.status} ${res.statusText}`); } else { const resBody = await res.json(); ranks = resBody.ranking; //app.debug(`Received: ${JSON.stringify(ranks)}`); for (var rank in ranks) { const ts = Date.now(); const boatname = ranks[rank].boatname; const latitude = ranks[rank].latitude; const longitude = ranks[rank].longitude; const country = ranks[rank].country.toString(); const loch = nMToM(ranks[rank].loch); const mmsi = (999999999 - rank ).toString(); var cog,sog; if (otherBoats[mmsi] != undefined) { const distance = haversineDistance(otherBoats[mmsi].latitude, otherBoats[mmsi].longitude, latitude, longitude); cog = calculateCOG(otherBoats[mmsi].latitude, otherBoats[mmsi].longitude, latitude, longitude); sog = calculateSOG(distance, ts - otherBoats[mmsi].ts); } else { sog = SOG; cog = COG; } otherBoats[mmsi] = { name: boatname, latitude: latitude, longitude: longitude, flag: country, trip: loch, mmsi: mmsi, cog: cog, sog: sog, ts: ts, }; } } } const publishOtherBoats = () => { for ( var boat in otherBoats ) { if ( boat != MMSI ) { app.handleMessage(plugin.id, { context: 'vessels.urn:mrn:signalk:uuid:' + otherBoats[boat].mmsi, updates: [{ values: [{ path: 'sensors.ais.class', value: "B", },{ path: 'navigation.speedOverGround', value: otherBoats[boat].sog, },{ path: 'navigation.courseOverGroundTrue', value: otherBoats[boat].cog, },{ path: '', value: { name: otherBoats[boat].name}, },{ path: '', value: { mmsi: otherBoats[boat].mmsi }, },{ path: 'navigation.trip.log', value: otherBoats[boat].trip, },{ path: '', value: { flag: otherBoats[boat].flag } },{ path: 'navigation.position', value: { latitude: otherBoats[boat].latitude, longitude: otherBoats[boat].longitude, } }] }] }); } } } const actualPos = () => { const dist = SOG * (Date.now() - timestamp) / 1000 / 1852 const dlat = dist / 60 * Math.cos(COG); const dlon = dist / 60 * Math.sin(COG) / Math.cos(degToRad(LAT)); return { 'longitude': LON + dlon, 'latitude': LAT + dlat, } } const actualTripLog = () => { const dlog = SOG * (Date.now() - timestamp) / 1000 return LOG + dlog } const piParms = () => { let piparms = []; if (PIM == 1) piparms = piparms.concat([{ path: 'steering.autopilot.state', value: "auto" }, { path: 'steering.autopilot.target.headingTrue', value: PIT } ]); else if (PIM == 2) piparms = piparms.concat([{ path: 'steering.autopilot.state', value: "wind" }, { path: 'steering.autopilot.target.windAngleTrueGround', value: -PIT } ]); else if (PIM == 3) piparms = piparms.concat([{ path: 'steering.autopilot.state', value: "track" }, ]); else if (PIM == 4) piparms = piparms.concat([{ path: 'steering.autopilot.state', value: "vmg" }, ]); else if (PIM == 5) piparms = piparms.concat([{ path: 'steering.autopilot.state', value: "vbvmg" }, ]); if (PIM > 2) { let pos = { 'longitude': WPLON, 'latitude': WPLAT, } piparms = piparms.concat([{ path: 'navigation.courseGreatCircle.nextPoint.position', value: pos, }, ]); } return piparms } const publishBoatInfo = () => { let values = [{ path: '', value: { mmsi: MMSI } }, { path: 'navigation.position', value: actualPos() }, { path: 'navigation.speedOverGround', value: SOG }, { path: 'navigation.courseOverGroundTrue', value: COG }, { path: 'environment.wind.speedTrue', value: TWS }, { path: 'environment.wind.directionTrue', value: TWD }, { path: 'environment.wind.angleTrueGround', value: TWA }, { path: 'navigation.trip.log', value: actualTripLog() }, { path: 'environment.wind.angleApparent', value: AWA }, { path: 'environment.wind.speedApparent', value: AWS }]; values = values.concat(piParms()); app.handleMessage(plugin.id, { updates: [{ values: values }] }); } await getBoatInfo(); publishBoatInfo(); dataPublish = setInterval(publishBoatInfo, 1 * 1000); if (options.ais){ await getOtherBoats(); publishOtherBoats(); dataPublishOther = setInterval(publishOtherBoats, 20 * 1000); } if (options.setwp){ registerWpPath(); } await sleep((NUP + 1) * 1000); getBoatInfo(); dataGet = setInterval(getBoatInfo, VAC * 1000); if (options.ais){ getOtherBoats(); dataGetOther = setInterval(getOtherBoats, VAC * 1000); } } plugin.stop = function() { clearInterval(dataGet); clearInterval(dataGetOther); clearInterval(dataPublish); clearInterval(dataPublishOther); unsubscribes.forEach(f => f()); unsubscribes = []; app.setPluginStatus('Pluggin stopped'); }; return plugin; }