diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9beeb4e --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.maze.io/go/ham + +go 1.25.6 + +replace git.maze.io/go/ham/internal/configuration => ./internal/configuration + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + git.maze.io/go/ham/internal/configuration v0.0.0-00010101000000-000000000000 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/joho/godotenv v1.5.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..99a1568 --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/collector/client.go b/internal/collector/client.go new file mode 100644 index 0000000..db3f1a9 --- /dev/null +++ b/internal/collector/client.go @@ -0,0 +1,153 @@ +package collector + +import ( + "encoding/json" + "log" + "os" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +type Options struct { + Brokers []string `yaml:"brokers" env:"MQTT_BROKERS"` + ClientID string `yaml:"client_id" env:"MQTT_CLIENT_ID"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Protocol string +} + +type DeviceConfig struct { + Callsign string `yaml:"callsign" env:"DEVICE_CALLSIGN"` + Device string `yaml:"device" env:"DEVICE"` + Manufacturer string `yaml:"manufacturer" env:"DEVICE_MANUFACTURER"` + RXFrequency float64 `yaml:"rx_frequency" env:"DEVICE_RX_FREQ"` // in Hz + RXGain float64 `yaml:"rx_gain" env:"DEVICE_RX_GAIN"` // in dBm + TXFrequency float64 `yaml:"tx_frequency" env:"DEVICE_TX_FREQ"` // in Hz + TXPower float64 `yaml:"tx_power" env:"DEVICE_TX_GAIN"` // in dBm +} + +type Client struct { + client mqtt.Client + options *Options + topicCollector string + topicDevice string + topicPacket string +} + +func NewClient(options *Options) (*Client, error) { + if options.ClientID == "" { + options.ClientID, _ = os.Hostname() + } + + opts := mqtt.NewClientOptions() + opts.SetCredentialsProvider(func() (username, password string) { + return options.Username, options.Password + }) + opts.SetClientID(options.ClientID) + + if len(options.Brokers) == 0 { + log.Println("collector: no brokers configured, using localhost") + opts.AddBroker("localhost:1883") + } else { + for _, broker := range options.Brokers { + log.Printf("collector: adding broker at tcp://%s", broker) + opts.AddBroker(broker) + } + } + + if options.Protocol == "" { + options.Protocol = "unspec" + } + + client := &Client{ + options: options, + topicCollector: "collector/" + options.ClientID, + topicDevice: "device", + topicPacket: "packet/" + options.Protocol, + } + + // Last will and testament. + opts.SetWill(client.topicCollector+"/status", client.collectorStatus("offline"), 1, true) + + // Logging + opts.OnConnect = func(c mqtt.Client) { + log.Println("collector: connected to MQTT broker") + token := c.Publish(client.topicCollector+"/status", 1, false, client.collectorStatus("online")) + if token.Wait() && token.Error() != nil { + log.Printf("collector: failed to signal collector online: %v", token.Error()) + c.Disconnect(0) + return + } + } + opts.OnConnectionLost = func(c mqtt.Client, err error) { + log.Printf("collector: disconnected from MQTT broker: %v", err) + } + + // Connect to broker(s). + client.client = mqtt.NewClient(opts) + token := client.client.Connect() + if token.Wait() && token.Error() != nil { + return nil, token.Error() + } + + return client, nil +} + +func (c *Client) collectorStatus(status string) string { + b, _ := json.Marshal(map[string]any{ + "status": status, + "time": time.Now().UTC(), + "id": c.options.ClientID, + "protocol": c.options.Protocol, + }) + return string(b) +} + +func (c *Client) deviceStatus(config *DeviceConfig, status string) string { + b, _ := json.Marshal(map[string]any{ + "status": status, + "time": time.Now().UTC(), + "protocol": c.options.Protocol, + "callsign": config.Callsign, + "device": config.Device, + "manufacturer": config.Manufacturer, + "rx_freq": config.RXFrequency, + "rx_gain": config.RXGain, + "tx_freq": config.TXFrequency, + "tx_power": config.TXPower, + }) + return string(b) +} + +func (c *Client) DeviceOnline(config *DeviceConfig) error { + return c.setDeviceStatus(config, "online") +} + +func (c *Client) DeviceOffline(config *DeviceConfig) error { + return c.setDeviceStatus(config, "offline") +} + +func (c *Client) setDeviceStatus(config *DeviceConfig, status string) error { + log.Printf("collector: device %s/%s is %s", c.options.ClientID, config.Callsign, status) + token := c.client.Publish(c.topicDevice+"/"+config.Callsign, 1, false, c.deviceStatus(config, status)) + return token.Error() +} + +func (c *Client) PublishPacket(id string, packet any) error { + var payload string + switch packet := packet.(type) { + case string: + payload = packet + case []byte: + payload = string(packet) + default: + b, err := json.Marshal(packet) + if err != nil { + return err + } + payload = string(b) + } + token := c.client.Publish(c.topicPacket, 0, false, payload) + return token.Error() +} diff --git a/protocol/aprs/address.go b/protocol/aprs/address.go new file mode 100644 index 0000000..9a0c559 --- /dev/null +++ b/protocol/aprs/address.go @@ -0,0 +1,106 @@ +package aprs + +import ( + "errors" + "fmt" + "strings" +) + +var ( + ErrAddressInvalid = errors.New(`aprs: invalid address`) +) + +type Address struct { + Call string `json:"call"` + SSID string `json:"ssid,omitempty"` + IsRepeated bool `json:"is_repeated,omitempty"` +} + +func (a Address) EqualTo(b Address) bool { + return a.Call == b.Call && a.SSID == b.SSID +} + +func (a Address) String() string { + var r = "" + + if a.IsRepeated { + r = "*" + } + if a.SSID == "" { + return a.Call + r + } + return fmt.Sprintf("%s-%s%s", a.Call, a.SSID, r) +} + +func (a Address) Secret() int16 { + var h = int16(0x73e2) + var c = a.Call + + if len(c)%2 > 0 { + c += "\x00" + } + for i := 0; i < len(c); i += 2 { + h ^= int16(c[i]) << 8 + h ^= int16(c[i+1]) + } + return h & 0x7fff +} + +func ParseAddress(s string) (Address, error) { + r := strings.HasSuffix(s, "*") + if r { + s = s[:len(s)-1] + } + p := strings.Split(s, "-") + if len(p) == 0 || len(p) > 2 { + return Address{}, ErrAddressInvalid + } + + a := Address{Call: p[0], IsRepeated: r} + if len(p) == 2 { + a.SSID = p[1] + } + + return a, nil +} + +func MustParseAddress(s string) Address { + a, err := ParseAddress(s) + if err != nil { + panic(err) + } + return a +} + +func IsQConstruct(call string) bool { + return len(call) == 3 && call[0] == 'q' +} + +type Path []Address + +func (p Path) String() string { + var s = make([]string, len(p)) + for i, a := range p { + s[i] = a.String() + } + return strings.Join(s, ",") +} + +func ParsePath(p string) (Path, error) { + ss := strings.Split(p, ",") + + if len(ss) == 0 { + return nil, nil + } + + var err error + as := make(Path, len(ss)) + for i, s := range ss { + as[i], err = ParseAddress(s) + if err != nil { + return nil, err + } + } + + return as, nil +} diff --git a/protocol/aprs/base91.go b/protocol/aprs/base91.go new file mode 100644 index 0000000..d5b0ec0 --- /dev/null +++ b/protocol/aprs/base91.go @@ -0,0 +1,29 @@ +package aprs + +import ( + "fmt" + "strings" +) + +func base91Decode(s string) (n int, err error) { + for i, l := 0, len(s); i < l; i++ { + c := s[i] + if c < 33 || c > 122 { + return 0, fmt.Errorf("aprs: invalid base-91 encoding char %q (%d)", c, c) + } + + n *= 91 + n += int(c) - 33 + } + return +} + +func base91Encode(n int) string { + var s []string + for n > 0 { + c := n % 91 + n /= 91 + s = append([]string{string(byte(c) + 33)}, s...) + } + return strings.Join(s, "") +} diff --git a/protocol/aprs/datatype.go b/protocol/aprs/datatype.go new file mode 100644 index 0000000..05b4c1f --- /dev/null +++ b/protocol/aprs/datatype.go @@ -0,0 +1,41 @@ +package aprs + +import "fmt" + +type DataType byte + +var ( + dataTypeName = map[DataType]string{ + 0x1c: "Current Mic-E Data (Rev 0 beta)", + 0x1d: "Old Mic-E Data (Rev 0 beta)", + '!': "Position without timestamp (no APRS messaging), or Ultimeter 2000 WX Station", + '#': "Peet Bros U-II Weather Station", + '$': "Raw GPS data or Ultimeter 2000", + '%': "Agrelo DFJr / MicroFinder", + '"': "Old Mic-E Data (but Current data for TM-D700)", + ')': "Item", + '*': "Peet Bros U-II Weather Station", + ',': "Invalid data or test data", + '/': "Position with timestamp (no APRS messaging)", + ':': "Message", + ';': "Object", + '<': "Station Capabilities", + '=': "Position without timestamp (with APRS messaging)", + '>': "Status", + '?': "Query", + '@': "Position with timestamp (with APRS messaging)", + 'T': "Telemetry data", + '[': "Maidenhead grid locator beacon (obsolete)", + '_': "Weather Report (without position)", + '`': "Current Mic-E Data (not used in TM-D700)", + '{': "User-Defined APRS packet format", + '}': "Third-party traffic", + } +) + +func (t DataType) String() string { + if s, ok := dataTypeName[t]; ok { + return s + } + return fmt.Sprintf("Unknown packet type %#02x", byte(t)) +} diff --git a/protocol/aprs/doc.go b/protocol/aprs/doc.go new file mode 100644 index 0000000..7b7c356 --- /dev/null +++ b/protocol/aprs/doc.go @@ -0,0 +1,2 @@ +// Package aprs implements Automatic Packet Reporting System message parsing. +package aprs diff --git a/protocol/aprs/packet.go b/protocol/aprs/packet.go new file mode 100644 index 0000000..f8c6522 --- /dev/null +++ b/protocol/aprs/packet.go @@ -0,0 +1,481 @@ +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 + } +} diff --git a/protocol/aprs/packet_test.go b/protocol/aprs/packet_test.go new file mode 100644 index 0000000..9b0403a --- /dev/null +++ b/protocol/aprs/packet_test.go @@ -0,0 +1,329 @@ +package aprs + +import ( + "math" + "testing" + "time" +) + +const earthRadius = float64(6378100) + +func testTime(day, hour, min, sec int) *time.Time { + t := time.Date(0, 0, day, hour, min, sec, 0, time.UTC) + return &t +} + +// haversin(θ) function +func testHaversin(theta float64) float64 { + return math.Pow(math.Sin(theta/2), 2) +} + +func testDistance(a *Position, b *Position) float64 { + var ( + lat1 = a.Latitude * math.Pi / 180 + lng1 = a.Longitude * math.Pi / 180 + lat2 = b.Latitude * math.Pi / 180 + lng2 = b.Longitude * math.Pi / 180 + h = testHaversin(lat2-lat1) + math.Cos(lat1)*math.Cos(lat2)*testHaversin(lng2-lng1) + ) + return 2 * earthRadius * math.Asin(math.Sqrt(h)) +} + +func TestPacket(t *testing.T) { + var tests = []struct { + Raw string + Src Address + Dst Address + PathLen int + Type DataType + Position *Position + Velocity *Velocity + PHG *PowerHeightGain + DFS *OmniDFStrength + Altitude float64 + Range float64 + Time *time.Time + }{ + { + Raw: "N0CALL>APRS,qAC:!4903.50N/07201.75W-Test 001234", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('!'), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + }, + { + Raw: "N0CALL>APRS,qAC:!4903.50N/07201.75W-Test /A=001234", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('!'), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + }, + { + Raw: "N0CALL>APRS,qAC:!49 . N/072 . W-", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('!'), + Position: &Position{Latitude: 49.0, Longitude: -72.000000}, + }, + /* + { + Raw: "N0CALL>APRS,qAC:TheNet X-1J4 (BFLD)!4903.50N/07201.75Wn", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('!'), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + }, + */ + { + Raw: "N0CALL>APRS,qAC:@092345/4903.50N/07201.75W>Test1234", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('@'), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + Time: testTime(9, 23, 45, 0), + }, + { + Raw: "N0CALL>APRS,qAC:=4903.50N/07201.75W#PHG5132", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('='), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + PHG: &PowerHeightGain{'5', '1', '3', '2'}, + }, + { + Raw: "N0CALL>APRS,qAC:=4903.50N/07201.75W 225/000g000t050r000p001...h00b10138dU2k", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('='), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + }, + { + Raw: "N0CALL>APRS,qAC:@092345/4903.50N/07201.75W>088/036", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('@'), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + Time: testTime(9, 23, 45, 0), + }, + { + Raw: "N0CALL>APRS,qAC:@234517h4903.50N/07201.75W>PHG5132", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('@'), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + Time: testTime(0, 23, 45, 17), + PHG: &PowerHeightGain{'5', '1', '3', '2'}, + }, + { + Raw: "N0CALL>APRS,qAC:@092345z4903.50N/07201.75W>RNG0050", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('@'), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + Time: testTime(9, 23, 45, 0), + Range: 50, + }, + { + Raw: "N0CALL>APRS,qAC:/234517h4903.50N/07201.75W>DFS2360", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('/'), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + Time: testTime(0, 23, 45, 17), + DFS: &OmniDFStrength{'2', '3', '6', '0'}, + }, + { + Raw: "N0CALL>APRS,qAC:@092345z4903.50N/07201.75W 090/000g000t066r000p000...dUII", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('@'), + Position: &Position{Latitude: 49.058333, Longitude: -72.029167}, + Time: testTime(9, 23, 45, 00), + }, + { + Raw: "N0CALL>APRS,qAC:[IO91SX] 35 miles NNW of London", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('['), + Position: &Position{Latitude: 51.958333, Longitude: -0.500000}, + }, + { + Raw: "N0CALL>APRS,qAC:[IO91]", + Src: MustParseAddress("N0CALL"), + Dst: MustParseAddress("APRS"), + PathLen: 1, + Type: DataType('['), + Position: &Position{Latitude: 51.0, Longitude: -2.0}, + }, + { + Raw: "WX4GSO-9>APN382,qAR,WD4LSS:!3545.18NL07957.08W#PHG5680/R,W,85NC,NCn Mount Shepherd Piedmont Triad NC", + Src: MustParseAddress("WX4GSO-9"), + Dst: MustParseAddress("APN382"), + PathLen: 2, + Type: DataType('!'), + Position: &Position{Latitude: 35.753000, Longitude: -79.951333}, + }, + { + Raw: "PA4TW>APRS,qAS,PA4TW-2:=5216.28N/00510.05Er Remco, DMR:2041014, Soms QRV op PI2NOS", + Src: MustParseAddress("PA4TW"), + Dst: MustParseAddress("APRS"), + PathLen: 2, + Type: DataType('='), + Position: &Position{Latitude: 52.271333, Longitude: 5.167500}, + }, + { + Raw: "PA4TW-10>APRS,TCPIP*,qAC,FOURTH:=5220.18N/00453.25EIhttp://aprs.pa4tw.nl:14501/", + Src: MustParseAddress("PA4TW-10"), + Dst: MustParseAddress("APRS"), + PathLen: 3, + Type: DataType('='), + Position: &Position{Latitude: 52.336333, Longitude: 4.887500}, + }, + { + Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:=/5L!!<*e7> sTComment", + Src: MustParseAddress("N0CALL-1"), + Dst: MustParseAddress("T3PY1Y"), + PathLen: 4, + Type: DataType('='), + Position: &Position{Latitude: 49.5, Longitude: -72.75}, + }, + { + Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:=/5L!!<*e7>7P[", + Src: MustParseAddress("N0CALL-1"), + Dst: MustParseAddress("T3PY1Y"), + PathLen: 4, + Type: DataType('='), + Position: &Position{Latitude: 49.5, Longitude: -72.75}, + Velocity: &Velocity{88.0, 36.2}, + }, + { + Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:=/5L!!<*e7>{?!", + Src: MustParseAddress("N0CALL-1"), + Dst: MustParseAddress("T3PY1Y"), + PathLen: 4, + Type: DataType('='), + Position: &Position{Latitude: 49.5, Longitude: -72.75}, + Range: 20.13, + }, + { + Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:=/5L!!<*e7OS]S", + Src: MustParseAddress("N0CALL-1"), + Dst: MustParseAddress("T3PY1Y"), + PathLen: 4, + Type: DataType('='), + Position: &Position{Latitude: 49.5, Longitude: -72.75}, + Altitude: 10004, + }, + { + Raw: "N0CALL-1>T3PY1Y,KQ1L-8*,WIDE1,WIDE2-1,qAR:@092345z/5L!!<*e7>{?!", + Src: MustParseAddress("N0CALL-1"), + Dst: MustParseAddress("T3PY1Y"), + PathLen: 4, + Type: '@', + Time: testTime(9, 23, 45, 0), + Position: &Position{Latitude: 49.5, Longitude: -72.75}, + Range: 20.13, + }, + { + Raw: "PE1ROG-8>APLC13,qAS,PE1ROG-2:!/49[8pQLoRa___MOBIL___TEST", + Src: MustParseAddress("PE1ROG-8"), + Dst: MustParseAddress("APLC13"), + PathLen: 2, + Type: '!', + }, + { + Raw: "PE1ROG-1>APPM13,TCPIP*,qAC,T2PRT:>Running on Raspberry Pi with RTL dongle", + Src: MustParseAddress("PE1ROG-1"), + Dst: MustParseAddress("APPM13"), + PathLen: 3, + Type: '>', + }, + { + Raw: "PD0MZ-4>APE32L,WIDE1-1,qAR,PD0MZ-10:>LoRa APRS Tracker", + Src: MustParseAddress("PD0MZ-4"), + Dst: MustParseAddress("APE32L"), + PathLen: 3, + Type: '>', + }, + } + + for _, test := range tests { + t.Run(test.Raw, func(t *testing.T) { + p, err := ParsePacket(test.Raw) + if err != nil { + t.Fatal(err) + } + if !p.Dst.EqualTo(test.Dst) { + t.Fatalf("expected dst %s, got %s", test.Dst, p.Dst) + } + if !p.Src.EqualTo(test.Src) { + t.Fatalf("expected src %s, got %s", test.Src, p.Src) + } + if len(p.Path) != test.PathLen { + t.Fatalf("expected path length %d, got %d: %v", test.PathLen, len(p.Path), p.Path) + } + if p.Payload.Type() != test.Type { + t.Fatalf("expected packet type %s [%c], got %s [%c]", + test.Type, test.Type, + p.Payload.Type(), p.Payload.Type()) + } + if test.Altitude != 0 { + if p.Altitude == 0 { + t.Fatalf("expected altitude %f, got none", test.Altitude) + } + if math.Abs(test.Altitude-p.Altitude) > 1.0 { + t.Fatalf("expected altitude %f, got %f", test.Altitude, p.Altitude) + } + } + if test.Velocity != nil { + if p.Velocity.Course == 0 { + t.Fatalf("expected velocity %v, got none", test.Velocity) + } + if math.Abs(test.Velocity.Course-p.Velocity.Course) > 1.0 { + t.Fatalf("expected course %f, got %f", test.Velocity.Course, p.Velocity.Course) + } + if math.Abs(test.Velocity.Speed-p.Velocity.Speed) > 1.0 { + t.Fatalf("expected speed %f, got %f", test.Velocity.Speed, p.Velocity.Speed) + } + } + if test.Range != 0 { + if p.Range == 0 { + t.Fatalf("expected range %f, got none", test.Range) + } + if math.Abs(test.Range-p.Range) > 0.1 { + t.Fatalf("expected range %f, got %f", test.Range, p.Range) + } + } + if test.Position != nil { + if p.Position == nil { + t.Fatalf("expected position %s, got none", test.Position) + } + if d := testDistance(test.Position, p.Position); d > 1.0 { + t.Fatalf("expected position %s, got %s with distance %f meter", test.Position, p.Position, d) + } + } + if test.Time != nil { + if p.Time == nil { + t.Fatalf("expected time %s", test.Time) + } + if test.Time.Sub(*p.Time) > time.Minute { + t.Fatalf("expected time %s, got %s", test.Time, p.Time) + } + } + //t.Logf("%#+v", p) + }) + } +} diff --git a/protocol/aprs/position.go b/protocol/aprs/position.go new file mode 100644 index 0000000..ec0905c --- /dev/null +++ b/protocol/aprs/position.go @@ -0,0 +1,313 @@ +package aprs + +import ( + "fmt" + "strconv" + "strings" + + "git.maze.io/go/ham/util/maidenhead" +) + +var ( + // Position ambiguity replacement + disambiguation = []int{2, 3, 5, 6, 12, 13, 15, 16} + + miceCodes = map[rune]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"}, + } + + 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", + } +) + +const ( + gridChars = "ABCDEFGHIJKLMNOPQRSTUVWX0123456789" + + messageTypeStd = "Std" + messageTypeCustom = "Custom" +) + +// Position contains GPS coordinates. +type Position struct { + Latitude float64 `json:"latitude"` // Degrees + Longitude float64 `json:"longitude"` // Degrees + Ambiguity int `json:"ambiguity,omitempty"` + Symbol Symbol `json:"symbol"` + Compressed bool `json:"compressed,omitempty"` +} + +func (pos Position) String() string { + if pos.Ambiguity == 0 { + return fmt.Sprintf("{%f, %f}", pos.Latitude, pos.Longitude) + } + return fmt.Sprintf("{%f, %f}, ambiguity=%d", pos.Latitude, pos.Longitude, pos.Ambiguity) +} + +func ParseUncompressedPosition(s string) (Position, string, error) { + // APRS PROTOCOL REFERENCE 1.0.1 Chapter 8, page 32 (42 in PDF) + + pos := Position{} + + if len(s) < 18 { + return pos, "", ErrInvalidPosition + } + + b := []byte(s) + for _, p := range disambiguation { + if b[p] == ' ' { + pos.Ambiguity++ + b[p] = '0' + } + } + s = string(b) + + var ( + err error + latDeg, latMin, latMinFrag uint64 + lngDeg, lngMin, lngMinFrag uint64 + latHemi, lngHemi byte + isSouth, isWest bool + ) + + if latDeg, err = strconv.ParseUint(s[0:2], 10, 8); err != nil { + return pos, "", err + } + if latMin, err = strconv.ParseUint(s[2:4], 10, 8); err != nil { + return pos, "", err + } + if latMinFrag, err = strconv.ParseUint(s[5:7], 10, 8); err != nil { + return pos, "", err + } + latHemi = s[7] + pos.Symbol[0] = s[8] + if lngDeg, err = strconv.ParseUint(s[9:12], 10, 8); err != nil { + return pos, "", err + } + if lngMin, err = strconv.ParseUint(s[12:14], 10, 8); err != nil { + return pos, "", err + } + if lngMinFrag, err = strconv.ParseUint(s[15:17], 10, 8); err != nil { + return pos, "", err + } + lngHemi = s[17] + pos.Symbol[1] = s[18] + + if latHemi == 'S' || latHemi == 's' { + isSouth = true + } else if latHemi != 'N' && latHemi != 'n' { + return pos, "", ErrInvalidPosition + } + + if lngHemi == 'W' || lngHemi == 'w' { + isWest = true + } else if lngHemi != 'E' && lngHemi != 'e' { + return pos, "", ErrInvalidPosition + } + + if latDeg > 89 || lngDeg > 179 { + return pos, "", ErrInvalidPosition + } + + pos.Latitude = float64(latDeg) + float64(latMin)/60.0 + float64(latMinFrag)/6000.0 + pos.Longitude = float64(lngDeg) + float64(lngMin)/60.0 + float64(lngMinFrag)/6000.0 + + if isSouth { + pos.Latitude = 0.0 - pos.Latitude + } + if isWest { + pos.Longitude = 0.0 - pos.Longitude + } + + if pos.Symbol[1] >= 'a' || pos.Symbol[1] <= 'k' { + pos.Symbol[1] -= 32 + } + + if len(s) > 19 { + return pos, s[19:], nil + } + return pos, "", nil +} + +func ParseCompressedPosition(s string) (Position, string, error) { + // APRS PROTOCOL REFERENCE 1.0.1 Chapter 9, page 36 (46 in PDF) + + pos := Position{} + + if len(s) < 10 { + return pos, "", ErrInvalidPosition + } + + // Base-91 check + for _, c := range s[1:9] { + if c < 0x21 || c > 0x7b { + return pos, "", ErrInvalidPosition + } + } + + var err error + var lat, lng int + if lat, err = base91Decode(s[1:5]); err != nil { + return pos, "", err + } + if lng, err = base91Decode(s[5:9]); err != nil { + return pos, "", err + } + + pos.Latitude = 90.0 - float64(lat)/380926.0 + pos.Longitude = -180.0 + float64(lng)/190463.0 + pos.Compressed = true + + return pos, s[10:], nil +} + +func ParseMicE(s, dest string) (Position, error) { + // APRS PROTOCOL REFERENCE 1.0.1 Chapter 10, page 42 in PDF + + pos := Position{} + + if len(s) < 9 || len(dest) != 6 { + return pos, ErrInvalidPosition + } + + ns := miceCodes[rune(dest[3])][2] + we := miceCodes[rune(dest[5])][4] + + latF := fmt.Sprintf("%s%s", miceCodes[rune(dest[0])][0], miceCodes[rune(dest[1])][0]) + latF = strings.Trim(latF, ". ") + latD, err := strconv.ParseFloat(latF, 64) + if err != nil { + return pos, ErrInvalidPosition + } + lonF := fmt.Sprintf("%s%s.%s%s", miceCodes[rune(dest[2])][0], miceCodes[rune(dest[3])][0], miceCodes[rune(dest[4])][0], miceCodes[rune(dest[5])][0]) + lonF = strings.Trim(lonF, ". ") + latM, err := strconv.ParseFloat(lonF, 64) + if err != nil { + return pos, ErrInvalidPosition + } + if latM != 0 { + latD += latM / 60 + } + if strings.ToUpper(ns) == "S" { + latD = -latD + } + + lonOff := miceCodes[rune(dest[4])][3] + lonD := float64(s[1]) - 28 + if lonOff == "100" { + lonD += 100 + } + if lonD >= 180 && lonD < 190 { + lonD -= 80 + } + if lonD >= 190 && lonD < 200 { + lonD -= 190 + } + + lonM := float64(s[2]) - 28 + if lonM >= 60 { + lonM -= 60 + } + // adding hundreth of minute then add minute as deg fraction + lonH := float64(s[3]) - 28 + if lonH != 0 { + lonM += lonH / 100 + } + if lonM != 0 { + lonD += lonM / 60 + } + if strings.ToUpper(we) == "W" { + lonD = -lonD + } + + pos.Latitude = latD + pos.Longitude = lonD + + return pos, nil +} + +func ParsePositionGrid(s string) (Position, string, error) { + var o int + for o = 0; o < len(s); o++ { + if strings.IndexByte(gridChars, s[o]) < 0 { + break + } + } + + pos := Position{} + if o == 2 || o == 4 || o == 6 || o == 8 { + p, err := maidenhead.ParseLocator(s[:o]) + if err != nil { + return pos, "", err + } + pos.Latitude = p.Latitude + pos.Longitude = p.Longitude + } + + var txt string + if o < len(s) { + txt = s[o+1:] + } + return pos, txt, nil +} + +func ParsePosition(s string, compressed bool) (Position, string, error) { + if compressed { + return ParseCompressedPosition(s) + } + return ParseUncompressedPosition(s) +} + +func ParsePositionBoth(s string) (Position, string, error) { + pos, txt, err := ParseUncompressedPosition(s) + if err != nil { + return ParseCompressedPosition(s) + } + return pos, txt, err +} diff --git a/protocol/aprs/symbol.go b/protocol/aprs/symbol.go new file mode 100644 index 0000000..abe9e9e --- /dev/null +++ b/protocol/aprs/symbol.go @@ -0,0 +1,266 @@ +package aprs + +import ( + "encoding/json" + "fmt" +) + +var emptySymbol Symbol + +type Symbol [2]byte + +func (s Symbol) IsPrimaryTable() bool { return s[0] != '\\' } + +func (s Symbol) MarshalJSON() ([]byte, error) { + if s == emptySymbol { + return json.Marshal("") + } + return json.Marshal(string(s[:])) +} + +func (s Symbol) get(idx int) (string, error) { + var m map[byte]map[int]string + if s.IsPrimaryTable() { + m = primarySymbol + } else { + m = alternateSymbol + } + n, ok := m[s[1]] + if !ok { + return "", fmt.Errorf("unknown symbol %x", s[1]) + } + if i, ok := n[idx]; ok { + return i, nil + } + return "", fmt.Errorf("symbol doesn't have requested index: %v", n) +} + +func (s Symbol) String() string { + hr, err := s.get(1) + if err != nil { + return err.Error() + } + return hr +} + +func (s Symbol) SSID() (string, error) { + return s.get(2) +} + +func (s Symbol) Emoji() (string, error) { + return s.get(3) +} + +func IsValidCompressedSymTable(c byte) bool { + return c == '/' || + c == '\\' || + (c >= 0x41 && c <= 0x5a) || + (c >= 0x61 && c <= 0x6a) +} + +func IsValidUncompressedSymTable(c byte) bool { + return c == '/' || + c == '\\' || + (c >= 0x41 && c <= 0x5a) || + (c >= 0x30 && c <= 0x39) +} + +var ( + // Source: http://www.aprs.org/symbols/symbolsX.txt + // 0: XYZ code + // 1: Human readable + // 2: SSID + // 3: Emoji + primarySymbol = map[byte]map[int]string{ + '!': {0: "BB", 1: "Police, Sheriff", 3: ":cop:"}, + '"': {0: "BC", 1: "reserved"}, + '#': {0: "BD", 1: "Digi"}, + '$': {0: "BE", 1: "Phone", 3: ":phone:"}, + '%': {0: "BF", 1: "DX Cluster"}, + '&': {0: "BG", 1: "HF Gateway"}, + '\'': {0: "BH", 1: "Small Aircraft", 2: "11", 3: ":airplane:"}, + '(': {0: "BI", 1: "Mobile Satellite Station", 3: ":satellite:"}, + ')': {0: "BJ", 1: "Wheelchair", 3: ":wheelchair:"}, + '*': {0: "BK", 1: "Snowmobile"}, + '+': {0: "BL", 1: "Red Cross"}, + ',': {0: "BM", 1: "Boy Scout"}, + '-': {0: "BN", 1: "House QTH (VHF)"}, + '.': {0: "BO", 1: "X"}, + '/': {0: "BP", 1: "Red Dot"}, + '0': {0: "P0", 1: "Circle (0)"}, + '1': {0: "P1", 1: "Circle (1)"}, + '2': {0: "P2", 1: "Circle (2)"}, + '3': {0: "P3", 1: "Circle (3)"}, + '4': {0: "P4", 1: "Circle (4)"}, + '5': {0: "P5", 1: "Circle (5)"}, + '6': {0: "P6", 1: "Circle (6)"}, + '7': {0: "P7", 1: "Circle (7)"}, + '8': {0: "P8", 1: "Circle (8)"}, + '9': {0: "P9", 1: "Circle (9)"}, + ':': {0: "MR", 1: "Fire", 3: ":fire:"}, + ';': {0: "MS", 1: "Campground", 3: ":tent:"}, + '<': {0: "MT", 1: "Motorcycle", 2: "10", 3: ":bike:"}, + '=': {0: "MU", 1: "Railroad Engine", 3: ":train:"}, + '>': {0: "MV", 1: "Car", 2: "9", 3: ":car:"}, + '?': {0: "MW", 1: "File Server"}, + '@': {0: "MX", 1: "HC Future"}, + 'A': {0: "PA", 1: "Aid Station", 3: ":hospital:"}, + 'B': {0: "PB", 1: "BBS or PBBS"}, + 'C': {0: "PC", 1: "Canoe"}, + 'D': {0: "PD"}, + 'E': {0: "PE", 1: "Eyeball"}, + 'F': {0: "PF", 1: "Tractor", 3: ":tractor:"}, + 'G': {0: "PG", 1: "Grid Square"}, + 'H': {0: "PH", 1: "Hotel", 3: ":hotel:"}, + 'I': {0: "PI", 1: "TCP/IP"}, + 'J': {0: "PJ"}, + 'K': {0: "PK", 1: "School", 3: ":school:"}, + 'L': {0: "PL", 1: "PC User", 3: ":computer:"}, + 'M': {0: "PM", 1: "MacAPRS", 3: ":computer:"}, + 'N': {0: "PN", 1: "NTS Station"}, + 'O': {0: "PO", 1: "Balloon", 2: "11", 3: ":airplane:"}, + 'P': {0: "PP", 1: "Police", 3: ":police_car:"}, + 'Q': {0: "PQ"}, + 'R': {0: "PR", 1: "Recreational Vehicle", 2: "13", 3: ":car:"}, + 'S': {0: "PS", 1: "Shuttle"}, + 'T': {0: "PT", 1: "SSTV"}, + 'U': {0: "PU", 1: "Bus", 2: "2", 3: ":bus:"}, + 'V': {0: "PV", 1: "ATV"}, + 'W': {0: "PW", 1: "National WX Service Site"}, + 'X': {0: "PX", 1: "Helo", 2: "6"}, + 'Y': {0: "PY", 1: "Yacht", 2: "5", 3: ":sailboat:"}, + 'Z': {0: "PZ", 1: "WinAPRS", 3: ":computer:"}, + '[': {0: "HS", 1: "Human/Person", 2: "7", 3: ":running:"}, + '\\': {0: "HT", 1: "DF Station"}, + ']': {0: "HU", 1: "Post Office", 3: ":post_office:"}, + '^': {0: "HV", 1: "Large Aircraft", 3: ":airplane:"}, + '_': {0: "HW", 1: "Weather Station", 3: ":cloud:"}, + '`': {0: "HX", 1: "Dish Antenna", 3: ":satellite:"}, + 'a': {0: "LA", 1: "Ambulance", 2: "1", 3: ":ambulance:"}, + 'b': {0: "LB", 1: "Bike", 2: "4", 3: ":bike:"}, + 'c': {0: "LC", 1: "Incident Command Post"}, + 'd': {0: "LD", 1: "Fire Dept", 3: ":fire_engine:"}, + 'e': {0: "LE", 1: "Horse", 3: ":racehorse:"}, + 'f': {0: "LF", 1: "Fire Truck", 2: "3", 3: ":fire_engine:"}, + 'g': {0: "LG", 1: "Glider", 3: ":airplane:"}, + 'h': {0: "LH", 1: "Hospital", 3: ":hospital:"}, + 'i': {0: "LI", 1: "IOTA"}, + 'j': {0: "LJ", 1: "Jeep", 2: "12", 3: ":car:"}, + 'k': {0: "LK", 1: "Truck", 2: "14", 3: ":truck:"}, + 'l': {0: "LL", 1: "Laptop", 3: ":computer:"}, + 'm': {0: "LM", 1: "Mic-E Repeater"}, + 'n': {0: "LN", 1: "Node"}, + 'o': {0: "LO", 1: "EOC"}, + 'p': {0: "LP", 1: "Dog", 3: ":dog2:"}, + 'q': {0: "LQ", 1: "Grid SQ"}, + 'r': {0: "LR", 1: "Repeater"}, + 's': {0: "LS", 1: "Ship", 2: "8", 3: ":ship:"}, + 't': {0: "LT", 1: "Truck Stop"}, + 'u': {0: "LU", 1: "Truck (18 Wheeler)", 3: ":truck:"}, + 'v': {0: "LV", 1: "Van", 2: "15", 3: ":minibus:"}, + 'w': {0: "LW", 1: "Water Station"}, + 'x': {0: "LX", 1: "xAPRS", 3: ":computer:"}, + 'y': {0: "LY", 1: "Yagi @ QTH"}, + 'z': {0: "LZ"}, + '{': {0: "J1"}, + '|': {0: "J2", 1: "TNC Stream Switch"}, + '}': {0: "J3"}, + '~': {0: "J4", 1: "TNC Stream Switch"}, + } + alternateSymbol = map[byte]map[int]string{ + '!': {0: "OBO", 1: "Emergency"}, + '"': {0: "OC", 1: "Reserved"}, + '#': {0: "OD#", 1: "Overlay Digi"}, + '$': {0: "OEO", 1: "Bank/ATM", 3: ":atm:"}, + '%': {0: "OFO", 1: "Power Plant", 3: ":factory:"}, + '&': {0: "OG#", 1: "I=Igte R=RX T=1hopTX 2=2hopTX"}, + '\'': {0: "OHO", 1: "Crash Site"}, + '(': {0: "OIO", 1: "Cloudy", 3: ":cloud:"}, + ')': {0: "OJO", 1: "Firenet MEO"}, + '*': {0: "OK"}, + '+': {0: "OL", 1: "Church", 3: ":church:"}, + ',': {0: "OM", 1: "Girl Scouts", 3: ":tent:"}, + '-': {0: "ONO", 1: "House", 3: ":house:"}, + '.': {0: "OO", 1: "Ambiguous"}, + '/': {0: "OP", 1: "Waypoint Destination"}, + '0': {0: "A0#", 1: "Circle", 3: ":red_circle:"}, + '1': {0: "A1"}, + '2': {0: "A2"}, + '3': {0: "A3"}, + '4': {0: "A4"}, + '5': {0: "A5"}, + '6': {0: "A6"}, + '7': {0: "A7"}, + '8': {0: "A8O", 1: "WiFi Network"}, + '9': {0: "A9", 1: "Gas Station", 3: ":fuelpump:"}, + ':': {0: "NR"}, + ';': {0: "NSO", 1: "Park/Picnic"}, + '<': {0: "NTO", 1: "Advisory"}, + '=': {0: "NUO"}, + '>': {0: "NV#", 1: "Cars & Vehicles", 3: ":car:"}, + '?': {0: "NW", 1: "Info Kiosk"}, + '@': {0: "NX", 1: "Hurricane", 3: ":cyclone:"}, + 'A': {0: "AA#", 1: "Box DTMF & RFID"}, + 'B': {0: "AB"}, + 'C': {0: "AC", 1: "Coast Guard"}, + 'D': {0: "ADO", 1: "Depots"}, + 'E': {0: "AE", 1: "Smoke"}, + 'F': {0: "AF"}, + 'G': {0: "AG"}, + 'H': {0: "AHO", 1: "Haze"}, + 'I': {0: "AI", 1: "Rain Shower", 3: ":umbrella:"}, + 'J': {0: "AJ"}, + 'K': {0: "AK", 1: "Kenwood HT"}, + 'L': {0: "AL", 1: "Lighthouse"}, + 'M': {0: "AMO", 1: "MARS"}, + 'N': {0: "AN", 1: "Navigation Buoy"}, + 'O': {0: "AO", 1: "Rocket", 3: ":rocket:"}, + 'P': {0: "AP", 1: "Parking", 3: ":parking:"}, + 'Q': {0: "AQ", 1: "Quake"}, + 'R': {0: "ARO", 1: "Restaurant"}, + 'S': {0: "AS", 1: "Satellite/Pacsat", 3: ":rocket:"}, + 'T': {0: "AT", 1: "Thunderstorm"}, + 'U': {0: "AU", 1: "Sunny"}, + 'V': {0: "AV", 1: "VORTAC Nav Aid"}, + 'W': {0: "AW#", 1: "NWS Site"}, + 'X': {0: "AX", 1: "Pharmacy"}, + 'Y': {0: "AYO", 1: "Radios and devices"}, + 'Z': {0: "AZ"}, + '[': {0: "DSO", 1: "W. Cloud"}, + '\\': {0: "DTO", 1: "GPS"}, + ']': {0: "DU"}, + '^': {0: "DV#", 1: "Other Aircraft", 3: ":airplane:"}, + '_': {0: "DW#", 1: "WX Site"}, + '`': {0: "DX", 1: "Rain", 3: ":umbrella:"}, + 'a': {0: "SA#O"}, + 'b': {0: "SB"}, + 'c': {0: "SC#O", 1: "CD Triangle"}, + 'd': {0: "SD", 1: "DX Spot"}, + 'e': {0: "SE", 1: "Sleet"}, + 'f': {0: "SF", 1: "Funnel Cloud"}, + 'g': {0: "SG", 1: "Gale Flags"}, + 'h': {0: "SHO", 1: "Store or Hamfest"}, + 'i': {0: "SI#", 1: "Box / POI"}, + 'j': {0: "SJ", 1: "Work Zone"}, + 'k': {0: "SKO", 1: "Special Vehicle"}, + 'l': {0: "SL", 1: "Areas"}, + 'm': {0: "SM", 1: "Value Sign"}, + 'n': {0: "SN#", 1: "Triangle"}, + 'o': {0: "SO", 1: "Small Circle"}, + 'p': {0: "SP"}, + 'q': {0: "SQ"}, + 'r': {0: "SR", 1: "Restrooms"}, + 's': {0: "SS#", 1: "Ship/Boats", 3: ":speedboat:"}, + 't': {0: "ST", 1: "Tornado", 3: ":cyclone:"}, + 'u': {0: "SU#", 1: "Truck", 3: ":truck:"}, + 'v': {0: "SV#", 1: "Van", 3: ":minibus:"}, + 'w': {0: "SWO", 1: "Flooding"}, + 'x': {0: "SX", 1: "Wreck/Obstruction"}, + 'y': {0: "SY", 1: "Skywarn"}, + 'z': {0: "SZ#", 1: "Shelter"}, + '{': {0: "Q1"}, + '|': {0: "Q2", 1: "TNC Stream Switch"}, + '}': {0: "Q3"}, + '~': {0: "Q4", 1: "TNC Stream Switch"}, + } +) diff --git a/protocol/aprs/time.go b/protocol/aprs/time.go new file mode 100644 index 0000000..3495c90 --- /dev/null +++ b/protocol/aprs/time.go @@ -0,0 +1,33 @@ +package aprs + +import ( + "fmt" + "time" +) + +type TimeFormatError struct { + Time string +} + +func (err TimeFormatError) Error() string { + return fmt.Sprintf("aprs: unknown time stamp %q", err.Time) +} + +func ParseTime(s string) (time.Time, error) { + if len(s) < 7 { + return time.Time{}, TimeFormatError{s} + } + + switch { + case s[6] == 'z': // Day/Hours/Minutes (DHM) format + return time.Parse("021504", s[:6]) + case s[6] == '/': // Day/Hours/Minutes (DHM) format + return time.Parse("021504", s[:6]) + case s[6] == 'h': // Hours/Minutes/Seconds (HMS) format + return time.Parse("150405", s[:6]) + case len(s) >= 8: // Month/Day/Hours/Minutes (MDHM) format + return time.Parse("01021504", s[:8]) + default: + return time.Time{}, TimeFormatError{s} + } +} diff --git a/protocol/meshcore/crypto/ed25519.go b/protocol/meshcore/crypto/ed25519.go new file mode 100644 index 0000000..ecb028e --- /dev/null +++ b/protocol/meshcore/crypto/ed25519.go @@ -0,0 +1,372 @@ +package crypto + +import ( + "bytes" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strconv" + + "filippo.io/edwards25519" +) + +const ( + seedSize = 32 + publicKeySize = 32 + privateKeySize = seedSize + publicKeySize + signatureSize = 64 + sha512Size = 64 +) + +type PrivateKey struct { + seed [seedSize]byte + pub [publicKeySize]byte + s edwards25519.Scalar + prefix [sha512Size / 2]byte +} + +func (priv *PrivateKey) Bytes() []byte { + k := make([]byte, 0, privateKeySize) + k = append(k, priv.seed[:]...) + k = append(k, priv.pub[:]...) + return k +} + +func (priv *PrivateKey) HexString() string { + return hex.EncodeToString(priv.Bytes()) +} + +func (priv *PrivateKey) Seed() []byte { + seed := priv.seed + return seed[:] +} + +func (priv *PrivateKey) PublicKey() []byte { + pub := priv.pub + return pub[:] +} + +type PublicKey struct { + a edwards25519.Point + aBytes [32]byte +} + +func (pub *PublicKey) Bytes() []byte { + a := pub.aBytes + return a[:] +} + +func (pub *PublicKey) String() string { + return hex.EncodeToString(pub.Bytes()) +} + +func (pub *PublicKey) EqualTo(other *PublicKey) bool { + if pub == nil || other == nil { + return false + } + return bytes.Equal(pub.aBytes[:], other.aBytes[:]) +} + +func (pub *PublicKey) MarshalJSON() ([]byte, error) { + return json.Marshal(hex.EncodeToString(pub.Bytes())) +} + +func (pub *PublicKey) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + b, err := hex.DecodeString(s) + if err != nil { + return err + } + k, err := NewPublicKey(b) + if err != nil { + return err + } + + pub.a = k.a + copy(pub.aBytes[:], k.aBytes[:]) + return nil +} + +// GenerateKey generates a new Ed25519 private key pair. +func GenerateKey() (*PublicKey, *PrivateKey, error) { + priv := &PrivateKey{} + key, err := generateKey(priv) + if err != nil { + return nil, nil, err + } + pub, err := NewPublicKey(priv.PublicKey()) + return pub, key, err +} + +func generateKey(priv *PrivateKey) (*PrivateKey, error) { + rand.Read(priv.seed[:]) + precomputePrivateKey(priv) + return priv, nil +} + +func NewPrivateKeyFromSeed(seed []byte) (*PrivateKey, error) { + priv := &PrivateKey{} + return newPrivateKeyFromSeed(priv, seed) +} + +func newPrivateKeyFromSeed(priv *PrivateKey, seed []byte) (*PrivateKey, error) { + if l := len(seed); l != seedSize { + return nil, errors.New("ed25519: bad seed length: " + strconv.Itoa(l)) + } + copy(priv.seed[:], seed) + precomputePrivateKey(priv) + return priv, nil +} + +func precomputePrivateKey(priv *PrivateKey) { + hs := sha512.New() + hs.Write(priv.seed[:]) + h := hs.Sum(make([]byte, 0, sha512Size)) + + s, err := priv.s.SetBytesWithClamping(h[:32]) + if err != nil { + panic("ed25519: internal error: setting scalar failed") + } + A := (&edwards25519.Point{}).ScalarBaseMult(s) + copy(priv.pub[:], A.Bytes()) + + copy(priv.prefix[:], h[32:]) +} + +func NewPrivateKey(priv []byte) (*PrivateKey, error) { + p := &PrivateKey{} + return newPrivateKey(p, priv) +} + +func newPrivateKey(priv *PrivateKey, privBytes []byte) (*PrivateKey, error) { + if l := len(privBytes); l != privateKeySize { + return nil, errors.New("ed25519: bad private key length: " + strconv.Itoa(l)) + } + + copy(priv.seed[:], privBytes[:32]) + + hs := sha512.New() + hs.Write(priv.seed[:]) + h := hs.Sum(make([]byte, 0, sha512Size)) + + if _, err := priv.s.SetBytesWithClamping(h[:32]); err != nil { + panic("ed25519: internal error: setting scalar failed") + } + // Note that we are not decompressing the public key point here, + // because it takes > 20% of the time of a signature generation. + // Signing doesn't use it as a point anyway. + copy(priv.pub[:], privBytes[32:]) + + copy(priv.prefix[:], h[32:]) + + return priv, nil +} + +func DecodePublicKey(s string) (*PublicKey, error) { + var ( + pub []byte + err error + ) + switch len(s) { + case hex.EncodedLen(32): + pub, err = hex.DecodeString(s) + case base64.RawStdEncoding.EncodedLen(32): + pub, err = base64.RawStdEncoding.DecodeString(s) + case base64.StdEncoding.DecodedLen(32): + pub, err = base64.StdEncoding.DecodeString(s) + default: + return nil, errors.New("auth: invalid public key") + } + if err != nil { + return nil, err + } + + return NewPublicKey(pub) +} + +func NewPublicKey(pub []byte) (*PublicKey, error) { + p := &PublicKey{} + return newPublicKey(p, pub) +} + +func newPublicKey(pub *PublicKey, pubBytes []byte) (*PublicKey, error) { + if l := len(pubBytes); l != publicKeySize { + return nil, errors.New("ed25519: bad public key length: " + strconv.Itoa(l)) + } + // SetBytes checks that the point is on the curve. + if _, err := pub.a.SetBytes(pubBytes); err != nil { + return nil, errors.New("ed25519: bad public key") + } + copy(pub.aBytes[:], pubBytes) + return pub, nil +} + +// Domain separation prefixes used to disambiguate Ed25519/Ed25519ph/Ed25519ctx. +// See RFC 8032, Section 2 and Section 5.1. +const ( + // domPrefixPure is empty for pure Ed25519. + domPrefixPure = "" + // domPrefixPh is dom2(phflag=1) for Ed25519ph. It must be followed by the + // uint8-length prefixed context. + domPrefixPh = "SigEd25519 no Ed25519 collisions\x01" + // domPrefixCtx is dom2(phflag=0) for Ed25519ctx. It must be followed by the + // uint8-length prefixed context. + domPrefixCtx = "SigEd25519 no Ed25519 collisions\x00" +) + +func Sign(priv *PrivateKey, message []byte) []byte { + // Outline the function body so that the returned signature can be + // stack-allocated. + signature := make([]byte, signatureSize) + return sign(signature, priv, message) +} + +func sign(signature []byte, priv *PrivateKey, message []byte) []byte { + return signWithDom(signature, priv, message, domPrefixPure, "") +} + +func SignPH(priv *PrivateKey, message []byte, context string) ([]byte, error) { + // Outline the function body so that the returned signature can be + // stack-allocated. + signature := make([]byte, signatureSize) + return signPH(signature, priv, message, context) +} + +func signPH(signature []byte, priv *PrivateKey, message []byte, context string) ([]byte, error) { + if l := len(message); l != sha512Size { + return nil, errors.New("ed25519: bad Ed25519ph message hash length: " + strconv.Itoa(l)) + } + if l := len(context); l > 255 { + return nil, errors.New("ed25519: bad Ed25519ph context length: " + strconv.Itoa(l)) + } + return signWithDom(signature, priv, message, domPrefixPh, context), nil +} + +func SignCtx(priv *PrivateKey, message []byte, context string) ([]byte, error) { + // Outline the function body so that the returned signature can be + // stack-allocated. + signature := make([]byte, signatureSize) + return signCtx(signature, priv, message, context) +} + +func signCtx(signature []byte, priv *PrivateKey, message []byte, context string) ([]byte, error) { + // FIPS 186-5 specifies Ed25519 and Ed25519ph (with context), but not Ed25519ctx. + // Note that per RFC 8032, Section 5.1, the context SHOULD NOT be empty. + if l := len(context); l > 255 { + return nil, errors.New("ed25519: bad Ed25519ctx context length: " + strconv.Itoa(l)) + } + return signWithDom(signature, priv, message, domPrefixCtx, context), nil +} + +func signWithDom(signature []byte, priv *PrivateKey, message []byte, domPrefix, context string) []byte { + mh := sha512.New() + if domPrefix != domPrefixPure { + mh.Write([]byte(domPrefix)) + mh.Write([]byte{byte(len(context))}) + mh.Write([]byte(context)) + } + mh.Write(priv.prefix[:]) + mh.Write(message) + messageDigest := make([]byte, 0, sha512Size) + messageDigest = mh.Sum(messageDigest) + r, err := edwards25519.NewScalar().SetUniformBytes(messageDigest) + if err != nil { + panic("ed25519: internal error: setting scalar failed") + } + + R := (&edwards25519.Point{}).ScalarBaseMult(r) + + kh := sha512.New() + if domPrefix != domPrefixPure { + kh.Write([]byte(domPrefix)) + kh.Write([]byte{byte(len(context))}) + kh.Write([]byte(context)) + } + kh.Write(R.Bytes()) + kh.Write(priv.pub[:]) + kh.Write(message) + hramDigest := make([]byte, 0, sha512Size) + hramDigest = kh.Sum(hramDigest) + k, err := edwards25519.NewScalar().SetUniformBytes(hramDigest) + if err != nil { + panic("ed25519: internal error: setting scalar failed") + } + + S := edwards25519.NewScalar().MultiplyAdd(k, &priv.s, r) + + copy(signature[:32], R.Bytes()) + copy(signature[32:], S.Bytes()) + + return signature +} + +func Verify(pub *PublicKey, message, sig []byte) error { + return verify(pub, message, sig) +} + +func verify(pub *PublicKey, message, sig []byte) error { + return verifyWithDom(pub, message, sig, domPrefixPure, "") +} + +func VerifyPH(pub *PublicKey, message []byte, sig []byte, context string) error { + if l := len(message); l != sha512Size { + return errors.New("ed25519: bad Ed25519ph message hash length: " + strconv.Itoa(l)) + } + if l := len(context); l > 255 { + return errors.New("ed25519: bad Ed25519ph context length: " + strconv.Itoa(l)) + } + return verifyWithDom(pub, message, sig, domPrefixPh, context) +} + +func VerifyCtx(pub *PublicKey, message []byte, sig []byte, context string) error { + if l := len(context); l > 255 { + return errors.New("ed25519: bad Ed25519ctx context length: " + strconv.Itoa(l)) + } + return verifyWithDom(pub, message, sig, domPrefixCtx, context) +} + +func verifyWithDom(pub *PublicKey, message, sig []byte, domPrefix, context string) error { + if l := len(sig); l != signatureSize { + return errors.New("ed25519: bad signature length: " + strconv.Itoa(l)) + } + + if sig[63]&224 != 0 { + return errors.New("ed25519: invalid signature") + } + + kh := sha512.New() + if domPrefix != domPrefixPure { + kh.Write([]byte(domPrefix)) + kh.Write([]byte{byte(len(context))}) + kh.Write([]byte(context)) + } + kh.Write(sig[:32]) + kh.Write(pub.aBytes[:]) + kh.Write(message) + hramDigest := make([]byte, 0, sha512Size) + hramDigest = kh.Sum(hramDigest) + k, err := edwards25519.NewScalar().SetUniformBytes(hramDigest) + if err != nil { + panic("ed25519: internal error: setting scalar failed") + } + + S, err := edwards25519.NewScalar().SetCanonicalBytes(sig[32:]) + if err != nil { + return errors.New("ed25519: invalid signature") + } + + // [S]B = R + [k]A --> [k](-A) + [S]B = R + minusA := (&edwards25519.Point{}).Negate(&pub.a) + R := (&edwards25519.Point{}).VarTimeDoubleScalarBaseMult(k, minusA, S) + + if !bytes.Equal(sig[:32], R.Bytes()) { + return errors.New("ed25519: invalid signature") + } + return nil +} diff --git a/protocol/meshcore/crypto/ed25519_test.go b/protocol/meshcore/crypto/ed25519_test.go new file mode 100644 index 0000000..d6c6c65 --- /dev/null +++ b/protocol/meshcore/crypto/ed25519_test.go @@ -0,0 +1,15 @@ +package crypto + +import ( + "testing" +) + +func TestKey(t *testing.T) { + pub, key, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + + t.Logf("key: %s", key.HexString()) + t.Logf("pub: %s", pub) +} diff --git a/util/maidenhead/doc.go b/util/maidenhead/doc.go new file mode 100644 index 0000000..ef5c1f3 --- /dev/null +++ b/util/maidenhead/doc.go @@ -0,0 +1,10 @@ +// Package maidenhead implements the maidenhead locator system. +// +// The Maidenhead Locator System (a.k.a. QTH Locator and IARU Locator) is a geocode system used +// by amateur radio operators to succinctly describe their geographic coordinates, which replaced +// the deprecated QRA locator, which was limited to European contacts. +// +// Maidenhead locators are also commonly referred to as QTH locators, grid locators or grid +// squares, although the "squares" are distorted on any non-equirectangular cartographic +// projection. +package maidenhead diff --git a/util/maidenhead/maidenhead.go b/util/maidenhead/maidenhead.go new file mode 100644 index 0000000..cca1998 --- /dev/null +++ b/util/maidenhead/maidenhead.go @@ -0,0 +1,148 @@ +// Package maidenhead implements the Maidenhead Locator System, a geographic +// coordinate system used by amataur radio (HAM) operators. +package maidenhead + +import ( + "errors" + "fmt" + "math" + "strings" +) + +// Precision of the computed locator. +const ( + FieldPrecision = iota + 1 + SquarePrecision + SubSquarePrecision + ExtendedSquarePrecision +) + +var ( + upper = "ABCDEFGHIJKLMNOPQRSTUVWX" + lower = "abcdefghijklmnopqrstuvwx" + digit = "0123456789" +) + +// locator computes the Maidenhead Locator for a given position. +func locator(p Point, precision int) (string, error) { + if math.IsNaN(p.Latitude) { + return "", errors.New("maidenhead: latitude is not a digit") + } + if math.IsInf(p.Latitude, 0) { + return "", errors.New("maidenhead: latitude is infinite") + } + if math.IsNaN(p.Longitude) { + return "", errors.New("maidenhead: longitude is not a digit") + } + if math.IsInf(p.Longitude, 0) { + return "", errors.New("maidenhead: longitude is infinite") + } + if math.Abs(p.Latitude) == 90.0 { + return "", errors.New("maidenhead: grid square invalid at poles") + } else if math.Abs(p.Latitude) > 90.0 { + return "", fmt.Errorf("maidenhead: invalid latitude %.04f", p.Latitude) + } else if math.Abs(p.Longitude) > 180.0 { + return "", fmt.Errorf("maidenhead: invalid longitude %.05f", p.Longitude) + } + + var ( + lat = p.Latitude + 90.0 + lng = p.Longitude + 180.0 + loc string + ) + + lat = lat/10.0 + 0.0000001 + lng = lng/20.0 + 0.0000001 + loc = loc + string(upper[int(lng)]) + string(upper[int(lat)]) + if precision == 1 { + return loc, nil + } + lat = 10 * (lat - math.Floor(lat)) + lng = 10 * (lng - math.Floor(lng)) + loc = loc + fmt.Sprintf("%d%d", int(lng)%10, int(lat)%10) + if precision == 2 { + return loc, nil + } + lat = 24 * (lat - math.Floor(lat)) + lng = 24 * (lng - math.Floor(lng)) + loc = loc + string(upper[int(lng)]) + string(upper[int(lat)]) + if precision == 3 { + return loc, nil + } + lat = 10 * (lat - math.Floor(lat)) + lng = 10 * (lng - math.Floor(lng)) + loc = loc + fmt.Sprintf("%d%d", int(lng)%10, int(lat)%10) + if precision == 4 { + return loc, nil + } + lat = 24 * (lat - math.Floor(lat)) + lng = 24 * (lng - math.Floor(lng)) + loc = loc + string(lower[int(lng)]) + string(lower[int(lat)]) + return loc, nil +} + +var parseLocatorMult = []struct { + s, p string + mult float64 +}{ + {upper[:18], lower[:18], 20.0}, + {upper[:18], lower[:18], 10.0}, + {digit[:10], digit[:10], 20.0 / 10.0}, + {digit[:10], digit[:10], 10.0 / 10.0}, + {upper[:24], lower[:24], 20.0 / (10.0 * 24.0)}, + {upper[:24], lower[:24], 10.0 / (10.0 * 24.0)}, + {digit[:10], digit[:10], 20.0 / (10.0 * 24.0 * 10.0)}, + {digit[:10], digit[:10], 10.0 / (10.0 * 24.0 * 10.0)}, + {lower[:24], lower[:24], 20.0 / (10.0 * 24.0 * 10.0 * 24.0)}, + {lower[:24], lower[:24], 10.0 / (10.0 * 24.0 * 10.0 * 24.0)}, +} + +var maxLocatorLength = len(parseLocatorMult) + +func parseLocator(locator string, strict bool, centered bool) (point Point, err error) { + var ( + lnglat = [2]float64{ + -180.0, + -90.0, + } + i, j int + char rune + ) + + if len(locator) > maxLocatorLength { + err = fmt.Errorf("maidenhead: locator is too long (%d characters, maximum %d characters allowed)", + len(locator), maxLocatorLength) + return + } + + if len(locator)%2 != 0 { + err = fmt.Errorf("maidenhead: locator has odd number of characters") + return + } + + if strict { + for i, char = range locator { + if j = strings.Index(parseLocatorMult[i].s, string(char)); j < 0 { + err = fmt.Errorf("maidenhead: invalid character at offset %d", i) + return + } + lnglat[i%2] += float64(j) * parseLocatorMult[i].mult + } + } else { + for i, char = range strings.ToLower(locator) { + if j = strings.Index(parseLocatorMult[i].p, string(char)); j < 0 { + err = fmt.Errorf("maidenhead: invalid character at offset %d", i) + return + } + lnglat[i%2] += float64(j) * parseLocatorMult[i].mult + } + } + + if centered { + lnglat[0] += parseLocatorMult[i-1].mult / 2.0 + lnglat[1] += parseLocatorMult[i].mult / 2.0 + } + + point = NewPoint(lnglat[1], lnglat[0]) + return +} diff --git a/util/maidenhead/maidenhead_test.go b/util/maidenhead/maidenhead_test.go new file mode 100644 index 0000000..b70629f --- /dev/null +++ b/util/maidenhead/maidenhead_test.go @@ -0,0 +1,142 @@ +package maidenhead + +import ( + "math" + "strings" + "testing" +) + +var tests = []struct { + point Point + loc string + loc4 string +}{ + {Point{48.14666, 11.60833}, "JN58TD", "JN58TD25"}, + {Point{-34.91, -56.21166}, "GF15VC", "GF15VC41"}, + {Point{38.92, -77.065}, "FM18LW", "FM18LW20"}, + {Point{-41.28333, 174.745}, "RE78IR", "RE78IR92"}, + {Point{41.714775, -72.727260}, "FN31PR", "FN31PR21"}, + {Point{37.413708, -122.1073236}, "CM87WJ", "CM87WJ79"}, + {Point{35.0542, -85.1142}, "EM75KB", "EM75KB63"}, +} + +func TestGridSquare(t *testing.T) { + for _, test := range tests { + t.Run(test.loc, func(t *testing.T) { + enc, err := test.point.GridSquare() + if err != nil { + t.Fatal(err) + } + if enc != test.loc { + t.Fatalf("%s want %q, got %q\n", test.point, test.loc, enc) + } + //t.Logf("%s encoded to %q\n", test.point, enc) + }) + } +} + +func TestExtendedSquarePrecision(t *testing.T) { + for _, test := range tests { + t.Run(test.loc4, func(t *testing.T) { + got, err := test.point.Locator(ExtendedSquarePrecision) + if err != nil { + t.Fatal(err) + } + if got != test.loc4 { + t.Fatalf("%s want %q, got %q\n", test.point, test.loc4, got) + } + //t.Logf("%s encoded to %q\n", test.point, got) + }) + } +} + +// parsed locator must be translated to the same locator +// using GridSquare() +func TestParseLocator(t *testing.T) { + var locTests = map[string]Point{ + "JN88RT": Point{48.791667, 17.416667}, + "JN89HF": Point{49.208333, 16.583333}, + "JN58TD": Point{48.125000, 11.583333}, + "GF15VC": Point{-34.916667, -56.250000}, + "FM18LW": Point{38.916667, -77.083333}, + "RE78IR": Point{-41.291667, 174.666667}, + "PM45lm": Point{35.5, 128.916667}, + } + + for loc, p := range locTests { + t.Run(loc, func(t *testing.T) { + point, err := ParseLocator(loc) + if err != nil { + t.Fatalf("%s parsing error: %s", loc, err) + } + + l, err := point.GridSquare() + if err != nil { + t.Fatalf("%s: %v to GridSquare(): %s", loc, point, err) + } + + if !strings.EqualFold(l, loc) { + t.Errorf("%s: parsed to %v produces %s\n", loc, point, l) + } + + if !(almostEqual(p.Latitude, point.Latitude) && almostEqual(p.Longitude, point.Longitude)) { + t.Errorf("%s: at %s, expeted %s", loc, point, p) + } + }) + } +} + +func almostEqual(a, b float64) bool { + return math.Abs(a-b) < 1e-06 +} + +// invalid Maiden Head locators must return error +func TestParseInvalidLocatorStrict(t *testing.T) { + locs := []string{ + "JN58td", + "JN58TDAA", + "JNH", + "QN58jh", + "JN77ya", + " ", + "JN55J", + "JN89HA11aa2", + "JN89HA11aa22", + } + + for _, l := range locs { + t.Run(l, func(t *testing.T) { + _, err := ParseLocatorStrict(l) + if err == nil { + t.Errorf("Parsing invalid locator '%s' with ParseLocatorStrict() doesn't return any error", l) + } + }) + } +} + +// Distance between corner point and center of the locator square +func TestParseLocatorCentered(t *testing.T) { + tests := []struct { + loc string + distExpected float64 + }{ + {"JN89", 91.42870273454076}, + {"JN89HF", 3.8111046375990782}, + {"JN89HF23", 0.38109528459829756}, + {"JN89HF23ag", 0.015878904160500258}, + } + + for _, test := range tests { + t.Run(test.loc, func(t *testing.T) { + p, _ := ParseLocator(test.loc) + pc, _ := ParseLocatorCentered(test.loc) + + dist := pc.Distance(p) + + if !almostEqual(dist, test.distExpected) { + t.Errorf("Distance between the center and corner of square locator '%s' is %g, expected %g", + test.loc, dist, test.distExpected) + } + }) + } +} diff --git a/util/maidenhead/point.go b/util/maidenhead/point.go new file mode 100644 index 0000000..7c102e3 --- /dev/null +++ b/util/maidenhead/point.go @@ -0,0 +1,162 @@ +package maidenhead + +import ( + "fmt" + "math" +) + +const ( + // Earth radius + r = 6371 +) + +var compassBearing = []struct { + label string + start, ended float64 +}{ + {"N", 000.00, 011.25}, {"NNE", 011.25, 033.75}, {"NE", 033.75, 056.25}, {"ENE", 056.25, 078.75}, + {"E", 078.75, 101.25}, {"ESE", 101.25, 123.75}, {"SE", 123.75, 146.25}, {"SSE", 146.25, 168.75}, + {"S", 168.75, 191.25}, {"SSW", 191.25, 213.75}, {"SW", 213.75, 236.25}, {"WSW", 236.25, 258.75}, + {"W", 258.75, 281.25}, {"WNW", 281.25, 303.75}, {"NW", 303.75, 326.25}, {"NNW", 326.25, 348.75}, + {"N", 348.75, 360.00}, +} + +// Point is a geographical point on the map. +type Point struct { + Latitude float64 + Longitude float64 +} + +// NewPoint returns a new Point structure with given latitude and longitude. +func NewPoint(latitude, longitude float64) Point { + return Point{latitude, longitude} +} + +// ParseLocator parses a Maidenhead Locator with permissive rule matching. +func ParseLocator(locator string) (Point, error) { + return parseLocator(locator, false, false) +} + +// ParseLocatorStrict parses a Maidenhead Locator with strict rule matching. +func ParseLocatorStrict(locator string) (Point, error) { + return parseLocator(locator, true, false) +} + +// ParseLocatorCentered parses a Maidenhead Locator with permissive rule matching. +// Returns Points structure with coordinates of the square center +func ParseLocatorCentered(locator string) (Point, error) { + return parseLocator(locator, false, true) +} + +// ParseLocatorStrictCentered parses a Maidenhead Locator with strict rule matching. +// Returns Points structure with coordinates of the square center +func ParseLocatorStrictCentered(locator string) (Point, error) { + return parseLocator(locator, true, true) +} + +// EqualTo returns true if the coordinates point to the same geographical location. +func (p Point) EqualTo(other Point) bool { + var ( + dlat = p.Latitude - other.Latitude + dlng = p.Longitude - other.Longitude + ) + + for dlat < -180.0 { + dlat += 360.0 + } + for dlat > 180.0 { + dlat -= 360.0 + } + for dlng < -90.0 { + dlng += 90.0 + } + for dlng > 90.0 { + dlng -= 90.0 + } + + return dlat == 0.0 && dlng == 0.0 +} + +// Bearing calculates the (approximate) bearing to another heading. +func (p Point) Bearing(heading Point) float64 { + var ( + hn = p.Latitude / 180 * math.Pi + he = p.Longitude / 180 * math.Pi + n = heading.Latitude / 180 * math.Pi + e = heading.Longitude / 180 * math.Pi + co = math.Cos(he-e)*math.Cos(hn)*math.Cos(n) + math.Sin(hn)*math.Sin(n) + ca = math.Atan(math.Abs(math.Sqrt(1-co*co) / co)) + ) + + if co < 0.0 { + ca = math.Pi - ca + } + + var si = math.Sin(e-he) * math.Cos(n) * math.Cos(hn) + co = math.Sin(n) - math.Sin(hn)*math.Cos(ca) + var az = math.Atan(math.Abs(si / co)) + if co < 0.0 { + az = math.Pi - az + } + if si < 0.0 { + az = -az + } + if az < 0.0 { + az = az + 2.0*math.Pi + } + return az * 180 / math.Pi +} + +// CompassBearing returns the compass bearing to a heading. +func (p Point) CompassBearing(heading Point) string { + bearing := p.Bearing(heading) + for bearing < 0.0 { + bearing += 360.0 + } + for bearing > 360.0 { + bearing -= 360.0 + } + + for _, compass := range compassBearing { + if bearing >= compass.start && bearing <= compass.ended { + return compass.label + } + } + + // Should never reach + return "" +} + +// Distance calculates the (approximate) distance to another point in km. +func (p Point) Distance(other Point) float64 { + var ( + hn = p.Latitude / 180 * math.Pi + he = p.Longitude / 180 * math.Pi + n = other.Latitude / 180 * math.Pi + e = other.Longitude / 180 * math.Pi + co = math.Cos(he-e)*math.Cos(hn)*math.Cos(n) + math.Sin(hn)*math.Sin(n) + ca = math.Atan(math.Abs(math.Sqrt(1-co*co) / co)) + ) + + if co < 0.0 { + ca = math.Pi - ca + } + + return r * ca +} + +// GridSquare returns a Maidenhead Locator for the point coordinates. +func (p Point) GridSquare() (string, error) { + return locator(p, SubSquarePrecision) +} + +// Locator returns a Maidenhead Locator for the point coordinates with +// specified precision +func (p Point) Locator(precision int) (string, error) { + return locator(p, precision) +} + +// String returns a stringified Point structure. +func (p Point) String() string { + return fmt.Sprintf("Point(%f, %f)", p.Latitude, p.Longitude) +} diff --git a/util/maidenhead/point_test.go b/util/maidenhead/point_test.go new file mode 100644 index 0000000..61bfca7 --- /dev/null +++ b/util/maidenhead/point_test.go @@ -0,0 +1,37 @@ +package maidenhead + +import ( + "math" + "testing" +) + +var pointTests = []struct { + point Point + bearing float64 + compass string +}{ + {Point{48.14666, 11.60833}, 195, "SSW"}, + {Point{-34.91, -56.21166}, 69, "ENE"}, + {Point{38.92, -77.065}, 98, "E"}, + {Point{-41.28333, 174.745}, 187, "S"}, + {Point{41.714775, -72.727260}, 101, "ESE"}, + {Point{37.413708, -122.1073236}, 69, "ENE"}, + {Point{35.0542, -85.1142}, 92, "E"}, +} + +func TestPointBearing(t *testing.T) { + var center = NewPoint(0, 0) + for _, test := range pointTests { + t.Run("", func(t *testing.T) { + bearing := math.Floor(test.point.Bearing(center)) + if bearing != test.bearing { + t.Fatalf("%s -> %s, expected %0.f, got %0.f\n", test.point, center, test.bearing, bearing) + } + compass := test.point.CompassBearing(center) + if compass != test.compass { + t.Logf("%s -> %s, expected %q, got %q\n", test.point, center, test.compass, compass) + } + //t.Logf("%s -> %s, bearing %.0f %s\n", test.point, center, bearing, compass) + }) + } +}