500 lines
14 KiB
Go
500 lines
14 KiB
Go
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
|
|
}
|