ggsimulator
Version:
GEOGATE-Simulator simulate one/many GPS/AIS receiver/transpondeur
405 lines (353 loc) • 16.4 kB
JavaScript
/*
* Copyright 2014 Fulup Ar Foll
*
* 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.
*/
/*
* GGsimulator class simulate a GPS/AIS receiver/transponder.
* It takes input from gpx route/track file, supports OpenCPN/VisuGPX export
* format, and hopefully while not tested many other GPX format may work.
*
* GGsimulator generate intermediary positions automatically. It takes each subsegment
* of your route/track. Computes intermediary positions depending on your selected
* sog/tic. Sends Position Event with intermediary lon/lat/sog/cog info.
* Stops or loop at file end depending on the option.
*
* Usage:
* simulator = new Simulator ({gpxfile: xxxx, mmsi: 1234, shipname:xxxx ...
* simulator.event.on("position",MyEventHandler);
*
* you can generate GPX files with:
* - opencpn or any other navigation software
* - upload gpx file from most GPS devices
* - create oneline with http://www.visugpx.com/editgpx/
* - http://events.paudax.com/content/planning-your-diy-perm-route-google-maps
*
*
*/
'use strict';
var util = require("util");
var fs = require('fs');
var path = require('path');
var net = require('net');
var async = require("async");
var sgeo = require('sgeo'); // https://www.npmjs.org/package/sgeo
var xml2js = require('xml2js'); // https://github.com/Leonidas-from-XIV/node-xml2js
var EventEmitter = require("events").EventEmitter;
/*
* GGsimulator uses an async queue to push event at tic rate
* each gpx segment is processed in one chuck. Every intermediary
* positions of a given segment are push in synchronous mode.
* then tic handler pop the queue at requested value and emit event
* toward the application. When queue is empty JobQueueEmpty callback
* process next segment.
*/
function QJob (simulator, lon, lat, sog, cog, count) {
this.simulator= simulator;
this.lon = lon;
this.lat = lat;
this.cog = parseInt ((parseFloat(cog)+ (Math.random()*5)-2.5)*10)/10;
this.sog = parseInt ((parseFloat(sog) + (Math.random()*5)-2.5)*100)/100;
this.count = count;
}
// push a dummy job in queue to force activatio
function JobQueueEmpty () {
this.simulator.ProcessSegment.call (this.simulator);
}
// Notice method is called by async within async context !!!!
function JobQueuePost (job, callback) {
// dummy job to activate jobqueue
if (job === null) {
callback ();
return;
} else {
// force change to simulator context
job.simulator.NewPosition.call (job.simulator, job);
}
// wait tic time before sending next message
job.simulator.queue.pause ();
// provide some randomization on tic with a 50% fluxtuation
var nexttic = parseInt (job.simulator.ticms * (1+ Math.random()/2));
setTimeout(function () {job.simulator.queue.resume();}, nexttic);
callback ();
}
function GGsimulator (opts) {
this.valid=true; // in code we trust :)
var random = parseInt (Math.random() * 1000000000);
// provide some default values
this.opts= {
gpxfile : opts.gpxfile || null, // no default for gpxfile
mmsi : opts.mmsi || random, // default fake mmsi
sog : opts.sog || 12, // m/s = 8knts
tic : opts.tic || 10, // 10s
debug : opts.debug || 3, // default no debug
cargo : opts.cargo || 36, // default sailing vessel
uway : opts.uway || 8, // default sailing
len : opts.length || 15, // Ship length
wid : opts.width || 4, // Ship width
draught : opts.draught || 3, // Ship width
loopwait : opts.loopwait || 0, // Sleep time before restating route
dumpfile : opts.dumpfile || null, // filename for log file or NMEA generated commands
shipname : opts.shipname || "GG"+ random,
callsign : opts.callsign || "FX"+ random,
class : opts.class || "B", // AIS class A|B
randomize : opts.randomize|| 0 // +-Math.Random/opts.randomize to Longitude/Latitude
};
this.debug = this.opts.debug | 0;
this.Debug (7, "Main Options: gpxfile=%s shipname=%s mmsi=%s sog=%d tic=%d ", this.opts.gpxfile
, this.opts.shipname, this.opts.mmsi, this.opts.sog, this.opts.tic);
// check --gpxfile is present and filename exite
if (this.opts.gpxfile === null) {
console.log ("Error: --gpxfile=xxxx [xxx must be a valid gpx file]");
this.valid=false;
} else {
this.opts.basename = path.basename (this.opts.gpxfile);
try {!fs.statSync (this.opts.gpxfile).isFile();}
catch (err) {
console.log ("Error: --gpxfile=%s err=%s", this.opts.gpxfile, err);
this.valid=false;
}
}
if (opts.mmsi === 0) this.opts.mmsi = 0; // special case for GPRMC formating
// openfile and read store it in a buffer string
try {
if (this.valid)this.xmlData = fs.readFileSync (this.opts.gpxfile, "utf-8");
} catch (err) {
this.Debug (0, "Hoops gpxfile=%s err=%s", this.opts.gpxfile, err);
this.valid=false;
}
// if needed check dumpfile can be create
if (this.opts.dumpfile !== null) {
try {
this.dumpfd= fs.openSync (this.opts.dumpfile, "w+");
} catch (err) {
console.log ("hoops file to open [%s] err=[%s]",this.opts.dumpfile, err);
return;
}
}
// Process XLM/GPX route/track File (result in this.route)
if (this.valid) this.route = this.ProcessGPX();
if (this.route === null) {
this.valid = false;
}
// if error within options, stop here
if (!this.valid) {
console.log ('\n## GGsimulator Abort [check options] ##\n');
return;
}
this.uid = "GGsimulator//mmsi:" + this.opts.mmsi;
// Create an event handler for user apps
this.event = new EventEmitter();
// migh want to check your waypoint before moving any further
this.Debug (2, "Gpx Route=[%s] Waypts=[%d]", this.route.name, this.route.count);
for (var pts in this.route.waypts) {
this.Debug (3, "GPX waypts %d -- name: %s Lon: %s Lat:%s", pts, this.route.waypts [pts].name, this.route.waypts [pts].lat, this.route.waypts [pts].lon);
}
// Route segment are process each time job queue is empty
this.queue = async.queue (JobQueuePost, 1);
this.queue.simulator = this;
this.segment = 0; // next segment to process counter
this.count = 0; // stat on NMEA packets
this.ticms = this.opts.tic * 1000; // node.js timer are in ms
this.queue.drain = JobQueueEmpty; // empty queue callback
// 1st segment is activate here, JobQueueEmpty will activate next ones
this.Debug (1,"Simulation Started mmsi=%s shipname=%s", this.opts.mmsi, this.opts.shipname);
this.ProcessSegment();
}
// JobQueue is empty let's process next segment
GGsimulator.prototype.ProcessSegment = function () {
// push a dummy job in queue to force activation
function JobQueueActivate(queue, callback, timeout) {
setTimeout(function () {queue.push (null, callback);}, timeout); // wait 5S before start
}
// Callback notify Async API that curent JobQueue processing is done
function JobCallback (job, callback) {
// Nothing to do
}
// each time job queue is empty we process a new segment
if (this.segment < this.route.count-1) {
// this is working segment
var segstart = this.route.waypts [this.segment];
var segstop = this.route.waypts [this.segment+1];
// compute segment distance
var p1 = new sgeo.latlon(segstart.lat, segstart.lon);
var p2 = new sgeo.latlon(segstop.lat, segstop.lon);
var distance = p2.distanceTo(p1);
// compute intemediary point sog/distance ration
var sogms = this.opts.sog * 1.852/ 3600; // sog from knts to meter/second
var tmmsins = distance/sogms; // time in second for this segment
var inter = Math.round (tmmsins / this.opts.tic); // number of intemediary segments
this.Debug (5, "segment %d -- from:%s to:%s distance=%dnm midsegment=%d", this.segment, segstart.name, segstop.name, distance/1.852, inter);
var statics = // statics report
{ cmd : 1
, mmsi : this.opts.mmsi
, shipname : this.opts.shipname
, class : this.opts.class
, cargo : this.opts.cargo
, callsign : this.opts.callsign
, draught : this.opts.draught
, length : this.opts.len
, width : this.opts.wid
};
this.Debug (4,"Emit Static=%j", statics);
this.event.emit ("statics", statics);
// calculate intermediary waypoint and push them onto NMEA job queue
var interpolated = p1.interpolate(p2, inter);
var inter1= interpolated[0];
this.Debug (6, "Computing [%s] segment [%d/%d] ", this.route.name, this.segment, this.route.count);
if (this.dumpfd !== undefined) {
fs.writeSync (this.dumpfd, "\n$ROUTE:[" +this.route.name +"] SEGMENT:[" + this.segment + "/" + this.route.count +"]\n");
}
for (var inter=1; inter < interpolated.length; inter ++) {
this.count ++;
var inter2 = interpolated[inter];
// push waypoint to the queue
var job = new QJob (this, inter1.lng, inter1.lat, this.opts.sog, inter1.bearingTo(inter2).toFixed(2), this.count);
this.Debug (6, "Queue Intermediary WayPts N°%d %s cog: %s", this.count, inter1, job.cog);
this.queue.push (job, JobCallback);
inter1 = interpolated[inter];
inter ++;
}
this.segment ++; // next time process next segment
} else {
this.Debug (3, "All [%d] segment from [%s] processed [loop in %ss]", this.segment, this.opts.basename, this.opts.loopwait/1000);
// if loop selected wait timeout and restart operation
if (this.opts.loopwait > 0) {
this.Debug (3, "Restarting route [%s]", this.opts.basename);
this.segment = 0;
JobQueueActivate (this.queue, this.callback, this.opts.loopwait);
}
}
};
// publish new position to listeners
GGsimulator.prototype.NewPosition = function (job) {
var position=
{ cmd : 2 // position report
, mmsi : this.opts.mmsi
, lat : job.lat
, lon : job.lon
, cog : job.cog
, hdg : job.cog
, sog : job.sog
};
this.Debug (4,"Emit Position=%j", position);
this.event.emit ("position", position);
};
// import Debug helper
GGsimulator.prototype.Debug = require('./_Debug');
// Process GPX file parse and send NMEA paquet
GGsimulator.prototype.ProcessGPX= function () {
var route = {
name : "", // this.route name from gpx file
count : 0, // number of waypts/trackpts
waypts:[] // list of waypoint lat/lon
};
var opts= this.opts; // provide acces to opts during parsing.
// process data return by XML2JSON
var ParseGPX= function(err, result) {
var data=[];
// default route name if not present in XML
var now=new Date();
data.name= 'ParseGPX' + now.toISOString();
// search for gpx tag
if (result['gpx'] === undefined) {
console.log ("Fatal: Not a GPX route/track file [no <gpx></gpx> tag]");
return (-1);
}
// search for track tag
if (result['gpx']["trk"] !== undefined) {
// console.log ("track=%s", JSON.stringify(result['gpx']["trk"]));
data = {
mode : 'track',
name : result['gpx']["trk"][0].name,
segment : result['gpx']["trk"][0]["trkseg"][0]['trkpt']
};
}
// search for route tag
if (result['gpx']["rte"] !== undefined) {
//console.log ("route=%s", JSON.stringify(result['gpx']["rte"]));
data = {
mode :'route',
name :result['gpx']["rte"][0].name,
segment :result['gpx']["rte"][0]["rtept"]
};
}
if (data.mode === undefined) {
console.log ("Fatal Not a valid GPX route/track file <trk>|<rte> tag");
return (-1);
}
// provide a default name if nothing found in gpxfile
if (data.name === undefined) {
now= new Date();
route.name = 'GGsimulator://' + opts.gpxfile + "/" + now.toISOString();
} else {
route.name = 'GGsimulator://' + data.name;
}
switch (data.mode) {
case "track":
var spd, crs, alt,dat;
for (var trackpts in data.segment) {
// console.log ("trackpts[%s]=%s", trackpts, JSON.stringify(data.segment[trackpts]));
if (data.segment[trackpts]['name'] === undefined) nam= 'TrackPts-' + trackpts;
else nam=data.segment[trackpts]['name'];
lat=parseFloat (data.segment[trackpts]["$"].lat);
lon=parseFloat (data.segment[trackpts]["$"].lon);
spd=data.segment[trackpts]['sog'];
crs=data.segment[trackpts]['course'];
alt=data.segment[trackpts]['ele'];
dat=data.segment[trackpts]['time'];
//console.log ("name=%s lat=%s lon=%s sog=%s course=%s alt=%s", nam, lat, lon, spd, crs, alt);
route.waypts.push ({name: nam, lat: lat, lon: lon, date: dat});
route.count++;
}
break;
case 'route':
var nam, lat, lon;
for (var routepts in data.segment) {
// console.log ("routepts[%s]=%s", routepts, JSON.stringify(data.segment[routepts]));
if (data.segment[routepts]['name'] === undefined)
nam= 'TrackPts-' + routepts;
else
nam=data.segment[routepts]['name'];
lat=parseFloat (data.segment[routepts]["$"].lat);
lon=parseFloat (data.segment[routepts]["$"].lon);
// console.log ("name=%s lat=%s lon=%s", nam, lat, lon);
route.waypts.push ({name: nam, lat: lat, lon: lon});
route.count++;
}
break;
}
};
// Create GPX parser and send file for parsing
this.parser = new xml2js.Parser();
try {
this.parser.parseString(this.xmlData, ParseGPX);
} catch (e) {
this.Debug (0, 'Hoops GpxFile=[%s] err=[%s]', this.opts.gpxfile, e);
return (null);
}
this.Debug (8,"XML parsed route=%s", route.name);
return (route);
};
// if use as a main and not as a module try start test
if (process.argv[1] === __filename) {
var config =
{ gpxfile : "../sample/gpx-file/opencpn-sample.gpx"
, mmsi : 1234 // my prefered fake MMSI
, tic : 1 // send a position every 10s
, loopwait: 1 // stop at end of gpxfile
, debug : 5 // 4 allow us to see event emit without officially listening to them
};
var simulator = new GGsimulator (config);
}
module.exports = GGsimulator; // http://openmymind.net/2012/2/3/Node-Require-and-Exports/