├── .gitmodules ├── debian ├── compat ├── pat.manpages ├── .gitignore ├── pat@.service ├── control └── rules ├── web ├── .nvmrc ├── .gitignore ├── .prettierignore ├── src │ ├── static │ │ └── pat_logo.png │ ├── js │ │ └── modules │ │ │ ├── progress-bar │ │ │ └── index.js │ │ │ ├── connect-modal │ │ │ ├── prediction-modal.js │ │ │ └── prediction-popover.js │ │ │ ├── status-text │ │ │ └── index.js │ │ │ ├── notifications │ │ │ └── index.js │ │ │ ├── version │ │ │ └── index.js │ │ │ ├── utils │ │ │ └── index.js │ │ │ ├── mailbox │ │ │ └── index.js │ │ │ └── geolocation │ │ │ └── index.js │ ├── template.html │ └── scss │ │ └── template.scss ├── dist │ ├── static │ │ └── pat_logo.png │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.ttf │ │ └── glyphicons-halflings-regular.woff │ ├── js │ │ ├── style.js │ │ ├── template.js.LICENSE.txt │ │ ├── config.js.LICENSE.txt │ │ ├── app.js.LICENSE.txt │ │ └── style.js.map │ ├── css │ │ └── template.css │ └── template.html ├── .prettierrc └── package.json ├── .gitignore ├── osx ├── Pat-Welcome.rtfd │ ├── TXT.rtf │ └── Pasted Graphic.tiff ├── Pat-Info.rtfd │ └── TXT.rtf └── Pat-License.txt ├── internal ├── forms │ ├── docs │ │ └── RMSE_FORMS │ │ │ └── source.txt │ ├── date_test.go │ ├── prompt_test.go │ ├── date.go │ ├── placeholder_test.go │ ├── io.go │ ├── sequence.go │ ├── unzip.go │ ├── placeholder.go │ ├── position.go │ ├── prompt.go │ └── builder_test.go ├── cmsapi │ ├── gateway_status.json.gz │ ├── hybrid_station.go │ ├── api_test.go │ ├── password_recovery.go │ ├── client.go │ └── mps.go ├── buildinfo │ ├── gitrev_legacy.go │ ├── gitrev.go │ ├── VERSION.go │ └── strings.go ├── osutil │ ├── rlimit_windows.go │ ├── rlimit_freebsd.go │ └── rlimit_unix.go ├── debug │ └── debug.go ├── propagation │ ├── voacap │ │ ├── testdata │ │ │ └── input.dat │ │ ├── encode_test.go │ │ └── parse_test.go │ ├── silso │ │ ├── KFprediCM.txt │ │ ├── testdata │ │ │ └── KFprediCM.txt │ │ └── silso_test.go │ ├── executable.go │ ├── propagation.go │ ├── voacap_api.go │ └── caching.go ├── patapi │ └── releases.go ├── editor │ └── editor.go ├── gpsd │ └── objects.go └── directories │ └── directories.go ├── cfg ├── ax25_engine_libax25.go ├── ax25_engine_other.go ├── ax25_engine.go └── prediction_engine.go ├── .editorconfig ├── docker-compose.yml ├── share ├── ax25 │ ├── README.systemd │ ├── mheardd.service │ ├── ax25.service │ ├── install-systemd-ax25-unit.bash │ ├── soundmodem-example.conf │ └── ax25.default ├── ardopc │ ├── ardop@.service │ └── install-systemd-ardop-unit.bash ├── rigctld │ ├── rigctld.service │ ├── rigctld.default │ └── install-systemd-rigctld-unit.bash └── bin │ └── axup ├── cli ├── env.go ├── help.go ├── extract.go ├── http.go ├── configure.go ├── riglist.go ├── schedule.go ├── templates.go ├── version.go ├── account.go ├── rmslist.go ├── connect.go ├── position.go ├── utils.go ├── prompter.go └── utils_test.go ├── app ├── utils.go ├── account_activation_test.go ├── wshub.go ├── command.go ├── env.go ├── event_log.go ├── gpsd_locator.go ├── config_test.go ├── attachment.go ├── listener_hub_test.go ├── freq.go ├── prompt_hub.go ├── account_activation.go ├── rmslist_test.go └── listener_hub.go ├── sighup_windows.go ├── sighup_unix.go ├── man ├── pat-configure.1 └── pat.1 ├── Dockerfile ├── api ├── types │ ├── prompt.go │ └── types.go └── winlink_account.go ├── LICENSE ├── .github └── workflows │ ├── docker.yaml │ └── go.yaml ├── go.mod ├── make.bash ├── CONTRIBUTING.md └── README.md /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 7 2 | -------------------------------------------------------------------------------- /web/.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen -------------------------------------------------------------------------------- /debian/pat.manpages: -------------------------------------------------------------------------------- 1 | man/*.1 2 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /debian/.gitignore: -------------------------------------------------------------------------------- 1 | pat/ 2 | files 3 | pat.debhelper.log 4 | pat.substvars 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | pat 3 | pat*.pkg 4 | docker-data/ 5 | .aider* 6 | *.swp 7 | -------------------------------------------------------------------------------- /web/src/static/pat_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architecture/pat/master/web/src/static/pat_logo.png -------------------------------------------------------------------------------- /osx/Pat-Welcome.rtfd/TXT.rtf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architecture/pat/master/osx/Pat-Welcome.rtfd/TXT.rtf -------------------------------------------------------------------------------- /web/dist/static/pat_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architecture/pat/master/web/dist/static/pat_logo.png -------------------------------------------------------------------------------- /internal/forms/docs/RMSE_FORMS/source.txt: -------------------------------------------------------------------------------- 1 | https://www.winlink.org/sites/default/files/RMSE_FORMS/insertion_tags.zip 2 | -------------------------------------------------------------------------------- /internal/cmsapi/gateway_status.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architecture/pat/master/internal/cmsapi/gateway_status.json.gz -------------------------------------------------------------------------------- /osx/Pat-Welcome.rtfd/Pasted Graphic.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architecture/pat/master/osx/Pat-Welcome.rtfd/Pasted Graphic.tiff -------------------------------------------------------------------------------- /web/dist/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architecture/pat/master/web/dist/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /web/dist/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architecture/pat/master/web/dist/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /web/dist/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/architecture/pat/master/web/dist/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /cfg/ax25_engine_libax25.go: -------------------------------------------------------------------------------- 1 | //go:build libax25 2 | // +build libax25 3 | 4 | package cfg 5 | 6 | func DefaultAX25Engine() AX25Engine { return AX25EngineLinux } 7 | -------------------------------------------------------------------------------- /cfg/ax25_engine_other.go: -------------------------------------------------------------------------------- 1 | //go:build !libax25 2 | // +build !libax25 3 | 4 | package cfg 5 | 6 | func DefaultAX25Engine() AX25Engine { return AX25EngineAGWPE } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,html,scss}] 2 | indent_style = space 3 | indent_size = 2 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | end_of_line = lf 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | pat: 3 | image: la5nta/pat 4 | build: . 5 | volumes: 6 | - ./docker-data:/app/pat 7 | ports: 8 | - 8080:8080 9 | -------------------------------------------------------------------------------- /internal/buildinfo/gitrev_legacy.go: -------------------------------------------------------------------------------- 1 | //go:build !go1.18 2 | // +build !go1.18 3 | 4 | package buildinfo 5 | 6 | // GitRev is the git commit hash that the binary was built at. 7 | var GitRev = "unknown origin" // Set by make.bash 8 | -------------------------------------------------------------------------------- /share/ax25/README.systemd: -------------------------------------------------------------------------------- 1 | For AX.25 as a systemd service: 2 | * Edit and copy ax25.service into /etc/systemd/system/ax25.service. 3 | * sudo systemctl enable ax25 4 | * sudo systemctl start ax25 5 | * systemctl status ax25 (optional) 6 | -------------------------------------------------------------------------------- /share/ax25/mheardd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=AX.25 mheard daemon 3 | After=ax25.service 4 | 5 | [Service] 6 | Type=forking 7 | ExecStart=/bin/sh -c "/usr/sbin/mheardd -l &" 8 | 9 | [Install] 10 | WantedBy=default.target 11 | -------------------------------------------------------------------------------- /internal/osutil/rlimit_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package osutil 5 | 6 | import "fmt" 7 | 8 | func RaiseOpenFileLimit(max uint64) error { 9 | return fmt.Errorf("Not available for Windows") 10 | } 11 | -------------------------------------------------------------------------------- /cli/env.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/la5nta/pat/app" 9 | ) 10 | 11 | func EnvHandle(_ context.Context, app *app.App, _ []string) { 12 | fmt.Println(strings.Join(app.Env(), "\n")) 13 | } 14 | -------------------------------------------------------------------------------- /share/ax25/ax25.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=AX.25 KISS interface 3 | After=network.target 4 | 5 | [Service] 6 | EnvironmentFile=/etc/default/ax25 7 | Type=forking 8 | ExecStart=/usr/share/pat/bin/axup ${DEV} ${AXPORT} ${HBAUD} 9 | 10 | [Install] 11 | WantedBy=default.target 12 | -------------------------------------------------------------------------------- /share/ardopc/ardop@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ardopc - ARDOP softmodem for %i 3 | After=network.target sound.target 4 | 5 | [Service] 6 | User=%i 7 | ExecStart=/bin/sh -c "cd /tmp && /usr/local/bin/ardopc" 8 | Restart=on-failure 9 | 10 | [Install] 11 | WantedBy=multi-user.target 12 | -------------------------------------------------------------------------------- /debian/pat@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=pat - Winlink client for %I 3 | Documentation=https://github.com/la5nta/pat/wiki 4 | After=ax25.service network.target 5 | 6 | [Service] 7 | User=%i 8 | ExecStart=/usr/bin/pat http 9 | Restart=on-failure 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /share/rigctld/rigctld.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Rigcontrol with rigctld 3 | After=network.target 4 | 5 | [Service] 6 | EnvironmentFile=/etc/default/rigctld 7 | ExecStart=/usr/bin/rigctld -m ${MODEL} -r ${DEV} ${EXTRA_OPTS} 8 | Restart=on-failure 9 | 10 | [Install] 11 | WantedBy=default.target 12 | -------------------------------------------------------------------------------- /app/utils.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved. 2 | // Use of this source code is governed by the MIT-license that can be 3 | // found in the LICENSE file. 4 | 5 | package app 6 | 7 | import ( 8 | "unicode" 9 | ) 10 | 11 | func SplitFunc(c rune) bool { 12 | return unicode.IsSpace(c) || c == ',' || c == ';' 13 | } 14 | -------------------------------------------------------------------------------- /share/rigctld/rigctld.default: -------------------------------------------------------------------------------- 1 | # Configuration for rigctld systemd unit file from Pat. 2 | 3 | # Radio model number (-m) 4 | # (run rigctl -l to find yours) 5 | MODEL=1 6 | 7 | # Path to your rig's tty device (-r) 8 | DEV=/dev/ttyUSB0 9 | 10 | # Additional parameters passed to rigctld 11 | # (this example sets serial baud 9600) 12 | #EXTRA_OPTS="-s 9600" 13 | -------------------------------------------------------------------------------- /cli/help.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/spf13/pflag" 5 | ) 6 | 7 | func HelpHandle(args []string) { 8 | // Print usage for the specified command 9 | arg := args[0] 10 | for _, cmd := range Commands { 11 | if cmd.Str == arg { 12 | cmd.PrintUsage() 13 | return 14 | } 15 | } 16 | 17 | // Fallback to main help text 18 | pflag.Usage() 19 | } 20 | -------------------------------------------------------------------------------- /share/ardopc/install-systemd-ardop-unit.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | 6 | [ -e "/etc/systemd/system/ardop@.service" ] && rm /etc/systemd/system/ardop@.service 7 | cp "$DIR/ardop@.service" "/lib/systemd/system/ardop@.service" 8 | systemctl daemon-reload 9 | 10 | echo "Installed. Install (pi)ardopc as /usr/local/bin/ardopc and start it with 'systemctl start ardop@username'" 11 | -------------------------------------------------------------------------------- /share/ax25/install-systemd-ax25-unit.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | 6 | [ -e "/etc/systemd/system/ax25.service" ] && rm /etc/systemd/system/ax25.service 7 | cp "$DIR/ax25.service" "/lib/systemd/system/ax25.service" 8 | [ -e "/etc/default/ax25" ] || cp "$DIR/ax25.default" "/etc/default/ax25" 9 | systemctl daemon-reload 10 | 11 | echo "Installed. Edit /etc/default/ax25 and start with 'systemctl start ax25'" 12 | -------------------------------------------------------------------------------- /internal/debug/debug.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | ) 8 | 9 | const ( 10 | EnvVar = "PAT_DEBUG" 11 | Prefix = "[DEBUG] " 12 | ) 13 | 14 | var enabled bool 15 | 16 | func init() { 17 | enabled, _ = strconv.ParseBool(os.Getenv(EnvVar)) 18 | } 19 | 20 | func Enabled() bool { return enabled } 21 | 22 | func Printf(format string, v ...interface{}) { 23 | if !enabled { 24 | return 25 | } 26 | log.Printf(Prefix+format, v...) 27 | } 28 | -------------------------------------------------------------------------------- /internal/buildinfo/gitrev.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | // +build go1.18 3 | 4 | package buildinfo 5 | 6 | import "runtime/debug" 7 | 8 | // GitRev is the git commit hash that the binary was built at. 9 | var GitRev = func() string { 10 | if info, ok := debug.ReadBuildInfo(); ok { 11 | for _, setting := range info.Settings { 12 | if setting.Key == "vcs.revision" && len(setting.Value) > 7 { 13 | return setting.Value[:7] 14 | } 15 | } 16 | } 17 | return "unknown origin" 18 | }() 19 | -------------------------------------------------------------------------------- /sighup_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Martin Hebnes Pedersen (LA5NTA). All rights reserved. 2 | // Use of this source code is governed by the MIT-license that can be 3 | // found in the LICENSE file. 4 | 5 | //go:build windows 6 | 7 | package main 8 | 9 | import ( 10 | "os" 11 | "os/signal" 12 | ) 13 | 14 | func notifySignals() <-chan os.Signal { 15 | sig := make(chan os.Signal, 1) 16 | signal.Notify(sig, os.Interrupt) 17 | return sig 18 | } 19 | 20 | func isSIGHUP(s os.Signal) bool { return false } 21 | -------------------------------------------------------------------------------- /share/rigctld/install-systemd-rigctld-unit.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | 6 | [ -e "/etc/systemd/system/rigctld.service" ] && rm /etc/systemd/system/rigctld.service 7 | cp "$DIR/rigctld.service" "/lib/systemd/system/rigctld.service" 8 | [ -e "/etc/default/rigctld" ] || cp "$DIR/rigctld.default" "/etc/default/rigctld" 9 | systemctl daemon-reload 10 | 11 | echo "Installed. Edit /etc/default/rigctld and start with 'systemctl start rigctld'" 12 | -------------------------------------------------------------------------------- /sighup_unix.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Martin Hebnes Pedersen (LA5NTA). All rights reserved. 2 | // Use of this source code is governed by the MIT-license that can be 3 | // found in the LICENSE file. 4 | 5 | //go:build !windows 6 | 7 | package main 8 | 9 | import ( 10 | "os" 11 | "os/signal" 12 | "syscall" 13 | ) 14 | 15 | func notifySignals() <-chan os.Signal { 16 | sig := make(chan os.Signal, 1) 17 | signal.Notify(sig, os.Interrupt, syscall.SIGHUP) 18 | return sig 19 | } 20 | 21 | func isSIGHUP(s os.Signal) bool { return s == syscall.SIGHUP } 22 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: pat 2 | Section: ham 3 | Priority: extra 4 | Maintainer: Martin Hebnes Pedersen 5 | Homepage: http://getpat.io 6 | Build-Depends: debhelper (>= 7.0.50~), golang (>= 2:1.16), libax25, libax25-dev 7 | Standards-Version: 3.9.1 8 | 9 | Package: pat 10 | Architecture: amd64 i386 armhf arm64 11 | Conflicts: wl2k-go, dist 12 | Replaces: wl2k-go 13 | Recommends: libhamlib-utils (>= 1.2), ax25-tools, gpsd (>= 2.90), voacapl 14 | Suggests: tmd710-tncsetup 15 | Description: A portable Winlink client for amateur radio email. 16 | -------------------------------------------------------------------------------- /cli/extract.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/la5nta/pat/app" 10 | ) 11 | 12 | func ExtractMessageHandle(_ context.Context, app *app.App, args []string) { 13 | if len(args) == 0 || args[0] == "" { 14 | fmt.Println("Missing argument, try 'extract help'.") 15 | os.Exit(1) 16 | } 17 | 18 | msg, err := openMessage(app, args[0]) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | fmt.Println(msg) 23 | for _, f := range msg.Files() { 24 | if err := os.WriteFile(f.Name(), f.Data(), 0o664); err != nil { 25 | log.Fatal(err) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/account_activation_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Martin Hebnes Pedersen (LA5NTA). All rights reserved. 2 | // Use of this source code is governed by the MIT-license that can be 3 | // found in the LICENSE file. 4 | 5 | package app 6 | 7 | import "testing" 8 | 9 | func TestIsAccountActivationMessage(t *testing.T) { 10 | msg := mockNewAccountMsg() 11 | isActivation, password := isAccountActivationMessage(msg) 12 | if !isActivation { 13 | t.Errorf("Expected isActivation to be true, but was false") 14 | } 15 | if password != "K1CHN7" { 16 | t.Errorf("Expected password to be 'K1CHN7', but was '%s'", password) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/buildinfo/VERSION.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Martin Hebnes Pedersen (LA5NTA). All rights reserved. 2 | // Use of this source code is governed by the MIT-license that can be 3 | // found in the LICENSE file. 4 | 5 | package buildinfo 6 | 7 | const ( 8 | // AppName is the friendly name of the app. 9 | // 10 | // Forks should consider using a different name. 11 | AppName = "Pat" 12 | 13 | // Version is the app's SemVer. 14 | // 15 | // Forks should NOT bump this unless they use a unique AppName. The Winlink 16 | // system uses this to derive the "these users should upgrade" wall of shame 17 | // from CMS connects. 18 | Version = "0.19.0" 19 | ) 20 | -------------------------------------------------------------------------------- /osx/Pat-Info.rtfd/TXT.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf1404\cocoasubrtf470 2 | {\fonttbl\f0\fswiss\fcharset0 Helvetica;\f1\fnil\fcharset0 Menlo-Regular;} 3 | {\colortbl;\red255\green255\blue255;} 4 | \margl1440\margr1440\vieww12540\viewh16140\viewkind1 5 | \pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\qc\partightenfactor0 6 | 7 | \f0\fs24 \cf0 This will install 8 | \f1 pat 9 | \f0 into 10 | \f1 /usr/local/bin 11 | \f0 . \ 12 | To run 13 | \f1 pat 14 | \f0 , use Terminal.app in the 15 | \f1 /Applications/Utilities 16 | \f0 folder.\ 17 | \ 18 | 19 | \fs26 For more help and information, visit getpat.io} -------------------------------------------------------------------------------- /share/ax25/soundmodem-example.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /cfg/ax25_engine.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | const ( 9 | AX25EngineAGWPE AX25Engine = "agwpe" 10 | AX25EngineLinux = "linux" 11 | AX25EngineSerialTNC = "serial-tnc" 12 | ) 13 | 14 | type AX25Engine string 15 | 16 | func (a *AX25Engine) UnmarshalJSON(p []byte) error { 17 | var str string 18 | if err := json.Unmarshal(p, &str); err != nil { 19 | return err 20 | } 21 | switch v := AX25Engine(str); v { 22 | case AX25EngineLinux, AX25EngineAGWPE, AX25EngineSerialTNC: 23 | *a = v 24 | return nil 25 | default: 26 | return fmt.Errorf("invalid AX.25 engine '%s'", v) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/cmsapi/hybrid_station.go: -------------------------------------------------------------------------------- 1 | package cmsapi 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | ) 7 | 8 | const PathHybridStationList = "/hybridStation/list" 9 | 10 | type HybridStation struct { 11 | Callsign string 12 | AutomaticForwarding bool 13 | ManualForwarding bool 14 | } 15 | 16 | func HybridStationList(ctx context.Context) ([]HybridStation, error) { 17 | var resp struct { 18 | HybridList []HybridStation 19 | ResponseStatus responseStatus 20 | } 21 | 22 | if err := getJSON(ctx, PathHybridStationList, url.Values{}, &resp); err != nil { 23 | return nil, err 24 | } 25 | 26 | return resp.HybridList, resp.ResponseStatus.errorOrNil() 27 | } 28 | -------------------------------------------------------------------------------- /internal/propagation/voacap/testdata/input.dat: -------------------------------------------------------------------------------- 1 | COMMENT 5.3 MHz, JP20QH -> JO59JS @ 2025-07-01T20:00:00Z 2 | LINEMAX 55 3 | COEFFS CCIR 4 | TIME 20 20 1 1 5 | MONTH 2025 7.00 6 | SUNSPOT 139.00 7 | LABEL JP20QH JO59JS 8 | CIRCUIT 60.29N 5.33E 59.75N 10.75E S 0 9 | SYSTEM 1.00 145 0.10 15-20.00 0.50 0.10 10 | FPROB 1.00 1.00 1.00 0.00 11 | ANTENNA 1 1 2 30 5.350[ ] 90.0 0.1000 12 | ANTENNA 2 2 2 30 5.350[ ]270.0 0.1000 13 | FREQUENCY 5.350 14 | METHOD 30 0 15 | EXECUTE 16 | QUIT 17 | -------------------------------------------------------------------------------- /internal/propagation/silso/KFprediCM.txt: -------------------------------------------------------------------------------- 1 | 2025 02 2025.125 : 148.6 0 2 | 2025 03 2025.208 : 145.6 0 3 | 2025 04 2025.292 : 143.1 0 4 | 2025 05 2025.375 : 130.7 7.1 5 | 2025 06 2025.458 : 127.2 7.7 6 | 2025 07 2025.542 : 125.0 8.5 7 | 2025 08 2025.625 : 121.8 9.7 8 | 2025 09 2025.708 : 118.0 10.5 9 | 2025 10 2025.792 : 115.2 11.4 10 | 2025 11 2025.875 : 114.0 12.4 11 | 2025 12 2025.958 : 113.2 13.3 12 | 2026 01 2026.042 : 112.2 14.1 13 | 2026 02 2026.125 : 111.1 14.9 14 | 2026 03 2026.208 : 110.0 15.4 15 | 2026 04 2026.292 : 108.3 15.7 16 | 2026 05 2026.375 : 106.4 16.1 17 | 2026 06 2026.458 : 103.9 16.2 18 | 2026 07 2026.542 : 101.0 16.3 19 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | 4 | PKGDIR=debian/pat 5 | 6 | %: 7 | dh $@ 8 | 9 | clean: 10 | dh_clean 11 | rm -rf $(PKGDIR) 12 | 13 | build: 14 | ./make.bash 15 | 16 | binary-arch: clean build 17 | dh_prep 18 | dh_installdirs 19 | 20 | mkdir -p $(PKGDIR)/usr/bin 21 | mkdir -p $(PKGDIR)/usr/share/pat 22 | mkdir -p $(PKGDIR)/lib/systemd/system 23 | 24 | mv ./pat $(PKGDIR)/usr/bin/ 25 | cp -r share/* $(PKGDIR)/usr/share/pat/ 26 | cp debian/pat@.service $(PKGDIR)/lib/systemd/system/ 27 | 28 | dh_installman 29 | dh_strip 30 | dh_compress 31 | dh_fixperms 32 | dh_installdeb 33 | dh_gencontrol 34 | dh_md5sums 35 | dh_builddeb 36 | 37 | binary: binary-arch 38 | 39 | -------------------------------------------------------------------------------- /internal/propagation/silso/testdata/KFprediCM.txt: -------------------------------------------------------------------------------- 1 | 2025 01 2025.042 : 153.1 0 2 | 2025 02 2025.125 : 150.5 0 3 | 2025 03 2025.208 : 147.5 0 4 | 2025 04 2025.292 : 137.4 7.4 5 | 2025 05 2025.375 : 132.0 8.0 6 | 2025 06 2025.458 : 128.0 8.7 7 | 2025 07 2025.542 : 124.4 9.7 8 | 2025 08 2025.625 : 120.3 10.5 9 | 2025 09 2025.708 : 116.9 11.3 10 | 2025 10 2025.792 : 114.4 12.2 11 | 2025 11 2025.875 : 112.3 12.9 12 | 2025 12 2025.958 : 110.4 13.5 13 | 2026 01 2026.042 : 108.5 14.1 14 | 2026 02 2026.125 : 108.1 14.8 15 | 2026 03 2026.208 : 107.3 15.3 16 | 2026 04 2026.292 : 106.0 15.7 17 | 2026 05 2026.375 : 104.1 16.0 18 | 2026 06 2026.458 : 101.6 16.1 19 | -------------------------------------------------------------------------------- /man/pat-configure.1: -------------------------------------------------------------------------------- 1 | .TH PAT 1 "2017-09-04" "" "Pat Configure" 2 | .SH NAME 3 | pat configure \- opens Pat's configuration file using the system default editor 4 | .SH Configuration 5 | .SS Main Configuration 6 | The current configuration file is located in the \fIPAT_CONFIG_PATH\fP returned from \fIpat env\fP 7 | .sp 1 8 | To get "on the air" you'll first have to set up your callsign, maidenhead locator, and secure login credentials. Look for the attributes \fImycall\fP, \fIlocator\fP and \fIsecure_login_password\fP and set them appropriately. 9 | .sp 1 10 | .in 20 11 | { 12 | "mycall": "LA5NTA", 13 | "locator": "JP20qe", 14 | "secure_login_password": "MYPASSWORD", 15 | } 16 | .in 17 | .SH "See Also" 18 | pat(1) 19 | -------------------------------------------------------------------------------- /internal/osutil/rlimit_freebsd.go: -------------------------------------------------------------------------------- 1 | //go:build freebsd 2 | // +build freebsd 3 | 4 | package osutil 5 | 6 | import ( 7 | "fmt" 8 | "syscall" 9 | ) 10 | 11 | // RaiseOpenFileLimit tries to maximize the limit of open file descriptors, limited by max or the OS's hard limit 12 | func RaiseOpenFileLimit(max uint64) error { 13 | var limit syscall.Rlimit 14 | if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &limit); err != nil { 15 | return fmt.Errorf("Could not get current limit: %v", err) 16 | } 17 | if limit.Cur >= limit.Max || limit.Cur >= int64(max) { 18 | return nil 19 | } 20 | limit.Cur = limit.Max 21 | if limit.Cur > int64(max) { 22 | limit.Cur = int64(max) 23 | } 24 | return syscall.Setrlimit(syscall.RLIMIT_NOFILE, &limit) 25 | } 26 | -------------------------------------------------------------------------------- /internal/osutil/rlimit_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !freebsd 2 | // +build !windows,!freebsd 3 | 4 | package osutil 5 | 6 | import ( 7 | "fmt" 8 | "syscall" 9 | ) 10 | 11 | // RaiseOpenFileLimit tries to maximize the limit of open file descriptors, limited by max or the OS's hard limit 12 | func RaiseOpenFileLimit(max uint64) error { 13 | var limit syscall.Rlimit 14 | if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &limit); err != nil { 15 | return fmt.Errorf("could not get current limit: %w", err) 16 | } 17 | if limit.Cur >= limit.Max || limit.Cur >= max { 18 | return nil 19 | } 20 | limit.Cur = limit.Max 21 | if limit.Cur > max { 22 | limit.Cur = max 23 | } 24 | return syscall.Setrlimit(syscall.RLIMIT_NOFILE, &limit) 25 | } 26 | -------------------------------------------------------------------------------- /cli/http.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/la5nta/pat/api" 9 | "github.com/la5nta/pat/app" 10 | 11 | "github.com/spf13/pflag" 12 | ) 13 | 14 | func HTTPHandle(ctx context.Context, a *app.App, args []string) { 15 | addr := a.Config().HTTPAddr 16 | if addr == "" { 17 | addr = ":8080" // For backwards compatibility (remove in future) 18 | } 19 | 20 | set := pflag.NewFlagSet("http", pflag.ExitOnError) 21 | set.StringVarP(&addr, "addr", "a", addr, "Listen address.") 22 | set.Parse(args) 23 | 24 | if addr == "" { 25 | set.Usage() 26 | os.Exit(1) 27 | } 28 | 29 | scheduleLoop(ctx, a) 30 | 31 | if err := api.ListenAndServe(ctx, a, addr); err != nil { 32 | log.Println(err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/forms/date_test.go: -------------------------------------------------------------------------------- 1 | package forms 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestDateFormat(t *testing.T) { 9 | location = time.FixedZone("UTC-4", -4*60*60) 10 | now := time.Date(2023, 12, 31, 23, 59, 59, 0, location) 11 | 12 | tests := []struct { 13 | fn func(t time.Time) string 14 | expect string 15 | }{ 16 | {formatDateTime, "2023-12-31 23:59:59"}, 17 | {formatDateTimeUTC, "2024-01-01 03:59:59Z"}, 18 | {formatDate, "2023-12-31"}, 19 | {formatTime, "23:59:59"}, 20 | {formatDateUTC, "2024-01-01Z"}, 21 | {formatTimeUTC, "03:59:59Z"}, 22 | {formatUDTG, "010359Z JAN 2024"}, 23 | } 24 | 25 | for i, tt := range tests { 26 | if got := tt.fn(now); got != tt.expect { 27 | t.Errorf("%d: got %q expected %q", i, got, tt.expect) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/wshub.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/la5nta/pat/api/types" 4 | 5 | type WSHub interface { 6 | UpdateStatus() 7 | WriteProgress(types.Progress) 8 | WriteNotification(types.Notification) 9 | Prompt(Prompt) 10 | NumClients() int 11 | ClientAddrs() []string 12 | Close() error 13 | } 14 | 15 | type noopWSSocket struct{} 16 | 17 | func (noopWSSocket) UpdateStatus() {} 18 | func (noopWSSocket) WriteProgress(types.Progress) {} 19 | func (noopWSSocket) WriteNotification(types.Notification) {} 20 | func (noopWSSocket) Prompt(Prompt) {} 21 | func (noopWSSocket) NumClients() int { return 0 } 22 | func (noopWSSocket) ClientAddrs() []string { return []string{} } 23 | func (noopWSSocket) Close() error { return nil } 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as builder 2 | RUN apk add --no-cache git ca-certificates 3 | WORKDIR /src 4 | ADD go.mod go.sum ./ 5 | RUN go mod download 6 | ADD . . 7 | RUN go build -o /src/pat 8 | 9 | FROM scratch 10 | LABEL org.opencontainers.image.source=https://github.com/la5nta/pat 11 | LABEL org.opencontainers.image.description="Pat - A portable Winlink client for amateur radio email" 12 | LABEL org.opencontainers.image.licenses=MIT 13 | # Make sure we have a /tmp directory with the correct permissions (01777) 14 | ADD .docker/tmp.tar / 15 | COPY --from=builder /etc/ssl/certs /etc/ssl/certs 16 | COPY --from=builder /src/pat /bin/pat 17 | USER 65534:65534 18 | WORKDIR /app 19 | ENV XDG_CONFIG_HOME=/app 20 | ENV XDG_DATA_HOME=/app 21 | ENV XDG_STATE_HOME=/app 22 | ENV PAT_HTTPADDR=:8080 23 | EXPOSE 8080 24 | ENTRYPOINT ["/bin/pat"] 25 | CMD ["http"] 26 | -------------------------------------------------------------------------------- /share/ax25/ax25.default: -------------------------------------------------------------------------------- 1 | # Configuration for AX.25 systemd unit file from Pat. 2 | 3 | # The axport from /etc/ax25/axports to bring up. 4 | AXPORT=wl2k 5 | 6 | # The AX.25 baudrate the TNC is configured for. 7 | # Make sure this matches the HBAUD setting of your TNC. 8 | HBAUD=1200 9 | 10 | # The TNC serial path. 11 | DEV=/dev/ttyUSB0 12 | 13 | # Script for initializing the TNC. 14 | # 15 | # This optional parameter is convenient when dealing with TNCs that require 16 | # additional initialization, e.g. entering KISS mode. Modify to fit your own needs. 17 | # 18 | #TNC_INIT_CMD="/usr/local/bin/my_tnc_init_script --serial-tty $DEV --hbaud $HBAUD" 19 | # 20 | # Example (for Kenwood TH-D7x and TM-D7x0): 21 | # Download, modify, compile and install https://github.com/fmarier/tmd710_tncsetup 22 | #TNC_INIT_CMD="/usr/local/bin/tmd710_tncsetup -B 1 -S $DEV -b $HBAUD" 23 | -------------------------------------------------------------------------------- /cfg/prediction_engine.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | type PredictionEngine string 10 | 11 | const ( 12 | PredictionEngineAuto PredictionEngine = "" 13 | PredictionEngineDisabled PredictionEngine = "disabled" 14 | PredictionEngineVOACAP PredictionEngine = "voacap" 15 | PredictionEngineVOACAPAPI PredictionEngine = "voacap-api" 16 | ) 17 | 18 | func (p *PredictionEngine) UnmarshalJSON(b []byte) error { 19 | var str string 20 | if err := json.Unmarshal(b, &str); err != nil { 21 | return err 22 | } 23 | switch v := PredictionEngine(strings.ToLower(strings.TrimSpace(str))); v { 24 | case PredictionEngineVOACAP, PredictionEngineVOACAPAPI, PredictionEngineDisabled, PredictionEngineAuto: 25 | *p = v 26 | return nil 27 | default: 28 | return fmt.Errorf("invalid prediction engine '%s'", v) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/buildinfo/strings.go: -------------------------------------------------------------------------------- 1 | package buildinfo 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | // VersionString returns a very descriptive version including the app SemVer, git rev plus the 9 | // Golang OS, architecture and version. 10 | func VersionString() string { 11 | return fmt.Sprintf("%s %s/%s - %s", 12 | VersionStringShort(), runtime.GOOS, runtime.GOARCH, runtime.Version()) 13 | } 14 | 15 | // VersionStringShort returns the app SemVer and git rev. 16 | func VersionStringShort() string { 17 | return fmt.Sprintf("v%s (%s)", Version, GitRev) 18 | } 19 | 20 | // UserAgent returns a suitable HTTP user agent string containing app name, SemVer, git rev, plus 21 | // the Golang OS, architecture and version. 22 | func UserAgent() string { 23 | return fmt.Sprintf("%v/%v (%v) %v (%v; %v)", 24 | AppName, Version, GitRev, runtime.Version(), runtime.GOOS, runtime.GOARCH) 25 | } 26 | -------------------------------------------------------------------------------- /internal/forms/prompt_test.go: -------------------------------------------------------------------------------- 1 | package forms 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestReplaceSelect(t *testing.T) { 9 | tests := []struct { 10 | In, Expect string 11 | Answer func(Select) Option 12 | }{ 13 | { 14 | In: "", 15 | Expect: "", 16 | Answer: nil, 17 | }, 18 | { 19 | In: "foobar", 20 | Expect: "foobar", 21 | Answer: nil, 22 | }, 23 | { 24 | In: `Subj: //WL2K