feat(tci): the transmit meters, and no temperature invented

TX_POWER and TX_SWR are read-only commands the radio answers when asked,
so they are asked for on each poll of a KEYED radio and not at all
otherwise: a receiving station pays nothing for meters nobody is watching.
Both appear next to the S-meter only while transmitting, because showing
them the rest of the time would show the last thing that happened as if it
were now.

An SWR of 0 draws as '—' rather than as 1.0. A perfect match on an antenna
nobody has measured is the one reading an operator should never be handed.

There is no temperature. The protocol's command list has TX_POWER and
TX_SWR and nothing thermal at all — so rather than leave the question
hanging, it is written down where the next person will look for it. A
temperature invented from something else, on a transmitter, is exactly the
kind of number somebody would trust.
This commit is contained in:
2026-08-26 21:14:58 +02:00
parent 01d0b8f22b
commit 1201b44908
7 changed files with 66 additions and 14 deletions
+20 -3
View File
@@ -23,6 +23,7 @@ type TCIState = {
filter_lo: number; filter_hi: number;
rit: boolean; rit_offset: number; xit: boolean; xit_offset: number; lock: boolean; split: boolean;
smeter: number; modulations?: string[];
tx_power_w: number; tx_swr: number;
};
const ZERO: TCIState = {
@@ -30,6 +31,7 @@ const ZERO: TCIState = {
volume: 0, mute: false, squelch_on: false, squelch: 0,
nb: false, nr: false, anf: false, apf: false, filter_lo: 0, filter_hi: 0,
rit: false, rit_offset: 0, xit: false, xit_offset: 0, lock: false, split: false, smeter: 0,
tx_power_w: 0, tx_swr: 0,
};
// The widths worth a button, PER MODE — because 250 Hz is useless in SSB and
@@ -265,9 +267,12 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{off && <div className="text-xs text-muted-foreground px-1">{t('tcip.waiting')}</div>}
{!!err && <div className="text-[11px] text-danger px-1">{err}</div>}
{/* Meters — one for now: TCI reports the receive level and does not
publish a transmit power reading, so a PWR bar here would be an
empty promise. */}
{/* Meters. The S-meter while receiving, power and SWR while
transmitting — the radio answers TX_POWER and TX_SWR only when it is
keyed, so showing them the rest of the time would be showing the
last thing that happened as if it were now.
There is no temperature: the protocol has no such command, and a
made-up figure on a transmitter is the kind somebody trusts. */}
<Card icon={Activity} title={t('tcip.meters')}>
<MeterBar label="S-METER" value={st.tx ? 0 : sBar(st.smeter)} lo={0} hi={100}
accent="#16a34a" segColor={sSegColor}
@@ -277,6 +282,18 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
onReportRST(sMeterRST(s.s, s.over, mode));
}}
title={t('tcip.sMeterHint')} />
{st.tx && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<MeterBar label="PWR" value={st.tx_power_w} lo={0} hi={Math.max(10, Math.ceil(st.tx_power_w / 10) * 10)}
accent="#0ea5e9" display={`${st.tx_power_w.toFixed(1)} W`} />
{/* 0 is "not measured yet", and it must not draw as a perfect
match: an SWR of 1.0 on an antenna nobody has measured is the
one reading an operator should not be handed. */}
<MeterBar label="SWR" value={st.tx_swr > 0 ? Math.min(100, (st.tx_swr - 1) * 50) : 0} lo={0} hi={100}
accent="#f59e0b" display={st.tx_swr > 0 ? st.tx_swr.toFixed(1) : '—'}
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
</div>
)}
</Card>
{/* Transmit */}
+4
View File
@@ -1238,6 +1238,8 @@ export namespace cat {
lock: boolean;
split: boolean;
smeter: number;
tx_power_w: number;
tx_swr: number;
modulations?: string[];
static createFrom(source: any = {}) {
@@ -1273,6 +1275,8 @@ export namespace cat {
this.lock = source["lock"];
this.split = source["split"];
this.smeter = source["smeter"];
this.tx_power_w = source["tx_power_w"];
this.tx_swr = source["tx_swr"];
this.modulations = source["modulations"];
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ type Flex struct {
meterRawLogged bool // log the first raw meter-definition status once
txRawLogged bool // log the first raw transmit status once (field-name audit)
spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotsEnabled bool // push cluster spots + manage the panadapter overlay
// foreignSpotSeen counts what probeForeignSpot has already reported, so a
// skimmer posting all evening cannot turn the log into its own transcript.
foreignSpotSeen int
+3 -3
View File
@@ -98,11 +98,11 @@ type Kenwood struct {
// Panel state — the K3/K4 control panel, see kenwood_panel.go. Read on the
// same serialised link as everything else, on a slow beat for the settings
// and every poll for the meters.
panel KenwoodTXState
panel KenwoodTXState
// The icon/status word, for working out which bit says "ATU in line" — see
// probeIcons. Kept so only CHANGES are logged.
lastIcons string
iconProbes int
lastIcons string
iconProbes int
panelCycle int
panelLoaded bool
metersLogged int
+12
View File
@@ -304,6 +304,18 @@ func (t *TCI) ReadState() (RigState, error) {
} else {
st.FreqHz = t.freqA
}
// The transmit meters are asked for, not pushed: TX_POWER and TX_SWR are
// read-only commands the radio answers when asked, and asking is only worth
// anything while it is keyed. Fired and forgotten from here — the answers
// arrive on the reader like everything else — and only while transmitting,
// so a receiving station pays nothing for a meter nobody is watching.
if t.tx {
tx := t
go func() {
_ = tx.send("tx_power;")
_ = tx.send("tx_swr;")
}()
}
st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
if st.FreqHz > 0 {
st.Band = BandFromHz(st.FreqHz)
+19
View File
@@ -68,6 +68,17 @@ type TCIPanelState struct {
// several times a second while receiving.
SMeter int `json:"smeter"`
// TXPowerW and TXSWR are the transmit meters. READ-ONLY in TCI, and only
// answered while transmitting — asked for on every poll of a keyed radio,
// see ReadState.
//
// There is no temperature in this protocol. The command list has TX_POWER
// and TX_SWR and nothing thermal at all, so a temperature reading here would
// have to be invented, and an invented temperature on a transmitter is the
// kind of number somebody trusts.
TXPowerW float64 `json:"tx_power_w"`
TXSWR float64 `json:"tx_swr"`
// Modulations is what this radio will accept, straight from its own
// announcement, so the mode buttons are the radio's and not a guess.
Modulations []string `json:"modulations,omitempty"`
@@ -194,6 +205,14 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
if forRX0() {
p.Lock = yes(get(1))
}
case "tx_power":
if v, err := strconv.ParseFloat(strings.TrimSpace(get(0)), 64); err == nil {
p.TXPowerW = v
}
case "tx_swr":
if v, err := strconv.ParseFloat(strings.TrimSpace(get(0)), 64); err == nil {
p.TXSWR = v
}
case "rx_smeter":
if n, ok := num(get(1)); ok && forRX0() {
p.SMeter = n
+7 -7
View File
@@ -51,13 +51,13 @@ type YaesuTXState struct {
// NarrowSupported says the rig answered NA at all. A button that reports a
// state the radio never gave, and does nothing when pressed, is worse than
// an absent one: it looks like a fault in the radio.
NarrowSupported bool `json:"narrow_supported"`
MicGain int `json:"mic_gain"` // 0-100
AFGain int `json:"af_gain"` // 0-100
RFGain int `json:"rf_gain"` // 0-100
Squelch int `json:"squelch"` // 0-100
AGC string `json:"agc,omitempty"`
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
NarrowSupported bool `json:"narrow_supported"`
MicGain int `json:"mic_gain"` // 0-100
AFGain int `json:"af_gain"` // 0-100
RFGain int `json:"rf_gain"` // 0-100
Squelch int `json:"squelch"` // 0-100
AGC string `json:"agc,omitempty"`
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
// Antenna is the selected jack, 1-3, or 0 when the rig has no AN command —
// an FT-891 or FT-991A has a single socket and answers nothing. 0 is what
// tells the panel to draw no selector at all rather than a dead one.