├── framer ├── example │ ├── example.jpg │ └── commands.txt ├── alternative-video-workflow.txt ├── render-alignment-video.sh ├── render-video.sh ├── README.md └── main.go ├── case ├── README.txt └── smallgps.scad ├── resource └── font │ └── README.md ├── README.md ├── goproextract ├── package.json ├── getJsonFromVideo.js ├── README.md └── getNmeaFromJson.js ├── hardware └── README.txt └── firmware ├── README.md ├── oldhwport.ino └── main.ino /framer/example/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DusteDdk/RacingGpsTracker/HEAD/framer/example/example.jpg -------------------------------------------------------------------------------- /case/README.txt: -------------------------------------------------------------------------------- 1 | This is not a very good box, it is too tight, and difficult to take apart again. On the positive side, it does work, and is pretty stable. 2 | -------------------------------------------------------------------------------- /framer/alternative-video-workflow.txt: -------------------------------------------------------------------------------- 1 | I've seen FFMPEG throw up for no explainable reason when doing the video + overlay. 2 | Instead you can render the overlay to PNGs in a MOV container, and use a program such as kdenlive to combine the two clips: 3 | ffmpeg -r 10 -i frame%06d.png -c:v png gps-overlay.mov 4 | -------------------------------------------------------------------------------- /resource/font/README.md: -------------------------------------------------------------------------------- 1 | Some bug in some go lib 2 | ======================= 3 | 4 | If your framer binary complains about a missing font, 5 | put something called luxisr.ttf here. 6 | 7 | The following worked for me: 8 | 9 | cp /usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf luxisr.ttf 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Racing GPS Tracker 2 | ================== 3 | Open Source solution for creating GPS video overlays for road-racing (and other stuff) 4 | 5 | Projects 6 | ======== 7 | 8 | * framer - Covert GPS stream data to video 9 | * firmware - Arduino firwmare to record GPS stream to sd card 10 | * hardware - How to build it 11 | * case - How to contain it 12 | -------------------------------------------------------------------------------- /framer/render-alignment-video.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z "$2" ] 4 | then 5 | echo "Usage: $0 INDIR OUTVID.mp4" 6 | exit 1 7 | fi 8 | 9 | set -e 10 | 11 | OUTVID=`realpath "$2"` 12 | INDIR="$1" 13 | 14 | cd "$INDIR" 15 | ffmpeg -r 10 -i frame%06d.png -c:v libx264 -vf fps=59.94 -pix_fmt yuv420p -preset veryfast "$OUTVID" 16 | 17 | echo "Rendered $OUTVID" 18 | -------------------------------------------------------------------------------- /goproextract/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "goprostuffs", 3 | "version": "1.0.0", 4 | "description": "For making GPS overlays with the framer from embedded GPS data from GoPro Hero 5+ cameras", 5 | "main": "getJsonFromVideo.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "WTFPL", 11 | "dependencies": { 12 | "gopro-telemetry": "^1.1.23", 13 | "gpmf-extract": "^0.1.17" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /framer/example/commands.txt: -------------------------------------------------------------------------------- 1 | # To generate the video frames 2 | mkdir out 3 | ./framer -first 1425 -in test.rmc -out out/ -trackFirst 120 -trackLast 8425 -last 10000 -finishAt 965 --finishAngle -38 -firstLap 1 -lastLap 9 4 | 5 | # To generate a video for alignment (finding the offset) 6 | bash render-alignment-video.sh out align.mp4 7 | 8 | # To generate the video (this part you can't do, since you don't have the source video) 9 | # There was 11 seconds, and 21 ms from the start of the video, until the first frame of the alignment video 10 | bash render-video.sh out race1.mp4 race1-gpsoverlay.mp4 11.21 11 | -------------------------------------------------------------------------------- /framer/render-video.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z "$4" ] 4 | then 5 | echo "Usage: $0 INDIR INVID.mp4 OUTVID.mp4 OFFSET" 6 | echo "Example offsets: MM:SS.MS or SS.MS (like 2:84.21 or 24.51)" 7 | exit 1 8 | fi 9 | 10 | set -e 11 | 12 | INDIR="$1" 13 | INVID=`realpath "$2"` 14 | OUTVID=`realpath "$3"` 15 | OFFSET="$4" 16 | 17 | cd "$INDIR" 18 | 19 | ffmpeg -i "$INVID" -framerate 10 -itsoffset $OFFSET -i frame%06d.png \ 20 | -filter_complex "[0:v]overlay=enable=gte(t\,$OFFSET):shortest=1[out]" \ 21 | -map [out] -map 0:a -c:v libx264 -preset slow -crf 20 -c:a copy \ 22 | -pix_fmt yuv420p "$OUTVID" 23 | 24 | echo "Rendered $OUTVID" 25 | -------------------------------------------------------------------------------- /goproextract/getJsonFromVideo.js: -------------------------------------------------------------------------------- 1 | const goproTelemetry = require('gopro-telemetry'); 2 | const gpmfExtract = require('gpmf-extract'); 3 | const fs = require('fs'); 4 | 5 | const fn = process.argv[2]; 6 | if(!fn || fn === '--help' || fn === '-h') { 7 | console.log('Take name of a video file and extract JSON GPS stream'); 8 | console.log('required: name of video file to extract'); 9 | process.exit(1); 10 | } 11 | gpmfExtract(bufferAppender(fn, 10 * 1024 * 1024)).then( res => { 12 | const options = { 13 | stream: ['GPS5'], 14 | }; 15 | 16 | goproTelemetry(res, options, (data)=>{ 17 | console.log(JSON.stringify(data,null,4)); 18 | }); 19 | }); 20 | 21 | 22 | function bufferAppender(path, chunkSize) { 23 | return function (mp4boxFile) { 24 | var stream = fs.createReadStream(path, { highWaterMark: chunkSize }); 25 | var bytesRead = 0; 26 | stream.on('end', () => { 27 | mp4boxFile.flush(); 28 | }); 29 | stream.on('data', chunk => { 30 | var arrayBuffer = new Uint8Array(chunk).buffer; 31 | arrayBuffer.fileStart = bytesRead; 32 | mp4boxFile.appendBuffer(arrayBuffer); 33 | bytesRead += chunk.length; 34 | }); 35 | stream.resume(); 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /framer/README.md: -------------------------------------------------------------------------------- 1 | The Framer 2 | ========== 3 | 4 | Render overlay frames from GPS data, fit for usage in video 5 | 6 | Building 7 | ======== 8 | 9 | go build 10 | 11 | Usage 12 | ===== 13 | Some tweaking is requierd, it's easiest to render a single, late frame, to determine what needs tweaking. 14 | ./framer --help Show all the options 15 | 16 | Example: 17 | ./framer -first 1450 -in 01.RMC -out out/ -trackFirst 95 -trackLast 8410 -finishAt 1685 -finishAngle -35 -firstLap 1 -lastLap 50 18 | 19 | -first 1450 The first sample that is used is 1450 20 | -in 01.RMC The input file is 01.RMC 21 | -out out/ out is an empty directory where the images will be ouput 22 | -trackFirst Start drawing track 95 samples after the first sample. (This allows the red dot to move outside the track at the start/end) 23 | -trackLast 8410 Stop drawing track after sample 1685, so we don't draw the route out of the track 24 | -finishAt 1685 Sample 1685 was taken very close to the place of the finish line (I eyeballed it!) 25 | -finishAngle -35 Rotate the finish-line -35 degrees so that it crosses the track (eyeballed) 26 | -firstLap 1 Skip the first lap (0), and use lap 1 as the first lap, lap0 was in this case begun when driving onto the track 27 | -lastLap 8 Stop after 8 laps (lap 0 is ignored), since the last lap was just slow driving into pit 28 | 29 | Generate video overlays 30 | ======================= 31 | 32 | Check the scripts: 33 | render-alignment-video.sh - For quickly rendering a video which can be used to line-up GPS data and video, this is a throwaway video.. 34 | render-video.sh - Overlay the image files from the output directory onto the target video (generates new viedo) 35 | -------------------------------------------------------------------------------- /hardware/README.txt: -------------------------------------------------------------------------------- 1 | This is how stuff is put together. 2 | I may do something more some time, but don't count on it. 3 | 4 | ^ = +5v, on the psu 5 | v = 0v, on the psu 6 | 7 | +-----+ 8 | | GPS | 9 | | +5 |--^ 10 | | gnd |--v 11 | | rx |---- tx on arduino 12 | | tx |---- rx on arduino 13 | +-----+ 14 | 15 | +------+ 16 | | SD | 17 | | +5 |--^ 18 | | gnd |--v 19 | | miso |---- 14 on arduino 20 | | mosi |---- 16 on arduino 21 | | sck |---- 15 on arduino 22 | | cs |---- 9 on arduino 23 | +------+ 24 | 25 | +-------+ 26 | |arduino| 27 | | vcc |--^ 28 | | gnd |--v 29 | | 4 |->--- LED3 cathode, "recording" indicator 30 | | 5 |---- green wire, for uart mode (tx) 31 | | 6 |---- LED1 cathode, "waiting for fix" indicator 32 | | 7 |---- LED2 cathode, "got fix" indicator 33 | | 8 |---- yello wire, for uart mode (rx) 34 | +-------+ 35 | 36 | +-----+ 37 | | PSU | 38 | | b+ |---- To the battery + terminal 39 | | b- |---- To the battery - terminal 40 | | p- |---- To toggle switch pole 41 | | + |--^ 42 | | - |--v 43 | +-----+ 44 | 45 | +-----------+ 46 | | Toggle sw | 47 | | Terminal1 |---- To battery+ 48 | | Terminal2 |---- To battery- 49 | +-----------+ 50 | 51 | +---------+ 52 | | Tact sw | 53 | | Side 1 |--^ 54 | | Side 2 |------+------------- 3 on arduino 55 | +---------+ | +-----+ 56 | +-| 10k |--v 57 | +-----+ 58 | 59 | Repeat x3: 60 | +-----+ 61 | | LED | +------+ 62 | |Anode|-----| 150r |--^ 63 | +-----+ +------+ 64 | -------------------------------------------------------------------------------- /goproextract/README.md: -------------------------------------------------------------------------------- 1 | Extract gopro GPS data from video 2 | ================== 3 | The Gopro hero 5 and later models are nice cameras and they can embed GPS data into the 4 | video stream. Unfortunately, the software support is absolutely terrible, the program they 5 | expect you to use is mainly for mobile phones (because we all know cellphones are super at 6 | encoding high-bitrate 4k video and video editing.. Their "app" can't even stitch!). 7 | 8 | getJsonFromVideo.js - extract GPS data from VIDEO into JSON 9 | usage: node getJsonFromVideo.js myFile.mp4 > gps.json 10 | 11 | getNmeaFromJson.js - Convert GPS data into the truncated NMEA RMC format used by the framer program. 12 | usage: node getNmeaFromJson.js ./gps.json > track.rmc 13 | 14 | if you need to truncate multiple files, use >> merged.rmc on all file instead and then 15 | remove all newlines: tr -d '\n' < merged.rmc > track.rmc. 16 | 17 | Note: While gopro captures at ~18 hz, the framer was made with 10hz in mind, thus, 18 | to get correct timing, and to generate a video that does not drift, getNmeaFromJson will 19 | throw away gps samples to keep timing around 10hz, it's not pretty, and if you want it 20 | to interpolate instead, then send me a pull request. But it's JUST FINE for generating nice 21 | videos. 22 | 23 | Example Workflow 24 | ====== 25 | Here i extract and combine gps data from 3 video files: 26 | 27 | node getJsonFromVideo GX010005.MP4 > gps1.json 28 | node getJsonFromVideo GX020005.MP4 > gps2.json 29 | node getJsonFromVideo GX030005.MP4 > gps3.json 30 | 31 | node getNmeaFromJson ./gps1.json > merged.rmc 32 | node getNmeaFromJson ./gps2.json >> merged.rmc 33 | node getNmeaFromJson ./gps3.json >> merged.rmc 34 | 35 | tr -d '\n' < merged.rmc > final.rmc 36 | 37 | mkdir out 38 | ./framer -in final.rmc -out out ... <-- follow instructions for how to use the framer. 39 | 40 | ffmpeg -r 10 -i out/frame%06d.png -c:v png gps-overlay.mov 41 | 42 | Then kdenlive and splice the videofiles and put the overlay on top. 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /firmware/README.md: -------------------------------------------------------------------------------- 1 | What's here 2 | ================ 3 | This is the firmware 4 | 5 | The GPS thing, user manual 6 | -------------------------- 7 | # About 8 | # Usage -Recording 9 | # LED Indicators 10 | # Charging 11 | # Usage -Getting data out / removing files 12 | # The .RMC format 13 | 14 | 15 | 16 | About 17 | ----- 18 | The GPS thing is a GPS logger for racing which is very easy to use. 19 | It writes GPS locations to a file 10 times per second when recording. 20 | The format is a slightly mangled NMEA RMC message stream. 21 | It was made for Road Racing, to record data for video overlay. 22 | It is open-source, under the WTFPL. 23 | Technical expertise is required for building, charging and extracting data, 24 | but not for recording. 25 | 26 | 27 | 28 | Usage - Recording 29 | ----------------- 30 | Turn on, good idea to do it 10 minutes before, to get a solid fix, let it sit outside with clear view to sky. 31 | Press button to start recording, the red light should be only one on. 32 | Press button to stop recording. 33 | If turned off while recording, up to one minute of data may be lost (autosaves once per minute). 34 | Recording can be started with or without signal. 35 | 36 | 37 | 38 | LED indicators 39 | -------------- 40 | From top to bottom (button is below LED3). 41 | 42 | +-----+-----------+--------------------+---------------+----------------+ 43 | | | Solid on | 100 ms blinks | 500 ms blinks | 1000 ms blinks | 44 | +-----+-----------+--------------------+---------------+----------------+ 45 | |LED1 | Searching | Couldn't open | | | 46 | | | | file for recording | | | 47 | +-----+-----------+--------------------+---------------+----------------+ 48 | |LED2 | Found SAT | No free filename | | SD Card | 49 | | | | for recording | | Init error | 50 | +-----+-----------+--------------------+---------------+----------------+ 51 | |LED3 | Recording | | Autosaving | | 52 | | | | | failed | | 53 | +-----+-----------+--------------------+---------------+----------------+ 54 | # LED1+LED2 Solid = UART Comms mode. 55 | 56 | 57 | 58 | Charging 59 | -------- 60 | Wire to a single-cell liion charger: 61 | Gray - Negative 62 | Red - Positive 63 | 64 | 65 | 66 | Usage - Getting data out / removing files 67 | ----------------------------------------- 68 | Turn on while holding down the button. Both leds stay on, it is in uart mode. 69 | Wire up the UART (there is enough power on the uart to keep the MCU alive, so wire GND after turning on) 70 | Green wire: TTL UART TX 71 | Yello wire: TTL UART RX 72 | Black wire: Gnd for UART 73 | Connect with uart speed: 28800 (which is 9600x3) 74 | Press ? to view online help. 75 | Press d to list directory 76 | Press s to dump a file 77 | Press r to remove a file 78 | Backspace is not supporeted, if a typo is made, press enter and try again, unless pressing enter 79 | would cause you to delete a file, and you didn't want it deleted, in that case, type more to ensure an invalid filename. 80 | 81 | 82 | 83 | The .RMC format 84 | --------------- 85 | Files are named according to %.2X.RMC. 86 | There are no linebreaks in the file. 87 | Each message is terminated with a pipe ( | ). 88 | If one replaces all pipes with newlines, and prefixes all lines with "$GPRMC," (without "") 89 | then the file is a standard NMEA RMC message stream. 90 | -------------------------------------------------------------------------------- /goproextract/getNmeaFromJson.js: -------------------------------------------------------------------------------- 1 | const fn = process.argv[2]; 2 | if(!fn || fn === '--help' || fn === '-h') { 3 | console.error('Take a JSON gps stream and generate truncated NMEA sentences for use with the goFramer'); 4 | console.error('required: json file to transform'); 5 | process.exit(1); 6 | } 7 | const data = require(fn); 8 | /* 9 | 10 | { 11 | "1": { 12 | "streams": { 13 | "GPS5": { 14 | "samples": [ 15 | { 16 | "value": [ 17 | 56.3392667, 18 | 10.6802929, 19 | 16.05, 20 | 0, 21 | 0 22 | ], 23 | "cts": 41.542, 24 | "date": "2020-09-05T06:05:40.349Z", 25 | "sticky": { 26 | "fix": 0, 27 | "precision": 9999, 28 | "altitude system": "MSLV" 29 | } 30 | }, 31 | 32 | 33 | * */ 34 | 35 | let last = 0; 36 | let num=0; 37 | let active = 'V'; 38 | let str=''; 39 | let lastActive=''; 40 | let frameLength=0; 41 | let time=100; 42 | data['1'].streams.GPS5.samples.forEach( (sample, sampleIdx, samples)=>{ 43 | 44 | const now = new Date(sample.date); 45 | if(!last) { 46 | last = new Date(sample.date).getTime(); 47 | } else { 48 | 49 | last = now.getTime(); 50 | } 51 | num++; 52 | const delta = now.getTime() - last; 53 | const h = (now.getUTCHours()<10)?'0'+now.getUTCHours():now.getUTCHours(); 54 | const m = (now.getUTCMinutes()<10)?'0'+now.getUTCMinutes():now.getUTCMinutes(); 55 | const s = (now.getUTCSeconds()<10)?'0'+now.getUTCSeconds():now.getUTCSeconds(); 56 | const ms = Math.floor(now.getUTCMilliseconds()/10); 57 | const pms = (ms <10)? '0'+ms:ms; 58 | const rmcTime = `${h}${m}${s}.${pms}`; 59 | if(sample.sticky) { 60 | if(sample.sticky.fix === 0) { 61 | active = 'V'; 62 | } 63 | if(sample.sticky.fix > 0) { 64 | active = 'A'; 65 | } 66 | } 67 | 68 | 69 | const dlat = sample.value[0]; 70 | const dlon = sample.value[1]/2.0; 71 | 72 | const latd = Math.floor(dlat); 73 | const latm = (dlat - latd) * 60; 74 | 75 | 76 | const rmcLat = `${latd}${latm.toFixed(5)},N`; 77 | 78 | const lond = Math.floor(dlon); 79 | const lonm = (dlon - lond) * 60; 80 | 81 | const rmcLon = `${lond}${lonm.toFixed(5)},E`; 82 | 83 | 84 | // const rmcLat = `${dlat},N`; 85 | // const rmcLon = `${dlon},E`; 86 | 87 | const speed = (sample.value[3] * 3.6).toFixed(3); 88 | 89 | let day = now.getUTCDay(); 90 | if(day<10) { 91 | day = '0'+day; 92 | } 93 | let month = now.getUTCMonth(); 94 | if(month < 10) { 95 | month = '0'+month; 96 | } 97 | let year = ''+now.getUTCFullYear(); 98 | year = (year[2]+year[3]); 99 | const rmcDate = `${day}${month}${year}`; 100 | 101 | const rmcSpeed = (speed * 0.539957).toFixed(3); 102 | 103 | let rmc = `GPRMC,${rmcTime},${active},${rmcLat},${rmcLon},${rmcSpeed},,${rmcDate},,,A`; 104 | let chk = 0; 105 | for(let i = 0; i < rmc.length; i++) { 106 | chk ^= rmc.charCodeAt(i); 107 | } 108 | if(true || active === 'A') { 109 | lastActive = rmc.substring(6,rmc.lenght)+'*'+chk.toString(16).toUpperCase()+'|'; 110 | if( sampleIdx+1 < samples.length ) { 111 | frameLength = samples[sampleIdx+1].cts - sample.cts; 112 | } 113 | } 114 | 115 | // roughest downsampling to 10 hz. 116 | time += frameLength; 117 | if(time >= 100) { 118 | time -= 100; 119 | str += lastActive 120 | } 121 | 122 | }); 123 | 124 | console.log(str); 125 | -------------------------------------------------------------------------------- /firmware/oldhwport.ino: -------------------------------------------------------------------------------- 1 | // This is ported back to the prototype hardware: different pin usage, only has two LEDs, and has a pole switch instead of a momentary pushbutton. 2 | 3 | /* 4 | * Error codes, interval length in ms. 5 | * 6 | * LEDs alternate: 7 | * 100 - no more file names 8 | * 500 - saveAndOpen: couldn't open file for writing 9 | * 1000 - startRecord: couldn't open file for writing 10 | * 3000 - sd card init error 11 | * 12 | * Both LEDs at same time: 13 | * 100 - record button engaged on boot 14 | */ 15 | 16 | #include 17 | #include 18 | 19 | 20 | const int SDCSPIN = 10; 21 | 22 | const int SRCHPIN = 9; 23 | const int RECPIN = 6; 24 | 25 | const int BTNPIN = 7; 26 | 27 | 28 | 29 | inline int nmeaSum(const char *msg) 30 | { 31 | int checksum = 0; 32 | for (int i = 0; msg[i] && i < 32; i++) 33 | checksum ^= (unsigned char)msg[i]; 34 | 35 | return checksum; 36 | } 37 | 38 | void nmeaWrite(char* msg) { 39 | 40 | char checkStr[8]; 41 | snprintf(checkStr,7, "*%.2X", nmeaSum(msg)); 42 | Serial1.print("$"); 43 | Serial1.print(msg); 44 | Serial1.println(checkStr); 45 | } 46 | 47 | void nmeaDisable(char* nam) { 48 | char buf[32]; 49 | snprintf(buf, 31,"PUBX,40,%s,0,0,0,0", nam); 50 | nmeaWrite(buf); 51 | } 52 | 53 | const unsigned char UBLOX_SLOW_RATE[] PROGMEM = {0xB5,0x62,0x06,0x08,0x06,0x00,0xE8,0x03,0x01,0x00,0x01,0x00,0x01,0x39}; 54 | void setSlow() { 55 | for(unsigned int i = 0; i < sizeof(UBLOX_SLOW_RATE); i++) { 56 | Serial1.write( pgm_read_byte(UBLOX_SLOW_RATE+i) ); 57 | } 58 | } 59 | 60 | const unsigned char UBLOX_FAST_RATE[] PROGMEM = {0xB5,0x62,0x06,0x08,0x06,0x00,0x64,0x00,0x01,0x00,0x01,0x00,0x7A,0x12}; 61 | 62 | void setFast() { 63 | for(unsigned int i = 0; i < sizeof(UBLOX_FAST_RATE); i++) { 64 | Serial1.write( pgm_read_byte(UBLOX_FAST_RATE+i) ); 65 | } 66 | } 67 | 68 | 69 | 70 | void setup() { 71 | // put your setup code here, to run once: 72 | pinMode(SDCSPIN, OUTPUT); 73 | 74 | pinMode(SRCHPIN, OUTPUT); 75 | digitalWrite(SRCHPIN, LOW); 76 | 77 | 78 | pinMode(RECPIN, OUTPUT); 79 | digitalWrite(RECPIN, HIGH); 80 | 81 | pinMode(BTNPIN, INPUT); 82 | digitalWrite(BTNPIN, HIGH); 83 | 84 | while( digitalRead( BTNPIN ) == 1 ) 85 | { 86 | digitalWrite(SRCHPIN, LOW); 87 | digitalWrite(RECPIN, LOW); 88 | delay(100); 89 | digitalWrite(SRCHPIN, HIGH); 90 | digitalWrite(RECPIN, HIGH); 91 | delay(100); 92 | } 93 | 94 | 95 | if (!SD.begin(SDCSPIN)) { 96 | err(3000); 97 | } 98 | 99 | 100 | delay(50); 101 | 102 | 103 | // Serial.println("Gps?"); 104 | Serial1.begin(9600); 105 | // delay a bit 106 | delay(500); 107 | // Serial.println("Gps!"); 108 | setSlow(); 109 | nmeaDisable("VTG"); 110 | nmeaDisable("GGA"); 111 | nmeaDisable("GSA"); 112 | nmeaDisable("GSV"); 113 | nmeaDisable("GLL"); 114 | 115 | 116 | digitalWrite(SRCHPIN, LOW); 117 | delay(1000); 118 | digitalWrite(RECPIN, LOW); 119 | delay(1000); 120 | 121 | 122 | digitalWrite(SRCHPIN, HIGH); 123 | delay(1000); 124 | digitalWrite(RECPIN, HIGH); 125 | delay(500); 126 | 127 | } 128 | 129 | void err(int d) { 130 | // Enter infinite loop. 131 | while(1) { 132 | digitalWrite(SRCHPIN, LOW); 133 | digitalWrite(RECPIN, HIGH); 134 | delay(d); 135 | digitalWrite(SRCHPIN, HIGH); 136 | digitalWrite(RECPIN, LOW); 137 | delay(d); 138 | } 139 | } 140 | 141 | 142 | char buf[128]; 143 | uint8_t ci=0; 144 | int fileNum=0; 145 | int subFileNum=0; 146 | 147 | char recFileName[16]; 148 | File recFile; 149 | uint8_t recording=0; 150 | int sample=0; 151 | 152 | void saveAndReopen() { 153 | subFileNum++; 154 | snprintf(recFileName, 15,"%.2X.%.3X", fileNum, subFileNum); 155 | 156 | recFile.close(); 157 | recFile=SD.open(recFileName, FILE_WRITE); 158 | if(!recFile) { 159 | err(500); 160 | } 161 | } 162 | 163 | void startRecord() { 164 | recording=1; 165 | sample=0; 166 | ci=0; 167 | 168 | digitalWrite(RECPIN, LOW); 169 | 170 | 171 | for(uint8_t i=0; i < 256; i++) { 172 | 173 | snprintf(recFileName,15,"%.2X.000", i); 174 | 175 | if(!SD.exists(recFileName)) { 176 | fileNum=i; 177 | subFileNum=0; 178 | break; 179 | } 180 | 181 | if(i==255) { 182 | err(100); 183 | } 184 | } 185 | 186 | recFile=SD.open(recFileName, FILE_WRITE); 187 | 188 | if(!recFile) { 189 | err(1000); 190 | } 191 | 192 | 193 | 194 | while(Serial1.available()>0) { 195 | Serial1.read(); 196 | } 197 | setFast(); 198 | } 199 | 200 | void stopRecord() { 201 | digitalWrite(RECPIN, HIGH); 202 | setSlow(); 203 | recording=0; 204 | recFile.close(); 205 | 206 | } 207 | 208 | bool recPinState=0; 209 | 210 | void loop() { 211 | 212 | 213 | 214 | //Button 215 | if( digitalRead(BTNPIN) ) { 216 | //Serial.println("Btn push if"); 217 | if(!recording) { 218 | delay(100); 219 | if(digitalRead(BTNPIN)) { 220 | startRecord(); 221 | } 222 | } 223 | } else { 224 | if(recording) { 225 | delay(100); 226 | if(!digitalRead(BTNPIN)) { 227 | stopRecord(); 228 | } 229 | 230 | } 231 | } 232 | 233 | //GPS stuff 234 | while( Serial1.available() > 0 ) { 235 | char c=Serial1.read(); 236 | 237 | if(c==13) { 238 | //Just skip it (I guess this can't be optimized out, right?) 239 | } else if(c==10) { 240 | 241 | buf[ci]='|'; 242 | buf[ci+1]=0; 243 | 244 | //Got message, let's see if it's valid: 245 | //$GPRMC,,V,,,,,,,,,,N*53 246 | //$GPRMC,224439.00,V,,,,,,,120718,,,N*7A 247 | //$GPRMC,165328.00,A,5707.38417,N,00937.64974,E,0.041,,120718,,,A*7A 248 | if(!recording && ci>17) { 249 | if(buf[17]=='A') { 250 | digitalWrite(SRCHPIN,HIGH ); 251 | } else { 252 | digitalWrite(SRCHPIN,LOW ); 253 | } 254 | digitalWrite(RECPIN, recPinState); 255 | recPinState=!recPinState; 256 | 257 | } else if(recording && ci > 17) { 258 | if(buf[3]=='R' && buf[4]=='M' && buf[5]=='C') { 259 | recFile.write(buf+7, ci+1-7); 260 | 261 | sample++; 262 | if(sample==600) { 263 | sample=0; 264 | saveAndReopen(); 265 | } 266 | 267 | digitalWrite(RECPIN, recPinState); 268 | recPinState=!recPinState; 269 | } 270 | } 271 | ci=0; 272 | 273 | } else { 274 | buf[ci]=c; 275 | ci++; 276 | } 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /case/smallgps.scad: -------------------------------------------------------------------------------- 1 | // 2 | 3 | batLength=72; 4 | batDia=18.25; 5 | module battery() { 6 | rotate( [ 0,90,0 ] ) 7 | cylinder( batLength, batDia/2, batDia/2); 8 | } 9 | 10 | module batteries() { 11 | color( [0.5,0.5,1] ) { 12 | translate( [0,batDia/2+0.25,0 ] ) 13 | battery(); 14 | translate( [0,-batDia/2-0.25,0 ] ) 15 | battery(); 16 | } 17 | } 18 | 19 | 20 | 21 | gpsBoardWidth=26.4; 22 | gpsBoardHeight=35.7; 23 | gpsBoardDepth=3.7; 24 | gpsAntOffset=4.9; // from bottom 25 | gpsAntWidth=25.5; 26 | gpsAntHeight=25.5; 27 | gpsAntDepth=9.6; 28 | module gpsModule(antSpace) { 29 | 30 | color([1,1,0] ) 31 | difference() { 32 | cube([gpsBoardWidth, gpsBoardHeight, 1.3] ); 33 | 34 | translate( [1.5+1.3, 1+1.5, -1 ] ) 35 | cylinder(10, 1.5, 1.5); 36 | translate( [1.5+1.3, gpsBoardHeight-1.5-1.5, -1 ] ) 37 | cylinder(10, 1.5, 1.5); 38 | 39 | translate( [gpsBoardWidth-1.5-1.8, 1+1.5, -1 ] ) 40 | cylinder(10, 1.5, 1.5); 41 | translate( [gpsBoardWidth-1.5-1.8, gpsBoardHeight-1.5-1.5, -1 ] ) 42 | cylinder(10, 1.5, 1.5); 43 | 44 | } 45 | translate( [ 0, gpsAntOffset, 1.3 ] ) 46 | color([1,0,0] ) 47 | cube([gpsBoardWidth, gpsAntHeight, gpsAntDepth] ); 48 | 49 | if(antSpace) { 50 | translate( [ -2, gpsAntOffset-2, 1.3 ] ) 51 | color([1,1,0] ) 52 | cube([gpsBoardWidth+4, gpsAntHeight+4, gpsAntDepth+2] ); 53 | } 54 | } 55 | 56 | 57 | 58 | psuWidth=38.2; 59 | psuHeight=5.8; 60 | psuDepth=9.7; 61 | module psuPcb() { 62 | color([1,0,0]) 63 | cube( [psuWidth, psuHeight, psuDepth] ); 64 | } 65 | 66 | 67 | mcuWidth=33.1; 68 | mcuHeight=4.9; 69 | mcuDepth=18.3; 70 | 71 | mcuUsbOffset=5.4; 72 | mcuUsbHeight=3.5; 73 | mcuUsbDepth=8.5; 74 | 75 | module mcuPcb() { 76 | cube( [mcuWidth, mcuHeight, mcuDepth ] ); 77 | translate( mcuPcbOffset ) 78 | translate( [mcuWidth-1,0,mcuUsbOffset]) 79 | cube( [10, mcuUsbHeight, mcuUsbDepth ] ); 80 | } 81 | 82 | sdWidth=38.2; 83 | sdHeight=7; 84 | sdDepth=18; 85 | sdSlotOffset=1.6; 86 | sdSlotDepth=13.5; 87 | sdSlotHeight=2.7; 88 | module sdPcb() { 89 | cube( [sdWidth, sdHeight, sdDepth ] ); 90 | translate( sdOffset ) 91 | translate( [sdWidth-1,0,sdSlotOffset] ) 92 | cube( [ 10, sdSlotHeight, sdSlotDepth ] ); 93 | } 94 | 95 | swWidth=14; 96 | swHeight=13.05; 97 | swDepth=7; 98 | swDia=6; 99 | module switch() { 100 | color( [ 1,1,0 ] ) 101 | cube( [swWidth, swHeight, swDepth ] ); 102 | 103 | translate(swOffset) 104 | translate( [10, swHeight/2,swDepth/2 ] ) 105 | rotate( [0,90,0 ] ) 106 | cylinder( 10, swDia/2, swDia/2 ); 107 | } 108 | 109 | btnWidth=6.18; 110 | btnDepth=4; 111 | btnDia=3.8; 112 | module btn() { 113 | cube( [btnWidth, btnWidth, btnDepth] ); 114 | translate([ btnWidth/2, btnWidth/2, btnDepth]) 115 | cylinder( 10, btnDia/2, btnDia/2 ); 116 | } 117 | 118 | 119 | module gpsmnt(h,r) { 120 | 121 | translate( [1.5+1.3, 1+1.5, -1 ] ) 122 | cylinder(h, r, r); 123 | translate( [1.5+1.3, gpsBoardHeight-1.5-1.5, -1 ] ) 124 | cylinder(h, r, r); 125 | 126 | translate( [gpsBoardWidth-1.5-1.8, 1+1.5, -1 ] ) 127 | cylinder(h, r, r); 128 | translate( [gpsBoardWidth-1.5-1.8, gpsBoardHeight-1.5-1.5, -1 ] ) 129 | cylinder(h, r, r); 130 | 131 | } 132 | 133 | w=40; 134 | $fn=90; 135 | module topPlate() { 136 | 137 | translate([-4,-3,-3]) difference() { 138 | cube( [batLength, w, 3] ); 139 | translate([-1,2,1.4]) cube( [batLength+2, w-4, 3] ); 140 | 141 | translate([0,21,-3]) cylinder( 10,6,6 ); 142 | translate([batLength,w/2,-3]) cylinder( 10,6,6 ); 143 | 144 | 145 | } 146 | } 147 | //btn(); 148 | module batbox() { 149 | difference() { 150 | translate([0, -w/2,-batDia/2-0.3]) 151 | cube([batLength, w, batDia/2+0.3]); 152 | batteries(); 153 | } 154 | 155 | translate([0, -w/2,0]) 156 | cube([batLength, 1.4, batDia/2+0.2]); 157 | translate([0, -1.4+w/2,0]) 158 | cube([batLength, 1.4, batDia/2+0.2]); 159 | } 160 | 161 | module rails() { 162 | translate( [10,0,0] ) cube([1.5,1.5,21.725+15]); 163 | translate( [50,0,0] ) cube([1.5,1.5,21.725+15]); 164 | translate( [30,40-1.5,0] ) cube([1.5,1.5,21.725+15]); 165 | } 166 | 167 | module rails2() { 168 | 169 | translate( [10-0.03,0,0] ) cube([1.6,1.6,21.725+15]); 170 | 171 | translate( [50,0,0] ) cube([1.5,1.5,21.725+15]); 172 | 173 | translate( [50-0.03,0,0] ) cube([1.6,1.6,21.725+15]); 174 | 175 | translate( [30-0.03,40-1.6,0] ) cube([1.6,1.6,21.725+15]); 176 | 177 | } 178 | 179 | 180 | module middle() { 181 | difference() { 182 | topPlate(); 183 | translate([0,-3,-21.725]) rails2(); 184 | } 185 | 186 | translate([0,-0.5,-2]) gpsmnt(8, 1.5); 187 | translate([0,-0.5,-2]) gpsmnt(3+2, 2.0); 188 | } 189 | 190 | 191 | 192 | module bottom() { 193 | 194 | 195 | difference() { 196 | translate([-4,17,-12.3]) batbox(); 197 | translate([0,-3,-21.725]) rails(); 198 | 199 | } 200 | 201 | difference() { 202 | color([1,0,0]) translate([-6,-4,-12.3]) translate([-10,0,-10.425]) cube( [76+20,42, 1] ); 203 | studs(); 204 | } 205 | 206 | } 207 | 208 | 209 | //translate([-4,17,-12.3]) batteries(); 210 | 211 | //translate([0,-0.5,2.0 ]) gpsModule(false); 212 | 213 | // topPlate(); 214 | 215 | //translate([0,-0.5,2.0 ]) gpsModule(false); 216 | //translate([-6,-4,-21.725]) shell(); 217 | 218 | 219 | module shell() { 220 | cube( [76,42,36.9] ); 221 | translate([-10,0,0]) cube( [76+20,42, 5] ); 222 | 223 | } 224 | 225 | module studs() { 226 | translate([-11,5,-25]) cylinder(10,2.55, 2.55); 227 | translate([-11,30,-25]) cylinder(10,2.55, 2.55); 228 | translate([75,5,-25]) cylinder(10,2.55, 2.55); 229 | translate([75,30,-25]) cylinder(10,2.55, 2.55); 230 | } 231 | 232 | 233 | 234 | module top() { 235 | difference() { 236 | translate([-6,-4,-21.725]) shell(); 237 | translate([-4,-3,-21.725-1]) cube( [72,40,36.9] ); 238 | translate([0,-0.5,2.0 ]) gpsModule(true); 239 | translate([55,3,10.5]) btn(); 240 | translate([55+3, 42/2-6, 10.5]) cylinder(100,2.5,2.5); 241 | translate([55+3, 42/2-6+6, 10.5]) cylinder(100,2.5,2.5); 242 | translate([55+3, 42/2-6+6+6, 10.5]) cylinder(100,2.5,2.5); 243 | translate([54,22,3]) rotate([0,0,0]) switch(); 244 | studs(); 245 | 246 | } 247 | 248 | difference() { 249 | translate([-4,-3,-3]) difference() { 250 | color([1,0,0]) cube( [batLength, w, 18.1] ); 251 | translate([-1,1.5,-1]) cube( [batLength+2, w-3, 100] ); 252 | 253 | } 254 | 255 | 256 | translate([-4,-3,-3]) difference() { 257 | cube( [batLength, w, 3] ); 258 | translate([-1,1.5,-1]) cube( [batLength+2, w-3, 10] ); 259 | 260 | } 261 | } 262 | 263 | translate([0,-3,-21.725]) rails(); 264 | 265 | difference() { 266 | translate([0,-0.5,4.4]) gpsmnt(3+4+4.77, 2.0); 267 | translate([0,-0.5,-1]) gpsmnt(8, 1.55); 268 | } 269 | 270 | 271 | difference() { 272 | translate([55-1,2,12]) cube([btnWidth+2,btnWidth+2, 3.0]); 273 | translate([55,3,10.5]) btn(); 274 | } 275 | } 276 | 277 | 278 | // Here are the parts, render one at a time! 279 | // Remove the translations if you like.. just there because they look cool 280 | translate([100,0,0 ]) top(); 281 | //translate([0,50,0 ] ) middle(); 282 | //bottom(); 283 | 284 | -------------------------------------------------------------------------------- /firmware/main.ino: -------------------------------------------------------------------------------- 1 | 2 | #include 3 | #include 4 | #include 5 | 6 | 7 | 8 | const int SDCSPIN = 9; 9 | const int FOUNDPIN = 7; 10 | const int SRCHPIN = 6; 11 | const int RECPIN = 5; 12 | const int BTNPIN = 3; 13 | 14 | const int RXPIN = 8; // Receive = Yellow 15 | const int TXPIN = 4; // Transmit = Green 16 | /// GNDPIN = Black 17 | 18 | 19 | inline int nmeaSum(const char *msg) 20 | { 21 | int checksum = 0; 22 | for (int i = 0; msg[i] && i < 32; i++) 23 | checksum ^= (unsigned char)msg[i]; 24 | 25 | return checksum; 26 | } 27 | 28 | void nmeaWrite(char* msg) { 29 | 30 | char checkStr[8]; 31 | snprintf(checkStr,7, "*%.2X", nmeaSum(msg)); 32 | Serial1.print("$"); 33 | Serial1.print(msg); 34 | Serial1.println(checkStr); 35 | } 36 | 37 | void nmeaDisable(char* nam) { 38 | char buf[32]; 39 | snprintf(buf, 31,"PUBX,40,%s,0,0,0,0", nam); 40 | nmeaWrite(buf); 41 | } 42 | 43 | const unsigned char UBLOX_SLOW_RATE[] PROGMEM = {0xB5,0x62,0x06,0x08,0x06,0x00,0xE8,0x03,0x01,0x00,0x01,0x00,0x01,0x39}; 44 | void setSlow() { 45 | for(unsigned int i = 0; i < sizeof(UBLOX_SLOW_RATE); i++) { 46 | Serial1.write( pgm_read_byte(UBLOX_SLOW_RATE+i) ); 47 | } 48 | } 49 | 50 | const unsigned char UBLOX_FAST_RATE[] PROGMEM = {0xB5,0x62,0x06,0x08,0x06,0x00,0x64,0x00,0x01,0x00,0x01,0x00,0x7A,0x12}; 51 | 52 | void setFast() { 53 | for(unsigned int i = 0; i < sizeof(UBLOX_FAST_RATE); i++) { 54 | Serial1.write( pgm_read_byte(UBLOX_FAST_RATE+i) ); 55 | } 56 | } 57 | 58 | void showHelp(SoftwareSerial* ss) { 59 | ss->println("?=this sNAME=show rNAME=remove d=dir"); 60 | } 61 | 62 | File root; 63 | void setup() { 64 | // put your setup code here, to run once: 65 | pinMode(SDCSPIN, OUTPUT); 66 | 67 | pinMode(SRCHPIN, OUTPUT); 68 | digitalWrite(SRCHPIN, LOW); 69 | 70 | pinMode(FOUNDPIN, OUTPUT); 71 | digitalWrite(FOUNDPIN, LOW); 72 | 73 | pinMode(RECPIN, OUTPUT); 74 | digitalWrite(RECPIN, HIGH); 75 | 76 | pinMode(BTNPIN, INPUT); 77 | digitalWrite(BTNPIN, LOW); 78 | 79 | 80 | 81 | 82 | /*Serial.begin(9600); 83 | while (!Serial) { 84 | ; // wait for serial port to connect. Needed for native USB port only 85 | } 86 | */ 87 | //Serial.print("Initializing SD card..."); 88 | 89 | if (!SD.begin(SDCSPIN)) { 90 | // Serial.println("initialization failed!"); 91 | // while (1); 92 | 93 | err(FOUNDPIN, 1000); 94 | } 95 | 96 | delay(50); 97 | if(digitalRead(BTNPIN)) { 98 | pinMode(RXPIN, INPUT); 99 | pinMode(TXPIN, OUTPUT); 100 | SoftwareSerial ss(RXPIN, TXPIN); 101 | 102 | ss.begin(28800); 103 | showHelp(&ss); 104 | uint8_t mode=0; 105 | char buf[17]; 106 | uint8_t cur=0; 107 | while(1) { 108 | if(mode==0) { 109 | switch( ss.read() ) { 110 | case '?': 111 | showHelp(&ss); 112 | break; 113 | case 'd': 114 | root=SD.open("/"); 115 | root.rewindDirectory(); 116 | if(!root) { 117 | ss.println("ERR opening dir."); 118 | } 119 | while(1) { 120 | File entry=root.openNextFile(); 121 | if(!entry) { 122 | break; 123 | } 124 | ss.print(entry.name()); 125 | if (entry.isDirectory()) { 126 | ss.println("/"); 127 | } else { 128 | // files have sizes, directories do not 129 | ss.print("\t"); 130 | ss.println(entry.size(), DEC); 131 | } 132 | entry.close(); 133 | } 134 | root.close(); 135 | break; 136 | case 's': 137 | ss.print("Show:"); 138 | mode=1; 139 | cur=0; 140 | memset(buf,0,16); 141 | break; 142 | case 'r': 143 | ss.print("Remove:"); 144 | mode=2; 145 | cur=0; 146 | memset(buf,0,16); 147 | break; 148 | } 149 | } else if(mode==1) { 150 | if(ss.available()>0) { 151 | uint8_t c = ss.read(); 152 | if(c==10) { 153 | } else if(c==13) { 154 | buf[cur]=0; 155 | cur=0; 156 | mode=0; 157 | ss.println(); 158 | ss.print("<"); 159 | ss.print(buf); 160 | ss.println(">"); 161 | File f=SD.open(buf, FILE_READ); 162 | if(!f) { 163 | ss.println("ERR"); 164 | } else { 165 | memset(buf,0,17); 166 | while( f.read(buf, 16) > 0 ) { 167 | ss.print(buf); 168 | memset(buf,0,16); 169 | } 170 | 171 | f.close(); 172 | ss.println(""); 173 | 174 | } 175 | } else { 176 | if(cur==16) { 177 | cur=0; 178 | mode=0; 179 | ss.println("ERR"); 180 | } else { 181 | ss.print((char)c); 182 | buf[cur]=c; 183 | cur++; 184 | } 185 | } 186 | } 187 | 188 | } else if(mode==2) { 189 | if(ss.available()>0) { 190 | uint8_t c = ss.read(); 191 | if(c==10) { 192 | } else if(c==13) { 193 | buf[cur]=0; 194 | cur=0; 195 | mode=0; 196 | ss.println(); 197 | if(SD.remove(buf)) { 198 | ss.println("REMOVED"); 199 | } else { 200 | ss.println("ERR"); 201 | } 202 | } else { 203 | if(cur==16) { 204 | cur=0; 205 | mode=0; 206 | ss.println("ERR"); 207 | } else { 208 | ss.print((char)c); 209 | buf[cur]=c; 210 | cur++; 211 | } 212 | } 213 | } 214 | 215 | } 216 | 217 | } 218 | 219 | } 220 | 221 | 222 | // Serial.println("Gps?"); 223 | Serial1.begin(9600); 224 | // delay a bit 225 | delay(500); 226 | // Serial.println("Gps!"); 227 | setSlow(); 228 | nmeaDisable("VTG"); 229 | nmeaDisable("GGA"); 230 | nmeaDisable("GSA"); 231 | nmeaDisable("GSV"); 232 | nmeaDisable("GLL"); 233 | 234 | 235 | } 236 | 237 | void err(uint8_t pin, int d) { 238 | // Turn them all off 239 | digitalWrite(FOUNDPIN, HIGH); 240 | digitalWrite(SRCHPIN, HIGH); 241 | digitalWrite(RECPIN, HIGH); 242 | // Enter infinite loop. 243 | while(1) { 244 | digitalWrite(pin, LOW); 245 | delay(d); 246 | digitalWrite(pin, HIGH); 247 | delay(d); 248 | } 249 | } 250 | 251 | 252 | char buf[128]; 253 | uint8_t ci=0; 254 | 255 | char recFileName[16]; 256 | File recFile; 257 | uint8_t recording=0; 258 | int sample=0; 259 | 260 | void saveAndReopen() { 261 | recFile.close(); 262 | recFile=SD.open(recFileName, FILE_WRITE); 263 | if(!recFile) { 264 | err(RECPIN,500); 265 | } 266 | } 267 | 268 | void startRecord() { 269 | recording=1; 270 | sample=0; 271 | ci=0; 272 | 273 | for(uint8_t i=0; i < 256; i++) { 274 | 275 | snprintf(recFileName,15,"%.2X.rmc", i); 276 | 277 | if(!SD.exists(recFileName)) { 278 | break; 279 | } 280 | 281 | if(i==255) { 282 | err(FOUNDPIN,100); 283 | } 284 | } 285 | 286 | recFile=SD.open(recFileName, FILE_WRITE); 287 | 288 | if(!recFile) { 289 | err(SRCHPIN, 100); 290 | } 291 | 292 | digitalWrite(FOUNDPIN, HIGH); 293 | digitalWrite(SRCHPIN, HIGH); 294 | digitalWrite(RECPIN, LOW); 295 | 296 | 297 | while(Serial1.available()>0) { 298 | Serial1.read(); 299 | } 300 | setFast(); 301 | } 302 | 303 | void stopRecord() { 304 | digitalWrite(FOUNDPIN, HIGH); 305 | digitalWrite(SRCHPIN, HIGH); 306 | digitalWrite(RECPIN, HIGH); 307 | setSlow(); 308 | recording=0; 309 | recFile.close(); 310 | 311 | } 312 | 313 | void loop() { 314 | 315 | // Serial.println(digitalRead(BTNPIN)); 316 | 317 | //Button 318 | if( digitalRead(BTNPIN) ) { 319 | //Serial.println("Btn push if"); 320 | delay(500); 321 | while(digitalRead(BTNPIN)) { 322 | //Serial.println("Btn push while"); 323 | delay(1000); 324 | } 325 | 326 | if(recording) { 327 | Serial.println("Stop record"); 328 | stopRecord(); 329 | } else { 330 | Serial.println("Start record"); 331 | startRecord(); 332 | } 333 | } 334 | 335 | 336 | //GPS stuff 337 | while( Serial1.available() > 0 ) { 338 | char c=Serial1.read(); 339 | 340 | if(c==13) { 341 | //Just skip it (I guess this can't be optimized out, right?) 342 | } else if(c==10) { 343 | 344 | buf[ci]='|'; 345 | buf[ci+1]=0; 346 | 347 | //Got message, let's see if it's valid: 348 | //$GPRMC,,V,,,,,,,,,,N*53 349 | //$GPRMC,224439.00,V,,,,,,,120718,,,N*7A 350 | //$GPRMC,165328.00,A,5707.38417,N,00937.64974,E,0.041,,120718,,,A*7A 351 | if(!recording && ci>17) { 352 | if(buf[17]=='A') { 353 | digitalWrite(SRCHPIN,HIGH ); 354 | digitalWrite(FOUNDPIN,LOW); 355 | } else { 356 | digitalWrite(FOUNDPIN,HIGH); 357 | digitalWrite(SRCHPIN,LOW ); 358 | } 359 | } else if(recording && ci > 17) { 360 | if(buf[3]=='R' && buf[4]=='M' && buf[5]=='C') { 361 | recFile.write(buf+7, ci+1-7); 362 | 363 | sample++; 364 | if(sample==600) { 365 | sample=0; 366 | saveAndReopen(); 367 | } 368 | } 369 | } 370 | ci=0; 371 | 372 | } else { 373 | buf[ci]=c; 374 | ci++; 375 | } 376 | } 377 | } 378 | 379 | -------------------------------------------------------------------------------- /framer/main.go: -------------------------------------------------------------------------------- 1 | // License: WTFPL 2 | // This is not only my first Go program, I also made no effort to make it pretty, so, enjoy. 3 | // Build with: go build 4 | // Run with ./framer --help 5 | 6 | // Why framer? 7 | // Because it outputs frames. 8 | 9 | package main 10 | 11 | import ( 12 | "flag" 13 | "fmt" 14 | "image" 15 | "image/color" 16 | "image/png" 17 | "io/ioutil" 18 | "math" 19 | "os" 20 | "strings" 21 | "time" 22 | 23 | "container/ring" 24 | 25 | "github.com/adrianmo/go-nmea" 26 | "github.com/llgcode/draw2d" 27 | "github.com/llgcode/draw2d/draw2dimg" 28 | "github.com/llgcode/draw2d/draw2dkit" 29 | ) 30 | 31 | // Point - A point indeed 32 | type Point struct { 33 | x, y float64 34 | } 35 | 36 | // Line - Another representation of a line 37 | type Line struct { 38 | a, b Point 39 | } 40 | 41 | // I totally failed at implementing this again, so I just cargo-culted it from OSGG (and I probably didn't understand it back then either) 42 | // It's not good enough that I can get the actual point of intersection, but then again, I'd probably fail at interpolating to gain extra precision anyway.. 43 | func linesCross(a, b Line) bool { 44 | 45 | x0 := a.a.x 46 | y0 := a.a.y 47 | x1 := a.b.x 48 | y1 := a.b.y 49 | x2 := b.a.x 50 | y2 := b.a.y 51 | x3 := b.b.x 52 | y3 := b.b.y 53 | 54 | d := (x1-x0)*(y3-y2) - (y1-y0)*(x3-x2) 55 | if math.Abs(d) < 0.001 { 56 | return false 57 | } 58 | 59 | AB := ((y0-y2)*(x3-x2) - (x0-x2)*(y3-y2)) / d 60 | 61 | if AB > 0.0 && AB < 1.0 { 62 | CD := ((y0-y2)*(x1-x0) - (x0-x2)*(y1-y0)) / d 63 | if CD > 0.0 && CD < 1.0 { 64 | return true 65 | } 66 | } 67 | 68 | return false 69 | } 70 | 71 | func encode(frame *image.RGBA, curFrameNum int, outDir string) { 72 | f, err := os.Create(fmt.Sprintf("%s/frame%06d.png", outDir, curFrameNum)) 73 | if err != nil { 74 | panic(err) 75 | } 76 | 77 | if err := png.Encode(f, frame); err != nil { 78 | f.Close() 79 | panic(err) 80 | } 81 | 82 | if err := f.Close(); err != nil { 83 | panic(err) 84 | } 85 | } 86 | 87 | func toxy(m nmea.GPRMC, s float64) (x float64, y float64) { 88 | 89 | y = (180.0 + m.Latitude) / (s * 360.0) 90 | x = (90.0 - m.Longitude) / (s * 180.0) 91 | return 92 | } 93 | 94 | func dot(ctx draw2d.GraphicContext, x float64, y float64) { 95 | 96 | draw2dkit.Circle(ctx, x, y, 7) 97 | ctx.SetStrokeColor(color.RGBA{0, 0, 0, 255}) 98 | ctx.SetLineWidth(6) 99 | ctx.Stroke() 100 | 101 | draw2dkit.Circle(ctx, x, y, 4) 102 | ctx.SetStrokeColor(color.RGBA{255, 0, 0, 255}) 103 | ctx.SetLineWidth(4) 104 | ctx.Stroke() 105 | 106 | draw2dkit.Circle(ctx, x, y, 2) 107 | ctx.SetStrokeColor(color.RGBA{255, 0, 0, 255}) 108 | ctx.SetLineWidth(4) 109 | ctx.Stroke() 110 | 111 | } 112 | 113 | func outline(ctx draw2d.GraphicContext, x float64, y float64, col color.Color, text string, size float64) { 114 | 115 | x = x + 3 116 | y = y + 3 117 | ctx.SetFontSize(size) 118 | ctx.SetFillColor(color.RGBA{0, 0, 0, 255}) 119 | ctx.FillStringAt(text, x-3, y) 120 | ctx.FillStringAt(text, x, y-3) 121 | ctx.FillStringAt(text, x-3, y-3) 122 | ctx.FillStringAt(text, x+3, y-3) 123 | 124 | ctx.FillStringAt(text, x+3, y) 125 | ctx.FillStringAt(text, x, y+3) 126 | ctx.FillStringAt(text, x+3, y+3) 127 | ctx.FillStringAt(text, x-3, y+3) 128 | 129 | ctx.SetFillColor(col) 130 | ctx.FillStringAt(text, x, y) 131 | } 132 | 133 | var mapSize = 500.0 134 | 135 | func unsetMapCoords(ctx draw2d.GraphicContext) { 136 | ctx.Rotate((1.570796 * 2) * -1) 137 | ctx.Translate(-mapSize-10, -mapSize-10) 138 | } 139 | 140 | func setMapCoords(ctx draw2d.GraphicContext) { 141 | ctx.Translate(+mapSize+10, +mapSize+10) 142 | ctx.Rotate(1.570796 * 2) 143 | } 144 | 145 | func main() { 146 | fmt.Println("Framer") 147 | 148 | firstSample := flag.Int("first", 0, "Start at this sample (optional)") 149 | lastSample := flag.Int("last", -1, "Stop at this sample (optional)") 150 | 151 | firstTrackSample := flag.Int("trackFirst", 0, "Start track at this sample (optional)") 152 | lastTrackSample := flag.Int("trackLast", -1, "Stop track at this sample (optional)") 153 | 154 | showOutline := flag.Bool("outline", false, "show the map rendering outline (optional)") 155 | inFile := flag.String("in", "", "RMC file to read coordinates from (REQUIRED)") 156 | outDir := flag.String("out", "", "Directory in which to write frames, will overwrite! (REQUIRED)") 157 | onlyThisFrame := flag.Int("onlyFrame", -1, "Only output this frame (optional)") 158 | 159 | renderFrameNum := flag.Bool("showFrameNumber", false, "Add the frame (sample) number as red text at bottom right (optional)") 160 | finishLineSample := flag.Int("finishAt", 0, "Put a finish-line at this sample (optional)") 161 | finishLineAngle := flag.Float64("finishAngle", 0, "The angle of the finish line (if using finishLineAt) in degrees") 162 | finishLineWidth := flag.Float64("finishWidth", 15, "The width of the finish-line (tweak this)") 163 | 164 | firstLap := flag.Int("firstLap", 0, "Don't count crossing finishLine the first N times (optional)") 165 | lastLap := flag.Int("lastLap", 100, "Don't count crossing finishLine after N times (optional)") 166 | 167 | speedGraph := flag.Bool("speedGraph", true, "Show the speed-graph") 168 | 169 | userScaleX := flag.Float64("mapScaleX", 1.0, "Scale map in X direction (to undistort maps stretched too wide)") 170 | userScaleY := flag.Float64("mapScaleY", 1.0, "Scale map in Y direction (to undistort maps stretched too tall)") 171 | userMapSize := flag.Float64("mapSize", mapSize, "Single number, describes both width and height, map is square.)") 172 | mapSize := userMapSize 173 | 174 | flag.Parse() 175 | 176 | if *inFile == "" || *outDir == "" { 177 | fmt.Println("-in FILE and -out DIR are required, see --help for more info.") 178 | os.Exit(1) 179 | } 180 | 181 | const height = 1080.0 182 | const width = 1920.0 183 | 184 | const mapPosX = 5.0 185 | const mapPosY = 5.0 186 | 187 | const fps = 59.94006 188 | const frameTime = 1000.0 / fps 189 | const gpsHz = 10.0 190 | const framesPrGpsSample = gpsHz / fps 191 | 192 | videoTime := 0.0 193 | 194 | start := time.Now() 195 | 196 | data, err := ioutil.ReadFile(*inFile) 197 | 198 | if err != nil { 199 | panic(err) 200 | } 201 | 202 | text := string(data) 203 | 204 | arr := strings.Split(text, "|") 205 | 206 | fmt.Println("There are ", len(arr), " samples.") 207 | 208 | if *lastSample < 0 { 209 | *lastSample = len(arr) 210 | } 211 | 212 | fmt.Println("Using samples [", *firstSample, "..", *lastSample, "].") 213 | 214 | arr = arr[*firstSample:*lastSample] 215 | 216 | numSamples := len(arr) 217 | samples := make([](nmea.GPRMC), numSamples) 218 | 219 | if *firstTrackSample == -1 { 220 | *firstTrackSample = 0 221 | } 222 | if *lastTrackSample == -1 { 223 | *lastTrackSample = numSamples - 1 224 | } 225 | 226 | minX := width 227 | maxX := 0.0 228 | minY := height 229 | maxY := 0.0 230 | 231 | for idx, line := range arr { 232 | 233 | // To save space/time/wear, the firmware cuts the first part of the RMC message before writing it to the file on the SD card. 234 | // First thing, reconstruct the original message and use some nmea lib to parse it, because lazy 235 | origMsg := "$GPRMC," + line 236 | 237 | s, err := nmea.Parse(origMsg) 238 | if err == nil { 239 | 240 | m := s.(nmea.GPRMC) 241 | 242 | samples[idx] = m 243 | 244 | videoTime += 10.0 245 | 246 | if m.Validity == "A" { 247 | 248 | x, y := toxy(m, *mapSize) 249 | 250 | if x < minX { 251 | minX = x 252 | } 253 | if x > maxX { 254 | maxX = x 255 | } 256 | 257 | if y < minY { 258 | minY = y 259 | } 260 | if y > maxY { 261 | maxY = y 262 | } 263 | 264 | } 265 | 266 | } else { 267 | fmt.Println("Invalid sample ", idx, ":", line, "err:", err) 268 | } 269 | 270 | } 271 | 272 | frame := image.NewRGBA(image.Rectangle{Max: image.Point{X: width, Y: height}}) 273 | 274 | fgc := draw2dimg.NewGraphicContext(frame) 275 | 276 | maxSpeed := 0.0 277 | 278 | scaleX := (*mapSize / (maxX - minX)) * (*userScaleX) 279 | scaleY := (*mapSize / (maxY - minY)) * (*userScaleY) 280 | 281 | var finishLine Line 282 | 283 | fgc.BeginPath() 284 | 285 | for i, val := range samples { 286 | 287 | validSample := (val.Validity == "A") 288 | if validSample && i >= *firstTrackSample && i <= *lastTrackSample { 289 | 290 | x, y := toxy(val, *mapSize) 291 | 292 | x = (x - minX) * scaleX 293 | y = (y - minY) * scaleY 294 | 295 | if val.Speed > maxSpeed { 296 | maxSpeed = val.Speed 297 | } 298 | 299 | if i != 0 { 300 | fgc.LineTo(x, y) 301 | 302 | if i == *finishLineSample { 303 | flw := *finishLineWidth 304 | fla := *finishLineAngle 305 | 306 | a := Point{x + math.Cos(fla*0.0174532925)*flw, y + math.Sin(fla*0.0174532925)*flw} 307 | b := Point{x + math.Cos((fla+180)*0.0174532925)*flw, y + math.Sin((fla+180)*0.0174532925)*flw} 308 | finishLine = Line{a, b} 309 | } 310 | 311 | } else { 312 | fgc.MoveTo(x, y) 313 | } 314 | } 315 | } 316 | 317 | maxSpeed *= 1.852 318 | 319 | setMapCoords(fgc) 320 | 321 | path := fgc.GetPath() 322 | 323 | fgc.SetStrokeColor(color.RGBA{0, 0, 0, 0xff}) 324 | fgc.SetLineWidth(14) 325 | fgc.Stroke() 326 | 327 | fgc.SetStrokeColor(color.RGBA{255, 255, 255, 0xff}) 328 | fgc.SetLineWidth(5) 329 | 330 | fgc.Stroke(&path) 331 | 332 | if *showOutline { 333 | fgc.BeginPath() 334 | fgc.SetStrokeColor(color.RGBA{255, 0, 0, 0xff}) 335 | fgc.SetLineWidth(1) 336 | fgc.MoveTo(0, 0) 337 | fgc.LineTo(*mapSize, 0) 338 | fgc.LineTo(*mapSize, *mapSize) 339 | fgc.LineTo(0, *mapSize) 340 | fgc.LineTo(0, 0) 341 | fgc.Stroke() 342 | } 343 | 344 | if *finishLineSample > 0 { 345 | fgc.BeginPath() 346 | fgc.SetStrokeColor(color.RGBA{0, 0, 0, 0xff}) 347 | fgc.SetLineWidth(6) 348 | fgc.MoveTo(finishLine.a.x, finishLine.a.y) 349 | fgc.LineTo(finishLine.b.x, finishLine.b.y) 350 | fgc.Stroke() 351 | 352 | fgc.BeginPath() 353 | fgc.SetStrokeColor(color.RGBA{0, 0, 255, 0xff}) 354 | fgc.SetLineWidth(3) 355 | fgc.MoveTo(finishLine.a.x, finishLine.a.y) 356 | fgc.LineTo(finishLine.b.x, finishLine.b.y) 357 | fgc.Stroke() 358 | } 359 | 360 | // Setup text stuff 361 | fgc.SetFontData(draw2d.FontData{Name: "DejaVu", Family: draw2d.FontFamilySerif, Style: draw2d.FontStyleNormal}) 362 | 363 | // Save the background 364 | bgPix := make([]uint8, frame.Bounds().Max.X*frame.Bounds().Max.Y*4) 365 | copy(bgPix, frame.Pix) 366 | 367 | // Render frames 368 | unsetMapCoords(fgc) 369 | 370 | px := 0.0 371 | py := 0.0 372 | x := 0.0 373 | y := 0.0 374 | 375 | overFinish := 0 376 | curLap := 0 - *firstLap 377 | 378 | curSpeed := 0.0 379 | 380 | graphHeight := 150.0 381 | graphWidth := 400.0 382 | 383 | const numGraphSamples = 200 384 | speedRing := ring.New(numGraphSamples) 385 | 386 | for i := 0; i < numGraphSamples; i++ { 387 | speedRing.Value = graphHeight - 1 388 | speedRing = speedRing.Next() 389 | } 390 | 391 | // Final iteration 392 | for cf, val := range samples { 393 | 394 | validSample := (val.Validity == "A") 395 | 396 | // Extract 397 | if validSample { 398 | 399 | x, y = toxy(val, *mapSize) 400 | x = (x - minX) * scaleX 401 | y = (y - minY) * scaleY 402 | 403 | // Handle start line intersection 404 | if px != 0.0 && py != 0 { 405 | testLine := Line{Point{x, y}, Point{px, py}} 406 | 407 | if linesCross(finishLine, testLine) { 408 | if overFinish > 0 { 409 | //Stamp it, next! 410 | // Todo: Consider not doing that, and draw all the times every frame so we can slide them out as new times pop in. 411 | if curLap > -1 && curLap < *lastLap { 412 | 413 | ms := (cf - overFinish) * 100 414 | minutes := ms / 1000 / 60 415 | seconds := ms / 1000 % 60 416 | ms = ms % 1000 417 | ms = ms / 100 418 | 419 | copy(frame.Pix, bgPix) 420 | outline(fgc, width/2+750, 60+60*float64(curLap), color.RGBA{255, 255, 255, 255}, fmt.Sprintf("%0d:%02d.%d", minutes, seconds, ms), 40) 421 | copy(bgPix, frame.Pix) 422 | } 423 | curLap++ 424 | 425 | } 426 | overFinish = cf 427 | } 428 | } 429 | px = x 430 | py = y 431 | } 432 | 433 | // Render frames 434 | if *onlyThisFrame == -1 || cf == *onlyThisFrame { 435 | fmt.Printf("Frame: %d / %d\n", cf, numSamples) 436 | 437 | if validSample { 438 | 439 | copy(frame.Pix, bgPix) 440 | 441 | setMapCoords(fgc) 442 | dot(fgc, x, y) 443 | unsetMapCoords(fgc) 444 | // Display current speed 445 | curSpeed = val.Speed * 1.852 446 | outline(fgc, width/2-372/2+20, height-20, color.RGBA{255, 255, 255, 255}, fmt.Sprintf("%05.1f km/h", curSpeed), 70) 447 | 448 | //Display the speed-graph 449 | if *speedGraph { 450 | 451 | fgc.Translate(1510, 920) 452 | fgc.BeginPath() 453 | fgc.SetFillColor(color.RGBA{0, 0, 0, 64}) 454 | fgc.SetLineWidth(1) 455 | fgc.MoveTo(0, 0) 456 | fgc.LineTo(graphWidth, 0) 457 | fgc.LineTo(graphWidth, graphHeight) 458 | fgc.LineTo(0, graphHeight) 459 | fgc.LineTo(0, 0) 460 | path := fgc.GetPath() 461 | 462 | fgc.Fill() 463 | 464 | speedRing.Value = (curSpeed/maxSpeed)*-graphHeight + graphHeight 465 | speedRing = speedRing.Next() 466 | 467 | fgc.BeginPath() 468 | fgc.SetStrokeColor(color.RGBA{0xff, 0xff, 0xff, 0xff}) 469 | fgc.SetLineWidth(2) 470 | fgc.MoveTo(0, speedRing.Value.(float64)) 471 | 472 | sw := graphWidth / numGraphSamples 473 | for i := 0; i < numGraphSamples; i++ { 474 | fgc.LineTo(float64(i)*sw+sw, speedRing.Value.(float64)) 475 | speedRing = speedRing.Next() 476 | } 477 | fgc.Stroke() 478 | 479 | fgc.SetStrokeColor(color.RGBA{0, 0, 0, 0xff}) 480 | fgc.SetLineWidth(1) 481 | fgc.Stroke(&path) 482 | 483 | msg := fmt.Sprintf("%.1f", maxSpeed) 484 | fgc.SetFontSize(20) 485 | fgc.SetFillColor(color.RGBA{255, 255, 255, 255}) 486 | _, h, r, _ := fgc.GetStringBounds(msg) 487 | fgc.FillStringAt(msg, graphWidth-r-2, -h+2) 488 | 489 | fgc.Translate(-1510, -920) // I REGRET NOTHING! If it had a translation stack I'd not have to resort to such violence. 490 | } 491 | 492 | // Display current lap 493 | if overFinish > 0 { 494 | 495 | if curLap > -1 && curLap < *lastLap { 496 | 497 | outline(fgc, 10, height-20, color.RGBA{255, 255, 255, 255}, fmt.Sprintf("Lap %d", curLap), 40) 498 | ms := (cf - overFinish) * 100 499 | minutes := ms / 1000 / 60 500 | seconds := ms / 1000 % 60 501 | ms = ms % 1000 502 | ms = ms / 100 503 | 504 | outline(fgc, width/2+750, 60+60*float64(curLap), color.RGBA{255, 255, 255, 255}, fmt.Sprintf("%0d:%02d.%d", minutes, seconds, ms), 40) 505 | 506 | } 507 | 508 | } 509 | 510 | } 511 | 512 | // Display frame number 513 | if *renderFrameNum { 514 | msg := fmt.Sprint(cf) 515 | fgc.SetFontSize(20) 516 | fgc.SetFillColor(color.RGBA{255, 0, 0, 255}) 517 | fgc.FillStringAt(msg, 10, height-6) 518 | } 519 | 520 | encode(frame, cf, *outDir) 521 | } 522 | 523 | } 524 | 525 | stop := time.Now() 526 | 527 | dur := stop.Sub(start) 528 | fmt.Printf("Program ran for %d ms\n", dur.Nanoseconds()/1000000) 529 | 530 | } 531 | --------------------------------------------------------------------------------