Merge branch 'lab/jayreflector'
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
build/bin/
|
build/bin/
|
||||||
frontend/wailsjs/
|
frontend/wailsjs/
|
||||||
@@ -2,9 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gitea.neitzel.de/konrad/funknetz/audio"
|
"gitea.neitzel.de/konrad/funknetz/audio"
|
||||||
"gitea.neitzel.de/konrad/funknetz/station"
|
"gitea.neitzel.de/konrad/funknetz/station"
|
||||||
@@ -47,18 +44,10 @@ func (a *App) HoldSpace() { a.station.HoldSpace() }
|
|||||||
// ReleaseSpace reports that Space was released.
|
// ReleaseSpace reports that Space was released.
|
||||||
func (a *App) ReleaseSpace() { a.station.ReleaseSpace() }
|
func (a *App) ReleaseSpace() { a.station.ReleaseSpace() }
|
||||||
|
|
||||||
func configPath() (string, error) {
|
func openStation(args []string) (*station.Station, error) {
|
||||||
home, err := os.UserHomeDir()
|
path, err := resolveConfigPath(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
|
||||||
return filepath.Join(home, ".funknetz.json"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func openStation() (*station.Station, error) {
|
|
||||||
path, err := configPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not be read")
|
|
||||||
}
|
}
|
||||||
return station.New(station.Options{ConfigPath: path})
|
return station.New(station.Options{ConfigPath: path})
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-41
@@ -12,47 +12,51 @@ import (
|
|||||||
|
|
||||||
const stationRate = 16000
|
const stationRate = 16000
|
||||||
|
|
||||||
// Start opens the default input and output and converts them to and from
|
// Start opens the default capture device and the default playback device and
|
||||||
// 16 kHz mono. It returns when the devices are running. They close when ctx ends.
|
// converts them to and from 16 kHz mono. It returns when both devices are
|
||||||
|
// running. They close when ctx ends.
|
||||||
func Start(ctx context.Context, capture func([]int16), playback func() []int16) error {
|
func Start(ctx context.Context, capture func([]int16), playback func() []int16) error {
|
||||||
mctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
|
mctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cfg := malgo.DefaultDeviceConfig(malgo.Duplex)
|
|
||||||
cfg.SampleRate = stationRate
|
|
||||||
cfg.Capture.Format = malgo.FormatS16
|
|
||||||
cfg.Capture.Channels = 1
|
|
||||||
cfg.Playback.Format = malgo.FormatS16
|
|
||||||
cfg.Playback.Channels = 1
|
|
||||||
|
|
||||||
var playRes, capRes converter
|
capCfg := malgo.DefaultDeviceConfig(malgo.Capture)
|
||||||
|
capCfg.SampleRate = stationRate
|
||||||
|
capCfg.Capture.Format = malgo.FormatS16
|
||||||
|
capCfg.Capture.Channels = 1
|
||||||
|
|
||||||
|
playCfg := malgo.DefaultDeviceConfig(malgo.Playback)
|
||||||
|
playCfg.SampleRate = stationRate
|
||||||
|
playCfg.Playback.Format = malgo.FormatS16
|
||||||
|
playCfg.Playback.Channels = 1
|
||||||
|
|
||||||
|
var capRes, playRes converter
|
||||||
var playBuf []int16
|
var playBuf []int16
|
||||||
callbacks := malgo.DeviceCallbacks{
|
|
||||||
Data: func(output, input []byte, framecount uint32) {
|
capDev, err := malgo.InitDevice(mctx.Context, capCfg, malgo.DeviceCallbacks{
|
||||||
if len(input) > 0 {
|
Data: func(_, input []byte, framecount uint32) {
|
||||||
channels := 1
|
if len(input) == 0 {
|
||||||
if framecount > 0 {
|
return
|
||||||
channels = len(input) / int(framecount) / 2
|
|
||||||
}
|
|
||||||
if channels < 1 {
|
|
||||||
channels = 1
|
|
||||||
}
|
|
||||||
pcm := capRes.Convert(downmix(bytesToS16(input), channels))
|
|
||||||
if len(pcm) > 0 {
|
|
||||||
capture(pcm)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
channels := channelCount(len(input), framecount)
|
||||||
|
pcm := capRes.Convert(downmix(bytesToS16(input), channels))
|
||||||
|
if len(pcm) > 0 {
|
||||||
|
capture(pcm)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
mctx.Free()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
playDev, err := malgo.InitDevice(mctx.Context, playCfg, malgo.DeviceCallbacks{
|
||||||
|
Data: func(output, _ []byte, framecount uint32) {
|
||||||
if len(output) == 0 {
|
if len(output) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
channels := 1
|
channels := channelCount(len(output), framecount)
|
||||||
if framecount > 0 {
|
|
||||||
channels = len(output) / int(framecount) / 2
|
|
||||||
}
|
|
||||||
if channels < 1 {
|
|
||||||
channels = 1
|
|
||||||
}
|
|
||||||
playBuf = append(playBuf, playRes.Convert(playback())...)
|
playBuf = append(playBuf, playRes.Convert(playback())...)
|
||||||
need := int(framecount)
|
need := int(framecount)
|
||||||
var frame []int16
|
var frame []int16
|
||||||
@@ -66,32 +70,60 @@ func Start(ctx context.Context, capture func([]int16), playback func() []int16)
|
|||||||
}
|
}
|
||||||
writeS16(output, frame, channels)
|
writeS16(output, frame, channels)
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
dev, err := malgo.InitDevice(mctx.Context, cfg, callbacks)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
capDev.Uninit()
|
||||||
mctx.Free()
|
mctx.Free()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rate := int(dev.SampleRate())
|
|
||||||
if rate == 0 {
|
capRate := int(capDev.SampleRate())
|
||||||
rate = stationRate
|
if capRate == 0 {
|
||||||
|
capRate = stationRate
|
||||||
}
|
}
|
||||||
capRes = converter{from: rate, to: stationRate}
|
playRate := int(playDev.SampleRate())
|
||||||
playRes = converter{from: stationRate, to: rate}
|
if playRate == 0 {
|
||||||
if err := dev.Start(); err != nil {
|
playRate = stationRate
|
||||||
dev.Uninit()
|
}
|
||||||
|
capRes = converter{from: capRate, to: stationRate}
|
||||||
|
playRes = converter{from: stationRate, to: playRate}
|
||||||
|
|
||||||
|
if err := capDev.Start(); err != nil {
|
||||||
|
playDev.Uninit()
|
||||||
|
capDev.Uninit()
|
||||||
mctx.Free()
|
mctx.Free()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := playDev.Start(); err != nil {
|
||||||
|
capDev.Stop()
|
||||||
|
playDev.Uninit()
|
||||||
|
capDev.Uninit()
|
||||||
|
mctx.Free()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
dev.Stop()
|
_ = playDev.Stop()
|
||||||
dev.Uninit()
|
_ = capDev.Stop()
|
||||||
|
playDev.Uninit()
|
||||||
|
capDev.Uninit()
|
||||||
mctx.Free()
|
mctx.Free()
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func channelCount(bytes int, framecount uint32) int {
|
||||||
|
channels := 1
|
||||||
|
if framecount > 0 {
|
||||||
|
channels = bytes / int(framecount) / 2
|
||||||
|
}
|
||||||
|
if channels < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return channels
|
||||||
|
}
|
||||||
|
|
||||||
func bytesToS16(b []byte) []int16 {
|
func bytesToS16(b []byte) []int16 {
|
||||||
n := len(b) / 2
|
n := len(b) / 2
|
||||||
out := make([]int16, n)
|
out := make([]int16, n)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultConfigPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".funknetz.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveConfigPath picks the config file from -c / --config in args (no program name).
|
||||||
|
// The default is ~/.funknetz.json. A relative path is resolved against the process CWD.
|
||||||
|
func resolveConfigPath(args []string) (string, error) {
|
||||||
|
fs := flag.NewFlagSet("funknetz", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
var config string
|
||||||
|
fs.StringVar(&config, "config", "", "path to the station config file")
|
||||||
|
fs.StringVar(&config, "c", "", "path to the station config file")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if config == "" {
|
||||||
|
path, err := defaultConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("config path: %w", err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(config)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("config path: %w", err)
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//go:build !bindings
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveConfigPathDefaultIsHome(t *testing.T) {
|
||||||
|
got, err := resolveConfigPath(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(home, ".funknetz.json")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("resolveConfigPath(nil) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveConfigPathLongAndShort(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
abs := filepath.Join(dir, "lab.json")
|
||||||
|
|
||||||
|
got, err := resolveConfigPath([]string{"--config", abs})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != abs {
|
||||||
|
t.Fatalf("--config = %q, want %q", got, abs)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = resolveConfigPath([]string{"-c", abs})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != abs {
|
||||||
|
t.Fatalf("-c = %q, want %q", got, abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveConfigPathRelativeBecomesAbsolute(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||||
|
if err := os.Chdir(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolveConfigPath([]string{"-c", "./funknetz.json"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want, err := filepath.EvalSymlinks(filepath.Join(dir, "funknetz.json"))
|
||||||
|
if err != nil {
|
||||||
|
// File need not exist; resolve the directory and join.
|
||||||
|
base, err := filepath.EvalSymlinks(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want = filepath.Join(base, "funknetz.json")
|
||||||
|
}
|
||||||
|
gotEval, err := filepath.EvalSymlinks(filepath.Dir(got))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got = filepath.Join(gotEval, filepath.Base(got))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("relative -c = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenStationUsesResolvedPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "custom.json")
|
||||||
|
|
||||||
|
_, err := openStation([]string{"-c", path})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("openStation() error = nil, want rejection after creating the file")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "created") {
|
||||||
|
t.Fatalf("openStation() error = %q, want it to say the file was created", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want {
|
||||||
|
t.Fatalf("mode = %o, want %o", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rules as the home file: a second open with a filled config works.
|
||||||
|
body := `{
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 5300,
|
||||||
|
"callsign": "DL0FN-1",
|
||||||
|
"authKey": "local-lab-secret-change-me",
|
||||||
|
"talkGroup": 9,
|
||||||
|
"enableLogging": false,
|
||||||
|
"logFile": "` + filepath.Join(dir, "funknetz.log") + `"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := openStation([]string{"--config", path})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.State().Callsign != "DL0FN-1" {
|
||||||
|
t.Fatalf("callsign = %q, want DL0FN-1", st.State().Callsign)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Hand-edited config in the home directory
|
# Hand-edited config in the home directory
|
||||||
|
|
||||||
The operator edits connection settings by hand. There is no settings window in version 1. The file is `~/.funknetz.json`, created mode 0600 when missing. Application Support would match other Mac apps and would be a worse place to open in an editor. That path is part of the station's contract; moving it later strands existing files.
|
The operator edits connection settings by hand. There is no settings window in version 1. The default file is `~/.funknetz.json`, created mode 0600 when missing. Application Support would match other Mac apps and would be a worse place to open in an editor. That default path is part of the station's contract; moving it later strands existing files. `-c` / `--config` may select another file; create, rewrite, and validate rules are the same—only the path changes.
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# Code review: funknetz
|
||||||
|
|
||||||
|
**Date:** 2026-10-07
|
||||||
|
**Scope:** Full review of the funknetz checkout (headset station + local SvxReflector lab).
|
||||||
|
**Fixed point:** `8e75515` (`Record the station glossary and the window and config decisions.`)
|
||||||
|
**Compared tip:** `HEAD` = `f90b615`
|
||||||
|
**Diff:** `git diff 8e75515...HEAD` (4222 insertions across 38 files)
|
||||||
|
**Spec:** [docs/issues/2026-10-03-funknetz-station/spec.md](../../docs/issues/2026-10-03-funknetz-station/spec.md) (workspace issue tracker)
|
||||||
|
**Standards:** [knowledge/engineering/go/](../../knowledge/engineering/go/), [CONTEXT.md](../CONTEXT.md), [docs/adr/](./adr/)
|
||||||
|
|
||||||
|
Two axes below are independent. Findings are not merged or ranked across axes.
|
||||||
|
|
||||||
|
### Commits in range (`8e75515..HEAD`)
|
||||||
|
|
||||||
|
| Commit | Summary |
|
||||||
|
| --- | --- |
|
||||||
|
| `9c4f662` | Join one FM-Funknetz talk group from a headset window |
|
||||||
|
| `6233292` | Pause playback while transmitting and declare microphone use |
|
||||||
|
| `ec5f660` | Append an optional station log and announce the software after login |
|
||||||
|
| `eda79e6` | Add a local SvxReflector Docker lab for headset testing |
|
||||||
|
| `f90b615` | Merge branch `lab/svxlink-docker` |
|
||||||
|
|
||||||
|
### Working tree (not in `HEAD` diff)
|
||||||
|
|
||||||
|
At review time, uncommitted edits touched `docs/svxlink.md`, `svxlink-docker/` (Dockerfile, compose, entrypoint, conf, `.env.example`, `.gitignore`), root `.gitignore`, and an untracked `svxlink-docker/config/svxreflector.link.conf`. Protocol and lab notes in this report include that working tree where it clarifies jayReflector Satellite/Trunk; Standards and Spec axes stay on `8e75515...HEAD` only.
|
||||||
|
|
||||||
|
### Tests run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOWORK=off go test ./station/ -count=1 # ok (~10s)
|
||||||
|
```
|
||||||
|
|
||||||
|
`main`, `audio`, and `internal/opus` have no package tests. `TestConnectsToLabReflector` skips when `127.0.0.1:5300` is down (or `FUNKNETZ_LAB=0`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standards
|
||||||
|
|
||||||
|
### Documented standards
|
||||||
|
|
||||||
|
**Hard — `modern.md` (new JSON → `encoding/json/v2`)**
|
||||||
|
`station/software.go` marshals with v1 `encoding/json` while `station/config.go` already uses `encoding/json/v2`. Same module, new code: prefer v2.
|
||||||
|
|
||||||
|
**Hard — `errors.md` (wrap with `%w`; don’t drop causes)**
|
||||||
|
Underlying failures are replaced with a bare string:
|
||||||
|
- `app.go` / config path resolution paths that surface `could not be read`
|
||||||
|
- `station/config.go` `loadConfig` / `parseConfig` / rewrite path: same `"could not be read"` without `%w`
|
||||||
|
|
||||||
|
**Hard — `testing.md` (prefer `t.Fatal` over `panic`)**
|
||||||
|
`station/config_test.go` `configJSON` / `configWithLog`: `panic(err)` on marshal failure.
|
||||||
|
|
||||||
|
**Judgement — `errors.md` / `modern.md` (`errors.Is`)**
|
||||||
|
`station/config.go`: `os.IsNotExist(err)` — prefer `errors.Is(err, fs.ErrNotExist)`.
|
||||||
|
|
||||||
|
**Judgement — `concurrency-context.md` + `functions-methods.md` (synchronous APIs; obvious goroutine lifetime)**
|
||||||
|
`audio.Start` returns after starting a cleanup goroutine on `ctx.Done()`. `app.go` fire-and-forgets `Run` with `_ =` (comment present; discard rule satisfied).
|
||||||
|
|
||||||
|
**Judgement — `modern.md` (`slices.Contains`)**
|
||||||
|
`station/protocol.go` `hasOPUS` hand-loops instead of `slices.Contains`.
|
||||||
|
|
||||||
|
**OK / tooling skip**
|
||||||
|
`_ = sendUDP(...)` sites carry justifying comments (`errors.md`). `gofmt`/layout not reviewed. `App` pass-through methods are required by ADR `0001-wails-station-window.md` (overrides Middle Man).
|
||||||
|
|
||||||
|
### Baseline smells (all judgement)
|
||||||
|
|
||||||
|
**Divergent Change** — `station.Station` / `session.go`: connection, TX, Opus, UDP, UI state, logging in one type.
|
||||||
|
|
||||||
|
**Data Clumps** — `onTCP` / `onServerInfo` pass `udpCh`, `udpErr`, `udpReady`, `udpRx`, `lastTCP` as pointers together.
|
||||||
|
|
||||||
|
**Duplicated Code** — `audio/device.go` repeats channel-count logic for capture vs playback; `reflector_test.go` reimplements append/parse helpers already in `protocol.go`.
|
||||||
|
|
||||||
|
**Mysterious Name** — `loaded`, `fileShape`, `up` / `outage` don’t say what they hold.
|
||||||
|
|
||||||
|
**Primitive Obsession** — `State.Connection` as free strings (`"Connecting"` / `"Connected"` / …); session `reason` as `"tcp"`/`"udp"`/`"timer"`.
|
||||||
|
|
||||||
|
**Middle Man** — `Fail` → `stop` → `endFatal` (thin hop).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
|
||||||
|
**Tests:** `GOWORK=off go test ./...` — `station` OK (~10s); `main` / `audio` / `internal/opus` have no tests. `TestConnectsToLabReflector` skips when `127.0.0.1:5300` is down (or `FUNKNETZ_LAB=0`).
|
||||||
|
|
||||||
|
### (a) Missing / partial
|
||||||
|
|
||||||
|
- **Separate default devices (partial).** Spec: “A device adapter behind the station talks to the operating system’s default input and default output” / US 52. `audio.Start` opens one `malgo.Duplex` device, not independent default input and default output—fails if duplex is unavailable even when separate devices would work.
|
||||||
|
- **Broken-JSON wording (partial).** Spec: “quit and say the file could not be read” (US 10). Implementation exits with `could not be read` (no “the file”).
|
||||||
|
|
||||||
|
Stories 1–9, 11–50, 53–62, 64–74 and the protocol / log Implementation Decisions look covered at the station seam (config, login, TG select, OPUS, PTT, heartbeats, retries, fatalities, log).
|
||||||
|
|
||||||
|
### (b) Scope creep
|
||||||
|
|
||||||
|
- **Local reflector lab.** Spec Solution / US 1–74 never ask for Docker or SvxReflector; Testing Decisions: “An in-process reflector double is the stand-in for TCP and UDP.” Diff adds `svxlink-docker/`, `docs/svxlink.md`, and `station/lab_reflector_test.go` (real TCP to `:5300`).
|
||||||
|
- **Audio-open fatal.** Spec never defines device-open failure copy. `app.go` calls `Fail("could not open the audio device")`, inventing an English sentence and stopping retries outside US 57–59.
|
||||||
|
|
||||||
|
### (c) Looks implemented, looks wrong
|
||||||
|
|
||||||
|
- **Duplex vs defaults (same as (a)).** Spec wants default **input and** default **output**; duplex is a different contract and can reject a valid headset/speakers pair.
|
||||||
|
- No other clear behavioral mismatches found against US 1–74 / protocol / log rules at HEAD (`8e75515...HEAD`). Wails labels, callsign/TG, PTT OR of button+Space, flush-on-release, busy-room inert PTT, and log line shapes match the quoted decisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Protocol deep dive: SvxLink vs funknetz
|
||||||
|
|
||||||
|
There is no separate RFC. The contract is SvxLink / jayReflector message types (`ReflectorMsg.h` and client logics). Funknetz targets **protocol 2.0** (ReflectorV2 / AUTH_KEY), matching FM-Funknetz’s published guidance. Background: [docs/research/2026-10-03-fm-funknetz-go-client.md](../../docs/research/2026-10-03-fm-funknetz-go-client.md), lab notes in [docs/svxlink.md](./svxlink.md).
|
||||||
|
|
||||||
|
### 1. What funknetz implements
|
||||||
|
|
||||||
|
**Framing.** TCP: 4-byte BE length + `uint16` type + body (`station/protocol.go`). UDP V2 header: type, client id, sequence (`station/session.go`). Strings/blobs: `uint16` length + bytes. Max TCP frame 32768.
|
||||||
|
|
||||||
|
**TCP types handled or sent** (`protocol.go`, `session.go`, `software.go`):
|
||||||
|
|
||||||
|
| Type | Role in funknetz |
|
||||||
|
| --- | --- |
|
||||||
|
| 5 | Send ProtoVer **2.0** first |
|
||||||
|
| 10 / 11 / 12 | HMAC-SHA1 challenge (20 B) → callsign + digest; AuthOK is a no-op |
|
||||||
|
| 13 | Fatal string → stop retries |
|
||||||
|
| 100 | Parse reserved, client id, nodes, codecs; require `"OPUS"` |
|
||||||
|
| 111 | Send NodeInfoV2 JSON `{"sw","swVer"}` only |
|
||||||
|
| 106 | Send configured TG once after 111 |
|
||||||
|
| 104 / 105 | Talker start/stop: honor only matching TG; own callsign start ignored |
|
||||||
|
| 1 | TCP heartbeat |
|
||||||
|
|
||||||
|
Unknown TCP types are ignored.
|
||||||
|
|
||||||
|
**UDP:** send/recv type **1** heartbeat; **101** audio (`uint16` len + Opus); **102** flush on PTT release (after padding a partial frame). Drop datagrams whose client id ≠ ours; **no** sequence check on RX. While transmitting, discard RX audio.
|
||||||
|
|
||||||
|
**Auth / codecs / Opus.** Auth key never on the wire. Opus-only: 16 kHz mono, 320 samples / 20 ms, 20 kbit/s VBR, voice, medium-band, DTX off (`internal/opus/opus.go`).
|
||||||
|
|
||||||
|
**Heartbeats / talker rules.** Match ReflectorV2 counters: TCP TX 10 s / RX 15 s; UDP TX 15 s / RX 60 s. Room busy from talker-start (matching TG) or any accepted RX audio; PTT inert while busy; mic never looped to playback.
|
||||||
|
|
||||||
|
**Tests assert:** login ProtoVer 2.0, HMAC digest, NodeInfo keys, SelectTG, initial UDP HB seq 0; heartbeats, retries, MsgError / no-OPUS stop retry, TCP/UDP timeouts, TG-filtered talker, busy blocks TX, PTT + flush 102, seq wrap, wrong-client-id audio dropped.
|
||||||
|
|
||||||
|
### 2. Parity with SvxLink ReflectorV2
|
||||||
|
|
||||||
|
| Capability | Upstream (ReflectorV2 / research) | funknetz |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Proto 2.0 + AUTH_KEY HMAC-SHA1 | ReflectorV2Logic | Yes |
|
||||||
|
| Framed TCP + UDP same host/port | AsyncFramedTcp / V2 UDP | Yes |
|
||||||
|
| MsgServerInfo → client id + codecs | type 100 | Yes (OPUS required) |
|
||||||
|
| MsgSelectTG | 106 | Yes |
|
||||||
|
| MsgNodeInfoV2 | 111 JSON | Yes (minimal) |
|
||||||
|
| TalkerStart/Stop with TG | 104 / 105 (≥ proto 2) | Yes |
|
||||||
|
| UDP HB / Audio / FlushSamples | 1 / 101 / 102 | Yes |
|
||||||
|
| One talker/TG; client mute when busy | Reflector + client | Client-side busy |
|
||||||
|
| Heartbeat timers 10 / 15 / 15 / 60 | ReflectorV2Logic.h | Yes |
|
||||||
|
| Opus defaults 16 kHz / 20 ms / 20 kbit | AsyncAudioEncoderOpus | Yes |
|
||||||
|
|
||||||
|
### 3. Differences
|
||||||
|
|
||||||
|
**Intentional (spec Out of Scope):** protocol 3 / certs / AES-GCM UDP; MsgTgMonitor / QSY / DTMF; DNS SRV / baked-in host; GSM / Speex / RAW; rich node-info; multi-TG; station list / siglev UI.
|
||||||
|
|
||||||
|
**Thinner than ReflectorV2 client:**
|
||||||
|
|
||||||
|
| Topic | Upstream V2 | funknetz |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| MsgProtoVerDowngrade (6) | Client should retry lower ver | Ignored |
|
||||||
|
| RX UDP sequence / peer filter | Drop behind-seq; peer = TCP peer | Client-id only; connected UDP socket |
|
||||||
|
| MsgUdpAllSamplesFlushed (103) | Flush handshake | Not sent/handled |
|
||||||
|
| TalkerStop vs late audio | Audio may still arrive after 105 | Stop clears busy; **any later 101 re-sets `roomBusy`** without a named talker (`session.go` `onDatagram`) — can strand PTT |
|
||||||
|
| Pre-auth size cap | 64 B | Always up to 32768 |
|
||||||
|
| MONITOR_TGS / StateEvent / Signal / TxStatus | Optional client telemetry | Absent |
|
||||||
|
| Codecs | First mutually supported | Hard-fail if no OPUS |
|
||||||
|
| jayReflector type 121 trunk node list | Extra | Ignored (OK) |
|
||||||
|
|
||||||
|
### 4. Currently missing
|
||||||
|
|
||||||
|
**(A) Intentionally out of scope for v1 headset** — Proto 3 / encryption; MsgTgMonitor; MsgRequestQsy; NodeJoined / Left UI; StateEvent / SignalStrength / TxStatus; DNS SRV; non-OPUS codecs; rich NODE_INFO; multi-TG.
|
||||||
|
|
||||||
|
**(B) Gaps that can still affect ReflectorV2 / FM-Funknetz / jayReflector headset use**
|
||||||
|
|
||||||
|
1. **Late audio after TalkerStop** re-busying the room without a named talker (upstream documents post-stop audio).
|
||||||
|
2. **No ProtoVerDowngrade** handling (unlikely on FM’s V2 servers; breaks if a server asks to step down).
|
||||||
|
3. **No UDP flush / all-flushed** — usually fine for app clients; edge cases if a peer expects 103.
|
||||||
|
4. **No SRV** — operators must set host/port (FM docs often use 5308 + `DNS_DOMAIN` on nodes).
|
||||||
|
5. **No MsgTgMonitor** — idle monitoring of other TGs unavailable; single SelectTG is enough for one configured TG.
|
||||||
|
6. **Stricter RX sequence** not implemented — more permissive than V2; rarely blocks interop.
|
||||||
|
|
||||||
|
Core login + SelectTG + Opus UDP path is aligned with FM-Funknetz’s ReflectorV2 guidance.
|
||||||
|
|
||||||
|
### 5. Lab stack
|
||||||
|
|
||||||
|
| Stack | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| **funknetz `svxlink-docker`** | Builds **jayReflector** → `svxreflector`; TCP+UDP **5300**; `[USERS]` / `[PASSWORDS]` for proto 2; optional **Satellite** (outbound **5303**) / **Trunk** (**5302**); clients stay on local `:5300` |
|
||||||
|
| **Stock sm0svx SvxReflector** | Same client wire protocol; **no** reflector peering |
|
||||||
|
| **jayReflector Satellite / Trunk** | Server-side mesh / uplink; Funknetz does **not** speak Trunk / Satellite |
|
||||||
|
|
||||||
|
Headset interop testing needs a ReflectorV2-capable `svxreflector` with OPUS and AUTH_KEY users. Satellite / Trunk only matter when exercising FM uplink, not the client protocol itself.
|
||||||
|
|
||||||
|
### Message coverage (quick map)
|
||||||
|
|
||||||
|
| Direction | Implemented | Not implemented (by design unless noted) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TCP send | 5, 11, 1, 111, 106 | 14–21 (proto 3), 107 monitor, 109 QSY, 110–113 telemetry |
|
||||||
|
| TCP recv | 1, 10, 12, 13, 100, 104, 105 | 6 downgrade (**gap**), 101 NodeList (legacy), 102/103 presence, 114 UDP encrypt |
|
||||||
|
| UDP send | 1, 101, 102 | 103 all-flushed, 104 signal |
|
||||||
|
| UDP recv | 1 (timer), 101 | sequence validation, 102/103 handshake |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Standards:** 3 hard findings (json/v2 in `software.go`; bare `could not be read` dropping causes; `panic` in test helpers); several judgement smells — worst hard issue is inconsistent JSON API / dropped error causes on config read.
|
||||||
|
**Spec:** 2 partial gaps (duplex vs separate default devices; broken-JSON wording) + 2 scope-creep items (Docker lab; audio-open fatal copy) — worst issue is duplex audio vs the specified separate default input/output devices.
|
||||||
|
|
||||||
|
### Follow-up (2026-10-07)
|
||||||
|
|
||||||
|
Hard Standards and Spec gaps addressed in code where possible:
|
||||||
|
|
||||||
|
| Finding | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| `software.go` → `encoding/json/v2` | Fixed |
|
||||||
|
| Config / path errors wrap with `%w`; US 10 wording `the file could not be read` | Fixed |
|
||||||
|
| Test helpers use `t.Fatal` instead of `panic` | Fixed |
|
||||||
|
| Separate default Capture + Playback devices (not Duplex) | Fixed |
|
||||||
|
| Docker lab scope creep | Left in place — intentional engineering aid, not a product story |
|
||||||
|
| Audio-open `Fail("could not open the audio device")` | Left — spec has no copy; stopping without audio is still required UX |
|
||||||
+63
-23
@@ -1,29 +1,42 @@
|
|||||||
# SvxLink lab for Funknetz
|
# SvxLink lab for Funknetz
|
||||||
|
|
||||||
Funknetz must be proven against a local SvxReflector before it touches the live network. This document is that lab: how to run the reflector, what it will accept from a station, and what the wire protocol actually does—so a headset client can be tested without FM-Funknetz credentials.
|
Funknetz must be proven against a local reflector before it touches the live network. This document is that lab: how to run the reflector, what it will accept from a station, and what the wire protocol actually does—so a headset client can be tested without FM-Funknetz credentials.
|
||||||
|
|
||||||
Upstream SvxLink is two processes with different jobs. `svxlink` is a node: logics, optional radio hardware, modules. `svxreflector` is the conference: stations connect over IP, and it forwards audio among those on the same talk group—one talker at a time. Funknetz is a station on that conference; the lab runs only `svxreflector`. You do not need a full RF node, LocalRx/LocalTx, or EchoLink to exercise the headset client.
|
The lab runs **[jayReflector](https://github.com/dj1jay/jayReflector)** (DJ1JAY / FM-Funknetz), a Trunk/Satellite-capable fork of SvxReflector. The process is still named `svxreflector`. You do **not** need the `svxlink` node binary, LocalRx/LocalTx, or EchoLink to exercise the headset client.
|
||||||
|
|
||||||
|
Funknetz is a station on the conference: it connects to the local reflector on TCP+UDP. Optional FM-Funknetz uplink is Satellite (outbound) or Trunk — configured separately when DJ1JAY provides parameters.
|
||||||
|
|
||||||
## Running a local reflector
|
## Running a local reflector
|
||||||
|
|
||||||
Lab compose lives in [`svxlink-docker/`](../svxlink-docker/). It builds upstream SvxLink (community Docker pattern from [f4hlv/svxlink-docker](https://github.com/f4hlv/svxlink-docker)), mounts a minimal `svxreflector.conf`, publishes TCP **and** UDP on the listen port, and runs only `svxreflector`. There is no first-party `sm0svx` image on Docker Hub.
|
Lab compose lives in [`svxlink-docker/`](../svxlink-docker/). It builds jayReflector, mounts a minimal `svxreflector.conf`, publishes TCP **and** UDP on the listen port, and runs only `svxreflector`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd svxlink-docker
|
cd svxlink-docker
|
||||||
|
cp -n .env.example .env # if needed
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
| Item | Lab value |
|
| Item | Lab value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Listen port | **5300** TCP **and** UDP (upstream default; FM-Funknetz production uses 5308) |
|
| Binary | jayReflector → `/usr/bin/svxreflector` |
|
||||||
| Config | `svxlink-docker/config/svxreflector.conf` → `/etc/svxlink/svxreflector.conf` |
|
| Listen port | **5300** TCP **and** UDP (local clients; FM-Funknetz production client port is often 5308) |
|
||||||
|
| Config | `config/svxreflector.conf` → `/etc/svxlink/svxreflector.conf` |
|
||||||
| Auth (protocol 2.0) | `[USERS]` → `[PASSWORDS]`; station `authKey` matches that secret |
|
| Auth (protocol 2.0) | `[USERS]` → `[PASSWORDS]`; station `authKey` matches that secret |
|
||||||
| Server cert | `[SERVER_CERT] COMMON_NAME=…` required on first start (current upstream bootstraps a local PKI even for AUTH_KEY clients) |
|
| Server cert | `[SERVER_CERT] COMMON_NAME=…` required on first start (PKI under `data/pki` → `/etc/svxlink/pki`) |
|
||||||
| Protocol 3 PKI | Material under `/var/lib/svxlink/pki` (compose volume `data/`) |
|
|
||||||
| Codec | Default **OPUS** when compiled in (`CODECS` optional) |
|
| Codec | Default **OPUS** when compiled in (`CODECS` optional) |
|
||||||
| Go smoke test | `GOWORK=off go test ./station/ -run TestConnectsToLabReflector -v` (needs libopus; skips if port 5300 is down) |
|
| Go smoke test | `GOWORK=off go test ./station/ -run TestConnectsToLabReflector -v` (needs libopus; skips if port 5300 is down) |
|
||||||
|
|
||||||
Point `~/.funknetz.json` at the lab (values match `.env` / the mounted conf; secrets are local-test placeholders):
|
### Lab app start
|
||||||
|
|
||||||
|
With the reflector already up, from the funknetz repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./teststart.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
That script requires `svxlink-docker/.env` (it will not copy `.env.example`), checks `FUNKNETZ_*` against `config/svxreflector.conf`, writes `svxlink-docker/.funknetz.generated.json` (gitignored), enables logging to `svxlink-docker/logs/funknetz-lab.log`, builds `funknetz.app` if missing or stale, and launches the binary with `-c` pointing at the generated file. The default operator config remains `~/.funknetz.json`; `-c` / `--config` only select another path (same create/rewrite rules). See [ADR 0002](./adr/0002-home-directory-config.md).
|
||||||
|
|
||||||
|
Manual lab JSON (values match `.env` / the mounted conf; secrets are local-test placeholders):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -39,6 +52,39 @@ Point `~/.funknetz.json` at the lab (values match `.env` / the mounted conf; sec
|
|||||||
|
|
||||||
A second station can use callsign `DL0FN-2` with the same auth key. Do not reuse production FM-Funknetz credentials in the lab.
|
A second station can use callsign `DL0FN-2` with the same auth key. Do not reuse production FM-Funknetz credentials in the lab.
|
||||||
|
|
||||||
|
## Optional FM-Funknetz link (Satellite / Trunk)
|
||||||
|
|
||||||
|
Default: `ENABLE_FM_LINK=0` — local island only. No network uplink.
|
||||||
|
|
||||||
|
When DJ1JAY provides parameters:
|
||||||
|
|
||||||
|
1. Edit [`config/svxreflector.link.conf`](../svxlink-docker/config/svxreflector.link.conf) (prefer **Satellite** for home labs).
|
||||||
|
2. Set `ENABLE_FM_LINK=1` in `.env`.
|
||||||
|
3. `docker compose up -d` (rebuild only if the image changed).
|
||||||
|
|
||||||
|
The entrypoint appends the link file after the base conf. Local Funknetz settings (`FUNKNETZ_*`, port 5300) stay the same.
|
||||||
|
|
||||||
|
### Satellite (recommended): outbound only
|
||||||
|
|
||||||
|
With Satellite, **your container dials out** to the parent on TCP **5303**. The network does **not** open a connection toward you. You do **not** need public DNS, a static IP, or inbound port-forwarding for the uplink. Only your **local** clients need to reach host `:5300` (typically localhost).
|
||||||
|
|
||||||
|
```text
|
||||||
|
Funknetz ──:5300──► jayReflector (lab) ──outbound :5303──► FM-Funknetz parent
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill from DJ1JAY (jayReflector README shape):
|
||||||
|
|
||||||
|
- `SATELLITE_OF=parent.example.org:5303`
|
||||||
|
- `SATELLITE_ID=…`
|
||||||
|
- `SATELLITE_SECRET=…`
|
||||||
|
- optional `SATELLITE_FILTER=…` for “only some TGs”
|
||||||
|
|
||||||
|
### Trunk (only if agreed)
|
||||||
|
|
||||||
|
Equal mesh peer: mutual TCP **5302**, matching `[TRUNK_x]` section name and `SECRET`, plus filters (`ALLOW_TGS` / `TG_MAP`). That **does** require the peer to reach your trunk port — not the default lab layout. Prefer Satellite unless DJ1JAY asks for Trunk.
|
||||||
|
|
||||||
|
Research baseline: [docs/research/2026-10-07-jayreflector-fm-funknetz.md](../../docs/research/2026-10-07-jayreflector-fm-funknetz.md).
|
||||||
|
|
||||||
## What the reflector admits
|
## What the reflector admits
|
||||||
|
|
||||||
The binary does not know “Funknetz” or “HamLink.” It admits a station by protocol version, callsign shape, proof of the auth key (or a certificate on protocol 3), and uniqueness of that callsign on the session. App names are never checked. FM-Funknetz’s tickets, issued keys, and the published rule to use ReflectorV2 are **network policy** layered on top of the same knobs—not a separate allowlist inside `svxreflector`.
|
The binary does not know “Funknetz” or “HamLink.” It admits a station by protocol version, callsign shape, proof of the auth key (or a certificate on protocol 3), and uniqueness of that callsign on the session. App names are never checked. FM-Funknetz’s tickets, issued keys, and the published rule to use ReflectorV2 are **network policy** layered on top of the same knobs—not a separate allowlist inside `svxreflector`.
|
||||||
@@ -53,11 +99,11 @@ The binary does not know “Funknetz” or “HamLink.” It admits a station by
|
|||||||
| Talk group | Optional per-TG `ALLOW` / `ALLOW_MONITOR` regexes after login. |
|
| Talk group | Optional per-TG `ALLOW` / `ALLOW_MONITOR` regexes after login. |
|
||||||
| Software / version string | **None.** Optional node-info JSON is for status only. |
|
| Software / version string | **None.** Optional node-info JSON is for status only. |
|
||||||
|
|
||||||
For the lab and for production as FM-Funknetz documents it, the station speaks **protocol 2.0** (ReflectorV2): auth key, not certificates. Upstream’s man page treats AUTH_KEY as deprecated for “modern” reflectors; that is a different target than the live network’s written instruction.
|
For the lab and for production as FM-Funknetz documents it, the station speaks **protocol 2.0** (ReflectorV2): auth key, not certificates.
|
||||||
|
|
||||||
## Wire protocol
|
## Wire protocol
|
||||||
|
|
||||||
There is no separate RFC. The contract is the message types in upstream SvxLink (`ReflectorMsg.h` and the client logics). Control rides **TCP** as length-prefixed frames (4-byte big-endian length, then payload). Audio and UDP keepalives ride **UDP** to the same host and port. Message types are numeric. Protocol **2.0** authenticates with the auth-key HMAC and sends plaintext UDP with a header of type, client id, and sequence. Protocol **3.0** uses X.509/TLS and encrypts UDP (AES-128-GCM). Funknetz targets 2.0.
|
There is no separate RFC. The contract is the message types in SvxLink / jayReflector (`ReflectorMsg.h` and the client logics). Control rides **TCP** as length-prefixed frames (4-byte big-endian length, then payload). Audio and UDP keepalives ride **UDP** to the same host and port. Message types are numeric. Protocol **2.0** authenticates with the auth-key HMAC and sends plaintext UDP with a header of type, client id, and sequence. Protocol **3.0** uses X.509/TLS and encrypts UDP (AES-128-GCM). Funknetz targets 2.0.
|
||||||
|
|
||||||
Typical protocol 2.0 login:
|
Typical protocol 2.0 login:
|
||||||
|
|
||||||
@@ -104,18 +150,12 @@ User messages (type ≥ 100) matter only after the session is connected. Unknown
|
|||||||
|
|
||||||
The codec name is negotiated in `MsgServerInfo`, not per audio packet. When Opus is available and `CODECS` is unset, the reflector prefers **OPUS**.
|
The codec name is negotiated in `MsgServerInfo`, not per audio packet. When Opus is available and `CODECS` is unset, the reflector prefers **OPUS**.
|
||||||
|
|
||||||
## Linking and talk-group sharing
|
## Reflector linking (lab context)
|
||||||
|
|
||||||
`svxreflector` interconnects **stations** on talk groups. It has no peer-reflector configuration: two reflector processes do not mesh. Upstream has discussed multi-reflector interconnect (GitHub issue #597); it is not shipped in the tree this lab tracks. FM-Funknetz states the same limit and attaches other networks by **talk-group sharing / bridge**—audio continuity on a designated talk-group number, implemented by a **client** present on both sides (typically an extra `svxlink` instance on the server), not by reflector peering.
|
| Concept | What it is in this lab |
|
||||||
|
|
||||||
A Funknetz station only **consumes** a shared talk group (select or monitor it). Creating a bridge on the live network needs coordination with the FM-Funknetz team (loop risk). Named cross-network talk groups in their prose include **2621** (Berlin), **2629** (Sachsen), and possibly **2624**; app ingress paths such as Zello, FRN, and Teamspeak often default to **262**.
|
|
||||||
|
|
||||||
| Concept | What it actually is |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Reflector peering | **Unsupported.** Not how you join two Verbünde. |
|
| Stock `sm0svx` peering | **Unsupported** — not what we build. |
|
||||||
| `LINKS=` / ReflectorLink | Audio link between logics **inside one** `svxlink` process (e.g. radio ↔ ReflectorLogic). Not two `svxreflector` servers. |
|
| jayReflector **Satellite** | Lab dials parent `:5303`; optional `SATELLITE_FILTER` for some TGs. Outbound only. |
|
||||||
| `MONITOR_TGS` / select TG | Watching or joining a talk group **on one** reflector. Uses a bridge TG if operators already bridge it; does not create the path. |
|
| jayReflector **Trunk** | Mesh peer on `:5302` with filters / `TG_MAP`. Needs mutual reachability. |
|
||||||
| EchoLink / Zello / FRN / Teamspeak | Alternate **ingress** into FM-Funknetz. Different protocols; EchoLink cannot switch talk groups. |
|
| `svxlink` dual ReflectorLogic bridge | Not required here; Satellite/Trunk replace that for FM-Funknetz attach. |
|
||||||
| Same reflector, same TG, two stations | Normal conference behavior—not a bridge test. |
|
| Same reflector, same TG, two stations | Normal conference — local test without any uplink. |
|
||||||
|
|
||||||
To **simulate** sharing locally: run two `svxreflector` instances on distinct ports and users, plus one bridging `svxlink` (two ReflectorLogic sections or two processes) linked so audio crosses on chosen talk groups. Prove with a third station on each side. Exact dual-ReflectorLogic INIs for FM-Funknetz’s production bridge are not published; compose the lab from upstream logic-linking docs.
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
st, err := openStation()
|
st, err := openStation(os.Args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err.Error())
|
fmt.Fprintln(os.Stderr, err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []strin
|
|||||||
func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []string, logPath string) loggedIn {
|
func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []string, logPath string) loggedIn {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
file := configJSON("127.0.0.1", refl.port, callsign, key, tg)
|
file := configJSON(t, "127.0.0.1", refl.port, callsign, key, tg)
|
||||||
if logPath != "" {
|
if logPath != "" {
|
||||||
file = configWithLog("127.0.0.1", refl.port, callsign, key, tg, logPath)
|
file = configWithLog(t, "127.0.0.1", refl.port, callsign, key, tg, logPath)
|
||||||
}
|
}
|
||||||
path := writeConfigFile(t, file)
|
path := writeConfigFile(t, file)
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
@@ -151,7 +151,7 @@ func TestRefusedConnectionRetriesUntilQuit(t *testing.T) {
|
|||||||
port := ln.Addr().(*net.TCPAddr).Port
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
ln.Close()
|
ln.Close()
|
||||||
|
|
||||||
path := writeConfigFile(t, configJSON("127.0.0.1", port, "DL1ABC", "key", 4))
|
path := writeConfigFile(t, configJSON(t, "127.0.0.1", port, "DL1ABC", "key", 4))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,7 +196,7 @@ func TestReflectorErrorStopsRetries(t *testing.T) {
|
|||||||
for _, msg := range []string{"Access denied", "no such station"} {
|
for _, msg := range []string{"Access denied", "no such station"} {
|
||||||
t.Run(msg, func(t *testing.T) {
|
t.Run(msg, func(t *testing.T) {
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON("127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -230,7 +230,7 @@ func TestReflectorErrorStopsRetries(t *testing.T) {
|
|||||||
|
|
||||||
func TestMissingOpusStopsRetries(t *testing.T) {
|
func TestMissingOpusStopsRetries(t *testing.T) {
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON("127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+6
-4
@@ -1,7 +1,9 @@
|
|||||||
package station
|
package station
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -48,13 +50,13 @@ var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z
|
|||||||
func loadConfig(path string) (loaded, error) {
|
func loadConfig(path string) (loaded, error) {
|
||||||
body, err := os.ReadFile(path)
|
body, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
if err := writeTemplate(path); err != nil {
|
if err := writeTemplate(path); err != nil {
|
||||||
return loaded{}, err
|
return loaded{}, err
|
||||||
}
|
}
|
||||||
return loaded{}, fmt.Errorf("created %s", path)
|
return loaded{}, fmt.Errorf("created %s", path)
|
||||||
}
|
}
|
||||||
return loaded{}, fmt.Errorf("could not be read")
|
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
|
||||||
}
|
}
|
||||||
return parseConfig(path, body)
|
return parseConfig(path, body)
|
||||||
}
|
}
|
||||||
@@ -83,7 +85,7 @@ func writeTemplate(path string) error {
|
|||||||
func parseConfig(path string, body []byte) (loaded, error) {
|
func parseConfig(path string, body []byte) (loaded, error) {
|
||||||
var shape fileShape
|
var shape fileShape
|
||||||
if err := jsonv2.Unmarshal(body, &shape); err != nil {
|
if err := jsonv2.Unmarshal(body, &shape); err != nil {
|
||||||
return loaded{}, fmt.Errorf("could not be read")
|
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
|
||||||
}
|
}
|
||||||
if block, notice := blockedLog(shape); block {
|
if block, notice := blockedLog(shape); block {
|
||||||
cfg, err := parseOperational(shape)
|
cfg, err := parseOperational(shape)
|
||||||
@@ -99,7 +101,7 @@ func parseConfig(path string, body []byte) (loaded, error) {
|
|||||||
}
|
}
|
||||||
shape = withLogDefaults(shape)
|
shape = withLogDefaults(shape)
|
||||||
if err := rewriteConfig(path, shape); err != nil {
|
if err := rewriteConfig(path, shape); err != nil {
|
||||||
return loaded{}, fmt.Errorf("could not be read")
|
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg, err := parseOperational(shape)
|
cfg, err := parseOperational(shape)
|
||||||
|
|||||||
+33
-22
@@ -84,32 +84,41 @@ func TestExistingConfigIsNotRewritten(t *testing.T) {
|
|||||||
|
|
||||||
func TestInvalidConfigNamesTheField(t *testing.T) {
|
func TestInvalidConfigNamesTheField(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
body string
|
host string
|
||||||
want string
|
port int
|
||||||
|
callsign string
|
||||||
|
authKey string
|
||||||
|
talkGroup int
|
||||||
|
body string
|
||||||
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "empty host", body: configJSON("", 5308, "DL1ABC", "key", 1), want: "host is empty"},
|
{name: "empty host", host: "", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"},
|
||||||
{name: "blank host", body: configJSON(" ", 5308, "DL1ABC", "key", 1), want: "host is empty"},
|
{name: "blank host", host: " ", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"},
|
||||||
{name: "host type", body: `{"host":1,"port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "host has the wrong type"},
|
{name: "host type", body: `{"host":1,"port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "host has the wrong type"},
|
||||||
{name: "empty callsign", body: configJSON("127.0.0.1", 5308, "", "key", 1), want: "callsign is empty"},
|
{name: "empty callsign", host: "127.0.0.1", port: 5308, callsign: "", authKey: "key", talkGroup: 1, want: "callsign is empty"},
|
||||||
{name: "callsign type", body: `{"host":"127.0.0.1","port":5308,"callsign":1,"authKey":"key","talkGroup":1}`, want: "callsign has the wrong type"},
|
{name: "callsign type", body: `{"host":"127.0.0.1","port":5308,"callsign":1,"authKey":"key","talkGroup":1}`, want: "callsign has the wrong type"},
|
||||||
{name: "empty auth key", body: configJSON("127.0.0.1", 5308, "DL1ABC", "", 1), want: "authKey is empty"},
|
{name: "empty auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "", talkGroup: 1, want: "authKey is empty"},
|
||||||
{name: "whitespace auth key", body: configJSON("127.0.0.1", 5308, "DL1ABC", " \t", 1), want: "authKey is empty"},
|
{name: "whitespace auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: " \t", talkGroup: 1, want: "authKey is empty"},
|
||||||
{name: "auth key type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":1,"talkGroup":1}`, want: "authKey has the wrong type"},
|
{name: "auth key type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":1,"talkGroup":1}`, want: "authKey has the wrong type"},
|
||||||
{name: "port zero", body: configJSON("127.0.0.1", 0, "DL1ABC", "key", 1), want: "port is invalid"},
|
{name: "port zero", host: "127.0.0.1", port: 0, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"},
|
||||||
{name: "port negative", body: configJSON("127.0.0.1", -1, "DL1ABC", "key", 1), want: "port is invalid"},
|
{name: "port negative", host: "127.0.0.1", port: -1, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"},
|
||||||
{name: "port too high", body: configJSON("127.0.0.1", 65536, "DL1ABC", "key", 1), want: "port is invalid"},
|
{name: "port too high", host: "127.0.0.1", port: 65536, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"},
|
||||||
{name: "port type", body: `{"host":"127.0.0.1","port":"5308","callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port has the wrong type"},
|
{name: "port type", body: `{"host":"127.0.0.1","port":"5308","callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port has the wrong type"},
|
||||||
{name: "port fraction", body: `{"host":"127.0.0.1","port":5308.5,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port is invalid"},
|
{name: "port fraction", body: `{"host":"127.0.0.1","port":5308.5,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port is invalid"},
|
||||||
{name: "talk group zero", body: configJSON("127.0.0.1", 5308, "DL1ABC", "key", 0), want: "talkGroup is invalid"},
|
{name: "talk group zero", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 0, want: "talkGroup is invalid"},
|
||||||
{name: "talk group negative", body: configJSON("127.0.0.1", 5308, "DL1ABC", "key", -3), want: "talkGroup is invalid"},
|
{name: "talk group negative", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: -3, want: "talkGroup is invalid"},
|
||||||
{name: "talk group type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":"20"}`, want: "talkGroup has the wrong type"},
|
{name: "talk group type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":"20"}`, want: "talkGroup has the wrong type"},
|
||||||
{name: "bad callsign", body: configJSON("127.0.0.1", 5308, "HELLO", "key", 1), want: "callsign is invalid"},
|
{name: "bad callsign", host: "127.0.0.1", port: 5308, callsign: "HELLO", authKey: "key", talkGroup: 1, want: "callsign is invalid"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
body := tt.body
|
||||||
|
if body == "" {
|
||||||
|
body = configJSON(t, tt.host, tt.port, tt.callsign, tt.authKey, tt.talkGroup)
|
||||||
|
}
|
||||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
if err := os.WriteFile(path, []byte(tt.body), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
before, err := os.ReadFile(path)
|
before, err := os.ReadFile(path)
|
||||||
@@ -156,8 +165,8 @@ func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("New() error = nil, want a read failure")
|
t.Fatal("New() error = nil, want a read failure")
|
||||||
}
|
}
|
||||||
if err.Error() != "could not be read" {
|
if !strings.Contains(err.Error(), "the file could not be read") {
|
||||||
t.Fatalf("New() error = %q, want %q", err.Error(), "could not be read")
|
t.Fatalf("New() error = %q, want it to say the file could not be read", err.Error())
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "callsign") {
|
if strings.Contains(err.Error(), "callsign") {
|
||||||
t.Fatalf("New() error = %q, want no field name", err.Error())
|
t.Fatalf("New() error = %q, want no field name", err.Error())
|
||||||
@@ -166,7 +175,7 @@ func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) {
|
|||||||
|
|
||||||
func TestCallsignIsTrimmedAndUppercased(t *testing.T) {
|
func TestCallsignIsTrimmedAndUppercased(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
body := configJSON(" 127.0.0.1 ", 5308, " dl1abc-r ", "a b", 20)
|
body := configJSON(t, " 127.0.0.1 ", 5308, " dl1abc-r ", "a b", 20)
|
||||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -359,7 +368,8 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func configJSON(host string, port int, callsign, authKey string, talkGroup int) string {
|
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
|
||||||
|
t.Helper()
|
||||||
body, err := jsonv2.Marshal(map[string]any{
|
body, err := jsonv2.Marshal(map[string]any{
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": port,
|
"port": port,
|
||||||
@@ -368,12 +378,13 @@ func configJSON(host string, port int, callsign, authKey string, talkGroup int)
|
|||||||
"talkGroup": talkGroup,
|
"talkGroup": talkGroup,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return string(body)
|
return string(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func configWithLog(host string, port int, callsign, authKey string, talkGroup int, logPath string) string {
|
func configWithLog(t testing.TB, host string, port int, callsign, authKey string, talkGroup int, logPath string) string {
|
||||||
|
t.Helper()
|
||||||
body, err := jsonv2.Marshal(map[string]any{
|
body, err := jsonv2.Marshal(map[string]any{
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": port,
|
"port": port,
|
||||||
@@ -384,7 +395,7 @@ func configWithLog(host string, port int, callsign, authKey string, talkGroup in
|
|||||||
"logFile": logPath,
|
"logFile": logPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return string(body)
|
return string(body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestConnectsToLabReflector(t *testing.T) {
|
|||||||
}
|
}
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|
||||||
path := writeConfigFile(t, configJSON(labHost, labPort, labCallsign, labAuthKey, labTalkGroup))
|
path := writeConfigFile(t, configJSON(t, labHost, labPort, labCallsign, labAuthKey, labTalkGroup))
|
||||||
st, err := station.New(station.Options{ConfigPath: path})
|
st, err := station.New(station.Options{ConfigPath: path})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
+2
-2
@@ -123,7 +123,7 @@ func TestLogFailureStaysInTheIdleSlot(t *testing.T) {
|
|||||||
func TestFirstDialFailureIsLoggedOnce(t *testing.T) {
|
func TestFirstDialFailureIsLoggedOnce(t *testing.T) {
|
||||||
logPath := filepath.Join(t.TempDir(), "funknetz.log")
|
logPath := filepath.Join(t.TempDir(), "funknetz.log")
|
||||||
ln := listenClosed(t)
|
ln := listenClosed(t)
|
||||||
path := writeConfigFile(t, configWithLog("127.0.0.1", ln, "DL1ABC", "key", 4, logPath))
|
path := writeConfigFile(t, configWithLog(t, "127.0.0.1", ln, "DL1ABC", "key", 4, logPath))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,7 +152,7 @@ func TestFirstDialFailureIsLoggedOnce(t *testing.T) {
|
|||||||
func TestReflectorErrorsAreOneLogLine(t *testing.T) {
|
func TestReflectorErrorsAreOneLogLine(t *testing.T) {
|
||||||
logPath := filepath.Join(t.TempDir(), "funknetz.log")
|
logPath := filepath.Join(t.TempDir(), "funknetz.log")
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configWithLog("127.0.0.1", refl.port, "DL1ABC", "key", 20, logPath))
|
path := writeConfigFile(t, configWithLog(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, logPath))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
|
func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON(" 127.0.0.1 ", refl.port, " dl1abc ", "auth key with spaces", 20))
|
path := writeConfigFile(t, configJSON(t, " 127.0.0.1 ", refl.port, " dl1abc ", "auth key with spaces", 20))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package station
|
package station
|
||||||
|
|
||||||
import "encoding/json"
|
import jsonv2 "encoding/json/v2"
|
||||||
|
|
||||||
// SoftwareName and SoftwareVersion are the identity sent to the reflector
|
// SoftwareName and SoftwareVersion are the identity sent to the reflector
|
||||||
// after login. Edit them here.
|
// after login. Edit them here.
|
||||||
@@ -10,7 +10,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func softwareIdentityBody() ([]byte, error) {
|
func softwareIdentityBody() ([]byte, error) {
|
||||||
raw, err := json.Marshal(struct {
|
raw, err := jsonv2.Marshal(struct {
|
||||||
SW string `json:"sw"`
|
SW string `json:"sw"`
|
||||||
SWVer string `json:"swVer"`
|
SWVer string `json:"swVer"`
|
||||||
}{
|
}{
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ TZ=Europe/Berlin
|
|||||||
|
|
||||||
LISTEN_PORT=5300
|
LISTEN_PORT=5300
|
||||||
|
|
||||||
START_SVXLINK=0
|
# 0 = local lab only (default). 1 = append config/svxreflector.link.conf
|
||||||
START_REMOTETRX=0
|
# (Satellite/Trunk from DJ1JAY). Satellite dials out — no public DNS needed.
|
||||||
START_SVXREFLECTOR=1
|
ENABLE_FM_LINK=0
|
||||||
|
|
||||||
SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.conf
|
SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.conf
|
||||||
|
SVXREFLECTOR_LINK_CONF=/etc/svxlink/svxreflector.link.conf
|
||||||
SVXREFLECTOR_ARGS=--logfile=/var/log/svxlink/svxreflector.log
|
SVXREFLECTOR_ARGS=--logfile=/var/log/svxlink/svxreflector.log
|
||||||
|
|
||||||
FUNKNETZ_HOST=127.0.0.1
|
FUNKNETZ_HOST=127.0.0.1
|
||||||
FUNKNETZ_PORT=5300
|
FUNKNETZ_PORT=5300
|
||||||
FUNKNETZ_CALLSIGN=DL0FN-1
|
FUNKNETZ_CALLSIGN=DL0FN-1
|
||||||
FUNKNETZ_AUTH_KEY=local-lab-secret-change-me
|
FUNKNETZ_AUTH_KEY=local-lab-secret-change-me
|
||||||
FUNKNETZ_TALK_GROUP=9
|
FUNKNETZ_TALK_GROUP=20
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ data/*
|
|||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
.funknetz.generated.json
|
||||||
|
# Optional: keep a private link file with real DJ1JAY secrets untracked.
|
||||||
|
# The committed config/svxreflector.link.conf is a commented stub only.
|
||||||
|
config/svxreflector.link.local.conf
|
||||||
|
|||||||
+40
-45
@@ -1,77 +1,72 @@
|
|||||||
# Build and run SvxReflector for the Funknetz local lab.
|
# Build jayReflector (DJ1JAY / FM-Funknetz) for the Funknetz local lab.
|
||||||
# Provenance: patterned on f4hlv/svxlink-docker (community); builds from upstream sm0svx/svxlink.
|
# Binary name remains svxreflector; Trunk/Satellite are opt-in via config.
|
||||||
|
# Upstream pattern: dj1jay/jayReflector Dockerfile (clone + cmake, not stock sm0svx).
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# STAGE 1 — BUILD
|
# STAGE 1 — BUILD
|
||||||
# =========================================================
|
# =========================================================
|
||||||
FROM debian:trixie-slim AS builder
|
FROM debian:bookworm AS builder
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN apt-get update && apt-get -y install --no-install-recommends \
|
RUN apt-get update && apt-get -y install --no-install-recommends \
|
||||||
git ca-certificates \
|
git ca-certificates \
|
||||||
cmake g++ make dpkg-dev \
|
build-essential cmake \
|
||||||
libsigc++-2.0-dev libgsm1-dev libpopt-dev tcl8.6-dev \
|
libsigc++-2.0-dev libssl-dev libjsoncpp-dev libpopt-dev \
|
||||||
libgcrypt20-dev libspeex-dev libasound2-dev \
|
libopus-dev libgsm1-dev \
|
||||||
libopus-dev librtlsdr-dev libjsoncpp-dev \
|
|
||||||
libcurl4-openssl-dev libgpiod-dev libogg-dev \
|
|
||||||
ladspa-sdk libssl-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ARG GIT_URL=https://github.com/sm0svx/svxlink.git
|
ARG GIT_URL=https://github.com/dj1jay/jayReflector.git
|
||||||
ARG GIT_BRANCH=master
|
ARG GIT_BRANCH=main
|
||||||
|
ARG GIT_REF=
|
||||||
ARG NUM_CORES=4
|
ARG NUM_CORES=4
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
RUN git clone --recursive --depth 1 --branch "${GIT_BRANCH}" "${GIT_URL}" svxlink
|
|
||||||
WORKDIR /build/svxlink
|
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
SVX_VER="$(awk -F= '$1=="SVXSERVER"{gsub(/[[:space:]]/,"",$2); print $2}' src/versions)"; \
|
if [ -n "${GIT_REF}" ]; then \
|
||||||
echo "SVXSERVER version from src/versions: ${SVX_VER}"; \
|
git clone --recursive "${GIT_URL}" jayReflector; \
|
||||||
rm -rf build && mkdir -p build && cd build; \
|
cd jayReflector && git checkout "${GIT_REF}"; \
|
||||||
cmake \
|
else \
|
||||||
-DCMAKE_INSTALL_PREFIX=/usr \
|
git clone --recursive --depth 1 --branch "${GIT_BRANCH}" "${GIT_URL}" jayReflector; \
|
||||||
-DCMAKE_INSTALL_SYSCONFDIR=/etc \
|
fi
|
||||||
-DCMAKE_INSTALL_LOCALSTATEDIR=/var \
|
|
||||||
|
WORKDIR /build/jayReflector
|
||||||
|
RUN set -eux; \
|
||||||
|
cmake -S src -B build \
|
||||||
|
-DLOCAL_STATE_DIR=/var \
|
||||||
-DUSE_QT=OFF \
|
-DUSE_QT=OFF \
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
-DUSE_ALSA=OFF \
|
||||||
-DCPACK_GENERATOR=DEB \
|
-DCMAKE_BUILD_TYPE=Release; \
|
||||||
-DCPACK_PACKAGE_VERSION="${SVX_VER}" \
|
cmake --build build --target svxreflector -j"${NUM_CORES}"
|
||||||
-DCPACK_DEBIAN_PACKAGE_VERSION="${SVX_VER}" \
|
|
||||||
../src; \
|
|
||||||
make -j"${NUM_CORES}"; \
|
|
||||||
make package; \
|
|
||||||
ls -lh *.deb
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# STAGE 2 — RUNTIME
|
# STAGE 2 — RUNTIME
|
||||||
# =========================================================
|
# =========================================================
|
||||||
FROM debian:trixie-slim
|
FROM debian:bookworm-slim
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN apt-get update && apt-get -y install --no-install-recommends \
|
RUN apt-get update && apt-get -y install --no-install-recommends \
|
||||||
adduser init-system-helpers ca-certificates openssl \
|
ca-certificates openssl \
|
||||||
libasyncaudio1.6t64 libasynccore1.6t64 libasynccpp1.6t64 libcurl4t64 \
|
libsigc++-2.0-0v5 libssl3 libjsoncpp25 libpopt0 \
|
||||||
libgcrypt20 libgsm1 libjsoncpp26 libpopt0 librtlsdr0 \
|
libopus0 libgsm1 \
|
||||||
libsigc++-2.0-0v5 libtcl8.6 libgcc-s1 libstdc++6 \
|
|
||||||
libasound2 libgpiod3 libogg0 libopus0 libspeex1 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /build/svxlink/build/*.deb /tmp/svxlink.deb
|
COPY --from=builder /build/jayReflector/build/bin/svxreflector /usr/bin/svxreflector
|
||||||
|
COPY --from=builder /build/jayReflector/build/lib/lib*.so* /usr/lib/
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN mkdir -p /etc/svxlink /etc/svxlink/pki /var/log/svxlink /var/lib/svxlink && \
|
||||||
apt-get -y install --no-install-recommends /tmp/svxlink.deb && \
|
ldconfig && \
|
||||||
rm -f /tmp/svxlink.deb && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN mkdir -p /etc/svxlink /var/log/svxlink /var/lib/svxlink && \
|
|
||||||
useradd -r -u 1000 -m -d /home/svx svx || true
|
useradd -r -u 1000 -m -d /home/svx svx || true
|
||||||
|
|
||||||
ENV SVXLINK_CONF=/etc/svxlink/svxlink.conf \
|
ENV SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.conf \
|
||||||
REMOTETRX_CONF=/etc/svxlink/remotetrx.conf \
|
SVXREFLECTOR_LINK_CONF=/etc/svxlink/svxreflector.link.conf \
|
||||||
SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.conf
|
ENABLE_FM_LINK=0
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# 5300: local Funknetz / SvxLink clients (TCP+UDP)
|
||||||
|
# Satellite mode dials out to parent :5303 — no inbound peer port required.
|
||||||
|
# Trunk mode would need 5302/tcp inbound; not published by default in compose.
|
||||||
|
EXPOSE 5300/tcp 5300/udp
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
###################################################################
|
###################################################################
|
||||||
# Local lab SvxReflector for Funknetz (protocol 2.0 / ReflectorV2)
|
# Local lab jayReflector for Funknetz (protocol 2.0 / ReflectorV2)
|
||||||
# Secrets here are placeholders — do not reuse production keys.
|
# Secrets here are placeholders — do not reuse production keys.
|
||||||
|
#
|
||||||
|
# Standalone by default: local clients only. FM-Funknetz Satellite/Trunk
|
||||||
|
# keys live in svxreflector.link.conf and are appended when
|
||||||
|
# ENABLE_FM_LINK=1 (see .env and docker-compose).
|
||||||
###################################################################
|
###################################################################
|
||||||
|
|
||||||
[GLOBAL]
|
[GLOBAL]
|
||||||
@@ -8,10 +12,13 @@ TIMESTAMP_FORMAT="%c"
|
|||||||
LISTEN_PORT=5300
|
LISTEN_PORT=5300
|
||||||
TG_FOR_V1_CLIENTS=999
|
TG_FOR_V1_CLIENTS=999
|
||||||
# CODECS=OPUS
|
# CODECS=OPUS
|
||||||
COMMAND_PTY=/dev/shm/reflector_ctrl
|
COMMAND_PTY=/dev/shm/svxreflector_ctrl
|
||||||
|
CERT_PKI_DIR=/etc/svxlink/pki/
|
||||||
|
# Optional status JSON (publish 8080 in compose if you enable this):
|
||||||
|
# HTTP_SRV_PORT=8080
|
||||||
|
|
||||||
# Newer SvxReflector bootstraps a local PKI even for protocol 2.0 AUTH_KEY clients.
|
# jayReflector / modern SvxReflector bootstraps a local PKI even for
|
||||||
# COMMON_NAME is required so the server certificate can be generated on first start.
|
# protocol 2.0 AUTH_KEY clients. COMMON_NAME is required on first start.
|
||||||
[SERVER_CERT]
|
[SERVER_CERT]
|
||||||
COMMON_NAME=funknetz-lab.local
|
COMMON_NAME=funknetz-lab.local
|
||||||
SUBJECT_ALT_NAME=DNS:funknetz-lab.local,DNS:localhost,IP:127.0.0.1
|
SUBJECT_ALT_NAME=DNS:funknetz-lab.local,DNS:localhost,IP:127.0.0.1
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
###################################################################
|
||||||
|
# Optional FM-Funknetz link stub (jayReflector)
|
||||||
|
#
|
||||||
|
# Default: leave ENABLE_FM_LINK=0 in .env — this file is ignored.
|
||||||
|
# When DJ1JAY provides values:
|
||||||
|
# 1. Fill ONE of the blocks below (prefer Satellite for home/NAT labs).
|
||||||
|
# 2. Set ENABLE_FM_LINK=1 in .env
|
||||||
|
# 3. docker compose up -d
|
||||||
|
#
|
||||||
|
# Satellite (recommended): this reflector dials OUT to the parent on
|
||||||
|
# TCP 5303. No public DNS, no inbound peer port, no port-forward required
|
||||||
|
# for the link. Local Funknetz clients still use host :5300 only.
|
||||||
|
#
|
||||||
|
# Trunk: equal mesh peer; needs mutual reachability on TCP 5302 and a
|
||||||
|
# matching [TRUNK_x] section name + SECRET on both sides.
|
||||||
|
#
|
||||||
|
# Do not commit real secrets. Prefer a local override or keep secrets
|
||||||
|
# only in your private .env workflow if you copy this file.
|
||||||
|
# Docs: docs/research/2026-10-07-jayreflector-fm-funknetz.md
|
||||||
|
# README: https://github.com/dj1jay/jayReflector
|
||||||
|
###################################################################
|
||||||
|
|
||||||
|
# --- Satellite (fill from DJ1JAY; uncomment to use) ---
|
||||||
|
# SATELLITE_OF=parent.example.org:5303
|
||||||
|
# SATELLITE_ID=lab-yourcall
|
||||||
|
# SATELLITE_SECRET="replace-with-secret-from-dj1jay"
|
||||||
|
# Only these TGs cross to/from the parent (bidirectional). Omit for all TGs.
|
||||||
|
# SATELLITE_FILTER=2621,2629,20
|
||||||
|
|
||||||
|
# --- Trunk peer alternative (fill from DJ1JAY; usually leave commented) ---
|
||||||
|
# LOCAL_PREFIX=9
|
||||||
|
# TRUNK_LISTEN_PORT=5302
|
||||||
|
#
|
||||||
|
# [TRUNK_LAB_FM]
|
||||||
|
# HOST=fm-parent.example.org
|
||||||
|
# PORT=5302
|
||||||
|
# SECRET="replace-with-agreed-trunk-secret"
|
||||||
|
# REMOTE_PREFIX=262
|
||||||
|
# ALLOW_TGS=2621,2629,20
|
||||||
|
# # TG_MAP=1:2624100
|
||||||
|
# MQTT_NAME=Lab-YourCall
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# Local SvxReflector lab for Funknetz.
|
# Local jayReflector lab for Funknetz.
|
||||||
# Build once, then: docker compose up -d
|
# Build once, then: docker compose up -d --build
|
||||||
# Connect the app with host/port/callsign/authKey from .env (see FUNKNETZ_*).
|
# Connect the app with host/port/callsign/authKey from .env (see FUNKNETZ_*).
|
||||||
|
#
|
||||||
|
# FM-Funknetz link (optional): fill config/svxreflector.link.conf from DJ1JAY,
|
||||||
|
# set ENABLE_FM_LINK=1, recreate. Satellite mode is outbound-only (no DNS/inbound).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
svxreflector:
|
svxreflector:
|
||||||
@@ -8,9 +11,11 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
GIT_URL: https://github.com/sm0svx/svxlink.git
|
GIT_URL: https://github.com/dj1jay/jayReflector.git
|
||||||
GIT_BRANCH: master
|
GIT_BRANCH: main
|
||||||
image: funknetz-svxreflector:local
|
# Optional pin (overrides branch clone when set), e.g.:
|
||||||
|
# GIT_REF: 50997ed21a37c7c05c132eca5ebb07bab4b80fc7
|
||||||
|
image: funknetz-jayreflector:local
|
||||||
container_name: funknetz-svxreflector
|
container_name: funknetz-svxreflector
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
@@ -18,16 +23,21 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
# Host LISTEN_PORT → container 5300 (must match config/svxreflector.conf).
|
# Host LISTEN_PORT → container 5300 (must match config/svxreflector.conf).
|
||||||
# Control is TCP; audio and UDP keepalives use the same number.
|
# Control is TCP; audio and UDP keepalives use the same number.
|
||||||
|
# Satellite uplink uses outbound TCP 5303 — not published here.
|
||||||
- "${LISTEN_PORT:-5300}:5300/tcp"
|
- "${LISTEN_PORT:-5300}:5300/tcp"
|
||||||
- "${LISTEN_PORT:-5300}:5300/udp"
|
- "${LISTEN_PORT:-5300}:5300/udp"
|
||||||
|
# Optional HTTP /status when HTTP_SRV_PORT=8080 is set in conf:
|
||||||
|
# - "8080:8080/tcp"
|
||||||
|
# Trunk peer only (not needed for Satellite):
|
||||||
|
# - "5302:5302/tcp"
|
||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-Europe/Berlin}
|
TZ: ${TZ:-Europe/Berlin}
|
||||||
START_SVXLINK: ${START_SVXLINK:-0}
|
ENABLE_FM_LINK: ${ENABLE_FM_LINK:-0}
|
||||||
START_REMOTETRX: ${START_REMOTETRX:-0}
|
|
||||||
START_SVXREFLECTOR: ${START_SVXREFLECTOR:-1}
|
|
||||||
SVXREFLECTOR_CONF: ${SVXREFLECTOR_CONF:-/etc/svxlink/svxreflector.conf}
|
SVXREFLECTOR_CONF: ${SVXREFLECTOR_CONF:-/etc/svxlink/svxreflector.conf}
|
||||||
|
SVXREFLECTOR_LINK_CONF: ${SVXREFLECTOR_LINK_CONF:-/etc/svxlink/svxreflector.link.conf}
|
||||||
SVXREFLECTOR_ARGS: ${SVXREFLECTOR_ARGS:-}
|
SVXREFLECTOR_ARGS: ${SVXREFLECTOR_ARGS:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/svxreflector.conf:/etc/svxlink/svxreflector.conf:ro
|
- ./config/svxreflector.conf:/etc/svxlink/svxreflector.conf:ro
|
||||||
|
- ./config/svxreflector.link.conf:/etc/svxlink/svxreflector.link.conf:ro
|
||||||
- ./logs:/var/log/svxlink
|
- ./logs:/var/log/svxlink
|
||||||
- ./data:/var/lib/svxlink
|
- ./data/pki:/etc/svxlink/pki
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SVXLINK_CONF="${SVXLINK_CONF:-/etc/svxlink/svxlink.conf}"
|
|
||||||
REMOTETRX_CONF="${REMOTETRX_CONF:-/etc/svxlink/remotetrx.conf}"
|
|
||||||
SVXREFLECTOR_CONF="${SVXREFLECTOR_CONF:-/etc/svxlink/svxreflector.conf}"
|
SVXREFLECTOR_CONF="${SVXREFLECTOR_CONF:-/etc/svxlink/svxreflector.conf}"
|
||||||
|
SVXREFLECTOR_LINK_CONF="${SVXREFLECTOR_LINK_CONF:-/etc/svxlink/svxreflector.link.conf}"
|
||||||
START_SVXLINK="${START_SVXLINK:-0}"
|
|
||||||
START_REMOTETRX="${START_REMOTETRX:-0}"
|
|
||||||
START_SVXREFLECTOR="${START_SVXREFLECTOR:-1}"
|
|
||||||
|
|
||||||
SVXLINK_ARGS="${SVXLINK_ARGS:-}"
|
|
||||||
REMOTETRX_ARGS="${REMOTETRX_ARGS:-}"
|
|
||||||
SVXREFLECTOR_ARGS="${SVXREFLECTOR_ARGS:-}"
|
SVXREFLECTOR_ARGS="${SVXREFLECTOR_ARGS:-}"
|
||||||
|
ENABLE_FM_LINK="${ENABLE_FM_LINK:-0}"
|
||||||
|
RUNTIME_CONF="/tmp/svxreflector.runtime.conf"
|
||||||
|
|
||||||
pids=""
|
pids=""
|
||||||
|
|
||||||
@@ -24,15 +18,6 @@ die() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
need_file_if_enabled() {
|
|
||||||
enabled="$1"
|
|
||||||
file="$2"
|
|
||||||
name="$3"
|
|
||||||
if [ "$enabled" = "1" ] && [ ! -f "$file" ]; then
|
|
||||||
die "Config for $name not found: $file (expected volume mount)"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
start_bg() {
|
start_bg() {
|
||||||
name="$1"
|
name="$1"
|
||||||
shift
|
shift
|
||||||
@@ -70,37 +55,34 @@ stop_all() {
|
|||||||
|
|
||||||
trap 'stop_all; exit 0' INT TERM
|
trap 'stop_all; exit 0' INT TERM
|
||||||
|
|
||||||
if [ "$START_SVXLINK" = "1" ] && ! command -v svxlink >/dev/null 2>&1; then
|
command -v svxreflector >/dev/null 2>&1 || die "svxreflector binary not found (jayReflector build)"
|
||||||
die "svxlink binary not found"
|
[ -f "$SVXREFLECTOR_CONF" ] || die "Config not found: $SVXREFLECTOR_CONF (expected volume mount)"
|
||||||
fi
|
|
||||||
if [ "$START_REMOTETRX" = "1" ] && ! command -v remotetrx >/dev/null 2>&1; then
|
# Local-only by default. When ENABLE_FM_LINK=1, append the link stub
|
||||||
die "remotetrx binary not found"
|
# (Satellite or Trunk keys from DJ1JAY) after the base lab conf.
|
||||||
fi
|
if [ "$ENABLE_FM_LINK" = "1" ]; then
|
||||||
if [ "$START_SVXREFLECTOR" = "1" ] && ! command -v svxreflector >/dev/null 2>&1; then
|
[ -f "$SVXREFLECTOR_LINK_CONF" ] || die "ENABLE_FM_LINK=1 but link config missing: $SVXREFLECTOR_LINK_CONF"
|
||||||
die "svxreflector binary not found"
|
{
|
||||||
|
cat "$SVXREFLECTOR_CONF"
|
||||||
|
echo
|
||||||
|
echo "###################################################################"
|
||||||
|
echo "# Appended FM-Funknetz link stub (ENABLE_FM_LINK=1)"
|
||||||
|
echo "###################################################################"
|
||||||
|
cat "$SVXREFLECTOR_LINK_CONF"
|
||||||
|
} >"$RUNTIME_CONF"
|
||||||
|
conf_path="$RUNTIME_CONF"
|
||||||
|
log "FM link enabled: using $SVXREFLECTOR_CONF + $SVXREFLECTOR_LINK_CONF"
|
||||||
|
else
|
||||||
|
conf_path="$SVXREFLECTOR_CONF"
|
||||||
|
log "FM link disabled (ENABLE_FM_LINK=0): local lab reflector only"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
need_file_if_enabled "$START_SVXLINK" "$SVXLINK_CONF" "svxlink"
|
# Ensure PKI dir exists for first-start cert bootstrap.
|
||||||
need_file_if_enabled "$START_REMOTETRX" "$REMOTETRX_CONF" "remotetrx"
|
mkdir -p /etc/svxlink/pki /var/lib/svxlink/pki
|
||||||
need_file_if_enabled "$START_SVXREFLECTOR" "$SVXREFLECTOR_CONF" "svxreflector"
|
|
||||||
|
|
||||||
if [ "$START_SVXREFLECTOR" = "1" ]; then
|
start_bg "svxreflector" svxreflector --config "$conf_path" $SVXREFLECTOR_ARGS
|
||||||
start_bg "svxreflector" svxreflector --config "$SVXREFLECTOR_CONF" $SVXREFLECTOR_ARGS
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$START_REMOTETRX" = "1" ]; then
|
log "jayReflector (svxreflector) is running. Waiting…"
|
||||||
start_bg "remotetrx" remotetrx --config "$REMOTETRX_CONF" $REMOTETRX_ARGS
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$START_SVXLINK" = "1" ]; then
|
|
||||||
start_bg "svxlink" svxlink --config "$SVXLINK_CONF" $SVXLINK_ARGS
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! echo "$pids" | grep -q '[0-9]'; then
|
|
||||||
die "No service enabled. Set START_SVXREFLECTOR=1 (lab default) and/or START_SVXLINK / START_REMOTETRX"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Requested services are running. Waiting…"
|
|
||||||
|
|
||||||
exit_code=0
|
exit_code=0
|
||||||
for pid in $pids; do
|
for pid in $pids; do
|
||||||
|
|||||||
Executable
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build (if needed) and start funknetz against the local svxlink-docker lab.
|
||||||
|
# Does not start Docker — run: (cd svxlink-docker && docker compose up -d)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
LAB="$ROOT/svxlink-docker"
|
||||||
|
ENV_FILE="$LAB/.env"
|
||||||
|
CONF="$LAB/config/svxreflector.conf"
|
||||||
|
GENERATED="$LAB/.funknetz.generated.json"
|
||||||
|
LOG_DIR="$LAB/logs"
|
||||||
|
LOG_FILE="$LOG_DIR/funknetz-lab.log"
|
||||||
|
APP_BIN="$ROOT/build/bin/funknetz.app/Contents/MacOS/funknetz"
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "teststart: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f "$ENV_FILE" ]] || die "missing $ENV_FILE (copy from .env.example and edit; this script will not copy it)"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
missing=()
|
||||||
|
[[ -n "${FUNKNETZ_HOST:-}" ]] || missing+=(FUNKNETZ_HOST)
|
||||||
|
[[ -n "${FUNKNETZ_PORT:-}" ]] || missing+=(FUNKNETZ_PORT)
|
||||||
|
[[ -n "${FUNKNETZ_CALLSIGN:-}" ]] || missing+=(FUNKNETZ_CALLSIGN)
|
||||||
|
[[ -n "${FUNKNETZ_AUTH_KEY:-}" ]] || missing+=(FUNKNETZ_AUTH_KEY)
|
||||||
|
[[ -n "${FUNKNETZ_TALK_GROUP:-}" ]] || missing+=(FUNKNETZ_TALK_GROUP)
|
||||||
|
if ((${#missing[@]})); then
|
||||||
|
die "missing in .env: ${missing[*]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -f "$CONF" ]] || die "missing $CONF"
|
||||||
|
|
||||||
|
# Resolve FUNKNETZ_CALLSIGN → password group in [USERS], then secret in [PASSWORDS].
|
||||||
|
callsign_upper="$(printf '%s' "$FUNKNETZ_CALLSIGN" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
section=""
|
||||||
|
password_group=""
|
||||||
|
auth_secret=""
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line="${line%%#*}"
|
||||||
|
line="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
if [[ "$line" =~ ^\[(.+)\]$ ]]; then
|
||||||
|
section="${BASH_REMATCH[1]}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$section" in
|
||||||
|
USERS)
|
||||||
|
key="${line%%=*}"
|
||||||
|
val="${line#*=}"
|
||||||
|
key="$(printf '%s' "$key" | sed -e 's/[[:space:]]*$//' | tr '[:lower:]' '[:upper:]')"
|
||||||
|
val="$(printf '%s' "$val" | sed -e 's/^[[:space:]]*//')"
|
||||||
|
if [[ "$key" == "$callsign_upper" ]]; then
|
||||||
|
password_group="$val"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
PASSWORDS)
|
||||||
|
key="${line%%=*}"
|
||||||
|
val="${line#*=}"
|
||||||
|
key="$(printf '%s' "$key" | sed -e 's/[[:space:]]*$//')"
|
||||||
|
val="$(printf '%s' "$val" | sed -e 's/^[[:space:]]*//' -e 's/^"//' -e 's/"$//')"
|
||||||
|
if [[ -n "$password_group" && "$key" == "$password_group" ]]; then
|
||||||
|
auth_secret="$val"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <"$CONF"
|
||||||
|
|
||||||
|
[[ -n "$password_group" ]] || die "callsign $FUNKNETZ_CALLSIGN is not in [USERS] in $CONF"
|
||||||
|
[[ -n "$auth_secret" ]] || die "password group $password_group has no [PASSWORDS] entry in $CONF"
|
||||||
|
[[ "$FUNKNETZ_AUTH_KEY" == "$auth_secret" ]] || die "FUNKNETZ_AUTH_KEY does not match [PASSWORDS] for $FUNKNETZ_CALLSIGN"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
# JSON-escape a string value.
|
||||||
|
json_str() {
|
||||||
|
python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
host_json="$(json_str "$FUNKNETZ_HOST")"
|
||||||
|
call_json="$(json_str "$callsign_upper")"
|
||||||
|
key_json="$(json_str "$FUNKNETZ_AUTH_KEY")"
|
||||||
|
log_json="$(json_str "$LOG_FILE")"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
cat >"$GENERATED" <<EOF
|
||||||
|
{
|
||||||
|
"host": $host_json,
|
||||||
|
"port": $FUNKNETZ_PORT,
|
||||||
|
"callsign": $call_json,
|
||||||
|
"authKey": $key_json,
|
||||||
|
"talkGroup": $FUNKNETZ_TALK_GROUP,
|
||||||
|
"enableLogging": true,
|
||||||
|
"logFile": $log_json
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$GENERATED"
|
||||||
|
|
||||||
|
needs_build=0
|
||||||
|
if [[ ! -x "$APP_BIN" ]]; then
|
||||||
|
needs_build=1
|
||||||
|
else
|
||||||
|
newer="$(find "$ROOT" \
|
||||||
|
\( -path "$ROOT/build" -o -path "$ROOT/svxlink-docker" -o -path "$ROOT/.git" \) -prune -o \
|
||||||
|
\( -name '*.go' -o -name 'go.mod' -o -name 'go.sum' -o -name 'wails.json' \) -newer "$APP_BIN" -print \
|
||||||
|
-o -path "$ROOT/frontend/dist/*" -newer "$APP_BIN" -print \
|
||||||
|
2>/dev/null | head -n 1 || true)"
|
||||||
|
[[ -n "$newer" ]] && needs_build=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((needs_build)); then
|
||||||
|
echo "teststart: building funknetz.app…"
|
||||||
|
(cd "$ROOT" && GOWORK=off wails build)
|
||||||
|
[[ -x "$APP_BIN" ]] || die "build finished but binary missing: $APP_BIN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "teststart: starting with -c $GENERATED"
|
||||||
|
exec "$APP_BIN" -c "$GENERATED"
|
||||||
Reference in New Issue
Block a user