Files
OpsLog/internal/operating/operating.go
T
2026-05-28 18:35:22 +02:00

325 lines
10 KiB
Go

// Package operating manages the per-profile tree of stations (radios),
// antennas, and the bands each antenna covers. The "default for a band"
// flag drives the auto-fill of MY_RIG / MY_ANTENNA on each logged QSO.
package operating
import (
"context"
"database/sql"
"fmt"
"strings"
)
// Station is a radio / TRX line. The display Name is also what gets
// written into the MY_RIG ADIF field on each QSO — no separate ADIF
// value to maintain. TXPower (W) is per-rig so changing rig auto-
// stamps the right power on logged QSOs.
type Station struct {
ID int64 `json:"id"`
ProfileID int64 `json:"profile_id"`
Name string `json:"name"`
TXPower *float64 `json:"tx_pwr,omitempty"`
SortOrder int `json:"sort_order"`
Antennas []Antenna `json:"antennas,omitempty"`
}
// Antenna is one antenna attached to a station. The display Name doubles
// as the MY_ANTENNA ADIF value.
type Antenna struct {
ID int64 `json:"id"`
StationID int64 `json:"station_id"`
Name string `json:"name"`
SortOrder int `json:"sort_order"`
Bands []AntennaBand `json:"bands"`
}
// AntennaBand pairs an antenna with one of the bands it covers, plus
// whether it is the default for that band in this profile.
type AntennaBand struct {
Band string `json:"band"`
IsDefault bool `json:"is_default"`
}
// BandDefault is the resolved tuple looked up at QSO save: which
// station+antenna should pre-fill MY_RIG / MY_ANTENNA / TX_PWR for a
// given band. Station and antenna names go straight into the ADIF
// fields — there is no separate "ADIF value" anymore.
type BandDefault struct {
StationID int64 `json:"station_id"`
StationName string `json:"station_name"`
AntennaID int64 `json:"antenna_id"`
AntennaName string `json:"antenna_name"`
TXPower *float64 `json:"tx_pwr,omitempty"`
}
type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// ListTree returns every station for the profile with its nested antennas
// and bands. One round-trip per level — three queries total regardless of
// tree size, so the Settings panel stays snappy on big setups.
func (r *Repo) ListTree(ctx context.Context, profileID int64) ([]Station, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, profile_id, name, tx_pwr, sort_order
FROM operating_stations
WHERE profile_id = ?
ORDER BY sort_order, id`, profileID)
if err != nil {
return nil, fmt.Errorf("list stations: %w", err)
}
defer rows.Close()
var stations []Station
stationByID := map[int64]int{} // id → index in stations slice
for rows.Next() {
var s Station
var pwr sql.NullFloat64
if err := rows.Scan(&s.ID, &s.ProfileID, &s.Name, &pwr, &s.SortOrder); err != nil {
return nil, err
}
if pwr.Valid {
v := pwr.Float64
s.TXPower = &v
}
stationByID[s.ID] = len(stations)
stations = append(stations, s)
}
if len(stations) == 0 {
return stations, nil
}
// Build IN-clause placeholders for the second query.
ids := make([]any, 0, len(stations))
placeholders := make([]string, 0, len(stations))
for _, s := range stations {
ids = append(ids, s.ID)
placeholders = append(placeholders, "?")
}
antRows, err := r.db.QueryContext(ctx,
`SELECT id, station_id, name, sort_order
FROM operating_antennas
WHERE station_id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY station_id, sort_order, id`, ids...)
if err != nil {
return nil, fmt.Errorf("list antennas: %w", err)
}
// Collect antennas into a flat map keyed by ID first — taking pointers
// into a slice we later append to is unsafe (a re-allocation
// invalidates older pointers, leaving the band loop writing to dead
// memory). We assemble the per-station slices at the very end, once
// everything is collected.
antennasByID := map[int64]*Antenna{}
antennaIDsByStation := map[int64][]int64{}
for antRows.Next() {
a := &Antenna{}
if err := antRows.Scan(&a.ID, &a.StationID, &a.Name, &a.SortOrder); err != nil {
antRows.Close()
return nil, err
}
antennasByID[a.ID] = a
antennaIDsByStation[a.StationID] = append(antennaIDsByStation[a.StationID], a.ID)
}
antRows.Close()
if len(antennasByID) > 0 {
antIDs := make([]any, 0, len(antennasByID))
antPlaceholders := make([]string, 0, len(antennasByID))
for id := range antennasByID {
antIDs = append(antIDs, id)
antPlaceholders = append(antPlaceholders, "?")
}
bandRows, err := r.db.QueryContext(ctx,
`SELECT antenna_id, band, is_default
FROM operating_antenna_bands
WHERE antenna_id IN (`+strings.Join(antPlaceholders, ",")+`)
ORDER BY band`, antIDs...)
if err != nil {
return nil, fmt.Errorf("list bands: %w", err)
}
for bandRows.Next() {
var antID int64
var band string
var isDefault int
if err := bandRows.Scan(&antID, &band, &isDefault); err != nil {
bandRows.Close()
return nil, err
}
if a, ok := antennasByID[antID]; ok {
a.Bands = append(a.Bands, AntennaBand{Band: band, IsDefault: isDefault != 0})
}
}
bandRows.Close()
}
// Now assemble each station's Antennas slice. By the time we do this
// every antenna already has its full band list attached, so no
// downstream pointer is left behind.
for sIdx := range stations {
for _, antID := range antennaIDsByStation[stations[sIdx].ID] {
if a, ok := antennasByID[antID]; ok {
stations[sIdx].Antennas = append(stations[sIdx].Antennas, *a)
}
}
}
return stations, nil
}
// SaveStation upserts a station. Returns the (possibly new) ID.
func (r *Repo) SaveStation(ctx context.Context, s *Station) error {
if strings.TrimSpace(s.Name) == "" {
return fmt.Errorf("station name required")
}
var pwr any
if s.TXPower != nil {
pwr = *s.TXPower
}
if s.ID == 0 {
res, err := r.db.ExecContext(ctx,
`INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order)
VALUES(?, ?, ?, ?)`, s.ProfileID, s.Name, pwr, s.SortOrder)
if err != nil {
return fmt.Errorf("insert station: %w", err)
}
id, _ := res.LastInsertId()
s.ID = id
return nil
}
_, err := r.db.ExecContext(ctx,
`UPDATE operating_stations
SET name = ?, tx_pwr = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?`, s.Name, pwr, s.SortOrder, s.ID)
if err != nil {
return fmt.Errorf("update station: %w", err)
}
return nil
}
// DeleteStation cascades to antennas and bands via FK ON DELETE CASCADE.
func (r *Repo) DeleteStation(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM operating_stations WHERE id = ?`, id)
return err
}
// SaveAntenna upserts an antenna and replaces its band list in one
// transaction. `is_default` is enforced per profile: setting it on one
// antenna clears any other antenna's default for the same band.
func (r *Repo) SaveAntenna(ctx context.Context, a *Antenna) error {
if strings.TrimSpace(a.Name) == "" {
return fmt.Errorf("antenna name required")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if a.ID == 0 {
res, err := tx.ExecContext(ctx,
`INSERT INTO operating_antennas(station_id, name, sort_order)
VALUES(?, ?, ?)`, a.StationID, a.Name, a.SortOrder)
if err != nil {
return fmt.Errorf("insert antenna: %w", err)
}
id, _ := res.LastInsertId()
a.ID = id
} else {
if _, err := tx.ExecContext(ctx,
`UPDATE operating_antennas
SET name = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?`, a.Name, a.SortOrder, a.ID); err != nil {
return fmt.Errorf("update antenna: %w", err)
}
}
// Look up profile_id for this antenna's station — needed for the
// "single default per band per profile" constraint.
var profileID int64
if err := tx.QueryRowContext(ctx,
`SELECT s.profile_id FROM operating_stations s WHERE s.id = ?`,
a.StationID).Scan(&profileID); err != nil {
return fmt.Errorf("lookup profile id: %w", err)
}
// Replace band list wholesale — simpler than diffing, fine for the
// small N (a typical antenna covers a handful of bands).
if _, err := tx.ExecContext(ctx,
`DELETE FROM operating_antenna_bands WHERE antenna_id = ?`, a.ID); err != nil {
return fmt.Errorf("clear bands: %w", err)
}
for _, b := range a.Bands {
band := strings.TrimSpace(strings.ToLower(b.Band))
if band == "" {
continue
}
def := 0
if b.IsDefault {
def = 1
}
// Insert this antenna's band entry, then if it's a default
// clear other antennas' default for the same band within
// the same profile.
if _, err := tx.ExecContext(ctx,
`INSERT INTO operating_antenna_bands(antenna_id, band, is_default)
VALUES(?, ?, ?)`, a.ID, band, def); err != nil {
return fmt.Errorf("insert band: %w", err)
}
if def == 1 {
if _, err := tx.ExecContext(ctx, `
UPDATE operating_antenna_bands
SET is_default = 0
WHERE band = ?
AND antenna_id != ?
AND antenna_id IN (
SELECT oa.id FROM operating_antennas oa
JOIN operating_stations os ON oa.station_id = os.id
WHERE os.profile_id = ?
)`, band, a.ID, profileID); err != nil {
return fmt.Errorf("clear other defaults: %w", err)
}
}
}
return tx.Commit()
}
// DeleteAntenna cascades to bands via FK ON DELETE CASCADE.
func (r *Repo) DeleteAntenna(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM operating_antennas WHERE id = ?`, id)
return err
}
// BandDefault returns the (station, antenna) flagged default for the given
// band in the given profile. Empty result when nothing matches — callers
// should leave MY_RIG/MY_ANTENNA blank in that case.
func (r *Repo) BandDefault(ctx context.Context, profileID int64, band string) (BandDefault, bool, error) {
band = strings.TrimSpace(strings.ToLower(band))
if band == "" {
return BandDefault{}, false, nil
}
row := r.db.QueryRowContext(ctx, `
SELECT s.id, s.name, s.tx_pwr,
a.id, a.name
FROM operating_antenna_bands ab
JOIN operating_antennas a ON ab.antenna_id = a.id
JOIN operating_stations s ON a.station_id = s.id
WHERE s.profile_id = ? AND ab.band = ? AND ab.is_default = 1
LIMIT 1`, profileID, band)
var (
d BandDefault
pwr sql.NullFloat64
)
if err := row.Scan(&d.StationID, &d.StationName, &pwr,
&d.AntennaID, &d.AntennaName); err != nil {
if err == sql.ErrNoRows {
return BandDefault{}, false, nil
}
return BandDefault{}, false, err
}
if pwr.Valid {
v := pwr.Float64
d.TXPower = &v
}
return d, true, nil
}