package aprs import ( "errors" "fmt" "math" "strconv" "strings" "time" ) var ( // ErrInvalidPacket signals a corrupted/unknown APRS packet. ErrInvalidPacket = errors.New("aprs: invalid packet") // ErrInvalidPosition signals a corrupted APRS position report. ErrInvalidPosition = errors.New("aprs: invalid position") ) // Payload is the raw payload contained within an APRS packet. type Payload string // Type of payload. func (p Payload) Type() DataType { var t DataType if len(p) > 0 { t = DataType(p[0]) } return t } func isDigit(b byte) bool { return b >= '0' && b <= '9' } func (p Payload) Len() int { return len(p) } // Velocity details. type Velocity struct { Course float64 // Degrees Speed float64 // Knots } // Wind details. type Wind struct { Direction float64 // Degrees Speed float64 // Knots } // PowerHeightGain details. type PowerHeightGain struct { PowerCode byte HeightCode byte GainCode byte DirectivityCode byte } // Power level (in Watts). func (p PowerHeightGain) Power() int { w := int(p.PowerCode - '0') if w <= 0 { return 0 } return w * w } // Height above ground (in meters). func (p PowerHeightGain) Height() float64 { h := float64(p.HeightCode - '0') if h <= 0 { return 10 } return math.Pow(2, h) * 10 } // Gain level (in dBs). func (p PowerHeightGain) Gain() int { d := int(p.GainCode - '0') if d <= 0 { return 0 } return d } // Directivity angle. func (p PowerHeightGain) Directivity() float64 { d := int(p.DirectivityCode - '0') if d <= 0 { return 0 } return float64(d%8) * 45.0 } // OmniDFStrength contains the omni-directional direction finding signal strength (for fox hunting). type OmniDFStrength struct { StrengthCode byte HeightCode byte GainCode byte DirectivityCode byte } // Strength of the signal. func (o OmniDFStrength) Strength() int { w := int(o.StrengthCode - '0') if w <= 0 { return 0 } return w * w } // Height above ground (in meters). func (o OmniDFStrength) Height() float64 { h := float64(o.HeightCode - '0') if h <= 0 { return 10 } return math.Pow(2, h) * 10 } // Gain level (in dBs). func (o OmniDFStrength) Gain() int { d := int(o.GainCode - '0') if d <= 0 { return 0 } return d } // Directivity angle. func (o OmniDFStrength) Directivity() float64 { d := int(o.DirectivityCode - '0') if d <= 0 { return 0 } return float64(d%8) * 45.0 } // Packet contains an APRS packet. type Packet struct { // Raw packet (as captured from the air or APRS-IS). Raw string `json:"raw"` // Src is the source address. Src Address `json:"src"` // Dst is the destination address. Dst Address `json:"dst"` // Path contains the digipeater path. Path Path `json:"path,omitempty"` // Payload is the raw payload. Payload Payload `json:"payload"` // Position encoded in the payload. Position *Position `json:"position,omitempty"` // Time encoded in the payload. Time *time.Time `json:"time,omitempty"` // Altitude encoded in the payload (in feet). Altitude float64 `json:"altitude,omitempty"` // Velocity encoded in the payload. Velocity *Velocity `json:"velocity,omitempty"` // Wind details encoded in the payload. Wind *Wind `json:"wind,omitempty"` // PHG are the power, height and gain details encoded in the payload. PHG *PowerHeightGain `json:"phg,omitempty"` // DFS are the direction finder strength details encoded in the payload. DFS *OmniDFStrength `json:"dfs,omitempty"` // Range encoded in the payload (in miles). Range float64 `json:"range,omitempty"` // Symbol encoded in the payload. Symbol Symbol `json:"symbol"` // Comment encoded in the payload. Comment string `json:"comment,omitempty"` // Unparsed data. data string } // ParsePacket parses an APRS packet as captured from AX.25 or APRS-IS. func ParsePacket(raw string) (Packet, error) { p := Packet{Raw: raw} var i int if i = strings.Index(raw, ":"); i < 0 { return p, ErrInvalidPacket } p.Payload = Payload(raw[i+1:]) // Parse src, dst and path var err error var a = raw[:i] if i = strings.Index(a, ">"); i < 0 { return p, ErrInvalidPacket } if p.Src, err = ParseAddress(a[:i]); err != nil { return p, err } var r = strings.Split(a[i+1:], ",") if p.Dst, err = ParseAddress(r[0]); err != nil { return p, err } if p.Path, err = ParsePath(strings.Join(r[1:], ",")); err != nil { return p, err } // Post processing of payload err = p.parse() return p, err } func (p *Packet) parse() error { s := string(p.Payload) //log.Printf("parse %q [%c]\n", s, p.Payload.Type()) switch p.Payload.Type() { case '!': // Lat/Long Position Report Format — without Timestamp var o = strings.IndexByte(s, '!') pos, txt, err := ParsePosition(s[o+1:], !isDigit(s[o+1])) if err != nil { return err } p.Position = &pos p.data = txt if len(s) >= 20 { p.Symbol[0] = s[9] p.Symbol[1] = s[19] } case '=': compressed := IsValidCompressedSymTable(s[1]) pos, txt, err := ParsePosition(s[1:], compressed) if err != nil { return err } p.Position = &pos p.data = txt if compressed { p.Symbol[0] = s[1] p.Symbol[1] = s[10] } else { p.Symbol[0] = s[9] p.Symbol[1] = s[19] } case '/', '@': // Lat/Long Position Report Format — with Timestamp if len(s) < 8 { return ErrInvalidPosition } var compressed bool if s[7] == 'h' || s[7] == 'z' || s[7] == '/' { if ts, err := ParseTime(s[1:]); err == nil { p.Time = &ts } compressed = IsValidCompressedSymTable(s[8]) pos, txt, err := ParsePosition(s[8:], compressed) if err != nil { return err } p.Position = &pos p.data = txt } else if s[7] >= '0' && s[7] <= '9' { ts, err := ParseTime(s[1:]) if err != nil { return err } p.Time = &ts compressed = IsValidCompressedSymTable(s[10]) pos, txt, err := ParsePosition(s[10:], compressed) if err != nil { return err } p.Position = &pos p.data = txt } if compressed { p.Symbol[0] = s[8] p.Symbol[1] = s[17] } else { p.Symbol[0] = s[16] p.Symbol[1] = s[26] } case ';': pos, txt, err := ParsePosition(s[18:], !isDigit(s[18])) if err != nil { return err } p.Position = &pos p.data = txt case '[': pos, txt, err := ParsePositionGrid(s[1:]) if err != nil { return err } p.Position = &pos p.data = txt case '`', '\'': pos, err := ParseMicE(s, p.Dst.Call) if err != nil { return err } p.Position = &pos p.parseMicEData() return nil // there is no additional data to parse default: pos, txt, err := ParsePositionBoth(s) if err != nil { if err != ErrInvalidPosition { return err } p.Comment = s[1:] } else { p.Position = &pos p.Comment = txt } } if p.Position != nil { if p.Position.Compressed { return p.parseCompressedData() } return p.parseData() } return nil } func (p *Packet) parseMicEData() error { // APRS PROTOCOL REFERENCE 1.0.1 Chapter 10, page 42 in PDF s := string(p.Payload) // Mic-E Message Type var mt []string var t string for i := 0; i < 3; i++ { mc := miceCodes[rune(p.Dst.Call[i])][1] if strings.HasSuffix(mc, "(Custom)") { t = messageTypeCustom } else if strings.HasSuffix(mc, "(Std)") { t = messageTypeStd } mt = append(mt, string(mc[0])) } switch t { case messageTypeStd: mt = append(mt, " (Std)") case messageTypeCustom: mt = append(mt, " (Custom)") } p.Comment = miceMsgTypes[strings.Join(mt, "")] // Speed and Course. speed := float64(int(s[4])-28) * 10 dc := float64(int(s[5])-28) / 10 unit := float64(int(dc)) speed += unit course := dc - unit course += float64(int(s[6]) - 28) if speed >= 800 { speed -= 800 } speed = 1.852 * speed // convert speed from knots to km/h if course >= 400 { course -= 400 } // Symbol p.Symbol[0] = s[7] p.Symbol[1] = s[8] p.Comment += fmt.Sprintf(" (%.fkm/h, %.f°)", speed, course) // Check whether there's additional Telemetry or Status Text data. if len(s) == 9 { return nil } if s[9] == ',' || s[9] == '\x1d' { // TODO: Parse telemetry data. return nil } // Parse MicE Status Text data. // TODO: Parse additional data in the Status Text data: // - Actual (custom) text message // - Maidenhead locator // - Altitude return nil } func (p *Packet) parseCompressedData() error { // Parse csT bytes if len(p.data) >= 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 | cb := p.data[0] - 33 sb := p.data[1] - 33 Tb := p.data[2] - 33 if p.data[0] != ' ' && ((Tb>>3)&3) == 2 { // CGA sentence, NMEA Source = 0b10 d, err := base91Decode(p.data[0:2]) if err != nil { return err } p.Altitude = math.Pow(1.002, float64(d)) p.Comment = p.data[3:] } else if cb <= 89 { // !..z // Course/Speed p.Velocity.Course = float64(cb) * 4.0 p.Velocity.Speed = math.Pow(1.08, float64(sb)) - 1.0 } else if cb == 90 { // { // Pre-Calculated Radio Range p.Range = 2 * math.Pow(1.08, float64(sb)) } } return nil } func (p *Packet) parseData() error { switch { case len(p.data) >= 1 && p.data[0] == ' ': p.Comment = p.data[1:] case len(p.data) >= 7 && strings.HasPrefix(p.data, "PHG"): p.PHG.PowerCode = p.data[3] p.PHG.HeightCode = p.data[4] p.PHG.GainCode = p.data[5] p.PHG.DirectivityCode = p.data[6] p.Range = math.Sqrt(2 * p.PHG.Height() * math.Sqrt((float64(p.PHG.Power())/10)*(float64(p.PHG.Gain())/2))) p.Comment = p.data[7:] case len(p.data) >= 7 && strings.HasPrefix(p.data, "RNG"): var err error p.Range, err = strconv.ParseFloat(p.data[3:7], 64) if err != nil { return err } p.Comment = p.data[7:] case len(p.data) >= 7 && strings.HasPrefix(p.data, "DFS"): p.DFS.StrengthCode = p.data[3] p.DFS.HeightCode = p.data[4] p.DFS.GainCode = p.data[5] p.DFS.DirectivityCode = p.data[6] p.Comment = p.data[7:] } return nil } func (p Payload) Time() (time.Time, error) { switch p.Type() { case '/', '@': return ParseTime(string(p)[1:]) default: return time.Time{}, nil } }