diff --git a/app.go b/app.go index 535e3b4..12dc025 100644 --- a/app.go +++ b/app.go @@ -13460,12 +13460,12 @@ func boolStr(b bool) string { // StationDevice is one configured relay board for the Station Control tab. type StationDevice struct { ID string `json:"id"` - Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay" + Type string `json:"type"` // "webswitch" | "kmtronic" | "dingtian" | "denkovi" | "usbrelay" Name string `json:"name"` Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay - User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none) - Pass string `json:"pass,omitempty"` - Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16) + User string `json:"user,omitempty"` // KMTronic HTTP auth; Dingtian HTTP session ID (blank = none) + Pass string `json:"pass,omitempty"` // KMTronic HTTP auth; Dingtian relay password (0–9999) + Channels int `json:"channels,omitempty"` // usbrelay/denkovi/dingtian: number of relays Labels []string `json:"labels"` // per-relay label (index 0 = relay 1) } @@ -13473,10 +13473,13 @@ type StationDevice struct { // except the Denkovi (4 or 8) and the generic USB-serial board, whose channel // count the user picks. func deviceRelayCount(d StationDevice) int { - if d.Type == "usbrelay" || d.Type == "denkovi" { + if d.Type == "usbrelay" || d.Type == "denkovi" || d.Type == "dingtian" { if d.Channels >= 1 { return d.Channels } + if d.Type == "dingtian" { + return 2 // the smallest board; the real count comes from its status reply + } return 8 } return relayCountFor(d.Type) @@ -13504,6 +13507,9 @@ func buildDeviceDriver(d StationDevice) relaydev.Device { switch d.Type { case "kmtronic": return relaydev.NewKMTronic(d.Host, d.User, d.Pass) + case "dingtian": + // User carries the optional HTTP session ID, Pass the relay password. + return relaydev.NewDingtian(d.Host, d.User, d.Pass, deviceRelayCount(d)) case "denkovi": // Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname. return relaydev.NewDenkovi(d.Host, deviceRelayCount(d)) @@ -13607,7 +13613,7 @@ func (a *App) SaveStationDevices(devs []StationDevice) error { } for i := range devs { switch devs[i].Type { - case "kmtronic", "denkovi", "usbrelay", "webswitch": + case "kmtronic", "dingtian", "denkovi", "usbrelay", "webswitch": // known type — keep default: devs[i].Type = "webswitch" diff --git a/changelog.json b/changelog.json index 48e35e5..0a73db6 100644 --- a/changelog.json +++ b/changelog.json @@ -1,4 +1,20 @@ [ + { + "version": "0.23.0", + "date": "", + "en": [ + "Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.", + "ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.", + "DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.", + "Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on." + ], + "fr": [ + "Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.", + "Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.", + "Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.", + "Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours." + ] + }, { "version": "0.22.9", "date": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8201e1..2a6b298 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2112,11 +2112,22 @@ export default function App() { setModePresets(l.modes); const names = l.modes.map((m) => m.name); setModes(names); - setMode((cur) => names.includes(cur) ? cur : names[0]); - const preset = l.modes.find((m) => m.name === mode) ?? l.modes[0]; - if (preset && !rstUserEditedRef.current) { - if (preset.default_rst_sent) setRstSent(preset.default_rst_sent); - if (preset.default_rst_rcvd) setRstRcvd(preset.default_rst_rcvd); + // Resolve the mode we are ACTUALLY on, then take that mode's report. + // + // This used to read the `mode` state variable, which this callback + // captures once (its dep list is empty) — so it was still the initial + // 'SSB' — and fell back to l.modes[0], SSB again, when the lookup + // missed. An operator who launched OpsLog with the rig already in CW + // and did not touch the VFO got 59/59 and kept it: nothing else runs + // until the CAT state actually changes. + const cur = names.includes(modeRef.current) ? modeRef.current : names[0]; + setMode(cur); + if (!rstUserEditedRef.current) { + const lists: RSTLists = { phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] }; + const p = l.modes.find((m) => m.name === cur); + const fallback = rstOptions(cur, lists)[0] || ''; + setRstSent(p?.default_rst_sent || fallback); + setRstRcvd(p?.default_rst_rcvd || fallback); } } } catch {} diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index f47846e..1ca599c 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -99,47 +99,42 @@ function statusFor(p: any): SpotStatusEntry | undefined { ]; } -// statusBadge maps a resolved spot status to a short labelled badge for the -// Status column, using the same colours as the per-cell fills (NEW DXCC = -// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when -// there's nothing notable to show. -type Badge = { text: string; fg: string; bg: string; bd: string }; +// Spot status is shown by COLOURING THE TEXT, never by a pill or a badge. +// +// The pills were dropped: a rounded box has its own height and padding, so it +// sat off the row's baseline and the callsign inside it no longer lined up with +// the plain callsigns above and below. A whole column of them read as chrome +// rather than as data. Same reasoning — and the same semantic tokens — as the +// Y/N/R QSL columns in lib/qslStatus.ts. +// +// Which cell is coloured IS the message, so one colour is enough for all three: +// +// yellow call → new DXCC yellow band → new band yellow mode → new mode +// blue call → already worked +const NEW = 'var(--warning)'; // yellow: something here is new +const WKD = 'var(--info)'; // blue: this callsign is already in the log -// tok builds a badge from a semantic status token (success/warning/caution/ -// danger/info/neutral) so the colours adapt to every theme via CSS variables, -// instead of the hard-coded light-theme pastels that looked wrong in dark mode. -function tok(name: string, text: string): Badge { - if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' }; - return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` }; -} - -// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the -// Status badges) when the slot is notable, instead of flooding the whole cell with -// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is -// null → plain text, no pill. -function cellChip(value: any, name: string | null): any { +// cellText renders a cell value in an optional colour. An empty value keeps the +// muted dash the grid used before, so blank cells still read as "nothing here" +// rather than as a gap. +function cellText(value: any, color: string | null): any { const txt = value === undefined || value === null || value === '' ? '' : String(value); - if (!name) return txt || ; - // Inherit the column's font size (no fixed 9px / height) so a pill around a - // callsign stays the same size as the plain callsigns next to it — just tinted - // and rounded, not shrunk. - return ( - {txt} - ); + if (!txt) return ; + return {txt}; } -function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null { +// statusColor is the colour for a resolved status in the Status column. County +// and POTA keep their own tokens: they are orthogonal to the band/mode/DXCC +// story and an operator filters on them separately. +function statusColor(s: SpotStatusEntry | undefined): string | null { switch (s?.status) { - case 'new': return tok('danger', t('clg2.newDxcc')); - case 'new-band': return tok('warning', t('clg2.newBand')); - case 'new-mode': return tok('caution', t('clg2.newMode')); - case 'new-slot': return tok('caution', t('clg2.newSlot')); - default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null; + case 'new': + case 'new-band': + case 'new-mode': + case 'new-slot': + return NEW; + default: + return s?.worked_call ? WKD : null; } } @@ -166,15 +161,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120, defaultVisible: true, cellClass: 'font-mono', - // NEW DXCC → a danger pill around the call, consistent with the NEW BAND / - // NEW MODE pills (same look, same tokens). Worked-call stays blue bold text; - // a plain spot inherits the theme's normal text colour so callsigns blend in - // with the rest of the row instead of always shouting a colour. + // NEW DXCC → yellow call. Already worked → blue call. Anything else keeps + // the theme's normal ink so ordinary callsigns don't shout. cellRenderer: (p: any) => { const s = statusFor(p); - if (s?.status === 'new') return cellChip(p.value, 'danger'); - const color = s?.worked_call ? 'var(--info)' : undefined; - return {p.value ?? ''}; + const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null; + return {p.value ?? ''}; }, tooltipValueGetter: (p: any) => { const s = statusFor(p); @@ -185,9 +177,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ group: 'Spot', label: t('clg2.c.status'), colId: 'status', headerName: t('clg2.c.status'), width: 120, sortable: true, defaultVisible: true, - // Spells out the slot status as a text badge so NEW SLOT (and the others) - // is obvious at the row level, not just a single coloured cell. NEW COUNTY - // and NEW POTA are orthogonal, so they stack as extra badges. + // Spells the status out in words so NEW SLOT (and the others) is obvious at + // the row level, not just a single coloured cell — NEW SLOT in particular + // colours no cell at all, since neither the band nor the mode is new on its + // own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it. valueGetter: (p: any) => { const s = statusFor(p); const parts: string[] = []; @@ -202,21 +195,25 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ }, cellRenderer: (p: any) => { const s = statusFor(p); - const badges: Badge[] = []; - const b = statusBadge(t, s); - if (b) badges.push(b); - if (s?.new_county) badges.push(tok('info', t('clg2.newCounty'))); - if (s?.new_pota) badges.push(tok('success', t('clg2.newPota'))); - if (badges.length === 0) return ; + const parts: { text: string; color: string }[] = []; + const main = statusColor(s); + if (main) { + const label = s?.status === 'new' ? t('clg2.newDxcc') + : s?.status === 'new-band' ? t('clg2.newBand') + : s?.status === 'new-mode' ? t('clg2.newMode') + : s?.status === 'new-slot' ? t('clg2.newSlot') + : t('clg2.wkdCall'); + parts.push({ text: label, color: main }); + } + if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--info)' }); + if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' }); + if (parts.length === 0) return ; return ( - - {badges.map((bd, i) => ( - {bd.text} + + {parts.map((pt, i) => ( + + {i > 0 ? ' · ' : ''}{pt.text} + ))} ); @@ -249,8 +246,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ headerName: t('clg2.c.band'), field: 'band' as any, width: 75, defaultVisible: true, cellClass: 'font-mono', - // NEW BAND for this entity → small warning pill around the band text. - cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null), + // NEW BAND for this entity → the band text turns yellow. + cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null), tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined), }, { @@ -263,7 +260,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [ // for the entity. NEW SLOT means band AND mode were each worked before (just // not together), so highlighting the mode cell would wrongly imply "CW is new"; // that case is signalled by the Status badge alone. - cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null), + cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null), tooltipValueGetter: (p: any) => { const st = statusFor(p)?.status; if (st === 'new-mode') return t('clg2.tipNewMode'); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b8d0491..2c3ecc5 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3298,6 +3298,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan + ); diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 118e945..47b5058 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -33,13 +33,15 @@ type DevStatus = { id: string; name: string; type: string; connected: boolean; e const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column const GRID_GAP = 16; // px between cards, both axes -const RELAY_COUNT: Record = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 }; -const TYPE_LABEL: Record = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' }; +const RELAY_COUNT: Record = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 }; +const TYPE_LABEL: Record = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' }; // Relay count for a configured device: fixed by type, except Denkovi (4/8) and // the generic USB-serial board, whose channel count the user picks. const chanCount = (d: Device): number => - (d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5); + (d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) + : d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2) + : (RELAY_COUNT[d.type] ?? 5); function blankDevice(): Device { return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') }; @@ -552,6 +554,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: { onChange({ ...device, channels, labels }); }; const isKM = device.type === 'kmtronic'; + const isDingtian = device.type === 'dingtian'; const isDenkovi = device.type === 'denkovi'; const isUsbRelay = device.type === 'usbrelay'; // COM ports for the generic USB-serial relay picker. @@ -602,6 +605,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: { WebSwitch 1216H (5 relays) KMTronic 8-relay (LAN) + Dingtian IOT relay (LAN / WiFi) Denkovi USB (FT245 / D2XX) Denkovi USB (serial / COM) @@ -612,13 +616,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: { onChange({ ...device, name: e.target.value })} /> - {(isDenkovi || isUsbRelay) && ( + {(isDenkovi || isUsbRelay || isDingtian) && (
onChange({ ...device, host: e.target.value })} />
+ {/* Dingtian: both are OFF on a factory board — the session ID only when + "HTTP Session" is enabled in its web page, the password only when a + relay password is set. */} + {isDingtian && ( + <> +
+ + onChange({ ...device, user: e.target.value })} /> +
+
+ + onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} /> +
+ + )} {isKM && ( <>
diff --git a/frontend/src/version.ts b/frontend/src/version.ts index cc47fc5..06ea451 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,6 +1,6 @@ // Single source of truth for the app version shown in the UI (header + About). // Bump this on a release (the release script updates it alongside telemetry.go). -export const APP_VERSION = '0.22.9'; +export const APP_VERSION = '0.23.0'; // Author / credits, shown in Help -> About. export const APP_AUTHOR = 'F4BPO'; diff --git a/internal/acom/ATU-FINDINGS.md b/internal/acom/ATU-FINDINGS.md new file mode 100644 index 0000000..f615deb --- /dev/null +++ b/internal/acom/ATU-FINDINGS.md @@ -0,0 +1,86 @@ +# ACOM built-in ATU — what we know, and why OpsLog does not drive it + +Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry +trace. Written down so nobody repeats the work. **Conclusion first: the tuner +cannot be started over the serial link, and the attempt to work around that was +abandoned.** + +## The tuner is PA state 8 + +Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the +ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed +twice, front-panel TUNE, with the byte-level trace running: + +``` +14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF +14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz) +14:47:24.006 state 8 step 2 +14:47:25.201 state 8 step 4 +14:47:27.457 state 8 step 5 +14:47:30.092 state 5 STANDBY ← accord finished +``` + +Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`. +Step 0 means *armed, waiting for RF* — in the first of the two runs the +amplifier sat there **30 seconds** untouched before the operator transmitted. + +`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this. + +## The command does not exist in the state-command family + +Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a +**verified** STANDBY (an earlier sweep was worthless because its second code +parked the amp in SERVICE, where it silently refuses everything — 17 false +negatives). Result: + +| byte | effect | +|------|--------| +| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` | +| 0x04 | → SERVICE (state 4) | +| 0x05 | → STANDBY (state 5) | +| 0x06 | → OPERATE (state 6) | +| 0x0A | → OFF (state 10) | +| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged | + +The action byte **is** the target PA state — four independent confirmations. +Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified +STANDBY, and does nothing.** State 8 is reachable from the front panel only. + +Going further would mean varying the other bytes of the frame. That is a large +space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth +it. + +## The workaround that was built and removed + +Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and +supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait +for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in, +Settings → Amplifier) and **removed** after the first hardware test: + +- the power change did not take effect, and +- **the PTT was not released when the tune finished.** + +A logger that can leave a transceiver keyed is not acceptable, and the whole +thing was scaffolding around a protocol we do not actually control. Removed at +the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la +bidouille."* + +If it is ever revisited, the unexplained part is why the tune-finished detection +did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should +have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier. + +## Loose ends worth knowing + +- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with + it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while + the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a + tuner flag for a while purely because of this — it toggles on its own several + times a minute. Do not read tail bytes without checking byte 6. +- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit, + not a command acknowledgement. +- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal + tune and during transmission.** `errText` renders those as "ERROR — see + display" and "Remove drive power", so an ACOM operator probably sees a false + alarm. This was never confirmed with the amplifier in OPERATE (it was in + STANDBY throughout, where drive genuinely is an error) — worth a look with a + clean log before touching the table. diff --git a/internal/acom/acom.go b/internal/acom/acom.go index 54ec0ac..ef88081 100644 --- a/internal/acom/acom.go +++ b/internal/acom/acom.go @@ -77,9 +77,14 @@ var models = map[string]model{ } // paStatusNames maps the PAstatus nibble to a display string. +// 8 is absent from the state list the ACOM-Controller project recovered; it is +// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the +// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing +// nothing until the operator keyed a carrier, ran the tune over ~8 s while the +// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY). var paStatusNames = map[int]string{ 1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE", - 5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF", + 5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF", } // acomBands maps the band nibble to a band label. diff --git a/internal/relaydev/relaydev.go b/internal/relaydev/relaydev.go index 105fe8d..8b57001 100644 --- a/internal/relaydev/relaydev.go +++ b/internal/relaydev/relaydev.go @@ -7,6 +7,10 @@ // - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss} // (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with // .. (relay0 is reserved). Optional HTTP basic auth. +// - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET +// CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&; +// status: GET /relay_cgi_load.cgi. Both answer &-separated fields. +// (IOT Relay Programming Manual V1.9.8.1, §3.) // // A Device presents the same surface to the app regardless of wire protocol. package relaydev @@ -173,3 +177,119 @@ func (k *kmtronic) Status(ctx context.Context) ([]bool, error) { } return out, nil } + +// ── Dingtian IOT relay (DTWONDER) ────────────────────────────────────── +// +// The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we +// use the HTTP GET CGI, which needs no connection state and matches how the +// other network boards here are driven. +// +// Both endpoints answer &-separated fields, first one 0 on success: +// +// /relay_cgi_load.cgi → &0&4&1&0&1&0& +// result, count, r1…rN +// /relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0& → &0&0&0&1&0& +// result, type, relay, on, time +// +// NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the +// Device interface is 1-based like every other board here. +type dingtian struct { + host string + session string // optional: the board can require "Cookie: session=" + pwd string // CGI password, 0–9999; "0" (or blank) when none is set + count int +} + +// NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID +// when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay +// password (blank or "0" when none). +func NewDingtian(host, session, pwd string, count int) Device { + if count <= 0 { + count = 2 + } + if strings.TrimSpace(pwd) == "" { + pwd = "0" + } + return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count} +} + +func (d *dingtian) Count() int { return d.count } +func (d *dingtian) Close() error { return nil } // stateless HTTP, nothing to release + +// getCGI issues the GET with the session cookie the board may require. +func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if d.session != "" { + req.Header.Set("Cookie", "session="+d.session) + } + resp, err := httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return body, nil +} + +// dtFields splits an &-separated CGI reply into its fields, dropping the empty +// ones the leading and trailing "&" produce. +func dtFields(body []byte) []string { + var out []string + for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") { + if f = strings.TrimSpace(f); f != "" { + out = append(out, f) + } + } + return out +} + +func (d *dingtian) Set(ctx context.Context, relay int, on bool) error { + if relay < 1 || relay > d.count { + return fmt.Errorf("relay %d out of range 1..%d", relay, d.count) + } + state := 0 + if on { + state = 1 + } + // type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle), + // and time is then unused. + body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&", + d.host, relay-1, state, d.pwd)) + if err != nil { + return err + } + // A wrong password or session answers 200 with a non-zero result (e.g. + // "&302&/&"), so the HTTP status alone does not tell us it worked. + if f := dtFields(body); len(f) == 0 || f[0] != "0" { + return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID", + strings.TrimSpace(string(body))) + } + return nil +} + +func (d *dingtian) Status(ctx context.Context) ([]bool, error) { + body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi_load.cgi", d.host)) + if err != nil { + return nil, err + } + f := dtFields(body) + if len(f) < 2 || f[0] != "0" { + return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body))) + } + // The board reports its own relay count; trust it over the configured one so + // a mis-set channel count doesn't silently hide relays. + if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 { + d.count = n + } + out := make([]bool, d.count) + for i := 0; i < d.count && i+2 < len(f); i++ { + out[i] = f[i+2] == "1" + } + return out, nil +} diff --git a/internal/relaydev/relaydev_test.go b/internal/relaydev/relaydev_test.go index 70fc5be..585e515 100644 --- a/internal/relaydev/relaydev_test.go +++ b/internal/relaydev/relaydev_test.go @@ -99,3 +99,76 @@ func TestKMTronicSetURL(t *testing.T) { } _ = d.Set(context.Background(), 1, false) // → /FF0100 } + +// Dingtian — the wire examples come straight from the IOT Relay Programming +// Manual V1.9.8.1 §3.1/§3.2. The two traps pinned here: the URL relay index is +// ZERO-based while the Device interface is 1-based, and a refusal (bad password +// or session) answers HTTP 200 with a non-zero first field. + +func TestDingtianStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/relay_cgi_load.cgi" { + t.Errorf("unexpected status path %q", r.URL.Path) + } + // Manual's own example: ok, 4 relays, 1 on, 2 off, 3 on, 4 off. + _, _ = w.Write([]byte("&0&4&1&0&1&0&")) + })) + defer srv.Close() + + d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2) + st, err := d.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + // The board said 4 relays even though 2 were configured — its count wins. + want := []bool{true, false, true, false} + if len(st) != len(want) { + t.Fatalf("got %d relays, want %d", len(st), len(want)) + } + for i := range want { + if st[i] != want[i] { + t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i]) + } + } + if d.Count() != 4 { + t.Errorf("Count() = %d, want 4 (taken from the board)", d.Count()) + } +} + +func TestDingtianSetUsesZeroBasedIndexAndSession(t *testing.T) { + var gotQuery, gotCookie string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + gotCookie = r.Header.Get("Cookie") + _, _ = w.Write([]byte("&0&0&2&1&0&")) + })) + defer srv.Close() + + d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "12345678", "4660", 4) + if err := d.Set(context.Background(), 3, true); err != nil { + t.Fatal(err) + } + if !strings.Contains(gotQuery, "relay=2") { + t.Errorf("relay 3 must go out as relay=2 (zero-based); query was %q", gotQuery) + } + if !strings.Contains(gotQuery, "on=1") || !strings.Contains(gotQuery, "type=0") || + !strings.Contains(gotQuery, "pwd=4660") { + t.Errorf("unexpected query %q", gotQuery) + } + if gotCookie != "session=12345678" { + t.Errorf("session cookie = %q, want session=12345678", gotCookie) + } +} + +func TestDingtianSetRefusalIsAnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Manual §3.4.4: a bad session still answers 200 OK. + _, _ = w.Write([]byte("&302&/&")) + })) + defer srv.Close() + + d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2) + if err := d.Set(context.Background(), 1, true); err == nil { + t.Fatal("a refused command answered HTTP 200 and was reported as success") + } +} diff --git a/telemetry.go b/telemetry.go index 75d8243..155ba6b 100644 --- a/telemetry.go +++ b/telemetry.go @@ -21,7 +21,7 @@ import ( const ( // appVersion is stamped on every heartbeat (and could feed the About box). - appVersion = "0.22.9" + appVersion = "0.23.0" // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // to https://us.i.posthog.com for a US project. diff --git a/wiki/Amplifiers-and-Switches.md b/wiki/Amplifiers-and-Switches.md index 4b5d3c1..36dce3c 100644 --- a/wiki/Amplifiers-and-Switches.md +++ b/wiki/Amplifiers-and-Switches.md @@ -57,10 +57,19 @@ Configure the host / password in Settings → Antenna Genius. ## Relay boards (Station Control) -USB relay boards for antenna/accessory switching, with named buttons in Station -Control: **Denkovi** (4/8 relays, FT245) and generic **CH340 / LCUS** -USB-serial boards. A Test-connection button and detection feedback live in the -setup panel. +Relay boards for antenna/accessory switching, with named buttons in Station +Control: + +- **WebSwitch 1216H** (5 relays, LAN) and **KMTronic** 8-relay WEB (LAN, with + optional HTTP authentication). +- **Dingtian IOT relay** (2 to 32 relays, LAN or WiFi), driven over its HTTP + CGI. Give its IP address and pick the relay count; the **relay password** and + **session ID** fields are only needed if you enabled them on the board's own + web page — a factory board needs neither. +- **Denkovi** USB (4/8 relays, FT245) and generic **CH340 / LCUS** USB-serial + boards. + +A Test-connection button and detection feedback live in the setup panel. ## Station Control