package aprs import ( "fmt" "io" "math" "regexp" "strconv" "strings" "time" ) type Position struct { HasMessaging bool `json:"has_messaging"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` Altitude float64 `json:"altitude,omitempty"` // Altitude (in meters) Range float64 `json:"range,omitempty"` // Radio range (in meters) IsCompressed bool `json:"is_compressed"` CompressedGPSFix uint8 `json:"-"` CompressedNMEASource uint8 `json:"-"` CompressedOrigin uint8 `json:"-"` Time time.Time `json:"time"` Comment string `json:"comment"` Symbol string `json:"symbol"` Velocity *Velocity `json:"velocity,omitempty"` // Velocity encoded in the payload. Wind *Wind `json:"wind,omitempty"` // Wind direction and speed. PHG *PowerHeightGain `json:"phg,omitempty"` DFS *OmniDFStrength `json:"dfs,omitempty"` Weather *Weather `json:"weather,omitempty"` Telemetry *Telemetry `json:"telemetry,omitempty"` // Telemetry data } func (pos *Position) String() string { return "position" } // Velocity details. type Velocity struct { Course int // degrees Speed float64 // meters per second } // Wind details. type Wind struct { Direction float64 // degrees Speed float64 // meters per second } // PowerHeightGain details. type PowerHeightGain struct { PowerCode byte HeightCode byte GainCode byte DirectivityCode byte } // OmniDFStrength contains the omni-directional direction finding signal strength (for fox hunting). type OmniDFStrength struct { StrengthCode byte HeightCode byte GainCode byte DirectivityCode byte } type positionDecoder struct{} func (d positionDecoder) CanDecode(frame *Frame) bool { switch frame.Raw.Type() { case '!', '=', '/', '@': // compressed/uncompressed with/without messaging return true default: return false } } func (d positionDecoder) Decode(frame *Frame) (data Data, err error) { var ( raw = frame.Raw kind = raw.Type() hasTimestamp = kind == '@' || kind == '/' hasMessaging = kind == '@' || kind == '=' content = string(raw[1:]) pos = &Position{ HasMessaging: hasMessaging, } ) if hasTimestamp { if len(content) < 7 { return nil, io.ErrUnexpectedEOF } if pos.Time, _, err = parseTimestamp(content[:7]); err != nil { return } content = content[7:] } if err = pos.parsePositionAndComment(content); err != nil { return } return pos, nil } func (pos *Position) parsePositionAndComment(s string) (err error) { var comment string //log.Printf("parse position and comment %q: %t", s, isDigit(s[0])) if isDigit(s[0]) { // probably not compressed if comment, err = pos.parseNotCompressedPosition(s); err != nil { return } pos.IsCompressed = false } else { // probably compressed if comment, err = pos.parseCompressedPosition(s); err != nil { return } pos.IsCompressed = true } if len(comment) > 0 { if err = pos.parseAltitudeWeatherAndExtension(comment); err != nil { return } } return } func (pos *Position) parseCompressedPosition(raw string) (comment string, err error) { if len(raw) < 10 { return "", fmt.Errorf("aprs: invalid compressed position string of length %d", len(raw)) } pos.Symbol = string([]byte{raw[0], raw[9]}) var lat, lng int if lat, err = base91Decode(raw[1:5]); err != nil { return } if lng, err = base91Decode(raw[5:9]); err != nil { return } pos.Latitude = 90.0 - float64(lat)/380926.0 pos.Longitude = -180.0 + float64(lng)/190463.0 pos.IsCompressed = true comment = raw[13:] if len(comment) >= 3 { // Compression Type (T) Byte Format // // Bit: 7 | 6 | 5 | 4 3 | 2 1 0 | // -------+--------+---------+-------------+------------------+ // Unused | Unused | GPS Fix | NMEA Source | Origin | // -------+--------+---------+-------------+------------------+ // Val: 0 | 0 | 0 = old | 00 = other | 000 = Compressed | // | | 1 = cur | 01 = GLL | 001 = TNC BTex | // | | | 10 = CGA | 010 = Software | // | | | 11 = RMC | 011 = [tbd] | // | | | | 100 = KPC3 | // | | | | 101 = Pico | // | | | | 110 = Other | // | | | | 111 = Digipeater | var ( c = comment[0] - 33 s = comment[1] - 33 T = comment[2] - 33 ) if raw[10] == ' ' { // Don't do any further processing } else if ((T >> 3) & 3) == 2 { // CGA sentence, NMEA Source = 0b10 var altitudeFeet int if altitudeFeet, err = base91Decode(comment[:2]); err != nil { return } pos.Altitude = feetToMeters(float64(altitudeFeet)) pos.CompressedGPSFix = (T >> 5) & 0x01 pos.CompressedNMEASource = (T >> 3) & 0x03 pos.CompressedOrigin = T & 0x07 comment = comment[3:] } else if comment[0] >= '!' && comment[0] <= 'z' { // !..z // Course/speed pos.Velocity = &Velocity{ Course: int(c) * 4, Speed: knotsToMetersPerSecond(math.Pow(1.08, float64(s))), } pos.CompressedGPSFix = (T >> 5) & 0x01 pos.CompressedNMEASource = (T >> 3) & 0x03 pos.CompressedOrigin = T & 0x07 comment = comment[3:] } else if comment[0] == '{' { // { // Precalculated range pos.Range = milesToMeters(2 * math.Pow(1.08, float64(s))) comment = comment[3:] } } return } func (pos *Position) parseNotCompressedPosition(s string) (comment string, err error) { //log.Printf("parse not compressed: %q", s) if len(s) < 19 { return "", fmt.Errorf("aprs: invalid not compressed position string of length %d", len(s)) } pos.Symbol = string([]byte{s[8], s[18]}) if pos.Latitude, err = parseDMLatitude(s[:8]); err != nil { return } if pos.Longitude, err = parseDMLongitude(s[9:18]); err != nil { return } return s[19:], nil } var ( matchVelocity = regexp.MustCompile(`^[. 0-3][. 0-9]{2,2}/[. 0-9]{3}`) matchPHG = regexp.MustCompile(`^PHG[0-9]{3,3}[0-8]`) matchDFS = regexp.MustCompile(`^DFS[0-9]{3,3}[0-8]`) matchRNG = regexp.MustCompile(`^RNG[0-9]{4}`) matchAreaObject = regexp.MustCompile(``) ) func (pos *Position) parseAltitudeWeatherAndExtension(s string) (err error) { //log.Printf("parse altitude, weather and extensions %q", s) var comment string // Parse extensions switch { case matchVelocity.MatchString(s): var course, speed int if course, err = strconv.Atoi(fillZeros(s[:3])); err != nil { return fmt.Errorf("invalid course: %v", err) } if speed, err = strconv.Atoi(fillZeros(s[4:7])); err != nil { return fmt.Errorf("invalid speed: %v", err) } pos.Velocity = &Velocity{ Course: int(course), Speed: knotsToMetersPerSecond(float64(speed)), } comment = s[7:] if len(comment) > 2 && comment[0] == '/' && isDigit(comment[1]) && isDigit(comment[2]) { var dir int if dir, err = strconv.Atoi(fillZeros(comment[1:4])); err != nil { return fmt.Errorf("invalid wind direction: %v", err) } if speed, err = strconv.Atoi(fillZeros(comment[5:8])); err != nil { return fmt.Errorf("invalid wind speed: %v", err) } pos.Wind = &Wind{ Direction: float64(dir), Speed: knotsToMetersPerSecond(float64(speed)), } comment = comment[8:] } case matchPHG.MatchString(s): comment = s[7:] case matchDFS.MatchString(s): comment = s[7:] case matchRNG.MatchString(s): comment = s[7:] default: comment = s } //log.Printf("after extensions: %q", comment) if pos.Altitude, comment, err = parseAltitude(comment); err != nil { return } //log.Printf("after altitude, before weather: %q", comment) if pos.Weather, comment, err = parseWeather(comment); err != nil { return } //log.Printf("after weather, before telemetry: %q", comment) if pos.Telemetry, comment, err = parseBase91Telemetry(comment); err != nil { return } pos.Comment = comment return } func parseAltitude(s string) (altitude float64, comment string, err error) { const altitudeMarker = "/A=" if i := strings.Index(s, altitudeMarker); i >= 0 { //log.Printf("parse altitude marker: %q", s[i+3:]) var feet int if feet, err = strconv.Atoi(strings.TrimSpace(s[i+3 : i+3+6])); err != nil { return 0, "", fmt.Errorf("aprs: invalid altitude: %v", err) } return feetToMeters(float64(feet)), s[:i] + s[i+3+6:], nil } return 0, s, nil } var weatherSymbolSize = map[byte]int{ 'g': 3, // peak wind speed in the past 5 minutes, in mph 't': 3, // temperature in degrees Fahrenheit 'r': 3, // rainfall in hundredths of an inch 'p': 3, // rainfall in hundredths of an inch 'P': 3, // rainfall in hundredths of an inch 'h': 2, // relative humidity in % 'b': 5, // barometric pressure in tenths of millibars 'l': 3, // luminosity (in Watts per square meter) 1000 and above 'L': 3, // luminosity (in Watts per square meter) 999 and below 's': 3, // snowfall in the last 24 hours in inches '#': 3, // raw rain counter } // Weather report. type Weather struct { WindGust float64 `json:"windGust"` // wind gust in m/s Temperature float64 `json:"temperature"` // temperature (in degrees C) Rain1h float64 `json:"rain1h"` // rain in the last hour (in mm) Rain24h float64 `json:"rain24h"` // rain in the last day (in mm) RainSinceMidnight float64 `json:"rainSinceMidnight"` // rain since midnight (in mm) RainRaw int `json:"rainRaw"` // rain raw counter Humidity float64 `json:"humidity"` // relative humidity (in %) Pressure float64 `json:"pressure"` // pressure (in mBar) Luminosity float64 `json:"luminosity"` // luminosity (in W/m^2) Snowfall float64 `json:"snowfall"` // snowfall (in cm/day) } func parseWeather(s string) (weather *Weather, comment string, err error) { comment = s for len(comment) > 0 { if size, ok := weatherSymbolSize[comment[0]]; ok { if len(comment[1:]) < size { return nil, "", fmt.Errorf("aprs: not enough characters to encode weather symbol %c (%d < %d)", comment[0], len(comment[1:]), size) } var value float64 if value, err = strconv.ParseFloat(comment[1:size+1], 64); err != nil { // Something else that started with a weather symbol, perhaps a comment, stop parsing err = nil break } if weather == nil { weather = new(Weather) } switch comment[0] { case 'g': weather.WindGust = value * 0.4470 case 't': weather.Temperature = fahrenheitToCelcius(value) case 'r': weather.Rain1h = value * 0.254 case 'p': weather.Rain24h = value * 0.254 case 'P': weather.RainSinceMidnight = value * 0.254 case 'h': weather.Humidity = value case 'b': weather.Pressure = value / 10 case 'l': weather.Luminosity = value + 1000 case 'L': weather.Luminosity = value case 's': weather.Snowfall = value * 2.54 case '#': weather.RainRaw = int(value) } comment = comment[1+size:] } else { break } } return } type Telemetry struct { ID int Analog []int Digital []bool } var matchTelemetry = regexp.MustCompile(`\|([!-{]{4,14})\|`) func parseBase91Telemetry(s string) (telemetry *Telemetry, comment string, err error) { var i int if i = strings.IndexByte(s, '|'); i == -1 { return nil, s, nil } var sequence string comment, sequence = s[:i], s[i+1:] if i = strings.IndexByte(sequence, '|'); i < 1 { // no closing | found, return as comment return nil, s, nil } if sequence, comment = sequence[:i], comment+sequence[i+1:]; len(sequence)%2 != 0 { // uneven number of sequence elements, return nil, s, nil } telemetry = new(Telemetry) if telemetry.ID, err = base91Decode(sequence[:2]); err != nil { // it wasn't base-91 encoded telemetry, return data as comment return nil, s, nil } var values []int for i = 2; i < len(sequence); i += 2 { var value int if value, err = base91Decode(sequence[i : i+2]); err != nil { // it wasn't base-91 encoded telemetry, return data as comment return nil, s, nil } values = append(values, value) } if len(values) > 5 { for i = 0; i < 8; i++ { telemetry.Digital = append(telemetry.Digital, (values[5]&1) == 1) values[5] >>= 1 } values = values[:5] } telemetry.Analog = values return } func parseDMLatitude(s string) (v float64, err error) { if len(s) != 8 || s[4] != '.' || !(s[7] == 'N' || s[7] == 'S') { return 0, fmt.Errorf("aprs: invalid latitude %q", s) } s = strings.Replace(s, " ", "0", -1) // position ambiguity var ( degs, mins, minFrags int south = s[7] == 'S' ) if degs, err = strconv.Atoi(s[:2]); err != nil { return } if mins, err = strconv.Atoi(s[2:4]); err != nil { return } if minFrags, err = strconv.Atoi(s[5:7]); err != nil { return } v = float64(degs) + float64(mins)/60 + float64(minFrags)/6000 if south { return -v, nil } return v, nil } func parseDMLongitude(s string) (v float64, err error) { if len(s) != 9 || s[5] != '.' || !(s[8] == 'W' || s[8] == 'E') { return 0, fmt.Errorf("aprs: invalid longitude %q", s) } s = strings.Replace(s, " ", "0", -1) // position ambiguity var ( degs, mins, minFrags int east = s[8] == 'E' ) if degs, err = strconv.Atoi(s[:3]); err != nil { return } if mins, err = strconv.Atoi(s[3:5]); err != nil { return } if minFrags, err = strconv.Atoi(s[6:8]); err != nil { return } v = float64(degs) + float64(mins)/60 + float64(minFrags)/6000 if east { return v, nil } return -v, nil }