fix: added functionality to net control

This commit is contained in:
2026-06-24 19:09:34 +02:00
parent 8b831145ad
commit d6626d96d0
6 changed files with 205 additions and 207 deletions
+129 -103
View File
@@ -406,7 +406,8 @@ type App struct {
netStore *netctl.Store
netMu sync.Mutex
netOpenID string // id of the currently open net ("" = none)
netActive []*netActiveEntry // stations on the air right now, in check-in order
netActive []*qso.QSO // on-air QSO drafts (transient negative ids), check-in order
netSeq int64 // transient-id counter for on-air drafts (decrements: -1, -2, …)
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
@@ -4284,18 +4285,6 @@ func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
// the session. The session is RAM-only — closing the app mid-net drops any
// active stations that were never logged.
// netActiveEntry is one station currently on the air in the open net.
type netActiveEntry struct {
Callsign string `json:"callsign"`
Name string `json:"name"`
QTH string `json:"qth"`
Country string `json:"country"`
RSTSent string `json:"rst_sent"`
RSTRcvd string `json:"rst_rcvd"`
Comment string `json:"comment"`
TimeOn time.Time `json:"time_on"`
}
// NetList returns all nets (with rosters), ordered by name.
func (a *App) NetList() []netctl.Net {
if a.netStore == nil {
@@ -4410,97 +4399,26 @@ func (a *App) NetOpenID() string {
}
// NetActiveList returns the stations currently on the air, in check-in order.
func (a *App) NetActiveList() []netActiveEntry {
// Each is a full QSO *draft* (not yet in the DB) carrying a negative transient
// id so the same QSOEditModal as Recent QSOs can edit every field.
func (a *App) NetActiveList() []qso.QSO {
a.netMu.Lock()
defer a.netMu.Unlock()
out := make([]netActiveEntry, len(a.netActive))
out := make([]qso.QSO, len(a.netActive))
for i, e := range a.netActive {
out[i] = *e
}
return out
}
// NetActivate puts a station on the air (records time_on, seeds defaults from
// the net + roster). No-op if already active. The net must be open.
func (a *App) NetActivate(callsign string) (netActiveEntry, error) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return netActiveEntry{}, fmt.Errorf("callsign required")
}
a.netMu.Lock()
defer a.netMu.Unlock()
if a.netOpenID == "" {
return netActiveEntry{}, fmt.Errorf("no net open")
}
for _, e := range a.netActive {
if e.Callsign == call {
return *e, nil // already on the air
}
}
e := &netActiveEntry{Callsign: call, TimeOn: time.Now().UTC()}
if net, ok := a.netStore.Get(a.netOpenID); ok {
e.RSTSent, e.RSTRcvd, e.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment
for _, st := range net.Stations {
if strings.EqualFold(st.Callsign, call) {
e.Name, e.QTH, e.Country = st.Name, st.QTH, st.Country
break
}
}
}
if e.RSTSent == "" {
e.RSTSent = "59"
}
if e.RSTRcvd == "" {
e.RSTRcvd = "59"
}
a.netActive = append(a.netActive, e)
return *e, nil
}
// NetUpdateActive edits the live fields (report/QTH/name/comment) of a station
// already on the air. TimeOn is preserved.
func (a *App) NetUpdateActive(e netActiveEntry) error {
call := strings.ToUpper(strings.TrimSpace(e.Callsign))
a.netMu.Lock()
defer a.netMu.Unlock()
for _, cur := range a.netActive {
if cur.Callsign == call {
cur.Name, cur.QTH, cur.Country = e.Name, e.QTH, e.Country
cur.RSTSent, cur.RSTRcvd, cur.Comment = e.RSTSent, e.RSTRcvd, e.Comment
return nil
}
}
return fmt.Errorf("station not active")
}
// NetDeactivate ends a station's QSO: it logs the contact to the active logbook
// (live CAT freq/mode, time_on→now) and removes it from the session. Returns
// the new QSO id.
func (a *App) NetDeactivate(callsign string) (int64, error) {
call := strings.ToUpper(strings.TrimSpace(callsign))
a.netMu.Lock()
var entry *netActiveEntry
idx := -1
for i, e := range a.netActive {
if e.Callsign == call {
entry, idx = e, i
break
}
}
if entry == nil {
a.netMu.Unlock()
return 0, fmt.Errorf("station not active")
}
a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...)
a.netMu.Unlock()
// Frequency/mode come live from the rig; fall back to the last UI-reported
// values when CAT is off.
// netLiveFreq returns the rig's live freq/band/mode, falling back to the last
// UI-reported values when CAT is off.
func (a *App) netLiveFreq() (freq int64, band, mode string) {
var st cat.RigState
if a.cat != nil {
st = a.cat.State()
}
freq, band, mode := st.FreqHz, st.Band, st.Mode
freq, band, mode = st.FreqHz, st.Band, st.Mode
if freq == 0 {
a.liveActMu.Lock()
freq, band, mode = a.liveFreqHz, a.liveBand, a.liveMode
@@ -4509,22 +4427,130 @@ func (a *App) NetDeactivate(callsign string) (int64, error) {
if band == "" && freq > 0 {
band = bandForHz(freq)
}
q := qso.QSO{
Callsign: call,
QSODate: entry.TimeOn,
QSODateOff: time.Now().UTC(),
Band: band,
Mode: mode,
RSTSent: entry.RSTSent,
RSTRcvd: entry.RSTRcvd,
Name: entry.Name,
QTH: entry.QTH,
Comment: entry.Comment,
return
}
// NetActivate puts a station on the air: it builds a QSO draft (time_on now,
// live freq/mode, defaults + roster info) with a transient negative id and
// returns it. No-op (returns the existing draft) if already active.
func (a *App) NetActivate(callsign string) (qso.QSO, error) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return qso.QSO{}, fmt.Errorf("callsign required")
}
a.netMu.Lock()
defer a.netMu.Unlock()
if a.netOpenID == "" {
return qso.QSO{}, fmt.Errorf("no net open")
}
for _, e := range a.netActive {
if strings.EqualFold(e.Callsign, call) {
return *e, nil // already on the air
}
}
a.netSeq--
q := &qso.QSO{ID: a.netSeq, Callsign: call, QSODate: time.Now().UTC()}
if net, ok := a.netStore.Get(a.netOpenID); ok {
q.RSTSent, q.RSTRcvd, q.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment
for _, st := range net.Stations {
if strings.EqualFold(st.Callsign, call) {
q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country
if st.DXCC != 0 {
d := st.DXCC
q.DXCC = &d
}
if st.CQ != 0 {
c := st.CQ
q.CQZ = &c
}
if st.ITU != 0 {
i := st.ITU
q.ITUZ = &i
}
break
}
}
}
if q.RSTSent == "" {
q.RSTSent = "59"
}
if q.RSTRcvd == "" {
q.RSTRcvd = "59"
}
freq, band, mode := a.netLiveFreq()
q.Band, q.Mode = band, mode
if freq > 0 {
f := freq
q.FreqHz = &f
}
a.applyDXCCNumber(q) // fill country/dxcc/zones for display
a.refineDistrictZones(q)
a.netActive = append(a.netActive, q)
return *q, nil
}
// NetUpdateActive replaces an on-air QSO draft (matched by its transient id)
// with the edited version from the QSOEditModal. Lets the operator change every
// field of a station before it's logged.
func (a *App) NetUpdateActive(q qso.QSO) error {
a.netMu.Lock()
defer a.netMu.Unlock()
for i, cur := range a.netActive {
if cur.ID == q.ID {
qq := q
a.netActive[i] = &qq
return nil
}
}
return fmt.Errorf("station not active")
}
// NetDiscardActive removes an on-air draft (by transient id) WITHOUT logging it
// — i.e. cancel a station added by mistake (the modal's Delete button).
func (a *App) NetDiscardActive(id int64) error {
a.netMu.Lock()
defer a.netMu.Unlock()
for i, e := range a.netActive {
if e.ID == id {
a.netActive = append(a.netActive[:i], a.netActive[i+1:]...)
return nil
}
}
return nil
}
// NetDeactivate ends a station's QSO (by transient id): it logs the draft to the
// active logbook (time_off = now; freq/mode refreshed from the rig only if the
// draft still has none, so manual edits are respected) and removes it from the
// session. Returns the new QSO id.
func (a *App) NetDeactivate(id int64) (int64, error) {
a.netMu.Lock()
var draft *qso.QSO
idx := -1
for i, e := range a.netActive {
if e.ID == id {
draft, idx = e, i
break
}
}
if draft == nil {
a.netMu.Unlock()
return 0, fmt.Errorf("station not active")
}
a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...)
a.netMu.Unlock()
q := *draft
q.ID = 0 // transient id must not reach the DB (AddQSO inserts a fresh row)
q.QSODateOff = time.Now().UTC()
if q.FreqHz == nil && q.Band == "" {
freq, band, mode := a.netLiveFreq()
q.Band, q.Mode = band, mode
if freq > 0 {
f := freq
q.FreqHz = &f
}
}
return a.AddQSO(q)
}
+1 -1
View File
@@ -3811,7 +3811,7 @@ export default function App() {
tune the rig. */}
{netEnabled && (
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
<NetControlPanel onLogged={refresh} rstChoices={rstOptions(mode, rstLists)} />
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
</TabsContent>
)}
+64 -53
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type RowDoubleClickedEvent, type CellValueChangedEvent,
type ColDef,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
@@ -12,10 +12,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { QSOEditModal } from '@/components/QSOEditModal';
import type { QSOForm } from '@/types';
import {
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive,
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
} from '@/../wailsjs/go/main/App';
import { netctl } from '@/../wailsjs/go/models';
@@ -45,10 +47,6 @@ const hamlogTheme = themeQuartz.withParams({
type Net = netctl.Net;
type Station = netctl.Station;
type Active = {
callsign: string; name: string; qth: string; country: string;
rst_sent: string; rst_rcvd: string; comment: string; time_on: any;
};
function fmtTimeOn(s: any): string {
if (!s) return '';
@@ -60,14 +58,24 @@ function fmtTimeOn(s: any): string {
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => void; rstChoices?: string[] }) {
type Props = {
onLogged?: () => void;
countries?: string[];
bands?: string[];
modes?: string[];
};
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const [nets, setNets] = useState<Net[]>([]);
const [selId, setSelId] = useState<string>('');
const [openId, setOpenId] = useState<string>('');
const [roster, setRoster] = useState<Station[]>([]);
const [active, setActive] = useState<Active[]>([]);
const [active, setActive] = useState<QSOForm[]>([]);
const [error, setError] = useState('');
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
// Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false);
const [contact, setContact] = useState<Station>(emptyStation());
@@ -95,7 +103,7 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
}, []);
const refreshActive = useCallback(async () => {
try { setActive(((await NetActiveList()) ?? []) as Active[]); }
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
catch { /* ignore */ }
}, []);
@@ -103,9 +111,12 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
// The roster side hides callsigns that are currently on the air (they live in
// the left grid until logged), mirroring Log4OM's two-list behaviour.
const activeCalls = useMemo(() => new Set(active.map((a) => a.callsign.toUpperCase())), [active]);
// The roster side hides callsigns currently on the air (they live in the left
// grid until logged), mirroring Log4OM's two-list behaviour.
const activeCalls = useMemo(
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
[active],
);
const rosterShown = useMemo(
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
[roster, activeCalls],
@@ -151,28 +162,20 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Active → logged (end QSO, removed from session, written to the logbook).
async function deactivate(call: string) {
if (!call) return;
try { await NetDeactivate(call); await refreshActive(); onLogged?.(); }
async function deactivate(id?: number) {
if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
function onActiveDblClick(e: RowDoubleClickedEvent<Active>) {
// Double-clicking a non-editable area logs the QSO; editable cells open the
// editor instead (ag-grid handles that before this fires only for blanks),
// so we gate on the column being the callsign.
if (e.data && (e as any).column?.getColId?.() === 'callsign') deactivate(e.data.callsign);
// Edit-modal handlers (operate on the in-memory draft, not the DB).
async function saveDraft(q: QSOForm) {
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function onActiveCellChanged(e: CellValueChangedEvent<Active>) {
const d = e.data;
if (!d) return;
try {
await NetUpdateActive({
callsign: d.callsign, name: d.name ?? '', qth: d.qth ?? '', country: d.country ?? '',
rst_sent: d.rst_sent ?? '', rst_rcvd: d.rst_rcvd ?? '', comment: d.comment ?? '', time_on: d.time_on,
} as any);
} catch (err: any) { setError(String(err?.message ?? err)); }
async function discardDraft(id: number) {
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Add-contact dialog.
@@ -210,22 +213,17 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
} catch (e: any) { setError(String(e?.message ?? e)); }
}
const activeCols = useMemo<ColDef<Active>[]>(() => [
{ colId: 'callsign', headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: 'Name', field: 'name', flex: 1, editable: true },
{ headerName: 'QTH', field: 'qth', flex: 1, editable: true },
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn(p.data?.time_on), width: 90, cellClass: 'font-mono text-[11px]' },
{ headerName: 'Country', field: 'country', width: 120 },
{
headerName: 'RST S', field: 'rst_sent', width: 80, editable: true, cellClass: 'font-mono',
cellEditor: 'agSelectCellEditor', cellEditorParams: { values: rstChoices ?? [] },
},
{
headerName: 'RST R', field: 'rst_rcvd', width: 80, editable: true, cellClass: 'font-mono',
cellEditor: 'agSelectCellEditor', cellEditorParams: { values: rstChoices ?? [] },
},
{ headerName: 'Comment', field: 'comment', flex: 1.5, editable: true },
], [rstChoices]);
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: 'Name', field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
{ headerName: 'Band', field: 'band', width: 70, cellClass: 'font-mono' },
{ headerName: 'Mode', field: 'mode', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
{ headerName: 'Comment', field: 'comment', flex: 1.5 },
], []);
const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
@@ -275,28 +273,27 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
On air active QSOs
<span className="ml-auto font-normal normal-case opacity-90">double-click callsign log &amp; end QSO</span>
<span className="ml-auto font-normal normal-case opacity-90">double-click edit all fields · "Log &amp; end" to save</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<Active>
<AgGridReact<QSOForm>
ref={activeGrid}
theme={hamlogTheme}
rowData={active}
columnDefs={activeCols}
defaultColDef={defaultColDef}
onRowDoubleClicked={onActiveDblClick}
onCellValueChanged={onActiveCellChanged}
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false}
getRowId={(p) => String((p.data as any).callsign)}
stopEditingWhenCellsLoseFocus
getRowId={(p) => String((p.data as any).id)}
/>
</div>
</div>
{isOpen && active.length > 0 && (
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as Active; if (r) deactivate(r.callsign); }}>
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
<MinusCircle className="size-3.5" /> Log &amp; end selected
</Button>
</div>
@@ -341,6 +338,20 @@ export function NetControlPanel({ onLogged, rstChoices }: { onLogged?: () => voi
</div>
</div>
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
{editingDraft && (
<QSOEditModal
qso={editingDraft}
onSave={saveDraft}
onDelete={discardDraft}
onClose={() => setEditingDraft(null)}
countries={countries}
bands={bands}
modes={modes}
/>
)}
{/* Add / edit contact dialog */}
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
<DialogContent className="max-w-md">
+6 -4
View File
@@ -372,18 +372,20 @@ export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function MoveDatabase(arg1:string):Promise<void>;
export function NetActivate(arg1:string):Promise<main.netActiveEntry>;
export function NetActivate(arg1:string):Promise<qso.QSO>;
export function NetActiveList():Promise<Array<main.netActiveEntry>>;
export function NetActiveList():Promise<Array<qso.QSO>>;
export function NetClose():Promise<void>;
export function NetCreate(arg1:string):Promise<netctl.Net>;
export function NetDeactivate(arg1:string):Promise<number>;
export function NetDeactivate(arg1:number):Promise<number>;
export function NetDelete(arg1:string):Promise<void>;
export function NetDiscardActive(arg1:number):Promise<void>;
export function NetList():Promise<Array<netctl.Net>>;
export function NetLookup(arg1:string):Promise<netctl.Station>;
@@ -402,7 +404,7 @@ export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
export function NetUpdateActive(arg1:main.netActiveEntry):Promise<void>;
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function OpenADIFFile():Promise<string>;
+4
View File
@@ -734,6 +734,10 @@ export function NetDelete(arg1) {
return window['go']['main']['App']['NetDelete'](arg1);
}
export function NetDiscardActive(arg1) {
return window['go']['main']['App']['NetDiscardActive'](arg1);
}
export function NetList() {
return window['go']['main']['App']['NetList']();
}
-45
View File
@@ -2004,51 +2004,6 @@ export namespace main {
return a;
}
}
export class netActiveEntry {
callsign: string;
name: string;
qth: string;
country: string;
rst_sent: string;
rst_rcvd: string;
comment: string;
// Go type: time
time_on: any;
static createFrom(source: any = {}) {
return new netActiveEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.callsign = source["callsign"];
this.name = source["name"];
this.qth = source["qth"];
this.country = source["country"];
this.rst_sent = source["rst_sent"];
this.rst_rcvd = source["rst_rcvd"];
this.comment = source["comment"];
this.time_on = this.convertValues(source["time_on"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}