325 lines
10 KiB
Go
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
|
|
}
|