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; filter_lo: number; filter_hi: number;
rit: boolean; rit_offset: number; xit: boolean; xit_offset: number; lock: boolean; split: boolean; rit: boolean; rit_offset: number; xit: boolean; xit_offset: number; lock: boolean; split: boolean;
smeter: number; modulations?: string[]; smeter: number; modulations?: string[];
tx_power_w: number; tx_swr: number;
}; };
const ZERO: TCIState = { const ZERO: TCIState = {
@@ -30,6 +31,7 @@ const ZERO: TCIState = {
volume: 0, mute: false, squelch_on: false, squelch: 0, volume: 0, mute: false, squelch_on: false, squelch: 0,
nb: false, nr: false, anf: false, apf: false, filter_lo: 0, filter_hi: 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, 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 // 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>} {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>} {!!err && <div className="text-[11px] text-danger px-1">{err}</div>}
{/* Meters — one for now: TCI reports the receive level and does not {/* Meters. The S-meter while receiving, power and SWR while
publish a transmit power reading, so a PWR bar here would be an transmitting — the radio answers TX_POWER and TX_SWR only when it is
empty promise. */} 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')}> <Card icon={Activity} title={t('tcip.meters')}>
<MeterBar label="S-METER" value={st.tx ? 0 : sBar(st.smeter)} lo={0} hi={100} <MeterBar label="S-METER" value={st.tx ? 0 : sBar(st.smeter)} lo={0} hi={100}
accent="#16a34a" segColor={sSegColor} accent="#16a34a" segColor={sSegColor}
@@ -277,6 +282,18 @@ export function TCIPanel({ onReportRST }: { onReportRST?: (rst: string) => void
onReportRST(sMeterRST(s.s, s.over, mode)); onReportRST(sMeterRST(s.s, s.over, mode));
}} }}
title={t('tcip.sMeterHint')} /> 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> </Card>
{/* Transmit */} {/* Transmit */}
+4
View File
@@ -1238,6 +1238,8 @@ export namespace cat {
lock: boolean; lock: boolean;
split: boolean; split: boolean;
smeter: number; smeter: number;
tx_power_w: number;
tx_swr: number;
modulations?: string[]; modulations?: string[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -1273,6 +1275,8 @@ export namespace cat {
this.lock = source["lock"]; this.lock = source["lock"];
this.split = source["split"]; this.split = source["split"];
this.smeter = source["smeter"]; this.smeter = source["smeter"];
this.tx_power_w = source["tx_power_w"];
this.tx_swr = source["tx_swr"];
this.modulations = source["modulations"]; this.modulations = source["modulations"];
} }
} }
+12
View File
@@ -304,6 +304,18 @@ func (t *TCI) ReadState() (RigState, error) {
} else { } else {
st.FreqHz = t.freqA 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) st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
if st.FreqHz > 0 { if st.FreqHz > 0 {
st.Band = BandFromHz(st.FreqHz) st.Band = BandFromHz(st.FreqHz)
+19
View File
@@ -68,6 +68,17 @@ type TCIPanelState struct {
// several times a second while receiving. // several times a second while receiving.
SMeter int `json:"smeter"` 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 // Modulations is what this radio will accept, straight from its own
// announcement, so the mode buttons are the radio's and not a guess. // announcement, so the mode buttons are the radio's and not a guess.
Modulations []string `json:"modulations,omitempty"` Modulations []string `json:"modulations,omitempty"`
@@ -194,6 +205,14 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
if forRX0() { if forRX0() {
p.Lock = yes(get(1)) 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": case "rx_smeter":
if n, ok := num(get(1)); ok && forRX0() { if n, ok := num(get(1)); ok && forRX0() {
p.SMeter = n p.SMeter = n