mirror of
https://github.com/la5nta/pat
synced 2026-08-13 17:46:54 -04:00
Enabled by default if voacapl or voacapw is found on the system. Configuration options are available for advanced setup. Issue #494
291 lines
8.2 KiB
Go
291 lines
8.2 KiB
Go
// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved.
|
|
// Use of this source code is governed by the MIT-license that can be
|
|
// found in the LICENSE file.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"net/url"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/la5nta/pat/internal/cmsapi"
|
|
"github.com/la5nta/pat/internal/debug"
|
|
"github.com/la5nta/pat/internal/directories"
|
|
"github.com/la5nta/pat/internal/propagation"
|
|
"github.com/la5nta/pat/internal/propagation/silso"
|
|
|
|
"github.com/pd0mz/go-maidenhead"
|
|
)
|
|
|
|
type JSONURL struct{ url.URL }
|
|
|
|
func (url JSONURL) MarshalJSON() ([]byte, error) { return json.Marshal(url.String()) }
|
|
|
|
// JSONFloat64 is a float64 which serializes NaN and Inf(+-) as JSON value null
|
|
type JSONFloat64 float64
|
|
|
|
func (f JSONFloat64) MarshalJSON() ([]byte, error) {
|
|
if math.IsNaN(float64(f)) || math.IsInf(float64(f), 0) {
|
|
return json.Marshal(nil)
|
|
}
|
|
return json.Marshal(float64(f))
|
|
}
|
|
|
|
type Prediction struct {
|
|
LinkQuality int `json:"link_quality"`
|
|
|
|
OutputRaw string `json:"output_raw"`
|
|
OutputValues any `json:"output_values"`
|
|
}
|
|
|
|
type RMS struct {
|
|
Callsign string `json:"callsign"`
|
|
Gridsquare string `json:"gridsquare"`
|
|
Distance JSONFloat64 `json:"distance"`
|
|
Azimuth JSONFloat64 `json:"azimuth"`
|
|
Modes string `json:"modes"`
|
|
Freq Frequency `json:"freq"`
|
|
Dial Frequency `json:"dial"`
|
|
URL *JSONURL `json:"url"`
|
|
Prediction *Prediction `json:"prediction"`
|
|
}
|
|
|
|
func (r RMS) IsMode(mode string) bool {
|
|
if mode == MethodVaraFM {
|
|
return strings.HasPrefix(r.Modes, "VARA FM")
|
|
}
|
|
if mode == MethodVaraHF {
|
|
return strings.HasPrefix(r.Modes, "VARA") && !strings.HasPrefix(r.Modes, "VARA FM")
|
|
}
|
|
return strings.Contains(strings.ToLower(r.Modes), mode)
|
|
}
|
|
|
|
func (r RMS) IsBand(band string) bool {
|
|
return bands[band].Contains(r.Freq)
|
|
}
|
|
|
|
type ByLinkQuality []RMS
|
|
|
|
func (r ByLinkQuality) Len() int { return len(r) }
|
|
func (r ByLinkQuality) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
|
|
func (r ByLinkQuality) Less(i, j int) bool {
|
|
quality := func(idx int) int {
|
|
if r[idx].Prediction == nil {
|
|
return -1
|
|
}
|
|
return r[idx].Prediction.LinkQuality
|
|
}
|
|
if iq, jq := quality(i), quality(j); iq != jq {
|
|
return iq < jq
|
|
}
|
|
// Fallback to distance sort (reversed since smaller value is better, as oppose to link quality)
|
|
return sort.Reverse(ByDist(r)).Less(i, j)
|
|
}
|
|
|
|
type ByDist []RMS
|
|
|
|
func (r ByDist) Len() int { return len(r) }
|
|
func (r ByDist) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
|
|
func (r ByDist) Less(i, j int) bool {
|
|
if r[i].Distance != r[j].Distance {
|
|
return r[i].Distance < r[j].Distance
|
|
}
|
|
if r[i].Callsign != r[j].Callsign {
|
|
return r[i].Callsign < r[j].Callsign
|
|
}
|
|
return r[i].Freq < r[j].Freq
|
|
}
|
|
|
|
func (a *App) ReadRMSList(ctx context.Context, forceDownload bool, filterFn func(rms RMS) (keep bool)) ([]RMS, error) {
|
|
me, err := maidenhead.ParseLocator(a.config.Locator)
|
|
if err != nil {
|
|
log.Print("Missing or Invalid Locator, will not compute distance and Azimuth")
|
|
}
|
|
|
|
fileName := "rmslist"
|
|
isDefaultServiceCode := len(a.config.ServiceCodes) == 1 && a.config.ServiceCodes[0] == "PUBLIC"
|
|
if !isDefaultServiceCode {
|
|
fileName += "-" + strings.Join(a.config.ServiceCodes, "-")
|
|
}
|
|
filePath := filepath.Join(directories.DataDir(), fileName+".json")
|
|
debug.Printf("RMS list file is %s", filePath)
|
|
|
|
f, err := cmsapi.GetGatewayStatusCached(ctx, filePath, forceDownload, a.config.ServiceCodes...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
var status cmsapi.GatewayStatus
|
|
if err = json.NewDecoder(f).Decode(&status); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
slice := []RMS{}
|
|
for _, gw := range status.Gateways {
|
|
for _, channel := range gw.Channels {
|
|
r := RMS{
|
|
Callsign: gw.Callsign,
|
|
Gridsquare: channel.Gridsquare,
|
|
Modes: channel.SupportedModes,
|
|
Freq: Frequency(channel.Frequency),
|
|
Dial: Frequency(channel.Frequency).Dial(channel.SupportedModes),
|
|
}
|
|
if chURL := toURL(channel, gw.Callsign); chURL != nil {
|
|
r.URL = &JSONURL{*chURL}
|
|
}
|
|
hasLocator := me != maidenhead.Point{}
|
|
if them, err := maidenhead.ParseLocator(channel.Gridsquare); err == nil && hasLocator {
|
|
r.Distance = JSONFloat64(me.Distance(them))
|
|
r.Azimuth = JSONFloat64(me.Bearing(them))
|
|
}
|
|
if keep := filterFn(r); !keep {
|
|
continue
|
|
}
|
|
slice = append(slice, r)
|
|
}
|
|
}
|
|
|
|
if a.predictor == nil {
|
|
return slice, nil
|
|
}
|
|
|
|
// Grab the forecasted SSN for today
|
|
ssn, err := getSSN(ctx, time.Now())
|
|
if err != nil {
|
|
log.Println(err)
|
|
}
|
|
|
|
// In case this takes a while, add a log statement if it's not within 5 seconds
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
go func() {
|
|
select {
|
|
case <-time.After(5 * time.Second):
|
|
log.Println("Hang tight - calculating propagation predictions...")
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
|
|
// Run prediction (in parallel)
|
|
params := make([]propagation.PredictionParams, len(slice))
|
|
for i, r := range slice {
|
|
params[i] = propagation.PredictionParams{
|
|
From: propagation.Maidenhead(a.Config().Locator),
|
|
To: propagation.Maidenhead(r.Gridsquare),
|
|
Frequency: int(r.Freq),
|
|
SSN: ssn,
|
|
TransmitPower: 50, // TODO: Consider making this configurable
|
|
Time: time.Now(),
|
|
}
|
|
}
|
|
propagation.PredictParallel(ctx, a.predictor, params, func(i int, p *propagation.Prediction, err error) {
|
|
switch {
|
|
case errors.Is(err, propagation.ErrFrequencyOutOfBounds), ctx.Err() != nil:
|
|
case err != nil:
|
|
debug.Printf("Could not predict propagation for %s: %s", slice[i].Callsign, err)
|
|
default:
|
|
slice[i].Prediction = &Prediction{LinkQuality: p.LinkQuality, OutputRaw: p.OutputRaw, OutputValues: p.OutputValues}
|
|
}
|
|
})
|
|
|
|
return slice, nil
|
|
}
|
|
|
|
func getSSN(ctx context.Context, now time.Time) (int, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
|
defer cancel()
|
|
debug.Printf("Fetching SIDC SSN prediction...")
|
|
|
|
// In case this takes a while, add a log statement if it's not ready after one second
|
|
go func() {
|
|
select {
|
|
case <-time.After(time.Second):
|
|
log.Println("Updating SIDC SSN prediction...")
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
|
|
cachePath := filepath.Join(directories.DataDir(), ".predicted-ssn-silso.json")
|
|
predictions, err := silso.GetPredictedSSNCached(ctx, cachePath)
|
|
if err != nil || len(predictions) == 0 {
|
|
return 50, fmt.Errorf("failed to get SSN prediction data: %v", err)
|
|
}
|
|
targetMonth := now.Format("2006-01")
|
|
for _, p := range predictions {
|
|
if p.TimeTag == targetMonth {
|
|
debug.Printf("SSN: %v", p.PredictedSSN)
|
|
return int(p.PredictedSSN), nil
|
|
}
|
|
}
|
|
p := predictions[len(predictions)-1]
|
|
return int(p.PredictedSSN), fmt.Errorf("failed to find SSN prediction for current month. Using %.0f (%s)", p.PredictedSSN, p.TimeTag)
|
|
}
|
|
|
|
func toURL(gc cmsapi.GatewayChannel, targetCall string) *url.URL {
|
|
freq := Frequency(gc.Frequency).Dial(gc.SupportedModes)
|
|
chURL, _ := url.Parse(fmt.Sprintf("%s:///%s?freq=%v", toTransport(gc), targetCall, freq.KHz()))
|
|
addBandwidth(gc, chURL)
|
|
return chURL
|
|
}
|
|
|
|
func addBandwidth(gc cmsapi.GatewayChannel, chURL *url.URL) {
|
|
bw := ""
|
|
modeF := strings.Fields(gc.SupportedModes)
|
|
switch modeF[0] {
|
|
case "ARDOP":
|
|
if len(modeF) > 1 {
|
|
bw = modeF[1] + "MAX"
|
|
}
|
|
case "VARA":
|
|
if len(modeF) > 1 && modeF[1] == "FM" {
|
|
// VARA FM should not set bandwidth in connect URL or sent over the command port,
|
|
// it's set in the VARA Setup dialog
|
|
bw = ""
|
|
} else {
|
|
// VARA HF may be 500, 2750, or none which is implicitly 2300
|
|
if len(modeF) > 1 {
|
|
if len(modeF) > 1 {
|
|
bw = modeF[1]
|
|
}
|
|
} else {
|
|
bw = "2300"
|
|
}
|
|
}
|
|
}
|
|
if bw != "" {
|
|
v := chURL.Query()
|
|
v.Set("bw", bw)
|
|
chURL.RawQuery = v.Encode()
|
|
}
|
|
}
|
|
|
|
var transports = []string{MethodAX25, MethodPactor, MethodArdop, MethodVaraFM, MethodVaraHF}
|
|
|
|
func toTransport(gc cmsapi.GatewayChannel) string {
|
|
modes := strings.ToLower(gc.SupportedModes)
|
|
for _, transport := range transports {
|
|
if strings.Contains(modes, "packet") {
|
|
// bug(maritnhpedersen): We really don't know which transport to use here. It could be serial-tnc or ax25, but ax25 is most likely.
|
|
return MethodAX25
|
|
}
|
|
if strings.HasPrefix(modes, "vara fm") {
|
|
return MethodVaraFM
|
|
}
|
|
if strings.HasPrefix(modes, "vara") {
|
|
return MethodVaraHF
|
|
}
|
|
if strings.Contains(modes, transport) {
|
|
return transport
|
|
}
|
|
}
|
|
return ""
|
|
}
|