336 lines
8.3 KiB
Go
336 lines
8.3 KiB
Go
package aprs
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"strings"
|
|
)
|
|
|
|
type MicE struct {
|
|
HasMessaging bool `json:"has_messaging"`
|
|
Latitude float64 `json:"latitude"`
|
|
Longitude float64 `json:"longitude"`
|
|
Altitude float64 `json:"altitude,omitempty"` // Altitude (in meters)
|
|
Comment string `json:"comment"`
|
|
Symbol string `json:"symbol"`
|
|
Velocity *Velocity `json:"velocity,omitempty"` // Velocity encoded in the payload.
|
|
Telemetry *Telemetry `json:"telemetry,omitempty"` // Telemetry data
|
|
}
|
|
|
|
func (m MicE) String() string {
|
|
return m.Comment
|
|
}
|
|
|
|
type micEDecoder struct{}
|
|
|
|
func (d micEDecoder) CanDecode(frame Frame) bool {
|
|
switch frame.Raw.Type() {
|
|
case '`', '\'':
|
|
return len(frame.Raw) >= 9 && len(frame.Destination.Call) >= 6
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (d micEDecoder) Decode(frame Frame) (data Data, err error) {
|
|
lat, _, longOffset, longDir, err := decodeMicECallsign([]byte(frame.Destination.Call))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info := []byte(frame.Raw[1:9])
|
|
long := decodeMicELongitude(info[:3], longOffset, longDir)
|
|
|
|
pos := &MicE{
|
|
Latitude: float64(lat),
|
|
Longitude: float64(long),
|
|
}
|
|
pos.Symbol = string([]byte{info[7], info[6]})
|
|
pos.Velocity = parseMicECourseAndSpeed(info[3:6])
|
|
|
|
var comment string
|
|
if comment, pos.HasMessaging = parseMicERadioModel(string(frame.Raw[9:])); comment != "" {
|
|
original := comment
|
|
if pos.Altitude, comment, err = parseMicEAltitude(comment); err != nil {
|
|
comment = original
|
|
}
|
|
if pos.Telemetry, comment, err = parseBase91Telemetry(comment); err != nil {
|
|
return nil, err
|
|
}
|
|
pos.Comment = comment
|
|
}
|
|
|
|
return pos, nil
|
|
}
|
|
|
|
type micEMessageBit uint8
|
|
|
|
const (
|
|
micEMessageBitZero micEMessageBit = iota
|
|
micEMessageBitCustom
|
|
micEMessageBitStandard
|
|
micEMessageBitInvalid
|
|
)
|
|
|
|
func decodeMicEMessageBit(v uint8) micEMessageBit {
|
|
switch {
|
|
case v >= '0' && v <= '9' || v == 'L':
|
|
return micEMessageBitZero
|
|
case v >= 'A' && v <= 'K':
|
|
return micEMessageBitCustom
|
|
case v >= 'P' && v <= 'Z':
|
|
return micEMessageBitStandard
|
|
default:
|
|
return micEMessageBitInvalid
|
|
}
|
|
}
|
|
|
|
type micEPositionCommentType uint8
|
|
|
|
const (
|
|
M0 micEPositionCommentType = iota
|
|
M1
|
|
M2
|
|
M3
|
|
M4
|
|
M5
|
|
M6
|
|
C0
|
|
C1
|
|
C2
|
|
C3
|
|
C4
|
|
C5
|
|
C6
|
|
Emergency
|
|
Invalid
|
|
)
|
|
|
|
var micEPositionCommentTypeMap = map[micEMessageBit]micEPositionCommentType{
|
|
(0 << 4) | (0 << 2) | 0: Emergency,
|
|
(1 << 4) | (1 << 2) | 1: C0,
|
|
(1 << 4) | (1 << 2) | 0: C1,
|
|
(1 << 4) | (0 << 2) | 1: C2,
|
|
(1 << 4) | (0 << 2) | 0: C3,
|
|
(0 << 4) | (1 << 2) | 1: C4,
|
|
(0 << 4) | (1 << 2) | 0: C5,
|
|
(0 << 4) | (0 << 2) | 1: C6,
|
|
(2 << 4) | (2 << 2) | 2: M0,
|
|
(2 << 4) | (2 << 2) | 0: M1,
|
|
(2 << 4) | (0 << 2) | 2: M2,
|
|
(2 << 4) | (0 << 2) | 0: M3,
|
|
(0 << 4) | (2 << 2) | 2: M4,
|
|
(0 << 4) | (2 << 2) | 0: M5,
|
|
(0 << 4) | (0 << 2) | 2: M6,
|
|
}
|
|
|
|
func decodeMicEPositionCommentType(bits []micEMessageBit) micEPositionCommentType {
|
|
if v, ok := micEPositionCommentTypeMap[(bits[0]&0x3)<<4|(bits[1]&0x03)<<2|(bits[2]&0x03)]; ok {
|
|
return v
|
|
}
|
|
return Invalid
|
|
}
|
|
|
|
func decodeMicECallsign(call []byte) (lat Latitude, kind micEPositionCommentType, longOffset int, longDir int, err error) {
|
|
if len(call) != 6 {
|
|
err = io.ErrUnexpectedEOF
|
|
return
|
|
}
|
|
|
|
var latDir byte = 'X'
|
|
if (call[3] >= '0' && call[3] <= '9') || call[3] == 'L' {
|
|
latDir = 'S'
|
|
} else if call[3] >= 'P' && call[3] <= 'Z' {
|
|
latDir = 'N'
|
|
}
|
|
|
|
latBytes := []byte{
|
|
decodeLatitudeDigit(call[0]),
|
|
decodeLatitudeDigit(call[1]),
|
|
decodeLatitudeDigit(call[2]),
|
|
decodeLatitudeDigit(call[3]),
|
|
'.',
|
|
decodeLatitudeDigit(call[4]),
|
|
decodeLatitudeDigit(call[5]),
|
|
latDir,
|
|
}
|
|
if err = lat.ParseUncompressed(latBytes); err != nil {
|
|
return
|
|
}
|
|
|
|
kind = decodeMicEPositionCommentType([]micEMessageBit{
|
|
decodeMicEMessageBit(call[0]),
|
|
decodeMicEMessageBit(call[1]),
|
|
decodeMicEMessageBit(call[2]),
|
|
})
|
|
|
|
switch {
|
|
case (call[4] >= '0' && call[4] <= '9') || call[4] == 'L':
|
|
longOffset = 0
|
|
case call[4] >= 'P' && call[4] <= 'Z':
|
|
longOffset = 100
|
|
}
|
|
|
|
switch {
|
|
case (call[5] >= '0' && call[5] <= '9') || call[5] == 'L':
|
|
longDir = -1
|
|
case call[5] >= 'P' && call[5] <= 'Z':
|
|
longDir = +1
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func decodeLatitudeDigit(c uint8) uint8 {
|
|
switch {
|
|
case c >= '0' && c <= '9':
|
|
return c
|
|
case c >= 'A' && c <= 'J':
|
|
return c - 17
|
|
case c == 'K' || c == 'L' || c == 'Z':
|
|
return ' '
|
|
case c >= 'P' && c <= 'Y':
|
|
return c - 32
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func decodeMicELongitude(b []byte, offset, dir int) Longitude {
|
|
if len(b) != 3 {
|
|
return 0
|
|
}
|
|
|
|
d := int(b[0]) - 28 + offset
|
|
if d >= 180 && d <= 189 {
|
|
d -= 80
|
|
} else if d >= 190 && d <= 199 {
|
|
d -= 190
|
|
}
|
|
|
|
m := int(b[1] - 28)
|
|
if m >= 60 {
|
|
m -= 60
|
|
}
|
|
|
|
h := int(b[2] - 28)
|
|
|
|
return LongitudeFromDMH(d, m, h, dir < 0)
|
|
}
|
|
|
|
func parseMicECourseAndSpeed(data []byte) (out *Velocity) {
|
|
var (
|
|
sp = data[0] - 28
|
|
dc = data[1] - 28
|
|
se = data[2] - 28
|
|
speedKnots = float64(sp)*10 + math.Floor(float64(dc)/10)
|
|
courseDeg = ((int(dc) % 10) * 100) + int(se)
|
|
)
|
|
if speedKnots >= 800 {
|
|
speedKnots -= 800
|
|
}
|
|
if courseDeg >= 400 {
|
|
courseDeg -= 400
|
|
}
|
|
return &Velocity{
|
|
Course: float64(courseDeg),
|
|
Speed: knotsToMetersPerSecond(speedKnots),
|
|
}
|
|
}
|
|
|
|
func parseMicEAltitude(data string) (altitude float64, comment string, err error) {
|
|
if len(data) < 4 || data[3] != '}' {
|
|
return 0, data, nil
|
|
}
|
|
|
|
var value int
|
|
if value, err = base91Decode(data[:3]); err != nil {
|
|
return 0, "", fmt.Errorf("aprs: invalid altitude %q: %v", data, err)
|
|
}
|
|
|
|
altitude = feetToMeters(float64(value - 10000))
|
|
comment = data[4:]
|
|
return
|
|
}
|
|
|
|
func parseMicERadioModel(data string) (stripped string, hasMessaging bool) {
|
|
if len(data) == 0 {
|
|
return data, false
|
|
}
|
|
|
|
switch data[0] {
|
|
case '>', ']':
|
|
stripped = strings.TrimRight(data[1:], "=") // Kenwood TH-D72 / Kenwood TM-D710
|
|
stripped = strings.TrimRight(data[1:], "^") // Kenwood TH-D74
|
|
stripped = strings.TrimRight(data[1:], "&") // Kenwood TH-D75
|
|
case '`', '\'':
|
|
hasMessaging = data[0] == '`'
|
|
stripped = strings.TrimSuffix(data[1:], "_(") // Yaesu FT2D
|
|
stripped = strings.TrimSuffix(data[1:], "_0") // Yaesu FT3D
|
|
stripped = strings.TrimSuffix(data[1:], "_3") // Yaesu FT5D
|
|
stripped = strings.TrimSuffix(data[1:], "|3") // Byonics TinyTrack 3
|
|
stripped = strings.TrimSuffix(data[1:], "|4") // Byonics TinyTrack 4
|
|
default:
|
|
stripped = data
|
|
}
|
|
return
|
|
}
|
|
|
|
func parseMicEGridSquare(data string) (latitude, longitude, altitude float64, comment string, err error) {
|
|
return
|
|
}
|
|
|
|
var miceCodes = map[byte]map[int]string{
|
|
'0': {0: "0", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'1': {0: "1", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'2': {0: "2", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'3': {0: "3", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'4': {0: "4", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'5': {0: "5", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'6': {0: "6", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'7': {0: "7", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'8': {0: "8", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'9': {0: "9", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'A': {0: "0", 1: "1 (Custom)"},
|
|
'B': {0: "1", 1: "1 (Custom)"},
|
|
'C': {0: "2", 1: "1 (Custom)"},
|
|
'D': {0: "3", 1: "1 (Custom)"},
|
|
'E': {0: "4", 1: "1 (Custom)"},
|
|
'F': {0: "5", 1: "1 (Custom)"},
|
|
'G': {0: "6", 1: "1 (Custom)"},
|
|
'H': {0: "7", 1: "1 (Custom)"},
|
|
'I': {0: "8", 1: "1 (Custom)"},
|
|
'J': {0: "9", 1: "1 (Custom)"},
|
|
'K': {0: " ", 1: "1 (Custom)"},
|
|
'L': {0: " ", 1: "0", 2: "S", 3: "0", 4: "E"},
|
|
'P': {0: "0", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'Q': {0: "1", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'R': {0: "2", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'S': {0: "3", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'T': {0: "4", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'U': {0: "5", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'V': {0: "6", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'W': {0: "7", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'X': {0: "8", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'Y': {0: "9", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
'Z': {0: " ", 1: "1 (Std)", 2: "N", 3: "100", 4: "W"},
|
|
}
|
|
|
|
var miceMsgTypes = map[string]string{
|
|
"000": "Emergency",
|
|
"001 (Std)": "Priority",
|
|
"001 (Custom)": "Custom-6",
|
|
"010 (Std)": "Special",
|
|
"010 (Custom)": "Custom-5",
|
|
"011 (Std)": "Committed",
|
|
"011 (Custom)": "Custom-4",
|
|
"100 (Std)": "Returning",
|
|
"100 (Custom)": "Custom-3",
|
|
"101 (Std)": "In Service",
|
|
"101 (Custom)": "Custom-2",
|
|
"110 (Std)": "En Route",
|
|
"110 (Custom)": "Custom-1",
|
|
"111 (Std)": "Off Duty",
|
|
"111 (Custom)": "Custom-0",
|
|
}
|