Added Radio.ID and refactored the Stats interface
Some checks failed
Run tests / test (1.25) (push) Failing after 1m0s
Run tests / test (stable) (push) Failing after 1m0s

This commit is contained in:
2026-03-17 08:33:06 +01:00
parent 8ec85821e4
commit 27e2da1943
15 changed files with 2045 additions and 22 deletions

View File

@@ -0,0 +1,335 @@
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",
}

View File

@@ -0,0 +1,219 @@
package aprs
import (
"errors"
"fmt"
)
var (
ErrLatitude = errors.New("aprs: invalid latitude")
ErrLongitude = errors.New("aprs: invalid longitude")
)
// Latitude is the north-south position. Positive values are North, negative are South.
type Latitude float64
func LatitudeFromDMH(degrees, minutes, hundreths int, north bool) Latitude {
v := float64(degrees) + float64(minutes)/60 + float64(hundreths)/6000
for v > 90 {
v -= 180
}
for v < -90 {
v += 180
}
if north {
return Latitude(v)
}
return -Latitude(v)
}
func (lat Latitude) DMH() (degrees, minutes, hundreths int, north bool) {
degrees = int(lat)
minutes = int((float64(lat) - float64(degrees)) * 60)
hundreths = int((float64(lat) - float64(degrees) - float64(minutes)/60) * 6000)
if hundreths == 100 {
hundreths = 0
minutes += 1
}
if minutes == 60 {
minutes = 0
degrees += 1
}
north = lat >= 0
return
}
func (lat *Latitude) ParseCompressed(b []byte) error {
if len(b) != 4 {
return ErrLatitude
}
n, err := base91Decode(string(b))
if err != nil {
return err
}
*lat = Latitude(90 - float64(n)/380926)
return nil
}
func (lat *Latitude) ParseUncompressed(b []byte) error {
if len(b) != 8 || b[4] != '.' {
return ErrLatitude
}
var north bool
switch b[7] {
case 'N':
north = true
case 'S':
north = false
default:
return ErrLatitude
}
var (
degrees, minutes, hundreths int
err error
)
if degrees, err = parseBytesWithSpaces(b[0:2]); err != nil {
return err
}
if minutes, err = parseBytesWithSpaces(b[2:4]); err != nil {
return err
}
if hundreths, err = parseBytesWithSpaces(b[5:7]); err != nil {
return err
}
*lat = LatitudeFromDMH(degrees, minutes, hundreths, north)
return nil
}
func (lat Latitude) Compressed(b []byte) {
v := int((90 - float64(lat)) * 380926.0)
base91Encode(b, v)
}
func (lat Latitude) Uncompressed(b []byte) {
var (
degrees, minutes, hundreths, north = lat.DMH()
v = fmt.Sprintf("%02d%02d.%02d", degrees, minutes, hundreths)
)
if north {
v += "N"
} else {
v += "S"
}
copy(b, []byte(b))
}
// Longitude is the east-west position. Positive values are East, negative are West.
type Longitude float64
func LongitudeFromDMH(degrees, minutes, hundreths int, east bool) Longitude {
v := float64(degrees) + float64(minutes)/60 + float64(hundreths)/6000
for v > 180 {
v -= 360
}
for v < -180 {
v += 360
}
if east {
return Longitude(v)
}
return -Longitude(v)
}
func (long Longitude) DMH() (degrees, minutes, hundreths int, east bool) {
degrees = int(long)
minutes = int((float64(long) - float64(degrees)) * 60)
hundreths = int((float64(long) - float64(degrees) - float64(minutes)/60) * 6000)
if hundreths == 100 {
hundreths = 0
minutes += 1
}
if minutes == 60 {
minutes = 0
degrees += 1
}
east = long >= 0
return
}
func (long *Longitude) ParseCompressed(b []byte) error {
if len(b) != 4 {
return ErrLatitude
}
n, err := base91Decode(string(b))
if err != nil {
return err
}
*long = Longitude(float64(n)/190463.0 - 180)
return nil
}
func (long *Longitude) ParseUncompressed(b []byte) error {
if len(b) != 9 || b[5] != '.' {
return ErrLongitude
}
var east bool
switch b[8] {
case 'E':
east = true
case 'W':
east = false
default:
return ErrLongitude
}
var (
degrees, minutes, hundreths int
err error
)
if degrees, err = parseBytesWithSpaces(b[0:3]); err != nil {
return err
}
if minutes, err = parseBytesWithSpaces(b[3:5]); err != nil {
return err
}
if hundreths, err = parseBytesWithSpaces(b[6:8]); err != nil {
return err
}
*long = LongitudeFromDMH(degrees, minutes, hundreths, east)
return nil
}
func (long Longitude) Compressed(b []byte) {
v := int((180 + float64(long)) * 190463)
base91Encode(b, v)
}
func (long Longitude) Uncompressed(b []byte) {
var (
degrees, minutes, hundreths, east = long.DMH()
v = fmt.Sprintf("%03d%02d.%02d", degrees, minutes, hundreths)
)
if east {
v += "E"
} else {
v += "W"
}
copy(b, []byte(b))
}

View File

@@ -0,0 +1,53 @@
package aprs
import "testing"
func TestLatitude(t *testing.T) {
tests := []struct {
Test string
Want Latitude
}{
{"4903.50N", 49.05833333333333},
{"4903.50S", -49.05833333333333},
{"4903.5 S", -49.05833333333333},
{"4903. S", -49.05},
{"490 . S", -49},
{"4 . S", -40},
}
for _, test := range tests {
t.Run(test.Test, func(t *testing.T) {
var lat Latitude
if err := lat.ParseUncompressed([]byte(test.Test)); err != nil {
t.Fatal(err)
}
if !testAlmostEqual(float64(test.Want), float64(lat)) {
t.Errorf("expected %f, got %f", test.Want, lat)
}
})
}
}
func TestLongitude(t *testing.T) {
tests := []struct {
Test string
Want Longitude
}{
{"00000.00E", 0},
{"00000.00W", 0},
{"00000.98W", -0.016333},
{"00098. W", -1.633333},
{"098 . W", -98.000000},
{"9 . W", -180.000000},
}
for _, test := range tests {
t.Run(test.Test, func(t *testing.T) {
var long Longitude
if err := long.ParseUncompressed([]byte(test.Test)); err != nil {
t.Fatal(err)
}
if !testAlmostEqual(float64(test.Want), float64(long)) {
t.Errorf("expected %f, got %f", test.Want, long)
}
})
}
}

View File

@@ -217,6 +217,7 @@ func (client *ProxyClient) copy(dst, src net.Conn, host, dir string, call *strin
func (client *ProxyClient) Info() *radio.Info {
// We have very little information actually, but here we go:
return &radio.Info{
ID: client.myCall,
Name: client.myCall,
}
}