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"];
}
}
+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