configstore

package
v0.13.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 9, 2026 License: GPL-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Package configstore persists graywolf configuration in a SQLite database via GORM. Pure-Go (no cgo) via glebarez/sqlite.

Index

Constants

View Source
const (
	KissModeModem = "modem"
	KissModeTnc   = "tnc"
)

KISS interface mode values. Stored lowercase and matched exactly — see ValidKissMode. The default for newly created rows is KissModeModem so existing behavior is preserved byte-for-byte.

View Source
const (
	DefaultTncIngressRateHz uint32 = 50
	DefaultTncIngressBurst  uint32 = 100
)

Defaults for KissInterface.TncIngressRateHz / TncIngressBurst. Kept in sync with the GORM struct-tag defaults on KissInterface. Go callers should reference these constants rather than hard-coding 50/100 so the two sides of the model can't drift.

View Source
const (
	KissTypeTCP       = "tcp"
	KissTypeTCPClient = "tcp-client"
	KissTypeSerial    = "serial"
	KissTypeBluetooth = "bluetooth"
)

KISS interface transport types. Kept lowercase and matched exactly via ValidKissInterfaceType. "tcp" is the server-listen (inbound) transport — graywolf binds ListenAddr and accepts multiple clients. "tcp-client" is the outbound dial (Phase 4) — graywolf connects to a remote KISS TNC at RemoteHost:RemotePort and maintains a single supervised connection with exponential backoff + jitter.

View Source
const (
	ChannelModeAPRS       = "aprs"        // APRS only — connected-mode AX.25 refuses this channel
	ChannelModePacket     = "packet"      // Packet only — beacon/digi/igate/messages all suppressed for this channel
	ChannelModeAPRSPacket = "aprs+packet" // Permissive — both allowed
)

Channel.Mode values. Default is ChannelModeAPRS to preserve current behavior on databases that pre-date the column.

View Source
const (
	ReferrerTypeBeacon             = "beacon"
	ReferrerTypeDigipeaterRuleFrom = "digipeater_rule_from"
	ReferrerTypeDigipeaterRuleTo   = "digipeater_rule_to"
	ReferrerTypeKissInterface      = "kiss_interface"
	ReferrerTypeIGateConfigRf      = "igate_config_rf"
	ReferrerTypeIGateConfigTx      = "igate_config_tx"
	ReferrerTypeIGateRfFilter      = "igate_rf_filter"
	ReferrerTypeTxTiming           = "tx_timing"
)

Referrer Type tokens. Exported so the webapi layer can switch on them without re-stringing constants in two places.

Variables

This section is empty.

Functions

func IsNotFound added in v0.13.0

func IsNotFound(err error) bool

IsNotFound mirrors gorm.ErrRecordNotFound for callers that want a stable not-found check without importing gorm directly.

func IsValidTheme

func IsValidTheme(id string) bool

IsValidTheme reports whether id is a well-formed theme identifier. It does NOT verify the id corresponds to a shipped theme — that's the frontend's job.

func U32Ptr

func U32Ptr(v uint32) *uint32

U32Ptr returns a pointer to a copy of v. Small helper for call sites (tests, DTO mappers, fixtures) that need to set Channel.InputDeviceID — a *uint32 after the Phase 2 nullable migration — from a literal or a uint32 local. Keeps the common case a one-liner without the "declare local, take address" dance.

func ValidChannelMode added in v0.12.4

func ValidChannelMode(m string) bool

ValidChannelMode reports whether m is an accepted Channel.Mode value. Empty string is rejected; callers wanting the default must substitute ChannelModeAPRS first.

func ValidKissInterfaceType

func ValidKissInterfaceType(t string) bool

ValidKissInterfaceType reports whether t is an accepted KissInterface.InterfaceType value. "tcp-client" was added in Phase 4 of the KISS TCP-client + channel-backing plan.

func ValidKissMode

func ValidKissMode(m string) bool

ValidKissMode reports whether m is an accepted KissInterface.Mode value. The match is case-sensitive and the empty string is rejected: callers that want the "absent field" default to land on KissModeModem must substitute it themselves before calling this helper.

Types

type AX25SessionProfile added in v0.12.4

type AX25SessionProfile struct {
	ID        uint32 `gorm:"primaryKey;autoIncrement" json:"id"`
	Name      string `gorm:"not null;default:''" json:"name"`
	LocalCall string `gorm:"not null" json:"local_call"`
	LocalSSID uint8  `gorm:"column:local_ssid;not null;default:0" json:"local_ssid"`
	DestCall  string `gorm:"not null" json:"dest_call"`
	DestSSID  uint8  `gorm:"column:dest_ssid;not null;default:0" json:"dest_ssid"`
	// ViaPath is a comma-separated digipeater list ("WIDE2-1,RELAY") so
	// the column stays a single text column without a join table.
	ViaPath   string  `gorm:"not null;default:''" json:"via_path"`
	Mod128    bool    `gorm:"not null;default:false" json:"mod128"`
	Paclen    uint32  `gorm:"not null;default:0" json:"paclen"`
	Maxframe  uint32  `gorm:"not null;default:0" json:"maxframe"`
	T1MS      uint32  `gorm:"not null;default:0" json:"t1_ms"`
	T2MS      uint32  `gorm:"not null;default:0" json:"t2_ms"`
	T3MS      uint32  `gorm:"not null;default:0" json:"t3_ms"`
	N2        uint32  `gorm:"not null;default:0" json:"n2"`
	ChannelID *uint32 `gorm:"" json:"channel_id,omitempty"`
	// Pinned distinguishes operator-saved profiles from automatic recents.
	// A pinned profile survives the recent-list trim that caps unpinned
	// rows at 20.
	Pinned bool `gorm:"not null;default:false;index" json:"pinned"`
	// LastUsed is updated whenever a profile is bound to a session that
	// reached CONNECTED. NULL until the first successful connection.
	LastUsed  *time.Time `gorm:"index" json:"last_used,omitempty"`
	CreatedAt time.Time  `json:"-"`
	UpdatedAt time.Time  `json:"-"`
}

AX25SessionProfile is a saved BBS shortcut. Operators can pin recents and the terminal UI surfaces both pinned and recent profiles in the pre-connect form. See plan §3d.

type AX25TerminalConfig added in v0.12.4

type AX25TerminalConfig struct {
	ID             uint32 `gorm:"primaryKey;autoIncrement" json:"id"`
	ScrollbackRows uint32 `gorm:"not null;default:1000" json:"scrollback_rows"`
	CursorBlink    bool   `gorm:"not null;default:false" json:"cursor_blink"`
	DefaultModulo  uint32 `gorm:"not null;default:8" json:"default_modulo"`
	DefaultPaclen  uint32 `gorm:"not null;default:256" json:"default_paclen"`
	// MacrosJSON stores `[{"label": "...", "payload": "<base64>"}]`. Kept
	// as a JSON-text column so the schema does not have to evolve as the
	// macro shape grows; the REST DTO marshals to a typed array.
	MacrosJSON    string    `gorm:"type:text;not null;default:'[]'" json:"-"`
	RawTailFilter string    `gorm:"not null;default:''" json:"raw_tail_filter"`
	CreatedAt     time.Time `json:"-"`
	UpdatedAt     time.Time `json:"-"`
}

AX25TerminalConfig is a singleton (id=1) row holding terminal-route UI preferences and operator-defined macros. See plan §3c.1 for the fields and how the bridge consumes them.

type AX25TranscriptEntry added in v0.12.4

type AX25TranscriptEntry struct {
	ID        uint64    `gorm:"primaryKey;autoIncrement" json:"id"`
	SessionID uint32    `gorm:"not null;index" json:"session_id"`
	TS        time.Time `gorm:"not null;index" json:"ts"`
	Direction string    `gorm:"not null" json:"direction"` // rx|tx
	Kind      string    `gorm:"not null" json:"kind"`      // data|event
	Payload   []byte    `gorm:"" json:"payload,omitempty"`
}

AX25TranscriptEntry is one line in a transcript: a single observed data block or a link-state event timestamp.

type AX25TranscriptSession added in v0.12.4

type AX25TranscriptSession struct {
	ID         uint32     `gorm:"primaryKey;autoIncrement" json:"id"`
	ChannelID  uint32     `gorm:"not null;index" json:"channel_id"`
	PeerCall   string     `gorm:"not null;index" json:"peer_call"`
	PeerSSID   uint8      `gorm:"column:peer_ssid;not null;default:0" json:"peer_ssid"`
	ViaPath    string     `gorm:"not null;default:''" json:"via_path"`
	StartedAt  time.Time  `gorm:"not null;index" json:"started_at"`
	EndedAt    *time.Time `gorm:"" json:"ended_at,omitempty"`
	EndReason  string     `gorm:"not null;default:''" json:"end_reason"`
	ByteCount  uint64     `gorm:"not null;default:0" json:"byte_count"`
	FrameCount uint64     `gorm:"not null;default:0" json:"frame_count"`
	CreatedAt  time.Time  `json:"-"`
	UpdatedAt  time.Time  `json:"-"`
}

AX25TranscriptSession is a single recorded link, populated when the operator toggles transcript on for that session. See plan §3e.

type Action added in v0.13.0

type Action struct {
	ID                  uint   `gorm:"primaryKey"`
	Name                string `gorm:"uniqueIndex;size:32;not null"`
	Description         string
	Type                string `gorm:"size:16;not null"` // 'command' | 'webhook'
	CommandPath         string
	WorkingDir          string
	WebhookMethod       string `gorm:"size:8"` // 'GET' | 'POST'
	WebhookURL          string
	WebhookHeaders      string `gorm:"type:text;default:'{}'"` // JSON map
	WebhookBodyTemplate string `gorm:"type:text"`
	TimeoutSec          int    `gorm:"not null;default:10"`
	// `default:true` on a gorm bool tag is a footgun: gorm uses it as
	// the value to send when the Go field is its zero value, which makes
	// a genuine `false` from the wire indistinguishable from "field not
	// set". A fresh action created with the toggle off would silently
	// persist as enabled. The DDL still carries DEFAULT 1 (see
	// migrate_actions.go) for downgrade-safety; the application layer
	// always provides an explicit value via the dto.Action wire shape,
	// so dropping the gorm-side default here costs nothing.
	OTPRequired     bool   `gorm:"not null"`
	OTPCredentialID *uint  `gorm:"column:otp_credential_id"` // FK to OTPCredential, nullable; ON DELETE SET NULL
	SenderAllowlist string // CSV
	ArgSchema       string `gorm:"type:text;default:'[]'"` // JSON list
	ArgMode         string `gorm:"size:16;not null;default:'kv'"`
	RateLimitSec    int    `gorm:"not null;default:5"`
	QueueDepth      int    `gorm:"not null;default:8"`
	MaxReplyLines   int    `gorm:"not null;default:1"`
	Enabled         bool   `gorm:"not null"`
	CreatedAt       time.Time
	UpdatedAt       time.Time
}

Action is one operator-defined trigger. Identified by Name (used as the message keyword). Type switches between command and webhook execution; the type-specific fields are nullable.

func (*Action) BeforeSave added in v0.13.0

func (a *Action) BeforeSave(_ *gorm.DB) error

BeforeSave normalizes Name to uppercase and trims whitespace before insert or update. The on-air Action grammar treats names as case-insensitive; canonical form on the wire and in the audit log is uppercase. Normalizing on write keeps the unique index on `name` collision-free across mixed-case operator input.

type ActionInvocation added in v0.13.0

type ActionInvocation struct {
	ID              uint   `gorm:"primaryKey"`
	ActionID        *uint  `gorm:"index"`
	ActionNameAt    string `gorm:"size:64"`
	SenderCall      string `gorm:"size:9;index"`
	Source          string `gorm:"size:4"` // 'rf' | 'is'
	OTPCredentialID *uint  `gorm:"column:otp_credential_id"`
	OTPVerified     bool
	RawArgsJSON     string `gorm:"type:text"`
	Status          string `gorm:"size:24"`
	StatusDetail    string
	ExitCode        *int
	HTTPStatus      *int
	OutputCapture   string `gorm:"type:text"`
	ReplyText       string
	Truncated       bool
	ReplyLineCount  int       `gorm:"not null;default:1"`
	CreatedAt       time.Time `gorm:"index"`
}

ActionInvocation is the per-attempt audit row. ActionID is nullable so an invocation that resolved to status=unknown still gets logged. ActionNameAt is denormalized so a row remains readable after the underlying Action is deleted.

type ActionInvocationFilter added in v0.13.0

type ActionInvocationFilter struct {
	ActionID   *uint
	SenderCall string
	Status     string
	Source     string // 'rf' | 'is'
	Search     string
	Limit      int
	Offset     int
}

ActionInvocationFilter narrows a ListActionInvocations call. All fields are optional; the zero filter returns the most recent rows up to the default limit. Search is a case-insensitive substring match applied to action_name_at, sender_call, and status_detail in a single OR predicate so the UI's free-text box can probe multiple columns without the caller having to choose.

type ActionListenerAddressee added in v0.13.0

type ActionListenerAddressee struct {
	ID        uint   `gorm:"primaryKey"`
	Addressee string `gorm:"uniqueIndex;size:9;not null"`
	CreatedAt time.Time
}

ActionListenerAddressee extends the Actions trigger surface with an extra APRS addressee (e.g. "GWACT") independent of the station call or tactical aliases. Ships empty.

type AgwConfig

type AgwConfig struct {
	ID         uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	ListenAddr string    `gorm:"not null;default:'0.0.0.0:8000'" json:"listen_addr"`
	Callsigns  string    `gorm:"not null;default:'N0CALL'" json:"callsigns"` // CSV; one per AGW port
	Enabled    bool      `gorm:"not null;default:false" json:"enabled"`
	CreatedAt  time.Time `json:"-"`
	UpdatedAt  time.Time `json:"-"`
}

AgwConfig is a singleton (id=1) row describing the AGWPE listener.

type AudioDevice

type AudioDevice struct {
	ID         uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	Name       string    `gorm:"not null" json:"name"`
	Direction  string    `gorm:"not null;default:'input'" json:"direction"` // input|output
	SourceType string    `gorm:"not null" json:"source_type"`               // soundcard|flac|stdin|sdr_udp
	SourcePath string    `json:"device_path"`                               // cpal name or file path
	SampleRate uint32    `gorm:"not null;default:48000" json:"sample_rate"`
	Channels   uint32    `gorm:"not null;default:1" json:"channels"`
	Format     string    `gorm:"not null;default:'s16le'" json:"format"`
	GainDB     float32   `gorm:"not null;default:0" json:"gain_db"` // software gain: -60 to +12 dB, 0 = unity
	CreatedAt  time.Time `json:"-"`
	UpdatedAt  time.Time `json:"-"`
}

AudioDevice describes a single audio input source feeding the modem. SourceType selects how the Rust modem opens the device:

  • "soundcard": cpal device by name (DeviceName is cpal name)
  • "flac": file playback (DeviceName/SourcePath is file path)
  • "stdin": raw s16le on stdin
  • "sdr_udp": SDR UDP stream (later phases)

type Beacon

type Beacon struct {
	ID           uint32  `gorm:"primaryKey;autoIncrement" json:"id"`
	Type         string  `gorm:"not null;default:'position'" json:"type"` // position|object|tracker|custom|igate
	Channel      uint32  `gorm:"not null;default:1" json:"channel"`
	Callsign     string  `gorm:"not null" json:"callsign"`
	Destination  string  `gorm:"not null;default:'APGRWO'" json:"destination"`
	Path         string  `gorm:"not null;default:'WIDE1-1'" json:"path"`
	UseGps       bool    `gorm:"column:use_gps;default:false" json:"use_gps"` // source lat/lon/alt from GPS cache instead of fixed fields
	Latitude     float64 `json:"latitude"`
	Longitude    float64 `json:"longitude"`
	AltFt        float64 `json:"alt_ft"` // altitude in feet for position reports
	Ambiguity    uint32  `gorm:"not null;default:0" json:"ambiguity"`
	SymbolTable  string  `gorm:"not null;default:'/'" json:"symbol_table"`
	Symbol       string  `gorm:"not null;default:'-'" json:"symbol"`
	Overlay      string  `json:"overlay"`                                 // alternate symbol table overlay character
	Compress     bool    `gorm:"not null;default:true" json:"compress"`   // use 13-byte base-91 compressed position encoding (APRS101 ch 9)
	Messaging    bool    `gorm:"not null;default:false" json:"messaging"` // '=' instead of '!' prefix
	Comment      string  `json:"comment"`
	CommentCmd   string  `json:"comment_cmd"`                      // shell command whose stdout is appended as comment
	CustomInfo   string  `json:"custom_info"`                      // raw info field override for Type=="custom"
	ObjectName   string  `json:"object_name"`                      // for Type=="object"
	Power        uint32  `gorm:"not null;default:0" json:"power"`  // watts for PHG
	Height       uint32  `gorm:"not null;default:0" json:"height"` // feet HAAT for PHG
	Gain         uint32  `gorm:"not null;default:0" json:"gain"`   // dBi for PHG
	Dir          uint32  `gorm:"not null;default:0" json:"dir"`    // antenna direction 0..8 for PHG
	Freq         string  `json:"freq"`                             // frequency string for freq info
	Tone         string  `json:"tone"`                             // CTCSS/DCS tone
	FreqOffset   string  `json:"freq_offset"`                      // repeater offset
	DelaySeconds uint32  `gorm:"not null;default:30" json:"delay_seconds"`
	EverySeconds uint32  `gorm:"not null;default:1800" json:"interval"`
	SlotSeconds  int32   `gorm:"not null;default:-1" json:"slot_seconds"`
	SmartBeacon  bool    `gorm:"not null;default:false" json:"smart_beacon"`
	// Deprecated: use the global configstore.SmartBeaconConfig instead.
	// This column is no longer read as of 2026-04-18 (the SmartBeacon
	// curve is now a global singleton, matching direwolf). The column
	// will be dropped in a future migration once all deployments have
	// moved to the global config. See
	// .context/2026-04-18-smart-beacon-implementation.md.
	SbFastSpeed uint32 `gorm:"default:60" json:"sb_fast_speed"`
	// Deprecated: use the global configstore.SmartBeaconConfig instead.
	// This column is no longer read as of 2026-04-18 (the SmartBeacon
	// curve is now a global singleton, matching direwolf). The column
	// will be dropped in a future migration once all deployments have
	// moved to the global config. See
	// .context/2026-04-18-smart-beacon-implementation.md.
	SbSlowSpeed uint32 `gorm:"default:5" json:"sb_slow_speed"`
	// Deprecated: use the global configstore.SmartBeaconConfig instead.
	// This column is no longer read as of 2026-04-18 (the SmartBeacon
	// curve is now a global singleton, matching direwolf). The column
	// will be dropped in a future migration once all deployments have
	// moved to the global config. See
	// .context/2026-04-18-smart-beacon-implementation.md.
	SbFastRate uint32 `gorm:"default:60" json:"sb_fast_rate"`
	// Deprecated: use the global configstore.SmartBeaconConfig instead.
	// This column is no longer read as of 2026-04-18 (the SmartBeacon
	// curve is now a global singleton, matching direwolf). The column
	// will be dropped in a future migration once all deployments have
	// moved to the global config. See
	// .context/2026-04-18-smart-beacon-implementation.md.
	SbSlowRate uint32 `gorm:"default:1800" json:"sb_slow_rate"`
	// Deprecated: use the global configstore.SmartBeaconConfig instead.
	// This column is no longer read as of 2026-04-18 (the SmartBeacon
	// curve is now a global singleton, matching direwolf). The column
	// will be dropped in a future migration once all deployments have
	// moved to the global config. See
	// .context/2026-04-18-smart-beacon-implementation.md.
	SbTurnAngle uint32 `gorm:"default:30" json:"sb_turn_angle"`
	// Deprecated: use the global configstore.SmartBeaconConfig instead.
	// This column is no longer read as of 2026-04-18 (the SmartBeacon
	// curve is now a global singleton, matching direwolf). The column
	// will be dropped in a future migration once all deployments have
	// moved to the global config. See
	// .context/2026-04-18-smart-beacon-implementation.md.
	SbTurnSlope uint32 `gorm:"default:255" json:"sb_turn_slope"`
	// Deprecated: use the global configstore.SmartBeaconConfig instead.
	// This column is no longer read as of 2026-04-18 (the SmartBeacon
	// curve is now a global singleton, matching direwolf). The column
	// will be dropped in a future migration once all deployments have
	// moved to the global config. See
	// .context/2026-04-18-smart-beacon-implementation.md.
	SbMinTurnTime uint32    `gorm:"default:5" json:"sb_min_turn_time"`
	SendToAPRSIS  bool      `gorm:"column:send_to_aprs_is;not null;default:false" json:"send_to_aprs_is"`
	Enabled       bool      `gorm:"not null;default:true" json:"enabled"`
	CreatedAt     time.Time `json:"-"`
	UpdatedAt     time.Time `json:"-"`
}

Beacon is a scheduled beacon. Type selects the payload builder.

type Channel

type Channel struct {
	ID             uint32       `gorm:"primaryKey;autoIncrement" json:"id"`
	Name           string       `gorm:"not null" json:"name"`
	InputDeviceID  *uint32      `gorm:"index" json:"input_device_id"`
	InputDevice    *AudioDevice `gorm:"foreignKey:InputDeviceID;references:ID;constraint:OnDelete:RESTRICT,OnUpdate:RESTRICT" json:"-"`
	InputChannel   uint32       `gorm:"not null;default:0" json:"input_channel"`          // 0=left/mono, 1=right
	OutputDeviceID uint32       `gorm:"not null;default:0;index" json:"output_device_id"` // 0=RX-only; soft FK, see type comment
	OutputChannel  uint32       `gorm:"not null;default:0" json:"output_channel"`
	ModemType      string       `gorm:"not null;default:'afsk'" json:"modem_type"`
	BitRate        uint32       `gorm:"not null;default:1200" json:"bit_rate"`
	MarkFreq       uint32       `gorm:"not null;default:1200" json:"mark_freq"`
	SpaceFreq      uint32       `gorm:"not null;default:2200" json:"space_freq"`
	Profile        string       `gorm:"not null;default:'A'" json:"profile"`
	NumSlicers     uint32       `gorm:"not null;default:1" json:"num_slicers"`
	FixBits        string       `gorm:"not null;default:'none'" json:"fix_bits"` // none|single|double
	FX25Encode     bool         `gorm:"not null;default:false" json:"fx25_encode"`
	IL2PEncode     bool         `gorm:"column:il2p_encode;not null;default:false" json:"il2p_encode"`
	NumDecoders    uint32       `gorm:"not null;default:1" json:"num_decoders"`
	DecoderOffset  int32        `gorm:"not null;default:0" json:"decoder_offset"`
	Mode           string       `gorm:"not null;default:'aprs'" json:"mode"` // aprs|packet|aprs+packet
	CreatedAt      time.Time    `json:"-"`
	UpdatedAt      time.Time    `json:"-"`
}

Channel is a logical radio channel optionally tied to an audio device.

Foreign-key policy:

  • InputDeviceID is a *pointer-typed soft FK* to AudioDevice.ID: a nil value means "KISS-only channel — no modem, no audio". When non-nil, the value must reference an existing input-direction device (enforced at the application layer in validateChannel). Phase 2 migrated the column from NOT NULL to NULL to allow channels that are serviced only by a KISS TNC interface. DeleteAudioDeviceChecked still walks both input and output references to refuse / cascade a device delete that would orphan channels.
  • OutputDeviceID is a *soft* FK, not enforced by SQLite. The column is a plain uint32 where 0 means "RX-only" (no output device). SQLite FK constraints treat any non-NULL value as a reference, so a stored 0 would fail the constraint, and making the column nullable would ripple through DTOs and protobuf mappings for no gain. The relation is validated at the application layer in validateChannel, and DeleteAudioDeviceChecked walks both input and output references.

When InputDeviceID is nil, ModemType / BitRate / MarkFreq / SpaceFreq / Profile / NumSlicers / FixBits / FX25Encode / IL2PEncode / NumDecoders / DecoderOffset are stored unchanged but effectively unused: the modem subprocess is never told about the channel (see pkg/modembridge/session.go pushConfiguration, which skips nil-input channels). They round-trip through the UI so a future Convert flow can flip a channel back to modem-backed without losing the operator's last known values.

type ChannelModeLookup added in v0.12.4

type ChannelModeLookup interface {
	ModeForChannel(ctx context.Context, channelID uint32) (string, error)
}

ChannelModeLookup is the small read-only surface the TX-gating subsystems (beacon, digipeater, igate, messages, ax25conn) consume to decide whether to permit a transmit on a given channel. The concrete *Store implements it; tests can substitute a fake.

type ConfigStore

type ConfigStore interface {
	// Audio devices
	CreateAudioDevice(ctx context.Context, d *AudioDevice) error
	GetAudioDevice(ctx context.Context, id uint32) (*AudioDevice, error)
	ListAudioDevices(ctx context.Context) ([]AudioDevice, error)
	UpdateAudioDevice(ctx context.Context, d *AudioDevice) error
	DeleteAudioDevice(ctx context.Context, id uint32) error

	// Channels
	CreateChannel(ctx context.Context, c *Channel) error
	GetChannel(ctx context.Context, id uint32) (*Channel, error)
	ListChannels(ctx context.Context) ([]Channel, error)
	UpdateChannel(ctx context.Context, c *Channel) error
	DeleteChannel(ctx context.Context, id uint32) error
	SetChannelFX25(ctx context.Context, id uint32, enable bool) error
	SetChannelIL2P(ctx context.Context, id uint32, enable bool) error

	// PTT
	UpsertPttConfig(ctx context.Context, p *PttConfig) error
	GetPttConfigForChannel(ctx context.Context, channelID uint32) (*PttConfig, error)

	// TX timing
	ListTxTimings(ctx context.Context) ([]TxTiming, error)
	GetTxTiming(ctx context.Context, channel uint32) (*TxTiming, error)
	UpsertTxTiming(ctx context.Context, t *TxTiming) error

	// KISS interfaces
	ListKissInterfaces(ctx context.Context) ([]KissInterface, error)
	GetKissInterface(ctx context.Context, id uint32) (*KissInterface, error)
	CreateKissInterface(ctx context.Context, k *KissInterface) error
	UpdateKissInterface(ctx context.Context, k *KissInterface) error
	DeleteKissInterface(ctx context.Context, id uint32) error

	// AGW
	GetAgwConfig(ctx context.Context) (*AgwConfig, error)
	UpsertAgwConfig(ctx context.Context, c *AgwConfig) error

	// Digipeater
	GetDigipeaterConfig(ctx context.Context) (*DigipeaterConfig, error)
	UpsertDigipeaterConfig(ctx context.Context, c *DigipeaterConfig) error
	ListDigipeaterRules(ctx context.Context) ([]DigipeaterRule, error)
	ListDigipeaterRulesForChannel(ctx context.Context, channel uint32) ([]DigipeaterRule, error)
	CreateDigipeaterRule(ctx context.Context, r *DigipeaterRule) error
	UpdateDigipeaterRule(ctx context.Context, r *DigipeaterRule) error
	DeleteDigipeaterRule(ctx context.Context, id uint32) error

	// iGate
	GetIGateConfig(ctx context.Context) (*IGateConfig, error)
	UpsertIGateConfig(ctx context.Context, c *IGateConfig) error
	ListIGateRfFilters(ctx context.Context) ([]IGateRfFilter, error)
	ListIGateRfFiltersForChannel(ctx context.Context, channel uint32) ([]IGateRfFilter, error)
	CreateIGateRfFilter(ctx context.Context, f *IGateRfFilter) error
	UpdateIGateRfFilter(ctx context.Context, f *IGateRfFilter) error
	DeleteIGateRfFilter(ctx context.Context, id uint32) error

	// Beacons
	ListBeacons(ctx context.Context) ([]Beacon, error)
	GetBeacon(ctx context.Context, id uint32) (*Beacon, error)
	CreateBeacon(ctx context.Context, b *Beacon) error
	UpdateBeacon(ctx context.Context, b *Beacon) error
	DeleteBeacon(ctx context.Context, id uint32) error

	// GPS
	GetGPSConfig(ctx context.Context) (*GPSConfig, error)
	UpsertGPSConfig(ctx context.Context, c *GPSConfig) error

	// Packet filters
	ListPacketFilters(ctx context.Context) ([]PacketFilter, error)
}

ConfigStore defines the persistence contract for graywolf configuration. The concrete *Store satisfies this interface; consumers should depend on ConfigStore to enable testing with fakes.

type DigipeaterConfig

type DigipeaterConfig struct {
	ID                  uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	Enabled             bool      `gorm:"not null;default:false" json:"enabled"`
	DedupeWindowSeconds uint32    `gorm:"not null;default:30" json:"dedupe_window_seconds"`
	MyCall              string    `gorm:"not null;default:'N0CALL'" json:"my_call"` // local callsign used for preemptive digi
	CreatedAt           time.Time `json:"-"`
	UpdatedAt           time.Time `json:"-"`
}

DigipeaterConfig is a singleton (id=1) row with global digipeater settings.

type DigipeaterRule

type DigipeaterRule struct {
	ID          uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	FromChannel uint32    `gorm:"not null;index" json:"from_channel"`
	ToChannel   uint32    `gorm:"not null" json:"to_channel"`
	Alias       string    `gorm:"not null" json:"alias"`
	AliasType   string    `gorm:"not null;default:'widen'" json:"alias_type"` // widen|exact|trace
	MaxHops     uint32    `gorm:"not null;default:2" json:"max_hops"`         // maximum N-N accepted (e.g. WIDE2-2)
	Action      string    `gorm:"not null;default:'repeat'" json:"action"`
	Priority    uint32    `gorm:"not null;default:100" json:"priority"` // lower = evaluated first
	Enabled     bool      `gorm:"not null;default:true" json:"enabled"`
	CreatedAt   time.Time `json:"-"`
	UpdatedAt   time.Time `json:"-"`
}

DigipeaterRule is one per-channel digipeater alias/rule. The digi engine walks rules in Priority ascending order looking for a match against an unconsumed path entry.

Action enumeration:

"repeat"   — retransmit on ToChannel, consume this alias slot
"drop"     — match and suppress (filter-only rule)

AliasType enumeration:

"widen"    — WIDEn-N style (Alias is the base e.g. "WIDE"; consumes 1 hop, decrements SSID)
"exact"    — exact callsign match (Alias is full "CALL[-SSID]"); e.g. the local callsign (preemptive)
"trace"    — TRACEn-N behaves like WIDEn-N but also inserts the local callsign before the alias

type GPSConfig

type GPSConfig struct {
	ID         uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	SourceType string    `gorm:"not null;default:'none'" json:"source"` // none|serial|gpsd
	Device     string    `json:"serial_port"`                           // serial device path, e.g. /dev/ttyUSB0
	BaudRate   uint32    `gorm:"not null;default:4800" json:"baud_rate"`
	GpsdHost   string    `gorm:"not null;default:'localhost'" json:"gpsd_host"`
	GpsdPort   uint32    `gorm:"not null;default:2947" json:"gpsd_port"`
	Enabled    bool      `gorm:"not null;default:false" json:"enabled"`
	CreatedAt  time.Time `json:"-"`
	UpdatedAt  time.Time `json:"-"`
}

GPSConfig is a singleton (id=1) row for the GPS receiver.

type IGateConfig

type IGateConfig struct {
	ID              uint32 `gorm:"primaryKey;autoIncrement" json:"id"`
	Enabled         bool   `gorm:"not null;default:false" json:"enabled"`
	Server          string `gorm:"not null;default:'rotate.aprs2.net'" json:"server"`
	Port            uint32 `gorm:"not null;default:14580" json:"port"`
	ServerFilter    string `json:"server_filter"` // APRS-IS server-side filter expression
	SimulationMode  bool   `gorm:"not null;default:false" json:"simulation_mode"`
	GateRfToIs      bool   `gorm:"not null;default:true" json:"gate_rf_to_is"`
	GateIsToRf      bool   `gorm:"not null;default:false" json:"gate_is_to_rf"`
	RfChannel       uint32 `gorm:"not null;default:0" json:"rf_channel"`             // channel used when gating IS->RF; 0 = unset
	MaxMsgHops      uint32 `gorm:"not null;default:2" json:"max_msg_hops"`           // WIDE hops for IS->RF messages
	SoftwareName    string `gorm:"not null;default:'graywolf'" json:"software_name"` // APRS-IS login banner software name
	SoftwareVersion string `gorm:"not null;default:'0.1'" json:"software_version"`   // APRS-IS login banner version
	// TxChannel governs IS->RF on this iGate. The messages-tx channel
	// moved to MessagesConfig.TxChannel; this column stays because
	// migration 13 (`messages_config_singleton`) reads it once on first
	// run to seed MessagesConfig. Do not remove without first deleting
	// that migration -- operators upgrading from older builds will
	// silently lose their messages TX channel otherwise.
	TxChannel uint32    `gorm:"not null;default:0" json:"tx_channel"` // radio channel for IS->RF submissions; 0 = unset
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

IGateConfig is a singleton (id=1) row for the iGate.

Callsign and Passcode columns remain in the DB for forward-safety on downgrade, but are no longer read/written by application code. See .context/2026-04-21-centralized-station-callsign.md §D4.

type IGateRfFilter

type IGateRfFilter struct {
	ID        uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	Channel   uint32    `gorm:"not null;index" json:"channel"`
	Type      string    `gorm:"not null" json:"type"` // callsign|prefix|message_dest|object
	Pattern   string    `gorm:"not null" json:"pattern"`
	Action    string    `gorm:"not null;default:'allow'" json:"action"` // allow|deny
	Priority  uint32    `gorm:"not null;default:100" json:"priority"`
	Enabled   bool      `gorm:"not null;default:true" json:"enabled"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

IGateRfFilter is a per-channel allow/deny rule used to decide which RF-originated packets are forwarded to APRS-IS. Evaluation: lowest Priority first (ascending order); first match determines action.

type KissInterface

type KissInterface struct {
	ID               uint32 `gorm:"primaryKey;autoIncrement" json:"id"`
	Name             string `gorm:"not null;uniqueIndex" json:"name"`
	InterfaceType    string `gorm:"not null;default:'tcp'" json:"type"` // tcp|tcp-client|serial|bluetooth
	ListenAddr       string `json:"listen_addr"`                        // host:port for tcp (server-listen)
	Device           string `json:"serial_device"`                      // /dev/ttyUSB0 or bluetooth mac
	BaudRate         uint32 `gorm:"default:9600" json:"baud_rate"`
	Channel          uint32 `gorm:"not null;default:1" json:"channel"` // default radio channel for this interface
	Broadcast        bool   `gorm:"not null;default:true" json:"broadcast"`
	Enabled          bool   `gorm:"not null;default:true" json:"enabled"`
	Mode             string `gorm:"not null;default:'modem'" json:"mode"`           // modem|tnc
	TncIngressRateHz uint32 `gorm:"not null;default:50" json:"tnc_ingress_rate_hz"` // token-bucket refill, frames/sec
	TncIngressBurst  uint32 `gorm:"not null;default:100" json:"tnc_ingress_burst"`  // token-bucket size
	// InterfaceType == "tcp-client" uses RemoteHost / RemotePort as the
	// dial target and ReconnectInitMs / ReconnectMaxMs to size the
	// supervisor's backoff schedule. ListenAddr is ignored on tcp-client
	// rows. Unused / zero on all other interface types; see Phase 4 in
	// .context/2026-04-20-kiss-tcp-client-and-channel-backing.md.
	RemoteHost      string `gorm:"column:remote_host;not null;default:''" json:"remote_host"`
	RemotePort      uint16 `gorm:"column:remote_port;not null;default:0" json:"remote_port"`
	ReconnectInitMs uint32 `gorm:"column:reconnect_init_ms;not null;default:1000" json:"reconnect_init_ms"`
	ReconnectMaxMs  uint32 `gorm:"column:reconnect_max_ms;not null;default:300000" json:"reconnect_max_ms"`
	// AllowTxFromGovernor: when true (and Mode == KissModeTnc), this
	// interface is registered as a KissTnc TX backend and the
	// dispatcher fan-outs governor-scheduled frames (beacon / digi /
	// iGate IS→RF / KISS / AGW submissions) for this channel to it.
	// Default false so existing TNC-mode rows that users configured
	// before Phase 3 do NOT silently start transmitting. Phase 4 sets
	// the DTO default to true for newly-created tcp-client rows only.
	// Modem-mode rows ignore this flag entirely (they TX via Submit,
	// they don't receive TX from the governor).
	AllowTxFromGovernor bool `gorm:"column:allow_tx_from_governor;not null;default:false" json:"allow_tx_from_governor"`
	// NeedsReconfig is set to true when a referential cascade (Phase 5)
	// nulls this row's Channel. Phase 3 merely declares the column so
	// the shape is stable before the cascade logic lands; no code reads
	// it yet.
	NeedsReconfig bool      `gorm:"column:needs_reconfig;not null;default:false" json:"needs_reconfig"`
	CreatedAt     time.Time `json:"-"`
	UpdatedAt     time.Time `json:"-"`
}

KissInterface represents one row in kiss_interfaces. Each Server in pkg/kiss corresponds to one row. InterfaceType is "tcp"|"serial"| "bluetooth"; for serial/bluetooth the Device and BaudRate are used and ListenAddr may be empty.

Mode selects the per-interface routing policy:

  • KissModeModem (default): peer is an APRS app; frames it sends are queued for RF transmission.
  • KissModeTnc: peer is a hardware TNC supplying off-air RX; frames are fanned out to digi/igate/messages/station cache, not auto-submitted to TX. See .context/2026-04-19-kiss-modem-tnc-mode.md.

TncIngressRateHz and TncIngressBurst configure the per-interface token-bucket ingress cap consumed in TNC mode (wired in Phase 3). The fields are stored and surfaced for every row regardless of mode so the operator's choice survives a mode flip.

type LogBufferConfig

type LogBufferConfig struct {
	ID        uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	MaxRows   int       `gorm:"not null;default:0" json:"max_rows"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

LogBufferConfig stores the operator's override for the in-database log ring size. Singleton at id=1. MaxRows == 0 disables persistence entirely (back to console-only logging). When no row exists, the logbuffer package picks an environment-aware default (2000 on Pi / SD-card / ramdisk, 5000 on disk-backed systems).

type MapsConfig

type MapsConfig struct {
	ID       uint32 `gorm:"primaryKey;autoIncrement" json:"id"`
	Source   string `gorm:"not null;default:'graywolf'" json:"source"`
	Callsign string `gorm:"not null;default:''" json:"callsign"`
	Token    string `gorm:"not null;default:''" json:"-"`
	// RegisteredAt is the zero time when no registration has occurred;
	// kept as a value type (not *time.Time) so the JSON contract is
	// always a string and Token=="" remains the single source of truth
	// for whether this device is registered.
	RegisteredAt time.Time `json:"registered_at"`
	CreatedAt    time.Time `json:"-"`
	UpdatedAt    time.Time `json:"-"`
}

MapsConfig is the singleton row that captures the operator's basemap source choice plus the device-local registration with auth.nw5w.com. Source is one of "osm" (public OSM raster tiles) or "graywolf" (private maps.nw5w.com vector tiles, requires Token). Graywolf is the default; an empty Token means the device hasn't registered yet, and the maplibre frontend falls back to OSM rendering until it does.

type MapsDownload

type MapsDownload struct {
	ID              uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	Slug            string    `gorm:"not null;uniqueIndex" json:"slug"`
	Status          string    `gorm:"not null;default:'pending'" json:"status"` // pending|downloading|complete|error
	BytesTotal      int64     `gorm:"not null;default:0" json:"bytes_total"`
	BytesDownloaded int64     `gorm:"not null;default:0" json:"bytes_downloaded"`
	DownloadedAt    time.Time `json:"downloaded_at"`
	ErrorMessage    string    `gorm:"not null;default:''" json:"error_message,omitempty"`
	CreatedAt       time.Time `json:"-"`
	UpdatedAt       time.Time `json:"-"`
}

MapsDownload tracks one state's offline PMTiles archive. The file itself lives at <tile-cache-dir>/<slug>.pmtiles; this row is just the metadata. Status transitions: pending -> downloading -> complete | error. A retry restarts at pending.

type Message

type Message struct {
	ID             uint64         `gorm:"primaryKey;autoIncrement" json:"id"`
	Direction      string         `gorm:"not null;index:idx_msg_direction_unread,priority:1" json:"direction"` // "in" | "out"
	OurCall        string         `gorm:"size:9;not null;index:idx_msg_peer,priority:1;index:idx_msg_to_time,priority:1" json:"our_call"`
	PeerCall       string         `gorm:"size:9;not null;index:idx_msg_peer,priority:2;index:idx_msg_peer_time" json:"peer_call"`
	FromCall       string         `gorm:"size:9;not null;index:idx_msg_from_time,priority:1;index:idx_msg_msgid_from,priority:2" json:"from_call"`
	ToCall         string         `gorm:"size:9;not null;index:idx_msg_to_time,priority:2" json:"to_call"`
	Text           string         `gorm:"size:200;not null" json:"text"`
	MsgID          string         `gorm:"size:5;index:idx_msg_msgid_from,priority:1" json:"msg_id"`
	CreatedAt      time.Time      `` /* 198-byte string literal not displayed */
	UpdatedAt      time.Time      `gorm:"not null" json:"updated_at"`
	ReceivedAt     *time.Time     `json:"received_at,omitempty"`
	SentAt         *time.Time     `json:"sent_at,omitempty"`
	AckedAt        *time.Time     `json:"acked_at,omitempty"`
	AckState       string         `gorm:"size:16;not null;default:'none'" json:"ack_state"` // none | acked | rejected | broadcast
	Source         string         `gorm:"size:4;not null;default:''" json:"source"`         // rf | is (string form of aprs.Direction)
	Channel        uint32         `gorm:"not null;default:0" json:"channel"`
	Path           string         `gorm:"size:64" json:"path"`                      // display path, e.g. "W1ABC*,WIDE1-1*"
	Via            string         `gorm:"size:64" json:"via"`                       // last used digipeater
	RawTNC2        string         `gorm:"column:raw_tnc2;size:512" json:"raw_tnc2"` // archival raw text
	Unread         bool           `gorm:"not null;default:false;index:idx_msg_direction_unread,priority:2" json:"unread"`
	Attempts       uint32         `gorm:"not null;default:0" json:"attempts"`
	NextRetryAt    *time.Time     `json:"next_retry_at,omitempty"`
	FailureReason  string         `gorm:"size:128" json:"failure_reason"`
	ReplyAckID     string         `gorm:"size:5" json:"reply_ack_id"` // inbound: APRS11 reply-ack id we observed
	IsAck          bool           `gorm:"not null;default:false" json:"is_ack"`
	IsRej          bool           `gorm:"not null;default:false" json:"is_rej"`
	IsBulletin     bool           `gorm:"not null;default:false" json:"is_bulletin"`
	IsNWS          bool           `gorm:"column:is_nws;not null;default:false" json:"is_nws"`
	PreferIS       bool           `gorm:"column:prefer_is;not null;default:false" json:"prefer_is"`
	DeletedAt      gorm.DeletedAt `gorm:"index" json:"-"`
	ThreadKind     string         `gorm:"size:10;not null;default:'dm';index:idx_msg_thread,priority:1" json:"thread_kind"` // dm | tactical
	ThreadKey      string         `gorm:"size:9;not null;default:'';index:idx_msg_thread,priority:2" json:"thread_key"`     // peer callsign for dm, tactical label for tactical
	ReceivedByCall string         `gorm:"size:9" json:"received_by_call"`                                                   // tactical outbound: first acker's call
	// Kind classifies the message body so the UI can render specialized
	// affordances (e.g. an Accept button for tactical invites) without
	// having to re-parse the wire text. Defaults to "text"; "invite"
	// marks a `!GW1 INVITE <TAC>` DM. The CHECK constraint pins the
	// enum at the SQL layer as a backstop against accidental writes of
	// other values from SQL shells or future migrations. No index — the
	// column is never a query predicate, only a display tag.
	Kind string `gorm:"size:10;not null;default:'text';check:kind IN ('text','invite')" json:"kind"`
	// InviteTactical is the tactical callsign referenced by an invite
	// message. Empty when Kind != "invite". Size 9 mirrors ThreadKey /
	// TacticalCallsign.Callsign.
	InviteTactical string `gorm:"size:9;not null;default:''" json:"invite_tactical"`
	// InviteAcceptedAt records when the local operator accepted this
	// invite. Audit-only: UI rendering of "Joined" keys off the live
	// TacticalSet cache, not this column, so first-paint is race-free
	// on refresh. Nil until accept. No index.
	InviteAcceptedAt *time.Time `json:"invite_accepted_at,omitempty"`
}

Message is one persisted APRS text message, DM or tactical, in either direction. Columns cover the full lifecycle: receipt metadata, state transitions (SentAt/AckedAt/AckState/Attempts), retry scheduling (NextRetryAt + FailureReason), ack/reply-ack correlation (MsgID + ReplyAckID), and thread identity ((ThreadKind, ThreadKey) — "dm" uses peer callsign, "tactical" uses the tactical label).

Lifecycle columns set by the repository:

  • Insert: CreatedAt, ReceivedAt (inbound), Direction, FromCall, ToCall, OurCall, ThreadKind, ThreadKey, PeerCall, Text, Unread, etc. The repository derives ThreadKey + PeerCall at insert and writes them directly — callers only need to set ThreadKind and the direction-dependent raw callsigns.
  • Send pipeline (Phase 3): QueuedAt, SentAt, AckState, Attempts, NextRetryAt, FailureReason.
  • Router (Phase 2): AckedAt, AckState, ReceivedByCall (for tactical reply-ack correlation).

ThreadKind is one of: "dm" (1:1) or "tactical" (group broadcast via tactical callsign). See the APRS messages feature plan for the full design.

func (Message) TableName

func (Message) TableName() string

TableName pins the messages table name to the plural lower-case form GORM would infer. Explicit so the migration-list raw SQL stays obviously in sync with the model.

type MessageCounter

type MessageCounter struct {
	ID        uint32    `gorm:"primaryKey;autoIncrement" json:"-"`
	NextID    uint32    `gorm:"not null;default:1" json:"next_id"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

MessageCounter is a singleton (id=1) holding the next msgid to allocate. NextID rolls 1..999; allocation skips values currently held by outstanding outbound DM rows (see pkg/messages/store.go AllocateMsgID). Separate from MessagePreferences so bumping the counter does not touch the preferences row.

type MessagePreferences

type MessagePreferences struct {
	ID               uint32 `gorm:"primaryKey;autoIncrement" json:"-"`
	FallbackPolicy   string `gorm:"size:16;not null;default:'is_fallback'" json:"fallback_policy"` // rf_only | is_fallback | is_only | both
	DefaultPath      string `gorm:"size:64;not null;default:'WIDE1-1,WIDE2-1'" json:"default_path"`
	RetryMaxAttempts uint32 `gorm:"not null;default:4" json:"retry_max_attempts"`
	RetentionDays    uint32 `gorm:"not null;default:0" json:"retention_days"` // 0 = forever
	// MaxMessageTextOverride raises the default 67-char cap on
	// addressee-line direct messages up to 200. 0 (the column default,
	// and the value seen on pre-upgrade rows after GORM AutoMigrate
	// adds the column) means "use the default 67". Valid non-zero
	// values fall in [68, 200]; the webapi DTO validator rejects
	// anything outside that range. Applies to addressee-line DMs only:
	// bulletins, status beacons, and position/weather frames are
	// unaffected.
	MaxMessageTextOverride uint32    `gorm:"not null;default:0" json:"max_message_text_override"`
	CreatedAt              time.Time `json:"-"`
	UpdatedAt              time.Time `json:"-"`
}

MessagePreferences is a singleton (id=1) holding operator-level messaging preferences. Seeded at migrate-time with defaults if no row exists. See plan Phase 3 for semantics.

type MessagesConfig added in v0.12.4

type MessagesConfig struct {
	ID        uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	TxChannel uint32    `gorm:"not null;default:0" json:"tx_channel"` // 0 = auto-resolve at runtime
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

MessagesConfig is a singleton (id=1) row that owns messaging-specific settings. TxChannel moved here from IGateConfig; iGate retains its own TxChannel which now governs IS->RF only.

type OTPCredential added in v0.13.0

type OTPCredential struct {
	ID         uint   `gorm:"primaryKey"`
	Name       string `gorm:"uniqueIndex;size:64;not null"`
	Issuer     string `gorm:"size:64"`
	Account    string `gorm:"size:128"`
	Algorithm  string `gorm:"size:16;not null;default:'SHA1'"`
	Digits     int    `gorm:"not null;default:6"`
	Period     int    `gorm:"not null;default:30"`
	SecretB32  string `gorm:"size:64;not null"`
	CreatedAt  time.Time
	LastUsedAt *time.Time
}

OTPCredential is one TOTP secret. Stored plaintext; UI surfaces the secret only once at create time and never reads it back.

type OrphanChannelRefRows

type OrphanChannelRefRows struct {
	// Token mirrors one of the ReferrerType* constants above so callers
	// can key their log field consistently with the 409 response body.
	Token string
	// RowIDs is the set of row primary keys that have a dangling ref.
	RowIDs []uint32
	// MissingChannelIDs is the deduplicated set of channel ids those
	// rows point at but that no longer exist. May have fewer entries
	// than RowIDs when several rows share the same missing channel.
	MissingChannelIDs []uint32
}

OrphanChannelRefRows describes one table's set of rows whose channel soft-FK column points at a non-existent channel id. Used by the bootstrap audit in pkg/app/wiring.go to emit a per-table WARN line listing the affected row ids plus the distinct missing channel ids, so operators can locate the referrers without clicking through every list page.

type PacketFilter

type PacketFilter struct {
	ID        uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	Channel   uint32    `gorm:"not null;index" json:"channel"`
	Name      string    `gorm:"not null" json:"name"`
	Expr      string    `gorm:"not null" json:"expr"`
	Action    string    `gorm:"not null;default:'allow'" json:"action"`
	Enabled   bool      `gorm:"not null;default:true" json:"enabled"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

PacketFilter is a reserved stub table for future per-channel packet filters (Phase 5/6).

type PositionLogConfig

type PositionLogConfig struct {
	ID      uint32 `gorm:"primaryKey" json:"id"`
	Enabled bool   `gorm:"not null;default:false" json:"enabled"`
	DBPath  string `gorm:"not null;default:'./graywolf-history.db'" json:"db_path"`
}

PositionLogConfig controls the optional persistent position history database. Disabled by default to protect SD-card-based systems.

type PttConfig

type PttConfig struct {
	ID         uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	ChannelID  uint32    `gorm:"not null;uniqueIndex" json:"channel_id"`
	Channel    *Channel  `gorm:"foreignKey:ChannelID;references:ID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE" json:"-"`
	Method     string    `gorm:"not null;default:'none'" json:"method"` // serial_rts|serial_dtr|gpio|cm108|none
	Device     string    `json:"device_path"`
	GpioPin    uint32    `json:"gpio_pin"`                             // CM108-only: 1-indexed HID GPIO pin (default 3)
	GpioLine   uint32    `gorm:"not null;default:0" json:"gpio_line"`  // gpiochip method: 0-indexed line offset
	Invert     bool      `gorm:"not null;default:false" json:"invert"` // reverse polarity for rigs wired backwards
	SlotTimeMs uint32    `gorm:"not null;default:10" json:"slot_time_ms"`
	Persist    uint32    `gorm:"not null;default:63" json:"persist"`
	DwaitMs    uint32    `gorm:"not null;default:0" json:"dwait_ms"`
	CreatedAt  time.Time `json:"-"`
	UpdatedAt  time.Time `json:"-"`
}

PttConfig holds push-to-talk configuration for a channel. ChannelID is a hard FK to Channel.ID with OnDelete:CASCADE: PTT settings have no meaning without the channel they belong to, and the uniqueIndex on ChannelID guarantees one row per channel.

type Referrer

type Referrer struct {
	Type string `json:"type"`
	ID   uint32 `json:"id"`
	Name string `json:"name"`
}

Referrer describes a single row in a dependent table that references a channel via a soft integer FK. Used by ChannelReferrers to surface the impact of a cascade delete to the operator before commit.

Type is a stable token identifying the referent table + role (so two columns of the same table — e.g. digipeater_rule_from vs digipeater_rule_to — don't collapse together). ID is the row's own primary key. Name is a human-legible label chosen from the row (Beacon.Callsign+Type, DigipeaterRule.Alias, KissInterface.Name, etc.); empty for singleton referents (IGateConfig) where the row has no meaningful display name.

type Referrers

type Referrers struct {
	Items []Referrer `json:"items"`
}

Referrers is the collected result of a ChannelReferrers scan. Items is always non-nil (possibly empty) so JSON encoders emit `[]` rather than `null` on the wire.

type SmartBeaconConfig

type SmartBeaconConfig struct {
	ID          uint32    `gorm:"primaryKey;autoIncrement" json:"-"`
	Enabled     bool      `gorm:"not null" json:"enabled"`
	FastSpeedKt uint32    `gorm:"not null" json:"fast_speed"`
	FastRateSec uint32    `gorm:"not null" json:"fast_rate"`
	SlowSpeedKt uint32    `gorm:"not null" json:"slow_speed"`
	SlowRateSec uint32    `gorm:"not null" json:"slow_rate"`
	MinTurnDeg  uint32    `gorm:"not null" json:"min_turn_angle"`
	TurnSlope   uint32    `gorm:"not null" json:"turn_slope"`
	MinTurnSec  uint32    `gorm:"not null" json:"min_turn_time"`
	CreatedAt   time.Time `json:"-"`
	UpdatedAt   time.Time `json:"-"`
}

SmartBeaconConfig is a singleton (id=1) row holding the global SmartBeacon curve parameters applied to every beacon with SmartBeacon=true. Mirrors direwolf's single SMARTBEACON directive: the curve is global, not per-beacon. No integer defaults are declared in gorm tags — defaults live in pkg/beacon.DefaultSmartBeacon() (the single source of truth) and are surfaced to callers via the DTO layer when no row exists. GetSmartBeaconConfig returning (nil, nil) signals "no row yet — apply defaults."

type StationConfig

type StationConfig struct {
	ID        uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	Callsign  string    `gorm:"not null;default:''" json:"callsign"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

StationConfig is a singleton (id=1) row holding the station-wide APRS callsign. This is the single source of truth for the callsign used by the iGate (APRS-IS login + passcode), the digipeater (unless overridden), beacons (unless overridden), and APRS messaging. See .context/2026-04-21-centralized-station-callsign.md.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store wraps a *gorm.DB with typed helpers for graywolf's tables.

func Open

func Open(path string) (*Store, error)

Open opens (or creates) the SQLite database at path. Use OpenMemory for tests.

func OpenMemory

func OpenMemory() (*Store, error)

OpenMemory opens an isolated in-memory database (one per call).

func (*Store) AppendAX25TranscriptEntry added in v0.12.4

func (s *Store) AppendAX25TranscriptEntry(ctx context.Context, e *AX25TranscriptEntry) error

AppendAX25TranscriptEntry inserts a single transcript entry. The caller stamps the timestamp so a transcript-on toggle that lands a burst of buffered events keeps their original wall-clock order.

func (*Store) ChannelExists

func (s *Store) ChannelExists(ctx context.Context, id uint32) (bool, error)

ChannelExists reports whether a channel row with the given ID exists. Returns (false, nil) when the row is absent; (false, err) on any driver / context error. Callers that need the row itself should use GetChannel; ChannelExists is the cheaper probe for write-time reference validation (see dto.ValidateChannelRef).

func (*Store) ChannelReferrers

func (s *Store) ChannelReferrers(ctx context.Context, channelID uint32) (Referrers, error)

ChannelReferrers queries every table that references channels.id via a soft integer foreign key and returns a structured list of dependent rows. Used by DELETE /api/channels/{id} to return 409 with the impact list, and by GET /api/channels/{id}/referrers to power the first confirmation dialog in the UI.

Covered tables (see design decision D12 in .context/2026-04-20-kiss-tcp-client-and-channel-backing.md):

  • Beacon.Channel
  • DigipeaterRule.FromChannel (emitted as "digipeater_rule_from")
  • DigipeaterRule.ToChannel (emitted as "digipeater_rule_to" only when ToChannel matches AND FromChannel does NOT — cross-channel rules where only the destination matched; same-channel rules are already covered by the FromChannel branch).
  • KissInterface.Channel
  • IGateConfig.RfChannel (as "igate_config_rf")
  • IGateConfig.TxChannel (as "igate_config_tx")
  • IGateRfFilter.Channel
  • TxTiming.Channel

PttConfig.ChannelID has a hard FK with OnDelete:CASCADE, so SQLite removes those rows automatically and this scan deliberately omits them.

A channelID of 0 returns an empty list — 0 is reserved for "none" in singletons like IGateConfig.TxChannel and never a real channel row.

func (*Store) Close

func (s *Store) Close() error

Close releases the database handle.

func (*Store) CountOrphanChannelRefs

func (s *Store) CountOrphanChannelRefs(ctx context.Context) (map[string]int, error)

CountOrphanChannelRefs runs a one-shot scan at bootstrap for rows whose channel references don't resolve. Returns a map keyed by a stable table-role token (mirroring ReferrerType*) to the number of orphan rows in that role. Never returns nil — an empty map means "all refs resolve". Tables are scanned with a LEFT JOIN + NULL predicate so the query is O(n) per table rather than O(n*m).

The caller is expected to log a warn line per non-zero entry. No deletion or cleanup happens here; operators decide whether to remediate via the cascade-delete UI.

func (*Store) CreateAX25SessionProfile added in v0.12.4

func (s *Store) CreateAX25SessionProfile(ctx context.Context, p *AX25SessionProfile) error

CreateAX25SessionProfile inserts a new profile row. ID is set on return.

func (*Store) CreateAX25TranscriptSession added in v0.12.4

func (s *Store) CreateAX25TranscriptSession(ctx context.Context, sess *AX25TranscriptSession) error

CreateAX25TranscriptSession inserts a new session row. Stamps StartedAt = now if the caller left it zero.

func (*Store) CreateAction added in v0.13.0

func (s *Store) CreateAction(ctx context.Context, a *Action) error

func (*Store) CreateActionListenerAddressee added in v0.13.0

func (s *Store) CreateActionListenerAddressee(ctx context.Context, name string) error

func (*Store) CreateAudioDevice

func (s *Store) CreateAudioDevice(ctx context.Context, d *AudioDevice) error

func (*Store) CreateBeacon

func (s *Store) CreateBeacon(ctx context.Context, b *Beacon) error

func (*Store) CreateChannel

func (s *Store) CreateChannel(ctx context.Context, c *Channel) error

func (*Store) CreateDigipeaterRule

func (s *Store) CreateDigipeaterRule(ctx context.Context, r *DigipeaterRule) error

func (*Store) CreateIGateRfFilter

func (s *Store) CreateIGateRfFilter(ctx context.Context, f *IGateRfFilter) error

func (*Store) CreateKissInterface

func (s *Store) CreateKissInterface(ctx context.Context, k *KissInterface) error

func (*Store) CreateOTPCredential added in v0.13.0

func (s *Store) CreateOTPCredential(ctx context.Context, c *OTPCredential) error

func (*Store) CreateTacticalCallsign

func (s *Store) CreateTacticalCallsign(ctx context.Context, t *TacticalCallsign) error

CreateTacticalCallsign inserts a new tactical entry. Callsign is normalized to uppercase by the TacticalCallsign.BeforeSave hook.

func (*Store) DB

func (s *Store) DB() *gorm.DB

DB exposes the underlying gorm DB for callers that need ad-hoc queries.

func (*Store) DeleteAX25SessionProfile added in v0.12.4

func (s *Store) DeleteAX25SessionProfile(ctx context.Context, id uint32) error

DeleteAX25SessionProfile removes the row by id. Idempotent: deleting a missing row returns nil so callers can wire it directly to a DELETE handler without a 404 race.

func (*Store) DeleteAX25TranscriptSession added in v0.12.4

func (s *Store) DeleteAX25TranscriptSession(ctx context.Context, id uint32) error

DeleteAX25TranscriptSession removes a session row plus every entry that references it. Idempotent.

func (*Store) DeleteAction added in v0.13.0

func (s *Store) DeleteAction(ctx context.Context, id uint) error

func (*Store) DeleteActionListenerAddresseeByName added in v0.13.0

func (s *Store) DeleteActionListenerAddresseeByName(ctx context.Context, name string) error

func (*Store) DeleteAllAX25Transcripts added in v0.12.4

func (s *Store) DeleteAllAX25Transcripts(ctx context.Context) error

DeleteAllAX25Transcripts wipes every transcript session + entry. Used by the "delete all" button on the transcripts subroute.

func (*Store) DeleteAllActionInvocations added in v0.13.0

func (s *Store) DeleteAllActionInvocations(ctx context.Context) (int, error)

DeleteAllActionInvocations truncates the audit log. Operator-driven only — surfaced via DELETE /api/actions/invocations and the UI's "clear log" button. The retention pruner uses PruneActionInvocations instead so the routine path stays bounded without dropping recent rows.

func (*Store) DeleteAudioDevice

func (s *Store) DeleteAudioDevice(ctx context.Context, id uint32) error

func (*Store) DeleteAudioDeviceChecked

func (s *Store) DeleteAudioDeviceChecked(ctx context.Context, id uint32, cascade bool) (deleted []Channel, refs []Channel, err error)

DeleteAudioDeviceChecked atomically checks for channels referencing the device and either refuses the delete (cascade=false with refs) or cascades through them (cascade=true, or no refs) within a single transaction. There is no window for a concurrent writer to slip in a new referencing channel between the check and the delete, so an operator who declined to cascade can never have a channel silently swept away.

Return shapes:

  • refs non-empty, deleted nil: operator refused to cascade; nothing was modified. Caller should surface refs to the user and ask.
  • refs nil, deleted: the device is gone; deleted lists the channels that went with it (possibly empty if nothing referenced the device).

func (*Store) DeleteBeacon

func (s *Store) DeleteBeacon(ctx context.Context, id uint32) error

func (*Store) DeleteChannel

func (s *Store) DeleteChannel(ctx context.Context, id uint32) error

func (*Store) DeleteChannelCascade

func (s *Store) DeleteChannelCascade(ctx context.Context, channelID uint32) (int, error)

DeleteChannelCascade atomically removes a channel plus every soft-FK reference per the D12 per-table policy:

  • Beacon.Channel == id → delete row
  • DigipeaterRule.FromChannel == id → delete row
  • DigipeaterRule.ToChannel == id AND FromChannel != id → delete row (cross-channel rules where only the destination matched)
  • KissInterface.Channel == id → set Channel=0 AND NeedsReconfig=true (the interface may still be useful on another channel after reconfig; don't delete it)
  • IGateConfig.RfChannel == id → set RfChannel=0
  • IGateConfig.TxChannel == id → set TxChannel=0
  • IGateRfFilter.Channel == id → delete row
  • TxTiming.Channel == id → delete row
  • Channel itself → delete (fires ON DELETE CASCADE for PttConfig)

All operations run in a single SQLite transaction; either every change lands or none do. Callers are expected to fire a single post-commit bridge / kiss-manager reload so in-memory state reconverges exactly once (not N times, one per affected row).

Returns the count of rows that were touched (for observability in the caller's reload log line) plus any error. A nonexistent channelID returns (0, gorm.ErrRecordNotFound) without changing anything.

func (*Store) DeleteDigipeaterRule

func (s *Store) DeleteDigipeaterRule(ctx context.Context, id uint32) error

func (*Store) DeleteIGateRfFilter

func (s *Store) DeleteIGateRfFilter(ctx context.Context, id uint32) error

func (*Store) DeleteKissInterface

func (s *Store) DeleteKissInterface(ctx context.Context, id uint32) error

func (*Store) DeleteMapsDownload

func (s *Store) DeleteMapsDownload(ctx context.Context, slug string) error

DeleteMapsDownload removes the row for slug. Idempotent — deleting an absent row is not an error.

func (*Store) DeleteOTPCredential added in v0.13.0

func (s *Store) DeleteOTPCredential(ctx context.Context, id uint) error

func (*Store) DeletePttConfig

func (s *Store) DeletePttConfig(ctx context.Context, channelID uint32) error

func (*Store) DeleteTacticalCallsign

func (s *Store) DeleteTacticalCallsign(ctx context.Context, id uint32) error

DeleteTacticalCallsign removes a tactical entry by id. Historical message rows keyed by the tactical label persist so the thread stays a read-only archive — only the monitor entry is deleted.

func (*Store) EndAX25TranscriptSession added in v0.12.4

func (s *Store) EndAX25TranscriptSession(ctx context.Context, id uint32, reason string, bytes, frames uint64) error

EndAX25TranscriptSession stamps EndedAt + EndReason and rolls up the final byte/frame counters. Idempotent: running twice is harmless, the latest call wins.

func (*Store) GetAX25SessionProfile added in v0.12.4

func (s *Store) GetAX25SessionProfile(ctx context.Context, id uint32) (*AX25SessionProfile, error)

GetAX25SessionProfile loads a profile by id; ErrRecordNotFound when missing so callers can map to 404.

func (*Store) GetAX25TerminalConfig added in v0.12.4

func (s *Store) GetAX25TerminalConfig(ctx context.Context) (*AX25TerminalConfig, error)

GetAX25TerminalConfig returns the singleton AX25TerminalConfig row, creating one with sane defaults on first read. Migration v14 seeds the row at startup; this FirstOrCreate is the belt-and-braces guard for any code path that opens a fresh database without going through Migrate() (e.g. in-process integration tests).

func (*Store) GetAX25TranscriptSession added in v0.12.4

func (s *Store) GetAX25TranscriptSession(ctx context.Context, id uint32) (*AX25TranscriptSession, error)

GetAX25TranscriptSession fetches one session by id.

func (*Store) GetAction added in v0.13.0

func (s *Store) GetAction(ctx context.Context, id uint) (*Action, error)

func (*Store) GetActionByName added in v0.13.0

func (s *Store) GetActionByName(ctx context.Context, name string) (*Action, error)

GetActionByName returns the Action with the given name. Matching is case-insensitive — Action.Name is stored uppercase (see BeforeSave), so an inbound `@@otp#unlock` resolves to the row created as `Unlock`.

func (*Store) GetAgwConfig

func (s *Store) GetAgwConfig(ctx context.Context) (*AgwConfig, error)

func (*Store) GetAudioDevice

func (s *Store) GetAudioDevice(ctx context.Context, id uint32) (*AudioDevice, error)

func (*Store) GetBeacon

func (s *Store) GetBeacon(ctx context.Context, id uint32) (*Beacon, error)

func (*Store) GetChannel

func (s *Store) GetChannel(ctx context.Context, id uint32) (*Channel, error)

func (*Store) GetDigipeaterConfig

func (s *Store) GetDigipeaterConfig(ctx context.Context) (*DigipeaterConfig, error)

func (*Store) GetGPSConfig

func (s *Store) GetGPSConfig(ctx context.Context) (*GPSConfig, error)

func (*Store) GetIGateConfig

func (s *Store) GetIGateConfig(ctx context.Context) (*IGateConfig, error)

func (*Store) GetKissInterface

func (s *Store) GetKissInterface(ctx context.Context, id uint32) (*KissInterface, error)

func (*Store) GetLogBufferConfig

func (s *Store) GetLogBufferConfig(ctx context.Context) (LogBufferConfig, bool, error)

GetLogBufferConfig returns the singleton log-buffer configuration row plus an exists flag. The flag is required because MaxRows == 0 is a valid override meaning "operator disabled persistence" — the caller can't distinguish that from "no row stored, use environment default" by inspecting MaxRows alone. DB errors other than not-found are returned verbatim.

func (*Store) GetMapsConfig

func (s *Store) GetMapsConfig(ctx context.Context) (MapsConfig, error)

GetMapsConfig returns the singleton maps preference. When no row exists (fresh install), returns MapsConfig{Source: "graywolf"} with no error so the UI has a deterministic default without a seed step. Graywolf is the default basemap; the maplibre frontend falls back to OSM rendering automatically when the device hasn't registered yet, so this is safe even before the operator obtains a token. An unknown Source value in the stored row is normalized to graywolf.

func (*Store) GetMapsDownload

func (s *Store) GetMapsDownload(ctx context.Context, slug string) (MapsDownload, error)

GetMapsDownload returns the row for slug, or a zero-value struct (ID==0) if none exists. Callers check ID==0 to detect absence.

func (*Store) GetMessagePreferences

func (s *Store) GetMessagePreferences(ctx context.Context) (*MessagePreferences, error)

GetMessagePreferences returns the singleton preferences row. The row is seeded with defaults by seedMessagePreferences on first migrate, so a nil return indicates a DB error path only (preserved for consistency with the other singleton getters).

func (*Store) GetMessagesConfig added in v0.12.4

func (s *Store) GetMessagesConfig(ctx context.Context) (*MessagesConfig, error)

GetMessagesConfig returns the singleton row, creating an empty row (TxChannel=0, "auto") on first read. Callers handle TxChannel==0 by resolving against the live channel inventory in pkg/app.

Uses FirstOrCreate so two concurrent callers on a freshly-opened database (e.g. a partial migration) cannot both win the race-to-Create and surface a UNIQUE-constraint error. Migration v13 pre-populates the row on real systems; this is the belt-and-braces guard.

func (*Store) GetOTPCredential added in v0.13.0

func (s *Store) GetOTPCredential(ctx context.Context, id uint) (*OTPCredential, error)

func (*Store) GetOTPCredentialByName added in v0.13.0

func (s *Store) GetOTPCredentialByName(ctx context.Context, name string) (*OTPCredential, error)

func (*Store) GetPositionLogConfig

func (s *Store) GetPositionLogConfig(ctx context.Context) (*PositionLogConfig, error)

func (*Store) GetPttConfigForChannel

func (s *Store) GetPttConfigForChannel(ctx context.Context, channelID uint32) (*PttConfig, error)

func (*Store) GetSmartBeaconConfig

func (s *Store) GetSmartBeaconConfig(ctx context.Context) (*SmartBeaconConfig, error)

GetSmartBeaconConfig returns the singleton SmartBeacon configuration row. When no row exists, returns (nil, nil) — the caller interprets that as "apply defaults from beacon.DefaultSmartBeacon()". DB errors are returned as non-nil errors. Matches the established singleton contract used by GetDigipeaterConfig, GetIGateConfig, GetGPSConfig.

func (*Store) GetStationConfig

func (s *Store) GetStationConfig(ctx context.Context) (StationConfig, error)

GetStationConfig returns the singleton station configuration row. Returns a zero-value StationConfig (no error) when no row exists — callers can treat an empty Callsign as "unconfigured" without a separate nil-check. DB errors other than not-found are returned verbatim.

func (*Store) GetTacticalCallsign

func (s *Store) GetTacticalCallsign(ctx context.Context, id uint32) (*TacticalCallsign, error)

GetTacticalCallsign returns a single tactical entry by id. Returns (nil, nil) on not-found to match the other singleton helpers.

func (*Store) GetTacticalCallsignByCallsign

func (s *Store) GetTacticalCallsignByCallsign(ctx context.Context, callsign string) (*TacticalCallsign, error)

GetTacticalCallsignByCallsign returns the entry whose Callsign equals the uppercase-normalized argument. Returns (nil, nil) on not-found to match the other singleton getters. Used by the invite accept handler so it can upsert without racing the autoincrement ID.

func (*Store) GetThemeConfig

func (s *Store) GetThemeConfig(ctx context.Context) (ThemeConfig, error)

GetThemeConfig returns the singleton theme preference. Fresh install returns ThemeConfig{ThemeID: "graywolf"} with no error. A row with a malformed id (e.g. hand-edited DB) is normalized to the default on read so the frontend never sees garbage.

func (*Store) GetTxTiming

func (s *Store) GetTxTiming(ctx context.Context, channel uint32) (*TxTiming, error)

func (*Store) GetUnitsConfig

func (s *Store) GetUnitsConfig(ctx context.Context) (UnitsConfig, error)

GetUnitsConfig returns the singleton measurement-system preference. When no row exists (fresh install), returns UnitsConfig{System: "imperial"} with no error so the UI has a deterministic default without a seed step. An unknown System value in the stored row is normalized to imperial so the frontend always sees one of the two valid values.

func (*Store) GetUpdatesConfig

func (s *Store) GetUpdatesConfig(ctx context.Context) (UpdatesConfig, error)

GetUpdatesConfig returns the singleton updates-check configuration row. When no row exists (fresh install), returns UpdatesConfig{Enabled: true} with no error — the feature is on by default and callers don't need a separate seed step. DB errors other than not-found are returned verbatim. Mirrors the shape of GetStationConfig but with a different zero-value-on-missing contract: StationConfig's zero value ("unconfigured") is also the safe default, whereas UpdatesConfig's safe default is Enabled=true, which differs from the Go zero value.

func (*Store) InsertActionInvocation added in v0.13.0

func (s *Store) InsertActionInvocation(ctx context.Context, row *ActionInvocation) error

func (*Store) ListAX25SessionProfiles added in v0.12.4

func (s *Store) ListAX25SessionProfiles(ctx context.Context) ([]AX25SessionProfile, error)

ListAX25SessionProfiles returns every saved profile ordered with pinned rows first, then recents by LastUsed desc, then by Name. The pre-connect form renders both groups in this order.

func (*Store) ListAX25TranscriptEntries added in v0.12.4

func (s *Store) ListAX25TranscriptEntries(ctx context.Context, sessionID uint32) ([]AX25TranscriptEntry, error)

ListAX25TranscriptEntries returns every entry for a session, ordered by TS asc (chronological).

func (*Store) ListAX25TranscriptSessions added in v0.12.4

func (s *Store) ListAX25TranscriptSessions(ctx context.Context, limit int) ([]AX25TranscriptSession, error)

ListAX25TranscriptSessions returns transcript-session rows ordered by StartedAt desc (most recent first). Cap clamps the result; pass 0 for "no cap" but expect callers to set a sane upper bound.

func (*Store) ListActionInvocations added in v0.13.0

func (s *Store) ListActionInvocations(ctx context.Context, f ActionInvocationFilter) ([]ActionInvocation, error)

func (*Store) ListActionListenerAddressees added in v0.13.0

func (s *Store) ListActionListenerAddressees(ctx context.Context) ([]ActionListenerAddressee, error)

func (*Store) ListActions added in v0.13.0

func (s *Store) ListActions(ctx context.Context) ([]Action, error)

func (*Store) ListAudioDevices

func (s *Store) ListAudioDevices(ctx context.Context) ([]AudioDevice, error)

func (*Store) ListBeacons

func (s *Store) ListBeacons(ctx context.Context) ([]Beacon, error)

func (*Store) ListChannels

func (s *Store) ListChannels(ctx context.Context) ([]Channel, error)

func (*Store) ListDigipeaterRules

func (s *Store) ListDigipeaterRules(ctx context.Context) ([]DigipeaterRule, error)

func (*Store) ListDigipeaterRulesForChannel

func (s *Store) ListDigipeaterRulesForChannel(ctx context.Context, channel uint32) ([]DigipeaterRule, error)

func (*Store) ListEnabledTacticalCallsigns

func (s *Store) ListEnabledTacticalCallsigns(ctx context.Context) ([]TacticalCallsign, error)

ListEnabledTacticalCallsigns returns only the entries with Enabled=true. The router uses this at startup and on preferences reload to rebuild its in-memory matching set.

func (*Store) ListIGateRfFilters

func (s *Store) ListIGateRfFilters(ctx context.Context) ([]IGateRfFilter, error)

func (*Store) ListIGateRfFiltersForChannel

func (s *Store) ListIGateRfFiltersForChannel(ctx context.Context, channel uint32) ([]IGateRfFilter, error)

func (*Store) ListKissInterfaces

func (s *Store) ListKissInterfaces(ctx context.Context) ([]KissInterface, error)

func (*Store) ListMapsDownloads

func (s *Store) ListMapsDownloads(ctx context.Context) ([]MapsDownload, error)

ListMapsDownloads returns every download row, ordered by slug for deterministic UI display. Returns an empty slice (not nil) on a fresh install.

func (*Store) ListOTPCredentials added in v0.13.0

func (s *Store) ListOTPCredentials(ctx context.Context) ([]OTPCredential, error)

func (*Store) ListOrphanChannelRefs

func (s *Store) ListOrphanChannelRefs(ctx context.Context) ([]OrphanChannelRefRows, error)

ListOrphanChannelRefs returns, per referrer table, the set of row ids whose channel soft-FK does not resolve, plus the distinct set of missing channel ids referenced. One query per table, same LEFT-JOIN / NOT-IN pattern as CountOrphanChannelRefs but returning the ids instead of just the count.

An empty slice return means "no orphans anywhere". Per-table probe errors are swallowed (e.g. table missing on a fresh DB before AutoMigrate) so the overall scan never fails startup.

func (*Store) ListPacketFilters

func (s *Store) ListPacketFilters(ctx context.Context) ([]PacketFilter, error)

func (*Store) ListPttConfigs

func (s *Store) ListPttConfigs(ctx context.Context) ([]PttConfig, error)

func (*Store) ListTacticalCallsigns

func (s *Store) ListTacticalCallsigns(ctx context.Context) ([]TacticalCallsign, error)

ListTacticalCallsigns returns every tactical entry (enabled or not), ordered by callsign for stable UI display.

func (*Store) ListTxTimings

func (s *Store) ListTxTimings(ctx context.Context) ([]TxTiming, error)

func (*Store) Migrate

func (s *Store) Migrate() error

Migrate brings the schema up to date. Safe to call repeatedly.

Ordering matters: the pre-AutoMigrate pass runs first to fix up legacy columns that AutoMigrate would otherwise stumble over (a column rename, for example, looks like an add+drop to the migrator), then AutoMigrate reconciles the Go model shape with SQLite, then the post-AutoMigrate pass runs data migrations that need the new schema in place. See migrate.go for the migration list and the user_version contract.

func (*Store) MigrateMapsDownloadSlugs added in v0.12.1

func (s *Store) MigrateMapsDownloadSlugs(ctx context.Context) error

MigrateMapsDownloadSlugs prepends "state/" to any legacy bare-slug row in maps_downloads. Idempotent: rows already containing "/" are left alone. Run once at startup after AutoMigrate.

Collision policy: if a row already exists at the namespaced target (e.g. both "colorado" and "state/colorado" coexist after some prior partial migration or hand edit), the legacy bare row is DELETED and the namespaced row is kept. The unique-index on slug means a naive UPDATE would error and abort startup, so this collision case is handled explicitly. The whole pass runs in a single transaction so a crash mid-migration leaves the table either fully migrated or fully untouched.

func (*Store) ModeForChannel added in v0.12.4

func (s *Store) ModeForChannel(ctx context.Context, channelID uint32) (string, error)

ModeForChannel returns the Mode column for the given channel id. Returns ChannelModeAPRS and a nil error when the channelID is 0 (no channel selected) or when the row does not exist -- TX subsystems treat both cases as the conservative APRS-only choice. Existing rows always carry a non-empty Mode (validateChannel normalizes empty to ChannelModeAPRS), so the empty-string branch is solely a missing-row guard.

Missing-row hits emit a debug log so operators investigating why a downstream subsystem (e.g. ax25conn refusing to bind) believes a channel is APRS-only can correlate against an actually-deleted channel ID without re-deriving the lookup path.

func (*Store) OTPCredentialUsedBy added in v0.13.0

func (s *Store) OTPCredentialUsedBy(ctx context.Context) (map[uint][]string, error)

OTPCredentialUsedBy returns a map cred-id -> action names that reference it. One scan over the actions table; callers iterate the returned map per credential rather than issuing N queries.

func (*Store) PinAX25SessionProfile added in v0.12.4

func (s *Store) PinAX25SessionProfile(ctx context.Context, id uint32, pinned bool) error

PinAX25SessionProfile flips Pinned to true on the row, promoting it from recents into the permanent list.

func (*Store) PruneActionInvocations added in v0.13.0

func (s *Store) PruneActionInvocations(ctx context.Context, maxRows int, maxAge time.Duration) (int, error)

PruneActionInvocations enforces the audit-log retention contract: rows older than maxAge are deleted unconditionally; if the post-age row count still exceeds maxRows, the oldest excess rows are deleted too. Either bound on its own keeps the table bounded; running both captures the more aggressive of the two so a quiet operator who hasn't crossed the time bound but somehow accumulated a million rows (e.g. a runaway test fixture) still stays under the count cap. Returns the total number of rows deleted across both passes.

func (*Store) ResolveStationCallsign

func (s *Store) ResolveStationCallsign(ctx context.Context) (string, error)

ResolveStationCallsign returns the normalized station callsign or a sentinel error. The callsign is read from StationConfig; empty (or whitespace-only) returns callsign.ErrCallsignEmpty, N0CALL (case-insensitive, SSID-agnostic) returns callsign.ErrCallsignN0Call. DB errors are returned as-is. Callers can branch on the sentinel errors via errors.Is.

func (*Store) SQLiteVersion

func (s *Store) SQLiteVersion() string

SQLiteVersion returns the runtime SQLite library version string (e.g. "3.42.0") via `SELECT sqlite_version()`. Called from app startup so ops can see the version in the logs — important for migrations that depend on a minimum SQLite version (the 12-step table rebuild added in migration 8 needs ≥ 3.25 for ALTER TABLE RENAME, which this driver satisfies). Returns the empty string on error; callers log the returned value verbatim.

func (*Store) SetChannelFX25

func (s *Store) SetChannelFX25(ctx context.Context, id uint32, enable bool) error

SetChannelFX25 sets FX.25 encoding for a channel.

func (*Store) SetChannelIL2P

func (s *Store) SetChannelIL2P(ctx context.Context, id uint32, enable bool) error

SetChannelIL2P sets IL2P encoding for a channel.

func (*Store) TouchAX25SessionProfileLastUsed added in v0.12.4

func (s *Store) TouchAX25SessionProfileLastUsed(ctx context.Context, id uint32, when time.Time) error

TouchAX25SessionProfileLastUsed updates LastUsed on a recent. Used by the OnStateChange(CONNECTED) hook in the WebSocket bridge so the recents list reflects the most recent successful connection.

func (*Store) TouchOTPCredentialUsed added in v0.13.0

func (s *Store) TouchOTPCredentialUsed(ctx context.Context, id uint, when time.Time) error

TouchOTPCredentialUsed records the most recent moment a credential successfully verified a TOTP code. Stored UTC; the UI surfaces this so operators can spot dormant credentials.

func (*Store) UpdateAX25SessionProfile added in v0.12.4

func (s *Store) UpdateAX25SessionProfile(ctx context.Context, p *AX25SessionProfile) error

UpdateAX25SessionProfile replaces all editable columns for the row identified by p.ID. Pinned + LastUsed are managed by their own helpers (PinAX25SessionProfile, TouchAX25SessionProfileLastUsed).

func (*Store) UpdateAction added in v0.13.0

func (s *Store) UpdateAction(ctx context.Context, a *Action) error

func (*Store) UpdateAudioDevice

func (s *Store) UpdateAudioDevice(ctx context.Context, d *AudioDevice) error

func (*Store) UpdateBeacon

func (s *Store) UpdateBeacon(ctx context.Context, b *Beacon) error

func (*Store) UpdateChannel

func (s *Store) UpdateChannel(ctx context.Context, c *Channel) error

func (*Store) UpdateDigipeaterRule

func (s *Store) UpdateDigipeaterRule(ctx context.Context, r *DigipeaterRule) error

func (*Store) UpdateIGateRfFilter

func (s *Store) UpdateIGateRfFilter(ctx context.Context, f *IGateRfFilter) error

func (*Store) UpdateKissInterface

func (s *Store) UpdateKissInterface(ctx context.Context, k *KissInterface) error

func (*Store) UpdateTacticalCallsign

func (s *Store) UpdateTacticalCallsign(ctx context.Context, t *TacticalCallsign) error

UpdateTacticalCallsign saves changes to an existing row. Callsign re-normalization happens via BeforeSave.

func (*Store) UpsertAX25TerminalConfig added in v0.12.4

func (s *Store) UpsertAX25TerminalConfig(ctx context.Context, cfg *AX25TerminalConfig) error

UpsertAX25TerminalConfig writes the singleton (id forced to 1). The REST handler converts the macros DTO array into MacrosJSON before calling.

func (*Store) UpsertAgwConfig

func (s *Store) UpsertAgwConfig(ctx context.Context, c *AgwConfig) error

func (*Store) UpsertDigipeaterConfig

func (s *Store) UpsertDigipeaterConfig(ctx context.Context, c *DigipeaterConfig) error

func (*Store) UpsertGPSConfig

func (s *Store) UpsertGPSConfig(ctx context.Context, c *GPSConfig) error

func (*Store) UpsertIGateConfig

func (s *Store) UpsertIGateConfig(ctx context.Context, c *IGateConfig) error

func (*Store) UpsertLogBufferConfig

func (s *Store) UpsertLogBufferConfig(ctx context.Context, c LogBufferConfig) error

UpsertLogBufferConfig stores the singleton log-buffer config row. When c.ID == 0 and a row already exists, the existing ID is adopted so Save updates in place. MaxRows is written verbatim — including 0, which the consumer treats as "disable persistence".

We use a map-based UpdateColumns path so MaxRows == 0 is never silently rewritten by GORM's "default" tag handling (same footgun the UpdatesConfig CRUD documents at seed_updates.go:38-45).

Side effect: UpdateColumns suppresses auto-timestamps, so UpdatedAt stays stale. No consumer reads it today; same behavior as seed_updates.go's UpsertUpdatesConfig.

Defensive: when c.ID != 0 but the row does not exist, the UpdateColumns call would silently no-op (RowsAffected=0). We require RowsAffected >= 1 on the update path so that footgun surfaces as an error rather than vanishing.

func (*Store) UpsertMapsConfig

func (s *Store) UpsertMapsConfig(ctx context.Context, c MapsConfig) error

UpsertMapsConfig persists the singleton maps preference. Source must be one of the two recognized values; anything else is rejected so a bad PUT can't corrupt the row. ID is adopted from any existing row to preserve the singleton invariant.

This is a full-replace operation: every mutable column (source, callsign, token, registered_at) is overwritten with the value on c. Callers that intend to update only one field (e.g. just Source) MUST GetMapsConfig first, mutate the returned struct, then pass it here — otherwise empty fields silently un-register the device.

func (*Store) UpsertMapsDownload

func (s *Store) UpsertMapsDownload(ctx context.Context, d MapsDownload) error

UpsertMapsDownload writes the row. Rows are keyed by slug (uniqueIndex on the model); a second call with the same slug updates in place rather than inserting a duplicate. Status must be one of the four documented values; the slug must be non-empty.

Slug format is namespaced: state/<slug>, country/<iso2>, or province/<iso2>/<slug>. Legacy bare-slug rows (e.g. "colorado") from pre-namespaced installs are migrated in place at startup by MigrateMapsDownloadSlugs. The store layer does not enforce the grammar -- the webapi layer validates against the live catalog before any write reaches here.

func (*Store) UpsertMessagePreferences

func (s *Store) UpsertMessagePreferences(ctx context.Context, cfg *MessagePreferences) error

UpsertMessagePreferences stores the singleton row. When cfg.ID == 0 and a row already exists, the existing ID is adopted so Save updates in place. Matches UpsertDigipeaterConfig et al.

func (*Store) UpsertMessagesConfig added in v0.12.4

func (s *Store) UpsertMessagesConfig(ctx context.Context, mc *MessagesConfig) error

UpsertMessagesConfig writes the singleton row (id forced to 1). TxChannel is validated against ChannelModeLookup at the handler layer; the store accepts any uint32 here.

Uses an INSERT ... ON CONFLICT DO UPDATE clause that touches only tx_channel + updated_at, so a stale CreatedAt on the caller's struct cannot clobber the original row's creation timestamp.

func (*Store) UpsertPositionLogConfig

func (s *Store) UpsertPositionLogConfig(ctx context.Context, c *PositionLogConfig) error

func (*Store) UpsertPttConfig

func (s *Store) UpsertPttConfig(ctx context.Context, p *PttConfig) error

func (*Store) UpsertRecentAX25SessionProfile added in v0.12.4

func (s *Store) UpsertRecentAX25SessionProfile(ctx context.Context, p *AX25SessionProfile, capRecents int) error

UpsertRecentAX25SessionProfile creates or updates a recent profile entry keyed by (LocalCall, LocalSSID, DestCall, DestSSID, ViaPath, ChannelID). Used by the bridge so successive connects to the same peer/path don't fan out the recents list.

On insert: stamps LastUsed = now, Pinned = false. On match: only updates LastUsed (the operator's prior settings stay).

After the upsert, trims unpinned recents back down to the cap (20) by deleting the oldest LastUsed rows.

func (*Store) UpsertSmartBeaconConfig

func (s *Store) UpsertSmartBeaconConfig(ctx context.Context, cfg *SmartBeaconConfig) error

UpsertSmartBeaconConfig stores the singleton row. Either inserts or updates: if the caller passes cfg.ID == 0 and a row already exists, the existing ID is adopted so Save updates in place rather than creating a second row. Matches UpsertDigipeaterConfig et al.

func (*Store) UpsertStationConfig

func (s *Store) UpsertStationConfig(ctx context.Context, c StationConfig) error

UpsertStationConfig stores the singleton station config row, normalizing the Callsign (TrimSpace + ToUpper) before persist. When c.ID == 0 and a row already exists, the existing ID is adopted so Save updates in place. Normalization at the store boundary means every caller — including future ones — sees a canonical value without having to remember to uppercase on write.

func (*Store) UpsertThemeConfig

func (s *Store) UpsertThemeConfig(ctx context.Context, c ThemeConfig) error

UpsertThemeConfig stores the singleton theme preference. Rejects malformed ids so a bad PUT can't corrupt the row. Preserves the singleton ID across upserts.

func (*Store) UpsertTxTiming

func (s *Store) UpsertTxTiming(ctx context.Context, t *TxTiming) error

func (*Store) UpsertUnitsConfig

func (s *Store) UpsertUnitsConfig(ctx context.Context, c UnitsConfig) error

UpsertUnitsConfig stores the singleton measurement-system preference. Values other than "imperial" or "metric" are rejected so a bad PUT can't corrupt the row. When c.ID == 0 and a row already exists, the existing ID is adopted so the singleton invariant is preserved.

func (*Store) UpsertUpdatesConfig

func (s *Store) UpsertUpdatesConfig(ctx context.Context, c UpdatesConfig) error

UpsertUpdatesConfig stores the singleton updates-check config row. When c.ID == 0 and a row already exists, the existing ID is adopted so Save updates in place. Unlike StationConfig there is no value to normalize (Enabled is a bool).

GORM footgun: the column carries `default:true`, so a plain Create with Enabled=false would be silently rewritten to true on insert (GORM treats bool zero-values with a default tag as "unset, use default"). To defeat that we build the insert via a map, which sends every column value verbatim. For updates we do the same with UpdateColumns so Enabled=false is always honored.

type TacticalCallsign

type TacticalCallsign struct {
	ID       uint32 `gorm:"primaryKey;autoIncrement" json:"id"`
	Callsign string `gorm:"size:9;not null;uniqueIndex" json:"callsign"` // 1-9 [A-Z0-9-], uppercase
	Alias    string `gorm:"size:64" json:"alias"`                        // optional free-text
	// Enabled: column does not declare default:true on purpose. The
	// handler-level default ("Monitor now" toggle) runs before the
	// insert, and a GORM default:true would silently override a caller
	// passing false (GORM treats Go-zero values as "use the DB
	// default"), which is hostile to the common "create disabled" path.
	Enabled   bool      `gorm:"not null" json:"enabled"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

TacticalCallsign is one monitored tactical addressee label. Operators register these to participate in group threads keyed by the label. Callsign is normalized to uppercase via BeforeSave so any path in/out is safe. See plan "Group chat via tactical callsigns" section.

func (*TacticalCallsign) BeforeSave

func (t *TacticalCallsign) BeforeSave(_ *gorm.DB) error

BeforeSave normalizes Callsign to uppercase and trims whitespace before insert or update. Ensures the router's case-sensitive exact match against the cached set always sees a canonical value regardless of how a handler constructed the row.

type ThemeConfig

type ThemeConfig struct {
	ID        uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	ThemeID   string    `gorm:"not null;default:'graywolf'" json:"theme_id"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

ThemeConfig stores the operator's preferred UI color theme. Singleton at id=1, default ThemeID="graywolf". The set of shipped themes lives in graywolf/web/themes/themes.json; ids are validated by regex (^[a-z0-9][a-z0-9-]{0,63}$) — see IsValidTheme in seed_theme.go — rather than by a hardcoded list so new themes don't require backend changes.

type TxTiming

type TxTiming struct {
	ID        uint32 `gorm:"primaryKey;autoIncrement" json:"id"`
	Channel   uint32 `gorm:"not null;uniqueIndex" json:"channel"`
	TxDelayMs uint32 `gorm:"not null;default:300" json:"tx_delay_ms"`
	TxTailMs  uint32 `gorm:"not null;default:100" json:"tx_tail_ms"`
	SlotMs    uint32 `gorm:"not null;default:100" json:"slot_ms"`
	Persist   uint32 `gorm:"not null;default:63" json:"persist"`
	FullDup   bool   `gorm:"not null;default:false" json:"full_dup"`
	// Rate limits; 0 = unlimited.
	Rate1Min  uint32    `gorm:"not null;default:0" json:"rate_1min"`
	Rate5Min  uint32    `gorm:"not null;default:0" json:"rate_5min"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

TxTiming holds per-channel CSMA parameters. Mirrors txgovernor.ChannelTiming.

type UnitsConfig

type UnitsConfig struct {
	ID        uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	System    string    `gorm:"not null;default:'imperial'" json:"system"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

UnitsConfig stores the operator's preferred measurement system for display. Singleton at id=1, default System="imperial". Valid values are "imperial" and "metric"; unknown values fall back to imperial on read (see GetUnitsConfig).

type UpdatesConfig

type UpdatesConfig struct {
	ID        uint32    `gorm:"primaryKey;autoIncrement" json:"id"`
	Enabled   bool      `gorm:"not null;default:true" json:"enabled"`
	CreatedAt time.Time `json:"-"`
	UpdatedAt time.Time `json:"-"`
}

UpdatesConfig controls the daily GitHub update check. Singleton at id=1, default Enabled=true. Disabling stops the ticker and causes GET /api/updates/status to report status="disabled" regardless of any cached result.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL