From 9a3a37e31c398fb31b952839ca5876aa2ded2af9 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 8 Apr 2026 10:24:58 +0200 Subject: [PATCH] control: improve runtime visibility and stop UX --- cmd/fmrtx/main.go | 2 ++ internal/app/engine.go | 8 ++++++++ internal/control/control.go | 9 ++++----- internal/control/ui.html | 34 ++++++++++++++++++++++++++++++---- internal/rds/encoder.go | 6 ++++++ 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 98a8e88..fdde1e4 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -330,6 +330,8 @@ func (b *txBridge) TXStats() map[string]any { "runtimeIndicator": s.RuntimeIndicator, "runtimeAlert": s.RuntimeAlert, "appliedFrequencyMHz": s.AppliedFrequencyMHz, + "activePS": s.ActivePS, + "activeRadioText": s.ActiveRadioText, "degradedTransitions": s.DegradedTransitions, "mutedTransitions": s.MutedTransitions, "faultedTransitions": s.FaultedTransitions, diff --git a/internal/app/engine.go b/internal/app/engine.go index b4a7707..39b9a9f 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -88,6 +88,8 @@ type EngineStats struct { RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` RuntimeAlert string `json:"runtimeAlert,omitempty"` AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"` + ActivePS string `json:"activePS,omitempty"` + ActiveRadioText string `json:"activeRadioText,omitempty"` LastFault *FaultEvent `json:"lastFault,omitempty"` DegradedTransitions uint64 `json:"degradedTransitions"` MutedTransitions uint64 `json:"mutedTransitions"` @@ -429,6 +431,10 @@ func (e *Engine) Stats() EngineStats { hasRecentLateBuffers := e.hasRecentLateBuffers() ri := runtimeIndicator(queue.Health, hasRecentLateBuffers) lastFault := e.lastFaultEvent() + activePS, activeRT := "", "" + if enc := e.generator.RDSEncoder(); enc != nil { + activePS, activeRT = enc.CurrentText() + } return EngineStats{ State: string(e.currentRuntimeState()), RuntimeStateDurationSeconds: e.runtimeStateDurationSeconds(), @@ -448,6 +454,8 @@ func (e *Engine) Stats() EngineStats { RuntimeIndicator: ri, RuntimeAlert: runtimeAlert(queue.Health, hasRecentLateBuffers), AppliedFrequencyMHz: e.appliedFrequencyMHz(), + ActivePS: activePS, + ActiveRadioText: activeRT, LastFault: lastFault, DegradedTransitions: e.degradedTransitions.Load(), MutedTransitions: e.mutedTransitions.Load(), diff --git a/internal/control/control.go b/internal/control/control.go index 1672874..0af6741 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -474,12 +474,11 @@ func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) { http.Error(w, "tx controller not available", http.StatusServiceUnavailable) return } - if err := tx.StopTX(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + go func() { + _ = tx.StopTX() + }() w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stopped"}) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": "stop-requested"}) } func (s *Server) handleDryRun(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/control/ui.html b/internal/control/ui.html index ccb6dc3..aa64eaa 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -821,7 +821,29 @@ async function sendPatch(patch,{ok='Applied',clearKeys=[]}={}){beginReq();try{co async function applySection(sec){if(secErrors(sec)){toast('Fix validation errors first','warn');return;}const patch=secPatch(sec),keys=Object.keys(patch);if(!keys.length){toast('No changes','info');return;}const msg=sec==='freq'?'Frequency updated':sec==='rds'?'RDS text updated':'Applied';await sendPatch(patch,{ok:msg,clearKeys:keys});} function resetSection(sec){clearDirty(Object.keys(FIELDS).filter(k=>FIELDS[k].section===sec));toast('Draft reset','info');} async function applyCfgSection(sec){if(sec==='rds-id'&&S.cfgErrors?.pi){toast('Fix validation errors first','warn');return;}const patch=cfgPatch(sec);if(!Object.keys(patch).length){toast('No changes','info');return;}const hasR=cfgHasRestart(sec);beginReq();try{const res=await api('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});Object.keys(CFG).filter(k=>CFG[k].sec===sec).forEach(k=>delete S.cfgDraft[k]);S.cfgDirty[sec]=false;if(sec==='rds-id'&&S.cfgErrors)delete S.cfgErrors.pi;toast(hasR?'Saved (restart required)':'Applied live','ok');log('CFG '+sec+' '+JSON.stringify(patch)+(hasR?' [restart]':' [live]'),'ok');await Promise.allSettled([loadConfig({silent:true}),loadRuntime({silent:true})]);return res;}catch(e){toast(e.message,'err');log('CFG failed: '+e.message,'err');throw e;}finally{endReq();}} -async function txAction(action){if(S.txBusy)return;S.txBusy=true;render();beginReq();try{await api(`/tx/${action}`,{method:'POST'});toast(action==='start'?'TX started':'TX stopped','ok');log('TX '+action,'ok');await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]);}catch(e){toast(e.message,'err');log('TX '+action+' failed: '+e.message,'err');}finally{S.txBusy=false;endReq();render();}} +async function txAction(action){ + if(S.txBusy)return; + S.txBusy=true; + if(action==='stop'&&S.server.runtime){ + S.server.runtime.engine={...(S.server.runtime.engine||{}),state:'stopping'}; + } + render(); + beginReq(); + try{ + log('TX '+action+' requested','info'); + await api(`/tx/${action}`,{method:'POST'}); + toast(action==='start'?'TX started':'TX stop requested','ok'); + log('TX '+action+' accepted','ok'); + await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]); + }catch(e){ + toast(e.message,'err'); + log('TX '+action+' failed: '+e.message,'err'); + }finally{ + S.txBusy=false; + endReq(); + render(); + } +} async function resetFault(){if(S.faultBusy)return;S.faultBusy=true;render();beginReq();try{await api('/runtime/fault/reset',{method:'POST'});toast('Fault reset','ok');log('Fault reset','ok');await loadRuntime({silent:true});}catch(e){toast(e.message,'err');log('Fault reset failed: '+e.message,'err');}finally{S.faultBusy=false;endReq();render();}} async function setToggle(key,val){if(S.toggleBusy[key])return;S.toggleBusy[key]=true;render();try{await sendPatch({[key]:val},{ok:key.replace(/Enabled$/,'')+' '+(val?'enabled':'disabled')});}finally{S.toggleBusy[key]=false;render();}} async function saveIngest(){if(S.ingestSaving)return;if(!S.ingestDirty){toast('No changes','info');return;}S.ingestSaving=true;S.ingestError='';beginReq();render();try{const res=await api('/config/ingest/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ingest:S.ingestDraft})});S.ingestDirty=false;toast(res.reloadScheduled?'Saved, reloading…':'Saved','ok');log('Ingest saved'+(res.reloadScheduled?' [reload]':''),'ok');if(res.reloadScheduled)setTimeout(()=>location.reload(),1500);}catch(e){S.ingestError=e.message;toast(e.message,'err');log('Ingest save failed: '+e.message,'err');}finally{S.ingestSaving=false;endReq();render();}} @@ -856,7 +878,9 @@ function _render(){ $('tx-state').textContent=S.txBusy?'WORKING':txSt.toUpperCase(); $('tx-state').className='tx-state '+(S.txBusy?'working':txSt); setText('tx-hint',eng.lastError?`Last error: ${eng.lastError}`:S.txBusy?'Command in progress':'Runtime polled every 1s'); - const startDis=S.txBusy||txSt==='running',stopDis=S.txBusy||['idle','stopped',''].includes(txSt); + const canStopStates=['running','arming','prebuffering','degraded','muted','faulted','stopping']; + const startDis=S.txBusy||txSt==='running'; + const stopDis=S.txBusy||!canStopStates.includes(txSt); $('btn-start').disabled=startDis;$('btn-stop').disabled=stopDis;$('btn-refresh').disabled=S.pending>0; // ── Overview: meters + sparklines @@ -908,7 +932,7 @@ function _render(){ setText('compliance-meta',S.cfgDirty['compliance']?'Draft pending':'Saved + Restart Required'); $('compliance-apply').disabled=!S.cfgDirty['compliance'];$('compliance-reset').disabled=!S.cfgDirty['compliance']; // Danger - $('danger-stop').disabled=stopDis;const rfl=$('danger-reset-fault');if(rfl){rfl.disabled=S.faultBusy||!S.server.runtimeOk;rfl.textContent=S.faultBusy?'Resetting...':'Reset Fault';} + $('danger-stop').disabled=S.txBusy;const rfl=$('danger-reset-fault');if(rfl){rfl.disabled=S.faultBusy||!S.server.runtimeOk;rfl.textContent=S.faultBusy?'Resetting...':'Reset Fault';} const rh=$('reset-hint');if(rh){const sn=normState(eng.state);rh.textContent=sn==='faulted'?'Faulted: reset moves runtime back to DEGRADED.':sn==='muted'||sn==='degraded'?'Reset Fault holds at DEGRADED until queue recovers.':'Manual fault reset drops to DEGRADED while queue recovers.';} // ── RDS tab @@ -934,8 +958,10 @@ function _render(){ syncSlider('rdsinj-slider','rdsinj-val','rdsInjection',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%'); const lvlDirty=!!S.cfgDirty['rds-lvl'];$('rds-levels-apply').disabled=!lvlDirty;$('rds-levels-reset').disabled=!lvlDirty; // Status card + const activePS=String(eng.activePS||cfg.rds?.ps||'').trim(); + const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim(); setText('rds-stat-enabled',cfg.rds?.enabled?'ON':'OFF');setText('rds-stat-pi',fmtPI(cfg.rds?.pi)); - setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',cfg.rds?.ps||'--');setText('rds-stat-rt',cfg.rds?.radioText||'--'); + setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',activePS||'--');setText('rds-stat-rt',activeRT||'--'); setText('rds-stat-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('rds-stat-inj',fmtPilot(cfg.fm?.rdsInjection)); // ── Ingest tab diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index 266e572..b4e3fa6 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -195,6 +195,12 @@ func (e *Encoder) ClearRT() { e.liveRT.Store(pendingText{val: "", set: true}) } +// CurrentText returns the currently active PS and RT from the encoder scheduler. +// It reflects the last text applied at an RDS group boundary. +func (e *Encoder) CurrentText() (ps, rt string) { + return e.scheduler.cfg.PS, e.scheduler.cfg.RT +} + // NextSample returns the next RDS subcarrier sample at the configured rate. // Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier // for phase-locked operation in a stereo MPX chain.