Compare commits
14
Commits
v0.20.4
...
666b933114
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 | ||
|
|
3cef885934 | ||
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 |
@@ -182,10 +182,16 @@ const (
|
||||
keyAntGeniusHost = "antgenius.host"
|
||||
keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN)
|
||||
|
||||
// PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius.
|
||||
keyPGXLEnabled = "pgxl.enabled"
|
||||
keyPGXLHost = "pgxl.host"
|
||||
keyPGXLPort = "pgxl.port"
|
||||
// Amplifier control — Hardware → Amplifier (PowerGenius XL over TCP; SPE Expert
|
||||
// over USB serial or an RS232-to-Ethernet bridge). Keys keep the pgxl.* prefix
|
||||
// for backward compatibility with existing saved settings.
|
||||
keyPGXLEnabled = "pgxl.enabled"
|
||||
keyPGXLHost = "pgxl.host"
|
||||
keyPGXLPort = "pgxl.port"
|
||||
keyPGXLType = "pgxl.type" // "pgxl" | "spe13" | "spe15" | "spe2k"
|
||||
keyPGXLTransport = "pgxl.transport" // "tcp" | "serial"
|
||||
keyPGXLComPort = "pgxl.com_port"
|
||||
keyPGXLBaud = "pgxl.baud"
|
||||
|
||||
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
|
||||
keyWKEnabled = "winkeyer.enabled"
|
||||
@@ -699,6 +705,20 @@ func (a *App) startup(ctx context.Context) {
|
||||
usingDefault = false
|
||||
}
|
||||
}
|
||||
// A rename in a previous session left the OLD file to delete now that it's no
|
||||
// longer open. Only ever delete a file that ISN'T the one we're about to use.
|
||||
if boot := readBootstrap(dataDir); strings.TrimSpace(boot.DeletePending) != "" {
|
||||
old := strings.TrimSpace(boot.DeletePending)
|
||||
if old != a.dbPath {
|
||||
for _, p := range []string{old, old + "-wal", old + "-shm"} {
|
||||
if err := os.Remove(p); err == nil {
|
||||
fmt.Printf("OpsLog: removed old database file %s\n", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
boot.DeletePending = ""
|
||||
_ = writeBootstrap(dataDir, boot)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||
a.startupErr = "cannot create db folder: " + err.Error()
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
@@ -1340,6 +1360,11 @@ func copyFileData(src, dst string) error {
|
||||
type dbPointer struct {
|
||||
DBPath string `json:"db_path"`
|
||||
MySQL *MySQLSettings `json:"mysql,omitempty"`
|
||||
// DeletePending is the previous database file to remove on the NEXT launch —
|
||||
// set by a rename, which can't delete the still-open old file in-process. The
|
||||
// startup path deletes it (with its -wal/-shm sidecars) once the new DB is the
|
||||
// one in use, then clears this.
|
||||
DeletePending string `json:"delete_pending,omitempty"`
|
||||
}
|
||||
|
||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||
@@ -1653,9 +1678,10 @@ func (a *App) PickSaveDatabase() (string, error) {
|
||||
return "", fmt.Errorf("no app context")
|
||||
}
|
||||
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||
Title: "Save the OpsLog database to…",
|
||||
DefaultFilename: "opslog.db",
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
||||
Title: "Save the OpsLog database to…",
|
||||
DefaultDirectory: filepath.Dir(a.dbPath),
|
||||
DefaultFilename: "opslog.db",
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1697,6 +1723,44 @@ func (a *App) MoveDatabase(dest string) error {
|
||||
return writeDBPointer(a.dataDir, dest)
|
||||
}
|
||||
|
||||
// RenameDatabase renames the current database file to dest — keeping ALL config
|
||||
// (it is the same database under a new name), unlike "New database" which starts
|
||||
// empty. Implemented as a consistent copy (VACUUM INTO) plus a switch, then the
|
||||
// ORIGINAL file is scheduled for deletion on the next launch (it is open now and
|
||||
// can't be removed in-process on Windows). dest must not already exist.
|
||||
func (a *App) RenameDatabase(dest string) error {
|
||||
dest = strings.TrimSpace(dest)
|
||||
if dest == "" {
|
||||
return fmt.Errorf("no destination given")
|
||||
}
|
||||
if a.db == nil {
|
||||
return fmt.Errorf("database not open")
|
||||
}
|
||||
old := a.dbPath
|
||||
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
|
||||
return fmt.Errorf("that is already the current database name")
|
||||
}
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("create folder: %w", err)
|
||||
}
|
||||
safe := strings.ReplaceAll(dest, "'", "''")
|
||||
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
|
||||
return fmt.Errorf("copy database: %w", err)
|
||||
}
|
||||
boot := readBootstrap(a.dataDir)
|
||||
boot.DBPath = dest
|
||||
// Only schedule the old file for deletion when it's a real, on-disk file (a
|
||||
// custom path or the default opslog.db) — never something we somehow share
|
||||
// with the destination.
|
||||
if strings.TrimSpace(old) != "" && !strings.EqualFold(filepath.Clean(old), filepath.Clean(dest)) {
|
||||
boot.DeletePending = old
|
||||
}
|
||||
return writeBootstrap(a.dataDir, boot)
|
||||
}
|
||||
|
||||
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
|
||||
// points OpsLog at it for the next launch. dest must not already exist.
|
||||
func (a *App) CreateDatabase(dest string) error {
|
||||
@@ -2452,6 +2516,14 @@ func (a *App) migrateAwardDefs() {
|
||||
applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
|
||||
a.reseedRefsFromCatalog(code)
|
||||
}
|
||||
// A catalog definition changed (e.g. DDFM gained the postal-code OR-rule), so
|
||||
// the materialised award_refs on existing QSOs are now stale. Clear the
|
||||
// one-shot backfill flag; backfillAwardRefsOnce runs later in startup and will
|
||||
// re-materialise every row against the updated definitions. This makes ANY
|
||||
// future catalog bump self-heal the stored columns without a manual flag bump.
|
||||
if len(updated) > 0 {
|
||||
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "")
|
||||
}
|
||||
// Version-gated correction of the built-in awards' Validate sources, which
|
||||
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
|
||||
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
|
||||
@@ -3415,6 +3487,10 @@ func (a *App) invalidateAwardStats() {
|
||||
// flips qsl_rcvd flags on existing rows).
|
||||
func (a *App) RescanAwards() error {
|
||||
a.invalidateAwardStats()
|
||||
// Also refresh the materialised award_refs on every QSO (the grid columns read
|
||||
// from there). Manual trigger for when a definition/reference change should be
|
||||
// re-applied to existing rows on demand rather than waiting for the next event.
|
||||
a.recomputeAwardRefsAsync()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3821,7 +3897,12 @@ func (a *App) recomputeAwardRefsAsync() {
|
||||
|
||||
// keyAwardsMaterialized marks (per profile / logbook) that the one-time award_refs
|
||||
// backfill has run, so it doesn't re-scan the whole logbook on every launch.
|
||||
const keyAwardsMaterialized = "awards.materialized.v1"
|
||||
// keyAwardsMaterialized is versioned: bump the suffix to force every operator to
|
||||
// re-materialise award_refs once on next launch. Needed when the stored values
|
||||
// could be stale against the CURRENT award definitions — e.g. rows materialised
|
||||
// before an award gained a new matching rule (DDFM's postal-code OR-rule), which
|
||||
// the one-shot backfill would otherwise never revisit.
|
||||
const keyAwardsMaterialized = "awards.materialized.v2"
|
||||
|
||||
// backfillAwardRefsOnce populates award_refs for QSOs that predate the feature
|
||||
// (or were logged by an older client that didn't materialise them). It runs at
|
||||
@@ -4186,6 +4267,65 @@ func (a *App) ExportAward(code string) (string, error) {
|
||||
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
|
||||
}
|
||||
|
||||
// ExportAwardForCatalog writes ONE award as a ready-to-ship CATALOG file — the
|
||||
// exact shape internal/award/catalog/*.json use ({"def":{…},"references":[…]}).
|
||||
//
|
||||
// The workflow it enables: edit an award in the UI, call this with the NEXT
|
||||
// version number, and paste the file over that award's catalog JSON. A new OpsLog
|
||||
// release then carries the change to the whole team: every operator whose copy is
|
||||
// unedited auto-upgrades to it (mergeCatalog), and those who edited it keep theirs
|
||||
// but are offered the update.
|
||||
//
|
||||
// Two things a plain export/mirror can't do and this does, both essential for a
|
||||
// catalog file: it STAMPS the award's Version (the UI save deliberately never
|
||||
// bumps it) and it CLEARS user_edited, so a fresh install seeded from this file is
|
||||
// NOT pre-flagged as the operator's own work — which would otherwise freeze it out
|
||||
// of every future catalog update.
|
||||
func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
|
||||
if a.awardRefs == nil || a.ctx == nil {
|
||||
return "", fmt.Errorf("db not initialized")
|
||||
}
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
var def award.Def
|
||||
found := false
|
||||
for _, d := range a.awardDefs() {
|
||||
if strings.EqualFold(strings.TrimSpace(d.Code), code) {
|
||||
def, found = d, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("unknown award %q", code)
|
||||
}
|
||||
def.Version = version
|
||||
def.UserEdited = false // a catalog seed is not "the operator's edit"
|
||||
def.Builtin = true
|
||||
refs, err := a.awardRefs.List(a.ctx, code)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load references: %w", err)
|
||||
}
|
||||
entry := struct {
|
||||
Def award.Def `json:"def"`
|
||||
References []awardref.Ref `json:"references,omitempty"`
|
||||
}{Def: def, References: refs}
|
||||
b, err := json.MarshalIndent(entry, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
|
||||
DefaultFilename: strings.ToLower(code) + ".json",
|
||||
Title: "Export " + code + " for the catalog",
|
||||
Filters: []wruntime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}},
|
||||
})
|
||||
if err != nil || strings.TrimSpace(path) == "" {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
|
||||
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
|
||||
if a.awardRefs == nil {
|
||||
@@ -10225,6 +10365,45 @@ func (a *App) IcomSendCW(text string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// FlexSendCW keys a CW message through the FlexRadio CWX keyer (SmartSDR), so a
|
||||
// Flex needs no WinKeyer / SmartCAT. Text is already variable-resolved by the UI.
|
||||
func (a *App) FlexSendCW(text string) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SendCW(text) })
|
||||
if err != nil {
|
||||
applog.Printf("flex cw: FlexSendCW(%q) failed: %v", text, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// FlexStopCW clears the CWX buffer, aborting whatever is being keyed.
|
||||
func (a *App) FlexStopCW() error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.StopCW() })
|
||||
}
|
||||
|
||||
// FlexSetKeySpeed sets the CW keyer speed in WPM (the CWX keyer uses the radio's
|
||||
// CW speed).
|
||||
func (a *App) FlexSetKeySpeed(wpm int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
|
||||
}
|
||||
|
||||
// FlexBackspaceCW removes the last n not-yet-keyed characters from the CWX buffer
|
||||
// (type-ahead correction). n<1 deletes one.
|
||||
func (a *App) FlexBackspaceCW(n int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.BackspaceCW(n) })
|
||||
}
|
||||
|
||||
// IcomStopCW aborts the CW message currently being sent.
|
||||
func (a *App) IcomStopCW() error {
|
||||
if a.cat == nil {
|
||||
@@ -11224,28 +11403,58 @@ func boolStr(b bool) string {
|
||||
// StationDevice is one configured relay board for the Station Control tab.
|
||||
type StationDevice struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "webswitch" | "kmtronic"
|
||||
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
|
||||
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
|
||||
Pass string `json:"pass,omitempty"`
|
||||
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
|
||||
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
|
||||
}
|
||||
|
||||
// deviceRelayCount is the relay count for a configured device — fixed by type,
|
||||
// except the Denkovi (4 or 8) and the generic USB-serial board, whose channel
|
||||
// count the user picks.
|
||||
func deviceRelayCount(d StationDevice) int {
|
||||
if d.Type == "usbrelay" || d.Type == "denkovi" {
|
||||
if d.Channels >= 1 {
|
||||
return d.Channels
|
||||
}
|
||||
return 8
|
||||
}
|
||||
return relayCountFor(d.Type)
|
||||
}
|
||||
|
||||
// relayCountFor returns a device type's fixed relay count.
|
||||
func relayCountFor(typ string) int {
|
||||
if typ == "kmtronic" {
|
||||
switch typ {
|
||||
case "kmtronic", "denkovi":
|
||||
return 8
|
||||
default:
|
||||
return 5 // webswitch 1216H
|
||||
}
|
||||
return 5 // webswitch 1216H
|
||||
}
|
||||
|
||||
// deviceDriver builds the wire driver for a configured device.
|
||||
func deviceDriver(d StationDevice) relaydev.Device {
|
||||
if d.Type == "kmtronic" {
|
||||
switch d.Type {
|
||||
case "kmtronic":
|
||||
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
|
||||
case "denkovi":
|
||||
// Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname.
|
||||
return relaydev.NewDenkovi(d.Host, deviceRelayCount(d))
|
||||
case "usbrelay":
|
||||
// Host carries the COM port (e.g. "COM5"); CH340/LCUS "A0" serial protocol.
|
||||
return relaydev.NewSerialRelay(d.Host, deviceRelayCount(d))
|
||||
default:
|
||||
return relaydev.NewWebswitch(d.Host)
|
||||
}
|
||||
return relaydev.NewWebswitch(d.Host)
|
||||
}
|
||||
|
||||
// ListDenkoviDevices returns the FTDI serial numbers of connected Denkovi/FTDI
|
||||
// boards, for the settings picker. Windows-only (FTDI D2XX).
|
||||
func (a *App) ListDenkoviDevices() ([]string, error) {
|
||||
return relaydev.ListDenkovi()
|
||||
}
|
||||
|
||||
// GetStationDevices returns the configured relay boards (without live state).
|
||||
@@ -11271,13 +11480,16 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
for i := range devs {
|
||||
if devs[i].Type != "kmtronic" {
|
||||
switch devs[i].Type {
|
||||
case "kmtronic", "denkovi", "usbrelay", "webswitch":
|
||||
// known type — keep
|
||||
default:
|
||||
devs[i].Type = "webswitch"
|
||||
}
|
||||
if strings.TrimSpace(devs[i].ID) == "" {
|
||||
devs[i].ID = fmt.Sprintf("dev%d-%d", i, time.Now().UnixNano())
|
||||
}
|
||||
n := relayCountFor(devs[i].Type)
|
||||
n := deviceRelayCount(devs[i])
|
||||
for len(devs[i].Labels) < n {
|
||||
devs[i].Labels = append(devs[i].Labels, "")
|
||||
}
|
||||
@@ -11318,7 +11530,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
d := devs[i]
|
||||
n := relayCountFor(d.Type)
|
||||
n := deviceRelayCount(d)
|
||||
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
@@ -11925,19 +12137,25 @@ func (a *App) AntGeniusDeselect(port int) error {
|
||||
|
||||
// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ─────
|
||||
|
||||
// PGXLSettings is the JSON shape for the Hardware → PowerGenius panel.
|
||||
// PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers
|
||||
// the 4O3A PowerGenius XL (TCP) and the SPE Expert amps (USB serial or an
|
||||
// RS232-to-Ethernet bridge). The type name is kept for binding compatibility.
|
||||
type PGXLSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k"
|
||||
Transport string `json:"transport"` // "tcp" | "serial"
|
||||
Host string `json:"host"` // TCP transport
|
||||
Port int `json:"port"` // TCP transport
|
||||
ComPort string `json:"com_port"` // serial transport
|
||||
Baud int `json:"baud"` // serial transport
|
||||
}
|
||||
|
||||
func (a *App) GetPGXLSettings() (PGXLSettings, error) {
|
||||
out := PGXLSettings{Port: 9008}
|
||||
out := PGXLSettings{Type: "pgxl", Transport: "tcp", Port: 9008, Baud: 115200}
|
||||
if a.settings == nil {
|
||||
return out, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyPGXLEnabled, keyPGXLHost, keyPGXLPort)
|
||||
m, err := a.settings.GetMany(a.ctx, keyPGXLEnabled, keyPGXLHost, keyPGXLPort, keyPGXLType, keyPGXLTransport, keyPGXLComPort, keyPGXLBaud)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -11946,6 +12164,16 @@ func (a *App) GetPGXLSettings() (PGXLSettings, error) {
|
||||
if p, _ := strconv.Atoi(m[keyPGXLPort]); p > 0 && p <= 65535 {
|
||||
out.Port = p
|
||||
}
|
||||
if t := strings.TrimSpace(m[keyPGXLType]); t != "" {
|
||||
out.Type = t
|
||||
}
|
||||
if tr := strings.TrimSpace(m[keyPGXLTransport]); tr != "" {
|
||||
out.Transport = tr
|
||||
}
|
||||
out.ComPort = m[keyPGXLComPort]
|
||||
if b, _ := strconv.Atoi(m[keyPGXLBaud]); b > 0 {
|
||||
out.Baud = b
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -11956,10 +12184,23 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 9008
|
||||
}
|
||||
if s.Baud <= 0 {
|
||||
s.Baud = 115200
|
||||
}
|
||||
if s.Type == "" {
|
||||
s.Type = "pgxl"
|
||||
}
|
||||
if s.Transport == "" {
|
||||
s.Transport = "tcp"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyPGXLEnabled: boolStr(s.Enabled),
|
||||
keyPGXLHost: strings.TrimSpace(s.Host),
|
||||
keyPGXLPort: strconv.Itoa(s.Port),
|
||||
keyPGXLEnabled: boolStr(s.Enabled),
|
||||
keyPGXLHost: strings.TrimSpace(s.Host),
|
||||
keyPGXLPort: strconv.Itoa(s.Port),
|
||||
keyPGXLType: s.Type,
|
||||
keyPGXLTransport: s.Transport,
|
||||
keyPGXLComPort: strings.TrimSpace(s.ComPort),
|
||||
keyPGXLBaud: strconv.Itoa(s.Baud),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -11979,7 +12220,17 @@ func (a *App) startPGXL() {
|
||||
a.pgxl = nil
|
||||
}
|
||||
s, err := a.GetPGXLSettings()
|
||||
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
|
||||
if err != nil || !s.Enabled {
|
||||
return
|
||||
}
|
||||
// Only the PowerGenius XL (TCP) is driven for now. SPE Expert control (serial /
|
||||
// RS232-to-Ethernet) is a separate protocol, not yet implemented — its settings
|
||||
// are stored but no client is started.
|
||||
if s.Type != "" && s.Type != "pgxl" {
|
||||
applog.Printf("amplifier: type %q selected — control not implemented yet (settings saved)", s.Type)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(s.Host) == "" {
|
||||
return
|
||||
}
|
||||
a.pgxl = powergenius.New(s.Host, s.Port)
|
||||
|
||||
+36
-14
@@ -36,6 +36,7 @@ import {
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||
@@ -775,7 +776,7 @@ export default function App() {
|
||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
||||
const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer';
|
||||
const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer';
|
||||
const cwSourceRef = useRef(cwSource);
|
||||
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
|
||||
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
|
||||
@@ -804,7 +805,9 @@ export default function App() {
|
||||
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
|
||||
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
|
||||
useEffect(() => {
|
||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected;
|
||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||
: wkStatus.connected;
|
||||
wkActiveRef.current = wkEnabled && connected;
|
||||
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
|
||||
@@ -2036,7 +2039,7 @@ export default function App() {
|
||||
setWkMacros((s.macros ?? []) as WKMacro[]);
|
||||
setWkEscClears(s.esc_clears_call !== false);
|
||||
setWkSendOnType(!!s.send_on_type);
|
||||
setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer');
|
||||
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : 'winkeyer');
|
||||
} catch { /* keyer not configured */ }
|
||||
}, []);
|
||||
|
||||
@@ -2134,11 +2137,13 @@ export default function App() {
|
||||
const keyed = resolved ? resolved + ' ' : resolved;
|
||||
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||
if (cwSourceRef.current === 'icom') {
|
||||
// The rig's keyer gives no busy echo back, so show the text we sent and,
|
||||
// for <LOGQSO>, wait the estimated send duration before logging.
|
||||
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
|
||||
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so show
|
||||
// the text we sent and, for <LOGQSO>, wait the estimated send duration
|
||||
// before logging.
|
||||
setWkSent(resolved);
|
||||
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
|
||||
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
||||
return;
|
||||
}
|
||||
@@ -2174,7 +2179,7 @@ export default function App() {
|
||||
// the wait at the ESTIMATED send time (not the busy flag alone): over a
|
||||
// remote/serial-over-IP link the "busy" status lags badly and stays stuck
|
||||
// true for tens of seconds, which made the next CQ fire ~120s late.
|
||||
if (cwSourceRef.current === 'icom') {
|
||||
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
|
||||
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
|
||||
} else {
|
||||
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
|
||||
@@ -2212,8 +2217,14 @@ export default function App() {
|
||||
writeUiPref('opslog.wkAutoCallSecs', String(v));
|
||||
}
|
||||
// send-on-type: key the typed chars verbatim (no variable substitution).
|
||||
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
|
||||
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
|
||||
function wkSendRaw(chars: string) {
|
||||
if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
|
||||
WinkeyerSend(chars).catch(() => {});
|
||||
}
|
||||
function wkBackspace() {
|
||||
if (cwSourceRef.current === 'flex') { FlexBackspaceCW(1).catch(() => {}); return; }
|
||||
WinkeyerBackspace().catch(() => {});
|
||||
}
|
||||
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
|
||||
|
||||
// Resolve slot status for any spot we haven't seen yet — debounced so we
|
||||
@@ -2880,7 +2891,14 @@ export default function App() {
|
||||
const keyerLive = wkActiveRef.current;
|
||||
// ESC aborts the current CW transmission AND the auto-call loop, so it
|
||||
// won't resend after the gap — you must click a CQ macro to restart it.
|
||||
if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); }
|
||||
// Route the abort to whichever engine is active (was WinKeyer-only, so
|
||||
// ESC didn't stop the Icom or Flex keyer).
|
||||
if (keyerLive) {
|
||||
stopAutoCall();
|
||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {});
|
||||
}
|
||||
if (!keyerLive || wkEscClearsRef.current) {
|
||||
resetEntry();
|
||||
callsignRef.current?.focus();
|
||||
@@ -4406,8 +4424,8 @@ export default function App() {
|
||||
{wkEnabled && (
|
||||
<div className="w-[380px] shrink-0 min-h-0">
|
||||
<WinkeyerPanel
|
||||
status={cwSource === 'icom'
|
||||
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' }
|
||||
status={cwSource === 'icom' || cwSource === 'flex'
|
||||
? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
|
||||
: wkStatus}
|
||||
ports={wkPorts}
|
||||
port={wkPort}
|
||||
@@ -4424,11 +4442,15 @@ export default function App() {
|
||||
onSetSpeed={(w) => {
|
||||
setWkWpm(w); saveWk({ wpm: w });
|
||||
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||
else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||
else WinkeyerSetSpeed(w).catch(() => {});
|
||||
}}
|
||||
onSend={wkSend}
|
||||
onSendMacro={wkSendMacro}
|
||||
onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }}
|
||||
onStop={() => { stopAutoCall();
|
||||
if (cwSource === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSource === 'flex') FlexStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {}); }}
|
||||
onClose={() => wkSetEnabled(false)}
|
||||
sendOnType={wkSendOnType}
|
||||
onToggleSendOnType={wkToggleSendOnType}
|
||||
|
||||
@@ -33,6 +33,37 @@ function pretty(name: string): string {
|
||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
|
||||
// would be a new component *type* on every render, so React would unmount and
|
||||
// remount every port button each time the panel re-renders — harmless when that's
|
||||
// rare, but with the CW decoder running the parent re-renders many times a second
|
||||
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
|
||||
// different element instances), so antenna changes silently did nothing.
|
||||
function PortBtn({ port, index, active, tx, onActivate, t }: {
|
||||
port: 1 | 2; index: number; active: boolean; tx: boolean;
|
||||
onActivate: (port: number, antenna: number) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
}) {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
if (filtered.length > 0) list = filtered;
|
||||
}
|
||||
|
||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||
return (
|
||||
<div key={a.index} className="flex items-center gap-1.5">
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
|
||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||
{pretty(a.name)}
|
||||
</div>
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ListCountries, DXCCForCountry, DXCCName,
|
||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
|
||||
ExportAwardForCatalog,
|
||||
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
@@ -37,7 +38,7 @@ export type AwardDef = {
|
||||
or_rules?: AwardOrRule[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||
total: number; builtin?: boolean;
|
||||
total: number; builtin?: boolean; version?: number;
|
||||
};
|
||||
|
||||
type AwardOrRule = {
|
||||
@@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
// Version to stamp into a "publish for catalog" export — defaults to one past
|
||||
// the selected award's current version whenever the selection changes.
|
||||
const [catVer, setCatVer] = useState('1');
|
||||
|
||||
// The err banner doubles as a success/notice area (export path, import counts,
|
||||
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
|
||||
@@ -212,6 +216,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
const cur = defs[sel];
|
||||
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
|
||||
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
|
||||
|
||||
// ── Award tester: run the award's rules against a real QSO and show every step.
|
||||
type Rejected = { candidate: string; reason: string };
|
||||
@@ -299,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
|
||||
// paste over internal/award/catalog/<code>.json. A new release then ships it to
|
||||
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
|
||||
async function exportForCatalog() {
|
||||
setErr('');
|
||||
if (!cur) return;
|
||||
const v = Math.trunc(Number(catVer));
|
||||
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
|
||||
try {
|
||||
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
|
||||
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Import: LOOK FIRST, then ask.
|
||||
//
|
||||
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||
@@ -352,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -726,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
title={t('awed.awardsFolderTip')}>
|
||||
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||
</Button>
|
||||
{/* Publish the selected award to the catalog: stamp a version and write a
|
||||
file to paste over internal/award/catalog/<code>.json, so a release
|
||||
ships your change to the whole team. */}
|
||||
{cur && (
|
||||
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
|
||||
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
|
||||
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
|
||||
<Button variant="outline" onClick={exportForCatalog}>
|
||||
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
|
||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
ConnectClusterServer, DisconnectClusterServer,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
|
||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
|
||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
@@ -276,7 +276,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
winkeyer: 'CW Keyer',
|
||||
antenna: 'Ultrabeam / Steppir',
|
||||
antgenius: 'Antenna Genius',
|
||||
pgxl: 'Power Genius',
|
||||
pgxl: 'Amplifier',
|
||||
flex: 'FlexRadio',
|
||||
relayauto: 'Relay auto-control',
|
||||
audio: 'Audio devices',
|
||||
@@ -647,7 +647,7 @@ function ADIFMonitorPanel() {
|
||||
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
|
||||
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
|
||||
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
||||
const relayCountUI = (type: string) => (type === 'kmtronic' ? 8 : 5);
|
||||
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
||||
function RelayAutoPanel() {
|
||||
const { t } = useI18n();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
@@ -698,7 +698,7 @@ function RelayAutoPanel() {
|
||||
<div key={dev.id} className="rounded-md border border-border">
|
||||
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{Array.from({ length: relayCountUI(dev.type) }, (_, i) => i + 1).map((relay) => {
|
||||
{Array.from({ length: (dev.labels?.length || relayCountUI(dev.type)) }, (_, i) => i + 1).map((relay) => {
|
||||
const r = ruleFor(dev.id, relay);
|
||||
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
||||
return (
|
||||
@@ -1013,8 +1013,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||
|
||||
// PowerGenius XL (4O3A) amp fan-control settings.
|
||||
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
|
||||
// Amplifier control settings (PowerGenius XL over TCP; SPE Expert over serial/IP).
|
||||
const [pgxl, setPgxl] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number }>(
|
||||
{ enabled: false, type: 'pgxl', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200 });
|
||||
|
||||
// WinKeyer CW keyer settings + macro editor.
|
||||
type WKMac = { label: string; text: string };
|
||||
@@ -2645,36 +2646,89 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
|
||||
function PGXLPanelSettings() {
|
||||
const isPGXL = pgxl.type === 'pgxl';
|
||||
const isSerial = pgxl.transport === 'serial';
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Power Genius XL"
|
||||
/>
|
||||
<SectionHeader title="Amplifier" />
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
||||
Enable PowerGenius fan control
|
||||
Enable amplifier control
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host / IP</Label>
|
||||
<Input
|
||||
value={pgxl.host ?? ''}
|
||||
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
||||
placeholder="192.168.1.70"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>TCP port</Label>
|
||||
<Input
|
||||
type="number" min={1} max={65535}
|
||||
value={pgxl.port}
|
||||
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Label>Amplifier</Label>
|
||||
<Select value={pgxl.type} onValueChange={(v) => setPgxl((s) => ({ ...s, type: v, transport: v === 'pgxl' ? 'tcp' : s.transport }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pgxl">4O3A PowerGenius XL</SelectItem>
|
||||
<SelectItem value="spe13">SPE Expert 1.3K-FA</SelectItem>
|
||||
<SelectItem value="spe15">SPE Expert 1.5K-FA</SelectItem>
|
||||
<SelectItem value="spe2k">SPE Expert 2K-FA</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* PowerGenius is TCP-only; SPE amps connect over USB serial or an
|
||||
RS232-to-Ethernet bridge, so they offer both. */}
|
||||
{!isPGXL && (
|
||||
<div className="space-y-1">
|
||||
<Label>Connection</Label>
|
||||
<Select value={pgxl.transport} onValueChange={(v) => setPgxl((s) => ({ ...s, transport: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSerial ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>COM port</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={pgxl.com_port || '_'} onValueChange={(v) => setPgxl((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Baud</Label>
|
||||
<Input type="number" min={1200} value={pgxl.baud}
|
||||
onChange={(e) => setPgxl((s) => ({ ...s, baud: parseInt(e.target.value) || 115200 }))} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host / IP</Label>
|
||||
<Input value={pgxl.host ?? ''} onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
||||
placeholder="192.168.1.70" className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>TCP port</Label>
|
||||
<Input type="number" min={1} max={65535} value={pgxl.port}
|
||||
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPGXL && (
|
||||
<p className="text-xs text-warning">
|
||||
SPE Expert control is not wired up yet — these settings are saved, but OpsLog can't command the amp until its serial protocol is implemented.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -2807,6 +2861,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectContent>
|
||||
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
|
||||
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
|
||||
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
|
||||
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -2837,6 +2892,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : wk.engine === 'flex' ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
FlexRadio keys CW through the radio's <strong>CWX</strong> keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).
|
||||
</p>
|
||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||
<span aria-hidden>⚠</span>
|
||||
<span>
|
||||
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Flex CWX needs the CAT backend set to <strong>FlexRadio</strong> and connected — change it under Settings → CAT interface, otherwise sending CW will fail.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Speed (WPM)</Label>
|
||||
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
@@ -3978,6 +4053,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
setDbMsg(p);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
// Rename the CURRENT database (keeps all config), unlike New database which
|
||||
// starts empty. The old file is removed on the next launch.
|
||||
async function renameDb() {
|
||||
try {
|
||||
const p = await PickSaveDatabase();
|
||||
if (!p) return;
|
||||
await RenameDatabase(p);
|
||||
await refreshDb();
|
||||
setDbMsg(p);
|
||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||
}
|
||||
async function resetDefault() {
|
||||
try {
|
||||
await ResetDatabaseToDefault();
|
||||
@@ -4056,6 +4142,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
||||
</div>
|
||||
|
||||
@@ -12,16 +12,22 @@ import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
|
||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
|
||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[] };
|
||||
type Relay = { number: number; label: string; on: boolean };
|
||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||
|
||||
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||
// the generic USB-serial board, whose channel count the user picks.
|
||||
const chanCount = (d: Device): number =>
|
||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
||||
|
||||
function blankDevice(): Device {
|
||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||
@@ -445,11 +451,39 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||
const setType = (type: string) => {
|
||||
const n = RELAY_COUNT[type] ?? 5;
|
||||
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
|
||||
const n = chanCount({ ...device, type, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, type, labels });
|
||||
onChange({ ...device, type, channels, labels });
|
||||
};
|
||||
const setChannels = (channels: number) => {
|
||||
const n = chanCount({ ...device, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, channels, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
const isDenkovi = device.type === 'denkovi';
|
||||
const isUsbRelay = device.type === 'usbrelay';
|
||||
// COM ports for the generic USB-serial relay picker.
|
||||
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!isUsbRelay) return;
|
||||
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
|
||||
}, [isUsbRelay]);
|
||||
// Detected FTDI serials for the Denkovi picker.
|
||||
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const detectDenkovi = async () => {
|
||||
setDetecting(true);
|
||||
try {
|
||||
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||
setDenkoviSerials(list);
|
||||
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||
} catch { setDenkoviSerials([]); }
|
||||
finally { setDetecting(false); }
|
||||
};
|
||||
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||
return (
|
||||
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||
@@ -461,6 +495,8 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<SelectContent>
|
||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -469,6 +505,54 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{(isDenkovi || isUsbRelay) && (
|
||||
<div className="space-y-1 max-w-[10rem]">
|
||||
<Label>{t('station.channels')}</Label>
|
||||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{isDenkovi ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.ftdiSerial')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||||
list="denkovi-serials"
|
||||
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
<datalist id="denkovi-serials">
|
||||
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||||
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||
{t('station.detect')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||
</div>
|
||||
) : isUsbRelay ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.comPort')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||
<Label>{t('station.host')}</Label>
|
||||
@@ -487,6 +571,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.labels')}</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
wpm: number;
|
||||
macros: WKMacro[];
|
||||
sent: string; // text echoed back by the keyer as it transmits
|
||||
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||
onSetBreakIn?: (mode: number) => void;
|
||||
onSelectPort: (p: string) => void;
|
||||
@@ -101,14 +101,16 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{source === 'icom' ? (
|
||||
{source === 'icom' || source === 'flex' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
|
||||
{source === 'flex'
|
||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
) : !connected ? (
|
||||
<>
|
||||
@@ -183,7 +185,7 @@ export function WinkeyerPanel({
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
|
||||
{t('wkp.cwText')}
|
||||
{source === 'winkeyer' && (
|
||||
{(source === 'winkeyer' || source === 'flex') && (
|
||||
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
|
||||
title={t('wkp.sendOnTypeHint')}>
|
||||
<input type="checkbox" className="accent-primary" checked={sendOnType}
|
||||
|
||||
@@ -131,9 +131,9 @@ const en: Dict = {
|
||||
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
|
||||
'uscty.backfillRun': 'Fill missing counties',
|
||||
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
|
||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
|
||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
|
||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||
'sec.relayauto': 'Relay auto-control',
|
||||
'relayauto.hint': 'Automatically switch Station Control relays from the rig frequency / band (like PstRotator). Each relay: a frequency window (ON inside, OFF outside) or a set of bands. Relays are set up in the Station Control panel.',
|
||||
'relayauto.enable': 'Enable relay auto-control',
|
||||
@@ -244,7 +244,7 @@ const en: Dict = {
|
||||
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
|
||||
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
|
||||
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
|
||||
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
||||
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.rename': 'Rename…', 'db.renameTip': 'Rename this database (keeps all your config). The old file is removed on the next launch.', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
|
||||
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
|
||||
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
|
||||
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
|
||||
@@ -275,7 +275,7 @@ const en: Dict = {
|
||||
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
|
||||
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
|
||||
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
|
||||
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)',
|
||||
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)', 'wkp.cwxReady': 'Flex CWX ready', 'wkp.cwxOffline': 'Flex not connected (Settings → CAT)',
|
||||
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
|
||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
|
||||
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
||||
@@ -307,6 +307,10 @@ const en: Dict = {
|
||||
'awed.builtin': 'Built-in',
|
||||
'awed.awardsFolder': 'Awards folder',
|
||||
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
|
||||
'awed.catalogPublish': 'Publish to catalog…',
|
||||
'awed.catalogPublishTip': 'Export this award as a catalog file (with the version on the left), to paste over internal/award/catalog/<code>.json. A new release then ships it to the whole team — copies nobody edited auto-upgrade, edited ones are offered it.',
|
||||
'awed.catalogExportedTo': 'Catalog file written to:\n{path}\n\nPaste it over internal/award/catalog/<code>.json, then build and release.',
|
||||
'awed.catalogBadVersion': 'Enter a version number (1 or more).',
|
||||
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
|
||||
'awed.protectedFlag': 'Protected',
|
||||
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
|
||||
@@ -440,9 +444,9 @@ const fr: Dict = {
|
||||
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
|
||||
'uscty.backfillRun': 'Remplir les comtés manquants',
|
||||
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
|
||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
|
||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
|
||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
'sec.relayauto': 'Relais automatiques',
|
||||
'relayauto.hint': "Commute automatiquement les relais du Station Control selon la fréquence / bande du poste (comme PstRotator). Par relais : une plage de fréquence (ON dedans, OFF dehors) ou un ensemble de bandes. Les relais se configurent dans le panneau Station Control.",
|
||||
'relayauto.enable': 'Activer les relais automatiques',
|
||||
@@ -540,7 +544,7 @@ const fr: Dict = {
|
||||
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
|
||||
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
|
||||
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
|
||||
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
||||
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.rename': 'Renommer…', 'db.renameTip': 'Renomme cette base (garde toute ta config). L’ancien fichier est supprimé au prochain lancement.', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
|
||||
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
|
||||
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
|
||||
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
|
||||
@@ -568,7 +572,7 @@ const fr: Dict = {
|
||||
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
|
||||
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
|
||||
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
|
||||
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)',
|
||||
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)', 'wkp.cwxReady': 'Flex CWX prêt', 'wkp.cwxOffline': 'Flex non connecté (Réglages → CAT)',
|
||||
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
|
||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
|
||||
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
||||
@@ -598,6 +602,10 @@ const fr: Dict = {
|
||||
'awed.builtin': 'Intégré',
|
||||
'awed.awardsFolder': 'Dossier awards',
|
||||
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
|
||||
'awed.catalogPublish': 'Publier au catalogue…',
|
||||
'awed.catalogPublishTip': "Exporte ce diplôme en fichier catalogue (avec la version à gauche), à coller par-dessus internal/award/catalog/<code>.json. Une nouvelle release le diffuse à toute l'équipe — les copies non modifiées s'upgradent seules, les modifiées se le voient proposer.",
|
||||
'awed.catalogExportedTo': 'Fichier catalogue écrit dans :\n{path}\n\nColle-le par-dessus internal/award/catalog/<code>.json, puis build et release.',
|
||||
'awed.catalogBadVersion': 'Entre un numéro de version (1 ou plus).',
|
||||
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.',
|
||||
'awed.protectedFlag': 'Protégé',
|
||||
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
|
||||
|
||||
Vendored
+14
@@ -168,6 +168,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
|
||||
|
||||
export function ExportAward(arg1:string):Promise<string>;
|
||||
|
||||
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
|
||||
|
||||
export function ExportAwards():Promise<string>;
|
||||
|
||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||
@@ -190,8 +192,12 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexBackspaceCW(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexMox(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||
|
||||
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||
@@ -222,6 +228,8 @@ export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function FlexSetKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMic(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||
@@ -278,6 +286,8 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexStopCW():Promise<void>;
|
||||
|
||||
export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||
@@ -536,6 +546,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
@@ -676,6 +688,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||
|
||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenameDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||
|
||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||
|
||||
@@ -294,6 +294,10 @@ export function ExportAward(arg1) {
|
||||
return window['go']['main']['App']['ExportAward'](arg1);
|
||||
}
|
||||
|
||||
export function ExportAwardForCatalog(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExportAwards() {
|
||||
return window['go']['main']['App']['ExportAwards']();
|
||||
}
|
||||
@@ -338,10 +342,18 @@ export function FlexApplyBandAntenna(arg1) {
|
||||
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||
}
|
||||
|
||||
export function FlexBackspaceCW(arg1) {
|
||||
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
|
||||
}
|
||||
|
||||
export function FlexMox(arg1) {
|
||||
return window['go']['main']['App']['FlexMox'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSendCW(arg1) {
|
||||
return window['go']['main']['App']['FlexSendCW'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetAGCMode(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
||||
}
|
||||
@@ -402,6 +414,10 @@ export function FlexSetFilter(arg1, arg2) {
|
||||
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function FlexSetKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetMic(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||
}
|
||||
@@ -514,6 +530,10 @@ export function FlexSetXITFreq(arg1) {
|
||||
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
||||
}
|
||||
|
||||
export function FlexStopCW() {
|
||||
return window['go']['main']['App']['FlexStopCW']();
|
||||
}
|
||||
|
||||
export function FlexTune(arg1) {
|
||||
return window['go']['main']['App']['FlexTune'](arg1);
|
||||
}
|
||||
@@ -1030,6 +1050,10 @@ export function ListCountries() {
|
||||
return window['go']['main']['App']['ListCountries']();
|
||||
}
|
||||
|
||||
export function ListDenkoviDevices() {
|
||||
return window['go']['main']['App']['ListDenkoviDevices']();
|
||||
}
|
||||
|
||||
export function ListOperatingTree() {
|
||||
return window['go']['main']['App']['ListOperatingTree']();
|
||||
}
|
||||
@@ -1310,6 +1334,10 @@ export function RemovePassphrase(arg1) {
|
||||
return window['go']['main']['App']['RemovePassphrase'](arg1);
|
||||
}
|
||||
|
||||
export function RenameDatabase(arg1) {
|
||||
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||
}
|
||||
|
||||
export function RenderEQSL(arg1, arg2) {
|
||||
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -2161,8 +2161,12 @@ export namespace main {
|
||||
}
|
||||
export class PGXLSettings {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
transport: string;
|
||||
host: string;
|
||||
port: number;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new PGXLSettings(source);
|
||||
@@ -2171,8 +2175,12 @@ export namespace main {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.transport = source["transport"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
}
|
||||
}
|
||||
export class POTAUnmatched {
|
||||
@@ -2600,6 +2608,7 @@ export namespace main {
|
||||
host: string;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
channels?: number;
|
||||
labels: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -2614,6 +2623,7 @@ export namespace main {
|
||||
this.host = source["host"];
|
||||
this.user = source["user"];
|
||||
this.pass = source["pass"];
|
||||
this.channels = source["channels"];
|
||||
this.labels = source["labels"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,27 @@
|
||||
"name": "Départements Français Métropolitains",
|
||||
"valid": true,
|
||||
"protected": true,
|
||||
"ref_display": "name",
|
||||
"type": "QSOFIELDS",
|
||||
"field": "note",
|
||||
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||
"or_rules": [
|
||||
{
|
||||
"field": "address",
|
||||
"match_by": "code",
|
||||
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||
"prefix": "D"
|
||||
},
|
||||
{
|
||||
"field": "qth",
|
||||
"match_by": "code",
|
||||
"pattern": "\\b(\\d{2})\\d{3}\\b",
|
||||
"prefix": "D"
|
||||
}
|
||||
],
|
||||
"dxcc_filter": [
|
||||
227
|
||||
227,
|
||||
214
|
||||
],
|
||||
"confirm": [
|
||||
"lotw",
|
||||
@@ -18,6 +34,777 @@
|
||||
"lotw"
|
||||
],
|
||||
"total": 96,
|
||||
"builtin": true
|
||||
}
|
||||
}
|
||||
"builtin": true,
|
||||
"version": 2
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"code": "D01",
|
||||
"name": "Ain",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D02",
|
||||
"name": "Aisne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D03",
|
||||
"name": "Allier",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D04",
|
||||
"name": "Alpes-de-Haute-Provence",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D05",
|
||||
"name": "Hautes-Alpes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D06",
|
||||
"name": "Alpes-Maritimes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D07",
|
||||
"name": "Ardèche",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D08",
|
||||
"name": "Ardennes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D09",
|
||||
"name": "Ariège",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D10",
|
||||
"name": "Aube",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D11",
|
||||
"name": "Aude",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D12",
|
||||
"name": "Aveyron",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D13",
|
||||
"name": "Bouches-du-Rhône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D14",
|
||||
"name": "Calvados",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D15",
|
||||
"name": "Cantal",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D16",
|
||||
"name": "Charente",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D17",
|
||||
"name": "Charente-Maritime",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D18",
|
||||
"name": "Cher",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D19",
|
||||
"name": "Corrèze",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D21",
|
||||
"name": "Côte-d'Or",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D22",
|
||||
"name": "Côtes-d'Armor",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D23",
|
||||
"name": "Creuse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D24",
|
||||
"name": "Dordogne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D25",
|
||||
"name": "Doubs",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D26",
|
||||
"name": "Drôme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D27",
|
||||
"name": "Eure",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D28",
|
||||
"name": "Eure-et-Loir",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D29",
|
||||
"name": "Finistère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D2A",
|
||||
"name": "Corse-du-Sud",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D2B",
|
||||
"name": "Haute-Corse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D30",
|
||||
"name": "Gard",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D31",
|
||||
"name": "Haute-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D32",
|
||||
"name": "Gers",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D33",
|
||||
"name": "Gironde",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D34",
|
||||
"name": "Hérault",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D35",
|
||||
"name": "Ille-et-Vilaine",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D36",
|
||||
"name": "Indre",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D37",
|
||||
"name": "Indre-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D38",
|
||||
"name": "Isère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D39",
|
||||
"name": "Jura",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D40",
|
||||
"name": "Landes",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D41",
|
||||
"name": "Loir-et-Cher",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D42",
|
||||
"name": "Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D43",
|
||||
"name": "Haute-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D44",
|
||||
"name": "Loire-Atlantique",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D45",
|
||||
"name": "Loiret",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D46",
|
||||
"name": "Lot",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D47",
|
||||
"name": "Lot-et-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D48",
|
||||
"name": "Lozère",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D49",
|
||||
"name": "Maine-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D50",
|
||||
"name": "Manche",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D51",
|
||||
"name": "Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D52",
|
||||
"name": "Haute-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D53",
|
||||
"name": "Mayenne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D54",
|
||||
"name": "Meurthe-et-Moselle",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D55",
|
||||
"name": "Meuse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D56",
|
||||
"name": "Morbihan",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D57",
|
||||
"name": "Moselle",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D58",
|
||||
"name": "Nièvre",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D59",
|
||||
"name": "Nord",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D60",
|
||||
"name": "Oise",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D61",
|
||||
"name": "Orne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D62",
|
||||
"name": "Pas-de-Calais",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D63",
|
||||
"name": "Puy-de-Dôme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D64",
|
||||
"name": "Pyrénées-Atlantiques",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D65",
|
||||
"name": "Hautes-Pyrénées",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D66",
|
||||
"name": "Pyrénées-Orientales",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D67",
|
||||
"name": "Bas-Rhin",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D68",
|
||||
"name": "Haut-Rhin",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D69",
|
||||
"name": "Rhône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D70",
|
||||
"name": "Haute-Saône",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D71",
|
||||
"name": "Saône-et-Loire",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D72",
|
||||
"name": "Sarthe",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D73",
|
||||
"name": "Savoie",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D74",
|
||||
"name": "Haute-Savoie",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D75",
|
||||
"name": "Paris",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D76",
|
||||
"name": "Seine-Maritime",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D77",
|
||||
"name": "Seine-et-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D78",
|
||||
"name": "Yvelines",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D79",
|
||||
"name": "Deux-Sèvres",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D80",
|
||||
"name": "Somme",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D81",
|
||||
"name": "Tarn",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D82",
|
||||
"name": "Tarn-et-Garonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D83",
|
||||
"name": "Var",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D84",
|
||||
"name": "Vaucluse",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D85",
|
||||
"name": "Vendée",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D86",
|
||||
"name": "Vienne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D87",
|
||||
"name": "Haute-Vienne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D88",
|
||||
"name": "Vosges",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D89",
|
||||
"name": "Yonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D90",
|
||||
"name": "Territoire de Belfort",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D91",
|
||||
"name": "Essonne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D92",
|
||||
"name": "Hauts-de-Seine",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D93",
|
||||
"name": "Seine-Saint-Denis",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D94",
|
||||
"name": "Val-de-Marne",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
},
|
||||
{
|
||||
"code": "D95",
|
||||
"name": "Val-d'Oise",
|
||||
"dxcc": 227,
|
||||
"group": "",
|
||||
"subgrp": "",
|
||||
"valid": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -394,6 +394,14 @@ type FlexController interface {
|
||||
SetCWSpeed(int) error
|
||||
SetCWPitch(int) error
|
||||
SetCWBreakInDelay(int) error
|
||||
// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
|
||||
// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
|
||||
// StopCW clears the buffer, aborting the send. BackspaceCW removes the last n
|
||||
// not-yet-keyed characters from the buffer (un-send while sending — for
|
||||
// type-ahead corrections).
|
||||
SendCW(string) error
|
||||
StopCW() error
|
||||
BackspaceCW(int) error
|
||||
SetCWSidetone(bool) error
|
||||
SetSidetoneLevel(int) error
|
||||
SetCWFilter(int) error
|
||||
|
||||
@@ -1581,6 +1581,37 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
|
||||
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
|
||||
// characters fed while it's already sending append and key in order (the basis
|
||||
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
|
||||
func (f *Flex) SendCW(text string) error {
|
||||
text = strings.TrimRight(text, "\r\n")
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
|
||||
f.send(`cwx send "` + esc + `"`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
|
||||
func (f *Flex) StopCW() error {
|
||||
f.send("cwx clear")
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackspaceCW removes the last n not-yet-keyed characters from the CWX buffer —
|
||||
// the "un-send while sending" a serial WinKeyer can't do. Used for type-ahead
|
||||
// corrections (backspacing a mistyped call before the radio has keyed it).
|
||||
func (f *Flex) BackspaceCW(n int) error {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
f.send(fmt.Sprintf("cwx erase %d", n))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) SetCWSidetone(on bool) error {
|
||||
f.mu.Lock()
|
||||
f.tx.cwSidetone = on
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build !windows
|
||||
|
||||
// The Denkovi USB board is driven through FTDI's D2XX bit-bang API (ftd2xx.dll),
|
||||
// which OpsLog only wires up on Windows. This stub keeps the package building on
|
||||
// other platforms; every call reports the board is unavailable.
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type denkoviStub struct{ count int }
|
||||
|
||||
// NewDenkovi returns a stub on non-Windows builds.
|
||||
func NewDenkovi(serial string, count int) Device {
|
||||
if count != 4 {
|
||||
count = 8
|
||||
}
|
||||
return denkoviStub{count: count}
|
||||
}
|
||||
|
||||
func (s denkoviStub) Count() int { return s.count }
|
||||
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
func (denkoviStub) Set(context.Context, int, bool) error {
|
||||
return fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
|
||||
// ListDenkovi has no devices to report off Windows.
|
||||
func ListDenkovi() ([]string, error) {
|
||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//go:build windows
|
||||
|
||||
// Denkovi USB 8-channel relay board (FT245RL). Despite enumerating as a virtual
|
||||
// COM port, this board is NOT driven by serial/ASCII: the FT245's 8 data lines
|
||||
// each drive a relay, controlled through FTDI's D2XX "bit-bang" mode. One byte
|
||||
// written = the 8 relays at once (bit 0 = relay 1). We load ftd2xx.dll at runtime
|
||||
// (no CGO) and call the D2XX API directly, exactly as the vendor's tool does
|
||||
// (which addresses the board by its FTDI serial, e.g. "DAE0006K").
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
ftdll = syscall.NewLazyDLL("ftd2xx.dll")
|
||||
procOpenEx = ftdll.NewProc("FT_OpenEx")
|
||||
procClose = ftdll.NewProc("FT_Close")
|
||||
procSetBitMode = ftdll.NewProc("FT_SetBitMode")
|
||||
procSetBaudRate = ftdll.NewProc("FT_SetBaudRate")
|
||||
procWrite = ftdll.NewProc("FT_Write")
|
||||
procPurge = ftdll.NewProc("FT_Purge")
|
||||
procCreateInfo = ftdll.NewProc("FT_CreateDeviceInfoList")
|
||||
procGetInfoDetail = ftdll.NewProc("FT_GetDeviceInfoDetail")
|
||||
)
|
||||
|
||||
const (
|
||||
ftOpenBySerial = 1 // FT_OPEN_BY_SERIAL_NUMBER
|
||||
ftBitModeSyncBB = 0x04 // FT_BITMODE_SYNC_BITBANG (the mode Denkovi documents)
|
||||
ftPurgeRX = 1 // FT_PURGE_RX
|
||||
)
|
||||
|
||||
func ftOK(r uintptr) bool { return r == 0 } // FT_OK == 0
|
||||
|
||||
type denkovi struct {
|
||||
serial string
|
||||
count int
|
||||
mu sync.Mutex
|
||||
shadow byte // last output byte (bit n = relay n+1); authoritative state
|
||||
h uintptr // FT handle
|
||||
opened bool
|
||||
}
|
||||
|
||||
// NewDenkovi builds a driver for a Denkovi USB relay board (4 or 8 relays)
|
||||
// identified by its FTDI serial number (shown by the vendor tool / FT_PROG,
|
||||
// e.g. "DAE0006K"). count defaults to 8; a 4-relay board just uses the low 4 bits.
|
||||
func NewDenkovi(serial string, count int) Device {
|
||||
if count != 4 && count != 8 {
|
||||
count = 8
|
||||
}
|
||||
return &denkovi{serial: strings.TrimSpace(serial), count: count}
|
||||
}
|
||||
|
||||
func (d *denkovi) Count() int { return d.count }
|
||||
|
||||
// ensureOpen opens the board and puts it in synchronous bit-bang mode with all 8
|
||||
// lines as outputs. Idempotent.
|
||||
func (d *denkovi) ensureOpen() error {
|
||||
if d.opened {
|
||||
return nil
|
||||
}
|
||||
if err := ftdll.Load(); err != nil {
|
||||
return fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software: %w", err)
|
||||
}
|
||||
if d.serial == "" {
|
||||
return fmt.Errorf("no FTDI serial number set for the Denkovi board")
|
||||
}
|
||||
ser, err := syscall.BytePtrFromString(d.serial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var h uintptr
|
||||
if r, _, _ := procOpenEx.Call(uintptr(unsafe.Pointer(ser)), ftOpenBySerial, uintptr(unsafe.Pointer(&h))); !ftOK(r) {
|
||||
return fmt.Errorf("cannot open Denkovi board %q (FT_OpenEx status %d) — is it connected and not in use by another app?", d.serial, r)
|
||||
}
|
||||
// All 8 lines output, synchronous bit-bang.
|
||||
if r, _, _ := procSetBitMode.Call(h, 0xFF, ftBitModeSyncBB); !ftOK(r) {
|
||||
procClose.Call(h)
|
||||
return fmt.Errorf("FT_SetBitMode failed (status %d)", r)
|
||||
}
|
||||
procSetBaudRate.Call(h, 9600) // bit-bang pin-update clock; relays don't need speed
|
||||
d.h = h
|
||||
d.opened = true
|
||||
// Make the hardware match our shadow (starts all-off on first open).
|
||||
return d.writeLocked()
|
||||
}
|
||||
|
||||
// writeLocked pushes the shadow byte to the relays. Caller holds d.mu.
|
||||
func (d *denkovi) writeLocked() error {
|
||||
var written uint32
|
||||
b := d.shadow
|
||||
if r, _, _ := procWrite.Call(d.h, uintptr(unsafe.Pointer(&b)), 1, uintptr(unsafe.Pointer(&written))); !ftOK(r) {
|
||||
return fmt.Errorf("FT_Write failed (status %d)", r)
|
||||
}
|
||||
// Synchronous bit-bang echoes each written byte into the RX buffer; drop it so
|
||||
// it doesn't fill over the life of the connection.
|
||||
procPurge.Call(d.h, ftPurgeRX)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *denkovi) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > d.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
bit := byte(1) << uint(relay-1) // relay 1 → bit 0
|
||||
if on {
|
||||
d.shadow |= bit
|
||||
} else {
|
||||
d.shadow &^= bit
|
||||
}
|
||||
return d.writeLocked()
|
||||
}
|
||||
|
||||
func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]bool, d.count)
|
||||
for i := 0; i < d.count; i++ {
|
||||
out[i] = d.shadow&(byte(1)<<uint(i)) != 0
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListDenkovi returns the FTDI serial numbers of connected devices, for the
|
||||
// settings UI to pick from. Requires ftd2xx.dll.
|
||||
func ListDenkovi() ([]string, error) {
|
||||
if err := ftdll.Load(); err != nil {
|
||||
return nil, fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software")
|
||||
}
|
||||
var n uint32
|
||||
if r, _, _ := procCreateInfo.Call(uintptr(unsafe.Pointer(&n))); !ftOK(r) {
|
||||
return nil, fmt.Errorf("FT_CreateDeviceInfoList failed (status %d)", r)
|
||||
}
|
||||
var out []string
|
||||
for i := uint32(0); i < n; i++ {
|
||||
var flags, typ, id, loc uint32
|
||||
serial := make([]byte, 16)
|
||||
desc := make([]byte, 64)
|
||||
var h uintptr
|
||||
r, _, _ := procGetInfoDetail.Call(
|
||||
uintptr(i),
|
||||
uintptr(unsafe.Pointer(&flags)), uintptr(unsafe.Pointer(&typ)),
|
||||
uintptr(unsafe.Pointer(&id)), uintptr(unsafe.Pointer(&loc)),
|
||||
uintptr(unsafe.Pointer(&serial[0])), uintptr(unsafe.Pointer(&desc[0])),
|
||||
uintptr(unsafe.Pointer(&h)),
|
||||
)
|
||||
if !ftOK(r) {
|
||||
continue
|
||||
}
|
||||
if s := cstr(serial); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cstr trims a C string (up to the first NUL) from a fixed buffer.
|
||||
func cstr(b []byte) string {
|
||||
if i := indexByte(b, 0); i >= 0 {
|
||||
b = b[:i]
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func indexByte(b []byte, c byte) int {
|
||||
for i := range b {
|
||||
if b[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// serialRelay drives the common cheap USB-serial relay boards (CH340 / LCUS-1
|
||||
// style) that use the "A0" command protocol over a virtual COM port:
|
||||
//
|
||||
// [0xA0] [relay 1-based] [state 0|1] [checksum] checksum = (0xA0+relay+state) & 0xFF
|
||||
//
|
||||
// e.g. relay 1 ON = A0 01 01 A2, relay 1 OFF = A0 01 00 A1. These boards are
|
||||
// write-only (no state readback), so Status reflects a shadow of what we sent.
|
||||
// Channel count varies by board (1/2/4/8/16), so it is configurable.
|
||||
type serialRelay struct {
|
||||
portName string
|
||||
count int
|
||||
baud int
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
shadow []bool
|
||||
}
|
||||
|
||||
// NewSerialRelay builds a driver for a CH340/LCUS-style USB-serial relay board on
|
||||
// the given COM port with `count` channels (defaults to 8).
|
||||
func NewSerialRelay(port string, count int) Device {
|
||||
if count < 1 {
|
||||
count = 8
|
||||
}
|
||||
return &serialRelay{portName: strings.TrimSpace(port), count: count, baud: 9600, shadow: make([]bool, count)}
|
||||
}
|
||||
|
||||
func (s *serialRelay) Count() int { return s.count }
|
||||
|
||||
func (s *serialRelay) ensureOpen() error {
|
||||
if s.port != nil {
|
||||
return nil
|
||||
}
|
||||
if s.portName == "" {
|
||||
return fmt.Errorf("no COM port set for the USB relay board")
|
||||
}
|
||||
p, err := serial.Open(s.portName, &serial.Mode{BaudRate: s.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", s.portName, err)
|
||||
}
|
||||
s.port = p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serialRelay) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > s.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, s.count)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
st := byte(0)
|
||||
if on {
|
||||
st = 1
|
||||
}
|
||||
r := byte(relay)
|
||||
frame := []byte{0xA0, r, st, byte(0xA0) + r + st} // last byte = checksum
|
||||
if _, err := s.port.Write(frame); err != nil {
|
||||
// Drop the handle so the next call reopens (USB unplugged / port reset).
|
||||
s.port.Close()
|
||||
s.port = nil
|
||||
return fmt.Errorf("write to %s: %w", s.portName, err)
|
||||
}
|
||||
s.shadow[relay-1] = on
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serialRelay) Status(ctx context.Context) ([]bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]bool, s.count)
|
||||
copy(out, s.shadow)
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user